54 changed files with 4266 additions and 1087 deletions

No files matched your search

+304
View File
@@ -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.
+1 -1
View File
@@ -13,7 +13,7 @@ nuxtApp.hook('page:finish', () => {
// 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 STORE_SCHEMA_VERSION = 4;
const VERSION_KEY = 'app-store-version';
// Daftar semua localStorage key yang dikelola oleh Pinia stores
+28
View File
@@ -0,0 +1,28 @@
// variables for Grand Paviliun (Eksekutif)
.theme-grand-paviliun {
--gp-primary: #3F51B5; // Indigo header
--gp-primary-dark: #303F9F;
--gp-bg: #F5F7FA; // Light grayish blue background
--gp-surface: #FFFFFF;
--gp-text-primary: #1F2937;
--gp-text-secondary: #6B7280;
--gp-border: #E5E7EB;
--gp-success: #10B981; // Selesai
--gp-danger: #EF4444; // Pending
--gp-warning: #F59E0B;
--gp-info: #3B82F6;
// Custom colors for specific tags
--gp-room-number: #F97316; // Orange text for RUANG 02
background-color: var(--gp-bg);
min-height: 100vh;
font-family: 'Inter', 'Roboto', sans-serif;
}
// Utility classes
.gp-text-primary { color: var(--gp-text-primary); }
.gp-text-secondary { color: var(--gp-text-secondary); }
.gp-font-bold { font-weight: 700; }
.gp-font-semibold { font-weight: 600; }
@@ -0,0 +1,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>
+63
View File
@@ -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>
+95
View File
@@ -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>
+95 -31
View File
@@ -4,29 +4,76 @@
<thead>
<tr>
<th class="text-left font-weight-bold">Halaman</th>
<th class="text-center font-weight-bold" style="width: 120px;">Akses</th>
<th class="text-center font-weight-bold" style="width: 80px;">Access</th>
<th class="text-center font-weight-bold" style="width: 80px;">View</th>
<th class="text-center font-weight-bold" style="width: 80px;">Add</th>
<th class="text-center font-weight-bold" style="width: 80px;">Edit</th>
<th class="text-center font-weight-bold" style="width: 80px;">Delete</th>
</tr>
</thead>
<tbody>
<template v-for="item in flatNavItems" :key="item.path || item.name">
<tr :class="{ 'bg-grey-lighten-4': !item.path }">
<template v-for="item in flatNavItems" :key="item.menuKey || item.name">
<tr :class="{ 'bg-grey-lighten-4': !item.menuKey }">
<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>
<span :class="{ 'font-weight-bold': !item.menuKey }">{{ 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)"
v-if="item.menuKey"
:model-value="getPermission(item.menuKey, 'canAccess')"
@update:model-value="toggleAccess(item, 'canAccess', !!$event)"
hide-details
density="compact"
color="primary"
class="d-inline-flex"
></v-checkbox>
</td>
<td class="text-center">
<v-checkbox
v-if="item.menuKey"
:model-value="getPermission(item.menuKey, 'canView')"
@update:model-value="toggleAccess(item, 'canView', !!$event)"
hide-details
density="compact"
color="primary"
class="d-inline-flex"
></v-checkbox>
</td>
<td class="text-center">
<v-checkbox
v-if="item.menuKey"
:model-value="getPermission(item.menuKey, 'canAdd')"
@update:model-value="toggleAccess(item, 'canAdd', !!$event)"
hide-details
density="compact"
color="primary"
class="d-inline-flex"
></v-checkbox>
</td>
<td class="text-center">
<v-checkbox
v-if="item.menuKey"
:model-value="getPermission(item.menuKey, 'canEdit')"
@update:model-value="toggleAccess(item, 'canEdit', !!$event)"
hide-details
density="compact"
color="primary"
class="d-inline-flex"
></v-checkbox>
</td>
<td class="text-center">
<v-checkbox
v-if="item.menuKey"
:model-value="getPermission(item.menuKey, 'canDelete')"
@update:model-value="toggleAccess(item, 'canDelete', !!$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>
@@ -37,24 +84,24 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useNavItemsStore } from '~/stores/navItems1';
import { defaultNavItems } from '~/stores/navItems1';
import type { HakAksesMenu } from '~/server/utils/schemas/permissionSchema';
const props = defineProps({
pages: {
type: Array as () => any[], // Be flexible with legacy data
hakAksesMenu: {
type: Array as () => HakAksesMenu[],
default: () => []
}
});
const emit = defineEmits(['update:pages']);
const navItemsStore = useNavItemsStore();
const emit = defineEmits(['update:hakAksesMenu']);
interface FlatNavItem {
name: string;
path: string;
icon: string;
level: number;
menuKey?: string;
}
const flatNavItems = computed(() => {
@@ -66,7 +113,8 @@ const flatNavItems = computed(() => {
name: item.name,
path: item.path,
icon: item.icon,
level
level,
menuKey: item.menuKey
});
if (item.children && item.children.length > 0) {
walk(item.children, level + 1);
@@ -74,32 +122,48 @@ const flatNavItems = computed(() => {
});
};
walk(navItemsStore.getNavItems);
walk(defaultNavItems);
return result;
});
const isAllowed = (path: string) => {
if (!props.pages || !Array.isArray(props.pages)) return false;
return props.pages.some(p => {
if (typeof p === 'string') return p === path;
if (p && typeof p === 'object' && p.path) return p.path === path;
return false;
});
const getPermission = (menuKey: string, action: keyof HakAksesMenu): boolean => {
if (!props.hakAksesMenu || !Array.isArray(props.hakAksesMenu)) return false;
const menuConfig = props.hakAksesMenu.find((p: HakAksesMenu) => p.menuKey === menuKey);
if (!menuConfig) return false;
return !!menuConfig[action];
};
const toggleAccess = (item: FlatNavItem, allowed: boolean) => {
let newPages = [...props.pages];
const toggleAccess = (item: FlatNavItem, action: keyof HakAksesMenu, value: boolean) => {
if (!item.menuKey) return;
if (allowed) {
if (!newPages.includes(item.path)) {
newPages.push(item.path);
// Clone current array
let newMenus = JSON.parse(JSON.stringify(props.hakAksesMenu || []));
let menuIndex = newMenus.findIndex((m: HakAksesMenu) => m.menuKey === item.menuKey);
if (menuIndex === -1) {
if (value) {
newMenus.push({
menuKey: item.menuKey,
name: item.name,
canAccess: action === 'canAccess' ? true : false,
canView: action === 'canView' ? true : false,
canAdd: action === 'canAdd' ? true : false,
canEdit: action === 'canEdit' ? true : false,
canDelete: action === 'canDelete' ? true : false
});
}
} else {
newPages = newPages.filter(p => p !== item.path);
newMenus[menuIndex][action] = value;
// Auto-grant canAccess and canView if Add/Edit/Delete is checked
if (value && (action === 'canAdd' || action === 'canEdit' || action === 'canDelete')) {
newMenus[menuIndex].canAccess = true;
newMenus[menuIndex].canView = true;
}
// If all are false, maybe remove it, but keeping it is fine too
}
emit('update:pages', newPages);
emit('update:hakAksesMenu', newMenus);
};
</script>
@@ -3,17 +3,31 @@
<v-card-text class="pa-4">
<div class="section-header mb-3">
<div class="section-label">SEDANG DIPROSES</div>
<v-btn
v-if="patient"
class="call-button text-white"
color="primary-500"
variant="flat"
size="small"
@click="$emit('call')"
>
<v-icon start size="18">mdi-bullhorn</v-icon>
Panggil
</v-btn>
<div class="d-flex align-center">
<v-btn
v-if="patient"
variant="outlined"
height="36"
rounded="lg"
class="data-pasien-btn bg-white mr-3 text-none font-weight-bold"
@click="showPatientDataDialog = true"
>
<v-icon start size="18">mdi-account-cog-outline</v-icon>
Data Pasien
</v-btn>
<v-btn
v-if="patient"
class="call-button text-white text-none font-weight-bold"
color="primary-500"
variant="flat"
height="36"
rounded="lg"
@click="$emit('call')"
>
<v-icon start size="18">mdi-microphone</v-icon>
Panggil
</v-btn>
</div>
</div>
<div v-if="patient" class="patient-details" :class="`patient-details-${theme}`">
@@ -138,11 +152,30 @@
</div>
</div>
</v-card-text>
<!-- Patient Data Dialog -->
<PatientDataDialog
v-if="patient"
v-model="showPatientDataDialog"
:patient="patient"
@linked="handlePatientLinked"
/>
</v-card>
</template>
<script setup>
import { computed } from 'vue';
import { computed, ref } from 'vue';
import PatientDataDialog from './PatientDataDialog.vue';
const showPatientDataDialog = ref(false);
/**
* Menangani event ketika antrean berhasil dihubungkan dengan data pasien di SIMRS
* Memunculkan notifikasi (snackbar) bahwa proses berhasil.
*/
const handlePatientLinked = () => {
emit('linked', 'Data pasien berhasil dihubungkan');
};
const props = defineProps({
patient: {
@@ -178,6 +211,11 @@ defineEmits(['action', 'change-klinik', 'process-next', 'call', 'open-klinik-rua
</script>
<style scoped lang="scss">
.data-pasien-btn {
color: var(--color-primary-500) !important;
border-color: var(--color-primary-500) !important;
}
.current-patient-card {
border-radius: 12px;
border: 1px solid var(--color-neutral-500);
@@ -0,0 +1,318 @@
<template>
<v-dialog
v-model="isOpen"
max-width="600px"
persistent
scrollable
>
<v-card class="patient-data-dialog" rounded="xl">
<!-- Header -->
<v-card-title class="dialog-header d-flex justify-space-between align-center px-4 py-3 text-white">
<span class="text-subtitle-1 font-weight-medium">Data Pasien</span>
<v-btn icon="mdi-close" variant="text" size="small" color="white" @click="closeDialog" />
</v-card-title>
<v-card-text class="pa-6">
<!-- Antrean Number Info -->
<div class="text-center mb-6">
<div class="text-subtitle-1 text-grey-darken-1 mb-1">Nomor Antrian</div>
<div class="text-h3 font-weight-bold mb-1">{{ antrianNumber }}</div>
<div class="text-caption text-grey-darken-1 mb-2">
Barcode: <span class="font-weight-bold">{{ patient?.barcode || '-' }}</span>
</div>
<div v-if="alreadyLinked || isLinked" class="text-success mt-3 text-body-1 font-weight-medium d-flex align-center justify-center">
<v-icon start size="20" color="success">mdi-check-circle</v-icon>
{{ isLinked ? 'Berhasil dihubungkan ke RM baru' : `Sudah terhubung dengan RM: ${patient?.noRM}` }}
</div>
<div v-else-if="searchError" class="text-error mt-3 text-body-1 font-weight-medium d-flex align-center justify-center">
<v-icon start size="20">mdi-close-circle</v-icon>
{{ searchError }}
</div>
<div v-else-if="!patientData" class="text-error mt-3 text-body-1 font-weight-medium d-flex align-center justify-center">
<v-icon start size="20">mdi-alert-circle</v-icon>
Nomor Antrean belum terhubung ke pasien
</div>
</div>
<!-- Search Form -->
<div class="form-section pa-5 border rounded-xl mb-6">
<div class="section-title text-caption font-weight-bold mb-3 text-uppercase text-navy-custom" style="letter-spacing: 0.5px;">
Hubungkan Antrean
</div>
<v-text-field
v-model="searchQuery"
placeholder="Cari Nomor BPJS atau Nomor RM atau NIK.."
variant="outlined"
density="comfortable"
hide-details
prepend-inner-icon="mdi-magnify"
bg-color="white"
class="search-input"
@keyup.enter="searchPatient"
>
<template #append-inner>
<div class="d-flex align-center h-100 pr-1">
<v-btn
variant="flat"
class="search-btn bg-navy-custom text-white text-none font-weight-bold"
height="36"
min-width="80"
rounded="lg"
:loading="isLoading"
@click="searchPatient"
>
Cari
</v-btn>
</div>
</template>
</v-text-field>
</div>
<!-- Patient Info Display -->
<v-expand-transition>
<div v-if="patientData" class="info-section">
<div class="section-title text-caption font-weight-bold text-primary mb-3 text-uppercase">
Informasi Data Diri
</div>
<div class="info-grid">
<!-- Nama Lengkap -->
<div class="info-field">
<div class="text-caption text-grey-darken-1 mb-1">Nama Lengkap</div>
<div class="info-box">{{ patientData.nama }}</div>
</div>
<!-- TTL -->
<div class="info-field">
<div class="text-caption text-grey-darken-1 mb-1">Tempat / Tanggal Lahir</div>
<div class="info-box">{{ patientData.ttl }}</div>
</div>
<!-- Jenis Kelamin -->
<div class="info-field">
<div class="text-caption text-grey-darken-1 mb-1">Jenis Kelamin</div>
<div class="info-box">{{ patientData.jenisKelamin }}</div>
</div>
<!-- No Telepon -->
<div class="info-field">
<div class="text-caption text-grey-darken-1 mb-1">Nomor Telepon</div>
<div class="info-box">{{ patientData.noTelepon }}</div>
</div>
<!-- Alamat -->
<div class="info-field full-width">
<div class="text-caption text-grey-darken-1 mb-1">Alamat</div>
<div class="info-box">{{ patientData.alamat }}</div>
</div>
</div>
</div>
</v-expand-transition>
</v-card-text>
<!-- Footer Actions -->
<v-card-actions v-if="patientData" class="pa-4 bg-grey-lighten-4 justify-end">
<v-btn
variant="flat"
class="bg-navy-custom text-white px-6 text-none"
prepend-icon="mdi-sync"
:loading="isLinking"
@click="linkPatient"
>
Hubungkan
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue';
const props = defineProps<{
modelValue: boolean;
patient: any;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void;
(e: 'linked'): void;
}>();
// State
const isOpen = ref(props.modelValue);
const searchQuery = ref('');
const isLoading = ref(false);
const isLinking = ref(false);
const isLinked = ref(false);
const searchError = ref('');
const antrianNumber = computed(() => {
return props.patient?.noAntrian ? props.patient.noAntrian.split(' |')[0] : '';
});
const alreadyLinked = computed(() => {
return !!props.patient?.noRM;
});
// Tipe data mock
interface MockPatient {
nama: string;
ttl: string;
jenisKelamin: string;
noTelepon: string;
alamat: string;
}
const patientData = ref<MockPatient | null>(null);
// Watchers
watch(() => props.modelValue, (newVal) => {
isOpen.value = newVal;
if (newVal) {
// Reset state saat modal dibuka
searchQuery.value = '';
patientData.value = null;
isLinked.value = false;
searchError.value = '';
}
});
watch(isOpen, (newVal) => {
emit('update:modelValue', newVal);
});
// Methods
const resetState = () => {
searchQuery.value = '';
patientData.value = null;
isLinked.value = false;
isLoading.value = false;
isLinking.value = false;
};
const closeDialog = () => {
isOpen.value = false;
};
/**
* Simulasi pencarian data pasien ke SIMRS.
* NANTI BISA DIGANTI DENGAN $fetch KE SERVICE API ASLI.
*/
const searchPatient = async () => {
if (!searchQuery.value.trim()) return;
isLoading.value = true;
patientData.value = null;
try {
// Reset state error sebelum pencarian
searchError.value = '';
// TODO: Ganti URL '/api/simrs/patient/' di bawah ini dengan URL API Service SIMRS yang sebenarnya
const response = await $fetch(`/api/simrs/patient/${searchQuery.value}`);
// @ts-ignore
patientData.value = response.data;
} catch (error: any) {
console.error('Gagal mencari data pasien:', error);
searchError.value = error.data?.statusMessage || `Pasien dengan nomor ${searchQuery.value} tidak ditemukan.`;
} finally {
isLoading.value = false;
}
};
/**
* Simulasi menghubungkan nomor antrean dengan data pasien di SIMRS.
*/
const linkPatient = async () => {
isLinking.value = true;
try {
// TODO: Ganti dengan API call asli untuk menghubungkan antrean
// await $fetch('/api/antrean/link', { method: 'POST', body: { ... } });
// Simulasi delay
await new Promise(resolve => setTimeout(resolve, 600));
isLinked.value = true;
emit('linked');
closeDialog();
} catch (error) {
console.error('Gagal menghubungkan data pasien:', error);
} finally {
isLinking.value = false;
}
};
</script>
<style scoped lang="scss">
.bg-navy-custom {
background-color: #003482 !important;
color: #ffffff !important;
}
.text-navy-custom {
color: #003482 !important;
}
.patient-data-dialog {
border-radius: 12px;
overflow: hidden;
.dialog-header {
background-color: #003482 !important;
}
.text-error {
color: #E53935;
}
.search-input {
:deep(.v-field) {
border-radius: 8px;
}
:deep(.v-field__append-inner) {
padding-top: 0;
padding-bottom: 0;
align-items: center;
}
}
.search-btn {
border-radius: 6px;
text-transform: none;
letter-spacing: normal;
font-weight: 500;
}
.info-section {
background-color: #F8F9FA;
border-radius: 12px;
padding: 20px;
border: 1px solid #E0E0E0;
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
.info-field {
&.full-width {
grid-column: 1 / -1;
}
.info-box {
background-color: #ECEFF1;
border-radius: 6px;
padding: 10px 14px;
font-size: 14px;
color: #333;
min-height: 40px;
}
}
}
}
}
</style>
+211
View File
@@ -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
};
};
+8
View File
@@ -94,6 +94,14 @@ export const useQueue = (adminType = "loket", specificId = null) => {
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;
+41 -172
View File
@@ -1,193 +1,62 @@
// composables/useQueueAPI.ts
// Composable untuk API calls terkait antrian pasien
export interface Patient {
no: number;
jamPanggil: string;
barcode: string;
noAntrian: string;
shift: string;
klinik: string;
fastTrack: string;
pembayaran: string;
status: 'anjungan' | 'pending' | 'di-loket' | 'di-klinik' | 'selesai' | 'terlambat';
processStage: 'loket' | 'klinik' | 'penunjang';
createdAt: string;
registrationType?: 'online' | 'onsite';
visitType?: string;
visitDate?: string;
}
export interface QueueAPIResponse<T = any> {
success: boolean;
data?: T;
message?: string;
error?: string;
}
import { useRuntimeConfig } from '#app';
import type { QueuePatient } from '@/types/queue';
export const useQueueAPI = () => {
const config = useRuntimeConfig();
const baseURL = config.public.apiBaseUrl || '/api/queue';
const verificationApiBase = config.public.verificationApiBaseUrl || 'http://10.10.123.140:8089/api/v1';
const externalApiBase = config.public.externalApiBaseUrl;
/**
* Fetch all patients from database
*/
const fetchAllPatients = async (): Promise<Patient[]> => {
const fetchRawLoketPatients = async (loketId: string | number) => {
try {
const response = await $fetch<QueueAPIResponse<Patient[]>>(`${baseURL}/patients`, {
method: 'GET',
});
if (response.success && response.data) {
return response.data;
const rawData: any = await $fetch(`${verificationApiBase}/loket/${loketId}`);
if (rawData.metadata && rawData.metadata.code !== 200) {
throw new Error(rawData.message || 'API returned error status');
}
throw new Error(response.message || 'Failed to fetch patients');
return rawData.data || [];
} catch (error: any) {
console.error('❌ Error fetching patients:', error);
throw error;
throw new Error(`HTTP error! status: ${error.response?.status || error.message}`);
}
};
/**
* Fetch single patient by ID or barcode
*/
const fetchPatient = async (idOrBarcode: string): Promise<Patient | null> => {
const fetchRawClinicPatients = async (clinicId: string | number) => {
const url = `${externalApiBase}/visit?klinik_id=${clinicId}&limit=500`;
try {
const response = await $fetch<QueueAPIResponse<Patient>>(`${baseURL}/patients/${idOrBarcode}`, {
method: 'GET',
});
const rawResponse: any = await $fetch(url);
return rawResponse?.data || [];
} catch (error: any) {
throw new Error(`HTTP error! status: ${error.response?.status || error.message}`);
}
};
if (response.success && response.data) {
return response.data;
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
}
return null;
} catch (error: any) {
console.error('❌ Error fetching patient:', error);
return null;
}
});
};
/**
* Create new patient (register from Anjungan)
*/
const createPatient = async (patientData: Partial<Patient>): Promise<Patient> => {
try {
const response = await $fetch<QueueAPIResponse<Patient>>(`${baseURL}/patients`, {
method: 'POST',
body: patientData,
});
if (response.success && response.data) {
return response.data;
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
}
throw new Error(response.message || 'Failed to create patient');
} catch (error: any) {
console.error('❌ Error creating patient:', error);
throw error;
}
};
/**
* Update patient status (check-in, process, etc)
*/
const updatePatient = async (
idOrBarcode: string,
updates: Partial<Patient>
): Promise<Patient> => {
try {
const response = await $fetch<QueueAPIResponse<Patient>>(
`${baseURL}/patients/${idOrBarcode}`,
{
method: 'PATCH',
body: updates,
}
);
if (response.success && response.data) {
return response.data;
}
throw new Error(response.message || 'Failed to update patient');
} catch (error: any) {
console.error('❌ Error updating patient:', error);
throw error;
}
};
/**
* Check-in patient (update status to di-loket)
*/
const checkInPatient = async (idOrBarcode: string): Promise<Patient> => {
return updatePatient(idOrBarcode, { status: 'di-loket' });
};
/**
* Process patient at loket (update status and processStage)
*/
const processPatientAtLoket = async (
idOrBarcode: string,
updates: { status?: string; processStage?: string }
): Promise<Patient> => {
return updatePatient(idOrBarcode, updates as Partial<Patient>);
};
/**
* Sync local state with database
*/
const syncWithDatabase = async (localPatients: Patient[]): Promise<Patient[]> => {
try {
// Fetch latest from database
const dbPatients = await fetchAllPatients();
// Merge strategy: prefer database data, but keep local if newer
const merged = new Map<string, Patient>();
// Add database patients
dbPatients.forEach(patient => {
merged.set(patient.barcode, patient);
});
// Add local patients that don't exist in DB or are newer
localPatients.forEach(localPatient => {
const existing = merged.get(localPatient.barcode);
if (!existing || new Date(localPatient.createdAt) > new Date(existing.createdAt)) {
merged.set(localPatient.barcode, localPatient);
}
});
return Array.from(merged.values());
} catch (error: any) {
console.error('❌ Error syncing with database:', error);
// Return local patients as fallback
return localPatients;
}
};
/**
* Batch sync: save multiple patients to database
*/
const batchSyncPatients = async (patients: Patient[]): Promise<boolean> => {
try {
const response = await $fetch<QueueAPIResponse>(`${baseURL}/patients/batch`, {
method: 'POST',
body: { patients },
});
return response.success || false;
} catch (error: any) {
console.error('❌ Error batch syncing patients:', error);
return false;
}
});
};
return {
fetchAllPatients,
fetchPatient,
createPatient,
updatePatient,
checkInPatient,
processPatientAtLoket,
syncWithDatabase,
batchSyncPatients,
fetchRawLoketPatients,
fetchRawClinicPatients,
updateTicketStatus,
completeTicketStatus
};
};
+242
View File
@@ -0,0 +1,242 @@
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;
/**
* Memulai interval auto-sync (fallback) untuk memastikan data tetap segar
* meskipun koneksi WebSocket terputus atau gagal menerima event.
* Berjalan setiap 30 detik berdasarkan active interest.
*/
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
};
};
+5 -5
View File
@@ -64,7 +64,7 @@ export const useWebSocket = (config: WebSocketConfig) => {
// 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...')
// console.log('🔌 Closing existing WebSocket before new connection...')
clearHandlers(ws.value)
ws.value.close()
}
@@ -75,7 +75,7 @@ export const useWebSocket = (config: WebSocketConfig) => {
ws.value = new WebSocket(connectionUrl)
ws.value.onopen = () => {
console.log('✅ WebSocket connected:', currentClientId.value)
// console.log('✅ WebSocket connected:', currentClientId.value)
isConnected.value = true
reconnectAttempts.value = 0
config.onOpen?.()
@@ -91,7 +91,7 @@ export const useWebSocket = (config: WebSocketConfig) => {
}
ws.value.onclose = () => {
console.log('❌ WebSocket closed:', currentClientId.value)
// console.log('❌ WebSocket closed:', currentClientId.value)
isConnected.value = false
config.onClose?.()
@@ -100,7 +100,7 @@ export const useWebSocket = (config: WebSocketConfig) => {
if (reconnectAttempts.value < (config.maxReconnectAttempts || 5)) {
reconnectAttempts.value++
const interval = config.reconnectInterval || 3000
console.log(`⏳ Reconnecting in ${interval}ms... Attempt ${reconnectAttempts.value}`)
// console.log(`⏳ Reconnecting in ${interval}ms... Attempt ${reconnectAttempts.value}`)
reconnectTimer.value = setTimeout(() => {
connect()
}, interval)
@@ -125,7 +125,7 @@ export const useWebSocket = (config: WebSocketConfig) => {
reconnectTimer.value = null
}
if (ws.value) {
console.log('🔌 Manual disconnect: Cleaning handlers and closing...')
// console.log('🔌 Manual disconnect: Cleaning handlers and closing...')
clearHandlers(ws.value)
ws.value.close()
ws.value = null
+167 -10
View File
@@ -63,6 +63,146 @@ Format output:
<!-- Tambahkan entry baru di bawah baris ini, urutan terbaru di atas -->
## 2026-07-10 — Fitur Hubungkan Data Pasien ke SIMRS
**Sprint/Phase:** Phase 7 — UI/UX Feature
**Durasi:** ~1.5 jam
**Status:** ✅ Done
### Yang Dikerjakan
- Membuat UI Pop-up Modal baru `PatientDataDialog.vue` untuk mengaitkan nomor antrean yang berjalan dengan data dari SIMRS.
- Menambahkan tombol **Data Pasien** (ikon *user-cog*) di komponen `CurrentPatientCard.vue` berdampingan dengan tombol **Panggil**.
- Mengimplementasikan state management lokal untuk pop-up, termasuk simulasi proses API (*loading bar*, *mock data*, dan *success notification*).
- Menyesuaikan warna komponen secara presisi menggunakan hex code `#003482` dengan *SCSS override* untuk menembus batasan tema *default*.
- Meningkatkan *border-radius* modal ke `xl` untuk sudut yang lebih melengkung (*rounded*) sesuai desain UI/UX.
- Mengubah logika *mock data* pada *method* `searchPatient` menjadi `$fetch` (*API call*) asinkron yang menembak internal API Nuxt (`/api/simrs/patient/[id]`).
- Mengimplementasikan endpoint API backend (menggunakan *Nitro/H3*) di folder `server/api/simrs/patient/[id].get.ts` sebagai *Dummy JSON Provider* untuk di-*replace* dengan layanan asli nanti.
- Melengkapi fungsi-fungsi yang baru dibuat dengan komentar JSDoc.
### Keputusan Teknis
> **Dummy API via Nitro**: Dengan memindahkan *mock data* dari dalam komponen Vue ke `server/api/`, arsitektur kode simulasi jadi lebih mirip dengan *production*. Saat pindah ke *Service API* sungguhan nanti, front-end developer hanya perlu mengganti URL string `$fetch` tanpa harus membongkar ulang struktur logika _async_ `try-catch`-nya.
> **Pemisahan Komponen**: Daripada menggabungkan form SIMRS ke dalam `CurrentPatientCard`, diputuskan untuk membuat file baru `PatientDataDialog.vue`. Ini menjaga agar file loket tidak terlalu membengkak (modular) dan mudah dikelola atau diganti endpoint API-nya nanti.
### Masalah & Solusi
| Masalah | Solusi | Referensi |
|---------|--------|-----------|
| Warna tombol garis luar (*outlined*) selalu kembali ke warna hitam (*fallback*) dan tidak menyerap properti `color="primary"`. | Menambahkan *style SCSS* kustom secara paksa (`!important`) untuk mengisi `border-color` dan `color` sesuai `hex code` `#003482` dari desain. | `CurrentPatientCard.vue`, `PatientDataDialog.vue` |
### Besok
- [ ] Mengganti endpoint `/api/simrs/patient/` internal ini dengan URL *Service API* SIMRS yang sebenarnya saat endpoint backend sudah di-*deploy*.
---
## 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
@@ -610,6 +750,22 @@ Format output:
---
## 2026-07-13 — Konsolidasi Loket Eksekutif
**Sprint/Phase:** Phase 7
**Status:** ✅ Done
### Yang Dikerjakan
- Mengubah `localLoketData` pada `loketStore.js` dari 14 loket menjadi 1 loket tunggal (`LOKET EKS`) untuk melayani semua klinik eksekutif (ID 1000 hingga 1022).
- Menaikkan `STORE_SCHEMA_VERSION` pada `app.vue` (menjadi v4) untuk memicu pembersihan *cache localStorage* pada pengguna agar konfigurasi loket tunggal segera diterapkan.
### Keputusan Teknis
- Mengubah default pembuatan array untuk `localLoketData` menjadi 1 objek.
- Properti `pelayanan` (hak akses layanan) diberikan semua ID layanan dari 1000 - 1022.
- Tidak ada penambahan/perubahan logika UI karena antrean dan grid UI otomatis menyesuaikan jumlah `loketStore`.
---
<!-- Tambahkan entry baru di atas baris ini, urutan terbaru di atas -->
---
@@ -618,13 +774,14 @@ Format output:
| Metrik | Value |
|--------|-------|
| Total hari dev | ~50+ hari (Jan 2026 — Mei 2026) |
| Fase project | 5 fase (Setup → Fondasi → Hak Akses → Eksekutif → Stabilisasi) |
| Fitur selesai | 8 modul utama (Anjungan, Check-in, Loket, Klinik, Penunjang, Dashboard, Setting, Hak Akses) |
| Total commits | 80+ commits |
| Bug ditemukan | 15+ (WebSocket, loket interference, memory, display sync) |
| Bug diselesaikan | 15+ |
| Halaman dibuat | 28 halaman |
| Composables | 16 composable |
| Pinia Stores | 13 stores |
| Komponen | 20+ komponen |
| 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 |
+29 -9
View File
@@ -1,10 +1,10 @@
# 🗺️ DEVPLAN — Development Plan
**Project:** Web Antrean — Sistem Manajemen Antrean Rawat Jalan RSSA
**Version:** 1.0.0
**Version:** 1.1.0
**Author:** Akbar
**Stack:** JavaScript · Vue 3 · TypeScript · Nuxt 3 · Vuetify 3 · Pinia
**Last Updated:** 2026-05-25
**Last Updated:** 2026-07-10
---
@@ -45,8 +45,8 @@ Format dalam tabel Markdown yang terstruktur.
## 1. Project Timeline
```
[Start]──Phase 0──Phase 1──Phase 2──Phase 3──Phase 4──Phase 5──Phase 6──[Launch]
Setup Fondasi Inti API Hak Akses Eksekutif Stabilisasi Verifikasi
[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 |
@@ -57,7 +57,8 @@ Format dalam tabel Markdown yang terstruktur.
| 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 | 🔄 In Progress |
| Phase 6 | Verifikasi Akun & Kiosk | 2 Minggu | Juni 2026 | ✅ Done |
| Phase 7 | Infrastruktur & Dokumentasi | Ongoing | Juli 2026 | 🔄 In Progress |
---
@@ -145,6 +146,19 @@ Format dalam tabel Markdown yang terstruktur.
| 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 |
---
@@ -157,12 +171,12 @@ Nuxt 3 + TypeScript
├── Vuetify 3 (UI Framework)
│ └── Material Design Icons
├── Nuxt Nitro (Server Engine)
│ └── Server Routes (CORS Proxy API)
│ └── 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.123.140:8089)
│ └── Antrian API (10.10.150.131:8089) ← UPDATED
└── Real-time Layer
└── Native Browser WebSocket
```
@@ -194,10 +208,12 @@ npm run _command_dev
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.123.140:8089/api/v1"
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
```
---
@@ -232,7 +248,9 @@ docs: update DEVPLAN
| 🔐 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 |
| 🚀 Production Go-Live | Deployment docker di server production | Q2/Q3 2026 | 🔄 Pending |
| 🔄 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 |
---
@@ -240,4 +258,6 @@ docs: update DEVPLAN
| Versi | Tanggal | Author | Perubahan |
|-------|---------|--------|-----------|
| 1.1.1 | 2026-07-13 | Agent | Refactor `loketStore.js` untuk menggabungkan loket eksekutif menjadi 1 loket tunggal (melayani semua) |
| 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 |
+84
View File
@@ -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.
+8 -7
View File
@@ -1,11 +1,11 @@
# 📋 PRD — Product Requirements Document
**Project:** Web Antrean — Sistem Manajemen Antrean Rawat Jalan RSSA
**Version:** 1.0.0
**Status:** `In Review`
**Version:** 1.1.0
**Status:** `In Progress`
**Author:** Akbar
**Stack:** JavaScript · Vue 3 · TypeScript · Nuxt 3 · Vuetify 3 · Pinia
**Last Updated:** 2026-05-25
**Last Updated:** 2026-07-10
---
@@ -195,7 +195,7 @@ Rumah Sakit RSSA menghadapi masalah antrean rawat jalan yang tidak terstruktur:
| Service | Base URL | Keterangan |
|---------|----------|------------|
| Visit API | `http://10.10.123.135:8084/api/v1` | Data kunjungan & antrean utama |
| Antrian API (Klinik) | `http://10.10.123.140:8089/api/v1` | Verifikasi & data klinik/dokter |
| 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
@@ -211,8 +211,8 @@ Rumah Sakit RSSA menghadapi masalah antrean rawat jalan yang tidak terstruktur:
┌──────────▼──────────┐ ┌────▼───────────────┐
│ Nuxt Server (Nitro) │ │ WebSocket Server │
│ server/api/ │ │ ws://10.10.123. │
server/routes/ │ │ 135:8084/api/v1/ws│
│ (Proxy + API layer) │ └────────────────────┘
SQLite (users.db) │ │ 135:8084/api/v1/ws│
│ (Config + Users) │ └────────────────────┘
└──────────┬──────────┘
│ HTTP Proxy (CORS bypass)
┌──────────▼──────────────────────────────────┐
@@ -227,7 +227,7 @@ Rumah Sakit RSSA menghadapi masalah antrean rawat jalan yang tidak terstruktur:
| Proxy Path | Target Backend | Keterangan |
|------------|---------------|------------|
| `/visit-api/**` | `http://10.10.123.135:8084/api/v1/**` | Data kunjungan pasien |
| `/klinik-api/**` | `http://10.10.123.140:8089/api/v1/**` | Data klinik & dokter |
| `/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
@@ -405,4 +405,5 @@ export interface ApiResponse<T> {
| 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 |
+181
View File
@@ -0,0 +1,181 @@
# Integrasi Hak Akses (Permissions) di UI beserta Konfigurasi CRUD
## ✅ Hasil Pengecekan Kesiapan Project
1. **Navigasi (*Menu/Routing*)**: Sudah tersedia di `stores/navItems1.ts` (`defaultNavItems`). Array ini akan menjadi sumber daftar halaman di dialog "Edit Hak Akses" — dengan catatan penting di bagian struktur data (lihat poin 2 di bawah).
2. **Library Komponen**: Vuetify 3 sudah tersedia, tinggal memasang `<v-checkbox>` / `<v-switch>` di tabel dialog Edit Hak Akses.
3. **Mock Backend API**: `server/api/hak-akses/index.ts` sudah bisa menahan format data CRUD yang akan dikirim dari UI.
Karena API backend sesungguhnya belum siap, kita pakai **Nuxt Local API (Mock Backend)** sebagai *Backend-for-Frontend* sementara, dengan arsitektur *toggling* API sejak awal agar migrasi ke backend asli mulus.
> ⚠️ **Catatan penting**: Toggle mock/real API ini menyelesaikan masalah *sumber data*, bukan masalah *keamanan*. Semua pengecekan `v-permission` dan middleware di bawah ini berjalan di client — lihat bagian **Keamanan** sebelum dianggap selesai.
---
## Bagan Alur Sistem Hak Akses (Permission Flow)
```mermaid
flowchart TD
User([Pengguna]) --> Login(Berhasil Login via Keycloak)
Login --> Auth[useAuth.ts: Dapat Data Roles & Groups]
Auth --> PermStore[permissionStore.ts memuat Permissions]
PermStore --> CheckFlag{USE_MOCK_API ?}
CheckFlag -- TRUE --> MockAPI[Nuxt Local API: /api/hak-akses]
CheckFlag -- FALSE --> RealAPI[Real Backend: /api/v1/permission]
MockAPI --> FetchFail{Fetch gagal?}
RealAPI --> FetchFail
FetchFail -- Ya --> DenyDefault[Deny-by-default: anggap tanpa izin]
FetchFail -- Tidak --> PermState(Permission State Disimpan)
PermState --> Router[Vue Router Middleware - client]
PermState --> ServerCheck[Server Middleware/Plugin - SSR guard]
PermState --> Sidebar[Sidebar Navigation]
Router -- canAccess: False --> Deny[Redirect ke Error/403]
Router -- canAccess: True --> Page[Buka Halaman]
ServerCheck -- canAccess: False --> Deny
Sidebar -- canView: False --> HideMenu[Sembunyikan Menu]
Page --> Directive[v-permission directive pada Komponen]
Directive -- canDelete: False --> HideBtn[Tombol Hapus Hilang/Disabled]
Directive -- canAdd: True --> ShowBtn[Tombol Tambah Tampil]
Page -.-> BackendGuard[[Backend API tetap validasi ulang izin]]
```
---
## Proposed Changes
### 1. Seamless Backend Migration Strategy (Persiapan API Asli)
- `stores/permissionStore.ts` dan composable pemanggil membaca konfigurasi *runtime* (`useRuntimeConfig()`).
- Tambahkan flag `USE_MOCK_PERMISSION_API: true`.
- Saat backend siap, cukup ubah `NUXT_PUBLIC_USE_MOCK_PERMISSION_API=false` di `.env`.
- **Tambahan — contract test**: buat satu skema/interface TypeScript (idealnya divalidasi dengan `zod`) yang dipakai bersama oleh mock API dan dipakai untuk memvalidasi response real API nanti. Ini memastikan klaim "tinggal ganti flag" benar-benar teruji, bukan asumsi.
### 2. Upgrade Local API Mock (`server/api/hak-akses/index.ts`)
- Perbarui `data/mock/hakAkses.json` agar field `hakAksesMenu` berisi boolean: `canAccess`, `canView`, `canAdd`, `canEdit`, `canDelete`.
- **Perubahan struktur — pakai key stabil, bukan label**: setiap entri `hakAksesMenu` menyimpan `menuKey` (mengacu ke `key`/`routeName` unik di `navItems1.ts`), bukan `name` (label tampilan). Label bisa berubah/di-rename tanpa memutus mapping izin.
```json
{
"menuKey": "master-klinik-ruang",
"name": "Master Klinik Ruang",
"canAccess": true,
"canView": true,
"canAdd": false,
"canEdit": false,
"canDelete": false
}
```
- Validasi payload masuk dengan skema (zod) di endpoint mock, supaya struktur tidak diam-diam berubah antara UI dan backend.
### 3. Memperbarui `Setting/HakAkses.vue` (UI Konfigurasi CRUD)
- Dialog Edit melooping `navItems1.ts`, menampilkan matriks checkbox untuk `canAccess`, `canView`, `canAdd`, `canEdit`, `canDelete`, dikunci ke `menuKey` masing-masing.
- Payload POST mengarah ke API Mock/Real sesuai flag.
- **Aturan precedence eksplisit** (harus didefinisikan sebelum coding, karena ada `role`, `group`, dan `isGroupBased` sekaligus):
1. Jika user punya override individual (role-based) → pakai itu.
2. Jika tidak ada override individual dan `isGroupBased: true` → pakai izin dari group.
3. Jika keduanya tidak ada → deny-by-default.
- Tuliskan aturan ini sebagai komentar di `permissionStore.ts`, bukan hanya di dokumen, supaya tidak jadi sumber bug tersembunyi saat logic berkembang.
### 4. Vue Custom Directive `v-permission`
- File baru `plugins/permission.ts` untuk registrasi directive.
- Dukungan penggunaan:
- Single: `<v-btn v-permission="'canEdit'">Edit</v-btn>`
- Multiple (AND): `<v-btn v-permission="['canEdit', 'canDelete']">...</v-btn>`
- **Mode hide vs disable**: tambahkan modifier, misal `v-permission:disable="'canDelete'"`, agar tombol bisa di-disable dengan tooltip ("Anda tidak punya izin") alih-alih hilang total tanpa penjelasan — pilih sesuai konteks UX per halaman.
### 5. `stores/permissionStore.ts` (State Management)
- Mengambil data izin dari Local/Real API saat login.
- Menyimpan state global.
- **Deny-by-default**: jika fetch permission gagal (network error, token expired), state dianggap "tanpa izin sama sekali", bukan default terbuka.
- **Refresh strategy**: tentukan apakah perubahan hak akses oleh admin berlaku langsung (polling/refetch berkala) atau baru berlaku setelah re-login. Pilih salah satu secara eksplisit dan dokumentasikan, jangan dibiarkan implisit.
### 6. Middleware & Dynamic Sidebar
- **Client middleware (`middleware/permissions.ts`)**: mencegat rute jika `canAccess` false.
- **Server-side guard**: karena project ini SSR (Nuxt), tambahkan pengecekan di server middleware/plugin juga — bukan hanya client — untuk mencegah *flash of unauthorized content* (halaman sempat ter-render sebelum redirect).
- **Sidebar (`stores/navItems1.ts`)**: filter menu berdasarkan `canView`, dikunci ke `menuKey`.
### 7. Keamanan Backend (wajib, non-negotiable)
- `v-permission` dan middleware di atas adalah **UX**, bukan kontrol akses sesungguhnya — keduanya berjalan di client dan bisa dilewati siapa saja yang memanggil API langsung.
- Backend API asli **wajib** memvalidasi ulang setiap permission di server berdasarkan identitas user dari token, tidak pernah mempercayai payload/izin yang dikirim dari frontend.
---
## Verification Plan
### 1. Verifikasi API Lokal via cURL
Pastikan API Lokal mampu membaca dan menyimpan JSON berformat CRUD dengan `menuKey`:
```bash
curl -X POST "http://localhost:3000/api/hak-akses" \
-H "Content-Type: application/json" \
-d '{
"role": "admin",
"group": "LOKET",
"namaTipeUser": "Admin Loket",
"isGroupBased": true,
"hakAksesMenu": [
{
"menuKey": "master-klinik-ruang",
"name": "Master Klinik Ruang",
"canAccess": true,
"canView": true,
"canAdd": false,
"canEdit": false,
"canDelete": false
}
]
}'
```
### 2. Manual UI Verification
1. Buka **Setting > Hak Akses**, atur hak untuk Role tertentu (centang `canView`, matikan `canDelete`).
2. Login sebagai akun dengan Role tersebut.
3. Buka halaman target.
4. **Validasi**: Halaman terbuka, tombol "Delete" tersembunyi/disabled sesuai mode `v-permission`.
5. **Validasi fetch gagal**: Simulasikan permission API error (mis. matikan endpoint sementara) → pastikan sistem deny-by-default, bukan default terbuka.
6. **Validasi kesiapan API asli**: Cek logika precedence dan toggle flag di `permissionStore.ts`.
### 3. Automated Testing (baru)
- **Unit test** untuk `permissionStore.ts`: precedence role vs group, deny-by-default saat fetch gagal, evaluasi `canAccess`/`canView`/dst.
- **Contract test**: bandingkan skema response Mock API vs Real API (setelah Real API tersedia) menggunakan skema TypeScript/zod yang sama, untuk memastikan switch flag benar-benar tanpa perubahan kode lain.
- **Directive test**: pastikan `v-permission` menyembunyikan/disable elemen dengan benar untuk kombinasi single dan multiple permission.
---
## Strategi Selama Backend Asli Belum Tersedia
Karena tim belum bisa mengimplementasikan validasi izin di server sungguhan, mock API diperlakukan sebagai **kontrak (contract-first)**, bukan sekadar penyimpanan data sementara. Tujuannya: frontend sudah teruji terhadap semua skenario yang nanti jadi tanggung jawab backend asli, dan tidak perlu dirombak saat migrasi.
### 1. Mock API mengikuti skema, bukan menerima apa saja
- Definisikan interface TypeScript / skema `zod` untuk request dan response `hak-akses` **sekarang**, bukan menunggu backend asli.
- Mock API menolak (400) payload yang tidak sesuai skema.
- Skema ini menjadi kontrak yang wajib dipatuhi backend asli nanti — perbedaan struktur akan ketahuan lewat contract test, bukan saat production.
### 2. Simulasikan tanggung jawab yang nanti dipegang backend
- Tambahkan endpoint mock `/api/hak-akses/check` yang bisa mensimulasikan penolakan server (403) karena user tidak punya izin — supaya UI dan middleware sudah teruji menangani penolakan dari server, bukan hanya dari state client.
- Simulasikan juga kegagalan fetch (delay/error 500) untuk memverifikasi deny-by-default benar-benar berjalan.
### 3. Tandai eksplisit bagian yang "sementara tidak aman"
Beri komentar `TODO(security)` di titik-titik yang wajib diperkuat saat backend asli terpasang, contoh:
```ts
// TODO(security): saat backend asli terpasang, endpoint ini WAJIB
// memvalidasi ulang permission dari token JWT/session di server,
// jangan percaya payload role/group yang dikirim dari client.
```
Ini mencegah asumsi keliru saat handoff bahwa "karena UI sudah mengatur tampilan sesuai izin, backend tidak perlu memvalidasi ulang".
### 4. Definition of Done — Migrasi ke Backend Asli
Checklist ini harus tercentang semua **sebelum** flag `USE_MOCK_PERMISSION_API` dimatikan (`false`) di production:
- [ ] Endpoint real API memvalidasi permission berdasarkan identitas dari token (JWT/session), bukan dari body request yang dikirim client.
- [ ] Response real API lolos contract test terhadap skema yang sama dengan mock API (field, tipe data, struktur `hakAksesMenu` identik).
- [ ] Skenario penolakan server (403) dan fetch gagal (500/timeout) sudah diuji terhadap real API, tidak hanya terhadap mock.
- [ ] Ada audit log untuk setiap perubahan hak akses (siapa mengubah, kapan, dari-ke apa) — direkomendasikan mengingat ini fitur kontrol akses.
- [ ] Rate limiting pada endpoint pengubahan hak akses, untuk mencegah penyalahgunaan.
- [ ] Semua komentar `TODO(security)` di kode sudah diselesaikan atau dipindahkan menjadi tiket tersendiri yang dilacak.
+181
View File
@@ -0,0 +1,181 @@
# Integrasi Hak Akses (Permissions) di UI beserta Konfigurasi CRUD
## ✅ Hasil Pengecekan Kesiapan Project
1. **Navigasi (*Menu/Routing*)**: Sudah tersedia di `stores/navItems1.ts` (`defaultNavItems`). Array ini akan menjadi sumber daftar halaman di dialog "Edit Hak Akses" — dengan catatan penting di bagian struktur data (lihat poin 2 di bawah).
2. **Library Komponen**: Vuetify 3 sudah tersedia, tinggal memasang `<v-checkbox>` / `<v-switch>` di tabel dialog Edit Hak Akses.
3. **Mock Backend API**: `server/api/hak-akses/index.ts` sudah bisa menahan format data CRUD yang akan dikirim dari UI.
Karena API backend sesungguhnya belum siap, kita pakai **Nuxt Local API (Mock Backend)** sebagai *Backend-for-Frontend* sementara, dengan arsitektur *toggling* API sejak awal agar migrasi ke backend asli mulus.
> ⚠️ **Catatan penting**: Toggle mock/real API ini menyelesaikan masalah *sumber data*, bukan masalah *keamanan*. Semua pengecekan `v-permission` dan middleware di bawah ini berjalan di client — lihat bagian **Keamanan** sebelum dianggap selesai.
---
## Bagan Alur Sistem Hak Akses (Permission Flow)
```mermaid
flowchart TD
User([Pengguna]) --> Login(Berhasil Login via Keycloak)
Login --> Auth[useAuth.ts: Dapat Data Roles & Groups]
Auth --> PermStore[permissionStore.ts memuat Permissions]
PermStore --> CheckFlag{USE_MOCK_API ?}
CheckFlag -- TRUE --> MockAPI[Nuxt Local API: /api/hak-akses]
CheckFlag -- FALSE --> RealAPI[Real Backend: /api/v1/permission]
MockAPI --> FetchFail{Fetch gagal?}
RealAPI --> FetchFail
FetchFail -- Ya --> DenyDefault[Deny-by-default: anggap tanpa izin]
FetchFail -- Tidak --> PermState(Permission State Disimpan)
PermState --> Router[Vue Router Middleware - client]
PermState --> ServerCheck[Server Middleware/Plugin - SSR guard]
PermState --> Sidebar[Sidebar Navigation]
Router -- canAccess: False --> Deny[Redirect ke Error/403]
Router -- canAccess: True --> Page[Buka Halaman]
ServerCheck -- canAccess: False --> Deny
Sidebar -- canView: False --> HideMenu[Sembunyikan Menu]
Page --> Directive[v-permission directive pada Komponen]
Directive -- canDelete: False --> HideBtn[Tombol Hapus Hilang/Disabled]
Directive -- canAdd: True --> ShowBtn[Tombol Tambah Tampil]
Page -.-> BackendGuard[[Backend API tetap validasi ulang izin]]
```
---
## Proposed Changes
### 1. Seamless Backend Migration Strategy (Persiapan API Asli)
- `stores/permissionStore.ts` dan composable pemanggil membaca konfigurasi *runtime* (`useRuntimeConfig()`).
- Tambahkan flag `USE_MOCK_PERMISSION_API: true`.
- Saat backend siap, cukup ubah `NUXT_PUBLIC_USE_MOCK_PERMISSION_API=false` di `.env`.
- **Tambahan — contract test**: buat satu skema/interface TypeScript (idealnya divalidasi dengan `zod`) yang dipakai bersama oleh mock API dan dipakai untuk memvalidasi response real API nanti. Ini memastikan klaim "tinggal ganti flag" benar-benar teruji, bukan asumsi.
### 2. Upgrade Local API Mock (`server/api/hak-akses/index.ts`)
- Perbarui `data/mock/hakAkses.json` agar field `hakAksesMenu` berisi boolean: `canAccess`, `canView`, `canAdd`, `canEdit`, `canDelete`.
- **Perubahan struktur — pakai key stabil, bukan label**: setiap entri `hakAksesMenu` menyimpan `menuKey` (mengacu ke `key`/`routeName` unik di `navItems1.ts`), bukan `name` (label tampilan). Label bisa berubah/di-rename tanpa memutus mapping izin.
```json
{
"menuKey": "master-klinik-ruang",
"name": "Master Klinik Ruang",
"canAccess": true,
"canView": true,
"canAdd": false,
"canEdit": false,
"canDelete": false
}
```
- Validasi payload masuk dengan skema (zod) di endpoint mock, supaya struktur tidak diam-diam berubah antara UI dan backend.
### 3. Memperbarui `Setting/HakAkses.vue` (UI Konfigurasi CRUD)
- Dialog Edit melooping `navItems1.ts`, menampilkan matriks checkbox untuk `canAccess`, `canView`, `canAdd`, `canEdit`, `canDelete`, dikunci ke `menuKey` masing-masing.
- Payload POST mengarah ke API Mock/Real sesuai flag.
- **Aturan precedence eksplisit** (harus didefinisikan sebelum coding, karena ada `role`, `group`, dan `isGroupBased` sekaligus):
1. Jika user punya override individual (role-based) → pakai itu.
2. Jika tidak ada override individual dan `isGroupBased: true` → pakai izin dari group.
3. Jika keduanya tidak ada → deny-by-default.
- Tuliskan aturan ini sebagai komentar di `permissionStore.ts`, bukan hanya di dokumen, supaya tidak jadi sumber bug tersembunyi saat logic berkembang.
### 4. Vue Custom Directive `v-permission`
- File baru `plugins/permission.ts` untuk registrasi directive.
- Dukungan penggunaan:
- Single: `<v-btn v-permission="'canEdit'">Edit</v-btn>`
- Multiple (AND): `<v-btn v-permission="['canEdit', 'canDelete']">...</v-btn>`
- **Mode hide vs disable**: tambahkan modifier, misal `v-permission:disable="'canDelete'"`, agar tombol bisa di-disable dengan tooltip ("Anda tidak punya izin") alih-alih hilang total tanpa penjelasan — pilih sesuai konteks UX per halaman.
### 5. `stores/permissionStore.ts` (State Management)
- Mengambil data izin dari Local/Real API saat login.
- Menyimpan state global.
- **Deny-by-default**: jika fetch permission gagal (network error, token expired), state dianggap "tanpa izin sama sekali", bukan default terbuka.
- **Refresh strategy**: tentukan apakah perubahan hak akses oleh admin berlaku langsung (polling/refetch berkala) atau baru berlaku setelah re-login. Pilih salah satu secara eksplisit dan dokumentasikan, jangan dibiarkan implisit.
### 6. Middleware & Dynamic Sidebar
- **Client middleware (`middleware/permissions.ts`)**: mencegat rute jika `canAccess` false.
- **Server-side guard**: karena project ini SSR (Nuxt), tambahkan pengecekan di server middleware/plugin juga — bukan hanya client — untuk mencegah *flash of unauthorized content* (halaman sempat ter-render sebelum redirect).
- **Sidebar (`stores/navItems1.ts`)**: filter menu berdasarkan `canView`, dikunci ke `menuKey`.
### 7. Keamanan Backend (wajib, non-negotiable)
- `v-permission` dan middleware di atas adalah **UX**, bukan kontrol akses sesungguhnya — keduanya berjalan di client dan bisa dilewati siapa saja yang memanggil API langsung.
- Backend API asli **wajib** memvalidasi ulang setiap permission di server berdasarkan identitas user dari token, tidak pernah mempercayai payload/izin yang dikirim dari frontend.
---
## Verification Plan
### 1. Verifikasi API Lokal via cURL
Pastikan API Lokal mampu membaca dan menyimpan JSON berformat CRUD dengan `menuKey`:
```bash
curl -X POST "http://localhost:3000/api/hak-akses" \
-H "Content-Type: application/json" \
-d '{
"role": "admin",
"group": "LOKET",
"namaTipeUser": "Admin Loket",
"isGroupBased": true,
"hakAksesMenu": [
{
"menuKey": "master-klinik-ruang",
"name": "Master Klinik Ruang",
"canAccess": true,
"canView": true,
"canAdd": false,
"canEdit": false,
"canDelete": false
}
]
}'
```
### 2. Manual UI Verification
1. Buka **Setting > Hak Akses**, atur hak untuk Role tertentu (centang `canView`, matikan `canDelete`).
2. Login sebagai akun dengan Role tersebut.
3. Buka halaman target.
4. **Validasi**: Halaman terbuka, tombol "Delete" tersembunyi/disabled sesuai mode `v-permission`.
5. **Validasi fetch gagal**: Simulasikan permission API error (mis. matikan endpoint sementara) → pastikan sistem deny-by-default, bukan default terbuka.
6. **Validasi kesiapan API asli**: Cek logika precedence dan toggle flag di `permissionStore.ts`.
### 3. Automated Testing (baru)
- **Unit test** untuk `permissionStore.ts`: precedence role vs group, deny-by-default saat fetch gagal, evaluasi `canAccess`/`canView`/dst.
- **Contract test**: bandingkan skema response Mock API vs Real API (setelah Real API tersedia) menggunakan skema TypeScript/zod yang sama, untuk memastikan switch flag benar-benar tanpa perubahan kode lain.
- **Directive test**: pastikan `v-permission` menyembunyikan/disable elemen dengan benar untuk kombinasi single dan multiple permission.
---
## Strategi Selama Backend Asli Belum Tersedia
Karena tim belum bisa mengimplementasikan validasi izin di server sungguhan, mock API diperlakukan sebagai **kontrak (contract-first)**, bukan sekadar penyimpanan data sementara. Tujuannya: frontend sudah teruji terhadap semua skenario yang nanti jadi tanggung jawab backend asli, dan tidak perlu dirombak saat migrasi.
### 1. Mock API mengikuti skema, bukan menerima apa saja
- Definisikan interface TypeScript / skema `zod` untuk request dan response `hak-akses` **sekarang**, bukan menunggu backend asli.
- Mock API menolak (400) payload yang tidak sesuai skema.
- Skema ini menjadi kontrak yang wajib dipatuhi backend asli nanti — perbedaan struktur akan ketahuan lewat contract test, bukan saat production.
### 2. Simulasikan tanggung jawab yang nanti dipegang backend
- Tambahkan endpoint mock `/api/hak-akses/check` yang bisa mensimulasikan penolakan server (403) karena user tidak punya izin — supaya UI dan middleware sudah teruji menangani penolakan dari server, bukan hanya dari state client.
- Simulasikan juga kegagalan fetch (delay/error 500) untuk memverifikasi deny-by-default benar-benar berjalan.
### 3. Tandai eksplisit bagian yang "sementara tidak aman"
Beri komentar `TODO(security)` di titik-titik yang wajib diperkuat saat backend asli terpasang, contoh:
```ts
// TODO(security): saat backend asli terpasang, endpoint ini WAJIB
// memvalidasi ulang permission dari token JWT/session di server,
// jangan percaya payload role/group yang dikirim dari client.
```
Ini mencegah asumsi keliru saat handoff bahwa "karena UI sudah mengatur tampilan sesuai izin, backend tidak perlu memvalidasi ulang".
### 4. Definition of Done — Migrasi ke Backend Asli
Checklist ini harus tercentang semua **sebelum** flag `USE_MOCK_PERMISSION_API` dimatikan (`false`) di production:
- [ ] Endpoint real API memvalidasi permission berdasarkan identitas dari token (JWT/session), bukan dari body request yang dikirim client.
- [ ] Response real API lolos contract test terhadap skema yang sama dengan mock API (field, tipe data, struktur `hakAksesMenu` identik).
- [ ] Skenario penolakan server (403) dan fetch gagal (500/timeout) sudah diuji terhadap real API, tidak hanya terhadap mock.
- [ ] Ada audit log untuk setiap perubahan hak akses (siapa mengubah, kapan, dari-ke apa) — direkomendasikan mengingat ini fitur kontrol akses.
- [ ] Rate limiting pada endpoint pengubahan hak akses, untuk mencegah penyalahgunaan.
- [ ] Semua komentar `TODO(security)` di kode sudah diselesaikan atau dipindahkan menjadi tiket tersendiri yang dilacak.
+36 -7
View File
@@ -12,22 +12,20 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { computed, onMounted, ref, watch } from "vue";
import { useLocalStorage } from "@vueuse/core";
import SideBar from "../components/layout/SideBar.vue";
// Ensure this path matches your store location
import { useNavItemsStore } from '~/stores/navItems1';
import { useAuth } from "~/composables/useAuth";
definePageMeta({
middleware: ['auth', 'checkPageAccess']
})
import { usePermissionStore } from '~/stores/permissionStore';
// State for controlling the sidebar
const drawer = ref(true);
const rail = ref(true);
const navItemsStore = useNavItemsStore();
const permissionStore = usePermissionStore();
const { user, checkAuth } = useAuth();
// Navigation will be filtered via navItemsStore using the new hakAkses system
@@ -36,16 +34,47 @@ const filteredNavItems = computed(() => navItemsStore.filteredNavItems);
onMounted(async () => {
await checkAuth();
if (user.value) {
if (!permissionStore.isLoaded) {
const roles = [
...(user.value.realm_access?.roles || []),
...(user.value.roles || [])
];
const groups: string[] = [];
const rawGroups = (user.value as any).groups || [];
rawGroups.forEach((g: string) => {
const parts = g.split('/').filter(Boolean);
if (parts.length > 1) {
groups.push(parts[1]);
} else if (parts.length === 1) {
groups.push(parts[0]);
}
});
const primaryRole = roles[0] || '';
const primaryGroup = groups[0] || '';
const username = user.value.preferred_username || user.value.email || user.value.name || '';
await permissionStore.load(primaryRole, primaryGroup, username);
}
await navItemsStore.refreshNavItems();
}
});
// Watch for permissions loaded state to refresh navItems (fixes empty sidebar on F5)
watch(() => permissionStore.isLoaded, async (isLoaded) => {
if (isLoaded && user.value) {
await navItemsStore.refreshNavItems();
}
}, { immediate: true });
// Watch for user changes to refresh navItems
watch(() => user.value, async (newUser) => {
if (newUser) {
if (newUser && permissionStore.isLoaded) {
await navItemsStore.refreshNavItems();
} else {
} else if (!newUser) {
// Optionally reset navItems when logged out
navItemsStore.filteredNavItems = [];
}
+59 -45
View File
@@ -1,70 +1,84 @@
// middleware/checkPageAccess.ts
// Middleware to check if user has access to the page based on hakAkses
import { defineNuxtRouteMiddleware, navigateTo } from '#app';
import { useAuth } from '~/composables/useAuth';
import { usePermissionStore } from '~/stores/permissionStore';
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;
const permissionStore = usePermissionStore();
// Ensure permissions are loaded
if (!permissionStore.isLoaded) {
const roles = [
...(user.value.realm_access?.roles || []),
...(user.value.roles || [])
];
const groups: string[] = [];
const rawGroups = (user.value as any).groups || [];
rawGroups.forEach((g: string) => {
const parts = g.split('/').filter(Boolean);
if (parts.length > 1) {
groups.push(parts[1]);
} else if (parts.length === 1) {
groups.push(parts[0]);
}
});
const primaryRole = roles[0] || '';
const primaryGroup = groups[0] || '';
const username = user.value.preferred_username || user.value.email || user.value.name || '';
await permissionStore.load(primaryRole, primaryGroup, username);
}
let menuKey = "";
if (to.name) {
menuKey = to.name.toString().toLowerCase().replace(/_|-/g, '-');
}
// If no explicit route name, allow it for now.
if (!menuKey) return;
// The previous implementation allowed some pages implicitly.
// If it's not configured, our can() method returns false.
// We should allow access if it's explicitly allowed.
let hasAccess = permissionStore.can(menuKey, 'canAccess');
// Default allow Dashboard for all authenticated users
if (menuKey === 'dashboard') {
hasAccess = true;
}
if (!hasAccess) {
console.warn(`Access denied to ${to.path}. User lacks 'canAccess' for menuKey: ${menuKey}`);
// Find a fallback page that the user CAN access
const fallbackMenu = permissionStore.permissions.find(p => p.canAccess);
if (fallbackMenu && fallbackMenu.menuKey) {
// Note: menuKey might not be a valid path, but if we map routeName to menuKey,
// we can try to navigate to the route name instead.
return navigateTo({ name: fallbackMenu.menuKey });
}
} catch (error) {
console.error('Error checking page access:', error);
// On error, we might want to allow or block. Let's allow but log.
return;
// Final fallback
return navigateTo('/');
}
});
+1
View File
@@ -88,6 +88,7 @@ export default defineNuxtConfig({
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'),
useMockPermissionApi: process.env.NUXT_PUBLIC_USE_MOCK_PERMISSION_API !== 'false', // Default true until backend is ready
},
},
+11 -1
View File
@@ -37,7 +37,8 @@
"vue-chartjs": "^5.3.2",
"vue-draggable-next": "^2.3.0",
"vue-router": "^4.5.1",
"vue3-carousel": "^0.17.0"
"vue3-carousel": "^0.17.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@nuxtjs/google-fonts": "^3.2.0",
@@ -23601,6 +23602,15 @@
"node": ">= 14"
}
},
"node_modules/zod": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zwitch": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
+2 -1
View File
@@ -47,7 +47,8 @@
"vue-chartjs": "^5.3.2",
"vue-draggable-next": "^2.3.0",
"vue-router": "^4.5.1",
"vue3-carousel": "^0.17.0"
"vue3-carousel": "^0.17.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@nuxtjs/google-fonts": "^3.2.0",
+5 -1
View File
@@ -1,6 +1,8 @@
<template>
<div>
<!-- Header -->
<AdminKlinikEksekutif v-if="jenisLayanan === 'Eksekutif'" />
<div v-else>
<!-- Header -->
<PageHeader
icon="mdi-door-open"
:title="`Admin Klinik Ruang - ${klinikData?.namaKlinik || ''}`"
@@ -785,6 +787,7 @@
:color="snackbarColor"
/>
</div>
</div>
</div>
</template>
@@ -798,6 +801,7 @@ import { useRuangStore } from '@/stores/ruangStore';
import PageHeader from '@/components/common/PageHeader.vue';
import PatientCard from '@/components/features/queue/PatientCard.vue';
import AppSnackbar from '@/components/common/AppSnackbar.vue';
import AdminKlinikEksekutif from '@/components/AdminKlinik/AdminKlinikEksekutif.vue';
import { useThermalPrint } from '@/composables/useThermalPrint';
const route = useRoute();
+40 -1
View File
@@ -103,11 +103,47 @@ const ruangStore = useRuangStore();
const loading = ref(false);
/**
* Computed property untuk mendapatkan daftar klinik dengan menggabungkan
* semua poli Eksekutif menjadi satu entitas "GRAND PAVILIUN".
* @returns {Array} Daftar klinik yang sudah digabung dan diurutkan.
*/
const klinikRuangList = computed(() => {
const list = masterStore.ruangData || [];
return [...list].sort((a, b) => a.namaKlinik.localeCompare(b.namaKlinik));
const regulerList = list.filter(k => k.jenisLayanan !== 'Eksekutif');
const eksekutifList = list.filter(k => k.jenisLayanan === 'Eksekutif');
const result = [...regulerList];
if (eksekutifList.length > 0) {
// Combine all Eksekutif rooms for the preview
const allRooms = eksekutifList.flatMap(k => k.ruangList || []);
// Filter unique room names
const uniqueRoomNames = new Set();
const uniqueRooms = [];
allRooms.forEach(r => {
if (!uniqueRoomNames.has(r.namaRuang)) {
uniqueRoomNames.add(r.namaRuang);
uniqueRooms.push(r);
}
});
result.push({
kodeKlinik: 'EKS',
namaKlinik: 'GRAND PAVILIUN',
jenisLayanan: 'Eksekutif',
ruangList: uniqueRooms
});
}
return result.sort((a, b) => a.namaKlinik.localeCompare(b.namaKlinik));
});
/**
* Navigasi ke halaman detail antrean klinik ruang tertentu.
* @param {string} kodeKlinik - Kode unik klinik (contoh: 'AN', 'EKS').
* @param {string} jenisLayanan - Jenis layanan ('Reguler' atau 'Eksekutif').
*/
const navigateToKlinik = (kodeKlinik, jenisLayanan) => {
// Include jenisLayanan as query parameter to differentiate same clinic code with different service types
router.push({
@@ -116,6 +152,9 @@ const navigateToKlinik = (kodeKlinik, jenisLayanan) => {
});
};
/**
* Navigasi ke halaman master setting klinik ruang.
*/
const navigateToSettings = () => {
router.push('/setting/masterklinikruang');
};
+29 -26
View File
@@ -37,6 +37,7 @@
@call="handleCallPatient"
@open-klinik-ruang="openKlinikRuangDialog"
@open-penunjang="openPenunjangDialog"
@linked="handlePatientLinked"
/>
<QueueActionsCard
@@ -709,28 +710,16 @@ const allPatientsForStage = computed(() => {
// Helper to check if patient belongs to this loket (for seed data only)
const isPatientForThisLoket = (p) => {
// Only check for EKSEKUTIF patients (seed data)
const isPatientEksekutif =
(p.pembayaran || "").toUpperCase().includes("EKSEKUTIF") ||
(p.pembayaran || "").toUpperCase().includes("VIP");
// 1. Strict isolation using ticket prefix
const isPatientEksekutif = p.noAntrian && (String(p.noAntrian).startsWith('E') || String(p.noAntrian).startsWith('F-E'));
if (isPatientEksekutif !== isLoketEksekutif) {
return false;
}
if (isLoketEksekutif) {
// Loket Eksekutif HANYA melayani pasien Eksekutif
if (!isPatientEksekutif) return false;
// For EKSEKUTIF loket: accept all EKSEKUTIF patients
// EKSEKUTIF lokets typically serve ALL clinics for executive patients
// So we don't need to check kodeKlinik matching
// Only check explicit loketId assignment if present
if (p.loketId) {
return String(p.loketId) === String(targetLoketId);
}
// If no loketId assigned, accept all EKSEKUTIF patients
return true;
} else {
// Loket Reguler TIDAK melayani pasien Eksekutif (this shouldn't happen with API data)
if (isPatientEksekutif) return false;
// 2. Explicit Loket assignment takes precedence
if (p.loketId) {
return String(p.loketId) === String(targetLoketId);
}
// For REGULER seed data only: check loket assignment
@@ -854,16 +843,22 @@ const filteredMenungguCount = computed(() => {
if (isLoketEksekutif) {
// For EKSEKUTIF, use seed data menunggu count (filtered by loket)
return menungguPatients.value.filter((p) => {
const isPatientEksekutif =
(p.pembayaran || "").toUpperCase().includes("EKSEKUTIF") ||
(p.pembayaran || "").toUpperCase().includes("VIP");
// 1. Strict isolation using ticket prefix
const isPatientEksekutif = p.noAntrian && (String(p.noAntrian).startsWith('E') || String(p.noAntrian).startsWith('F-E'));
if (!isPatientEksekutif) return false;
// Accept all EKSEKUTIF patients if no explicit loketId
// 2. Explicit Loket assignment takes precedence
if (p.loketId) {
return String(p.loketId) === String(targetLoketId);
}
return true;
// 3. Fallback to clinic mapping if unassigned
const allowedServices = currentLoket?.pelayanan || [];
if (p.kodeKlinik) {
return allowedServices.includes(p.kodeKlinik);
}
return false;
}).length;
} else {
// For REGULER, count from allPatients (reactive)
@@ -1457,12 +1452,20 @@ const buatAntreanKlinikRuang = async (klinikRuang, ruang) => {
snackbarColor.value = result.success ? "success" : "error";
snackbar.value = true;
if (result.success) {
broadcastUpdate();
}
closeKlinikRuangDialog();
};
// --- SIMRS Integration Handlers ---
const handlePatientLinked = (message) => {
snackbarText.value = message;
snackbarColor.value = "success";
snackbar.value = true;
};
</script>
<style scoped lang="scss">
@@ -209,8 +209,14 @@ const klinikData = computed(() => {
}
// Filter by kodeKlinik AND jenisLayanan if provided
let filteredRuang
if (jenisLayanan.value) {
let filteredRuang = []
if (jenisLayanan.value === 'Eksekutif' && kode === 'EKS') {
return {
kodeKlinik: 'EKS',
namaKlinik: 'GRAND PAVILIUN',
jenisLayanan: 'Eksekutif'
}
} else if (jenisLayanan.value) {
filteredRuang = allRuang.filter(r =>
r.kodeKlinik === kode && r.jenisLayanan === jenisLayanan.value
)
@@ -280,7 +286,15 @@ watch(() => queueStore.lastKlinikCall, (newCall) => {
const callKode = String(newCall.kodeKlinik || '').toUpperCase();
const myKode = String(kodeKlinik.value || '').toUpperCase();
const isMatch = callKode === myKode;
let isMatch = false;
if (myKode === 'EKS' && jenisLayanan.value === 'Eksekutif') {
// For consolidated view, check if the called clinic is an Eksekutif clinic
const targetClinic = (masterStore.ruangData || []).find(r => r.kodeKlinik === callKode || r.kodeKlinik === newCall.kodeKlinik);
isMatch = targetClinic && targetClinic.jenisLayanan === 'Eksekutif';
} else {
isMatch = callKode === myKode;
}
console.log('🔍 [Anjungan] Klinik Code Comparison:', {
received: callKode,
@@ -332,13 +346,19 @@ const klinikPatients = computed(() => {
// Get patients from klinik-ruang stage (created from AdminKlinikRuang)
// Filter out finished patients (status: 'processed' or 'selesai')
// AND filter by room number to separate patients by jenisLayanan
return queueStore.allPatients.filter(p =>
p.processStage === 'klinik-ruang' &&
p.kodeKlinik === kodeKlinik.value &&
p.status !== 'processed' &&
p.status !== 'selesai' &&
validRoomNumbers.includes(p.nomorRuang) // Only show patients in rooms belonging to this jenisLayanan
)
return queueStore.allPatients.filter(p => {
if (p.processStage !== 'klinik-ruang' || p.status === 'processed' || p.status === 'selesai') {
return false;
}
// For consolidated Eksekutif view, any valid Eksekutif room is a match, regardless of patient's original kodeKlinik
if (kodeKlinik.value === 'EKS' && jenisLayanan.value === 'Eksekutif') {
return validRoomNumbers.includes(p.nomorRuang);
}
// For regular clinics, enforce exact kodeKlinik match
return p.kodeKlinik === kodeKlinik.value && validRoomNumbers.includes(p.nomorRuang);
})
})
// Get ruang list for this specific klinik
@@ -364,8 +384,11 @@ const ruangListForKlinik = computed(() => {
}
// Filter by kodeKlinik AND jenisLayanan if provided
let filtered
if (jenisLayanan.value) {
let filtered = []
if (jenisLayanan.value === 'Eksekutif' && kodeKlinik.value === 'EKS') {
// If it's the consolidated Eksekutif view, we want ALL Eksekutif rooms
filtered = allRuang.filter(r => r.jenisLayanan === 'Eksekutif')
} else if (jenisLayanan.value) {
filtered = allRuang.filter(r =>
r.kodeKlinik === kodeKlinik.value && r.jenisLayanan === jenisLayanan.value
)
+40 -2
View File
@@ -126,15 +126,50 @@ const clinicStore = useClinicStore();
const ruangStore = useRuangStore();
const route = useRoute();
// Simplified computed to use masterStore.ruangData (which is already grouped/processed by ruangStore)
/**
* Computed property untuk mendapatkan daftar klinik dengan menggabungkan
* semua poli Eksekutif menjadi satu entitas "GRAND PAVILIUN".
* @returns {Array} Daftar klinik yang sudah digabung dan diurutkan berdasarkan nama.
*/
const kliniksWithRuang = computed(() => {
const list = masterStore.ruangData || [];
return [...list].sort((a, b) => a.namaKlinik.localeCompare(b.namaKlinik));
const regulerList = list.filter(k => k.jenisLayanan !== 'Eksekutif');
const eksekutifList = list.filter(k => k.jenisLayanan === 'Eksekutif');
const result = [...regulerList];
if (eksekutifList.length > 0) {
// Combine all Eksekutif rooms for the preview
const allRooms = eksekutifList.flatMap(k => k.ruangList || []);
// Filter unique room names
const uniqueRoomNames = new Set();
const uniqueRooms = [];
allRooms.forEach(r => {
if (!uniqueRoomNames.has(r.namaRuang)) {
uniqueRoomNames.add(r.namaRuang);
uniqueRooms.push(r);
}
});
result.push({
id: 'EKS',
kodeKlinik: 'EKS',
namaKlinik: 'GRAND PAVILIUN',
jenisLayanan: 'Eksekutif',
ruangList: uniqueRooms
});
}
return result.sort((a, b) => a.namaKlinik.localeCompare(b.namaKlinik));
});
// Deprecated: Pagination logic removed
/**
* Navigasi ke halaman anjungan antrean klinik ruang tertentu.
* @param {Object} klinik - Objek data klinik yang dipilih.
*/
const navigateToKlinik = (klinik) => {
// Build URL with jenisLayanan query parameter to differentiate same clinic codes
const url = `/anjungan/antrianklinikruang/${klinik.kodeKlinik}`;
@@ -142,6 +177,9 @@ const navigateToKlinik = (klinik) => {
navigateTo({ path: url, query });
};
/**
* Navigasi ke halaman master setting klinik ruang.
*/
const navigateToSettings = () => {
navigateTo('/setting/masterklinikruang');
};
+27 -21
View File
@@ -129,6 +129,7 @@
prepend-icon="mdi-shield-edit-outline"
class="text-capitalize rounded-lg mr-2"
@click="editPermissions(item)"
v-permission="'canEdit'"
>
Atur Akses
</v-btn>
@@ -165,8 +166,8 @@
<div class="pa-6 pt-0">
<EditHakAkses
v-if="editedEntity"
:pages="editedEntity.pages"
@update:pages="editedEntity.pages = $event"
:hakAksesMenu="editedEntity.hakAksesMenu || []"
@update:hakAksesMenu="editedEntity.hakAksesMenu = $event"
/>
</div>
</v-card-text>
@@ -240,7 +241,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import EditHakAkses from '@/components/HakAkses/EditHakAkses.vue';
import { useNavItemsStore } from '~/stores/navItems1';
import { useNavItemsStore, defaultNavItems } from '~/stores/navItems1';
import type { HakAkses } from '~/types/setting';
definePageMeta({
@@ -321,6 +322,8 @@ const loadData = async () => {
}
};
// Computed list based on active tab
const displayItems = computed(() => {
let list = [];
@@ -328,15 +331,15 @@ const displayItems = computed(() => {
else if (activeTab.value === 'groups') list = keycloakEntities.value.groups;
else list = keycloakEntities.value.users;
// Get all valid paths from current navigation store
const allNavPaths = new Set<string>();
const extractPaths = (items: any[]) => {
// Get all valid menuKeys from current navigation store
const allNavKeys = new Set<string>();
const extractKeys = (items: any[]) => {
items.forEach((item: any) => {
if (item.path) allNavPaths.add(item.path);
if (item.children) extractPaths(item.children);
if (item.menuKey) allNavKeys.add(item.menuKey);
if (item.children) extractKeys(item.children);
});
};
extractPaths(navItemsStore.getNavItems);
extractKeys(defaultNavItems);
return list.map((entity: any) => {
// Find existing permission mapping
@@ -344,18 +347,15 @@ const displayItems = computed(() => {
const lookupKey = entity.type === 'user' ? entity.username : entity.name;
const mapping = hakAksesList.value.find(h => h.namaHakAkses === lookupKey);
if (!mapping) return { ...entity, pages: [], validPagesCount: 0, status: 'tidak aktif', id_mapping: null };
if (!mapping) return { ...entity, hakAksesMenu: [], 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);
});
// Count only valid pages that exist in navigation and have canAccess
const validMenus = (mapping.hakAksesMenu || []).filter((m: any) => m.menuKey && allNavKeys.has(m.menuKey) && m.canAccess);
return {
...entity,
pages: mapping.pages, // Keep original for editing
validPagesCount: validPages.length,
hakAksesMenu: mapping.hakAksesMenu, // Keep original for editing
validPagesCount: validMenus.length,
status: mapping.status,
id_mapping: mapping.id
};
@@ -401,10 +401,14 @@ const savePermissions = async () => {
loading.value = true;
try {
const payload = {
id: editedEntity.value.id_mapping, // If null, backend create new
namaHakAkses: editedEntity.value.name,
id: editedEntity.value.id_mapping || undefined, // Zod optional() expects undefined, not null
namaHakAkses: editedEntity.value.type === 'user' ? editedEntity.value.username : editedEntity.value.name,
role: editedEntity.value.type === 'role' ? editedEntity.value.name : undefined,
group: editedEntity.value.type === 'group' ? editedEntity.value.name : undefined,
namaTipeUser: editedEntity.value.type === 'user' ? editedEntity.value.username : undefined,
isGroupBased: editedEntity.value.type === 'group',
status: 'aktif',
pages: editedEntity.value.pages
hakAksesMenu: editedEntity.value.hakAksesMenu || []
};
const response = await $fetch<{ success: boolean, message: string }>('/api/hak-akses', {
@@ -417,10 +421,12 @@ const savePermissions = async () => {
await loadData();
await navItemsStore.refreshNavItems();
showEditDialog.value = false;
} else {
showSnackbar(response.message || 'Gagal menyimpan perubahan', 'error');
}
} catch (error) {
console.error('Error saving:', error);
showSnackbar('Gagal menyimpan perubahan', 'error');
showSnackbar('Terjadi kesalahan pada server saat menyimpan perubahan', 'error');
} finally {
loading.value = false;
}
+76 -21
View File
@@ -76,7 +76,7 @@
<v-data-table
v-model:page="page"
:headers="headers"
:items="masterStore.ruangData"
:items="consolidatedRuangData"
:items-per-page="itemsPerPage"
:search="search"
item-value="id"
@@ -120,6 +120,7 @@
<template v-slot:item.layarInformasi="{ item }">
<v-btn
v-if="item.id !== 'EKS'"
size="small"
variant="outlined"
color="primary-600"
@@ -129,6 +130,17 @@
<v-icon size="16" left>mdi-eye</v-icon>
Preview
</v-btn>
<v-btn
v-else
size="small"
variant="outlined"
color="primary-600"
@click="previewUrl = '/anjungan/antrianklinikruang/EKS?jenisLayanan=Eksekutif'; previewDialog = true;"
class="btn-preview mr-2"
>
<v-icon size="16" left>mdi-eye</v-icon>
Preview
</v-btn>
</template>
<template v-slot:item.jenisLayanan="{ item }">
@@ -158,26 +170,29 @@
</template>
<template v-slot:item.aksi="{ item }">
<v-btn
size="small"
color=" warning-600"
@click="openEditDialog(item)"
class="btn-edit mr-2"
variant="flat"
>
<v-icon size="16" left>mdi-pencil</v-icon>
Edit
</v-btn>
<v-btn
size="small"
color="error-600"
@click="handleDelete(item)"
class="btn-delete"
variant="flat"
>
<v-icon size="16" left>mdi-delete</v-icon>
Delete
</v-btn>
<template v-if="item.id !== 'EKS'">
<v-btn
size="small"
color=" warning-600"
@click="openEditDialog(item)"
class="btn-edit mr-2"
variant="flat"
>
<v-icon size="16" left>mdi-pencil</v-icon>
Edit
</v-btn>
<v-btn
size="small"
color="error-600"
@click="handleDelete(item)"
class="btn-delete"
variant="flat"
>
<v-icon size="16" left>mdi-delete</v-icon>
Delete
</v-btn>
</template>
<span v-else class="text-caption text-grey">Data Otomatis</span>
</template>
</v-data-table>
</v-card-text>
@@ -391,6 +406,46 @@ const itemsPerPage = ref(10);
const search = ref('');
const filteredTotal = ref(masterStore.ruangData.length);
/**
* Menggabungkan data ruang klinik agar poli Eksekutif tampil sebagai satu kesatuan 'GRAND PAVILIUN'.
* Memisahkan poli reguler dan mengekstrak semua ruangan dari poli eksekutif menjadi satu set ruangan unik.
* @returns {Array} Array objek data ruang klinik yang sudah dikonsolidasi.
*/
const consolidatedRuangData = computed(() => {
const list = masterStore.ruangData || [];
const regulerList = list.filter(k => k.jenisLayanan !== 'Eksekutif');
const eksekutifList = list.filter(k => k.jenisLayanan === 'Eksekutif');
const result = [...regulerList];
if (eksekutifList.length > 0) {
// Combine all Eksekutif rooms
const allRooms = eksekutifList.flatMap(k => k.ruangList || []);
const uniqueRoomNames = new Set();
const uniqueRooms = [];
allRooms.forEach(r => {
if (!uniqueRoomNames.has(r.namaRuang)) {
uniqueRoomNames.add(r.namaRuang);
uniqueRooms.push(r);
}
});
result.push({
id: 'EKS',
kodeKlinik: 'EKS',
namaKlinik: 'GRAND PAVILIUN',
jenisLayanan: 'Eksekutif',
ruangList: uniqueRooms,
_originalClinics: eksekutifList
});
}
return result;
});
/**
* Watcher untuk memperbarui total entri yang difilter ketika jumlah data utama berubah (dan tidak sedang mencari).
*/
watch(() => masterStore.ruangData.length, (newLen) => {
if (!search.value) filteredTotal.value = newLen;
}, { immediate: true });
+352 -219
View File
@@ -20,16 +20,13 @@ const profileData = reactive({
registrationNumber: 'RM-2023-8812',
nim: '3573230897851332',
phone: '+62 812-3456-7890',
verified: true
verified: true,
birthDate: '10 Juni 1970',
address: 'Jalan Soekarno Hatta, no 1A, Lowokwaru, Kota Malang'
});
// Family members data
const familyMembers = reactive([
{
id: 1,
name: 'Andi Pratama',
status: 'PENDING'
},
{
id: 2,
name: 'Siti Aminah',
@@ -81,54 +78,179 @@ const modalData = reactive({
alamat: ''
});
const genderOptions = ['Laki-laki', 'Perempuan'];
const relationshipOptions = ['Orang tua', 'Suami/Istri', 'Anak', 'Saudara', 'Lainnya'];
const openModal = (member) => {
selectedMember.value = member;
// Reset form
modalData.namaLengkap = member.name;
modalData.tanggalLahir = '';
modalData.nik = '';
modalData.jenisKelamin = '';
modalData.hubungan = '';
modalData.nomorTelepon = '';
modalData.alamat = '';
isModalOpen.value = true;
/** Opsi hubungan anggota keluarga */
const relationshipOptions = [
'Anak Kandung',
'Anak Tiri',
'Anak Angkat',
'Suami',
'Istri',
'Orang Tua',
'Saudara Kandung',
'Wali',
'Kerabat',
'Lainnya'
];
// State Modal Tambah Anggota
/** Mode modal: 'add' untuk tambah baru, 'edit' untuk edit data yang ada */
const memberModalMode = ref('add');
/** Apakah modal tambah/edit anggota terbuka */
const isAddMemberModalOpen = ref(false);
/** Judul modal yang ditampilkan sesuai mode */
const memberModalTitle = computed(() => {
if (memberModalMode.value === 'editProfile') return 'Edit Profil';
if (memberModalMode.value === 'edit') return 'Edit Peserta';
return 'Tambah Peserta';
});
/** Label tombol submit modal sesuai mode */
const memberModalSubmitLabel = computed(() =>
memberModalMode.value === 'add' ? 'Tambah' : 'Simpan'
);
/** True jika tombol submit harus disabled */
const memberModalSubmitDisabled = computed(() => {
if (!searchResult.value) return true;
// Mode editProfile: cukup ada searchResult (tidak perlu hubungan)
if (memberModalMode.value === 'editProfile') return false;
// Mode tambah/edit anggota: wajib pilih hubungan
return !addMemberForm.hubungan;
});
/** Teks pencarian pasien (nomor RM atau NIK) */
const searchQuery = ref('');
/** Loading saat sedang mencari pasien */
const isSearching = ref(false);
/** Hasil pencarian / data pasien yang sedang diedit; null = belum ada data */
const searchResult = ref(null);
/** Form data tambah/edit anggota — hanya hubungan & alamat yang dapat diisi */
const addMemberForm = reactive({
hubungan: '',
alamat: ''
});
/**
* Membuka modal dalam mode TAMBAH dan mereset semua state pencarian.
*/
const openAddMemberModal = () => {
memberModalMode.value = 'add';
searchQuery.value = '';
searchResult.value = null;
addMemberForm.hubungan = '';
addMemberForm.alamat = '';
isAddMemberModalOpen.value = true;
};
const closeModal = () => {
isModalOpen.value = false;
selectedMember.value = null;
};
const handleApprove = () => {
snackbar.value = {
show: true,
message: 'Pengajuan anggota baru disetujui.',
color: 'success'
/**
* Membuka modal dalam mode EDIT PROFIL, mengisi data dari profileData.
* Section pencarian dan field Hubungan disembunyikan.
* Field Nomor Telepon & Alamat menjadi enabled.
*/
const openEditProfileModal = () => {
memberModalMode.value = 'editProfile';
searchResult.value = {
namaLengkap: profileData.name,
nomorRM: profileData.registrationNumber,
tanggalLahir: profileData.birthDate,
nik: profileData.nim,
jenisKelamin: 'Laki-laki',
// Nomor Telepon dimasukkan ke field yang dapat diedit
nomorTelepon: profileData.phone
};
closeModal();
// Alamat profil dimasukkan ke addMemberForm.alamat agar bisa diedit
addMemberForm.alamat = profileData.address;
addMemberForm.hubungan = '';
isAddMemberModalOpen.value = true;
};
const handleReject = () => {
snackbar.value = {
show: true,
message: 'Pengajuan anggota baru ditolak.',
color: 'error'
/**
* Membuka modal dalam mode EDIT, langsung mengisi data pasien.
* Section pencarian disembunyikan saat mode ini.
* @param {object} member - Data anggota yang akan diedit
*/
const openEditMemberModal = (member) => {
memberModalMode.value = 'edit';
// Isi langsung data dari member ke searchResult (sama strukturnya)
searchResult.value = {
namaLengkap: member.name,
nomorRM: member.registrationNumber ?? '-',
tanggalLahir: member.birthDate ?? '-',
nik: member.nik ?? '-',
jenisKelamin: member.jenisKelamin ?? '-',
nomorTelepon: member.phone ?? '-'
};
closeModal();
addMemberForm.hubungan = member.hubungan ?? '';
addMemberForm.alamat = member.address ?? '';
isAddMemberModalOpen.value = true;
};
/**
* Menutup modal tambah/edit anggota.
*/
const closeAddMemberModal = () => {
isAddMemberModalOpen.value = false;
};
/**
* Mensimulasikan pencarian pasien berdasarkan nomor RM atau NIK.
* Pada implementasi nyata, ini akan memanggil API backend.
*/
const handleSearchPatient = async () => {
if (!searchQuery.value.trim()) return;
isSearching.value = true;
searchResult.value = null;
// Simulasi delay network request
await new Promise(resolve => setTimeout(resolve, 800));
// Data dummy hasil pencarian pasien
searchResult.value = {
namaLengkap: 'Andi Pratama',
nomorRM: searchQuery.value,
tanggalLahir: '12 Maret 1970',
nik: '197054478954315',
jenisKelamin: 'Laki-laki',
nomorTelepon: '0812-3456-7890'
};
isSearching.value = false;
};
/**
* Menangani submit form tambah/edit anggota / edit profil.
* Pesan snackbar menyesuaikan mode yang aktif.
*/
const handleAddMember = () => {
const nama = searchResult.value?.namaLengkap;
let message = '';
if (memberModalMode.value === 'editProfile') {
// Update profileData langsung dengan nilai yang sudah diubah user
profileData.phone = searchResult.value.nomorTelepon;
profileData.address = addMemberForm.alamat;
message = 'Profil berhasil diperbarui.';
} else if (memberModalMode.value === 'edit') {
message = `Data anggota ${nama} berhasil diperbarui.`;
} else {
message = `Anggota keluarga ${nama} berhasil ditambahkan.`;
}
snackbar.value = { show: true, message, color: 'success' };
closeAddMemberModal();
};
</script>
<template>
<div>
<PageHeader
icon="mdi-shield-check"
title="Detail Akun"
:subtitle="currentDate"
:show-add-button="false"
theme="primary"
/>
<div style="background: var(--color-neutral-300);">
<PageHeader icon="mdi-shield-check" title="Detail Akun" :subtitle="currentDate" :show-add-button="false"
theme="primary" />
<v-container class="py-6">
@@ -136,54 +258,94 @@ const handleReject = () => {
<!-- Profile Card -->
<v-row class="mb-6">
<v-col cols="12">
<v-card class="pa-8" color="white" elevation="2" rounded="lg">
<v-row class="align-center" no-gutters>
<v-card class="pa-6" color="white" elevation="2" rounded="lg">
<!-- Baris 1: Avatar + Nama + Tombol Edit -->
<v-row class="align-center mb-2" no-gutters>
<!-- Avatar -->
<v-col cols="auto" class="mr-6">
<v-card height="100" width="100" class="bg-lightPrimary d-flex align-center justify-center" elevation="0" rounded="lg">
<v-icon size="50" color="primary">mdi-account-outline</v-icon>
<v-col cols="auto" class="mr-5">
<v-card height="100" width="100" class="bg-lightPrimary d-flex align-center justify-center"
elevation="0" rounded="lg">
<v-icon size="40" color="primary">mdi-account-outline</v-icon>
</v-card>
</v-col>
<!-- Nama Lengkap & NIK -->
<v-col cols="3">
<div class="text-caption text-uppercase text-grey font-weight-bold">Nama Lengkap</div>
<div class="text-h6 font-weight-bold text-primary-700 mb-4">{{ profileData.name }}</div>
<div class="text-caption text-uppercase text-grey font-weight-bold">N.I.K</div>
<div class="text-body2">{{ profileData.nim }}</div>
</v-col>
<v-col cols="max">
<!-- Baris 2: NIK | Nomor RM | Nomor Telepon -->
<v-row no-gutters class="mb-0">
<v-col cols="12" class="mb-3">
<v-row>
<!-- Nama Lengkap -->
<v-col>
<div class="text-caption text-uppercase text-grey-darken-1 font-weight-bold mb-1">
Nama Lengkap
</div>
<div class="text-h6 font-weight-bold text-primary">
{{ profileData.name }}
</div>
</v-col>
<!-- Nomor RM & Nomor Telepon -->
<v-col cols="3">
<div class="text-caption text-uppercase text-grey font-weight-bold">Nomor RM</div>
<div class="text-h6 font-weight-bold mb-4">{{ profileData.registrationNumber }}</div>
<div class="text-caption text-uppercase text-grey font-weight-bold">Nomor Telepon</div>
<div class="text-body2">{{ profileData.phone }}</div>
</v-col>
<!-- Tombol Edit Profil -->
<v-col cols="auto">
<v-btn color="primary" variant="outlined" size="small" rounded="lg"
@click="openEditProfileModal">
<v-icon start size="small">mdi-pencil</v-icon>
Edit Profil
</v-btn>
</v-col>
</v-row>
</v-col>
<v-col cols="4">
<div class="text-caption text-uppercase text-grey-darken-1 font-weight-bold mb-1">
NIK
</div>
<div class="text-body-1 font-weight-bold">{{ profileData.nim }}</div>
</v-col>
<!-- Buttons -->
<v-col cols="max" class="text-right">
<v-chip
v-if="profileData.verified"
color="success"
size="small"
class="mb-3"
>
<v-icon start size="small">mdi-check-circle</v-icon>
Terverifikasi
</v-chip>
<div>
<v-btn
color="primary"
variant="outlined"
size="small"
>
<v-icon start>mdi-pencil</v-icon>
Edit Profil
</v-btn>
</div>
<v-col cols="4">
<div class="text-caption text-uppercase text-grey-darken-1 font-weight-bold mb-1">
Nomor RM
</div>
<div class="text-body-1 font-weight-bold">{{ profileData.registrationNumber }}</div>
</v-col>
<v-col cols="4">
<div class="text-caption text-uppercase text-grey-darken-1 font-weight-bold mb-1">
Nomor Telepon
</div>
<div class="text-body-1 font-weight-bold">{{ profileData.phone }}</div>
</v-col>
</v-row>
</v-col>
</v-row>
<v-divider class="my-4" />
<!-- Baris 3: Tanggal Lahir | Nomor Telepon (No RM) | Alamat -->
<v-row no-gutters>
<v-col cols="2">
<div class="text-caption text-uppercase text-grey-darken-1 font-weight-bold mb-1">
Tanggal Lahir
</div>
<div class="text-body-2">{{ profileData.birthDate }}</div>
</v-col>
<v-col cols="3">
<div class="text-caption text-uppercase text-grey-darken-1 font-weight-bold mb-1">
Nomor Telepon
</div>
<div class="text-body-2">{{ profileData.registrationNumber }}</div>
</v-col>
<v-col cols="7">
<div class="text-caption text-uppercase text-grey-darken-1 font-weight-bold mb-1">
Alamat
</div>
<div class="text-body-2">{{ profileData.address }}</div>
</v-col>
</v-row>
</v-card>
</v-col>
</v-row>
@@ -191,9 +353,16 @@ const handleReject = () => {
<!-- Family Members Section -->
<v-row class="mb-6">
<v-col cols="12">
<h3 class="text-h6 font-weight-bold">Anggota Keluarga Terhubung</h3>
<!-- Header: judul + tombol Tambah Anggota -->
<div class="d-flex align-center justify-space-between mb-1">
<h3 class="text-h6 font-weight-bold">Anggota Keluarga Terhubung</h3>
<v-btn color="primary" variant="flat" size="small" rounded="lg" @click="openAddMemberModal">
<v-icon start size="small">mdi-account-plus</v-icon>
Tambah Anggota
</v-btn>
</div>
<p class="text-body-2 text-muted mb-4">Daftar anggota keluarga yang berada dalam satu kartu keluarga</p>
<v-card class="pa-2">
<v-table class="elevation-0">
<thead>
@@ -207,10 +376,7 @@ const handleReject = () => {
<tr v-for="member in familyMembers" :key="member.id">
<td class="text-body-2 font-weight-bold ">{{ member.name }}</td>
<td class="text-center">
<v-chip
:color="member.status === 'PENDING' ? 'secondary' : 'success'"
size="small"
>
<v-chip :color="member.status === 'PENDING' ? 'secondary' : 'success'" size="small">
<v-icon start size="small">{{
member.status === 'PENDING' ? 'mdi-clock-outline' : 'mdi-check-decagram'
}}</v-icon>
@@ -219,30 +385,12 @@ const handleReject = () => {
</td>
<td class="text-center">
<div class="d-flex justify-center gap-2">
<v-btn
v-if="member.status === 'PENDING'"
size="small"
color="primary"
@click="openModal(member)"
>
<v-icon start>mdi-clipboard-check-outline</v-icon>
Proses
</v-btn>
<div v-else>
<v-btn
size="small"
color="error"
variant="outlined"
class="mr-2"
>
<div>
<v-btn size="small" color="error" variant="outlined" class="mr-2">
<v-icon start>mdi-delete</v-icon>
Hapus
</v-btn>
<v-btn
size="small"
color="primary"
variant="outlined"
>
<v-btn size="small" color="primary" variant="outlined" @click="openEditMemberModal(member)">
<v-icon start>mdi-pencil</v-icon>
Edit
</v-btn>
@@ -260,26 +408,19 @@ const handleReject = () => {
<v-row>
<v-col cols="12">
<div class="d-flex align-center mb-6">
<v-icon class="mr-2" color="primary">mdi-history</v-icon>
<h3 class="text-h6 font-weight-bold">Riwayat Aktivitas</h3>
</div>
<v-icon class="mr-2" color="primary">mdi-history</v-icon>
<h3 class="text-h6 font-weight-bold">Riwayat Aktivitas</h3>
</div>
<v-card class="pa-6">
<v-timeline
density="compact"
side="end">
<v-timeline-item
v-for="activity in activityHistory"
:key="activity.id"
:dot-color="activity.color"
:icon="activity.icon"
fill-dot
>
<v-timeline density="compact" side="end">
<v-timeline-item v-for="activity in activityHistory" :key="activity.id" :dot-color="activity.color"
:icon="activity.icon" fill-dot>
<div class="text-subtitle-1 font-weight-bold">{{ activity.title }}</div>
<div class="text-caption text-muted">{{ activity.date }}</div>
</v-timeline-item>
</v-timeline>
</v-timeline>
<div class="text-center mt-6">
<v-btn color="primary" variant="text">
@@ -290,124 +431,116 @@ const handleReject = () => {
</v-col>
</v-row>
<!-- Verification Modal -->
<v-dialog v-model="isModalOpen" max-width="700px" persistent scrollable>
<!-- Modal Tambah Anggota Keluarga -->
<v-dialog v-model="isAddMemberModalOpen" max-width="700px" persistent scrollable>
<v-card class="pa-0">
<!-- Modal Header -->
<v-card-title class="bg-primary text-white pa-6">
<div class="d-flex justify-space-between align-center w-100">
<h2 class="text-h5 font-weight-bold">Verifikasi Pengajuan Anggota Keluarga</h2>
<v-btn icon="mdi-close" variant="text" @click="closeModal" class="text-white"></v-btn>
<h2 class="text-h5 font-weight-bold">{{ memberModalTitle }}</h2>
<v-btn icon="mdi-close" variant="text" @click="closeAddMemberModal" class="text-white"></v-btn>
</div>
</v-card-title>
<!-- Form Content - Scrollable -->
<v-card-text class="pa-6 bg-light">
<!-- Informasi Data Diri Section - Card -->
<v-card class="mb-6 pa-6" variant="flat">
<div class="d-flex align-center mb-4">
<h3 class="text-subtitle-1 font-weight-bold text-primary">INFORMASI DATA DIRI</h3>
</div>
<!-- Bagian: Data Pasien (Cari) hanya tampil saat mode tambah -->
<v-card v-if="memberModalMode === 'add'" class="mb-6 pa-6" variant="flat">
<div class="d-flex align-center mb-4">
<h3 class="text-subtitle-1 font-weight-bold text-primary">DATA PASIEN</h3>
</div>
<v-form>
<v-row>
<v-col cols="12">
<v-text-field
v-model="modalData.namaLengkap"
label="Nama Lengkap"
variant="outlined"
density="compact"
></v-text-field>
</v-col>
<v-col cols="12" sm="6">
<v-text-field
v-model="modalData.tanggalLahir"
label="Tanggal Lahir"
type="date"
variant="outlined"
density="compact"
></v-text-field>
</v-col>
<v-col cols="12" sm="6">
<v-select
v-model="modalData.jenisKelamin"
:items="genderOptions"
label="Jenis Kelamin"
variant="outlined"
density="compact"
></v-select>
</v-col>
<v-col cols="12" sm="6">
<v-text-field
v-model="modalData.nik"
label="NIK"
variant="outlined"
density="compact"
></v-text-field>
</v-col>
<v-col cols="12" sm="6">
<v-select
v-model="modalData.hubungan"
:items="relationshipOptions"
label="Hubungan"
variant="outlined"
density="compact"
></v-select>
<v-text-field v-model="searchQuery" placeholder="Cari nomor RM atau NIK" variant="outlined"
density="compact" hide-details :loading="isSearching" @keyup.enter="handleSearchPatient">
<template #prepend-inner>
<v-icon size="small" color="grey">mdi-magnify</v-icon>
</template>
<template #append-inner>
<v-btn color="primary" variant="text" size="small" icon="mdi-magnify" :loading="isSearching"
@click="handleSearchPatient" />
</template>
</v-text-field>
</v-col>
</v-row>
</v-form>
</v-card>
<!-- Kontak & Alamat Section - Card -->
<v-card class="mb-6 pa-6" variant="flat">
<div class="d-flex align-center mb-4">
<h3 class="text-subtitle-1 font-weight-bold text-primary">KONTAK & ALAMAT</h3>
</div>
<!-- Hasil Pencarian: tampil setelah ada hasil -->
<template v-if="searchResult">
<!-- Informasi Data Diri Section -->
<v-card class="mb-6 pa-6" variant="flat">
<div class="d-flex align-center mb-4">
<h3 class="text-subtitle-1 font-weight-bold text-primary">INFORMASI DATA DIRI</h3>
</div>
<v-form>
<v-row>
<v-col cols="12" sm="6">
<v-text-field v-model="searchResult.namaLengkap" label="Nama Lengkap" variant="outlined"
density="compact" disabled />
</v-col>
<v-col cols="12" sm="6">
<v-text-field v-model="searchResult.nomorRM" label="Nomor RM" variant="outlined" density="compact"
disabled />
</v-col>
<v-col cols="12" sm="6">
<v-text-field v-model="searchResult.tanggalLahir" label="Tanggal Lahir" variant="outlined"
density="compact" disabled />
</v-col>
<v-col cols="12" sm="6">
<v-text-field v-model="searchResult.nik" label="NIK" variant="outlined" density="compact"
disabled />
</v-col>
<v-col cols="12" sm="6">
<v-text-field v-model="searchResult.jenisKelamin" label="Jenis Kelamin" variant="outlined"
density="compact" disabled />
</v-col>
<!-- Hubungan: hanya tampil saat mode tambah/edit anggota -->
<v-col v-if="memberModalMode !== 'editProfile'" cols="12" sm="6">
<v-select v-model="addMemberForm.hubungan" :items="relationshipOptions" label="Hubungan"
variant="outlined" density="compact" />
</v-col>
</v-row>
</v-form>
</v-card>
<!-- Kontak & Alamat Section -->
<v-card class="mb-6 pa-6" variant="flat">
<div class="d-flex align-center mb-4">
<h3 class="text-subtitle-1 font-weight-bold text-primary">KONTAK & ALAMAT</h3>
</div>
<v-form>
<v-row>
<v-col cols="6">
<!-- Nomor Telepon: enabled saat editProfile, disabled saat mode lain -->
<v-text-field v-model="searchResult.nomorTelepon" label="Nomor Telepon" variant="outlined"
density="compact" :disabled="memberModalMode !== 'editProfile'" />
</v-col>
<v-col cols="6">
<!-- Alamat: freetext yang dapat diisi -->
<v-text-field v-model="addMemberForm.alamat" label="Alamat" variant="outlined"
density="compact" />
</v-col>
</v-row>
</v-form>
</v-card>
</template>
<v-form>
<v-row>
<v-col cols="12">
<v-text-field
v-model="modalData.nomorTelepon"
label="Nomor Telepon"
variant="outlined"
density="compact"
></v-text-field>
</v-col>
<v-col cols="12">
<v-text-field
v-model="modalData.alamat"
label="Alamat"
variant="outlined"
density="compact"
></v-text-field>
</v-col>
</v-row>
</v-form>
</v-card>
</v-card-text>
<!-- Modal Actions -->
<v-divider></v-divider>
<v-card-actions class="pa-6 justify-end">
<v-btn
color="error"
variant="outlined"
@click="handleReject"
>
<v-icon start>mdi-close</v-icon>
Tolak
</v-btn>
<v-btn
color="primary"
variant="flat"
@click="handleApprove"
>
<v-icon start>mdi-check</v-icon>
Setujui
<v-btn color="primary" variant="flat" :disabled="memberModalSubmitDisabled" @click="handleAddMember">
<v-icon start>mdi-check-circle</v-icon>
{{ memberModalSubmitLabel }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
+2 -7
View File
@@ -102,6 +102,7 @@
Verifikasi
</v-btn>
<v-btn
v-if="item.status !== 'Belum Terverifikasi'"
class="my-2"
color="primary"
size="small"
@@ -114,11 +115,6 @@
</template>
<template v-slot:item.pending="{ item }">
<v-chip size="small" class="chip-orange">
{{ item.pending || 2 }}
</v-chip>
</template>
</v-data-table>
</v-card-text>
</v-card>
@@ -179,10 +175,9 @@ const headers = ref([
{ title: 'No', value: 'no', sortable: false, width: '60px', align: 'center' },
{ title: 'Nama Pasien', value: 'nama', sortable: true, align: 'center' },
{ title: 'No. RM', value: 'rm', sortable: true, align: 'center' },
{ title: 'Alamat', value: 'alamat', sortable: false, align: 'left' },
{ title: 'Alamat', value: 'alamat', sortable: false, align: 'left', width: '400px' },
{ title: 'No. Telepon', value: 'telepon', sortable: false, align: 'center' },
{ title: 'Status', value: 'status', sortable: true, width: '180px', align: 'center' },
{ title: 'Pending', value: 'pending', sortable: false, width: '120px', align: 'center' },
{ title: 'Actions', value: 'actions', sortable: false, width: '200px', align: 'center' },
]);
+95
View File
@@ -0,0 +1,95 @@
import { defineNuxtPlugin } from '#app';
import { usePermissionStore } from '~/stores/permissionStore';
import type { HakAksesMenu } from '~/server/utils/schemas/permissionSchema';
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.directive('permission', {
mounted(el, binding) {
const permissionStore = usePermissionStore();
// Determine what action to check based on modifiers or value.
// If it's an array: v-permission="['canEdit', 'canDelete']"
// If it's a string: v-permission="'canEdit'"
const permissionsRequired = Array.isArray(binding.value) ? binding.value : [binding.value];
// Determine the menuKey context.
// In a real app, you might inject this context from the page component.
// For now, we extract it from the current route name.
const currentRoute = useRoute();
let menuKey = "";
if (currentRoute && currentRoute.name) {
// Convert vue-router route name to our menuKey format
menuKey = currentRoute.name.toString().toLowerCase().replace(/_|-/g, '-');
}
let hasPermission = true;
for (const action of permissionsRequired) {
if (!permissionStore.can(menuKey, action as keyof HakAksesMenu)) {
hasPermission = false;
break;
}
}
if (!hasPermission) {
if (binding.arg === 'disable') {
el.disabled = true;
el.classList.add('v-btn--disabled'); // If it's vuetify button
el.setAttribute('title', 'Anda tidak memiliki izin untuk aksi ini');
el.style.opacity = '0.5';
el.style.pointerEvents = 'none';
} else {
// Default: hide
el.style.display = 'none';
// Better hide by removing from DOM if possible,
// but display: none is safer for custom directives that don't want to break layout engines
// To truly remove: el.parentNode?.removeChild(el) (but breaks if Vue tries to update it)
}
}
},
updated(el, binding) {
// Handle reactivity if permissions change or route changes
const permissionStore = usePermissionStore();
const permissionsRequired = Array.isArray(binding.value) ? binding.value : [binding.value];
const currentRoute = useRoute();
let menuKey = "";
if (currentRoute && currentRoute.name) {
menuKey = currentRoute.name.toString().toLowerCase().replace(/_|-/g, '-');
}
let hasPermission = true;
for (const action of permissionsRequired) {
if (!permissionStore.can(menuKey, action as keyof HakAksesMenu)) {
hasPermission = false;
break;
}
}
if (!hasPermission) {
if (binding.arg === 'disable') {
el.disabled = true;
el.classList.add('v-btn--disabled');
el.setAttribute('title', 'Anda tidak memiliki izin untuk aksi ini');
el.style.opacity = '0.5';
el.style.pointerEvents = 'none';
} else {
el.style.display = 'none';
}
} else {
// Restore if permission was granted
if (binding.arg === 'disable') {
el.disabled = false;
el.classList.remove('v-btn--disabled');
el.removeAttribute('title');
el.style.opacity = '1';
el.style.pointerEvents = 'auto';
} else {
el.style.display = ''; // Revert to original display
}
}
}
});
});
+54
View File
@@ -0,0 +1,54 @@
import re
import sys
path = r'e:\antrean operasi\web-antrean\stores\queueStore.ts'
try:
with open(path, 'r', encoding='utf-8') as f:
content = f.read()
# 1. Add import for useQueueSync at the top
if 'useQueueSync' not in content:
content = content.replace("import { useQueueAPI } from '@/composables/useQueueAPI';", "import { useQueueAPI } from '@/composables/useQueueAPI';\nimport { useQueueSync } from '@/composables/useQueueSync';")
# 2. Remove the WEBSOCKET INTEGRATION block
ws_pattern = re.compile(r' // WEBSOCKET INTEGRATION \(CENTRALIZED\).*? const disconnectWebSocket = \(\) => \{\n disconnect\(\);\n \};', re.DOTALL)
content = ws_pattern.sub(' // WS and AutoSync logic extracted to composables/useQueueSync.ts', content)
# 3. Add the initialization right before return
init_code = """
// Initialize Queue Sync
const queueSync = useQueueSync({
allPatients,
currentProcessingPatient,
activeLoketInterest,
activeClinicInterest,
globalInterestCount,
fetchPatientsForLoket,
fetchPatientsForClinic,
fetchAllPatients
});
const {
isWsConnected,
wsClientId,
lastGlobalCall,
lastKlinikCall,
initWebSocket,
disconnectWebSocket,
sendViaPost,
startAutoSync,
stopAutoSync
} = queueSync;
return {
"""
content = content.replace(" return {\n // State\n allPatients,", init_code + " // State\n allPatients,")
with open(path, 'w', encoding='utf-8') as f:
f.write(content)
print("Success")
except Exception as e:
print("Error:", e)
sys.exit(1)
+23
View File
@@ -0,0 +1,23 @@
// server/api/hak-akses/check.ts
export default defineEventHandler(async (event) => {
// This is a mock endpoint to simulate backend checking permission.
// In production, the backend will validate the user's token directly.
const method = event.method;
if (method === 'POST') {
const body = await readBody(event);
const { menuKey, action } = body;
// Simulating some random failure or checking logic
if (!menuKey || !action) {
return createError({ statusCode: 400, statusMessage: 'Bad Request: menuKey and action required' });
}
// Just simulating success for now.
return {
success: true,
message: `Mock check: User has ${action} permission for ${menuKey}`,
allowed: true
}
}
return createError({ statusCode: 405, statusMessage: 'Method Not Allowed' });
});
+30 -14
View File
@@ -1,12 +1,12 @@
import fs from 'fs';
import path from 'path';
import type { HakAkses } from '~/types/setting';
import { randomUUID } from 'node:crypto'; // Use standard node crypto
import { hakAksesPayloadSchema, type HakAksesPayload } from '~/server/utils/schemas/permissionSchema';
const filePath = path.resolve('data/mock/hakAkses.json');
// Helper to read JSON file
const readData = (): HakAkses[] => {
const readData = (): HakAksesPayload[] => {
try {
if (!fs.existsSync(filePath)) {
// Ensure directory exists
@@ -26,7 +26,7 @@ const readData = (): HakAkses[] => {
};
// Helper to write JSON file
const writeData = (data: HakAkses[]): boolean => {
const writeData = (data: HakAksesPayload[]): boolean => {
try {
fs.writeFileSync(filePath, JSON.stringify(data, null, 4), 'utf-8');
return true;
@@ -51,7 +51,20 @@ export default defineEventHandler(async (event) => {
// POST - Create or Update hak akses
if (method === 'POST') {
try {
const body = await readBody(event);
const rawBody = await readBody(event);
// Validate payload with Zod Schema (Contract testing)
const validationResult = hakAksesPayloadSchema.safeParse(rawBody);
if (!validationResult.success) {
return {
success: false,
message: 'Payload invalid: Gagal menyimpan hak akses',
error: validationResult.error.format()
};
}
const body = validationResult.data;
const data = readData();
if (body.id) {
@@ -63,18 +76,21 @@ export default defineEventHandler(async (event) => {
...body
};
} else {
data.push(body);
data.push(body as HakAksesPayload);
}
} else {
// Create new
const newId = randomUUID();
const newHakAkses: HakAkses = {
id: newId,
namaHakAkses: body.namaHakAkses,
status: body.status || 'aktif',
pages: body.pages || []
};
data.push(newHakAkses);
body.id = randomUUID();
// For backward compatibility with HakAkses.vue UI that expects 'namaHakAkses'
if (!body.namaHakAkses) {
body.namaHakAkses = body.role || body.group || body.namaTipeUser || "Unknown";
}
if (!body.status) {
body.status = 'aktif';
}
data.push(body as HakAksesPayload);
}
const success = writeData(data);
@@ -91,7 +107,7 @@ export default defineEventHandler(async (event) => {
} catch (error) {
return {
success: false,
message: 'Gagal menyimpan hak akses',
message: 'Gagal memproses request',
error: error instanceof Error ? error.message : 'Unknown error'
};
}
+35
View File
@@ -0,0 +1,35 @@
/**
* Dummy API Endpoint untuk mencari data pasien dari SIMRS
*
* Endpoint ini merespons request GET dengan ID pasien.
* Digunakan sebagai simulasi (mock) sebelum API Service SIMRS yang asli tersedia.
*
* @param event - Objek event request dari Nitro H3
* @returns Object data pasien jika ID cocok, atau throw 404 Error jika tidak ditemukan
*/
export default defineEventHandler(async (event) => {
const id = event.context.params?.id;
// Simulasi delay jaringan (biar ada efek loading di UI)
await new Promise((resolve) => setTimeout(resolve, 800));
// Data dummy (nanti ini akan didapatkan dari database SIMRS asli)
if (id === '123456789') {
return {
success: true,
data: {
nama: 'Andi Pratama',
ttl: 'Surabaya, 15 Agustus 1990',
jenisKelamin: 'Laki-laki',
noTelepon: '081234567890',
alamat: 'Jl. Merdeka No. 45, Klojen, Malang'
}
};
}
// Jika pasien tidak ditemukan
throw createError({
statusCode: 404,
statusMessage: `Pasien dengan nomor ${id} tidak ditemukan.`,
});
});
+1
View File
@@ -29,6 +29,7 @@ const SEED_ANJUNGAN = [
const SEED_ANTREAN_MASUK_SCREENS = [
{ namaScreen: 'Layar Antrean Masuk 1', nomorScreen: 'AM-001', loket: JSON.stringify([1, 2, 12, 14]) },
{ namaScreen: 'Layar Antrean Masuk 2', nomorScreen: 'AM-002', loket: JSON.stringify([3, 4]) },
{ namaScreen: 'Layar Antrean Masuk Eksekutif', nomorScreen: 'AM-EKS', loket: JSON.stringify([1000]) },
];
const SEED_KLINIK_RUANG = [
+25
View File
@@ -0,0 +1,25 @@
import { z } from 'zod';
export const hakAksesMenuSchema = z.object({
menuKey: z.string().min(1, "menuKey is required"),
name: z.string().min(1, "name is required"),
canAccess: z.boolean().default(false),
canView: z.boolean().default(false),
canAdd: z.boolean().default(false),
canEdit: z.boolean().default(false),
canDelete: z.boolean().default(false),
});
export const hakAksesPayloadSchema = z.object({
id: z.string().optional(),
role: z.string().optional(),
group: z.string().optional(),
namaTipeUser: z.string().optional(),
isGroupBased: z.boolean().default(true),
status: z.string().optional(), // Tambahan dari mock lama
namaHakAkses: z.string().optional(), // Tambahan dari mock lama
hakAksesMenu: z.array(hakAksesMenuSchema).default([]),
});
export type HakAksesMenu = z.infer<typeof hakAksesMenuSchema>;
export type HakAksesPayload = z.infer<typeof hakAksesPayloadSchema>;
+14 -1
View File
@@ -94,6 +94,14 @@ export const useDoctorStore = defineStore('doctor', () => {
break; // Success!
} catch (err) {
lastError = err;
const status = err.response ? err.response.status : (err.status || 'unknown');
// Do not retry for 500 (Internal Server Error) or 404 (Not Found)
// as these are likely permanent failures for this specific clinic
if (status === 500 || status === 404) {
break;
}
if (i < retries) {
const delay = 500 * (i + 1);
console.warn(`⚠️ [doctorStore] Retry ${i+1}/${retries} for klinik ${idklinik} after ${delay}ms...`);
@@ -130,7 +138,12 @@ export const useDoctorStore = defineStore('doctor', () => {
return doctorNames;
} catch (error) {
const status = error.response ? error.response.status : (error.status || 'unknown');
console.error(`❌ [doctorStore] Gagal mengambil dokter untuk klinik ID ${idklinik} (${clinic.name}) - Status: ${status}`);
if (status === 500 || status === 404) {
console.warn(`⚠️ [doctorStore] Data dokter kosong/tidak ditemukan untuk klinik ID ${idklinik} (${clinic.name}) - Status: ${status}`);
} else {
console.error(`❌ [doctorStore] Gagal mengambil dokter untuk klinik ID ${idklinik} (${clinic.name}) - Status: ${status}`);
}
// Track failure for blacklisting
if (!failedClinics.value[idklinik]) {
+15 -29
View File
@@ -79,35 +79,21 @@ export const useLoketStore = defineStore('loket', () => {
const apiLoketData = ref([])
// Local Data (EKSEKUTIF) - ID 1000+ manual input
// DI-persist ke localStorage. Default 14 loket hardcoded.
const localLoketData = ref(Array.from({ length: 14 }, (_, i) => {
// Distribute services securely
// Loket 1-5: Single service
// Loket 6-10: Dual services
// Loket 11+: Single service
let services = [];
if (i < 5) {
services = [1000 + i];
} else if (i < 10) {
services = [1000 + i, 1005 + i];
} else {
services = [1000 + i];
}
return {
id: 1000 + i,
no: i + 1,
namaLoket: `LOKET ${i + 1} EKS`,
kuota: 500,
pelayanan: services,
pembayaran: ['EKSEKUTIF'], // Changed to array for consistency
tipeLoket: 'EKSEKUTIF', // Add tipeLoket field for consistency
keterangan: 'ONLINE',
statusPelayanan: 'RAWAT JALAN',
source: 'local',
loketAktif: true
};
}))
// DI-persist ke localStorage. Default 1 loket melayani semua EKS.
const localLoketData = ref([{
id: 1000,
no: 1,
namaLoket: `LOKET EKS`,
kuota: 500,
// Provide all EKS clinic IDs (1000-1022) so it serves all
pelayanan: Array.from({ length: 23 }, (_, i) => 1000 + i),
pembayaran: ['EKSEKUTIF'],
tipeLoket: 'EKSEKUTIF',
keterangan: 'ONLINE',
statusPelayanan: 'RAWAT JALAN',
source: 'local',
loketAktif: true
}])
// Merged data (computed) - Gabungan API + Local
const loketData = computed(() => {
+38 -31
View File
@@ -2,33 +2,35 @@
import { defineStore } from 'pinia';
import { useLocalStorage } from '@vueuse/core';
import { computed, ref } from 'vue'; // Import computed dari Vue
import { useHakAkses } from '~/composables/useHakAkses';
import { usePermissionStore } from '~/stores/permissionStore';
interface NavItem {
id: number;
name: string; // Menggantikan 'title'
path: string; // Menggantikan 'to'
icon: string;
menuKey?: string; // New field for permission checking
children?: NavItem[];
badge?: string; // Tambahkan properti badge
}
// Initial default navigation items
const defaultNavItems: NavItem[] = [
{ id: 1, name: "Dashboard", icon: "mdi-view-dashboard", path: "/dashboard" },
{ id: 2, name: "Verifikasi Akun", icon: "mdi-account-check-outline", path:"/verifikasiAkun/VerifikasiAkun" },
export const defaultNavItems: NavItem[] = [
{ id: 1, name: "Dashboard", icon: "mdi-view-dashboard", path: "/dashboard", menuKey: "dashboard" },
{ id: 2, name: "Verifikasi Akun", icon: "mdi-account-check-outline", path:"/verifikasiAkun/VerifikasiAkun", menuKey: "verifikasiakun-verifikasiakun" },
{
id: 3,
name: "Check In",
icon: "mdi-file-document-edit-outline",
path: "/CheckInPasien/checkIn"
path: "/CheckInPasien/checkIn",
menuKey: "checkinpasien-checkin"
// badge: "3",
},
{ id: 4, name: "Admin Loket", icon: "mdi-account-supervisor-outline", path: "/AdminLoket" },
{ id: 6, name: "Admin Klinik Ruang", icon: "mdi-door-open", path: "/AdminKlinikRuang" },
{ id: 4, name: "Admin Loket", icon: "mdi-account-supervisor-outline", path: "/AdminLoket", menuKey: "adminloket" },
{ id: 6, name: "Admin Klinik Ruang", icon: "mdi-door-open", path: "/AdminKlinikRuang", menuKey: "adminklinikruang" },
// { id: 7, name: "Admin Penunjang", icon: "mdi-plus-box-outline", path: "/AdminPenunjang" },
// { id: 8, name: "Buat Antrean", icon: "mdi-account-multiple-plus-outline", path: "/BuatAntrean" },
{ id: 9, name: "Monitoring Pasien", icon: "mdi-account-group-outline", path: "/MonitoringPasien/monitoringPasien" },
{ id: 9, name: "Monitoring Pasien", icon: "mdi-account-group-outline", path: "/MonitoringPasien/monitoringPasien", menuKey: "monitoringpasien-monitoringpasien" },
{
id: 10,
name: "Layar Informasi",
@@ -36,13 +38,12 @@ const defaultNavItems: NavItem[] = [
path: "",
children: [
// { id: 10, name: "Anjungan", path: "/Anjungan/Anjungan", icon: "mdi-circle-small" },
{ id: 11, name: "Anjungan", path: "/anjungan/anjungancopy", icon: "mdi-circle-small" },
{ id: 11, name: "Anjungan", path: "/anjungan/anjungancopy", icon: "mdi-circle-small", menuKey: "anjungan-anjungancopy" },
// { id: 11, name: "Klinik", path: "/Anjungan/AntrianKlinik", icon: "mdi-circle-small" },
{ id: 12, name: "Klinik Ruang", path: "/Anjungan/AntrianKlinikRuang", icon: "mdi-circle-small"},
{ id: 12, name: "Klinik Ruang", path: "/Anjungan/AntrianKlinikRuang", icon: "mdi-circle-small", menuKey: "anjungan-antrianklinikruang"},
// { id: 13, name: "Penunjang", path: "/Anjungan/AntrianPenunjang", icon: "mdi-circle-small"},
{id: 14, name: "Loket", path: "/Anjungan/AntrianLoket", icon: "mdi-circle-small"},
{id: 15, name: "Antrean Masuk", path: "/Anjungan/AntreanMasuk", icon: "mdi-circle-small"},
{id: 14, name: "Loket", path: "/Anjungan/AntrianLoket", icon: "mdi-circle-small", menuKey: "anjungan-antrianloket"},
{id: 15, name: "Antrean Masuk", path: "/Anjungan/AntreanMasuk", icon: "mdi-circle-small", menuKey: "anjungan-antreanmasuk"},
],
},
{
@@ -51,20 +52,20 @@ const defaultNavItems: NavItem[] = [
icon: "mdi-cog-outline",
path: "",
children: [
{ id: 16, name: "Hak Akses", path: "/Setting/HakAkses", icon: "mdi-circle-small" },
{ id: 17, name: "User Login", path: "/Setting/UserLogin", icon: "mdi-circle-small" },
{ id: 18, name: "Master Anjungan", path: "/Setting/MasterAnjungan", icon: "mdi-circle-small" },
{ id: 19, name: "Master Loket", path: "/Setting/MasterLoket", icon: "mdi-circle-small" },
{ id: 20, name: "Master Klinik", path: "/Setting/MasterKlinik", icon: "mdi-circle-small" },
{ id: 21, name: "Master Klinik Ruang", path: "/Setting/MasterKlinikRuang", icon: "mdi-circle-small" },
{ id: 16, name: "Hak Akses", path: "/Setting/HakAkses", icon: "mdi-circle-small", menuKey: "setting-hakakses" },
{ id: 17, name: "User Login", path: "/Setting/UserLogin", icon: "mdi-circle-small", menuKey: "setting-userlogin" },
{ id: 18, name: "Master Anjungan", path: "/Setting/MasterAnjungan", icon: "mdi-circle-small", menuKey: "setting-masteranjungan" },
{ id: 19, name: "Master Loket", path: "/Setting/MasterLoket", icon: "mdi-circle-small", menuKey: "setting-masterloket" },
{ id: 20, name: "Master Klinik", path: "/Setting/MasterKlinik", icon: "mdi-circle-small", menuKey: "setting-masterklinik" },
{ id: 21, name: "Master Klinik Ruang", path: "/Setting/MasterKlinikRuang", icon: "mdi-circle-small", menuKey: "setting-masterklinikruang" },
// { id: 22, name: "Master Penunjang", path: "/Setting/MasterPenunjang", icon: "mdi-circle-small" },
// { id: 23, name: "Screen", path: "/Setting/Screen", icon: "mdi-circle-small" },
{ id: 24, name: "Screen Antrean Masuk", path: "/Setting/ScreenAntreanMasuk", icon: "mdi-circle-small" },
{ id: 24, name: "Screen Antrean Masuk", path: "/Setting/ScreenAntreanMasuk", icon: "mdi-circle-small", menuKey: "setting-screenantreanmasuk" },
],
},
];
const STORAGE_VERSION = '1.3'; // Increment this if you change structure or default paths
const STORAGE_VERSION = '1.5'; // Increment this if you change structure or default paths
export const useNavItemsStore = defineStore('navItems', () => {
const storedVersion = useLocalStorage('navItems_version', '0');
@@ -101,17 +102,16 @@ export const useNavItemsStore = defineStore('navItems', () => {
}
// Filtered navigation items based on hakAkses
const filteredNavItems = ref<NavItem[]>(defaultNavItems);
const filteredNavItems = ref<NavItem[]>([]);
async function refreshNavItems() {
const { getAllowedPages } = useHakAkses();
const allowedPages = await getAllowedPages();
const permissionStore = usePermissionStore();
if (allowedPages.length === 0) {
// If no hak akses defined (maybe new system not setup yet),
// keep default or clear? Let's keep for now for safety during transition
filteredNavItems.value = defaultNavItems;
return;
// Wait for permissions to be loaded if they aren't
if (!permissionStore.isLoaded) {
// We assume middleware already triggered load, if not, wait
// Typically, refreshNavItems is called after checkAuth, which doesn't auto-load now
// But checkPageAccess does. Let's just use what's in store.
}
const filterItems = (items: NavItem[]): NavItem[] => {
@@ -126,8 +126,15 @@ export const useNavItemsStore = defineStore('navItems', () => {
return false;
}
// If it's a child or leaf, check if path is in allowedPages
return allowedPages.includes(item.path);
// If it's a child or leaf, check permissionStore
if (item.menuKey) {
if (item.menuKey === 'dashboard') return true;
return permissionStore.can(item.menuKey, 'canAccess');
}
// If no menuKey configured, fallback to previous allow/deny logic or true
// Safe bet is to hide if not mapped, but we mapped everything in defaultNavItems.
return false;
});
};
+112 -20
View File
@@ -1,24 +1,116 @@
// /stores/permissionStore.ts
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import type { HakAksesPayload, HakAksesMenu } from "~/server/utils/schemas/permissionSchema";
export const usePermissionStore = defineStore("permission", {
state: () => ({
data: null as any,
}),
actions: {
async load(path: string) {
const parts = path.split("/").filter(Boolean);
const group = parts[1] || "";
const role = parts.at(-1)?.toLowerCase() || "";
export const usePermissionStore = defineStore("permission", () => {
const permissions = ref<HakAksesMenu[]>([]);
const isLoaded = ref(false);
const isError = ref(false);
/**
* Load permissions for the current user's role and group
*/
const load = async (role: string, group: string = "", username: string = "") => {
try {
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 } = await useFetch(url);
this.data = data.value;
},
can(action: string) {
return this.data?.[action] === true;
},
},
const useMock = config.public.useMockPermissionApi;
let url = "";
if (useMock) {
url = `/api/hak-akses`; // Mock endpoint (returns all config)
} else {
const apiBase = config.public.verificationApiBaseUrl || 'http://10.10.123.140:8089/api/v1';
// Ensure valid origin parsing
const origin = apiBase.startsWith('http') ? new URL(apiBase).origin : 'http://10.10.123.140:8089';
// In a real API, passing username might be needed if they support user-level overrides
url = `${origin}/api/v1/permission?roles=${role}&groups=${group}&username=${username}`;
}
const { data, error } = await useFetch<any>(url);
if (error.value) {
throw new Error(error.value.message || "Failed to fetch permissions");
}
isError.value = false;
// Handle Mock vs Real API response differences
if (useMock && data.value?.success) {
// Mock returns all permission configs. Find the matching one based on precedence:
const allConfigs = data.value.data as HakAksesPayload[];
// Precedence 0: Exact User Match (Specific Override)
let matchedConfig = allConfigs.find(c => c.namaTipeUser?.toLowerCase() === username.toLowerCase() || c.namaHakAkses?.toLowerCase() === username.toLowerCase());
// Precedence 1: Exact Role Match
if (!matchedConfig && role) {
matchedConfig = allConfigs.find(c => c.role?.toLowerCase() === role.toLowerCase() && !c.isGroupBased);
}
// Precedence 2: Group Match
if (!matchedConfig && group) {
matchedConfig = allConfigs.find(c => c.isGroupBased && c.group?.toLowerCase() === group.toLowerCase());
}
// If found, use it, else empty (Deny-by-default)
permissions.value = matchedConfig?.hakAksesMenu || [];
} else if (!useMock && data.value?.data) {
// Real API returns { message: "...", data: [ { id, create, read, update, delete, pagename } ] }
// We need to map Real API schema back to HakAksesMenu schema (contract mapping)
const realApiData = Array.isArray(data.value.data) ? data.value.data : [];
permissions.value = realApiData.map((item: any) => ({
menuKey: item.pagename?.toLowerCase().replace(/\s+/g, '-') || "", // Best effort mapping if API doesn't send menuKey
name: item.pagename || "Unknown",
canAccess: item.read === true,
canView: item.read === true,
canAdd: item.create === true,
canEdit: item.update === true,
canDelete: item.delete === true
}));
} else {
// Unrecognized format -> Deny-by-default
permissions.value = [];
}
isLoaded.value = true;
} catch (err) {
console.error("[permissionStore] Failed to load permissions:", err);
// Deny-by-default on fetch error
permissions.value = [];
isError.value = true;
isLoaded.value = true;
}
};
/**
* Check if a specific menuKey has a specific permission action.
* If the fetch failed or config is missing, it returns false (Deny-by-default).
*/
const can = (menuKey: string, action: keyof HakAksesMenu = 'canAccess'): boolean => {
// If we haven't loaded yet or there was an error, deny everything
if (!isLoaded.value || isError.value) return false;
// // TODO(security): This check is purely for UX.
// // True access control must be validated on the backend!
// Find the menu configuration
const menuConfig = permissions.value.find(
(m) => m.menuKey.toLowerCase() === menuKey.toLowerCase()
);
if (!menuConfig) return false; // Not configured -> deny
return menuConfig[action] === true;
};
/**
* Reset the store (e.g. on logout)
*/
const clear = () => {
permissions.value = [];
isLoaded.value = false;
isError.value = false;
};
return { permissions, isLoaded, isError, load, can, clear };
});
+173 -381
View File
@@ -2,43 +2,48 @@
import { defineStore } from 'pinia';
import { ref, computed, watch } from 'vue';
import { useClinicStore } from './clinicStore';
import { usePenunjangStore } from './penunjangStore';
import { useLoketStore } from './loketStore';
import { usePenunjangStore } from './penunjangStore';
import { useWebSocket } from '@/composables/useWebSocket';
import { useQueueAPI } from '@/composables/useQueueAPI';
import { useQueueSync } from '@/composables/useQueueSync';
import type { QueuePatient } from '@/types/queue';
export const useQueueStore = defineStore('queue', () => {
const config = useRuntimeConfig();
const clinicStore = useClinicStore();
const penunjangStore = usePenunjangStore();
const loketStore = useLoketStore();
const queueAPI = useQueueAPI();
// ============================================
// API INTEGRATION FOR LOKET PATIENTS
// ============================================
// State untuk API patient data per loket
const allPatients = ref([]);
const apiPatientsPerLoket = ref({});
const isLoadingPatients = ref(false);
const apiPatientsError = ref(null);
const quotaUsed = ref(5);
const currentProcessingPatient = ref({});
const allPatients = ref<QueuePatient[]>([]);
const apiPatientsPerLoket = ref<Record<string, QueuePatient[]>>({});
const isLoadingPatients = ref<boolean>(false);
const apiPatientsError = ref<string | null>(null);
const quotaUsed = ref<number>(5);
const currentProcessingPatient = ref<Record<string, QueuePatient>>({});
const lastUpdated = ref(Date.now());
const lastFetchTime = ref({});
const lastGlobalFetchTime = ref(0); // Cooldown for bulk refreshes
const lastUpdated = ref<number>(Date.now());
const lastFetchTime = ref<Record<string, number>>({});
const lastGlobalFetchTime = ref<number>(0); // Cooldown for bulk refreshes
// Scoped Refresh Logic: track which lokets are currently being viewed
const activeLoketInterest = ref({}); // { [loketId]: count }
const activeClinicInterest = ref({}); // { [kodeKlinik]: count }
const globalInterestCount = ref(0); // Tracks pages that need ALL loket data (e.g. CheckInPasien)
const activeLoketInterest = ref<Record<string, number>>({}); // { [loketId]: count }
const activeClinicInterest = ref<Record<string, number>>({}); // { [kodeKlinik]: count }
const globalInterestCount = ref<number>(0); // Tracks pages that need ALL loket data (e.g. CheckInPasien)
const registerInterest = (loketId) => {
const registerInterest = (loketId: string | number) => {
if (!loketId) return;
const id = String(loketId);
activeLoketInterest.value[id] = (activeLoketInterest.value[id] || 0) + 1;
};
const unregisterInterest = (loketId) => {
const unregisterInterest = (loketId: string | number) => {
if (!loketId) return;
const id = String(loketId);
if (activeLoketInterest.value[id]) {
@@ -49,13 +54,13 @@ export const useQueueStore = defineStore('queue', () => {
}
};
const registerClinicInterest = (kodeKlinik) => {
const registerClinicInterest = (kodeKlinik: string) => {
if (!kodeKlinik) return;
const code = String(kodeKlinik);
activeClinicInterest.value[code] = (activeClinicInterest.value[code] || 0) + 1;
};
const unregisterClinicInterest = (kodeKlinik) => {
const unregisterClinicInterest = (kodeKlinik: string) => {
if (!kodeKlinik) return;
const code = String(kodeKlinik);
if (activeClinicInterest.value[code]) {
@@ -74,7 +79,7 @@ export const useQueueStore = defineStore('queue', () => {
globalInterestCount.value = Math.max(0, globalInterestCount.value - 1);
};
const fetchPatientsForClinic = async (kodeKlinik, force = false) => {
const fetchPatientsForClinic = async (kodeKlinik: string, force: boolean = false) => {
if (!kodeKlinik) return { success: false, message: 'Kode Klinik diperlukan' };
isLoadingPatients.value = true;
@@ -86,7 +91,7 @@ export const useQueueStore = defineStore('queue', () => {
const timeSinceLastFetch = now - lastFetch;
if (!force && timeSinceLastFetch < 2000) {
console.log(`⏭️ [queueStore] Skipping fetch for clinic ${kodeKlinik} (last fetched ${Math.round(timeSinceLastFetch/1000)}s ago)`);
// console.log(`⏭️ [queueStore] Skipping fetch for clinic ${kodeKlinik} (last fetched ${Math.round(timeSinceLastFetch/1000)}s ago)`);
isLoadingPatients.value = false;
return { success: true, message: 'Using cache' };
}
@@ -100,14 +105,9 @@ export const useQueueStore = defineStore('queue', () => {
throw new Error(`Klinik ID tidak ditemukan untuk kode: ${kodeKlinik}`);
}
const url = `${config.public.externalApiBaseUrl}/visit?klinik_id=${clinic.id}&limit=500`;
console.log(`🔄 [queueStore] Fetching patients for clinic ${kodeKlinik} (ID: ${clinic.id})...`);
// console.log(`🔄 [queueStore] Fetching patients for clinic ${kodeKlinik} (ID: ${clinic.id})...`);
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const rawResponse = await response.json();
const data = rawResponse?.data || [];
const data = await queueAPI.fetchRawClinicPatients(clinic.id);
const mappedClinicPatients = [];
data.forEach((visit, index) => {
@@ -270,7 +270,7 @@ export const useQueueStore = defineStore('queue', () => {
syncCountersWithState();
return { success: true, message: `${mappedClinicPatients.length} pasien dimuat` };
} catch (error) {
} catch (error: any) {
console.error(`❌ [queueStore] Error fetching clinic patients (${kodeKlinik}):`, error);
apiPatientsError.value = error.message;
return { success: false, message: error.message };
@@ -280,251 +280,6 @@ export const useQueueStore = defineStore('queue', () => {
};
// ============================================
// WEBSOCKET INTEGRATION (CENTRALIZED)
// ============================================
// ============================================
// WEBSOCKET INTEGRATION (CENTRALIZED)
// ============================================
const isWsConnected = ref(false);
const wsClientId = ref(`client-${Math.random().toString(36).substring(7)}`);
const lastGlobalCall = ref(null);
const lastKlinikCall = ref(null);
const onWsMessage = (data) => {
// 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(`🔄 [queueStore] Received refresh trigger for clinic ${messageData.klinikId}`);
// Handle current processing update if provided
if (messageData.currentProcessingUpdate) {
console.log(`🎯 [queueStore] Applying current processing update:`, messageData.currentProcessingUpdate);
// Merge specifically for the keys that exist in the update
Object.keys(messageData.currentProcessingUpdate).forEach(key => {
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 = allPatients.value.findIndex(p => p.no === processingPatient.no);
if (idx !== -1) {
allPatients.value[idx] = { ...allPatients.value[idx], status: 'di-loket' };
}
}
});
}
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('🏥 [queueStore] 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
// Match by barcode (string) or target antrian number (part before |)
const idx = 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) {
// Create a patched object to ensure reactivity
const updatedPatient = {
...allPatients.value[idx],
tipeLayanan: ev.tipeLayanan,
lastCalledAt: ev.lastCalledAt || new Date().toISOString(),
lastCalledTipeLayanan: ev.tipeLayanan,
status: 'di-loket',
calledPemeriksaanAwal: ev.tipeLayanan === 'Pemeriksaan Awal' ? true : allPatients.value[idx].calledPemeriksaanAwal,
calledTindakan: ev.tipeLayanan === 'Tindakan' ? true : allPatients.value[idx].calledTindakan
};
allPatients.value[idx] = updatedPatient;
console.log(`✅ [queueStore] Successfully patched patient ${ev.noantrian} status to di-loket (lastCalledAt: ${updatedPatient.lastCalledAt})`);
} else {
console.warn(`⚠️ [queueStore] Patient ${ev.noantrian} not found in store for clinic ${ev.kodeKlinik}.`);
console.log('🧪 [queueStore] Available klinik-ruang patients in store:',
allPatients.value
.filter(p => p.processStage === 'klinik-ruang')
.map(p => `[${p.kodeKlinik}] ${p.noAntrian?.split(' |')[0]} / ${p.barcode}`)
);
}
}
// TRIGGER STRATEGIC REFRESHES
let refreshedSomething = false;
if (targetLoketId) {
fetchPatientsForLoket(targetLoketId, true);
refreshedSomething = true;
}
if (targetKlinikId) {
const interestingClinics = Object.keys(activeClinicInterest.value);
if (interestingClinics.includes(String(targetKlinikId)) || targetKlinikId === 'broadcast') {
const clinicToFetch = targetKlinikId === 'broadcast' ? interestingClinics[0] : targetKlinikId;
fetchPatientsForClinic(clinicToFetch, true);
refreshedSomething = true;
}
}
if (globalInterestCount.value > 0) {
fetchAllPatients();
refreshedSomething = true;
}
// ALWAYS refresh our own active interests when a WebSocket message is received,
// because shared lists (e.g. unassigned patients in 'menunggu') might have changed.
const interestingLokets = Object.keys(activeLoketInterest.value);
const interestingClinics = Object.keys(activeClinicInterest.value);
if (interestingLokets.length > 0) {
interestingLokets.forEach(loketId => {
if (String(loketId) !== String(targetLoketId)) {
fetchPatientsForLoket(loketId, true);
refreshedSomething = true;
}
});
}
if (interestingClinics.length > 0) {
interestingClinics.forEach(kodeKlinik => {
if (String(kodeKlinik) !== String(targetKlinikId)) {
fetchPatientsForClinic(kodeKlinik, true);
refreshedSomething = true;
}
});
}
if (!refreshedSomething) {
console.log(`🔕 [queueStore] 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, // 2 seconds between reconnect attempts
maxReconnectAttempts: 9999, // Effectively infinite — never give up on remote machines
onOpen: () => {
console.log('✅ [queueStore] WebSocket connected');
isWsConnected.value = true;
},
onClose: () => {
console.log('❌ [queueStore] WebSocket disconnected');
isWsConnected.value = false;
},
onError: (err) => {
console.error('⚠️ [queueStore] WebSocket error:', err);
isWsConnected.value = false;
},
onMessage: onWsMessage
});
// ============================================
// STORE-LEVEL AUTO-POLLING (cross-device sync fallback)
// ============================================
// Runs on every browser instance every 30 seconds.
// Fetches data based on whatever active interests are registered
// (lokets, clinics, or global). This ensures any device always
// has fresh data regardless of WS delivery reliability.
let _autoSyncInterval = null;
const startAutoSync = () => {
// Guard: only run on client, and only start once
if (typeof window === 'undefined') return;
if (_autoSyncInterval) return; // Already running
console.log('🔄 [queueStore] Starting store-level auto-sync (30s interval)');
_autoSyncInterval = setInterval(async () => {
const hasLoketInterest = Object.keys(activeLoketInterest.value).length > 0;
const hasClinicInterest = Object.keys(activeClinicInterest.value).length > 0;
const hasGlobalInterest = globalInterestCount.value > 0;
if (hasGlobalInterest) {
fetchAllPatients();
} else {
if (hasLoketInterest) {
Object.keys(activeLoketInterest.value).forEach(loketId => {
fetchPatientsForLoket(loketId, true);
});
}
if (hasClinicInterest) {
Object.keys(activeClinicInterest.value).forEach(kodeKlinik => {
fetchPatientsForClinic(kodeKlinik, true);
});
}
}
}, 30000); // 30 seconds
};
const stopAutoSync = () => {
if (_autoSyncInterval) {
clearInterval(_autoSyncInterval);
_autoSyncInterval = null;
console.log('⏹️ [queueStore] Store-level auto-sync stopped');
}
};
/**
* Initialize Global WebSocket
*/
const initWebSocket = (customClientId = null) => {
if (isConnected.value && customClientId === wsClientId.value) {
console.log('🔌 [queueStore] WebSocket already connected with same ID.');
// Auto-sync should still start even if WS is already connected
startAutoSync();
return;
}
if (customClientId) {
wsClientId.value = customClientId;
// Re-connect with new ID if changed
disconnect();
}
console.log(`🔌 [queueStore] Connecting to WebSocket: ${wsBaseUrl} as ${wsClientId.value}`);
connect();
// Start store-level auto-polling if not already running (client-side only).
// This guarantees cross-device sync even when WS messages are missed.
startAutoSync();
};
/**
* Disconnect Global WebSocket
*/
const disconnectWebSocket = () => {
disconnect();
isWsConnected.value = false;
};
/**
* Sync patient status to apiPatientsPerLoket for reactivity
@@ -602,12 +357,7 @@ export const useQueueStore = defineStore('queue', () => {
18: 'pemeriksaan', // PO PEMERIKSAAN
19: 'pemeriksaan', // PS PEMERIKSAAN
32: 'pending', // PE PEMERIKSAAN
33: 'terlambat', // TR PEMERIKSAAN
// String versions for robustness
"1": 'menunggu', "2": 'menunggu', "3": 'anjungan', "4": 'anjungan', "5": 'di-loket',
"6": 'di-loket', "14": 'pemeriksaan', "15": 'pemeriksaan', "28": 'pending', "29": 'terlambat',
"30": 'pending', "31": 'terlambat', "32": 'pending', "33": 'terlambat'
33: 'terlambat' // TR PEMERIKSAAN
};
/**
@@ -742,7 +492,7 @@ export const useQueueStore = defineStore('queue', () => {
/**
* Fetch patient data untuk loket tertentu dari API
*/
const fetchPatientsForLoket = async (loketId, force = false) => {
const fetchPatientsForLoket = async (loketId: string | number, force: boolean = false) => {
if (!loketId) {
console.error('loketId required for fetchPatientsForLoket');
return { success: false, message: 'ID Loket diperlukan' };
@@ -757,7 +507,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
const timeSinceLastFetch = now - lastFetch;
if (!force && timeSinceLastFetch < 2000 && apiPatientsPerLoket.value[loketId]) {
console.log(`⏭️ [queueStore] Skipping fetch for loket ${loketId} (last fetched ${Math.round(timeSinceLastFetch/1000)}s ago)`);
// console.log(`⏭️ [queueStore] Skipping fetch for loket ${loketId} (last fetched ${Math.round(timeSinceLastFetch/1000)}s ago)`);
isLoadingPatients.value = false;
return {
success: true,
@@ -773,22 +523,8 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
checkAndResetDaily();
try {
console.log(`🔄 [queueStore] Fetching patients for loket ${loketId}...`);
const apiBase = config.public.verificationApiBaseUrl || 'http://10.10.123.140:8089/api/v1';
const response = await fetch(`${apiBase}/loket/${loketId}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const rawData = await response.json();
// Check response structure
if (rawData.metadata && rawData.metadata.code !== 200) {
throw new Error(rawData.message || 'API returned error status');
}
const patientsRaw = rawData.data || [];
// console.log(`🔄 [queueStore] Fetching patients for loket ${loketId}...`);
const patientsRaw = await queueAPI.fetchRawLoketPatients(loketId);
// Fetch temporary subspesialis mapping
let subspesialisMap = {};
@@ -797,7 +533,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
if (subRes.ok) {
subspesialisMap = await subRes.json();
}
} catch (e) {
} catch (e: any) {
console.error('Failed to fetch temporary subspesialis mapping', e);
}
@@ -999,7 +735,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
// 6. RESTORE terlambat/pending status from LocalStorage (hybrid fallback)
// This ensures status persists even if API doesn't save it
allPatients.value.forEach((patient, index) => {
allPatients.value.forEach((patient: any, index: number) => {
if (patient.barcode) {
const storageKey = `patient-status-${patient.barcode}`;
const savedData = localStorage.getItem(storageKey);
@@ -1021,7 +757,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
// Clean up old data
localStorage.removeItem(storageKey);
}
} catch (e) {
} catch (e: any) {
console.error('Error parsing LocalStorage data:', e);
localStorage.removeItem(storageKey);
}
@@ -1041,7 +777,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
data: mappedPatients
};
} catch (error) {
} catch (error: any) {
console.error(`❌ [queueStore] Error fetching patients for loket ${loketId}:`, error);
apiPatientsError.value = error.message;
@@ -1066,7 +802,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
* Global fetcher for all patients across all available lokets
* Uses staggered fetching to prevent 429 Too Many Requests errors.
*/
const fetchAllPatients = async (force = false) => {
const fetchAllPatients = async (force: boolean = false) => {
// 1. Cooldown Check: Prevent global refresh spam (max once every 5 seconds)
const now = Date.now();
if (!force && now - lastGlobalFetchTime.value < 5000) {
@@ -1075,7 +811,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
}
lastGlobalFetchTime.value = now;
console.log('🔄 [queueStore] Fetching all patients for all lokets (Staggered)...');
// console.log('🔄 [queueStore] Fetching all patients for all lokets (Staggered)...');
const allLokets = loketStore.lokets || [];
if (allLokets.length === 0) {
console.warn('⚠️ [queueStore] No lokets available for fetchAllPatients');
@@ -1129,7 +865,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
/**
* Get patients for a specific loket (from API or seed data based on loket type)
*/
const getPatientsForLoket = (loketId) => {
const getPatientsForLoket = (loketId: string | number) => {
return computed(() => {
const loket = loketStore.getLoketById(parseInt(loketId));
@@ -1139,11 +875,10 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
(loket?.namaLoket || '').toUpperCase().includes('EKSEKUTIF');
if (isEksekutif) {
// Return EKSEKUTIF patients from seed data
// Return EKSEKUTIF patients assigned to this loket
return allPatients.value.filter(p => {
const isPembayaranEksekutif = (p.pembayaran || '').toUpperCase().includes('EKSEKUTIF') ||
(p.pembayaran || '').toUpperCase().includes('VIP');
return isPembayaranEksekutif && p.processStage === 'loket';
const isMatchingLoket = String(p.loketId) === String(loketId);
return p.processStage === 'loket' && isMatchingLoket;
});
}
@@ -1527,7 +1262,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
/**
* Filter strictly to only show today's patients (after 2 AM)
*/
const isTodayPatient = (patient) => {
const isTodayPatient = (patient: any) => {
if (!patient) return false;
// Status processing overrides filter (always show if currently processing)
@@ -1617,7 +1352,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
setTimeout(() => { isSyncing = false; }, 50);
}
}
} catch (e) {
} catch (e: any) {
console.error('Error hydrating from storage event:', e);
isSyncing = false;
}
@@ -1670,7 +1405,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
const resetPatients = () => {
allPatients.value = cloneSeed();
quotaUsed.value = 5;
currentProcessingPatient.value = { loket: null, klinik: null, penunjang: null };
currentProcessingPatient.value = {};
syncCountersWithState(); // Re-initialize counters after reset
};
@@ -1749,6 +1484,20 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
// Enforce clinic mapping (pelayanan)
if (thisLoket && thisLoket.pelayanan && Array.isArray(thisLoket.pelayanan)) {
if (thisLoket.pelayanan.includes(p.kodeKlinik)) {
// Bypass payment check for Eksekutif patients
const isPatientEksekutif = p.noAntrian && (p.noAntrian.startsWith('E') || p.noAntrian.startsWith('F-E'));
const isLoketEksekutif = thisLoket.tipeLoket === 'EKSEKUTIF' || thisLoket.id >= 1000;
if (isPatientEksekutif && isLoketEksekutif) {
return true;
}
if (!isPatientEksekutif && isLoketEksekutif) {
return false;
}
if (isPatientEksekutif && !isLoketEksekutif) {
return false;
}
// NEW: Check payment compatibility
if (!isPaymentCompatible(p.pembayaran, thisLoket.pembayaran)) {
return false;
@@ -1815,20 +1564,13 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
// POST to external API when patient is called
try {
const apiBase = config.public.verificationApiBaseUrl || 'http://10.10.123.140:8089/api/v1';
fetch(`${apiBase}/tiket/update`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
barcode: nextPatient.barcode || "",
statuspasien: "3",
statuspasien2: "4",
idklinikstatus: "1",
idklinikstatus2: "1"
})
}).then(response => {
queueAPI.updateTicketStatus(
nextPatient.barcode || "",
"3",
"4",
"1",
"1"
).then(response => {
if (response.ok) {
console.log(`✅ Successfully posted status update for patient ${nextPatient.barcode}`);
} else {
@@ -1837,7 +1579,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
}).catch(error => {
console.error(`❌ Error posting status update for patient ${nextPatient.barcode}:`, error);
});
} catch (error) {
} catch (error: any) {
console.error(`❌ Error initiating status update for patient ${nextPatient.barcode}:`, error);
}
}
@@ -1909,7 +1651,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
const patientsToCall = menungguList.slice(0, maxCallable);
const callTimestamp = new Date().toISOString();
patientsToCall.forEach(async (patient) => {
patientsToCall.forEach(async (patient: any) => {
const index = allPatients.value.findIndex(p => p.no === patient.no);
if (index !== -1) {
const newStatus = "anjungan";
@@ -1927,27 +1669,20 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
// POST to external API when patient is called
try {
const apiBase = config.public.verificationApiBaseUrl || 'http://10.10.123.140:8089/api/v1';
const response = await fetch(`${apiBase}/tiket/update`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
barcode: patient.barcode || "",
statuspasien: "3",
statuspasien2: "4",
idklinikstatus: "1",
idklinikstatus2: "1"
})
});
const response = await queueAPI.updateTicketStatus(
patient.barcode || "",
"3",
"4",
"1",
"1"
);
if (response.ok) {
console.log(`✅ Successfully posted status update for patient ${patient.barcode}`);
} else {
console.error(`⚠️ Failed to post status update for patient ${patient.barcode}:`, response.status);
}
} catch (error) {
} catch (error: any) {
console.error(`❌ Error posting status update for patient ${patient.barcode}:`, error);
}
}
@@ -1986,19 +1721,12 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
// POST to external API when patient finishes at loket
try {
const apiBase = config.public.verificationApiBaseUrl || 'http://10.10.123.140:8089/api/v1';
fetch(`${apiBase}/tiket/selesai`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
idloket: String(patient.loketId || specificId || ""),
barcode: patient.barcode || "",
statuspasien: "9",
idklinikstatus: "2"
})
}).then(response => {
queueAPI.completeTicketStatus(
String(patient.loketId || specificId || ""),
patient.barcode || "",
"9",
"2"
).then(response => {
if (response.ok) {
console.log(`✅ [queueStore] Successfully posted selesai status for patient ${patient.barcode}`);
} else {
@@ -2007,7 +1735,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
}).catch(error => {
console.error(`❌ [queueStore] Error posting selesai status for patient ${patient.barcode}:`, error);
});
} catch (error) {
} catch (error: any) {
console.error(`❌ [queueStore] Error initiating selesai status update for patient ${patient.barcode}:`, error);
}
}
@@ -2088,7 +1816,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
console.error(`❌ [TERLAMBAT] API rejected request:`, responseData);
message = `Gagal: ${responseData.message || 'API error'}`;
}
} catch (error) {
} catch (error: any) {
console.error(`❌ [TERLAMBAT] Error:`, error);
message = `Error: ${error.message}`;
}
@@ -2145,7 +1873,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
console.error(`❌ [PENDING] API rejected request:`, responseData);
message = `Gagal: ${responseData.message || 'API error'}`;
}
} catch (error) {
} catch (error: any) {
console.error(`❌ [PENDING] Error:`, error);
message = `Error: ${error.message}`;
}
@@ -2188,7 +1916,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
}).catch(error => {
console.error(`❌ [queueStore] Error activating patient ${patient.barcode}:`, error);
});
} catch (error) {
} catch (error: any) {
console.error(`❌ [queueStore] Error initiating activation for patient ${patient.barcode}:`, error);
}
} else {
@@ -2253,7 +1981,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
}).catch(error => {
console.error(`❌ [queueStore] Error updating patient ${patient.barcode} to sedang diproses:`, error);
});
} catch (error) {
} catch (error: any) {
console.error(`❌ [queueStore] Error initiating status update for patient ${patient.barcode}:`, error);
}
} else {
@@ -2483,7 +2211,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
// Pindah pasien ke klinik ruang lain dengan nomor antrian tetap
const pindahKlinikRuang = (patient, targetKlinikRuang, targetRuang) => {
const patientIndex = allPatients.value.findIndex(p => p.no === patient.no);
if (patientIndex === -1) {
return { success: false, message: "Pasien tidak ditemukan" };
}
@@ -2820,7 +2548,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
} else {
console.log('✅ [queueStore] Successfully finished patient via API');
}
} catch (error) {
} catch (error: any) {
console.error('❌ [queueStore] Error calling finish API:', error);
// Continue with local status update even if API fails
}
@@ -2846,7 +2574,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody)
});
} catch (error) {
} catch (error: any) {
console.error('❌ [queueStore] Error calling terlambat API:', error);
}
@@ -2870,7 +2598,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody)
});
} catch (error) {
} catch (error: any) {
console.error('❌ [queueStore] Error calling pending API:', error);
}
@@ -2894,11 +2622,22 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
let targetLoketName = oldPatient.loket;
if (newKlinik.kode) {
const isEksekutif = (oldPatient.pembayaran || '').toUpperCase().includes('EKSEKUTIF') ||
(oldPatient.pembayaran || '').toUpperCase().includes('VIP');
const allLokets = loketStore.lokets || [];
const foundLoket = allLokets.find(l =>
l.pelayanan && Array.isArray(l.pelayanan) &&
(l.pelayanan.includes(newKlinik.kode) || l.pelayanan.includes(newKlinik.kode?.split('-')[0]))
);
const foundLoket = allLokets.find(l => {
const handlesClinic = l.pelayanan && Array.isArray(l.pelayanan) &&
(l.pelayanan.includes(newKlinik.kode) || l.pelayanan.includes(newKlinik.kode?.split('-')[0]));
if (!handlesClinic) return false;
if (isEksekutif) {
return l.id >= 1000 || l.tipeLoket === 'EKSEKUTIF';
} else {
return l.id < 1000 && l.tipeLoket !== 'EKSEKUTIF';
}
});
if (foundLoket) {
targetLoketId = foundLoket.id;
@@ -3043,6 +2782,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
shift: shift,
klinik: clinic.name || clinic,
kodeKlinik: clinic.kode || null, // Essential for filtering
idKlinik: clinic.id || null, // Essential for Eksekutif loket matching
fastTrack: isFastTrack ? "YA" : "TIDAK",
pembayaran: paymentType,
noRM: `RM-${barcode.slice(-6)}`,
@@ -3065,17 +2805,43 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
// Auto-assign Loket ID if not provided, based on Clinic Mapping
// fulfill requirement: "adjust based on loket id depending on creation"
if (!newPatient.loketId && newPatient.kodeKlinik) {
if (!newPatient.loketId && (newPatient.kodeKlinik || newPatient.idKlinik)) {
const allLokets = loketStore.lokets || [];
const targetLoket = allLokets.find(l =>
l.pelayanan && Array.isArray(l.pelayanan) &&
(l.pelayanan.includes(newPatient.kodeKlinik) || l.pelayanan.includes(newPatient.kodeKlinik?.split('-')[0]))
);
if (targetLoket) {
newPatient.loketId = targetLoket.id;
if (!newPatient.loket) newPatient.loket = targetLoket.namaLoket;
console.log(`✅ Auto-assigned Onsite Ticket ${newPatient.noAntrian} to Loket ${targetLoket.id} (${targetLoket.namaLoket})`);
if (isEksekutif) {
const targetLoket = allLokets.find(l =>
l.pelayanan && Array.isArray(l.pelayanan) &&
(
l.pelayanan.includes(newPatient.kodeKlinik) ||
l.pelayanan.includes(newPatient.kodeKlinik?.split('-')[0]) ||
l.pelayanan.includes(newPatient.idKlinik) ||
l.pelayanan.includes(String(newPatient.idKlinik))
) &&
(l.id >= 1000 || l.tipeLoket === 'EKSEKUTIF')
);
if (targetLoket) {
newPatient.loketId = targetLoket.id;
if (!newPatient.loket) newPatient.loket = targetLoket.namaLoket;
console.log(`✅ Auto-assigned Eksekutif Ticket ${newPatient.noAntrian} to Loket ${targetLoket.id} (${targetLoket.namaLoket})`);
} else {
console.warn(`⚠️ Warning: No Eksekutif Loket found for clinic ID ${newPatient.idKlinik} or Code ${newPatient.kodeKlinik}. Ticket will not be routed to any loket.`);
}
} else {
const targetLoket = allLokets.find(l =>
l.pelayanan && Array.isArray(l.pelayanan) &&
(
l.pelayanan.includes(newPatient.kodeKlinik) ||
l.pelayanan.includes(newPatient.kodeKlinik?.split('-')[0]) ||
l.pelayanan.includes(newPatient.idKlinik) ||
l.pelayanan.includes(String(newPatient.idKlinik))
)
);
if (targetLoket) {
newPatient.loketId = targetLoket.id;
if (!newPatient.loket) newPatient.loket = targetLoket.namaLoket;
console.log(`✅ Auto-assigned Onsite Ticket ${newPatient.noAntrian} to Loket ${targetLoket.id} (${targetLoket.namaLoket})`);
}
}
}
@@ -3238,7 +3004,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
subspesialis: subSpesialis
})
});
} catch (e) {
} catch (e: any) {
console.error('Failed to save temporary subSpesialis mapping', e);
}
}
@@ -3250,7 +3016,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
patient: newPatient
};
} catch (error) {
} catch (error: any) {
console.error('❌ [queueStore] Error generating ticket via API:', error);
return {
success: false,
@@ -3292,7 +3058,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
data: result
};
} catch (error) {
} catch (error: any) {
console.error('❌ [queueStore] Error syncing check-in via API:', error);
return {
success: false,
@@ -3422,6 +3188,31 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
return { success: false, message: "Gagal memproses antrean." };
};
// Initialize Queue Sync
const queueSync = useQueueSync({
allPatients,
currentProcessingPatient,
activeLoketInterest,
activeClinicInterest,
globalInterestCount,
fetchPatientsForLoket,
fetchPatientsForClinic,
fetchAllPatients
});
const {
isWsConnected,
wsClientId,
lastGlobalCall,
lastKlinikCall,
initWebSocket,
disconnectWebSocket,
sendViaPost,
startAutoSync,
stopAutoSync
} = queueSync;
return {
// State
allPatients,
@@ -3504,12 +3295,13 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
persist: {
key: 'queue-store-state',
storage: typeof window !== 'undefined' ? localStorage : undefined,
// @ts-ignore - plugin version mismatch
paths: ['quotaUsed', 'lastUpdated'],
serializer: {
deserialize: JSON.parse,
serialize: JSON.stringify,
},
restore: (value) => {
restore: (value: any) => {
// Ensure allPatients is always an array
if (value && value.allPatients && !Array.isArray(value.allPatients)) {
value.allPatients = [];
+48
View File
@@ -0,0 +1,48 @@
export interface QueuePatient {
no: number;
barcode: string;
noAntrian: string;
jamPanggil: string;
klinik: string;
kodeKlinik: string;
klinikId?: number;
healthcareServiceId?: number;
ruang?: string;
nomorRuang?: string;
kodeRuang?: string;
pembayaran?: string;
status: 'menunggu' | 'di-loket' | 'anjungan' | 'pemeriksaan' | 'pending' | 'terlambat' | 'selesai' | 'skip' | 'processed';
processStage: 'loket' | 'klinik-ruang';
createdAt: string;
visitType?: string;
noRM?: string;
fastTrack?: "YA" | "TIDAK";
registrationType?: 'api' | 'manual';
visitId?: number;
visitCode?: string;
referencePatient?: string;
loketId?: number;
calledByAdmin?: boolean;
lastCalledAt?: string;
lastCalledTipeLayanan?: string;
calledPemeriksaanAwal?: boolean;
calledTindakan?: boolean;
tipeLayanan?: string;
idtiket?: string;
ticket?: string;
posisi?: any[];
deskripsi?: string;
manuallyMoved?: boolean;
movedAt?: number;
idvisit?: number;
visitDate?: string;
namaDokter?: string | null;
penanggungJawab?: string | null;
alasanFastTrack?: string | null;
}
export interface ApiPatientResponse {
success: boolean;
message: string;
data?: QueuePatient[];
}
+39
View File
@@ -0,0 +1,39 @@
const fs = require('fs');
const navContent = fs.readFileSync('stores/navItems1.ts', 'utf-8');
const keys = [...navContent.matchAll(/menuKey:\s*['"]([^'"]+)['"]/g)].map(m => m[1]);
console.log('Extracted keys:', keys);
const data = JSON.parse(fs.readFileSync('data/mock/hakAkses.json', 'utf-8'));
const menus = keys.map(k => ({
menuKey: k,
name: k,
canAccess: true,
canView: true,
canAdd: true,
canEdit: true,
canDelete: true
}));
const usernameOverrides = ['[email protected]', 'akbar44', 'Akbar Attallah'];
for (const user of usernameOverrides) {
const existing = data.find(d => d.namaTipeUser === user || d.namaHakAkses === user);
if (existing) {
existing.hakAksesMenu = menus;
existing.namaTipeUser = user;
} else {
data.push({
id: require('crypto').randomUUID(),
namaHakAkses: user,
namaTipeUser: user,
status: 'aktif',
isGroupBased: false,
hakAksesMenu: menus
});
}
}
fs.writeFileSync('data/mock/hakAkses.json', JSON.stringify(data, null, 4));
console.log('Granted full access to ' + usernameOverrides.join(', '));