Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bc314f463 | ||
|
|
49e0ee2bc4 | ||
|
|
a5d7338e58 | ||
|
|
b0b5200802 | ||
|
|
d1b6629ac8 | ||
|
|
fec58f14b5 | ||
|
|
bbcaf9826c | ||
|
|
d0eee7d062 | ||
|
|
61772105fd | ||
|
|
e18ed186e1 | ||
|
|
400bfcdcaf | ||
|
|
8b9c4725de | ||
|
|
f81dd57a16 | ||
|
|
0928e78bec | ||
|
|
047b39064d | ||
|
|
ffdb88d13c |
No files matched your search
@@ -0,0 +1,304 @@
|
||||
# 📋 Project Knowledge — Web Antrean (Sistem Antrian Rumah Sakit)
|
||||
|
||||
> Dokumen ini berisi knowledge base lengkap, aturan pengkodean, dan workflow wajib untuk project **web-antrean**.
|
||||
> **WAJIB DIBACA** sebelum melakukan task apapun.
|
||||
|
||||
---
|
||||
|
||||
## 🚨 ATURAN WAJIB (MANDATORY RULES)
|
||||
|
||||
### Rule 1: Selalu Buat Implementation Plan Sebelum Eksekusi
|
||||
- **SEBELUM** menulis kode apapun, buat implementation plan di `docs/DEVPLAN.md` atau artifact
|
||||
- Plan harus mencakup: file yang akan diubah, perubahan spesifik, dan alasan
|
||||
- Tunggu persetujuan user sebelum eksekusi (kecuali fix minor/typo)
|
||||
|
||||
### Rule 2: Selalu Tambahkan Komentar di Setiap Fungsi
|
||||
- Setiap **function**, **method**, **computed**, dan **watcher** HARUS punya komentar JSDoc/TSDoc
|
||||
- Format minimum: `/** Deskripsi singkat fungsi ini */`
|
||||
- Untuk fungsi kompleks, tambahkan `@param`, `@returns`, `@example`
|
||||
- Komentar inline untuk logika yang tidak obvious
|
||||
|
||||
```javascript
|
||||
// ✅ BENAR
|
||||
/**
|
||||
* Mengambil data pasien dari API berdasarkan ID loket
|
||||
* @param {string} loketId - ID loket yang akan di-fetch
|
||||
* @returns {Promise<QueuePatient[]>} Daftar pasien dalam antrian
|
||||
*/
|
||||
const fetchPatientsForLoket = async (loketId) => { ... }
|
||||
|
||||
// ❌ SALAH — tanpa komentar
|
||||
const fetchPatientsForLoket = async (loketId) => { ... }
|
||||
```
|
||||
|
||||
### Rule 3: Selalu Update Dokumentasi Setelah Perubahan
|
||||
- **DEVLOG.md**: Tambahkan entry untuk setiap perubahan signifikan
|
||||
- **DEVPLAN.md**: Update status task yang sudah selesai
|
||||
- **PRD.md**: Update jika ada perubahan requirements/API/arsitektur
|
||||
- **AGENTS.md**: Update jika ada knowledge baru yang penting
|
||||
|
||||
### Rule 4: Selalu Baca AGENTS.md dan Skills Sebelum Eksekusi
|
||||
- Baca file ini SEBELUM mulai task apapun
|
||||
- Baca skill `web-antrean-coding-standards` untuk konvensi kode
|
||||
- Jangan mengulang kesalahan yang sudah tercatat di bagian Gotchas
|
||||
|
||||
### Rule 5: Jangan Mengulang Kesalahan yang Sudah Diketahui
|
||||
- Cek bagian **⚠️ Gotchas & Known Issues** sebelum debug
|
||||
- Cek **DEVLOG.md** untuk masalah serupa yang pernah diselesaikan
|
||||
- Jika menemukan masalah baru, catat di DEVLOG dan Gotchas
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Arsitektur & Tech Stack
|
||||
|
||||
| Layer | Teknologi |
|
||||
|---|---|
|
||||
| **Framework** | Nuxt 3 (`^3.17.7`) dengan SSR |
|
||||
| **UI Library** | Vuetify 3 (`^3.9.3`) + Material Design Icons (`@mdi/font`) |
|
||||
| **State Management** | Pinia (`^3.0.3`) + `pinia-plugin-persistedstate` |
|
||||
| **Styling** | SCSS (`sass-embedded`), design tokens di `assets/scss/` |
|
||||
| **Auth** | Keycloak OAuth2 (custom server-side implementation, bukan nuxt-auth) |
|
||||
| **Database (Server)** | SQLite via `better-sqlite3` — untuk konfigurasi device & user sync |
|
||||
| **Charting** | Chart.js + vue-chartjs + nuxt-charts |
|
||||
| **QR Code** | `html5-qrcode` (scanner) + `qrcode.vue` / `nuxt-qrcode` (generator) |
|
||||
| **WebSocket** | Custom composable (`useWebSocket.ts`) ke backend Go |
|
||||
| **Deployment** | Docker + docker-compose |
|
||||
| **Testing** | Vitest (unit) + Cypress (E2E) |
|
||||
| **Font** | Google Fonts (Inter 400-700) |
|
||||
|
||||
---
|
||||
|
||||
## 📁 Struktur Direktori
|
||||
|
||||
```
|
||||
web-antrean/
|
||||
├── app.vue # Entry point — store schema migration + loading overlay
|
||||
├── nuxt.config.ts # Konfigurasi Nuxt, Vuetify, runtime config, proxy
|
||||
├── .env # Environment variables (API URLs, auth, proxy)
|
||||
├── docker-compose.yml # Docker deployment config
|
||||
│
|
||||
├── .agents/ # Agent rules & skills
|
||||
│ ├── AGENTS.md # FILE INI — rules & knowledge base
|
||||
│ └── skills/ # Coding standards & workflow skills
|
||||
│
|
||||
├── docs/ # Dokumentasi project
|
||||
│ ├── DEVLOG.md # Development log (kronologis)
|
||||
│ ├── DEVPLAN.md # Development plan (task breakdown)
|
||||
│ └── PRD.md # Product Requirements Document
|
||||
│
|
||||
├── assets/scss/ # Design system
|
||||
│ ├── _colors.scss # Color tokens (CSS variables)
|
||||
│ ├── _variables.scss # Spacing, breakpoints, etc.
|
||||
│ ├── _typography.scss # Font styles
|
||||
│ └── main.scss # Global imports
|
||||
│
|
||||
├── components/
|
||||
│ ├── common/ # Shared: PageHeader, Avatar, AppSnackbar, SelectionDialog
|
||||
│ ├── layout/ # SideBar, ProfileMenu, ProfilePopup
|
||||
│ ├── features/ # Feature-specific (antrean, master, monitoring, queue)
|
||||
│ ├── AdminKlinik/ # Komponen admin klinik eksekutif
|
||||
│ ├── GrandPaviliun/ # Komponen Grand Paviliun (Gp*)
|
||||
│ ├── HakAkses/ # Komponen manajemen hak akses
|
||||
│ ├── MonitorPasien/ # Komponen monitoring pasien
|
||||
│ ├── checkin/ # Komponen check-in
|
||||
│ ├── verification/ # Komponen verifikasi akun
|
||||
│ └── preview/ # Komponen preview
|
||||
│
|
||||
├── composables/
|
||||
│ ├── useAuth.ts # Auth state & session management
|
||||
│ ├── useWebSocket.ts # WebSocket client dengan auto-reconnect
|
||||
│ ├── useQueueAPI.ts # API wrapper untuk antrian (verificationApiBaseUrl)
|
||||
│ ├── useVisitAPI.ts # API wrapper untuk visit (externalApiBaseUrl)
|
||||
│ ├── useQueueSync.ts # Sinkronisasi antrian antar client
|
||||
│ ├── useCheckIn.ts # Logika check-in pasien
|
||||
│ ├── useCheckInHistory.ts # Riwayat check-in
|
||||
│ ├── useClinicAPI.ts # API wrapper klinik
|
||||
│ ├── useGrandPaviliun.ts # Logika Grand Paviliun
|
||||
│ ├── useHakAkses.ts # Manajemen hak akses
|
||||
│ ├── usePermissions.ts # Permission check helper
|
||||
│ ├── useQRGenerator.ts # QR code generation
|
||||
│ ├── useQRScanner.ts # QR code scanning (html5-qrcode)
|
||||
│ ├── useThermalPrint.ts # Thermal printer (tiket antrian)
|
||||
│ ├── useSnackbar.ts # Toast notification
|
||||
│ └── useInfiniteScroll.ts # Infinite scroll pagination
|
||||
│
|
||||
├── layouts/
|
||||
│ ├── default.vue # Main layout — SideBar + middleware auth + checkPageAccess
|
||||
│ └── empty.vue # Empty layout (untuk halaman tanpa sidebar)
|
||||
│
|
||||
├── middleware/
|
||||
│ ├── auth.ts # Auth check → redirect ke /LoginPage jika belum login
|
||||
│ ├── guest.ts # Untuk halaman publik
|
||||
│ ├── checkPageAccess.ts # Cek akses halaman berdasarkan hak akses
|
||||
│ └── permissions.ts # Auto-sync permissions dari backend (DISABLED)
|
||||
│
|
||||
├── pages/ # Semua halaman (lihat detail di PRD.md section 8.1)
|
||||
│ ├── AdminKlinikRuang/ # [kodeKlinik].vue (~111KB)
|
||||
│ ├── AdminLoket/ # [id].vue (~58KB)
|
||||
│ ├── CheckInPasien/ # checkIn.vue (~230KB, LARGEST)
|
||||
│ ├── Anjungan/ # Display anjungan (kiosk)
|
||||
│ ├── Setting/ # Master data & konfigurasi
|
||||
│ └── ...
|
||||
│
|
||||
├── server/
|
||||
│ ├── api/auth/ # Auth endpoints (Keycloak OAuth flow)
|
||||
│ ├── api/users/ # User management (SQLite-backed)
|
||||
│ ├── api/config/ # Device configuration (SQLite-backed)
|
||||
│ ├── api/hak-akses/ # Hak akses management
|
||||
│ └── utils/ # configDb.ts, sessionStore.ts, userSync.ts
|
||||
│
|
||||
├── stores/ # Pinia stores (Single Source of Truth)
|
||||
│ ├── clinicStore.js # Master data klinik (~1007 lines)
|
||||
│ ├── queueStore.ts # Antrian pasien (~3302 lines, LARGEST)
|
||||
│ ├── loketStore.js # Data loket (~549 lines)
|
||||
│ ├── ruangStore.js # Data ruang klinik (~558 lines)
|
||||
│ ├── masterStore.js # BACKWARD COMPAT LAYER (jangan tulis langsung!)
|
||||
│ ├── penunjangStore.js # Data penunjang medis
|
||||
│ ├── doctorStore.js # Data dokter (fetched from API)
|
||||
│ └── ... # anjunganStore, screenStore, navItems1, dll
|
||||
│
|
||||
├── types/ # TypeScript interfaces & types
|
||||
│ ├── auth.ts, queue.ts, checkin.ts, setting.ts
|
||||
│
|
||||
└── data/
|
||||
└── users.db # SQLite database (user sync + device config)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔌 API Endpoints & External Services
|
||||
|
||||
### Environment Variables (`.env`)
|
||||
|
||||
| Variable | Deskripsi | Value (Juli 2026) |
|
||||
|---|---|---|
|
||||
| `ANTRIAN_API_URL` | API antrian utama (Go backend) | `http://10.10.150.131:8089/api/v1` |
|
||||
| `VERIFICATION_API_BASE_URL` | Alias untuk ANTRIAN_API_URL | (same) |
|
||||
| `VISIT_API_URL` | API visit/kunjungan pasien | `http://10.10.123.135:8084/api/v1` |
|
||||
| `WS_API_URL` | WebSocket URL | `ws://10.10.123.135:8084/api/v1/ws` |
|
||||
| `EXTERNAL_API_BASE_URL` | External API (JWT validation) | `http://10.10.123.135:8084` |
|
||||
| `KEYCLOAK_ISSUER` | Keycloak realm URL | `https://auth.rssa.top/realms/sandbox` |
|
||||
| `AUTH_ORIGIN` | Aplikasi origin URL | `http://10.10.150.175:3000` |
|
||||
| `HOST` | Dev server host | `http://10.10.150.175:3000` |
|
||||
|
||||
### Runtime Config (`nuxt.config.ts`)
|
||||
|
||||
```
|
||||
runtimeConfig.public.verificationApiBaseUrl → ANTRIAN_API_URL (antrian API)
|
||||
runtimeConfig.public.externalApiBaseUrl → VISIT_API_URL (visit API)
|
||||
runtimeConfig.public.wsBaseUrl → WS_API_URL (WebSocket)
|
||||
```
|
||||
|
||||
### External Backend APIs
|
||||
|
||||
1. **Antrian API** (`verificationApiBaseUrl`) — Go backend
|
||||
- `GET /klinik/reguler` — Fetch daftar klinik reguler
|
||||
- `GET /loket/ruang` — Fetch daftar ruangan per klinik
|
||||
- `GET /loket/{id}` — Fetch pasien per loket
|
||||
- `GET /dokter/{id}` — Fetch data dokter per klinik
|
||||
- `POST /tiket/generate` — Generate tiket antrian
|
||||
- `POST /tiket/checkin` — Check-in pasien
|
||||
- `GET /permission` — Fetch permission/hak akses
|
||||
|
||||
2. **Visit API** (`externalApiBaseUrl`) — Visit/kunjungan
|
||||
- `GET /visit?klinik_id={id}` — Fetch data pasien per klinik
|
||||
- `POST /external/validate-token` — Validate JWT token
|
||||
|
||||
3. **WebSocket** (`wsBaseUrl`)
|
||||
- Real-time queue updates, connected via `useWebSocket.ts`
|
||||
|
||||
> **PENTING**: Semua kode menggunakan `config.public.verificationApiBaseUrl` dengan fallback hardcoded. Saat ganti IP, ubah di `.env` lalu **restart dev server**. Nuxt hanya membaca `.env` saat startup!
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ Store Architecture
|
||||
|
||||
### Hirarki Store
|
||||
|
||||
```
|
||||
masterStore (BACKWARD COMPAT LAYER — JANGAN TULIS LANGSUNG)
|
||||
├── clinicStore → Single source of truth untuk data KLINIK
|
||||
├── loketStore → Data LOKET (counter antrian)
|
||||
├── ruangStore → Data RUANG per klinik
|
||||
└── penunjangStore → Data PENUNJANG medis
|
||||
|
||||
queueStore → Data ANTRIAN PASIEN (terbesar, 136KB)
|
||||
doctorStore → Data DOKTER (fetched per klinik)
|
||||
anjunganStore → Config ANJUNGAN (kiosk)
|
||||
screenStore → Config SCREEN display
|
||||
antreanMasukScreenStore → Config screen antrian masuk
|
||||
navItems1 → Navigation items + hak akses filter
|
||||
permissionStore → Permission data
|
||||
verificationStore → Verifikasi akun
|
||||
```
|
||||
|
||||
### Store Schema Migration
|
||||
- `app.vue` manages `STORE_SCHEMA_VERSION` (currently `3`)
|
||||
- Saat versi tidak cocok, semua localStorage Pinia di-clear
|
||||
- **Naikkan versi** saat ada perubahan struktur state
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Authentication Flow
|
||||
|
||||
1. **Login**: User klik login → `keycloak-login.ts` → redirect ke Keycloak
|
||||
2. **Callback**: Keycloak redirect back → `keycloak-callback.get.ts` → exchange code for token → create session cookie
|
||||
3. **Session**: `session.get.ts` validates cookie → returns user data
|
||||
4. **Middleware**: `auth.ts` checks session on every protected route
|
||||
5. **User Sync**: `userSync.ts` syncs Keycloak users to local SQLite DB
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Gotchas & Known Issues (HARUS DIBACA SEBELUM DEBUG)
|
||||
|
||||
1. **ENV reload**: Nuxt hanya membaca `.env` saat startup. Setelah ubah `.env`, HARUS restart dev server (`Ctrl+C` → `npm run dev`).
|
||||
|
||||
2. **Hardcoded fallback IPs**: Banyak file masih punya fallback IP lama. Saat ganti IP, ubah `.env` + restart server. JANGAN cuma ubah satu file.
|
||||
|
||||
3. **`totalQuota === 0` filter di ruangStore**: Klinik dengan `totalQuota: 0` diskip dari Admin Klinik Ruang. Ini menyembunyikan klinik yang valid tapi belum dikonfigurasi kuotanya. Trade-off yang disengaja.
|
||||
|
||||
4. **masterStore adalah proxy**: Jangan langsung tulis data ke masterStore. Selalu gunakan store yang sesuai (clinicStore, loketStore, ruangStore, penunjangStore).
|
||||
|
||||
5. **File besar** (hati-hati saat edit):
|
||||
- `CheckInPasien/checkIn.vue` (~230KB)
|
||||
- `queueStore.ts` (~136KB)
|
||||
- `AdminKlinikRuang/[kodeKlinik].vue` (~111KB)
|
||||
|
||||
6. **SQLite DB path**: `data/users.db` — digunakan untuk konfigurasi device & user sync. Di Docker, di-mount sebagai volume.
|
||||
|
||||
7. **WebSocket auto-upgrade**: `useWebSocket.ts` otomatis upgrade `ws://` ke `wss://` jika halaman diakses via HTTPS.
|
||||
|
||||
8. **Pinia persist `paths` type mismatch**: Gunakan `// @ts-ignore` untuk properti `paths` karena kompatibel runtime tapi melanggar validasi tipe.
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Key File Quick Reference
|
||||
|
||||
| Kebutuhan | File |
|
||||
|---|---|
|
||||
| Ganti API URL | `.env` → restart dev server |
|
||||
| Auth flow | `server/api/auth/` |
|
||||
| Konfigurasi Nuxt | `nuxt.config.ts` |
|
||||
| Data klinik | `stores/clinicStore.js` |
|
||||
| Data antrian | `stores/queueStore.ts` |
|
||||
| Data ruang | `stores/ruangStore.js` |
|
||||
| Data loket | `stores/loketStore.js` |
|
||||
| Sidebar/Navigation | `stores/navItems1.ts` + `components/layout/SideBar.vue` |
|
||||
| Design tokens | `assets/scss/_colors.scss`, `_variables.scss` |
|
||||
| Database schema | `server/utils/configDb.ts` |
|
||||
| User management | `server/api/users/` + `server/utils/userSync.ts` |
|
||||
| WebSocket | `composables/useWebSocket.ts` |
|
||||
| Thermal print | `composables/useThermalPrint.ts` |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Development Commands
|
||||
|
||||
```bash
|
||||
npm run dev # Start dev server
|
||||
npm run dev:https # Start with HTTPS + host 0.0.0.0
|
||||
npm run build # Production build
|
||||
npm run preview # Preview production build
|
||||
npm run test # Run Vitest
|
||||
docker compose up -d # Deploy production
|
||||
```
|
||||
@@ -0,0 +1,173 @@
|
||||
---
|
||||
name: web-antrean-coding-standards
|
||||
description: Aturan dasar pengkodean dan konvensi untuk project web-antrean berbasis Nuxt 3, Vue 3, TypeScript, Pinia, dan Vuetify.
|
||||
---
|
||||
|
||||
# 📜 Web Antrean Coding Standards
|
||||
|
||||
Skill ini berisi standar pengkodean wajib yang harus diikuti saat memodifikasi atau membuat kode baru di project `web-antrean`.
|
||||
|
||||
## 1. Komentar & Dokumentasi (WAJIB)
|
||||
- **Tiap Fungsi/Method/Computed/Watcher HARUS dikomentari.**
|
||||
- Gunakan JSDoc (`/** ... */`) untuk mendeskripsikan tujuan fungsi.
|
||||
- Jika fungsi kompleks atau memiliki parameter spesifik, gunakan tag `@param`, `@returns`.
|
||||
- Berikan komentar singkat (`//`) di dalam body fungsi jika ada logika yang tidak langsung jelas (non-obvious).
|
||||
|
||||
```typescript
|
||||
// ✅ CONTOH BENAR
|
||||
/**
|
||||
* Menghitung total antrean aktif untuk loket tertentu.
|
||||
* @param {string} loketId - ID unik dari loket.
|
||||
* @returns {number} Jumlah antrean berstatus 'menunggu'.
|
||||
*/
|
||||
const countActiveQueue = (loketId: string): number => {
|
||||
// Hanya hitung pasien yang belum dipanggil atau di-skip
|
||||
return allPatients.value.filter(p => p.loketId === loketId && p.status === 'menunggu').length;
|
||||
};
|
||||
```
|
||||
|
||||
## 2. Struktur Komponen (Vue 3 / Nuxt 3)
|
||||
- Gunakan `<script setup lang="ts">`. Sebisa mungkin gunakan TypeScript.
|
||||
- **Urutan blok dalam Vue SFC (Single File Component):**
|
||||
1. `<template>`
|
||||
2. `<script setup lang="ts">`
|
||||
3. `<style scoped>` (Lebih disukai `<style scoped lang="scss">`)
|
||||
- **Organisasi dalam `<script setup>`:**
|
||||
1. `imports` (Vue, composables, stores, types)
|
||||
2. `defineProps`, `defineEmits`, `defineExpose`
|
||||
3. State variables (`ref`, `reactive`)
|
||||
4. Computed properties (`computed`)
|
||||
5. Watchers (`watch`, `watchEffect`)
|
||||
6. Functions / Methods
|
||||
7. Lifecycle hooks (`onMounted`, `onUnmounted`)
|
||||
|
||||
## 3. Composables (`composables/`)
|
||||
- Nama file dan nama fungsi harus diawali dengan `use` (contoh: `useQueueSync.ts`, `export const useQueueSync = () => {}`).
|
||||
- Fokuskan composable pada satu fitur spesifik (contoh: `useThermalPrint` khusus print, `useWebSocket` khusus WS).
|
||||
- Jika mengembalikan state reaktif, kembalikan objek berisi `ref` atau `computed`.
|
||||
|
||||
## 4. Pinia Stores (`stores/`)
|
||||
- Gunakan format Setup Store (seperti composable) alih-alih Option Store.
|
||||
- Berikan tipe eksplisit (TypeScript) untuk state array/object yang kompleks.
|
||||
- Jangan melakukan mutasi data store lain secara langsung, gunakan actions.
|
||||
- **PENTING:** Jika ada store baru, daftarkan di `PINIA_STORE_KEYS` di `app.vue` jika perlu di-persist dan bersihkan localStorage jika `STORE_SCHEMA_VERSION` naik.
|
||||
|
||||
```typescript
|
||||
// ✅ CONTOH SETUP STORE
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
import type { QueuePatient } from '~/types/queue';
|
||||
|
||||
export const useMyStore = defineStore('myStore', () => {
|
||||
// State
|
||||
const patients = ref<QueuePatient[]>([]);
|
||||
|
||||
// Computed
|
||||
const activePatients = computed(() => patients.value.filter(p => p.status === 'menunggu'));
|
||||
|
||||
/**
|
||||
* Action untuk menambah pasien
|
||||
* @param {QueuePatient} newPatient
|
||||
*/
|
||||
const addPatient = (newPatient: QueuePatient) => {
|
||||
patients.value.push(newPatient);
|
||||
};
|
||||
|
||||
return { patients, activePatients, addPatient };
|
||||
});
|
||||
```
|
||||
|
||||
## 5. Integrasi API & Error Handling
|
||||
- Gunakan `useRuntimeConfig().public` untuk base URL (contoh: `verificationApiBaseUrl`).
|
||||
- **JANGAN** hardcode IP address (seperti `10.10.123.140`) di dalam kode komponen atau composable. Selalu ambil dari config.
|
||||
- Gunakan composable wrapper atau `useFetch`/`$fetch` dari Nuxt.
|
||||
- Harus ada blok `try...catch` untuk setiap pemanggilan API, dengan logging error yang jelas (minimal `console.error`).
|
||||
|
||||
## 6. Styling
|
||||
- Gunakan class Vuetify sebanyak mungkin (`d-flex`, `mt-4`, `text-h5`, `text-primary`).
|
||||
- Jika butuh custom style, gunakan `<style scoped lang="scss">` dan manfaatkan variabel dari `assets/scss/_colors.scss` atau `_variables.scss` jika memungkinkan.
|
||||
|
||||
## 7. Naming Conventions
|
||||
- **Komponen:** PascalCase (contoh: `PatientCard.vue`, `AdminKlinik.vue`).
|
||||
- **File/Folder (selain komponen):** camelCase atau kebab-case tergantung standar folder (Nuxt standard: pages biasanya kebab-case atau PascalCase jika spesifik view).
|
||||
- **Fungsi/Variabel:** camelCase (contoh: `fetchPatients`, `isActive`).
|
||||
- **Tipe Data (Types/Interfaces):** PascalCase (contoh: `QueuePatient`).
|
||||
|
||||
## 8. Aturan Refactoring (Refactoring Rules)
|
||||
Saat melakukan refactoring (terutama mengkonversi `.js` lama ke `.ts` atau memecah file besar), ikuti aturan wajib berikut:
|
||||
- **Incremental Refactoring (Bertahap):** Jangan merombak seluruh file raksasa (seperti `queueStore` atau `checkIn.vue`) sekaligus. Pisahkan sebagian logika (contoh: pisahkan API call ke `useQueueAPI`, WS ke `useQueueSync`) secara bertahap untuk meminimalisasi *blast radius* (kerusakan beruntun).
|
||||
- **Isolasi Logika (Separation of Concerns):** Komponen `.vue` hanya boleh berisi logika UI (presentational). Logika bisnis berat harus dipindah ke `composables/` atau `stores/`.
|
||||
- **Backward Compatibility (Kompabilitas Mundur):** Jika mengubah struktur *store* yang banyak dipakai (contoh: `masterStore`), jangan langsung menghapus properti lama. Buat *proxy/delegate* ke store baru sampai semua komponen selesai di-update.
|
||||
- **No Implicit `any`:** Saat memigrasi `.js` ke `.ts`, WAJIB mendefinisikan tipe data/interface yang jelas di direktori `types/`. Sebisa mungkin hindari penggunaan tipe `any`.
|
||||
- **Pertahankan Business Logic:** Refactoring HANYA mengubah struktur/kebersihan kode, **BUKAN** mengubah alur bisnis kecuali diminta secara spesifik. Pastikan fitur safety seperti *polling fallback 30s* atau *auto-reconnect WS* tidak terhapus tanpa sengaja.
|
||||
- **Dokumentasikan Perubahan:** Jika refactor mengubah alur arsitektur secara signifikan, selalu catat di `docs/DEVLOG.md` dan perbarui `AGENTS.md`.
|
||||
|
||||
## 9. Reusability & Prinsip DRY (Don't Repeat Yourself)
|
||||
- Jika suatu fungsi, blok UI, atau logika API dipakai di lebih dari dua tempat, **wajib** diekstrak menjadi `Composable` (untuk logika), `Component` (untuk UI), atau file utility di `utils/`.
|
||||
- Jangan melakukan copy-paste kode panjang antar halaman.
|
||||
|
||||
## 10. Penggunaan State (Lokal vs Global)
|
||||
- **Lokal (`ref` / `reactive`):** Gunakan untuk state yang hanya hidup di satu komponen (contoh: status loading tombol, form input sementara, toggle modal).
|
||||
- **Global (Pinia):** Gunakan HANYA untuk state yang perlu dibagikan lintas halaman/komponen (contoh: data pasien antrean berjalan, data user login, master data klinik).
|
||||
|
||||
## 11. Props & Emits (Komunikasi Antar Komponen)
|
||||
- Definisikan `props` secara eksplisit menggunakan `defineProps<{ ... }>()` dengan tipe data yang ketat.
|
||||
- Gunakan `defineEmits<{ ... }>()` untuk event child-to-parent. Hindari memanipulasi prop secara langsung dari child komponen (mutate prop mutation).
|
||||
|
||||
## 12. Manajemen Siklus Hidup (Lifecycle & Cleanup)
|
||||
- Jika menginisiasi *event listener* global (`window.addEventListener`), *timer* (`setInterval`), atau *WebSocket subscription* di `onMounted`, Anda **WAJIB** membersihkannya di `onUnmounted`.
|
||||
- Kegagalan melakukan *cleanup* akan menyebabkan kebocoran memori (memory leak) terutama di aplikasi SPA (Single Page Application).
|
||||
|
||||
## 13. Navigasi & Routing (Nuxt 3)
|
||||
- Gunakan `<NuxtLink>` untuk navigasi internal di template. JANGAN menggunakan tag `<a>` biasa karena akan memicu *full page reload*.
|
||||
- Untuk navigasi terprogram (programmatic) di dalam script, gunakan `navigateTo()` atau `useRouter().push()`.
|
||||
|
||||
## 14. Keamanan Dasar (Security Practices)
|
||||
- **JANGAN PERNAH** meletakkan token JWT, password, atau secret key secara hardcode di sisi klien.
|
||||
- Baca variabel sensitif melalui `useRuntimeConfig()` yang disuplai dari `.env`.
|
||||
- Selalu percayakan validasi otorisasi di sisi *server* (middleware/Nitro), jangan hanya menyembunyikan tombol di UI.
|
||||
|
||||
## 15. Penanganan Feedback UI (Error & Success Boundaries)
|
||||
- Jangan biarkan error API tertelan diam-diam (`swallowed error`).
|
||||
- Selalu tampilkan umpan balik ke pengguna jika terjadi kegagalan (misalnya menggunakan komponen `AppSnackbar` yang sudah ada).
|
||||
- Berikan indikator visual (spinner/loading state) saat memanggil API.
|
||||
|
||||
## 16. Magic Numbers & Magic Strings
|
||||
- Hindari hardcode angka atau string spesifik yang memiliki arti logika bisnis di tengah komponen (contoh: `if (status === 3)` atau `if (role === 'superadmin')`).
|
||||
- Pindahkan ke file konstan di `constants/` atau jadikan `Enum` di direktori `types/`.
|
||||
|
||||
## 17. Penggunaan Proxy API (Bypass CORS)
|
||||
- Untuk endpoint eksternal yang terhalang CORS (seperti API Visit atau API Antrian asli), panggil selalu via *Proxy Route* bawaan Nuxt Nitro (contoh: panggil `/visit-api/...` atau `/klinik-api/...`).
|
||||
- Jangan mencoba menebak atau merubah setting header CORS di sisi *client* Vue.
|
||||
|
||||
## 18. Konsistensi Design System
|
||||
- DILARANG melakukan *hardcode* warna HEX/RGB di tag `<style>` (contoh: `color: #ff0000;`).
|
||||
- Selalu gunakan SCSS variables dari `assets/scss/_colors.scss` (contoh: `color: var(--color-primary);`) atau gunakan *utility classes* bawaan Vuetify.
|
||||
|
||||
## 19. Optimasi Performa Vue (v-memo, v-once, Lazy)
|
||||
- Untuk *list rendering* yang sangat panjang (ratusan pasien), pertimbangkan penggunaan pagination, infinite scroll, atau virtual scrolling.
|
||||
- Gunakan `LazyComponent` (prefix `Lazy` di Nuxt) untuk modal atau komponen berat yang tidak langsung muncul di layar.
|
||||
|
||||
## 20. Penanganan Koneksi Real-time (WebSocket)
|
||||
- Pastikan ada mekanisme **Auto-Reconnect** dengan *backoff delay*.
|
||||
- Selalu miliki **Polling Fallback**. Jika WS mati total, sistem harus fallback mengambil data via HTTP `setInterval` setiap 30 detik agar layar antrean tidak *stuck*.
|
||||
- Cegah duplikasi data pesan (implementasi *deduplication logic* berdasarkan ID unik pesan).
|
||||
|
||||
## 21. Isolasi State per Entitas (Loket / Ruang)
|
||||
- Jangan gunakan variabel state tunggal untuk sesuatu yang berjalan paralel.
|
||||
- Contoh buruk: `currentPatient` (berisiko loket A tertukar pasien dengan loket B).
|
||||
- Contoh benar: `currentPatientPerLoket: Record<string, Patient>` (diakses dengan ID loket).
|
||||
|
||||
## 22. Format Koding & Tanda Baca
|
||||
- Ikuti standar *formatting* yang ada: indentasi 2 spasi, gunakan *single quotes* (`'`) untuk string JS/TS, dan selalu gunakan tanda titik koma (`;`) di akhir *statement* script.
|
||||
|
||||
## 23. Standar Pengujian (Testing Readiness)
|
||||
- Setiap logika kalkulasi yang rumit (contoh: perhitungan estimasi waktu tunggu, filter spesialisasi) harus ditulis murni (pure function) agar mudah di-unit test nantinya (Vitest). Jangan mencampur logika ini dengan DOM manipulation.
|
||||
|
||||
## 24. Git Workflow & Commit Messages
|
||||
- Tulis pesan *commit* yang jelas dan bermakna mengikuti pola *Conventional Commits*:
|
||||
- `feat: ...` (fitur baru)
|
||||
- `fix: ...` (perbaikan bug)
|
||||
- `refactor: ...` (perubahan struktur tanpa ubah fitur)
|
||||
- `docs: ...` (pembaruan dokumentasi)
|
||||
- Satu *commit* sebaiknya fokus pada satu perubahan logika/fitur saja.
|
||||
@@ -1,5 +1,70 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
const nuxtApp = useNuxtApp();
|
||||
const loading = ref(false);
|
||||
|
||||
nuxtApp.hook('page:start', () => {
|
||||
loading.value = true;
|
||||
});
|
||||
nuxtApp.hook('page:finish', () => {
|
||||
loading.value = false;
|
||||
});
|
||||
|
||||
// Naikkan versi ini setiap kali ada perubahan struktur state (schema migration).
|
||||
// Saat versi tidak cocok, semua cache Pinia akan dibersihkan otomatis.
|
||||
const STORE_SCHEMA_VERSION = 4;
|
||||
const VERSION_KEY = 'app-store-version';
|
||||
|
||||
// Daftar semua localStorage key yang dikelola oleh Pinia stores
|
||||
const PINIA_STORE_KEYS = [
|
||||
'queue-store-state',
|
||||
'clinic-store-state',
|
||||
'doctor-store',
|
||||
'loket-store-state',
|
||||
'ruang-store-state',
|
||||
'klinikruang-store-state',
|
||||
'antrean-masuk-screen-store',
|
||||
'anjungan-store',
|
||||
'screen-store',
|
||||
'penunjang-store',
|
||||
];
|
||||
|
||||
onMounted(() => {
|
||||
try {
|
||||
if (typeof window === 'undefined' || !window.localStorage) return;
|
||||
|
||||
const savedVersion = parseInt(localStorage.getItem(VERSION_KEY) || '0', 10);
|
||||
|
||||
if (savedVersion < STORE_SCHEMA_VERSION) {
|
||||
// Versi lama terdeteksi → hapus semua cache Pinia sekaligus
|
||||
console.log(`[Migration] Store schema v${savedVersion} → v${STORE_SCHEMA_VERSION}. Clearing all caches...`);
|
||||
PINIA_STORE_KEYS.forEach(key => localStorage.removeItem(key));
|
||||
localStorage.setItem(VERSION_KEY, String(STORE_SCHEMA_VERSION));
|
||||
console.log('[Migration] Done. App will fetch fresh data from API.');
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore storage errors (e.g., private browsing mode)
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-app>
|
||||
<v-overlay
|
||||
:model-value="loading"
|
||||
class="align-center justify-center"
|
||||
persistent
|
||||
z-index="9999"
|
||||
>
|
||||
<v-progress-circular
|
||||
color="primary"
|
||||
indeterminate
|
||||
size="64"
|
||||
width="6"
|
||||
></v-progress-circular>
|
||||
</v-overlay>
|
||||
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// variables for Grand Paviliun (Eksekutif)
|
||||
.theme-grand-paviliun {
|
||||
--gp-primary: #3F51B5; // Indigo header
|
||||
--gp-primary-dark: #303F9F;
|
||||
--gp-bg: #F5F7FA; // Light grayish blue background
|
||||
--gp-surface: #FFFFFF;
|
||||
--gp-text-primary: #1F2937;
|
||||
--gp-text-secondary: #6B7280;
|
||||
--gp-border: #E5E7EB;
|
||||
|
||||
--gp-success: #10B981; // Selesai
|
||||
--gp-danger: #EF4444; // Pending
|
||||
--gp-warning: #F59E0B;
|
||||
--gp-info: #3B82F6;
|
||||
|
||||
// Custom colors for specific tags
|
||||
--gp-room-number: #F97316; // Orange text for RUANG 02
|
||||
|
||||
background-color: var(--gp-bg);
|
||||
min-height: 100vh;
|
||||
font-family: 'Inter', 'Roboto', sans-serif;
|
||||
}
|
||||
|
||||
// Utility classes
|
||||
.gp-text-primary { color: var(--gp-text-primary); }
|
||||
.gp-text-secondary { color: var(--gp-text-secondary); }
|
||||
.gp-font-bold { font-weight: 700; }
|
||||
.gp-font-semibold { font-weight: 600; }
|
||||
@@ -1,7 +1,7 @@
|
||||
import { defineEventHandler, proxyRequest } from 'h3'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const config = useRuntimeConfig(event)
|
||||
const targetBase = config.public.verificationApiBaseUrl || 'http://10.10.123.140:8089/api/v1'
|
||||
|
||||
// Extract path suffix after /klinik-api
|
||||
@@ -16,12 +16,13 @@ export default defineEventHandler(async (event) => {
|
||||
const urlObj = new URL(targetBase)
|
||||
headers.host = urlObj.host
|
||||
} catch (e) {
|
||||
headers.host = '10.10.123.140:8089'
|
||||
headers.host = config.proxyTargetHostKlinikFallback || '10.10.123.140:8089'
|
||||
}
|
||||
|
||||
// Spoof the origin and referer to match the server's trusted/whitelisted client origin
|
||||
headers.origin = 'http://10.10.150.175:3000'
|
||||
headers.referer = 'http://10.10.150.175:3000/'
|
||||
const proxyOrigin = config.proxyClientOrigin as string
|
||||
headers.origin = proxyOrigin
|
||||
headers.referer = proxyOrigin.endsWith('/') ? proxyOrigin : `${proxyOrigin}/`
|
||||
|
||||
console.log(`[Proxy Klinik-API] Forwarding ${event.node.req.method} to: ${targetUrl}`)
|
||||
console.log(`[Proxy Klinik-API] Headers - Host: ${headers.host}, Origin: ${headers.origin}`)
|
||||
@@ -1,7 +1,7 @@
|
||||
import { defineEventHandler, proxyRequest } from 'h3'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const config = useRuntimeConfig(event)
|
||||
const targetBase = config.public.externalApiBaseUrl || 'http://10.10.123.135:8084/api/v1'
|
||||
|
||||
// Extract path suffix after /stats-api
|
||||
@@ -16,12 +16,13 @@ export default defineEventHandler(async (event) => {
|
||||
const urlObj = new URL(targetBase)
|
||||
headers.host = urlObj.host
|
||||
} catch (e) {
|
||||
headers.host = '10.10.123.135:8084'
|
||||
headers.host = config.proxyTargetHostFallback || '10.10.123.135:8084'
|
||||
}
|
||||
|
||||
// Spoof the origin and referer to match the server's trusted/whitelisted client origin
|
||||
headers.origin = 'http://10.10.150.175:3000'
|
||||
headers.referer = 'http://10.10.150.175:3000/'
|
||||
const proxyOrigin = config.proxyClientOrigin as string
|
||||
headers.origin = proxyOrigin
|
||||
headers.referer = proxyOrigin.endsWith('/') ? proxyOrigin : `${proxyOrigin}/`
|
||||
|
||||
console.log(`[Proxy Stats-API] Forwarding ${event.node.req.method} to: ${targetUrl}`)
|
||||
console.log(`[Proxy Stats-API] Headers - Host: ${headers.host}, Origin: ${headers.origin}`)
|
||||
@@ -1,7 +1,7 @@
|
||||
import { defineEventHandler, proxyRequest } from 'h3'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const config = useRuntimeConfig(event)
|
||||
const targetBase = config.public.externalApiBaseUrl || 'http://10.10.123.135:8084/api/v1'
|
||||
|
||||
// Extract path suffix after /visit-api
|
||||
@@ -16,12 +16,13 @@ export default defineEventHandler(async (event) => {
|
||||
const urlObj = new URL(targetBase)
|
||||
headers.host = urlObj.host
|
||||
} catch (e) {
|
||||
headers.host = '10.10.123.135:8084'
|
||||
headers.host = config.proxyTargetHostFallback || '10.10.123.135:8084'
|
||||
}
|
||||
|
||||
// Spoof the origin and referer to match the server's trusted/whitelisted client origin
|
||||
headers.origin = 'http://10.10.150.175:3000'
|
||||
headers.referer = 'http://10.10.150.175:3000/'
|
||||
const proxyOrigin = config.proxyClientOrigin as string
|
||||
headers.origin = proxyOrigin
|
||||
headers.referer = proxyOrigin.endsWith('/') ? proxyOrigin : `${proxyOrigin}/`
|
||||
|
||||
console.log(`[Proxy Visit-API] Forwarding ${event.node.req.method} to: ${targetUrl}`)
|
||||
console.log(`[Proxy Visit-API] Headers - Host: ${headers.host}, Origin: ${headers.origin}`)
|
||||
@@ -0,0 +1,174 @@
|
||||
<template>
|
||||
<div class="theme-grand-paviliun gp-container">
|
||||
<!-- Header -->
|
||||
<div class="gp-header">
|
||||
<div class="header-left">
|
||||
<v-icon size="24" color="white" class="mr-3">mdi-domain</v-icon>
|
||||
<div>
|
||||
<h1 class="header-title">Klinik Admin Grand Paviliun</h1>
|
||||
<p class="header-date">{{ currentDate }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<v-btn color="#F97316" variant="flat" class="text-white font-weight-bold">
|
||||
<v-icon start size="18">mdi-account-cog</v-icon>
|
||||
KELOLA PASIEN
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Bar -->
|
||||
<div class="gp-search-bar">
|
||||
<v-text-field
|
||||
v-model="searchQuery"
|
||||
placeholder="Cari pasien di semua ruang (barcode, nomor antrian, nama...)"
|
||||
density="compact"
|
||||
hide-details
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
variant="outlined"
|
||||
bg-color="white"
|
||||
class="search-input"
|
||||
></v-text-field>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="gp-main-content">
|
||||
<div class="kanban-board">
|
||||
<GpRoomColumn
|
||||
v-for="clinic in filteredClinics"
|
||||
:key="clinic.kodeKlinik"
|
||||
:clinic-name="clinic.namaKlinik"
|
||||
:current-patient="clinic.currentPatient"
|
||||
:queue-patients="clinic.queuePatients"
|
||||
:room-options="clinic.roomOptions"
|
||||
@pemeriksaan-awal="(p, r) => handlePemeriksaanAwal(p, clinic.kodeKlinik, r)"
|
||||
@panggil-pemeriksaan="(p, r) => handlePanggilPemeriksaan(p, clinic.kodeKlinik, r)"
|
||||
@action-pending="p => handleAction(p, 'pending', clinic.kodeKlinik)"
|
||||
@action-selesai="p => handleAction(p, 'selesai', clinic.kodeKlinik)"
|
||||
@process-patient="p => handleProcess(p, clinic.kodeKlinik, clinic.roomOptions?.[0]?.nomorRuang || '1')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Right Sidebar -->
|
||||
<GpMonitorSidebar :rooms="monitorRooms" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import GpRoomColumn from '../GrandPaviliun/GpRoomColumn.vue';
|
||||
import GpMonitorSidebar from '../GrandPaviliun/GpMonitorSidebar.vue';
|
||||
import { useGrandPaviliun } from '@/composables/useGrandPaviliun';
|
||||
import '@/assets/scss/eksekutif.scss';
|
||||
|
||||
const {
|
||||
clinics,
|
||||
monitorRooms,
|
||||
fetchEksekutifData,
|
||||
handlePemeriksaanAwal,
|
||||
handlePanggilPemeriksaan,
|
||||
processPatientAction
|
||||
} = useGrandPaviliun();
|
||||
const searchQuery = ref('');
|
||||
|
||||
const filteredClinics = computed(() => {
|
||||
if (!searchQuery.value) return clinics.value;
|
||||
const lowerSearch = searchQuery.value.toLowerCase();
|
||||
|
||||
// Filter by patient barcode, queue number, or name in the clinic columns
|
||||
return clinics.value.map(clinic => {
|
||||
const isCurrentMatch = clinic.currentPatient && (
|
||||
(clinic.currentPatient.noAntrian || '').toLowerCase().includes(lowerSearch) ||
|
||||
(clinic.currentPatient.barcode || '').toLowerCase().includes(lowerSearch) ||
|
||||
(clinic.currentPatient.name || '').toLowerCase().includes(lowerSearch)
|
||||
);
|
||||
|
||||
const matchedQueue = clinic.queuePatients.filter(p =>
|
||||
(p.noAntrian || '').toLowerCase().includes(lowerSearch) ||
|
||||
(p.barcode || '').toLowerCase().includes(lowerSearch) ||
|
||||
(p.name || '').toLowerCase().includes(lowerSearch)
|
||||
);
|
||||
|
||||
return {
|
||||
...clinic,
|
||||
currentPatient: isCurrentMatch ? clinic.currentPatient : null,
|
||||
queuePatients: matchedQueue
|
||||
};
|
||||
}).filter(c => c.currentPatient || c.queuePatients.length > 0);
|
||||
});
|
||||
|
||||
// Action Handlers
|
||||
const handleAction = async (patient, action, kodeKlinik) => {
|
||||
// Use the current room of the patient or fallback to '1'
|
||||
const nomorRuang = patient.nomorRuang || '1';
|
||||
await processPatientAction(patient, action, kodeKlinik, nomorRuang);
|
||||
};
|
||||
|
||||
const handleProcess = async (patient, kodeKlinik, fallbackRuang) => {
|
||||
const action = patient.status === 'pending' ? 'waiting' : 'proses';
|
||||
await processPatientAction(patient, action, kodeKlinik, fallbackRuang);
|
||||
};
|
||||
|
||||
// Date Formatting
|
||||
const currentDate = computed(() => {
|
||||
const options = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' };
|
||||
return new Date().toLocaleDateString('id-ID', options);
|
||||
});
|
||||
|
||||
// Load data when mounted
|
||||
fetchEksekutifData();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.gp-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background-color: var(--gp-bg, #F5F7FA);
|
||||
}
|
||||
.gp-header {
|
||||
background-color: var(--gp-primary, #3F51B5);
|
||||
color: white;
|
||||
padding: 16px 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.header-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.header-date {
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
margin: 0;
|
||||
}
|
||||
.gp-search-bar {
|
||||
padding: 12px 24px;
|
||||
background: white;
|
||||
border-bottom: 1px solid var(--gp-border, #e5e7eb);
|
||||
}
|
||||
.search-input {
|
||||
max-width: 100%;
|
||||
}
|
||||
.gp-main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
.kanban-board {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
padding: 16px 24px;
|
||||
align-items: stretch;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<div class="gp-current-patient">
|
||||
<div class="header-row">
|
||||
<span class="label">SEDANG DIPROSES</span>
|
||||
<div class="badges">
|
||||
<button class="action-badge" @click="$emit('action-pending')"><GpStatusBadge status="PENDING" /></button>
|
||||
<button class="action-badge ml-1" @click="$emit('action-selesai')"><GpStatusBadge status="SELESAI" /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="patient-big-info">
|
||||
<div class="queue-number">{{ queueNumber || '-' }}</div>
|
||||
<div class="room-indicator" v-if="roomName">{{ roomName }}</div>
|
||||
</div>
|
||||
|
||||
<div class="action-section">
|
||||
<label class="input-label">Tujukan ke ruang*</label>
|
||||
<v-select
|
||||
v-model="selectedRoom"
|
||||
:items="roomOptions"
|
||||
item-title="namaRuang"
|
||||
item-value="nomorRuang"
|
||||
placeholder="Pilih Ruang Pemeriksaan"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
class="room-select mb-3"
|
||||
></v-select>
|
||||
|
||||
<div class="action-buttons">
|
||||
<v-btn
|
||||
color="#3F51B5"
|
||||
variant="flat"
|
||||
class="flex-1 text-white text-none font-weight-bold"
|
||||
size="small"
|
||||
@click="$emit('pemeriksaan-awal')"
|
||||
>
|
||||
<v-icon start size="16">mdi-stethoscope</v-icon>
|
||||
Pemeriksaan Awal
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="#3F51B5"
|
||||
variant="flat"
|
||||
class="flex-1 text-white text-none font-weight-bold ml-2"
|
||||
size="small"
|
||||
@click="$emit('panggil-pemeriksaan')"
|
||||
>
|
||||
<v-icon start size="16">mdi-bullhorn</v-icon>
|
||||
Panggil Pemeriksaan
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import GpStatusBadge from './GpStatusBadge.vue';
|
||||
|
||||
const props = defineProps({
|
||||
queueNumber: { type: String, default: '' },
|
||||
roomName: { type: String, default: '' },
|
||||
roomOptions: { type: Array, default: () => [] }
|
||||
});
|
||||
|
||||
const emit = defineEmits(['pemeriksaan-awal', 'panggil-pemeriksaan', 'action-pending', 'action-selesai', 'update:selectedRoom']);
|
||||
|
||||
const selectedRoom = ref(props.roomOptions?.[0]?.nomorRuang || null);
|
||||
watch(selectedRoom, (newVal) => {
|
||||
emit('update:selectedRoom', newVal);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.gp-current-patient {
|
||||
background: white;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--gp-border, #e5e7eb);
|
||||
}
|
||||
.header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--gp-text-secondary, #6b7280);
|
||||
}
|
||||
.patient-big-info {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.queue-number {
|
||||
font-size: 36px;
|
||||
font-weight: 800;
|
||||
color: var(--gp-text-primary, #1f2937);
|
||||
line-height: 1.2;
|
||||
}
|
||||
.room-indicator {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--gp-room-number, #F97316);
|
||||
margin-top: 4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.input-label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--gp-text-secondary, #6b7280);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.room-select {
|
||||
/* vuetify default is fine */
|
||||
}
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
.flex-1 {
|
||||
flex: 1;
|
||||
}
|
||||
.action-badge {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
.action-badge:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<div class="gp-monitor-sidebar">
|
||||
<div class="sidebar-header">
|
||||
<v-icon size="18" class="mr-2" color="#3F51B5">mdi-monitor-dashboard</v-icon>
|
||||
<span class="title">MONITOR RUANG</span>
|
||||
</div>
|
||||
|
||||
<div class="stats-row">
|
||||
<div class="stat-box box-success">
|
||||
<div class="stat-label text-success">TERSEDIA</div>
|
||||
<div class="stat-value text-success">{{ availableCount }}</div>
|
||||
</div>
|
||||
<div class="stat-box box-danger ml-2">
|
||||
<div class="stat-label text-danger">SIBUK</div>
|
||||
<div class="stat-value text-danger">{{ busyCount }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rooms-list">
|
||||
<GpRoomMonitorCard
|
||||
v-for="room in rooms"
|
||||
:key="room.id"
|
||||
:room-name="room.name"
|
||||
:active-patient="room.activePatient"
|
||||
:clinic-name="room.clinicName"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import GpRoomMonitorCard from './GpRoomMonitorCard.vue';
|
||||
|
||||
const props = defineProps({
|
||||
rooms: { type: Array, default: () => [] }
|
||||
});
|
||||
|
||||
const availableCount = computed(() => props.rooms.filter(r => !r.activePatient).length);
|
||||
const busyCount = computed(() => props.rooms.filter(r => r.activePatient).length);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.gp-monitor-sidebar {
|
||||
width: 300px;
|
||||
background: white;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-left: 1px solid var(--gp-border, #e5e7eb);
|
||||
}
|
||||
.sidebar-header {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--gp-border, #e5e7eb);
|
||||
}
|
||||
.title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--gp-text-primary, #1f2937);
|
||||
}
|
||||
.stats-row {
|
||||
display: flex;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--gp-border, #e5e7eb);
|
||||
}
|
||||
.stat-box {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.box-success {
|
||||
background: #ECFDF5;
|
||||
border: 1px solid #D1FAE5;
|
||||
}
|
||||
.box-danger {
|
||||
background: #FFF7ED;
|
||||
border: 1px solid #FFEDD5;
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.text-success { color: var(--gp-success, #10b981); }
|
||||
.text-danger { color: var(--gp-room-number, #F97316); } /* Orange */
|
||||
|
||||
.rooms-list {
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div class="gp-queue-list-container">
|
||||
<div class="list-header">
|
||||
<span class="title">DAFTAR ANTRIAN</span>
|
||||
<span class="count-badge">{{ patients.length }}</span>
|
||||
</div>
|
||||
<div class="list-body">
|
||||
<GpQueueListItem
|
||||
v-for="patient in patients"
|
||||
:key="patient.id || patient.noAntrian"
|
||||
:queue-number="patient.noAntrian"
|
||||
:status="patient.status"
|
||||
:is-active="patient.isActive"
|
||||
@view-detail="$emit('view-detail', patient)"
|
||||
@process-patient="$emit('process-patient', patient)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import GpQueueListItem from './GpQueueListItem.vue';
|
||||
|
||||
defineProps({
|
||||
patients: { type: Array, default: () => [] }
|
||||
});
|
||||
|
||||
defineEmits(['view-detail', 'process-patient']);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.gp-queue-list-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
background: white;
|
||||
min-height: 200px;
|
||||
}
|
||||
.list-header {
|
||||
padding: 12px 16px;
|
||||
background: #EEF2F6; /* Light blue-gray from design */
|
||||
border-bottom: 1px solid var(--gp-border, #e5e7eb);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.title {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--gp-text-secondary, #6b7280);
|
||||
}
|
||||
.count-badge {
|
||||
background: #D1D5DB; /* Gray */
|
||||
color: white;
|
||||
border-radius: 12px;
|
||||
padding: 2px 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.list-body {
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<div :class="['gp-queue-list-item', { active: isActive }]">
|
||||
<div class="item-left">
|
||||
<span class="queue-number">{{ queueNumber }}</span>
|
||||
</div>
|
||||
<div class="item-right">
|
||||
<GpStatusBadge v-if="status && !isActive" :status="status" />
|
||||
<v-btn v-if="!isActive" icon="mdi-play" size="x-small" variant="text" color="success" class="ml-1" @click="$emit('process-patient')" />
|
||||
<v-btn v-if="isActive" icon="mdi-eye" size="x-small" variant="text" color="#3F51B5" @click="$emit('view-detail')" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import GpStatusBadge from './GpStatusBadge.vue';
|
||||
|
||||
defineProps({
|
||||
queueNumber: { type: String, required: true },
|
||||
status: { type: String, default: '' },
|
||||
isActive: { type: Boolean, default: false }
|
||||
});
|
||||
|
||||
defineEmits(['view-detail', 'process-patient']);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.gp-queue-list-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--gp-border, #e5e7eb);
|
||||
background-color: var(--gp-surface, #fff);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
.gp-queue-list-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.gp-queue-list-item.active {
|
||||
background-color: #E3EBFF; /* Light blue highlighting active patient */
|
||||
}
|
||||
.queue-number {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--gp-text-primary, #1f2937);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div class="gp-room-column">
|
||||
<div class="column-header">
|
||||
<v-icon size="20" class="mr-2" color="white">mdi-domain</v-icon>
|
||||
<span class="column-title">{{ clinicName.toUpperCase() }}</span>
|
||||
</div>
|
||||
|
||||
<div class="column-content">
|
||||
<template v-if="currentPatient || queuePatients.length > 0">
|
||||
<GpCurrentPatient
|
||||
v-if="currentPatient"
|
||||
:queue-number="currentPatient.noAntrian"
|
||||
:room-name="currentPatient.roomIndicator"
|
||||
:room-options="roomOptions"
|
||||
@pemeriksaan-awal="$emit('pemeriksaan-awal', currentPatient, $event)"
|
||||
@panggil-pemeriksaan="$emit('panggil-pemeriksaan', currentPatient, $event)"
|
||||
@action-pending="$emit('action-pending', currentPatient)"
|
||||
@action-selesai="$emit('action-selesai', currentPatient)"
|
||||
/>
|
||||
|
||||
<GpQueueList
|
||||
:patients="queuePatients"
|
||||
@view-detail="p => $emit('view-detail', p)"
|
||||
@process-patient="p => $emit('process-patient', p)"
|
||||
/>
|
||||
</template>
|
||||
<div v-else class="empty-column-state">
|
||||
<v-icon size="48" color="#D1D5DB" class="mb-4">mdi-account-off</v-icon>
|
||||
<div class="empty-text">TIDAK ADA PASIEN YANG DIPROSES</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import GpCurrentPatient from './GpCurrentPatient.vue';
|
||||
import GpQueueList from './GpQueueList.vue';
|
||||
|
||||
defineProps({
|
||||
clinicName: { type: String, required: true },
|
||||
currentPatient: { type: Object, default: null },
|
||||
queuePatients: { type: Array, default: () => [] },
|
||||
roomOptions: { type: Array, default: () => [] }
|
||||
});
|
||||
|
||||
defineEmits(['pemeriksaan-awal', 'panggil-pemeriksaan', 'view-detail', 'action-pending', 'action-selesai', 'process-patient']);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.gp-room-column {
|
||||
width: 380px;
|
||||
min-width: 380px;
|
||||
background: var(--gp-bg, #F5F7FA);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
margin-right: 16px;
|
||||
}
|
||||
.column-header {
|
||||
background: var(--gp-primary, #3F51B5);
|
||||
color: white;
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.column-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.column-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: white; /* Make the whole column body white */
|
||||
}
|
||||
.empty-column-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
background: white;
|
||||
}
|
||||
.empty-text {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--gp-text-secondary, #6b7280);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div class="gp-room-monitor-card" :class="{ 'is-busy': isBusy }">
|
||||
<div class="card-header">
|
||||
<span class="room-name">{{ roomName }}</span>
|
||||
<span class="room-status" :class="isBusy ? 'text-danger' : 'text-success'">
|
||||
{{ isBusy ? 'SIBUK' : 'TERSEDIA' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<span v-if="isBusy" class="active-patient">{{ activePatient }} - {{ clinicName }}</span>
|
||||
<span v-else class="waiting-text">Menunggu pasien...</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
roomName: { type: String, required: true },
|
||||
activePatient: { type: String, default: null }, // e.g. "AI002"
|
||||
clinicName: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const isBusy = computed(() => !!props.activePatient);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.gp-room-monitor-card {
|
||||
background: var(--gp-surface, #fff);
|
||||
border: 1px solid var(--gp-border, #e5e7eb);
|
||||
border-left: 4px solid var(--gp-success, #10b981);
|
||||
border-radius: 2px;
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.02);
|
||||
}
|
||||
.gp-room-monitor-card.is-busy {
|
||||
border-left-color: var(--gp-room-number, #F97316); /* Orange */
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.room-name {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--gp-text-primary, #1f2937);
|
||||
}
|
||||
.room-status {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.text-success { color: var(--gp-success, #10b981); }
|
||||
.text-danger { color: var(--gp-room-number, #F97316); } /* Matching image orange */
|
||||
|
||||
.card-body {
|
||||
font-size: 13px;
|
||||
}
|
||||
.active-patient {
|
||||
color: var(--gp-primary, #3F51B5);
|
||||
font-weight: 700;
|
||||
}
|
||||
.waiting-text {
|
||||
color: var(--gp-text-secondary, #6b7280);
|
||||
font-style: italic;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<span :class="['gp-badge', badgeClass]">
|
||||
{{ label }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
status: { type: String, required: true },
|
||||
text: { type: String, default: '' }
|
||||
});
|
||||
|
||||
const label = computed(() => props.text || props.status.toUpperCase());
|
||||
|
||||
const badgeClass = computed(() => {
|
||||
const s = props.status.toLowerCase();
|
||||
if (s === 'pending' || s === 'sibuk') return 'gp-badge-danger';
|
||||
if (s === 'selesai' || s === 'tersedia' || s === 'proses') return 'gp-badge-success';
|
||||
return 'gp-badge-default';
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.gp-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px 6px;
|
||||
border-radius: 2px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
color: white;
|
||||
}
|
||||
.gp-badge-danger { background-color: var(--gp-danger, #ef4444); }
|
||||
.gp-badge-success { background-color: var(--gp-success, #10b981); }
|
||||
.gp-badge-default { background-color: var(--gp-text-secondary, #6b7280); }
|
||||
</style>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
<template>
|
||||
<v-dialog v-model="internalModel" max-width="500px">
|
||||
<v-card class="dialog-card overflow-hidden rounded-xl" elevation="0">
|
||||
<!-- Solid Blue Header -->
|
||||
<v-card-title class="bg-primary-600 text-white py-4 px-6 d-flex justify-space-between align-center">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon start icon="mdi-alert-circle-outline" class="mr-2"></v-icon>
|
||||
<span class="text-h6 font-weight-bold">Konfirmasi Selesai</span>
|
||||
</div>
|
||||
<v-btn icon="mdi-close" variant="text" size="small" @click="internalModel = false" class="text-white"></v-btn>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="dialog-content-premium pa-6 text-center">
|
||||
<div class="text-center mb-6 text-uppercase font-weight-bold text-caption text-neutral-600" style="letter-spacing: 1px;">
|
||||
Antrean Belum Dibuat
|
||||
</div>
|
||||
|
||||
<p class="text-body-1 mb-6">
|
||||
Tiket antrean ruang atau penunjang <strong>belum dibuat</strong> untuk pasien ini.
|
||||
</p>
|
||||
|
||||
<div class="comparison-container mb-6 mx-auto" style="max-width: 220px; justify-content: center;">
|
||||
<div class="comparison-item current">
|
||||
<div class="item-label text-center">PASIEN AKTIF</div>
|
||||
<div class="item-card">
|
||||
<div class="item-number text-danger-700">{{ patient?.noAntrian?.split(' |')[0] || '-' }}</div>
|
||||
<div class="item-status">Sedang Diproses</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-body-2 text-neutral-600">
|
||||
Apakah Anda yakin ingin menyelesaikan pelayanan untuk pasien ini?
|
||||
</p>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-actions class="pa-4 bg-light justify-center">
|
||||
<v-btn
|
||||
class="px-8 font-weight-bold rounded-lg text-none btn-batal"
|
||||
variant="outlined"
|
||||
@click="internalModel = false"
|
||||
size="large"
|
||||
>
|
||||
Batal
|
||||
</v-btn>
|
||||
<v-btn
|
||||
class="px-8 font-weight-bold ml-4 rounded-lg text-none btn-confirm"
|
||||
variant="flat"
|
||||
@click="confirm"
|
||||
size="large"
|
||||
elevation="2"
|
||||
>
|
||||
Ya, Selesaikan
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
patient: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'confirm']);
|
||||
|
||||
const internalModel = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val)
|
||||
});
|
||||
|
||||
const confirm = () => {
|
||||
emit('confirm');
|
||||
internalModel.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dialog-card {
|
||||
/* Remove border to prevent white outline around blue header */
|
||||
}
|
||||
.bg-light {
|
||||
background-color: var(--color-neutral-50) !important;
|
||||
}
|
||||
|
||||
.comparison-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: white;
|
||||
padding: 16px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--color-neutral-300);
|
||||
}
|
||||
|
||||
.comparison-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.item-label {
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
color: var(--color-neutral-600);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.item-card {
|
||||
background: white;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--color-neutral-400);
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.item-number {
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.item-status {
|
||||
font-size: 11px;
|
||||
color: var(--color-neutral-600);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.text-danger-700 {
|
||||
color: var(--color-danger-700) !important;
|
||||
}
|
||||
|
||||
.btn-batal {
|
||||
border-color: var(--color-neutral-300) !important;
|
||||
color: var(--color-neutral-700) !important;
|
||||
}
|
||||
|
||||
.btn-confirm {
|
||||
background-color: var(--color-primary-600) !important;
|
||||
color: white !important;
|
||||
}
|
||||
</style>
|
||||
@@ -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);
|
||||
|
||||
@@ -34,15 +34,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right d-flex align-center" style="gap: 8px;">
|
||||
<v-chip
|
||||
v-if="patient.ruang"
|
||||
color="primary-600"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
class="subspesialis-chip text-caption font-weight-bold"
|
||||
>
|
||||
{{ patient.ruang }}
|
||||
</v-chip>
|
||||
<v-chip
|
||||
:color="getStatusColor(patient.status)"
|
||||
size="small"
|
||||
@@ -63,6 +54,17 @@
|
||||
>
|
||||
{{ patient.klinik }}
|
||||
</v-chip>
|
||||
<!-- Sub Spesialis -->
|
||||
<v-chip
|
||||
v-if="patient.ruang"
|
||||
color="primary-600"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
class="subspesialis-chip font-weight-bold"
|
||||
style="height: 24px; font-size: 11px;"
|
||||
>
|
||||
{{ patient.ruang }}
|
||||
</v-chip>
|
||||
<!-- Tipe Layanan atau Jenis Pasien -->
|
||||
<v-chip
|
||||
v-if="displayJenisPasien"
|
||||
|
||||
@@ -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>
|
||||
@@ -487,7 +487,11 @@ const handleAction = (patient, action) => {
|
||||
padding: 8px 12px;
|
||||
background: var(--color-neutral-200);
|
||||
border-radius: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid var(--color-neutral-400);
|
||||
border-left: 3px solid var(--color-primary-600);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.results-text {
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
<template>
|
||||
<v-dialog v-model="internalModel" max-width="500px">
|
||||
<v-card class="dialog-card overflow-hidden rounded-xl" elevation="0">
|
||||
<!-- Solid Blue Header -->
|
||||
<v-card-title class="bg-primary-600 text-white py-4 px-6 d-flex justify-space-between align-center">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon start icon="mdi-alert-circle-outline" class="mr-2"></v-icon>
|
||||
<span class="text-h6 font-weight-bold">Konfirmasi Selesai</span>
|
||||
</div>
|
||||
<v-btn icon="mdi-close" variant="text" size="small" @click="internalModel = false" class="text-white"></v-btn>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="dialog-content-premium pa-6 text-center">
|
||||
<div class="text-center mb-6 text-uppercase font-weight-bold text-caption text-neutral-600" style="letter-spacing: 1px;">
|
||||
Tindakan Diperlukan
|
||||
</div>
|
||||
|
||||
<p class="text-body-1 mb-6">
|
||||
Pasien ini <strong>sudah dibuatkan</strong> tiket antrean ruang atau penunjang, namun statusnya belum diselesaikan di Loket.
|
||||
</p>
|
||||
|
||||
<div class="comparison-container mb-6 mx-auto" style="max-width: 220px; justify-content: center;">
|
||||
<div class="comparison-item current">
|
||||
<div class="item-label text-center">PASIEN AKTIF</div>
|
||||
<div class="item-card">
|
||||
<div class="item-number text-danger-700">{{ patient?.noAntrian?.split(' |')[0] || '-' }}</div>
|
||||
<div class="item-status">Menunggu Selesai</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-body-2 text-neutral-600">
|
||||
Apakah Anda ingin menyelesaikan pasien ini sekarang dan melanjutkan ke antrean berikutnya?
|
||||
</p>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-actions class="pa-4 bg-light justify-center">
|
||||
<v-btn
|
||||
class="px-8 font-weight-bold rounded-lg text-none btn-batal"
|
||||
variant="outlined"
|
||||
@click="internalModel = false"
|
||||
size="large"
|
||||
>
|
||||
Batal
|
||||
</v-btn>
|
||||
<v-btn
|
||||
class="px-6 font-weight-bold ml-4 rounded-lg text-none btn-confirm"
|
||||
variant="flat"
|
||||
@click="confirm"
|
||||
size="large"
|
||||
elevation="2"
|
||||
>
|
||||
Ya, Selesaikan
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
patient: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'confirm']);
|
||||
|
||||
const internalModel = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val)
|
||||
});
|
||||
|
||||
const confirm = () => {
|
||||
emit('confirm');
|
||||
internalModel.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dialog-card {
|
||||
/* No border to prevent white outline */
|
||||
}
|
||||
.bg-light {
|
||||
background-color: var(--color-neutral-50) !important;
|
||||
}
|
||||
|
||||
|
||||
.comparison-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: white;
|
||||
padding: 16px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--color-neutral-300);
|
||||
}
|
||||
|
||||
.comparison-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.item-label {
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
color: var(--color-neutral-600);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.item-card {
|
||||
background: white;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--color-neutral-400);
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.item-number {
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.item-status {
|
||||
font-size: 11px;
|
||||
color: var(--color-neutral-600);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.text-danger-700 {
|
||||
color: var(--color-danger-700) !important;
|
||||
}
|
||||
|
||||
.btn-batal {
|
||||
border-color: var(--color-neutral-300) !important;
|
||||
color: var(--color-neutral-700) !important;
|
||||
}
|
||||
|
||||
.btn-confirm {
|
||||
background-color: var(--color-primary-600) !important;
|
||||
color: white !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,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
|
||||
};
|
||||
};
|
||||
+14
-4
@@ -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;
|
||||
@@ -186,14 +194,15 @@ export const useQueue = (adminType = "loket", specificId = null) => {
|
||||
showSnackbar(result.message, result.success ? "success" : "warning");
|
||||
};
|
||||
|
||||
const processPatient = (patient, action) => {
|
||||
const result = queueStore.processPatient(patient, action, adminType, idValue.value);
|
||||
const processPatient = async (patient, action) => {
|
||||
const result = await queueStore.processPatient(patient, action, adminType, idValue.value);
|
||||
|
||||
let color = "success";
|
||||
if (action === "terlambat") color = "warning";
|
||||
else if (action === "pending") color = "info";
|
||||
|
||||
showSnackbar(result.message, color);
|
||||
return result;
|
||||
};
|
||||
|
||||
// Helper function untuk mendapatkan pasien yang sedang diproses dari store
|
||||
@@ -293,9 +302,10 @@ export const useQueue = (adminType = "loket", specificId = null) => {
|
||||
};
|
||||
|
||||
|
||||
const processNextQueue = () => {
|
||||
const result = queueStore.processNextQueue(adminType, idValue.value);
|
||||
const processNextQueue = async () => {
|
||||
const result = await queueStore.processNextQueue(adminType, idValue.value);
|
||||
showSnackbar(result.message, result.success ? "success" : "warning");
|
||||
return result;
|
||||
};
|
||||
|
||||
const getRowClass = (item) => {
|
||||
|
||||
+41
-172
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -126,18 +126,25 @@ export const useThermalPrint = () => {
|
||||
// Format nomor antrian (hilangkan bagian "| Onsite - barcode")
|
||||
const noAntrianDisplay = data.noAntrian.split(' |')[0];
|
||||
|
||||
// Format informasi ruang: "Poli Anak - Ruang A"
|
||||
// Format informasi ruang: "Poli Anak - Ruang A" atau "Poli Anak - Gastro-Hepatologi"
|
||||
let ruangInfo = '';
|
||||
if (data.klinik && data.nomorRuang) {
|
||||
// Konversi nomor ruang ke abjad (1 = A, 2 = B, 3 = C, dst)
|
||||
const ruangNumber = parseInt(data.nomorRuang) || 1;
|
||||
const ruangLetter = String.fromCharCode(64 + ruangNumber); // 64 = '@', 65 = 'A', 66 = 'B', dst
|
||||
ruangInfo = `${data.klinik} - Ruang ${ruangLetter}`;
|
||||
} else if (data.klinik && data.ruang) {
|
||||
// Fallback jika hanya ada nama ruang
|
||||
|
||||
// 1. Jika ada data.ruang dan bukan sekadar kata "Ruang X" (berarti ini subspesialis)
|
||||
if (data.ruang && !data.ruang.toLowerCase().startsWith('ruang')) {
|
||||
ruangInfo = `${data.klinik} - ${data.ruang}`;
|
||||
} else if (data.klinik) {
|
||||
// Hanya klinik jika tidak ada info ruang
|
||||
}
|
||||
// 2. Fallback: gunakan nomorRuang untuk dikonversi ke abjad (Ruang A, Ruang B, dll)
|
||||
else if (data.klinik && data.nomorRuang) {
|
||||
const ruangNumber = parseInt(data.nomorRuang) || 1;
|
||||
const ruangLetter = String.fromCharCode(64 + ruangNumber); // 64 = '@', 65 = 'A'
|
||||
ruangInfo = `${data.klinik} - Ruang ${ruangLetter}`;
|
||||
}
|
||||
// 3. Fallback: gunakan data.ruang yang ada
|
||||
else if (data.klinik && data.ruang) {
|
||||
ruangInfo = `${data.klinik} - ${data.ruang}`;
|
||||
}
|
||||
// 4. Default: hanya nama klinik
|
||||
else if (data.klinik) {
|
||||
ruangInfo = data.klinik;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,8 +18,8 @@ export interface VisitStats {
|
||||
|
||||
export const useVisitAPI = () => {
|
||||
const config = useRuntimeConfig();
|
||||
// We use the configured proxy path to avoid CORS issues
|
||||
const statsURL = '/stats-api/visit/stats';
|
||||
// We use the configured external API URL directly
|
||||
const statsURL = `${config.public.externalApiBaseUrl}/visit/stats`;
|
||||
|
||||
/**
|
||||
* Fetch visit statistics
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -26,6 +26,9 @@ services:
|
||||
- NUXT_PUBLIC_WS_API_URL=${WS_API_URL}
|
||||
- NUXT_SESSION_DURATION_HOURS=${SESSION_DURATION_HOURS}
|
||||
- NUXT_OAUTH_STATE_DURATION_MINUTES=${OAUTH_STATE_DURATION_MINUTES}
|
||||
- NUXT_PROXY_CLIENT_ORIGIN=${PROXY_CLIENT_ORIGIN}
|
||||
- NUXT_PROXY_TARGET_HOST_FALLBACK=${PROXY_TARGET_HOST_FALLBACK}
|
||||
- NUXT_PROXY_TARGET_HOST_KLINIK_FALLBACK=${PROXY_TARGET_HOST_KLINIK_FALLBACK}
|
||||
volumes:
|
||||
# Mount local data directory to persist the sqlite users database
|
||||
- ./data:/app/data
|
||||
|
||||
+787
@@ -0,0 +1,787 @@
|
||||
# 📓 DEVLOG — Development Log
|
||||
|
||||
**Project:** Web Antrean — Sistem Manajemen Antrean Rawat Jalan RSSA
|
||||
**Author:** Akbar
|
||||
**Stack:** JavaScript · Vue 3 · TypeScript · Nuxt 3 · Vuetify 3 · Pinia
|
||||
|
||||
---
|
||||
|
||||
## 🧠 AI Prompt — Cara Menggunakan Dokumen Ini
|
||||
|
||||
> Salin prompt berikut ke Claude untuk membantu mengisi DEVLOG harian:
|
||||
|
||||
```
|
||||
Kamu adalah technical writer. Bantu aku menulis DEVLOG untuk hari ini.
|
||||
|
||||
Context:
|
||||
- Project: Web Antrean — Sistem Manajemen Antrean Rawat Jalan RSSA
|
||||
- Stack: Nuxt 3, Vue 3, TypeScript, Vuetify 3, Pinia, WebSocket, Keycloak
|
||||
- Yang dikerjakan hari ini: [ceritakan bebas]
|
||||
- Masalah yang ditemui: [ceritakan]
|
||||
- Solusi yang diterapkan: [ceritakan]
|
||||
|
||||
Format output:
|
||||
## [Tanggal] — [Judul Singkat]
|
||||
**Yang dikerjakan:** ...
|
||||
**Keputusan teknis:** ...
|
||||
**Masalah & solusi:** ...
|
||||
**Besok:** ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Format Entry
|
||||
|
||||
```markdown
|
||||
## [YYYY-MM-DD] — [Judul Singkat Pekerjaan]
|
||||
|
||||
**Sprint/Phase:** [nama sprint atau fase]
|
||||
**Durasi:** [X jam]
|
||||
**Status:** ✅ Done | 🔄 In Progress | ⏸ Blocked
|
||||
|
||||
### Yang Dikerjakan
|
||||
- ...
|
||||
|
||||
### Keputusan Teknis
|
||||
> Jelaskan keputusan arsitektur/implementasi penting dan alasannya
|
||||
|
||||
### Masalah & Solusi
|
||||
| Masalah | Solusi | Referensi |
|
||||
|---------|--------|-----------|
|
||||
| | | |
|
||||
|
||||
### Besok
|
||||
- [ ] ...
|
||||
|
||||
### Referensi
|
||||
- [link]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Log Entries
|
||||
|
||||
<!-- Tambahkan entry baru di bawah baris ini, urutan terbaru di atas -->
|
||||
|
||||
## 2026-07-10 — 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
|
||||
|
||||
**Sprint/Phase:** Phase 6 — Verifikasi Akun
|
||||
**Durasi:** ~4 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Penambahan halaman `DetailAkun.vue` (`400bfcd`)
|
||||
- Update `VerifikasiAkun.vue` untuk integrasi dengan detail akun
|
||||
- Modifikasi konfigurasi Vuetify di `plugins/vuetify.ts`
|
||||
|
||||
### Keputusan Teknis
|
||||
> Pembuatan halaman detail akun terpisah untuk memberikan view lengkap informasi akun yang sedang diverifikasi, memperbaiki user experience admin saat memvalidasi data user.
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-08–09 — Voice Over Antrean & Manajemen Loket/Klinik
|
||||
|
||||
**Sprint/Phase:** Phase 5 — Stabilisasi & Kiosk Features
|
||||
**Durasi:** ~12 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Implementasi sistem notifikasi suara (voice over) untuk antrean loket dan klinik (`8b9c472`)
|
||||
- Update UI `AdminLoket/[id].vue` dan `Anjungan/AntrianLoket/[id].vue` untuk support voice announcement
|
||||
- Modifikasi `queueStore.js` untuk integrasi status antrean
|
||||
- Resolusi isu WebSocket dan CORS policy pada endpoint api antrean
|
||||
|
||||
### Keputusan Teknis
|
||||
> Menggunakan voice synthesis/announcement otomatis untuk pendaftaran antrean agar pasien dengan keterbatasan visual dapat dipanggil secara otomatis dan jelas di area loket maupun klinik.
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-02–05 — Proxy Routes & Fitur Pindah Klinik
|
||||
|
||||
**Sprint/Phase:** Phase 5 — Integrasi API & Queue System
|
||||
**Durasi:** ~16 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Implementasi proxy routes untuk `klinik-api`, `stats-api`, `visit-api` menghindari CORS issues (`0928e78`)
|
||||
- Fitur pindah klinik pada dashboard admin (`f81dd57`)
|
||||
- Penambahan dialog konfirmasi (CheckIn, Unfinished Patient) di queue features
|
||||
- Store lokalisasi menggunakan Pinia untuk loket, klinik, dan queue management (`047b390`)
|
||||
- Pembaruan DEVLOG, DEVPLAN, PRD, QMD, dan EVLOG
|
||||
|
||||
### Keputusan Teknis
|
||||
> Menggunakan proxy routes di sisi server (Nitro) untuk mem-bypass CORS ketika memanggil backend API. Hal ini memperkuat keamanan dan menstabilkan fetch data dari aplikasi Vue frontend ke service backend.
|
||||
|
||||
## 2026-05-25 — Perbaikan & Dokumentasi Project
|
||||
|
||||
**Sprint/Phase:** Phase 5 — Stabilisasi & Dokumentasi
|
||||
**Durasi:** ~4 jam
|
||||
**Status:** 🔄 In Progress
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Push perbaikan berbagai bug (`ffdb88d`)
|
||||
- Pembuatan dokumentasi project: PRD, QMD, DEVLOG
|
||||
- Stabilisasi WebSocket sync dengan deterministic client ID
|
||||
- Penambahan polling fallback 30 detik di semua admin interface
|
||||
|
||||
### Keputusan Teknis
|
||||
> Membuat dokumentasi lengkap (PRD, QMD, DEVLOG) untuk meningkatkan maintainability project ke depan. Semua dokumen diisi berdasarkan kondisi aktual project, bukan template kosong.
|
||||
|
||||
### Masalah & Solusi
|
||||
| Masalah | Solusi | Referensi |
|
||||
|---------|--------|-----------|
|
||||
| Dokumentasi project belum ada | Buat PRD, QMD, DEVLOG di folder `docs/` | — |
|
||||
|
||||
### Besok
|
||||
- [ ] Review dan finalisasi dokumen
|
||||
- [ ] Lanjutkan stabilisasi fitur penunjang
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-22 — Stabilisasi WebSocket & Tooltips Dashboard
|
||||
|
||||
**Sprint/Phase:** Phase 5 — Stabilisasi
|
||||
**Durasi:** ~8 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Standardisasi header konfigurasi (origin & referer) di `stats-api`, `visit-api`, `klinik-api` ke `http://10.10.150.175:3000`
|
||||
- Penambahan tooltips di komponen Dashboard
|
||||
- Standardisasi WebSocket client ID menjadi deterministic untuk reliable cross-device messaging
|
||||
- Implementasi polling fallback 30 detik di semua admin interface
|
||||
- Audit semua halaman yang bergantung WebSocket
|
||||
|
||||
### Keputusan Teknis
|
||||
> WebSocket client ID diubah dari random ke deterministic agar server bisa menargetkan pesan ke device tertentu. Polling 30 detik ditambahkan sebagai safety net jika WebSocket disconnect — ini menghindari data drift antar display.
|
||||
|
||||
### Masalah & Solusi
|
||||
| Masalah | Solusi | Referensi |
|
||||
|---------|--------|-----------|
|
||||
| Header origin/referer tidak konsisten antar proxy route | Standardisasi ke `http://10.10.150.175:3000` di semua route handler | `server/routes/` |
|
||||
| Data antrean tidak sinkron antar display saat WS drop | Polling fallback 30 detik + deterministic WS client ID | `useWebSocket.ts` |
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-21 — Perbaikan Layar Info & Isolasi Loket
|
||||
|
||||
**Sprint/Phase:** Phase 5 — Stabilisasi
|
||||
**Durasi:** ~6 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Fix: info klinik ruang tidak muncul di layar informasi (`ec9dac0`)
|
||||
- Isolasi state `currentProcessingPatient` menggunakan unique storage key per loket
|
||||
- Perbaikan `processNextQueue` dan `callNext` — strict filter berdasarkan `loketId`
|
||||
- Validasi WebSocket event handler agar tidak ada update lintas loket yang tidak sah
|
||||
- Memastikan `allPatients` tetap single source of truth
|
||||
|
||||
### Keputusan Teknis
|
||||
> Masalah kritis: loket A memproses pasien loket B karena `currentProcessingPatient` menggunakan key yang sama. Solusi: setiap loket mendapat unique persisted state key (`currentPatient_loket_{id}`).
|
||||
|
||||
### Masalah & Solusi
|
||||
| Masalah | Solusi | Referensi |
|
||||
|---------|--------|-----------|
|
||||
| Cross-loket interference pada `currentProcessingPatient` | Unique storage key per loket di persisted state | `queueStore.js` |
|
||||
| `processNextQueue` memproses pasien dari loket lain | Strict filter berdasarkan `loketId` dan service mapping | `useQueue.js` |
|
||||
| WebSocket event mengupdate loket yang salah | Guard check di event handler | `useWebSocket.ts` |
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-20 — Subspesialis & Perbaikan Bug Loket
|
||||
|
||||
**Sprint/Phase:** Phase 4 — Fitur Eksekutif
|
||||
**Durasi:** ~7 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Penambahan fitur subspesialis di PatientCard (`1446e87`)
|
||||
- Display subspesialis sebagai pill/chip di samping status badge
|
||||
- Perbaikan bug tampilan loket (`f622052`)
|
||||
- UI enhancement: PatientCard menampilkan info ruang/subspesialis secara kondisional
|
||||
|
||||
### Keputusan Teknis
|
||||
> Subspesialis ditampilkan sebagai pill/chip di PatientCard agar admin klinik bisa langsung melihat spesialisasi pasien tanpa perlu buka detail. Conditional rendering — tampilkan ruang ATAU subspesialis, tergantung data yang tersedia.
|
||||
|
||||
### Masalah & Solusi
|
||||
| Masalah | Solusi | Referensi |
|
||||
|---------|--------|-----------|
|
||||
| Info subspesialis tidak tampil di card pasien | Tambah conditional pill/chip di `PatientCard.vue` | `components/features/queue/PatientCard.vue` |
|
||||
| Bug tampilan loket | Fix layout dan data binding | `pages/AdminLoket.vue` |
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-19 — Docker & Anjungan Eksekutif
|
||||
|
||||
**Sprint/Phase:** Phase 4 — Fitur Eksekutif & Deployment
|
||||
**Durasi:** ~8 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Penambahan Dockerfile dan docker-compose untuk deployment (`bc74b98`)
|
||||
- Refinement UI anjungan eksekutif — pilihan subspesialis
|
||||
- Peningkatan kontras dan visibilitas button seleksi (border tebal, shadow, hover state)
|
||||
- Implementasi handler function untuk selection state yang robust
|
||||
|
||||
### Keputusan Teknis
|
||||
> Docker ditambahkan untuk standardisasi deployment. Anjungan eksekutif mendapat UI khusus dengan radio-style button yang lebih intuitif — target user adalah pasien dengan tech level rendah, jadi kontras dan ukuran harus maksimal.
|
||||
|
||||
### Masalah & Solusi
|
||||
| Masalah | Solusi | Referensi |
|
||||
|---------|--------|-----------|
|
||||
| Deployment manual dan tidak konsisten | Dockerfile + docker-compose | `Dockerfile`, `docker-compose.yml` |
|
||||
| Button seleksi subspesialis kurang kontras | Thicker border, deeper shadow, distinct hover/selected state | Anjungan pages |
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-18 — Optimasi Performa & Memory
|
||||
|
||||
**Sprint/Phase:** Phase 5 — Stabilisasi
|
||||
**Durasi:** ~6 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Perbaikan memory usage dan load performance (`cb3310b`)
|
||||
- Verifikasi status kunjungan operasi
|
||||
- Pengecekan semua API endpoint yang sudah terintegrasi
|
||||
- Pembersihan `console.log` verbose di high-frequency path
|
||||
|
||||
### Keputusan Teknis
|
||||
> Mengurangi memory footprint dengan: (1) membersihkan verbose logging di WebSocket handler dan QR scanner, (2) implementasi blacklist untuk endpoint yang terus-menerus gagal agar tidak spam request.
|
||||
|
||||
### Masalah & Solusi
|
||||
| Masalah | Solusi | Referensi |
|
||||
|---------|--------|-----------|
|
||||
| `doctorStore.js` terus retry endpoint yang 500 | Implementasi blacklist endpoint gagal | `stores/doctorStore.js` |
|
||||
| `console.log` verbose di `useWebSocket.ts` | Cleanup logging di high-frequency path | `composables/useWebSocket.ts` |
|
||||
| Memory usage terus naik | Reduce excessive API retry + cleanup logging | — |
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-20 — Update Hak Akses & Perbaikan Tiket
|
||||
|
||||
**Sprint/Phase:** Phase 3 — Hak Akses & Permission
|
||||
**Durasi:** ~6 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Update sistem hak akses berbasis Keycloak role & group (`2bf00ef`)
|
||||
- Perbaikan API pembuatan tiket antrean
|
||||
- Standardisasi user role slugs dari Keycloak groups
|
||||
- Implementasi slugification utility untuk role normalization
|
||||
|
||||
### Keputusan Teknis
|
||||
> Role dari Keycloak group path (`/Instalasi STIM/Devops/Superadmin`) di-slugify menjadi URL-friendly (`instalasi-stim-devops-superadmin`) untuk konsistensi di permission API dan internal user data.
|
||||
|
||||
### Masalah & Solusi
|
||||
| Masalah | Solusi | Referensi |
|
||||
|---------|--------|-----------|
|
||||
| Role dari Keycloak tidak URL-friendly | Implementasi slugification utility | `composables/useHakAkses.ts` |
|
||||
| Tiket antrean gagal digenerate | Perbaikan API endpoint pembuatan tiket | `server/api/` |
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-15 — Update Favicon
|
||||
|
||||
**Sprint/Phase:** Phase 3 — Polish
|
||||
**Durasi:** ~1 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Ganti favicon aplikasi web-antrean
|
||||
- Update konfigurasi `app.head` di `nuxt.config.ts` untuk mengarah ke file icon baru
|
||||
|
||||
### Keputusan Teknis
|
||||
> Favicon diletakkan di folder `public/` dan direferensi via `nuxt.config.ts` → `app.head.link`.
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-13 — Integrasi Keycloak Role Access
|
||||
|
||||
**Sprint/Phase:** Phase 3 — Hak Akses & Permission
|
||||
**Durasi:** ~8 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Verifikasi dan standardisasi akses Keycloak role
|
||||
- Implementasi middleware `auth.ts`, `guest.ts`, `permissions.ts`, `checkPageAccess.ts`
|
||||
- Setup permission store (`permissionStore.ts`)
|
||||
- Integrasi permission API endpoint (`server/api/permission.get.ts`)
|
||||
|
||||
### Keputusan Teknis
|
||||
> Menggunakan 4 layer middleware: (1) `auth.ts` — cek login, (2) `guest.ts` — redirect jika sudah login, (3) `permissions.ts` — cek hak akses halaman, (4) `checkPageAccess.ts` — validasi granular per page. Model ini memastikan defense-in-depth untuk access control.
|
||||
|
||||
### Masalah & Solusi
|
||||
| Masalah | Solusi | Referensi |
|
||||
|---------|--------|-----------|
|
||||
| Role dari Keycloak format path, bukan slug | Konversi ke slug untuk matching di permission API | `middleware/permissions.ts` |
|
||||
|
||||
---
|
||||
|
||||
## 2026-02-24 — Perbaikan Ambil Tiket
|
||||
|
||||
**Sprint/Phase:** Phase 2 — Fitur Inti
|
||||
**Durasi:** ~4 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Fix bug pengambilan tiket antrean di anjungan (`94ff9f5`)
|
||||
- Perbaikan flow generate nomor antrean
|
||||
|
||||
### Masalah & Solusi
|
||||
| Masalah | Solusi | Referensi |
|
||||
|---------|--------|-----------|
|
||||
| Tiket gagal digenerate pada kondisi tertentu | Fix logic generate nomor antrean | `composables/useQRGenerator.ts` |
|
||||
|
||||
---
|
||||
|
||||
## 2026-02-19 — Implementasi Anjungan (Kiosk) & Queue Store
|
||||
|
||||
**Sprint/Phase:** Phase 2 — Fitur Inti
|
||||
**Durasi:** ~10 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Implementasi modul Anjungan (Kiosk) lengkap (`0c4019c`)
|
||||
- Halaman pilih klinik, klinik ruang, penunjang
|
||||
- Halaman antrian per klinik, klinik ruang, penunjang
|
||||
- Check-in pasien via QR
|
||||
- Admin anjungan
|
||||
- Implementasi Pinia store baru untuk queue management (`4342cdc`)
|
||||
- Integrasi dengan Visit API dan Antrian API
|
||||
- WebSocket integration untuk real-time update
|
||||
- Halaman admin dan kiosk display
|
||||
|
||||
### Keputusan Teknis
|
||||
> `queueStore.js` dibuat sebagai central store untuk semua operasi antrean — ini menjadi single source of truth untuk `allPatients`. Keputusan ini mempermudah sinkronisasi real-time tapi membuat file menjadi sangat besar (saat ini 141KB). Perlu dipecah di masa depan.
|
||||
|
||||
---
|
||||
|
||||
## 2026-02-18 — Core Anjungan Display & Verification
|
||||
|
||||
**Sprint/Phase:** Phase 2 — Fitur Inti
|
||||
**Durasi:** ~8 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Implementasi core display system anjungan (`7d07a4f`)
|
||||
- Display antrean klinik ruang
|
||||
- Display antrean masuk
|
||||
- Display antrean loket
|
||||
- Tambah verification store dan konfigurasi endpoint baru (`f78bbea`)
|
||||
- `verificationApiBaseUrl` → `http://10.10.123.140:8089/api/v1`
|
||||
- `ekstrakExpertiseUrl` endpoint
|
||||
- Fix wsBaseUrl fallback di Nuxt config (`d03fa63`)
|
||||
|
||||
### Keputusan Teknis
|
||||
> Dual backend API architecture: Visit API (port 8084) untuk data kunjungan utama, Antrian API (port 8089) untuk verifikasi dan data klinik/dokter. Proxy layer Nitro digunakan untuk bypass CORS dan menyembunyikan IP backend dari client.
|
||||
|
||||
---
|
||||
|
||||
## 2026-02-13 — Admin Loket, Klinik Ruang & Queue Management
|
||||
|
||||
**Sprint/Phase:** Phase 2 — Fitur Inti
|
||||
**Durasi:** ~10 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Implementasi halaman Admin Loket detail (`8c6b3fd`)
|
||||
- Manajemen antrian pasien per loket
|
||||
- Aksi pasien: panggil, skip, recall, selesai
|
||||
- Dialog seleksi (pilih loket, pilih layanan)
|
||||
- Implementasi `AdminKlinikRuang` page (`67a5514`)
|
||||
- Manajemen antrian per klinik ruang
|
||||
- Processing, calling, filtering, global search
|
||||
- Implementasi komprehensif patient queue management (`dbc9054`)
|
||||
- Pinia store untuk queue
|
||||
- API integration
|
||||
- Admin counter page
|
||||
- Tambah komponen `PatientCard`, `CurrentPatientCard` (`95ff830`)
|
||||
- Fitur queue management untuk klinik ruang dan counter (`4df13cb`)
|
||||
|
||||
### Keputusan Teknis
|
||||
> Memisahkan queue components menjadi 4 komponen: `PatientCard` (card per pasien), `CurrentPatientCard` (pasien sedang dilayani), `QueueActionsCard` (tombol aksi), `TabelPatientData` (tabel lengkap). Ini memastikan reusability di halaman admin loket, klinik, dan penunjang.
|
||||
|
||||
---
|
||||
|
||||
## 2026-02-12 — Anjungan Pages, WebSocket & Queue API
|
||||
|
||||
**Sprint/Phase:** Phase 2 — Fitur Inti
|
||||
**Durasi:** ~10 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Introduce halaman Anjungan untuk antrian klinik, klinik ruang, dan counter (`a5ab338`)
|
||||
- Tambah halaman check-in pasien
|
||||
- Implementasi core queue management system dengan WebSocket (`0e3ee37`)
|
||||
- Pinia store untuk queue
|
||||
- WebSocket integration
|
||||
- Halaman check-in dan kiosk display
|
||||
- Update post API klinik dan loket di queueStore (`4985aef`)
|
||||
- Fix status pasien selesai di klinik ruang (`c08941a`)
|
||||
- Various merge dan bugfix
|
||||
|
||||
### Keputusan Teknis
|
||||
> WebSocket dipilih sebagai primary communication channel untuk real-time sync karena latensi < 2 detik krusial di rumah sakit — pasien harus langsung melihat nomor panggil berubah di layar display.
|
||||
|
||||
---
|
||||
|
||||
## 2026-02-10–11 — WebSocket Bug Fixing
|
||||
|
||||
**Sprint/Phase:** Phase 2 — Stabilisasi WebSocket
|
||||
**Durasi:** ~12 jam (2 hari)
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Fix bug WebSocket di check-in loket (`b6dc252`)
|
||||
- Fix socket check-in flow (`e686dda`)
|
||||
- Update socket data dan token handling (`fb70237`)
|
||||
- Fix duplikasi data dan ticket menghilang di admin klinik loket (`c02905e`)
|
||||
- Update WS untuk admin loket dan check-in (`a7a654b`)
|
||||
- Fix missing function error (`23164bc`)
|
||||
- Perbaikan sorting pasien di next process (`6af3f58`)
|
||||
|
||||
### Masalah & Solusi
|
||||
| Masalah | Solusi | Referensi |
|
||||
|---------|--------|-----------|
|
||||
| Duplikasi tiket di admin klinik | Fix logic deduplication di WebSocket handler | `useWebSocket.ts` |
|
||||
| Tiket menghilang setelah check-in | Fix state update flow — pastikan re-render setelah WS event | `queueStore.js` |
|
||||
| Socket check-in tidak trigger update | Fix event listener binding | `composables/useCheckIn.ts` |
|
||||
|
||||
---
|
||||
|
||||
## 2026-02-09 — Integrasi WebSocket
|
||||
|
||||
**Sprint/Phase:** Phase 2 — Real-time
|
||||
**Durasi:** ~6 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Setup dan implementasi WebSocket client (`101dc38`)
|
||||
- Koneksi ke `ws://10.10.123.135:8084/api/v1/ws`
|
||||
- Implementasi `useWebSocket.ts` composable
|
||||
|
||||
### Keputusan Teknis
|
||||
> Menggunakan native browser WebSocket API dibungkus composable (`useWebSocket.ts`) daripada library seperti `socket.io` — mengurangi dependency dan lebih ringan untuk environment LAN internal.
|
||||
|
||||
---
|
||||
|
||||
## 2026-02-02–06 — API Integration & Display Screen
|
||||
|
||||
**Sprint/Phase:** Phase 2 — Integrasi API
|
||||
**Durasi:** ~20 jam (5 hari)
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Update tampilan layar screen display (`cf54ded`)
|
||||
- Penambahan API klinik ruang (`a29838e`, `b327997`, `c2d9023`)
|
||||
- Update logika pemanggilan admin loket by payment (`efeb42e`)
|
||||
- Fix HTTPS implementation dan check-in (`c899a71`)
|
||||
- Update session dan WebSocket (`0428017`)
|
||||
- Update status klinik ruang (`6696881`)
|
||||
- Update tampilan anjungan (`329ac9c`)
|
||||
- Fixing admin loket dan blokade loket yang tidak tersedia (`5d139c8`)
|
||||
- Update verifikasi dan lainnya (`2838016`)
|
||||
- Update post API status dan data pasien ruang (`e2a5d43`)
|
||||
|
||||
### Keputusan Teknis
|
||||
> Logika pemanggilan admin loket diubah menjadi payment-based — pasien yang sudah bayar diprioritaskan. Loket yang tidak tersedia di-blokade otomatis agar petugas tidak melihat loket yang bukan tanggung jawabnya.
|
||||
|
||||
---
|
||||
|
||||
## 2026-01-28–30 — Master Data & API Queue Store
|
||||
|
||||
**Sprint/Phase:** Phase 1 — Fondasi
|
||||
**Durasi:** ~12 jam (3 hari)
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Update master data berdasarkan jenis layanan (`9f0f6a7`)
|
||||
- Update API antrean masuk dan minor changes (`507f415`)
|
||||
- Fix session dan tampilan screen (`8dd94ed`)
|
||||
- Fix loop dan duplikasi data (`0bd5311`)
|
||||
- Update fetch API (`cccefb0`)
|
||||
- Update layout semua halaman (`ae8b06d`)
|
||||
- Update queueStore dari API (`19633af`)
|
||||
- Minor update anjungan (`59e42f3`)
|
||||
|
||||
---
|
||||
|
||||
## 2026-01-22–27 — Admin Loket & API Integrasi
|
||||
|
||||
**Sprint/Phase:** Phase 1 — Fondasi
|
||||
**Durasi:** ~16 jam (4 hari)
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Update logika penarikan API dan tampilan anjungan (`e900c5a`)
|
||||
- Update No RM dan desain (`2ccb378`)
|
||||
- Update API master loket dan antrian loket (`75a9638`)
|
||||
- Update anjungan (`8de89cb`)
|
||||
- Update master klinik ruang (`6c08352`)
|
||||
- Update sidebar check-in dan antrian loket (`525322b`)
|
||||
- Update API loket admin (`083fe3e`)
|
||||
- Push update admin loket (`f606045`)
|
||||
|
||||
---
|
||||
|
||||
## 2026-01-19–21 — Layout, Sidebar, Dashboard & Monitoring
|
||||
|
||||
**Sprint/Phase:** Phase 1 — Fondasi
|
||||
**Durasi:** ~20 jam (3 hari)
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Update flow klinik ruang dan design consistency (`cbd8f44`)
|
||||
- Update sidebar, profile, dan loket (`f2efd83`)
|
||||
- Update loket dan monitoring (`81b877b`, `c00c18e`)
|
||||
- Update page header komponen (`00bb954`)
|
||||
- Sidebar change (`3db912a`)
|
||||
- Update header, card, dialog (`fde7111`, `531ca10`, `f7a3b20`)
|
||||
- Perubahan update sidebar, dashboard, monitoring, antrean loket, verifikasi (`bb71955`)
|
||||
- Update check-in tampilan dan function (`482294b`)
|
||||
|
||||
### Keputusan Teknis
|
||||
> Layout system menggunakan Vuetify navigation drawer (`SideBar.vue`) + custom `PageHeader.vue`. Design consistency diterapkan via SCSS variables (`_variables.scss`, `_colors.scss`) untuk warna, spacing, dan typography.
|
||||
|
||||
---
|
||||
|
||||
## 2026-01-15 — Login Page, Check-in & Antrean Masuk
|
||||
|
||||
**Sprint/Phase:** Phase 1 — Fondasi
|
||||
**Durasi:** ~8 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Implementasi login page dan check-in (`e99a0ab`)
|
||||
- Update color palette dan konsultasi pasien (`a8a55b9`)
|
||||
- Tampilan antrean masuk (`ed57e2d`)
|
||||
- Edit manual check-in (`928be7c`)
|
||||
- Update logika pemanggilan, sticky konten, tampilan card (`96c6376`)
|
||||
- Perbaikan generate dan scan tiket QR (`8aa5ab2`)
|
||||
|
||||
### Keputusan Teknis
|
||||
> QR code digunakan untuk tiket antrean — pasien mendapat QR saat ambil antrean, lalu scan QR saat check-in. Library `html5-qrcode` dipilih untuk scanner dan `qrcode.vue` untuk generator.
|
||||
|
||||
---
|
||||
|
||||
## 2026-01-12–14 — Loket, Klinik Ruang & Fast Track
|
||||
|
||||
**Sprint/Phase:** Phase 1 — Fondasi
|
||||
**Durasi:** ~16 jam (3 hari)
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Update layout klinik dan WebSocket layar klinik (`6e7160c`)
|
||||
- Update klinik ruang dan status fast track (`27209de`)
|
||||
- Perubahan tiket dan alur antrean (`5c14227`)
|
||||
- Push loket baru (`42fe62c`)
|
||||
- Loket perbaikan "sedang dilayani" (`48e4aab`)
|
||||
- Anjungan fast track dan tampilan ruang (`3817b2d`)
|
||||
- Perbaikan loket dan total antrean (`a000ba0`, `0a8e11a`, `9ccbaba`)
|
||||
- Check-in tampilan (`0c7b33a`)
|
||||
- Fixing layar loket dan status pasien (`e80032f`)
|
||||
- QRcode update (`6c80c08`)
|
||||
- Update warna dan klinik ruang (`f3e90ad`)
|
||||
- Perubahan antrean masuk (`c5b623f`)
|
||||
- Update seed data dan fungsi pindah/konsul klinik (`89c3549`)
|
||||
|
||||
---
|
||||
|
||||
## 2026-01-08–09 — Klinik Ruang, QR & Print
|
||||
|
||||
**Sprint/Phase:** Phase 1 — Fondasi
|
||||
**Durasi:** ~12 jam (2 hari)
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Layar baru antrean klinik dan antrean masuk (`2d3d589`)
|
||||
- Update klinik ruang (`ca5913c`)
|
||||
- Nav items (`8a4bb44`)
|
||||
- Update print admin loket, logika klinik ruang, numbering antrian baru (`676bdc0`)
|
||||
- Update QR function (`22d7205`)
|
||||
- Perubahan format No Antrean (`6d5d565`)
|
||||
- Check-in footers detail (`1b7a142`)
|
||||
- Klinik ruang dan update admin loket (`9ea8300`)
|
||||
|
||||
### Keputusan Teknis
|
||||
> Format nomor antrean: `[KodeKlinik]-[NomorUrut]` (contoh: `PDL-001`). Thermal print diimplementasi via `useThermalPrint.ts` composable untuk cetak tiket langsung dari browser ke printer thermal via Web API.
|
||||
|
||||
---
|
||||
|
||||
## 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 -->
|
||||
|
||||
---
|
||||
|
||||
## 📊 Summary Stats
|
||||
|
||||
| Metrik | Value |
|
||||
|--------|-------|
|
||||
| Total hari dev | ~60+ hari (Jan 2026 — Juli 2026) |
|
||||
| Fase project | 7 fase (Setup → Fondasi → Hak Akses → Eksekutif → Stabilisasi → Verifikasi → Infrastruktur) |
|
||||
| Fitur selesai | 8 modul utama + dokumentasi + migrasi infra |
|
||||
| Total commits | 90+ commits |
|
||||
| Bug ditemukan | 18+ (WebSocket, loket interference, memory, display sync, missing clinics, API endpoint) |
|
||||
| Bug diselesaikan | 18+ |
|
||||
| Halaman dibuat | 28+ halaman |
|
||||
| Composables | 18 composable |
|
||||
| Pinia Stores | 14 stores |
|
||||
| Komponen | 25+ komponen |
|
||||
| Dokumentasi | AGENTS.md, DEVLOG.md, DEVPLAN.md, PRD.md, API_ENDPOINTS_CLINIC_DOCTOR.md |
|
||||
+263
@@ -0,0 +1,263 @@
|
||||
# 🗺️ DEVPLAN — Development Plan
|
||||
|
||||
**Project:** Web Antrean — Sistem Manajemen Antrean Rawat Jalan RSSA
|
||||
**Version:** 1.1.0
|
||||
**Author:** Akbar
|
||||
**Stack:** JavaScript · Vue 3 · TypeScript · Nuxt 3 · Vuetify 3 · Pinia
|
||||
**Last Updated:** 2026-07-10
|
||||
|
||||
---
|
||||
|
||||
## 🧠 AI Prompt — Cara Menggunakan Dokumen Ini
|
||||
|
||||
> Salin prompt berikut ke Claude untuk membantu membuat dev plan:
|
||||
|
||||
```
|
||||
Kamu adalah tech lead senior fullstack (Nuxt 3, Vue 3, TypeScript).
|
||||
|
||||
Project: Web Antrean — Sistem Manajemen Antrean Rawat Jalan RSSA
|
||||
Deskripsi: Digitalisasi alur antrean pasien rawat jalan di rumah sakit
|
||||
Deadline: Mei 2026 (Go-Live)
|
||||
Tim: 1 Fullstack Engineer (Akbar), PO Tim RSSA
|
||||
|
||||
Fitur yang harus dibangun:
|
||||
1. Anjungan mandiri (kiosk registrasi pasien, pilih klinik/subspesialis)
|
||||
2. Check-in pasien via QR code
|
||||
3. Manajemen antrean loket & display screen
|
||||
4. Manajemen antrean klinik & ruang
|
||||
5. Manajemen antrean penunjang
|
||||
6. Real-time sync via WebSocket
|
||||
7. Dashboard monitoring & statistik
|
||||
8. Manajemen user, master data, dan hak akses (Keycloak)
|
||||
|
||||
Buatkan:
|
||||
1. Breakdown fase development (Phase 0 s/d launch)
|
||||
2. Task list per fase dengan estimasi
|
||||
3. Urutan prioritas fitur (MoSCoW)
|
||||
4. Technical dependencies antar task
|
||||
5. Milestone dan checkpoint
|
||||
|
||||
Format dalam tabel Markdown yang terstruktur.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Project Timeline
|
||||
|
||||
```
|
||||
[Start]──Phase 0──Phase 1──Phase 2──Phase 3──Phase 4──Phase 5──Phase 6──Phase 7──[Launch]
|
||||
Setup Fondasi Inti API Hak Akses Eksekutif Stabilisasi Verifikasi Infrastruktur
|
||||
```
|
||||
|
||||
| Fase | Nama | Durasi | Target | Status |
|
||||
|------|------|--------|--------|--------|
|
||||
| Phase 0 | Setup & Arsitektur | 1 Minggu | Awal Jan 2026 | ✅ Done |
|
||||
| Phase 1 | Fondasi (Loket, Klinik, QR) | 3 Minggu | Jan 2026 | ✅ Done |
|
||||
| Phase 2 | Fitur Inti (Anjungan, WS, API) | 3 Minggu | Feb 2026 | ✅ Done |
|
||||
| Phase 3 | Hak Akses & Keycloak | 2 Minggu | Apr 2026 | ✅ Done |
|
||||
| Phase 4 | Fitur Eksekutif & Docker | 1 Minggu | Pertengahan Mei | ✅ Done |
|
||||
| Phase 5 | Stabilisasi & Dokumentasi | 2 Minggu | Akhir Mei 2026 | ✅ Done |
|
||||
| Phase 6 | Verifikasi Akun & Kiosk | 2 Minggu | Juni 2026 | ✅ Done |
|
||||
| Phase 7 | Infrastruktur & Dokumentasi | Ongoing | Juli 2026 | 🔄 In Progress |
|
||||
|
||||
---
|
||||
|
||||
## 2. Feature Prioritization (MoSCoW)
|
||||
|
||||
| Priority | Fitur | Estimasi | Phase |
|
||||
|----------|-------|----------|-------|
|
||||
| 🔴 Must Have | Modul Anjungan (Pilih klinik, ambil tiket, cetak) | 40 jam | 2 |
|
||||
| 🔴 Must Have | Modul Loket (Panggil, skip, recall, next) | 30 jam | 1 & 2 |
|
||||
| 🔴 Must Have | Check-in via QR Code | 20 jam | 1 |
|
||||
| 🔴 Must Have | Sinkronisasi Real-time via WebSocket | 30 jam | 2 |
|
||||
| 🔴 Must Have | Integrasi API Eksternal (Visit API, Antrian API) | 40 jam | 2 |
|
||||
| 🔴 Must Have | Manajemen Hak Akses Keycloak | 25 jam | 3 |
|
||||
| 🟡 Should Have | Antrean Klinik & Penunjang | 30 jam | 1 & 4 |
|
||||
| 🟡 Should Have | Dashboard Monitoring & Statistik | 15 jam | 1 |
|
||||
| 🟡 Should Have | Pilihan Subspesialis (Klinik Eksekutif) | 10 jam | 4 |
|
||||
| 🟢 Could Have | Notifikasi Suara Panggilan (Text-to-Speech) | 15 jam | Future |
|
||||
| ⬜ Won't Have | Aplikasi Mobile Native | — | Out of scope |
|
||||
| ⬜ Won't Have | Integrasi Billing / Pembayaran Langsung | — | Out of scope |
|
||||
|
||||
---
|
||||
|
||||
## 3. Task Breakdown
|
||||
|
||||
### Phase 0 — Setup & Arsitektur
|
||||
| Task | Estimasi | Dependencies | Status |
|
||||
|------|----------|--------------|--------|
|
||||
| Init Nuxt 3 + TypeScript | 2 jam | — | ✅ Done |
|
||||
| Setup Vuetify 3 + SCSS variables | 4 jam | Task 1 | ✅ Done |
|
||||
| Setup Pinia state management | 2 jam | Task 1 | ✅ Done |
|
||||
| Setup Vue Router (Nuxt pages/layout) | 3 jam | Task 1 | ✅ Done |
|
||||
| Konfigurasi Proxy API di `nuxt.config.ts` | 3 jam | Task 1 | ✅ Done |
|
||||
|
||||
### Phase 1 — Fondasi (Loket, Klinik, QR)
|
||||
| Task | Estimasi | Dependencies | Status |
|
||||
|------|----------|--------------|--------|
|
||||
| UI Layout, Sidebar, PageHeader | 8 jam | Phase 0 | ✅ Done |
|
||||
| Komponen PatientCard & Tabel | 8 jam | Phase 0 | ✅ Done |
|
||||
| UI Admin Loket & Admin Klinik | 12 jam | Task 2 | ✅ Done |
|
||||
| Check-in Pasien UI & QR Scanner (`html5-qrcode`) | 12 jam | Phase 0 | ✅ Done |
|
||||
| Generator Tiket QR (`qrcode.vue`) | 6 jam | Phase 0 | ✅ Done |
|
||||
| Cetak Tiket Thermal (`useThermalPrint.ts`) | 8 jam | Phase 0 | ✅ Done |
|
||||
|
||||
### Phase 2 — Fitur Inti (Anjungan, WS, API)
|
||||
| Task | Estimasi | Dependencies | Status |
|
||||
|------|----------|--------------|--------|
|
||||
| Integrasi Visit API & Antrian API | 16 jam | Phase 0 | ✅ Done |
|
||||
| Setup WebSocket client (`useWebSocket.ts`) | 8 jam | Task 1 | ✅ Done |
|
||||
| Layar Display Antrean (Klinik & Loket) | 12 jam | Task 2 | ✅ Done |
|
||||
| Modul Anjungan Mandiri (UI & Logic) | 16 jam | Task 1 | ✅ Done |
|
||||
| Centralized `queueStore` | 12 jam | Task 1, 2 | ✅ Done |
|
||||
| Bugfix duplikasi data & WS handling | 12 jam | Task 2, 5 | ✅ Done |
|
||||
|
||||
### Phase 3 — Hak Akses & Keycloak
|
||||
| Task | Estimasi | Dependencies | Status |
|
||||
|------|----------|--------------|--------|
|
||||
| Konfigurasi Keycloak SSO di frontend | 8 jam | — | ✅ Done |
|
||||
| Middleware Auth & Guest | 4 jam | Task 1 | ✅ Done |
|
||||
| CRUD Master Data (Klinik, Loket, Screen) | 12 jam | Phase 1 | ✅ Done |
|
||||
| Manajemen Hak Akses User/Group (UI & API) | 12 jam | Task 1 | ✅ Done |
|
||||
| Middleware Permission & Page Access Guard | 8 jam | Task 4 | ✅ Done |
|
||||
|
||||
### Phase 4 — Fitur Eksekutif & Docker
|
||||
| Task | Estimasi | Dependencies | Status |
|
||||
|------|----------|--------------|--------|
|
||||
| UI Pemilihan Subspesialis di Anjungan | 8 jam | Phase 2 | ✅ Done |
|
||||
| Conditional display spesialis di PatientCard | 4 jam | Task 1 | ✅ Done |
|
||||
| Setup Dockerfile & docker-compose | 4 jam | — | ✅ Done |
|
||||
| Update konfigurasi environment untuk container | 4 jam | Task 3 | ✅ Done |
|
||||
|
||||
### Phase 5 — Stabilisasi & QA
|
||||
| Task | Estimasi | Dependencies | Status |
|
||||
|------|----------|--------------|--------|
|
||||
| Standardisasi WS Client ID (Deterministic) | 4 jam | Phase 2 | ✅ Done |
|
||||
| Polling fallback 30s untuk layar display | 6 jam | Phase 2 | ✅ Done |
|
||||
| Isolasi state loket (`currentProcessingPatient`) | 6 jam | Phase 2 | ✅ Done |
|
||||
| Blacklist handler untuk endpoint gagal 500 | 4 jam | Phase 2 | ✅ Done |
|
||||
| Dokumentasi PRD, QMD, DEVLOG, EVLOG | 8 jam | — | ✅ Done |
|
||||
| Setup Unit Testing & E2E framework | 4 jam | — | ✅ Done |
|
||||
|
||||
### Phase 6 — Verifikasi Akun & Kiosk
|
||||
| Task | Estimasi | Dependencies | Status |
|
||||
|------|----------|--------------|--------|
|
||||
| Sistem notifikasi suara (voice over) loket & klinik | 12 jam | Phase 2 | ✅ Done |
|
||||
| Fitur pindah klinik pada dashboard admin | 8 jam | Phase 2 | ✅ Done |
|
||||
| Halaman Detail Akun Verifikasi | 4 jam | Phase 3 | ✅ Done |
|
||||
| Implementasi Proxy Routes (CORS) | 8 jam | Phase 2 | ✅ Done |
|
||||
| Refactoring queueStore.js ke TypeScript | 8 jam | Phase 2 | ✅ Done |
|
||||
| Ekstraksi useQueueSync composable | 4 jam | Phase 2 | ✅ Done |
|
||||
|
||||
### Phase 7 — Infrastruktur & Dokumentasi
|
||||
| Task | Estimasi | Dependencies | Status |
|
||||
|------|----------|--------------|--------|
|
||||
| Migrasi API endpoint (123.140 → 150.131) | 2 jam | — | ✅ Done |
|
||||
| Pembuatan Project Knowledge Base (AGENTS.md) | 3 jam | — | ✅ Done |
|
||||
| Update DEVLOG, DEVPLAN, PRD | 2 jam | — | ✅ Done |
|
||||
| Fix Admin Klinik Ruang missing clinics | 1 jam | Phase 2 | ✅ Done |
|
||||
| Eliminasi hardcoded fallback IP di source files | 4 jam | Task 1 | ⏳ Pending |
|
||||
| SQLite config → External API migration | 8 jam | Phase 2 | ⏳ Pending |
|
||||
| Production deployment & monitoring | 8 jam | All | ⏳ Pending |
|
||||
|
||||
---
|
||||
|
||||
## 4. Technical Dependencies
|
||||
|
||||
```text
|
||||
Nuxt 3 + TypeScript
|
||||
├── Pinia (State Management)
|
||||
│ └── pinia-plugin-persistedstate (localStorage)
|
||||
├── Vuetify 3 (UI Framework)
|
||||
│ └── Material Design Icons
|
||||
├── Nuxt Nitro (Server Engine)
|
||||
│ └── Server API Routes + SQLite (better-sqlite3)
|
||||
├── Auth Layer
|
||||
│ └── Keycloak SSO (OIDC/OAuth 2.0)
|
||||
├── API Layer
|
||||
│ ├── Visit API (10.10.123.135:8084)
|
||||
│ └── Antrian API (10.10.150.131:8089) ← UPDATED
|
||||
└── Real-time Layer
|
||||
└── Native Browser WebSocket
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Environment Setup
|
||||
|
||||
### Local Development
|
||||
```bash
|
||||
# Clone repo
|
||||
git clone https://git.rssa.top/arie.bagus.2905/web-antrean
|
||||
cd web-antrean
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Copy env
|
||||
cp .env.example .env
|
||||
|
||||
# Jalankan dev server dengan host spesifik
|
||||
npm run dev
|
||||
# ATAU
|
||||
npm run _command_dev
|
||||
```
|
||||
|
||||
### Environment Variables (.env)
|
||||
```env
|
||||
AUTH_ORIGIN="http://10.10.150.175:3000"
|
||||
KEYCLOAK_CLIENT_ID="akbar-test"
|
||||
KEYCLOAK_ISSUER="https://auth.rssa.top/realms/sandbox"
|
||||
ANTRIAN_API_URL="http://10.10.150.131:8089/api/v1" # UPDATED Juli 2026
|
||||
VERIFICATION_API_BASE_URL="http://10.10.150.131:8089/api/v1" # UPDATED Juli 2026
|
||||
VISIT_API_URL="http://10.10.123.135:8084/api/v1"
|
||||
WS_API_URL="ws://10.10.123.135:8084/api/v1/ws"
|
||||
PROXY_CLIENT_ORIGIN="http://10.10.150.175:3000"
|
||||
PROXY_TARGET_HOST_KLINIK_FALLBACK="10.10.150.131:8089" # UPDATED Juli 2026
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Git Strategy
|
||||
|
||||
### Branch Naming
|
||||
```
|
||||
main → production (stable)
|
||||
Antrean-Code → development / staging
|
||||
feature/xxx → fitur baru (contoh: feature/anjungan-eksekutif)
|
||||
fix/xxx → bug fix (contoh: fix/ws-duplicate)
|
||||
```
|
||||
|
||||
### Commit Convention
|
||||
Saat ini commit message banyak menggunakan free-text (contoh: `push perbaikan`, `update bug ws`). Ke depannya, disarankan menggunakan **Conventional Commits**:
|
||||
```
|
||||
feat: tambah halaman dashboard
|
||||
fix: perbaiki duplikasi antrean di loket
|
||||
chore: update docker config
|
||||
docs: update DEVPLAN
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Milestones & Checkpoints
|
||||
|
||||
| Milestone | Kriteria | Target Tanggal | Status |
|
||||
|-----------|----------|----------------|--------|
|
||||
| 🏁 Project Kickoff | Setup Nuxt, Layout, Styling | Jan 2026 | ✅ Done |
|
||||
| 🔌 API & WS Connected | Data mengalir dari backend, WS sync jalan | Feb 2026 | ✅ Done |
|
||||
| 🔐 Auth & Roles | Keycloak jalan, routing terlindungi middleware | Apr 2026 | ✅ Done |
|
||||
| 🏥 Anjungan Ready | Pasien bisa check-in & ambil tiket lancar | Pertengahan Mei | ✅ Done |
|
||||
| 🛡️ Stability Pass | Tidak ada leak, isolasi loket aman, no WS drift | Akhir Mei 2026 | ✅ Done |
|
||||
| 🔄 TypeScript Migration | queueStore refactored ke TS, composable extracted | Juni 2026 | ✅ Done |
|
||||
| 📡 API Endpoint Migration | Antrian API pindah ke server baru (150.131) | Juli 2026 | ✅ Done |
|
||||
| 🚀 Production Go-Live | Deployment docker di server production | Q3 2026 | 🔄 Pending |
|
||||
|
||||
---
|
||||
|
||||
## 8. Changelog
|
||||
|
||||
| Versi | Tanggal | Author | Perubahan |
|
||||
|-------|---------|--------|-----------|
|
||||
| 1.1.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 |
|
||||
+544
@@ -0,0 +1,544 @@
|
||||
# 🚨 EVLOG — Event & Error Log
|
||||
|
||||
**Project:** Web Antrean — Sistem Manajemen Antrean Rawat Jalan RSSA
|
||||
**Author:** Akbar
|
||||
**Stack:** JavaScript · Vue 3 · TypeScript · Nuxt 3 · Vuetify 3 · Pinia
|
||||
|
||||
---
|
||||
|
||||
## 🧠 AI Prompt — Cara Menggunakan Dokumen Ini
|
||||
|
||||
> Salin prompt berikut ke Claude untuk menganalisis dan mendokumentasikan error:
|
||||
|
||||
```
|
||||
Kamu adalah senior engineer Nuxt 3 + TypeScript. Aku menemukan error berikut:
|
||||
|
||||
Error message: [paste error]
|
||||
Stack trace: [paste stack trace]
|
||||
Context: [dimana terjadi, langkah reproduksi]
|
||||
Tech: Nuxt 3, Vue 3, TypeScript, Vuetify 3, Pinia, WebSocket, Keycloak
|
||||
|
||||
Bantu aku:
|
||||
1. Analisis root cause error ini
|
||||
2. Berikan solusi step-by-step
|
||||
3. Sarankan cara mencegah error serupa
|
||||
4. Format hasilnya untuk EVLOG dalam Markdown
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Severity Legend
|
||||
|
||||
| Level | Icon | Deskripsi | SLA |
|
||||
|-------|------|-----------|-----|
|
||||
| Critical | 🔴 | App down / data loss / security | < 4 jam |
|
||||
| High | 🟠 | Fitur utama break | < 1 hari |
|
||||
| Medium | 🟡 | Fitur minor terganggu | < 3 hari |
|
||||
| Low | 🟢 | UI/kosmetik | Backlog |
|
||||
|
||||
---
|
||||
|
||||
## Format Entry
|
||||
|
||||
```markdown
|
||||
### [EV-XXX] — [Judul Singkat Error/Event]
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **ID** | EV-XXX |
|
||||
| **Tanggal** | YYYY-MM-DD |
|
||||
| **Severity** | 🔴 Critical / 🟠 High / 🟡 Medium / 🟢 Low |
|
||||
| **Environment** | Development / Staging / Production |
|
||||
| **Status** | 🔍 Investigating / 🔧 In Fix / ✅ Resolved / ⏭ Wontfix |
|
||||
| **Reporter** | Akbar |
|
||||
|
||||
**Error Message:**
|
||||
> [paste error message]
|
||||
|
||||
**Langkah Reproduksi:**
|
||||
1. ...
|
||||
|
||||
**Root Cause:**
|
||||
> ...
|
||||
|
||||
**Solusi / Fix:**
|
||||
> ...
|
||||
|
||||
**Prevention:**
|
||||
> ...
|
||||
|
||||
**Related:** [commit / file]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Event Log
|
||||
|
||||
<!-- Tambahkan entry baru di bawah baris ini, urutan terbaru di atas -->
|
||||
|
||||
---
|
||||
|
||||
### [EV-012] — Info Klinik Ruang Tidak Muncul di Layar Display
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **ID** | EV-012 |
|
||||
| **Tanggal** | 2026-05-21 |
|
||||
| **Severity** | 🟠 High |
|
||||
| **Environment** | Development |
|
||||
| **Status** | ✅ Resolved |
|
||||
| **Reporter** | Akbar |
|
||||
|
||||
**Error Message:**
|
||||
> Layar informasi klinik ruang menampilkan data kosong — tidak ada info klinik ruang yang muncul.
|
||||
|
||||
**Langkah Reproduksi:**
|
||||
1. Buka halaman display screen klinik ruang
|
||||
2. Data klinik ruang seharusnya muncul
|
||||
3. Layar kosong — tidak ada informasi tampil
|
||||
|
||||
**Root Cause:**
|
||||
> Data binding untuk info klinik ruang tidak ter-update setelah fetch dari API. Kemungkinan reactive state tidak di-watch dengan benar sehingga UI tidak re-render saat data berubah.
|
||||
|
||||
**Solusi / Fix:**
|
||||
> Perbaikan data binding dan reactive state untuk informasi klinik ruang pada layar display.
|
||||
|
||||
**Related:** Commit `ec9dac0` — "push perbaikan layar informasi info klinik ruang tidak muncul"
|
||||
|
||||
---
|
||||
|
||||
### [EV-011] — Data Antrean Tidak Sinkron Antar Display Screen
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **ID** | EV-011 |
|
||||
| **Tanggal** | 2026-05-22 |
|
||||
| **Severity** | 🔴 Critical |
|
||||
| **Environment** | Development |
|
||||
| **Status** | ✅ Resolved |
|
||||
| **Reporter** | Akbar |
|
||||
|
||||
**Error Message:**
|
||||
> Display screen di lokasi berbeda menampilkan nomor panggil yang berbeda. Admin memanggil pasien tapi layar display tidak update, atau update terlambat > 10 detik.
|
||||
|
||||
**Langkah Reproduksi:**
|
||||
1. Buka admin loket di PC A
|
||||
2. Buka display screen di TV/monitor B
|
||||
3. Panggil pasien berikutnya di PC A
|
||||
4. Display di monitor B tidak menampilkan nomor panggil terbaru
|
||||
|
||||
**Root Cause:**
|
||||
> WebSocket client ID menggunakan random ID, sehingga server tidak bisa menargetkan pesan ke device tertentu secara reliable. Saat koneksi putus lalu reconnect, client ID berubah dan device kehilangan subscription.
|
||||
|
||||
**Solusi / Fix:**
|
||||
```typescript
|
||||
// ❌ Before — random client ID
|
||||
const clientId = `client_${Math.random().toString(36).substr(2, 9)}`
|
||||
|
||||
// ✅ After — deterministic client ID berdasarkan page + device
|
||||
const clientId = `${pageType}_${loketId || 'global'}_${deviceFingerprint}`
|
||||
```
|
||||
Ditambahkan juga polling fallback setiap 30 detik sebagai safety net.
|
||||
|
||||
**Prevention:**
|
||||
> Selalu gunakan deterministic identifier untuk WebSocket client. Implementasi polling fallback untuk semua halaman yang bergantung pada real-time data.
|
||||
|
||||
**Related:** Conversation `6fe234cf` — "Stabilizing WebSocket Queue Synchronization"
|
||||
|
||||
---
|
||||
|
||||
### [EV-010] — Cross-Loket Interference pada Patient Processing
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **ID** | EV-010 |
|
||||
| **Tanggal** | 2026-05-21 |
|
||||
| **Severity** | 🔴 Critical |
|
||||
| **Environment** | Development |
|
||||
| **Status** | ✅ Resolved |
|
||||
| **Reporter** | Akbar |
|
||||
|
||||
**Error Message:**
|
||||
> Loket A memproses pasien yang seharusnya milik Loket B. `currentProcessingPatient` menampilkan pasien dari loket yang salah.
|
||||
|
||||
**Langkah Reproduksi:**
|
||||
1. Login sebagai admin loket A di browser tab 1
|
||||
2. Login sebagai admin loket B di browser tab 2
|
||||
3. Panggil pasien di loket A
|
||||
4. Loket B menampilkan pasien yang sama sebagai "sedang diproses"
|
||||
|
||||
**Root Cause:**
|
||||
> `currentProcessingPatient` menggunakan key persisted state yang sama untuk semua loket. Karena `pinia-plugin-persistedstate` menyimpan ke `localStorage` dengan key yang sama, operasi di satu loket menimpa state loket lain.
|
||||
|
||||
**Solusi / Fix:**
|
||||
```javascript
|
||||
// ❌ Before — shared key
|
||||
persist: { key: 'currentProcessingPatient' }
|
||||
|
||||
// ✅ After — unique key per loket
|
||||
persist: { key: `currentPatient_loket_${loketId}` }
|
||||
```
|
||||
Ditambahkan juga strict filter di `processNextQueue` dan `callNext` berdasarkan `loketId`, serta guard check di WebSocket event handler.
|
||||
|
||||
**Prevention:**
|
||||
> Setiap state yang bersifat per-instance (per loket, per klinik) HARUS menggunakan unique key. Jangan pernah share persisted state key antar instance.
|
||||
|
||||
**Related:** Conversation `7a37e693` — "Isolating Loket Queue Operations"
|
||||
|
||||
---
|
||||
|
||||
### [EV-009] — Bug Tampilan Loket
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **ID** | EV-009 |
|
||||
| **Tanggal** | 2026-05-20 |
|
||||
| **Severity** | 🟡 Medium |
|
||||
| **Environment** | Development |
|
||||
| **Status** | ✅ Resolved |
|
||||
| **Reporter** | Akbar |
|
||||
|
||||
**Error Message:**
|
||||
> Layout admin loket tidak render dengan benar — elemen UI tumpang tindih atau alignment salah.
|
||||
|
||||
**Langkah Reproduksi:**
|
||||
1. Login sebagai admin loket
|
||||
2. Buka halaman `/admin-loket`
|
||||
3. Layout tampilan loket tidak sesuai desain
|
||||
|
||||
**Root Cause:**
|
||||
> CSS layout issue dan data binding yang tidak sinkron dengan state loket.
|
||||
|
||||
**Solusi / Fix:**
|
||||
> Perbaikan layout dan data binding di halaman admin loket.
|
||||
|
||||
**Related:** Commit `f622052` — "perbaikan bug tampilan loket"
|
||||
|
||||
---
|
||||
|
||||
### [EV-008] — Memory Leak & Request Spam dari doctorStore
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **ID** | EV-008 |
|
||||
| **Tanggal** | 2026-05-18 |
|
||||
| **Severity** | 🟠 High |
|
||||
| **Environment** | Development |
|
||||
| **Status** | ✅ Resolved |
|
||||
| **Reporter** | Akbar |
|
||||
|
||||
**Error Message:**
|
||||
```
|
||||
Internal Server Error (500) — /klinik-api/doctors/...
|
||||
```
|
||||
Error terjadi berulang-ulang tanpa henti, menyebabkan request spam ke backend.
|
||||
|
||||
**Langkah Reproduksi:**
|
||||
1. Buka halaman yang memuat `doctorStore.js`
|
||||
2. Backend endpoint `/doctors` return 500
|
||||
3. Store terus retry tanpa batas
|
||||
4. Network tab penuh dengan request gagal, memory usage naik terus
|
||||
|
||||
**Root Cause:**
|
||||
> `doctorStore.js` tidak memiliki mekanisme backoff atau blacklist untuk endpoint yang gagal. Setiap kali fetch gagal, store langsung retry tanpa delay, menyebabkan infinite loop request ke server.
|
||||
|
||||
**Solusi / Fix:**
|
||||
```javascript
|
||||
// ✅ Implementasi blacklist endpoint gagal
|
||||
const blacklistedEndpoints = new Set()
|
||||
|
||||
async function fetchDoctors(endpoint) {
|
||||
if (blacklistedEndpoints.has(endpoint)) {
|
||||
return [] // skip, sudah di-blacklist
|
||||
}
|
||||
try {
|
||||
return await $fetch(endpoint)
|
||||
} catch (error) {
|
||||
if (error.status === 500) {
|
||||
blacklistedEndpoints.add(endpoint)
|
||||
console.warn(`Endpoint blacklisted: ${endpoint}`)
|
||||
}
|
||||
return []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Prevention:**
|
||||
> Semua API fetch HARUS memiliki: (1) error handling, (2) retry limit atau backoff, (3) blacklist mechanism untuk persistent failures. Jangan pernah retry tanpa batas.
|
||||
|
||||
**Related:** Conversation `c7502197` — "Optimizing Web Antrean Memory Usage", commit `cb3310b`
|
||||
|
||||
---
|
||||
|
||||
### [EV-007] — Console.log Verbose Memperlambat Performa
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **ID** | EV-007 |
|
||||
| **Tanggal** | 2026-05-18 |
|
||||
| **Severity** | 🟢 Low |
|
||||
| **Environment** | Development |
|
||||
| **Status** | ✅ Resolved |
|
||||
| **Reporter** | Akbar |
|
||||
|
||||
**Error Message:**
|
||||
> Tidak ada error message, tapi browser DevTools penuh dengan log output, menyebabkan performa menurun terutama di device yang lebih lambat.
|
||||
|
||||
**Langkah Reproduksi:**
|
||||
1. Buka halaman yang menggunakan WebSocket atau QR scanner
|
||||
2. Buka browser DevTools → Console
|
||||
3. Log membanjir setiap detik dari `useWebSocket.ts` dan QR scanner di `checkIn.vue`
|
||||
|
||||
**Root Cause:**
|
||||
> `console.log` debugging statements di high-frequency code paths (WebSocket message handler yang dipanggil per-detik, QR scanner frame processing) tidak dihapus setelah debugging selesai.
|
||||
|
||||
**Solusi / Fix:**
|
||||
> Cleanup semua `console.log` di:
|
||||
> - `composables/useWebSocket.ts` — WebSocket message handler
|
||||
> - `pages/CheckInPasien/checkIn.vue` — QR scanner frame processing
|
||||
> Hanya sisakan `console.warn` dan `console.error` untuk kondisi yang benar-benar perlu.
|
||||
|
||||
**Prevention:**
|
||||
> Gunakan convention: `console.log` hanya untuk debugging sementara, `console.warn`/`console.error` untuk production logging. Tambahkan lint rule atau pre-commit hook untuk mendeteksi `console.log` yang tersisa.
|
||||
|
||||
**Related:** Conversation `c7502197` — "Optimizing Web Antrean Memory Usage"
|
||||
|
||||
---
|
||||
|
||||
### [EV-006] — Header Origin/Referer Tidak Konsisten di Proxy Routes
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **ID** | EV-006 |
|
||||
| **Tanggal** | 2026-05-22 |
|
||||
| **Severity** | 🟡 Medium |
|
||||
| **Environment** | Development |
|
||||
| **Status** | ✅ Resolved |
|
||||
| **Reporter** | Akbar |
|
||||
|
||||
**Error Message:**
|
||||
> CORS error atau origin validation failure saat memanggil backend API melalui proxy routes. Beberapa request berhasil, beberapa gagal secara intermittent.
|
||||
|
||||
**Langkah Reproduksi:**
|
||||
1. Panggil API via proxy route `stats-api`, `visit-api`, atau `klinik-api`
|
||||
2. Beberapa request gagal dengan CORS error
|
||||
3. Request yang sama kadang berhasil, kadang gagal
|
||||
|
||||
**Root Cause:**
|
||||
> Setiap proxy route handler (`server/routes/stats-api/`, `visit-api/`, `klinik-api/`) menggunakan header origin dan referer yang berbeda-beda. Backend melakukan validasi origin, dan header yang tidak konsisten menyebabkan intermittent failure.
|
||||
|
||||
**Solusi / Fix:**
|
||||
> Standardisasi semua proxy route handler agar menggunakan origin dan referer yang sama:
|
||||
```typescript
|
||||
// ✅ Semua proxy routes menggunakan header yang sama
|
||||
headers: {
|
||||
'Origin': 'http://10.10.150.175:3000',
|
||||
'Referer': 'http://10.10.150.175:3000',
|
||||
}
|
||||
```
|
||||
|
||||
**Prevention:**
|
||||
> Buat shared utility function untuk proxy header configuration agar semua route handler menggunakan config yang sama. Hindari copy-paste header config per file.
|
||||
|
||||
**Related:** Conversation `97867794` — "Standardizing Header Configurations Across APIs"
|
||||
|
||||
---
|
||||
|
||||
### [EV-005] — Duplikasi Tiket di Admin Klinik Loket
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **ID** | EV-005 |
|
||||
| **Tanggal** | 2026-02-11 |
|
||||
| **Severity** | 🟠 High |
|
||||
| **Environment** | Development |
|
||||
| **Status** | ✅ Resolved |
|
||||
| **Reporter** | Akbar |
|
||||
|
||||
**Error Message:**
|
||||
> Daftar pasien di admin klinik menampilkan tiket yang sama dua kali. Setelah check-in, tiket pasien menghilang dari daftar.
|
||||
|
||||
**Langkah Reproduksi:**
|
||||
1. Pasien ambil antrean di anjungan
|
||||
2. Buka admin klinik loket
|
||||
3. Pasien yang sama muncul 2x di daftar
|
||||
4. Setelah check-in, tiket menghilang dari daftar
|
||||
|
||||
**Root Cause:**
|
||||
> WebSocket event handler menambahkan pasien ke `allPatients` tanpa deduplication check. Saat event masuk bersamaan dari multiple sources (initial load + WebSocket), data terduplikasi. Saat check-in, state update menghapus entry yang salah.
|
||||
|
||||
**Solusi / Fix:**
|
||||
```javascript
|
||||
// ✅ Deduplication check sebelum add ke allPatients
|
||||
function addPatient(patient) {
|
||||
const exists = allPatients.value.find(p => p.nomorAntrean === patient.nomorAntrean)
|
||||
if (!exists) {
|
||||
allPatients.value.push(patient)
|
||||
} else {
|
||||
// Update existing instead of duplicate
|
||||
Object.assign(exists, patient)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Prevention:**
|
||||
> Semua operasi mutasi pada `allPatients` harus melalui function yang melakukan deduplication check. Jangan pernah langsung `push` tanpa cek duplikat.
|
||||
|
||||
**Related:** Commit `c02905e` — "fix duplication and ticket disappear in adminklinik loket"
|
||||
|
||||
---
|
||||
|
||||
### [EV-004] — WebSocket Check-in Tidak Trigger UI Update
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **ID** | EV-004 |
|
||||
| **Tanggal** | 2026-02-10 |
|
||||
| **Severity** | 🟠 High |
|
||||
| **Environment** | Development |
|
||||
| **Status** | ✅ Resolved |
|
||||
| **Reporter** | Akbar |
|
||||
|
||||
**Error Message:**
|
||||
> Pasien check-in via QR berhasil (API return success), tapi status di admin loket tidak berubah. UI tetap menampilkan pasien sebagai "belum hadir".
|
||||
|
||||
**Langkah Reproduksi:**
|
||||
1. Pasien scan QR di halaman check-in
|
||||
2. API return success — pasien tercatat hadir
|
||||
3. Buka admin loket — status pasien masih "belum hadir"
|
||||
4. Perlu manual refresh untuk melihat update
|
||||
|
||||
**Root Cause:**
|
||||
> WebSocket event listener untuk check-in event tidak ter-bind dengan benar. Event `checkin_success` diterima oleh WebSocket, tapi handler tidak melakukan state update ke Pinia store.
|
||||
|
||||
**Solusi / Fix:**
|
||||
> Fix event listener binding di `useWebSocket.ts` — pastikan `checkin_success` event memicu update pada `queueStore.allPatients`.
|
||||
|
||||
**Prevention:**
|
||||
> Setiap WebSocket event type harus memiliki dedicated handler yang ter-test. Buat mapping event → handler yang eksplisit.
|
||||
|
||||
**Related:** Commits `e686dda`, `b6dc252` — "fix socket checkin", "update bug ws checkin loket"
|
||||
|
||||
---
|
||||
|
||||
### [EV-003] — HTTPS Implementation Gagal di Check-in
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **ID** | EV-003 |
|
||||
| **Tanggal** | 2026-02-02 |
|
||||
| **Severity** | 🟡 Medium |
|
||||
| **Environment** | Development |
|
||||
| **Status** | ✅ Resolved |
|
||||
| **Reporter** | Akbar |
|
||||
|
||||
**Error Message:**
|
||||
> Check-in via QR tidak berfungsi saat HTTPS aktif. Kamera QR scanner tidak bisa diakses.
|
||||
|
||||
**Langkah Reproduksi:**
|
||||
1. Aktifkan HTTPS di dev server
|
||||
2. Buka halaman check-in
|
||||
3. QR scanner gagal — kamera tidak muncul
|
||||
|
||||
**Root Cause:**
|
||||
> Browser memerlukan HTTPS untuk akses kamera (MediaDevices API), tapi mixed content policy memblokir request ke backend HTTP. Konfigurasi HTTPS tidak lengkap — SSL certificate self-signed tidak dipercaya browser.
|
||||
|
||||
**Solusi / Fix:**
|
||||
> Fix implementasi HTTPS menggunakan `@vitejs/plugin-basic-ssl` di `nuxt.config.ts`. Konfigurasi dev server untuk HTTPS dengan self-signed cert yang di-trust browser.
|
||||
|
||||
**Prevention:**
|
||||
> Saat menggunakan Web API yang memerlukan secure context (kamera, geolocation), pastikan HTTPS sudah configured end-to-end termasuk backend.
|
||||
|
||||
**Related:** Commit `c899a71` — "fix https implementation and checkin"
|
||||
|
||||
---
|
||||
|
||||
### [EV-002] — Loop & Duplikasi Data di Fetch API
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **ID** | EV-002 |
|
||||
| **Tanggal** | 2026-01-29 |
|
||||
| **Severity** | 🟠 High |
|
||||
| **Environment** | Development |
|
||||
| **Status** | ✅ Resolved |
|
||||
| **Reporter** | Akbar |
|
||||
|
||||
**Error Message:**
|
||||
> Data pasien terduplikasi di daftar antrean. Fetch API dipanggil berulang-ulang dalam loop.
|
||||
|
||||
**Langkah Reproduksi:**
|
||||
1. Buka halaman yang memuat data antrean
|
||||
2. Data pasien muncul berkali-kali
|
||||
3. Network tab menunjukkan API dipanggil berulang-ulang
|
||||
|
||||
**Root Cause:**
|
||||
> `watch` atau `computed` yang bergantung pada reactive state memicu re-fetch setiap kali state berubah, dan hasil fetch mengubah state lagi — menyebabkan infinite loop.
|
||||
|
||||
**Solusi / Fix:**
|
||||
> Fix logic watch/computed agar tidak circular. Tambahkan guard condition untuk mencegah re-fetch saat data sudah di-load.
|
||||
|
||||
**Prevention:**
|
||||
> Hindari circular dependency antara watcher dan state mutation. Gunakan flag `isLoading` untuk mencegah concurrent fetch.
|
||||
|
||||
**Related:** Commit `0bd5311` — "fix loop dan duplicate data"
|
||||
|
||||
---
|
||||
|
||||
### [EV-001] — Session & Tampilan Screen Tidak Sinkron
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **ID** | EV-001 |
|
||||
| **Tanggal** | 2026-01-30 |
|
||||
| **Severity** | 🟡 Medium |
|
||||
| **Environment** | Development |
|
||||
| **Status** | ✅ Resolved |
|
||||
| **Reporter** | Akbar |
|
||||
|
||||
**Error Message:**
|
||||
> Display screen menampilkan data lama setelah session expire. Setelah re-login, data tidak refresh otomatis.
|
||||
|
||||
**Langkah Reproduksi:**
|
||||
1. Login dan buka display screen
|
||||
2. Tunggu session expire (1 jam)
|
||||
3. Re-login
|
||||
4. Display masih menampilkan data dari session sebelumnya
|
||||
|
||||
**Root Cause:**
|
||||
> Pinia persisted state menyimpan data lama di `localStorage`. Saat session expire dan user re-login, store tidak di-reset — data lama masih tampil.
|
||||
|
||||
**Solusi / Fix:**
|
||||
> Reset persisted state saat login baru. Pastikan display screen melakukan fresh fetch setelah session recovery.
|
||||
|
||||
**Prevention:**
|
||||
> Implementasi session lifecycle hooks: on session expire → clear stale state, on re-login → fresh fetch semua data.
|
||||
|
||||
**Related:** Commit `8dd94ed` — "update fix session dan tampilan screen"
|
||||
|
||||
---
|
||||
|
||||
## 📊 Error Statistics
|
||||
|
||||
| Bulan | Critical | High | Medium | Low | Total |
|
||||
|-------|---------|------|--------|-----|-------|
|
||||
| Jan 2026 | 0 | 1 | 1 | 0 | 2 |
|
||||
| Feb 2026 | 0 | 3 | 1 | 0 | 4 |
|
||||
| Mar 2026 | 0 | 0 | 0 | 0 | 0 |
|
||||
| Apr 2026 | 0 | 0 | 0 | 0 | 0 |
|
||||
| Mei 2026 | 2 | 2 | 1 | 1 | 6 |
|
||||
| **Total** | **2** | **6** | **3** | **1** | **12** |
|
||||
|
||||
---
|
||||
|
||||
## 🔁 Recurring Issues
|
||||
|
||||
> Daftar error yang muncul lebih dari sekali — kandidat untuk refactor/improvement permanen.
|
||||
|
||||
| Error Pattern | Frekuensi | Action |
|
||||
|---------------|-----------|--------|
|
||||
| WebSocket disconnect → data tidak sinkron | 3x (EV-004, EV-011, EV-012) | ✅ Implemented: polling fallback 30 detik + auto-reconnect + deterministic client ID |
|
||||
| Duplikasi data pasien di daftar | 2x (EV-002, EV-005) | ✅ Implemented: deduplication check di `allPatients` mutation |
|
||||
| Session/state stale setelah reconnect | 2x (EV-001, EV-011) | ✅ Implemented: fresh fetch on reconnect + state reset on re-login |
|
||||
| Request spam ke endpoint yang gagal | 1x (EV-008) | ✅ Implemented: blacklist + retry limit. **Monitor untuk recurring** |
|
||||
| Cross-instance state conflict (loket) | 1x (EV-010) | ✅ Implemented: unique persisted state key per instance. **Audit untuk klinik/penunjang** |
|
||||
@@ -0,0 +1,84 @@
|
||||
# 🌿 Git Workflow & Branching Guide
|
||||
|
||||
Dokumen ini berisi panduan *step-by-step* untuk mengelola Git *branch* di project **web-antrean**. Branch utama (main) untuk project ini adalah **`Antrean-Code`**.
|
||||
|
||||
---
|
||||
|
||||
## 1. Persiapan Sebelum Mengubah Kode (Membuat Branch Baru)
|
||||
Sangat disarankan untuk tidak mengubah kode secara langsung di `Antrean-Code`. Buatlah branch baru untuk setiap fitur atau perbaikan *bug*.
|
||||
|
||||
**Langkah-langkah:**
|
||||
1. Pastikan Anda berada di branch utama dan kodenya terbaru:
|
||||
```bash
|
||||
git checkout Antrean-Code
|
||||
git pull origin Antrean-Code
|
||||
```
|
||||
2. Buat dan pindah ke branch baru (misalnya `fitur-baru`):
|
||||
```bash
|
||||
git checkout -b nama-fitur-anda
|
||||
```
|
||||
*(Contoh: `git checkout -b tes-refaktoring`)*
|
||||
|
||||
---
|
||||
|
||||
## 2. Menyimpan Perubahan (Commit)
|
||||
Setelah Anda selesai menulis kode, ikuti langkah ini untuk menyimpannya.
|
||||
|
||||
**Langkah-langkah:**
|
||||
1. Cek file apa saja yang berubah:
|
||||
```bash
|
||||
git status
|
||||
```
|
||||
2. Tambahkan file yang ingin disimpan:
|
||||
```bash
|
||||
git add .
|
||||
```
|
||||
*(Tanda `.` artinya menambahkan semua file. Anda juga bisa menyebut nama filenya satu-satu)*
|
||||
3. Lakukan commit dengan pesan yang jelas (mengikuti aturan Conventional Commits di SKILL.md):
|
||||
```bash
|
||||
git commit -m "feat: menambah fitur X"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Menggabungkan Kode ke Branch Utama (Merge ke Antrean-Code)
|
||||
Jika fitur di branch Anda sudah selesai dan teruji, saatnya menggabungkannya (*merge*) kembali ke `Antrean-Code`. Ini adalah tahapan yang baru saja kita lakukan.
|
||||
|
||||
**Langkah-langkah:**
|
||||
1. Pastikan tidak ada perubahan yang belum di-commit di branch Anda (harus clean).
|
||||
```bash
|
||||
git status
|
||||
```
|
||||
2. Pindah ke branch utama (`Antrean-Code`):
|
||||
```bash
|
||||
git checkout Antrean-Code
|
||||
```
|
||||
3. Gabungkan (*merge*) branch fitur Anda (misal `tes-refaktoring`) ke `Antrean-Code`:
|
||||
```bash
|
||||
git merge tes-refaktoring
|
||||
```
|
||||
4. Jika tidak ada konflik, Git akan otomatis menyatukan kodenya. (Jika ada konflik, Anda harus memperbaikinya secara manual di code editor, lalu `git add .` dan `git commit`).
|
||||
|
||||
---
|
||||
|
||||
## 4. Mengunggah Kode ke Server (Push ke Remote)
|
||||
Setelah merge selesai di komputer lokal, Anda harus mengunggah perubahannya ke server GitLab/GitHub.
|
||||
|
||||
**Langkah-langkah:**
|
||||
1. Pastikan Anda berada di branch `Antrean-Code`.
|
||||
2. Push ke remote origin:
|
||||
```bash
|
||||
git push origin Antrean-Code
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Membersihkan Branch (Opsional)
|
||||
Jika branch fitur sudah berhasil di-merge dan di-push, Anda bisa menghapusnya agar daftar branch lokal tetap rapi.
|
||||
```bash
|
||||
git branch -d tes-refaktoring
|
||||
```
|
||||
|
||||
---
|
||||
> **Catatan Penting:**
|
||||
> Jangan lupa untuk selalu mengacu pada `docs/DEVLOG.md` saat Anda berhasil menggabungkan fitur besar untuk mencatat apa saja yang baru dirilis.
|
||||
+409
@@ -0,0 +1,409 @@
|
||||
# 📋 PRD — Product Requirements Document
|
||||
|
||||
**Project:** Web Antrean — Sistem Manajemen Antrean Rawat Jalan RSSA
|
||||
**Version:** 1.1.0
|
||||
**Status:** `In Progress`
|
||||
**Author:** Akbar
|
||||
**Stack:** JavaScript · Vue 3 · TypeScript · Nuxt 3 · Vuetify 3 · Pinia
|
||||
**Last Updated:** 2026-07-10
|
||||
|
||||
---
|
||||
|
||||
## 🧠 AI Prompt — Cara Menggunakan Dokumen Ini
|
||||
|
||||
> Salin prompt berikut ke Claude / ChatGPT untuk membantu melanjutkan PRD:
|
||||
|
||||
```
|
||||
Kamu adalah product manager senior. Bantu aku mengembangkan PRD untuk project berikut:
|
||||
- Nama project: Web Antrean — Sistem Manajemen Antrean Rawat Jalan RSSA
|
||||
- Tujuan utama: Digitalisasi alur antrean pasien rawat jalan di rumah sakit
|
||||
- Target pengguna: Pasien, Admin Loket, Admin Klinik, Admin Penunjang, Superadmin
|
||||
- Tech stack: Nuxt 3, Vue 3, TypeScript, Vuetify 3, Pinia, WebSocket, Keycloak SSO
|
||||
|
||||
Buatkan:
|
||||
1. Problem statement yang tajam
|
||||
2. User stories tambahan dengan acceptance criteria
|
||||
3. Risiko teknis dan mitigasinya
|
||||
|
||||
Format output dalam Markdown.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
### 1.1 Executive Summary
|
||||
Web Antrean adalah aplikasi manajemen antrean rawat jalan berbasis web yang dibangun untuk Rumah Sakit RSSA. Sistem ini mendigitalisasi seluruh alur antrean pasien mulai dari pendaftaran via anjungan mandiri, pemanggilan pasien di loket dan klinik, hingga monitoring real-time oleh administrator. Aplikasi ini kritis karena menggantikan sistem antrean manual yang tidak efisien dan rawan error.
|
||||
|
||||
### 1.2 Problem Statement
|
||||
Rumah Sakit RSSA menghadapi masalah antrean rawat jalan yang tidak terstruktur: pasien tidak mengetahui posisi antrean mereka, petugas loket kesulitan mengelola urutan panggil, dan tidak ada visibilitas real-time lintas unit (loket, klinik, penunjang). Kondisi ini menyebabkan penumpukan pasien, waktu tunggu tidak terprediksi, dan pengalaman buruk bagi pasien.
|
||||
|
||||
### 1.3 Goals & Success Metrics
|
||||
|
||||
| # | Goal | Metrik | Target |
|
||||
|---|------|--------|--------|
|
||||
| 1 | Mengurangi waktu tunggu pasien | Rata-rata waktu tunggu per sesi | < 30 menit |
|
||||
| 2 | Digitalisasi antrean loket & klinik | % proses antrean via sistem | 100% |
|
||||
| 3 | Real-time sync antar perangkat | Latensi update antrean via WebSocket | < 2 detik |
|
||||
| 4 | Kemandirian pasien dalam ambil antrean | % pasien gunakan anjungan mandiri | > 80% |
|
||||
| 5 | Stabilitas sistem | Uptime saat jam operasional | > 99.5% |
|
||||
|
||||
### 1.4 Non-Goals (Out of Scope)
|
||||
- [ ] Integrasi dengan sistem pembayaran / billing
|
||||
- [ ] Manajemen rekam medis (EMR)
|
||||
- [ ] Aplikasi mobile native (iOS/Android)
|
||||
- [ ] Antrean rawat inap (ranap) — fitur ranap admin tersedia tapi belum final
|
||||
- [ ] Sistem appointment/penjadwalan janji temu online
|
||||
|
||||
---
|
||||
|
||||
## 2. Stakeholders
|
||||
|
||||
| Role | Nama | Tanggung Jawab |
|
||||
|------|------|----------------|
|
||||
| Product Owner | Tim RSSA | Prioritas fitur & validasi kebutuhan |
|
||||
| Fullstack Engineer | Akbar | Arsitektur, implementasi, & deployment |
|
||||
| UI/UX Designer | — | Desain antarmuka (dikerjakan oleh engineer) |
|
||||
| QA Engineer | — | Pengujian via Cypress & Vitest |
|
||||
| System Admin | Tim IT RSSA | Infrastruktur, server, & jaringan internal |
|
||||
|
||||
---
|
||||
|
||||
## 3. User Personas
|
||||
|
||||
### 🧑 Persona 1: Pasien Rawat Jalan
|
||||
- **Goals:** Mendapatkan nomor antrean dengan mudah, mengetahui posisi antrean, dan dipanggil tepat waktu
|
||||
- **Pain Points:** Tidak tahu urutan antrean, harus menunggu tanpa informasi, bingung harus ke mana
|
||||
- **Tech Level:** Rendah — menggunakan anjungan layar sentuh di rumah sakit
|
||||
|
||||
### 🧑 Persona 2: Petugas Loket
|
||||
- **Goals:** Memanggil pasien secara urut, memproses lebih dari satu loket secara paralel, melihat daftar antrean real-time
|
||||
- **Pain Points:** Antrean manual rawan konflik antar loket, sulit pantau siapa yang sudah dipanggil
|
||||
- **Tech Level:** Menengah — menggunakan PC/tablet di meja loket
|
||||
|
||||
### 🧑 Persona 3: Admin Klinik / Dokter
|
||||
- **Goals:** Melihat daftar pasien yang akan dilayani di kliniknya, memanggil pasien sesuai urutan, mencatat status kunjungan
|
||||
- **Pain Points:** Tidak tahu berapa pasien tersisa, data pasien tidak sinkron antar perangkat
|
||||
- **Tech Level:** Menengah
|
||||
|
||||
### 🧑 Persona 4: Superadmin / Admin IT
|
||||
- **Goals:** Mengelola master data (klinik, loket, penunjang, screen), mengatur hak akses user, memonitor seluruh antrean
|
||||
- **Pain Points:** Tidak ada dashboard terpusat, perubahan konfigurasi butuh restart sistem
|
||||
- **Tech Level:** Tinggi
|
||||
|
||||
---
|
||||
|
||||
## 4. User Stories
|
||||
|
||||
| ID | Epic | Story | Priority | Acceptance Criteria | Status |
|
||||
|----|------|-------|----------|---------------------|--------|
|
||||
| US-001 | Anjungan | Sebagai pasien, saya ingin mengambil nomor antrean via anjungan mandiri agar tidak perlu antri ke loket | 🔴 High | - [ ] Pasien dapat memilih klinik tujuan<br>- [ ] Sistem generate nomor antrean unik<br>- [ ] Tiket dicetak atau ditampilkan di layar | `Done` |
|
||||
| US-002 | Anjungan | Sebagai pasien eksekutif, saya ingin memilih sub-spesialis di anjungan agar antrean sesuai dokter yang dituju | 🔴 High | - [ ] Daftar subspesialis tampil berdasarkan klinik<br>- [ ] Pilihan tersimpan ke nomor antrean | `Done` |
|
||||
| US-003 | Check-in | Sebagai pasien, saya ingin check-in via scan QR agar kedatangan saya tercatat di sistem | 🔴 High | - [ ] QR scanner aktif via kamera<br>- [ ] Status pasien berubah menjadi "hadir"<br>- [ ] Notifikasi berhasil muncul | `Done` |
|
||||
| US-004 | Loket | Sebagai petugas loket, saya ingin memanggil pasien berikutnya agar antrean berjalan terurut | 🔴 High | - [ ] Tombol "Panggil Berikutnya" tersedia<br>- [ ] Nomor antrean tampil di display screen<br>- [ ] Sync real-time via WebSocket | `Done` |
|
||||
| US-005 | Klinik | Sebagai admin klinik, saya ingin melihat daftar pasien yang akan dilayani agar saya bisa mempersiapkan pelayanan | 🔴 High | - [ ] Daftar pasien tampil dengan status terkini<br>- [ ] Update otomatis tanpa refresh manual | `Done` |
|
||||
| US-006 | Monitoring | Sebagai superadmin, saya ingin melihat dashboard antrean seluruh unit agar dapat memantau kondisi operasional | 🟡 Medium | - [ ] Statistik antrean per unit tersedia<br>- [ ] Data diperbarui real-time | `Done` |
|
||||
| US-007 | Setting | Sebagai superadmin, saya ingin mengatur hak akses per user/group agar keamanan data terjaga | 🟡 Medium | - [ ] CRUD hak akses per role & group Keycloak<br>- [ ] Perubahan langsung efektif tanpa restart | `Done` |
|
||||
| US-008 | Penunjang | Sebagai admin penunjang, saya ingin mengelola antrean unit penunjang (lab, radiologi) agar terpisah dari antrean klinik | 🟡 Medium | - [ ] Antrean penunjang terpisah per unit<br>- [ ] Admin hanya melihat unit penunjangnya | `In Progress` |
|
||||
|
||||
---
|
||||
|
||||
## 5. Functional Requirements
|
||||
|
||||
### 5.1 Modul: Anjungan Mandiri
|
||||
- **FR-01:** Pasien dapat memilih klinik tujuan dari daftar klinik aktif
|
||||
- **FR-02:** Sistem mengenerate nomor antrean berformat `[KodeKlinik]-[Nomor]` (contoh: `PDL-001`)
|
||||
- **FR-03:** Anjungan mendukung pilihan subspesialis untuk klinik eksekutif
|
||||
- **FR-04:** Tiket antrean dapat dicetak via thermal printer
|
||||
- **FR-05:** Anjungan dapat dikonfigurasi per tipe (klinik, penunjang, klinik ruang)
|
||||
|
||||
### 5.2 Modul: Check-in Pasien
|
||||
- **FR-06:** Pasien dapat check-in mandiri via scan QR code
|
||||
- **FR-07:** Sistem memvalidasi QR dan mengupdate status kunjungan ke "hadir"
|
||||
- **FR-08:** Riwayat check-in pasien dapat dilihat oleh petugas
|
||||
|
||||
### 5.3 Modul: Loket
|
||||
- **FR-09:** Petugas loket dapat memanggil pasien berikutnya sesuai urutan antrean
|
||||
- **FR-10:** Setiap loket memiliki state antrean yang terisolasi (tidak interferensi antar loket)
|
||||
- **FR-11:** Petugas dapat skip, recall, atau selesaikan pasien
|
||||
- **FR-12:** Display nomor panggil sinkron via WebSocket ke layar display
|
||||
- **FR-12b:** Pemanggilan antrean dilengkapi dengan notifikasi suara (voice over) otomatis
|
||||
|
||||
### 5.4 Modul: Klinik / Dokter
|
||||
- **FR-13:** Admin klinik melihat daftar pasien berdasarkan klinik yang diampu
|
||||
- **FR-14:** Status pasien (menunggu, dipanggil, selesai) dapat diupdate
|
||||
- **FR-15:** Pemanggilan pasien di klinik sync ke display screen ruangan
|
||||
|
||||
### 5.5 Modul: Dashboard & Monitoring
|
||||
- **FR-16:** Dashboard menampilkan statistik antrean real-time (total, menunggu, selesai)
|
||||
- **FR-17:** Superadmin dapat melihat data seluruh unit sekaligus
|
||||
- **FR-18:** Data diperbarui via WebSocket + polling fallback 30 detik
|
||||
|
||||
### 5.6 Modul: Setting & Master Data
|
||||
- **FR-19:** CRUD Master Klinik, Loket, Penunjang, Klinik Ruang, Screen
|
||||
- **FR-20:** Manajemen hak akses berbasis Keycloak role & group
|
||||
- **FR-21:** Konfigurasi screen display untuk setiap loket/klinik
|
||||
- **FR-22:** Manajemen user login dan sesi
|
||||
|
||||
### 5.7 Modul: Penunjang
|
||||
- **FR-23:** Antrean unit penunjang (lab, radiologi, dll) dikelola terpisah
|
||||
- **FR-24:** Admin penunjang hanya mengakses unit yang menjadi tanggung jawabnya
|
||||
|
||||
---
|
||||
|
||||
## 6. Non-Functional Requirements
|
||||
|
||||
| Kategori | Requirement | Target |
|
||||
|----------|-------------|--------|
|
||||
| Performance | WebSocket latency update antrean | < 2 detik |
|
||||
| Performance | Waktu load halaman utama | < 3 detik |
|
||||
| Performance | Polling fallback interval | 30 detik |
|
||||
| Security | Auth method | Keycloak SSO (OAuth 2.0 / OIDC) |
|
||||
| Security | Session duration | 1 jam (configurable) |
|
||||
| Security | Hak akses berbasis role & group | Role-Based + Group-Based |
|
||||
| Availability | Uptime jam operasional (06.00–21.00) | > 99.5% |
|
||||
| Accessibility | Anjungan — touch target size | > 44px (mudah dioperasikan pasien) |
|
||||
| Scalability | Jumlah koneksi WebSocket simultan | > 50 device sekaligus |
|
||||
| Compatibility | Browser support | Chrome, Edge (terbaru) |
|
||||
| Network | Operasi di jaringan LAN internal RS | ✅ Fully LAN-based |
|
||||
|
||||
---
|
||||
|
||||
## 7. Technical Specifications
|
||||
|
||||
### 7.1 Tech Stack
|
||||
|
||||
| Layer | Teknologi |
|
||||
|-------|-----------|
|
||||
| Frontend Framework | Nuxt 3 (SSR — server-side rendering) |
|
||||
| UI Framework | Vue 3 · Vuetify 3 |
|
||||
| Language | TypeScript · JavaScript |
|
||||
| State Management | Pinia + pinia-plugin-persistedstate |
|
||||
| Styling | SCSS (Vuetify override) + Material Design Icons |
|
||||
| Real-time | WebSocket (native browser API via `useWebSocket.ts`) |
|
||||
| Auth | Keycloak SSO (OAuth 2.0 / OIDC) |
|
||||
| Charts | Chart.js · vue-chartjs · nuxt-charts |
|
||||
| QR | html5-qrcode (scanner) · qrcode.vue (generator) |
|
||||
| Print | Thermal printer via `useThermalPrint.ts` |
|
||||
| Date | Day.js |
|
||||
| Icons | FontAwesome · Material Design Icons |
|
||||
| Testing | Vitest · Cypress |
|
||||
| Fonts | Inter (Google Fonts) |
|
||||
|
||||
### 7.2 Backend APIs (Eksternal)
|
||||
|
||||
| Service | Base URL | Keterangan |
|
||||
|---------|----------|------------|
|
||||
| Visit API | `http://10.10.123.135:8084/api/v1` | Data kunjungan & antrean utama |
|
||||
| Antrian API (Klinik) | `http://10.10.150.131:8089/api/v1` | Verifikasi & data klinik/dokter |
|
||||
| WebSocket | `ws://10.10.123.135:8084/api/v1/ws` | Real-time queue update |
|
||||
|
||||
### 7.3 Arsitektur Sistem
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Nuxt 3 Frontend (SSR/CSR) │
|
||||
│ Pages · Components · Stores (Pinia) │
|
||||
│ Composables: useWebSocket · useQueue · │
|
||||
│ useCheckIn · useThermalPrint · useQRScanner │
|
||||
└──────────┬──────────────────┬────────────────┘
|
||||
│ $fetch / useFetch │ WebSocket
|
||||
┌──────────▼──────────┐ ┌────▼───────────────┐
|
||||
│ Nuxt Server (Nitro) │ │ WebSocket Server │
|
||||
│ server/api/ │ │ ws://10.10.123. │
|
||||
│ SQLite (users.db) │ │ 135:8084/api/v1/ws│
|
||||
│ (Config + Users) │ └────────────────────┘
|
||||
└──────────┬──────────┘
|
||||
│ HTTP Proxy (CORS bypass)
|
||||
┌──────────▼──────────────────────────────────┐
|
||||
│ Backend Services (Eksternal) │
|
||||
│ Visit API (8084) · Antrian API (8089) │
|
||||
│ Keycloak SSO (auth.rssa.top) │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 7.4 Proxy Routes (Nuxt Server)
|
||||
|
||||
| Proxy Path | Target Backend | Keterangan |
|
||||
|------------|---------------|------------|
|
||||
| `/visit-api/**` | `http://10.10.123.135:8084/api/v1/**` | Data kunjungan pasien |
|
||||
| `/klinik-api/**` | `http://10.10.150.131:8089/api/v1/**` | Data klinik & dokter |
|
||||
| `/stats-api/**` | `http://10.10.123.135:8084/api/v1/**` | Statistik dashboard |
|
||||
|
||||
### 7.5 Struktur Folder
|
||||
|
||||
```
|
||||
web-antrean/
|
||||
├── assets/scss/ ← Global styles & variables
|
||||
├── components/ ← Reusable UI components
|
||||
├── composables/ ← useWebSocket, useQueue, useCheckIn,
|
||||
│ useThermalPrint, useQRScanner, useAPI
|
||||
├── layouts/ ← Layout default & admin
|
||||
├── middleware/ ← Auth & permission guards
|
||||
├── pages/ ← Semua halaman (lihat seksi 8.1)
|
||||
├── server/
|
||||
│ ├── api/ ← Internal API (auth, hak-akses, queue, users)
|
||||
│ └── routes/ ← Proxy routes (visit-api, klinik-api, stats-api)
|
||||
├── stores/ ← Pinia stores (queue, clinic, loket, dll)
|
||||
├── types/ ← TypeScript interfaces & types
|
||||
├── public/ ← Static assets (favicon, logo)
|
||||
├── nuxt.config.ts ← Konfigurasi utama Nuxt
|
||||
└── .env ← Environment variables (tidak di-commit)
|
||||
```
|
||||
|
||||
### 7.6 Internal API Endpoints (Nuxt Nitro)
|
||||
|
||||
| Method | Endpoint | Deskripsi | Auth |
|
||||
|--------|----------|-----------|------|
|
||||
| GET | `/api/permission` | Ambil permissions berdasarkan role & group | ✅ |
|
||||
| GET | `/api/hak-akses` | Daftar semua hak akses | ✅ |
|
||||
| POST | `/api/hak-akses` | Buat hak akses baru | ✅ |
|
||||
| PATCH | `/api/hak-akses/:id` | Update hak akses | ✅ |
|
||||
| DELETE | `/api/hak-akses/:id` | Hapus hak akses | ✅ |
|
||||
| GET | `/api/users/list` | Daftar user dari Keycloak | ✅ |
|
||||
| POST | `/api/auth/login` | Login via Keycloak SSO | ❌ |
|
||||
| POST | `/api/auth/logout` | Logout & invalidate session | ✅ |
|
||||
| POST | `/api/external/validate-token` | Validasi JWT token eksternal | ✅ |
|
||||
| GET | `/api/config/...` | Konfigurasi aplikasi | ✅ |
|
||||
|
||||
### 7.7 TypeScript Types Utama
|
||||
|
||||
```typescript
|
||||
// types/user.ts
|
||||
export interface User {
|
||||
id: string
|
||||
namaLengkap: string
|
||||
namaUser: string
|
||||
email: string
|
||||
tipeUser: string
|
||||
roles: string[]
|
||||
groups: string[]
|
||||
lastLogin: number
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
// types/queue.ts
|
||||
export interface QueuePatient {
|
||||
nomorAntrean: string
|
||||
namaPassien: string
|
||||
noRM: string
|
||||
klinik: string
|
||||
status: 'menunggu' | 'dipanggil' | 'selesai' | 'skip'
|
||||
loketId?: string
|
||||
subspesialis?: string
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
// types/permission.ts
|
||||
export interface Permission {
|
||||
id: number
|
||||
pagename: string
|
||||
read: boolean
|
||||
create: boolean
|
||||
update: boolean
|
||||
delete: boolean
|
||||
disable: boolean
|
||||
active: boolean
|
||||
level: number
|
||||
parent: number | null
|
||||
}
|
||||
|
||||
// types/api.ts
|
||||
export interface ApiResponse<T> {
|
||||
data: T
|
||||
message: string
|
||||
success: boolean
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. UI/UX
|
||||
|
||||
### 8.1 Halaman & Routes
|
||||
|
||||
| Halaman | Route | Auth | Role |
|
||||
|---------|-------|------|------|
|
||||
| Landing / Home | `/` | ❌ | Public |
|
||||
| Login | `/login-page` | ❌ | Public |
|
||||
| Dashboard | `/dashboard` | ✅ | Superadmin |
|
||||
| Admin Klinik | `/admin-klinik` | ✅ | Admin Klinik |
|
||||
| Admin Loket | `/admin-loket` | ✅ | Admin Loket |
|
||||
| Admin Penunjang | `/admin-penunjang` | ✅ | Admin Penunjang |
|
||||
| Buat Antrean | `/buat-antrean` | ✅ | Loket |
|
||||
| Klinik Ruang Admin | `/klinik-ruang-admin` | ✅ | Admin Klinik Ruang |
|
||||
| Ranap Admin | `/ranap-admin` | ✅ | Admin Ranap |
|
||||
| Anjungan Utama | `/anjungan` | ❌ | Kiosk |
|
||||
| Admin Anjungan | `/anjungan/admin-anjungan` | ✅ | Superadmin |
|
||||
| Antrian Klinik | `/anjungan/antrian-klinik` | ❌ | Kiosk |
|
||||
| Antrian Klinik Ruang | `/anjungan/antrian-klinik-ruang` | ❌ | Kiosk |
|
||||
| Antrian Penunjang | `/anjungan/antrian-penunjang` | ❌ | Kiosk |
|
||||
| Antrean Masuk Screen | `/anjungan/antrean-masuk` | ❌ | Display |
|
||||
| Check In Pasien | `/check-in-pasien/check-in` | ❌ | Kiosk |
|
||||
| Data Pasien | `/data-pasien` | ✅ | Admin |
|
||||
| Edit Data Pasien | `/data-pasien/edit/:id` | ✅ | Admin |
|
||||
| Monitoring Pasien | `/monitoring-pasien/monitoring-pasien` | ✅ | Admin |
|
||||
| Detail Pasien | `/monitoring-pasien/pasien/:id` | ✅ | Admin |
|
||||
| Profil | `/profile/profil` | ✅ | Semua |
|
||||
| User Login | `/setting/user-login` | ✅ | Superadmin |
|
||||
| Hak Akses | `/setting/hak-akses` | ✅ | Superadmin |
|
||||
| Master Klinik | `/setting/master-klinik` | ✅ | Superadmin |
|
||||
| Master Klinik Ruang | `/setting/master-klinik-ruang` | ✅ | Superadmin |
|
||||
| Master Loket | `/setting/master-loket` | ✅ | Superadmin |
|
||||
| Master Penunjang | `/setting/master-penunjang` | ✅ | Superadmin |
|
||||
| Screen Settings | `/setting/screen` | ✅ | Superadmin |
|
||||
| Verifikasi Akun | `/verifikasi-akun/verifikasi-akun` | ❌ | Public |
|
||||
| Detail Akun Verifikasi | `/verifikasi-akun/detail-akun` | ✅ | Admin |
|
||||
|
||||
### 8.2 Breakpoints
|
||||
|
||||
| Nama | Size | Keterangan |
|
||||
|------|------|------------|
|
||||
| Mobile | < 640px | Tidak diprioritaskan (akses via PC/tablet) |
|
||||
| Tablet | 640–1024px | Anjungan & loket (tablet) |
|
||||
| Desktop | > 1024px | Admin & monitoring |
|
||||
|
||||
### 8.3 Design System
|
||||
- **Framework UI:** Vuetify 3 (Material Design 3)
|
||||
- **Font:** Inter (400, 500, 600, 700)
|
||||
- **Icons:** Material Design Icons (`@mdi/font`) + FontAwesome
|
||||
- **Color scheme:** Mengikuti theme Vuetify (light/dark configurable)
|
||||
- **SCSS Variables:** Didefinisikan di `assets/scss/_variables.scss` & `_colors.scss`
|
||||
|
||||
---
|
||||
|
||||
## 9. Risks
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigasi |
|
||||
|------|-----------|--------|----------|
|
||||
| WebSocket disconnect saat jaringan LAN tidak stabil | Tinggi | Tinggi | Polling fallback 30 detik + auto-reconnect logic |
|
||||
| Backend API eksternal down (Visit API / Antrian API) | Sedang | Tinggi | Error handling graceful, retry logic, blacklist endpoint gagal |
|
||||
| Konflik antrean antar loket (race condition) | Sedang | Tinggi | State isolasi per `loketId`, strict filtering di `processNextQueue` |
|
||||
| Sesi Keycloak expire saat jam operasional | Sedang | Sedang | Session duration dikonfigurasi 1 jam, refresh token otomatis |
|
||||
| Thermal printer tidak kompatibel di semua device | Rendah | Sedang | Uji di device target sebelum go-live, fallback ke tampilan layar |
|
||||
| Data antrean tidak sinkron antar display screen | Sedang | Tinggi | WebSocket deterministic client ID + polling fallback |
|
||||
| Kapasitas WebSocket server saat pasien peak | Rendah | Tinggi | Monitor jumlah koneksi, koordinasi dengan tim backend |
|
||||
|
||||
---
|
||||
|
||||
## 10. Deployment
|
||||
|
||||
| Item | Detail |
|
||||
|------|--------|
|
||||
| Server | VPS / Server internal RSSA |
|
||||
| Domain dev | `http://10.10.150.175:3000` |
|
||||
| Domain staging | `https://antrean.dev.rssa.id` |
|
||||
| Domain prod | `https://antrean.rssa.id` |
|
||||
| Containerisasi | Docker + docker-compose |
|
||||
| Auth Server | Keycloak (`https://auth.rssa.top/realms/sandbox`) |
|
||||
| Build command | `nuxt build` |
|
||||
| Start command | `node .output/server/index.mjs` |
|
||||
|
||||
---
|
||||
|
||||
## 11. Changelog
|
||||
|
||||
| Versi | Tanggal | Author | Perubahan |
|
||||
|-------|---------|--------|-----------|
|
||||
| 1.1.0 | 2026-07-10 | Akbar | Update API endpoints (150.131), arsitektur diagram, proxy routes, Phase 7 |
|
||||
| 1.0.0 | 2026-05-25 | Akbar | Initial PRD — dibuat berdasarkan kondisi project aktual |
|
||||
+394
@@ -0,0 +1,394 @@
|
||||
# ✅ QMD — Quality Management Document
|
||||
|
||||
**Project:** Web Antrean — Sistem Manajemen Antrean Rawat Jalan RSSA
|
||||
**Version:** 1.0.0
|
||||
**Author:** Akbar
|
||||
**Stack:** JavaScript · Vue 3 · TypeScript · Nuxt 3 · Vuetify 3 · Pinia
|
||||
**Last Updated:** 2026-05-25
|
||||
|
||||
---
|
||||
|
||||
## 🧠 AI Prompt — Cara Menggunakan Dokumen Ini
|
||||
|
||||
> Salin prompt berikut ke Claude untuk membantu QA planning:
|
||||
|
||||
```
|
||||
Kamu adalah QA engineer senior untuk project Nuxt 3 + Vue 3 + TypeScript.
|
||||
Project: Web Antrean — Sistem manajemen antrean rawat jalan RSSA.
|
||||
|
||||
Fitur utama:
|
||||
- Anjungan mandiri (kiosk registrasi pasien, pilih klinik/subspesialis)
|
||||
- Check-in pasien via QR code
|
||||
- Manajemen antrean loket (panggil, skip, recall, selesai)
|
||||
- Manajemen antrean klinik & penunjang
|
||||
- Real-time sync via WebSocket + polling fallback
|
||||
- Dashboard monitoring & statistik
|
||||
- Setting: master data (klinik, loket, penunjang, screen), hak akses (Keycloak role/group)
|
||||
- Cetak tiket via thermal printer
|
||||
|
||||
Bantu aku membuat:
|
||||
1. Test plan lengkap (unit, integration, E2E)
|
||||
2. Test cases untuk fitur di atas
|
||||
3. Definition of Done (DoD) per story
|
||||
4. Checklist code review untuk Vue 3 + TypeScript
|
||||
5. Standar kualitas kode (naming, linting, typing)
|
||||
|
||||
Format dalam tabel Markdown. Tool: Vitest, Cypress, Vue Test Utils, happy-dom.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Quality Objectives
|
||||
|
||||
| Objektif | Target | Cara Ukur |
|
||||
|----------|--------|-----------|
|
||||
| Test Coverage (Unit) | > 60% | Vitest coverage report (`npx vitest run --coverage`) |
|
||||
| Bug Rate (prod) | < 5 bug/sprint | Manual tracking / issue log |
|
||||
| Code Review | 100% PR di-review | Git workflow |
|
||||
| TypeScript strict | Minimal `any` type | ESLint + `tsc --noEmit` |
|
||||
| WebSocket Reliability | > 99% message delivery | Monitoring log + polling fallback |
|
||||
| Page Load | < 3 detik | Lighthouse / manual timing di jaringan LAN |
|
||||
|
||||
---
|
||||
|
||||
## 2. Testing Strategy
|
||||
|
||||
### 2.1 Piramida Testing
|
||||
```
|
||||
▲
|
||||
/E2E\ ← Cypress (browser real)
|
||||
/──────\
|
||||
/ Integ \ ← Vitest + Nuxt Test Utils
|
||||
/──────────\
|
||||
/ Unit Test \ ← Vitest + Vue Test Utils + happy-dom
|
||||
/______________\
|
||||
```
|
||||
|
||||
### 2.2 Test Toolchain
|
||||
|
||||
| Tipe | Tool | Config File | Status |
|
||||
|------|------|-------------|--------|
|
||||
| Unit | Vitest + happy-dom | `vitest.config.ts` | ✅ Terkonfigurasi |
|
||||
| Component | Vue Test Utils (`@vue/test-utils`) | — | ✅ Terinstal |
|
||||
| E2E | Cypress | `cypress.config.ts` | ✅ Terkonfigurasi |
|
||||
| Component (Cypress) | Cypress Component Testing | `cypress.config.ts` → `component` | ✅ Terkonfigurasi |
|
||||
| Linting | ESLint (Nuxt preset) | `eslint.config.mjs` | ✅ Terkonfigurasi |
|
||||
| Type Check | TypeScript (via Nuxt) | `tsconfig.json` → extends `.nuxt/tsconfig.json` | ✅ |
|
||||
| Test Environment | happy-dom | `vitest.config.ts` → `environment: 'happy-dom'` | ✅ |
|
||||
|
||||
### 2.3 Test Commands
|
||||
|
||||
```bash
|
||||
# Unit & Component Tests
|
||||
npm run test # vitest (watch mode)
|
||||
npm run test:ui # vitest --ui (browser UI)
|
||||
|
||||
# E2E Tests
|
||||
npm run cypress:open # Cypress interactive
|
||||
npm run cypress:run # Cypress headless
|
||||
|
||||
# Linting
|
||||
npx eslint .
|
||||
|
||||
# Type Check
|
||||
npx nuxi typecheck
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Test Cases
|
||||
|
||||
### 3.1 Unit Tests — Composables
|
||||
|
||||
| ID | Modul | Test Case | Expected | Status |
|
||||
|----|-------|-----------|----------|--------|
|
||||
| UT-001 | `useAuth.ts` | Login — mengembalikan user data setelah Keycloak auth | `user.value` tidak null, memiliki `id`, `roles`, `groups` | `Todo` |
|
||||
| UT-002 | `useAuth.ts` | Logout — session di-clear dan redirect ke login page | `user.value` menjadi null, navigasi ke `/LoginPage` | `Todo` |
|
||||
| UT-003 | `useQueue.js` | `processNextQueue()` — mengambil pasien berikutnya sesuai loketId | Return pasien dengan status `menunggu` dan `loketId` yang cocok | `Todo` |
|
||||
| UT-004 | `useQueue.js` | `processNextQueue()` — skip pasien dari loket lain | Pasien dari loket lain tidak terproses | `Todo` |
|
||||
| UT-005 | `useWebSocket.ts` | Koneksi sukses — state connected | `isConnected.value === true` setelah open event | `Todo` |
|
||||
| UT-006 | `useWebSocket.ts` | Auto-reconnect setelah disconnect | Reconnect attempt dalam < 5 detik | `Todo` |
|
||||
| UT-007 | `useCheckIn.ts` | Check-in valid QR — update status pasien | Status pasien berubah ke `hadir`, return success | `Todo` |
|
||||
| UT-008 | `useCheckIn.ts` | Check-in invalid QR — error handling | Return error message, status tidak berubah | `Todo` |
|
||||
| UT-009 | `useQRScanner.ts` | Inisialisasi scanner — kamera aktif | Scanner instance terbuat tanpa error | `Todo` |
|
||||
| UT-010 | `useHakAkses.ts` | Fetch permissions — mapping role ke menu | Menu permissions sesuai dengan role & group user | `Todo` |
|
||||
| UT-011 | `useThermalPrint.ts` | Generate tiket — format nomor antrean benar | Output mengandung kode klinik + nomor urut | `Todo` |
|
||||
| UT-012 | `useClinicAPI.ts` | Fetch daftar klinik — return data klinik aktif | Array klinik tidak kosong, setiap item punya `id` & `nama` | `Todo` |
|
||||
|
||||
### 3.2 Unit Tests — Stores (Pinia)
|
||||
|
||||
| ID | Modul | Test Case | Expected | Status |
|
||||
|----|-------|-----------|----------|--------|
|
||||
| UT-013 | `queueStore.js` | `allPatients` — menyimpan & mengembalikan daftar pasien | State `allPatients` terisi array setelah fetch | `Todo` |
|
||||
| UT-014 | `queueStore.js` | `currentProcessingPatient` — isolasi per loket | Setiap loket punya key unik di persisted state | `Todo` |
|
||||
| UT-015 | `clinicStore.js` | Fetch daftar klinik dari API | `clinics` terisi data dari klinik-api | `Todo` |
|
||||
| UT-016 | `doctorStore.js` | Blacklist endpoint gagal | Endpoint yang 500 di-blacklist, tidak di-retry spam | `Todo` |
|
||||
| UT-017 | `loketStore.js` | State loket terisolasi antar loket | Operasi di loket A tidak mempengaruhi loket B | `Todo` |
|
||||
| UT-018 | `masterStore.js` | CRUD master klinik | Create, read, update, delete berjalan tanpa error | `Todo` |
|
||||
| UT-019 | `permissionStore.ts` | Load permission sesuai role | Permission loaded dan accessible via getter | `Todo` |
|
||||
|
||||
### 3.3 Unit Tests — Middleware
|
||||
|
||||
| ID | Modul | Test Case | Expected | Status |
|
||||
|----|-------|-----------|----------|--------|
|
||||
| UT-020 | `auth.ts` | User belum login → redirect ke `/LoginPage` | `navigateTo('/LoginPage')` dipanggil | `Todo` |
|
||||
| UT-021 | `auth.ts` | User sudah login → lanjut ke halaman tujuan | Tidak ada redirect | `Todo` |
|
||||
| UT-022 | `guest.ts` | User sudah login akses `/LoginPage` → redirect ke `/dashboard` | `navigateTo('/dashboard')` dipanggil | `Todo` |
|
||||
| UT-023 | `permissions.ts` | User tanpa akses ke halaman → redirect/block | Akses ditolak, redirect ke halaman authorized | `Todo` |
|
||||
| UT-024 | `checkPageAccess.ts` | Validasi hak akses per halaman berdasarkan group | Halaman hanya bisa diakses sesuai permission | `Todo` |
|
||||
|
||||
### 3.4 Component Tests
|
||||
|
||||
| ID | Komponen | Skenario | Expected | Status |
|
||||
|----|----------|----------|----------|--------|
|
||||
| CT-001 | `PatientCard.vue` | Render data pasien lengkap | Nama, noRM, nomor antrean, status, subspesialis tampil | `Todo` |
|
||||
| CT-002 | `PatientCard.vue` | Status badge warna sesuai status | `menunggu` = kuning, `dipanggil` = biru, `selesai` = hijau | `Todo` |
|
||||
| CT-003 | `CurrentPatientCard.vue` | Tampilkan pasien yang sedang diproses | Data pasien aktif tampil dengan aksi (selesai, skip) | `Todo` |
|
||||
| CT-004 | `QueueActionsCard.vue` | Tombol aksi antrean (panggil, skip, recall) | Semua tombol render dan emit event yang benar | `Todo` |
|
||||
| CT-005 | `TabelPatientData.vue` | Render tabel daftar pasien | Kolom: nama, noRM, antrean, status, aksi tampil benar | `Todo` |
|
||||
| CT-006 | `SideBar.vue` | Menu render sesuai hak akses user | Menu yang tidak diizinkan tidak tampil | `Todo` |
|
||||
| CT-007 | `PageHeader.vue` | Render judul halaman dan breadcrumb | Judul dan navigasi sesuai route aktif | `Todo` |
|
||||
| CT-008 | `AppSnackbar.vue` | Notifikasi muncul dan auto-dismiss | Snackbar tampil 3 detik lalu hilang | `Todo` |
|
||||
| CT-009 | `SelectionDialog.vue` | Dialog pilihan dengan konfirmasi | Pilihan terseleksi, emit event saat konfirmasi | `Todo` |
|
||||
| CT-010 | `ProfileMenu.vue` | Tampil info user dan tombol logout | Nama user tampil, klik logout memanggil `useAuth().logout()` | `Todo` |
|
||||
|
||||
### 3.5 E2E Tests (Cypress)
|
||||
|
||||
| ID | Flow | Steps | Expected | Status |
|
||||
|----|------|-------|----------|--------|
|
||||
| E2E-001 | Login | 1. Buka `/` 2. Redirect ke `/LoginPage` 3. Klik login Keycloak 4. Isi credentials | Redirect ke `/dashboard`, user session aktif | `Skeleton` |
|
||||
| E2E-002 | Anjungan — Ambil Antrean | 1. Buka `/anjungan` 2. Pilih klinik 3. Konfirmasi | Nomor antrean di-generate, tiket tampil | `Todo` |
|
||||
| E2E-003 | Anjungan Eksekutif — Pilih Subspesialis | 1. Buka `/anjungan` 2. Pilih klinik eksekutif 3. Pilih subspesialis 4. Konfirmasi | Antrean tercipta dengan subspesialis terpilih | `Todo` |
|
||||
| E2E-004 | Check-in QR | 1. Buka `/check-in-pasien/check-in` 2. Scan QR valid | Status pasien update ke "hadir", notifikasi sukses | `Todo` |
|
||||
| E2E-005 | Loket — Panggil Pasien | 1. Login sebagai admin loket 2. Buka `/admin-loket` 3. Klik "Panggil Berikutnya" | Pasien berikutnya tampil di current patient card | `Todo` |
|
||||
| E2E-006 | Loket — Skip & Recall | 1. Panggil pasien 2. Klik skip 3. Klik recall | Pasien di-skip lalu bisa di-recall kembali | `Todo` |
|
||||
| E2E-007 | Klinik — Lihat Daftar Pasien | 1. Login sebagai admin klinik 2. Buka `/admin-klinik` | Daftar pasien klinik tampil sesuai klinik user | `Todo` |
|
||||
| E2E-008 | Dashboard — Statistik | 1. Login sebagai superadmin 2. Buka `/dashboard` | Chart statistik dan data antrean tampil | `Todo` |
|
||||
| E2E-009 | Setting — CRUD Master Klinik | 1. Buka `/setting/master-klinik` 2. Tambah klinik 3. Edit 4. Hapus | Data klinik berhasil CRUD tanpa error | `Todo` |
|
||||
| E2E-010 | Setting — Hak Akses | 1. Buka `/setting/hak-akses` 2. Pilih role & group 3. Set permission 4. Simpan | Hak akses tersimpan dan efektif | `Todo` |
|
||||
| E2E-011 | WebSocket Sync | 1. Buka admin loket di tab A 2. Buka display screen di tab B 3. Panggil pasien di tab A | Tab B menampilkan nomor panggil dalam < 2 detik | `Todo` |
|
||||
| E2E-012 | Auth Guard | 1. Tanpa login, akses `/dashboard` | Redirect ke `/LoginPage` | `Todo` |
|
||||
|
||||
---
|
||||
|
||||
## 4. Definition of Done (DoD)
|
||||
|
||||
Sebuah task/story dianggap **Done** jika:
|
||||
|
||||
- [ ] Code sudah diimplementasi dan berjalan di dev server
|
||||
- [ ] Unit test ditulis untuk logic kritis (composable, store)
|
||||
- [ ] TypeScript: tidak ada error pada `npx nuxi typecheck`
|
||||
- [ ] ESLint: tidak ada error (`npx eslint .`)
|
||||
- [ ] Code review dilakukan minimal 1 orang
|
||||
- [ ] Tested manual di browser Chrome (target utama)
|
||||
- [ ] WebSocket sync diverifikasi antar device (jika fitur terkait real-time)
|
||||
- [ ] Responsive layout dicek untuk tablet (anjungan) dan desktop (admin)
|
||||
- [ ] Tidak ada `console.log` debugging yang tertinggal di production path
|
||||
- [ ] State antrean terisolasi per loket (jika fitur terkait loket)
|
||||
- [ ] Polling fallback 30 detik berfungsi sebagai safety net
|
||||
|
||||
---
|
||||
|
||||
## 5. Code Review Checklist
|
||||
|
||||
### General
|
||||
- [ ] Logic mudah dibaca dan dipahami
|
||||
- [ ] Tidak ada dead code atau file `.txt` backup yang tersisa
|
||||
- [ ] Error handling ada di semua `$fetch` / `useFetch` call
|
||||
- [ ] Tidak ada hardcoded IP/URL (gunakan `.env` + `runtimeConfig`)
|
||||
- [ ] Tidak ada `console.log` di high-frequency path (WebSocket handler, polling loop)
|
||||
|
||||
### Vue 3 + TypeScript
|
||||
- [ ] `<script setup>` digunakan (Composition API)
|
||||
- [ ] Props & emits memiliki type yang jelas
|
||||
- [ ] Minimal penggunaan `any` type (justified jika ada)
|
||||
- [ ] Composables dipakai untuk logic reusable (`composables/use*.ts`)
|
||||
- [ ] Reactive data menggunakan `ref()` / `reactive()` dengan benar
|
||||
- [ ] `watch` / `computed` digunakan daripada manual mutation
|
||||
|
||||
### Nuxt 3 Specific
|
||||
- [ ] Data fetching pakai `$fetch` / `useFetch` / `useAsyncData`
|
||||
- [ ] Middleware auth terpasang di route yang memerlukan login
|
||||
- [ ] Server routes (proxy) mengikuti konvensi `server/routes/[prefix]/[...].ts`
|
||||
- [ ] Internal API mengikuti konvensi `server/api/[resource].[method].ts`
|
||||
- [ ] Environment variables diakses via `useRuntimeConfig()`
|
||||
|
||||
### Pinia Store
|
||||
- [ ] Store menggunakan `defineStore()` dengan naming `use[Name]Store`
|
||||
- [ ] State yang perlu persisten menggunakan `pinia-plugin-persistedstate`
|
||||
- [ ] Key persisted state unik per loket/klinik (hindari konflik antar instance)
|
||||
- [ ] `allPatients` tetap single source of truth (tidak duplikasi state)
|
||||
|
||||
### WebSocket
|
||||
- [ ] Client ID deterministic (bukan random) untuk targetable messaging
|
||||
- [ ] Auto-reconnect logic aktif
|
||||
- [ ] Polling fallback 30 detik sebagai safety net
|
||||
- [ ] Event handler tidak melakukan full re-render (surgical update)
|
||||
|
||||
---
|
||||
|
||||
## 6. Standar Kode
|
||||
|
||||
### Naming Convention
|
||||
|
||||
| Tipe | Konvensi | Contoh (Aktual) |
|
||||
|------|----------|-----------------|
|
||||
| Page (Vue) | PascalCase | `Dashboard.vue`, `AdminLoket.vue` |
|
||||
| Component | PascalCase | `PatientCard.vue`, `QueueActionsCard.vue` |
|
||||
| Composable | camelCase + `use` prefix | `useWebSocket.ts`, `useQueue.js` |
|
||||
| Store (Pinia) | camelCase + `Store` suffix | `queueStore.js`, `clinicStore.js` |
|
||||
| Middleware | camelCase | `auth.ts`, `permissions.ts` |
|
||||
| Type/Interface | PascalCase | `User`, `Permission`, `ApiResponse<T>` |
|
||||
| Constants | SCREAMING_SNAKE | `API_BASE_URL`, `WS_API_URL` |
|
||||
| CSS Class | kebab-case | `.patient-card`, `.queue-actions` |
|
||||
| Server API | `[resource].[method].ts` | `permission.get.ts`, `validate-token.post.ts` |
|
||||
| Server Proxy Route | `[...].ts` di folder prefix | `server/routes/visit-api/[...].ts` |
|
||||
|
||||
### File Organization Rules
|
||||
|
||||
```
|
||||
# ✅ Good — composable terpisah untuk concern berbeda
|
||||
composables/
|
||||
useAuth.ts ← Authentication logic
|
||||
useWebSocket.ts ← WebSocket connection management
|
||||
useQueue.js ← Queue business logic
|
||||
useCheckIn.ts ← Check-in flow
|
||||
useThermalPrint.ts ← Thermal printer integration
|
||||
|
||||
# ✅ Good — komponen terorganisir per fitur
|
||||
components/
|
||||
common/ ← Reusable (AppSnackbar, Avatar, PageHeader)
|
||||
layout/ ← Layout (SideBar, ProfileMenu)
|
||||
features/
|
||||
queue/ ← PatientCard, CurrentPatientCard, QueueActionsCard
|
||||
antrean/ ← Komponen anjungan
|
||||
master/ ← Komponen setting master data
|
||||
monitoring/ ← Komponen monitoring pasien
|
||||
|
||||
# ❌ Bad — logic besar langsung di <script setup> page
|
||||
pages/AdminLoket.vue ← Jangan taruh >100 baris logic di sini, extract ke composable
|
||||
```
|
||||
|
||||
### Code Pattern — Composable
|
||||
|
||||
```typescript
|
||||
// ✅ Good — typed composable dengan error handling
|
||||
export function useClinicAPI() {
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
async function fetchClinics(): Promise<Clinic[]> {
|
||||
try {
|
||||
const data = await $fetch('/klinik-api/clinics')
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch clinics:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
return { fetchClinics }
|
||||
}
|
||||
|
||||
// ❌ Bad — untyped, no error handling
|
||||
export function useClinicAPI() {
|
||||
async function fetchClinics() {
|
||||
const data = await $fetch('/klinik-api/clinics') // bisa crash
|
||||
return data
|
||||
}
|
||||
return { fetchClinics }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. CI/CD Quality Gates
|
||||
|
||||
> **Catatan:** CI/CD belum diimplementasi. Berikut rencana pipeline saat siap.
|
||||
|
||||
```yaml
|
||||
# GitHub Actions — quality checks (planned)
|
||||
name: Quality Gate
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Type Check
|
||||
run: npx nuxi typecheck
|
||||
|
||||
- name: Lint
|
||||
run: npx eslint .
|
||||
|
||||
- name: Unit Tests
|
||||
run: npx vitest run --coverage
|
||||
|
||||
- name: Build
|
||||
run: npx nuxt build
|
||||
```
|
||||
|
||||
### Manual Quality Gate (Saat Ini)
|
||||
|
||||
Sebelum merge / deploy, lakukan checklist manual berikut:
|
||||
|
||||
- [ ] `npx nuxi typecheck` — tidak ada error
|
||||
- [ ] `npx eslint .` — tidak ada error
|
||||
- [ ] `npm run test` — semua test pass
|
||||
- [ ] `npm run build` — build sukses tanpa error
|
||||
- [ ] Test manual di browser Chrome di jaringan LAN (`http://10.10.150.175:3000`)
|
||||
- [ ] Verifikasi WebSocket sync antara admin & display screen
|
||||
- [ ] Pastikan Docker build berhasil (`docker compose build`)
|
||||
|
||||
---
|
||||
|
||||
## 8. Bug Severity Matrix
|
||||
|
||||
| Level | Deskripsi | Contoh di Web Antrean | SLA Fix |
|
||||
|-------|-----------|----------------------|---------|
|
||||
| 🔴 Critical | App crash / data loss / security breach | WebSocket down total → antrean tidak sync, Keycloak auth bypass, pasien kehilangan antrean | < 4 jam |
|
||||
| 🟠 High | Fitur utama tidak bisa dipakai | Tombol panggil pasien tidak berfungsi, anjungan tidak bisa generate antrean, QR scanner error | < 1 hari |
|
||||
| 🟡 Medium | Fitur minor terganggu, ada workaround | Thermal print gagal (pasien masih bisa lihat di layar), statistik dashboard delayed | < 3 hari |
|
||||
| 🟢 Low | UI/kosmetik, tidak mengganggu fungsi | Alignment card tidak rapi, warna badge sedikit off, tooltip tidak muncul | Backlog |
|
||||
|
||||
### Known Issues & Mitigasi
|
||||
|
||||
| Issue | Severity | Mitigasi Saat Ini |
|
||||
|-------|----------|-------------------|
|
||||
| WebSocket disconnect saat jaringan LAN tidak stabil | 🔴 | Auto-reconnect + polling fallback 30 detik |
|
||||
| Endpoint 500 menyebabkan request spam | 🟠 | Blacklist endpoint gagal di `doctorStore.js` |
|
||||
| `console.log` verbose di WebSocket handler | 🟢 | Dibersihkan di high-frequency path |
|
||||
| Race condition antar loket | 🟠 | State isolasi per `loketId` + unique storage key |
|
||||
| Mixed JS/TS — beberapa store masih `.js` | 🟢 | Migrasi bertahap ke `.ts` |
|
||||
|
||||
---
|
||||
|
||||
## 9. Technical Debt Tracker
|
||||
|
||||
| Item | Impact | Priority | Plan |
|
||||
|------|--------|----------|------|
|
||||
| Store files masih `.js` (queueStore, clinicStore, dll) | Type safety rendah | 🟡 Medium | Migrasi bertahap ke TypeScript |
|
||||
| `queueStore.js` terlalu besar (141KB) | Sulit maintain & test | 🟠 High | Pecah menjadi sub-stores per concern |
|
||||
| Tidak ada test coverage saat ini | Regresi tidak terdeteksi | 🟠 High | Mulai dari composable kritis |
|
||||
| CI/CD belum ada | Manual quality gate | 🟡 Medium | Setup GitHub Actions |
|
||||
| Beberapa file backup (`.txt`, `old_*.vue`) masih ada | Noise di codebase | 🟢 Low | Cleanup & gitignore |
|
||||
|
||||
---
|
||||
|
||||
## 10. Changelog
|
||||
|
||||
| Versi | Tanggal | Author | Perubahan |
|
||||
|-------|---------|--------|-----------|
|
||||
| 1.0.0 | 2026-05-25 | Akbar | Initial QMD — dibuat berdasarkan kondisi project aktual |
|
||||
@@ -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.
|
||||
@@ -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
@@ -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 = [];
|
||||
}
|
||||
|
||||
@@ -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('/');
|
||||
}
|
||||
});
|
||||
@@ -77,6 +77,9 @@ export default defineNuxtConfig({
|
||||
// External API
|
||||
externalApiBaseUrl: process.env.EXTERNAL_API_BASE_URL || 'http://10.10.123.135:8084',
|
||||
externalApiTimeout: parseInt(process.env.EXTERNAL_API_TIMEOUT || '10000', 10),
|
||||
proxyClientOrigin: process.env.PROXY_CLIENT_ORIGIN || 'http://10.10.150.175:3000',
|
||||
proxyTargetHostFallback: process.env.PROXY_TARGET_HOST_FALLBACK || '10.10.123.135:8084',
|
||||
proxyTargetHostKlinikFallback: process.env.PROXY_TARGET_HOST_KLINIK_FALLBACK || '10.10.123.140:8089',
|
||||
|
||||
public: {
|
||||
authUrl: process.env.AUTH_ORIGIN,
|
||||
@@ -85,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
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
Generated
+11
-1
@@ -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
@@ -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",
|
||||
|
||||
+68
-14
@@ -236,7 +236,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
import { ref, computed, onMounted, onUnmounted } from "vue";
|
||||
import { useQueue } from "@/composables/useQueue";
|
||||
import { useQueueStore } from "@/stores/queueStore";
|
||||
import { useMasterStore } from "@/stores/masterStore";
|
||||
@@ -289,6 +289,16 @@ const currentDate = ref(
|
||||
})
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
queueStore.ensureInitialData();
|
||||
queueStore.initWebSocket('admin-klinik');
|
||||
queueStore.registerGlobalInterest();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
queueStore.unregisterGlobalInterest();
|
||||
});
|
||||
|
||||
const selectedStatus = ref("all");
|
||||
const searchQuery = ref("");
|
||||
const selectedFastTrack = ref(null);
|
||||
@@ -359,34 +369,74 @@ const nextQueueInfo = computed(() => {
|
||||
return null;
|
||||
});
|
||||
|
||||
const handlePatientAction = (action) => {
|
||||
if (currentProcessingPatient.value) {
|
||||
processPatient(currentProcessingPatient.value, action);
|
||||
const broadcastUpdate = async (callData = null) => {
|
||||
try {
|
||||
const displayClientIds = [];
|
||||
displayClientIds.push('admin-klinik'); // Broadcast to other AdminKlinik instances
|
||||
|
||||
// Broadcast to a few likely screen IDs to ensure all Anjungan Klinik screens get the trigger
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
displayClientIds.push(`anjungan-klinik-${i}`);
|
||||
}
|
||||
|
||||
console.log('📡 [AdminKlinik] Broadcasting update trigger to:', displayClientIds);
|
||||
|
||||
const payload = {
|
||||
triggerRefresh: true
|
||||
};
|
||||
|
||||
if (currentProcessingPatient.value && currentProcessingPatient.value.kodeKlinik) {
|
||||
payload.klinikId = currentProcessingPatient.value.kodeKlinik;
|
||||
}
|
||||
|
||||
if (callData) {
|
||||
payload.callEvent = {
|
||||
...callData,
|
||||
calledAt: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
await Promise.all(displayClientIds.map(clientId =>
|
||||
queueStore.sendViaPost({
|
||||
to_client: clientId,
|
||||
data: payload
|
||||
})
|
||||
));
|
||||
} catch (error) {
|
||||
console.error('❌ [AdminKlinik] Error broadcasting update:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCall = (count) => {
|
||||
const handlePatientAction = async (action) => {
|
||||
if (currentProcessingPatient.value) {
|
||||
await processPatient(currentProcessingPatient.value, action);
|
||||
broadcastUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCall = async (count) => {
|
||||
if (count === 1) {
|
||||
callNext();
|
||||
} else {
|
||||
callMultiplePatients(count);
|
||||
}
|
||||
broadcastUpdate();
|
||||
};
|
||||
|
||||
const handleTableAction = (item, action) => {
|
||||
processPatient(item, action);
|
||||
const handleTableAction = async (item, action) => {
|
||||
await processPatient(item, action);
|
||||
broadcastUpdate();
|
||||
};
|
||||
|
||||
const handleProcessNext = () => {
|
||||
processNextQueue();
|
||||
const handleProcessNext = async () => {
|
||||
await processNextQueue();
|
||||
broadcastUpdate();
|
||||
};
|
||||
|
||||
const handleCallPatient = () => {
|
||||
// TODO: Integrate text-to-speech library here
|
||||
// Example: speak(`Nomor antrian ${currentProcessingPatient.value?.noAntrian.split(" |")[0]}, silakan menuju ke loket`)
|
||||
if (currentProcessingPatient.value) {
|
||||
console.log('Calling patient:', currentProcessingPatient.value);
|
||||
// Placeholder for text-to-speech integration
|
||||
broadcastUpdate(JSON.parse(JSON.stringify(currentProcessingPatient.value)));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -399,10 +449,10 @@ const closeKlinikRuangDialog = () => {
|
||||
klinikRuangSearch.value = "";
|
||||
};
|
||||
|
||||
const buatAntreanKlinikRuang = (klinikRuang, ruang) => {
|
||||
const buatAntreanKlinikRuang = async (klinikRuang, ruang) => {
|
||||
if (!currentProcessingPatient.value) return;
|
||||
|
||||
const result = queueStore.createAntreanKlinikRuang(
|
||||
const result = await queueStore.createAntreanKlinikRuang(
|
||||
klinikRuang,
|
||||
ruang,
|
||||
currentProcessingPatient.value,
|
||||
@@ -413,6 +463,10 @@ const buatAntreanKlinikRuang = (klinikRuang, ruang) => {
|
||||
snackbarColor.value = result.success ? "success" : "error";
|
||||
snackbar.value = true;
|
||||
|
||||
if (result.success) {
|
||||
broadcastUpdate();
|
||||
}
|
||||
|
||||
closeKlinikRuangDialog();
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -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 || ''}`"
|
||||
@@ -54,9 +56,6 @@
|
||||
<div class="room-title">
|
||||
<v-icon color="warning-600" class="mr-2">mdi-door</v-icon>
|
||||
<span>{{ ruang.namaRuang }}</span>
|
||||
<v-chip size="small" class="ml-2" color="warning-600">
|
||||
Kamar : R.{{ ruang.nomorRuang }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</v-card-title>
|
||||
|
||||
@@ -788,6 +787,7 @@
|
||||
:color="snackbarColor"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -801,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();
|
||||
@@ -809,6 +810,7 @@ const queueStore = useQueueStore();
|
||||
const masterStore = useMasterStore();
|
||||
const clinicStore = useClinicStore();
|
||||
const ruangStore = useRuangStore();
|
||||
const config = useRuntimeConfig();
|
||||
|
||||
const kodeKlinik = computed(() => {
|
||||
const kode = route.params.kodeKlinik;
|
||||
@@ -931,14 +933,11 @@ const filterOptionsList = {
|
||||
};
|
||||
|
||||
// WebSocket configuration
|
||||
const config = useRuntimeConfig();
|
||||
|
||||
// Generate a unique session suffix (random ID)
|
||||
const uniqueSessionSuffix = ref(process.client ? Math.random().toString(36).substring(2, 8) : '')
|
||||
|
||||
// WebSocket client ID for admin
|
||||
// Use a DETERMINISTIC client ID so that other pages can broadcast directly to this admin.
|
||||
// Format: admin-klinik-ruang-{kodeKlinik} — stable and targetable from any device.
|
||||
const adminClientId = computed(() => {
|
||||
return `admin-klinik-ruang-${kodeKlinik.value}-${uniqueSessionSuffix.value}`
|
||||
return `admin-klinik-ruang-${kodeKlinik.value}`
|
||||
})
|
||||
|
||||
const isConnected = computed(() => queueStore.isWsConnected);
|
||||
@@ -946,10 +945,10 @@ const sendViaPost = (data) => queueStore.sendViaPost(data);
|
||||
|
||||
const fetchAllData = async () => {
|
||||
if (!kodeKlinik.value) return;
|
||||
// console.log('🔄 AdminKlinikRuang refresh: Syncing data...');
|
||||
try {
|
||||
await queueStore.fetchPatientsForClinic(kodeKlinik.value);
|
||||
queueStore.ensureInitialData();
|
||||
queueStore.registerClinicInterest(kodeKlinik.value);
|
||||
// console.log('✅ AdminKlinikRuang refresh: Success');
|
||||
} catch (err) {
|
||||
console.error('❌ AdminKlinikRuang refresh error:', err);
|
||||
@@ -1624,6 +1623,9 @@ const broadcastUpdate = async () => {
|
||||
// Base broadcast ID
|
||||
anjunganClientIds.push(`anjungan-klinik-ruang-${klinikData.value.kodeKlinik}`);
|
||||
|
||||
// Broadcast to other AdminKlinikRuang instances for same clinic
|
||||
anjunganClientIds.push(`admin-klinik-ruang-${klinikData.value.kodeKlinik}`);
|
||||
|
||||
// Screen-specific IDs
|
||||
ruangList.value.forEach(r => {
|
||||
if (r.nomorScreen) {
|
||||
@@ -1716,7 +1718,7 @@ const handleCallPatientByTipe = async (ruang, tipeLayanan) => {
|
||||
visit_code: patient.barcode || patient.visitCode,
|
||||
visit_status_id: [visitStatusId]
|
||||
};
|
||||
const visitApiBase = '/visit-api';
|
||||
const visitApiBase = config.public.externalApiBaseUrl || 'http://10.10.123.135:8084/api/v1';
|
||||
const apiResponse = await fetch(`${visitApiBase}/visit/status/finish`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -1735,6 +1737,7 @@ const handleCallPatientByTipe = async (ruang, tipeLayanan) => {
|
||||
try {
|
||||
const anjunganClientIds = [];
|
||||
anjunganClientIds.push(`anjungan-klinik-ruang-${klinikData.value.kodeKlinik}`);
|
||||
anjunganClientIds.push(`admin-klinik-ruang-${klinikData.value.kodeKlinik}`);
|
||||
|
||||
if (ruang.nomorScreen) {
|
||||
const specificScreenId = `anjungan-klinik-ruang-${klinikData.value.kodeKlinik}-screen-${ruang.nomorScreen}`;
|
||||
@@ -1759,7 +1762,17 @@ const handleCallPatientByTipe = async (ruang, tipeLayanan) => {
|
||||
to_client: clientId,
|
||||
data: {
|
||||
noantrian: nomorAntrian,
|
||||
tipeLayanan: tipeLayanan
|
||||
klinikId: kodeKlinik.value,
|
||||
tipeLayanan: tipeLayanan,
|
||||
triggerRefresh: true,
|
||||
callKlinikEvent: {
|
||||
noantrian: nomorAntrian,
|
||||
barcode: updateData.barcode,
|
||||
kodeKlinik: kodeKlinik.value,
|
||||
tipeLayanan: tipeLayanan,
|
||||
lastCalledAt: updateData.lastCalledAt,
|
||||
nomorRuang: String(ruang.nomorRuang)
|
||||
}
|
||||
},
|
||||
};
|
||||
await sendViaPost(message);
|
||||
@@ -1941,23 +1954,25 @@ onMounted(async () => {
|
||||
await fetchAllData();
|
||||
|
||||
// 3. Centralized WebSocket & interest registration
|
||||
// initWebSocket is already called via the watcher on adminClientId
|
||||
// Explicitly init WS with deterministic ID so other pages can target this admin directly.
|
||||
if (kodeKlinik.value) {
|
||||
queueStore.registerClinicInterest(kodeKlinik.value);
|
||||
queueStore.initWebSocket(adminClientId.value);
|
||||
queueStore.registerClinicInterest(kodeKlinik.value);
|
||||
}
|
||||
});
|
||||
|
||||
// 4. Lifecycle cleanup (Keep outside async block to preserve context)
|
||||
let pollInterval;
|
||||
onMounted(() => {
|
||||
// Check for daily reset and poll data every minute
|
||||
// Safety-net polling: re-fetch every 30 seconds to catch any missed WS events.
|
||||
// This guarantees data is never more than 30 seconds stale.
|
||||
pollInterval = setInterval(() => {
|
||||
const didReset = queueStore.checkAndResetDaily();
|
||||
if (didReset) {
|
||||
console.log("🕒 [AdminKlinikRuang] 2 AM threshold reached. Data reset performed.");
|
||||
}
|
||||
fetchAllData();
|
||||
}, 60000);
|
||||
}, 30000); // 30 seconds
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
|
||||
+205
-61
@@ -37,6 +37,7 @@
|
||||
@call="handleCallPatient"
|
||||
@open-klinik-ruang="openKlinikRuangDialog"
|
||||
@open-penunjang="openPenunjangDialog"
|
||||
@linked="handlePatientLinked"
|
||||
/>
|
||||
|
||||
<QueueActionsCard
|
||||
@@ -304,6 +305,20 @@
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Check-In Confirmation Dialog -->
|
||||
<CheckInConfirmationDialog
|
||||
v-model="showCheckInConfirmDialog"
|
||||
:patient="currentProcessingPatient"
|
||||
@confirm="confirmCheckIn"
|
||||
/>
|
||||
|
||||
<!-- Unfinished Patient Dialog -->
|
||||
<UnfinishedPatientDialog
|
||||
v-model="showUnfinishedPatientDialog"
|
||||
:patient="currentProcessingPatient"
|
||||
@confirm="confirmUnfinishedAndProcess"
|
||||
/>
|
||||
|
||||
<!-- Snackbar -->
|
||||
<AppSnackbar
|
||||
v-model="snackbar"
|
||||
@@ -327,6 +342,8 @@ import PageHeader from "@/components/common/PageHeader.vue";
|
||||
import CurrentPatientCard from "@/components/features/queue/CurrentPatientCard.vue";
|
||||
import QueueActionsCard from "@/components/features/queue/QueueActionsCard.vue";
|
||||
import PatientDataTable from "@/components/features/queue/TabelPatientData.vue";
|
||||
import CheckInConfirmationDialog from "@/components/features/queue/CheckInConfirmationDialog.vue";
|
||||
import UnfinishedPatientDialog from "@/components/features/queue/UnfinishedPatientDialog.vue";
|
||||
import SelectionDialog from "@/components/common/SelectionDialog.vue";
|
||||
import AppSnackbar from "@/components/common/AppSnackbar.vue";
|
||||
import { useThermalPrint } from "@/composables/useThermalPrint";
|
||||
@@ -339,6 +356,13 @@ const loketStore = useLoketStore();
|
||||
const clinicStore = useClinicStore();
|
||||
const ruangStore = useRuangStore();
|
||||
const { printTicketFromPatient } = useThermalPrint();
|
||||
const config = useRuntimeConfig();
|
||||
|
||||
useHead({
|
||||
script: [
|
||||
{ src: "https://code.responsivevoice.org/responsivevoice.js?key=ZeMK8Joo" }
|
||||
]
|
||||
});
|
||||
|
||||
// Broadcast Channel
|
||||
let broadcastChannel = null;
|
||||
@@ -386,18 +410,20 @@ const {
|
||||
const changeKlinik = async (klinik) => {
|
||||
const result = await originalChangeKlinik(klinik);
|
||||
if (result.success) {
|
||||
// Global broadcast to update all displays and other admins
|
||||
broadcastUpdate();
|
||||
// If patient was moved to a different loket, include destination loket in broadcast
|
||||
const broadcastData = result.moved && result.patient?.loketId
|
||||
? { loketId: result.patient.loketId } // Destination loket will get notified
|
||||
: null;
|
||||
broadcastUpdate(broadcastData);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Generate a unique session suffix (random ID)
|
||||
const uniqueSessionSuffix = ref(process.client ? Math.random().toString(36).substring(2, 8) : '')
|
||||
|
||||
// Use a DETERMINISTIC client ID so that Anjungan can broadcast directly to this admin.
|
||||
// Format: admin-loket-{loketId} — stable and targetable from any device.
|
||||
const anjunganClientId = computed(() => {
|
||||
if (!loketId.value) return ''
|
||||
return `admin-loket-${loketId.value}-${uniqueSessionSuffix.value}`
|
||||
return `admin-loket-${loketId.value}`
|
||||
})
|
||||
|
||||
const fetchAllData = async () => {
|
||||
@@ -450,6 +476,9 @@ watch(anjunganClientId, (newClientId, oldClientId) => {
|
||||
})
|
||||
|
||||
// PERSISTENCE FIX: Ensure data exists on mount
|
||||
// Periodic polling interval ref (safety-net fallback)
|
||||
let periodicRefreshInterval = null;
|
||||
|
||||
onMounted(async () => {
|
||||
console.log("🚀 AdminLoket Component mounted");
|
||||
|
||||
@@ -470,6 +499,12 @@ onMounted(async () => {
|
||||
}
|
||||
}, 60000); // Check every minute
|
||||
|
||||
// Safety-net: Poll every 30 seconds to catch patients registered from Anjungan
|
||||
// even if a WebSocket notification was missed or not received.
|
||||
periodicRefreshInterval = setInterval(() => {
|
||||
fetchPatientsForCurrentLoket();
|
||||
}, 30000);
|
||||
|
||||
// Initialize and connect WebSocket (Centralized)
|
||||
queueStore.initWebSocket(anjunganClientId.value);
|
||||
|
||||
@@ -484,6 +519,7 @@ onMounted(async () => {
|
||||
|
||||
onUnmounted(() => {
|
||||
if (resetCheckInterval) clearInterval(resetCheckInterval);
|
||||
if (periodicRefreshInterval) clearInterval(periodicRefreshInterval);
|
||||
// WebSocket is now global, we might not want to disconnect on every unmount
|
||||
// if other pages are still open, but usually for Admin/Anjungan it's okay.
|
||||
// We keep it connected unless specified otherwise.
|
||||
@@ -494,7 +530,7 @@ const apiQuota = ref(null);
|
||||
// Fetch latest quota data specifically for this loket
|
||||
const fetchQuotaFromAPI = async () => {
|
||||
try {
|
||||
const apiBase = '/klinik-api';
|
||||
const apiBase = config.public.verificationApiBaseUrl || 'http://10.10.123.140:8089/api/v1';
|
||||
const response = await fetch(
|
||||
`${apiBase}/klinik/loket`,
|
||||
);
|
||||
@@ -559,10 +595,10 @@ const selectedFastTrack = ref(null);
|
||||
// Dialog Klinik Ruang
|
||||
const showKlinikRuangDialog = ref(false);
|
||||
const klinikRuangSearch = ref("");
|
||||
const activePanel = ref(null); // Tracks the open Klinik Ruang panel
|
||||
|
||||
// Confirmation Replace Dialog
|
||||
const activePanel = ref(null); // Additional Refs
|
||||
const showConfirmReplaceDialog = ref(false);
|
||||
const showCheckInConfirmDialog = ref(false);
|
||||
const showUnfinishedPatientDialog = ref(false);
|
||||
const pendingReplaceItem = ref(null);
|
||||
const pendingReplaceAction = ref(null);
|
||||
|
||||
@@ -674,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
|
||||
@@ -819,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)
|
||||
@@ -875,9 +905,37 @@ const nextQueueInfo = computed(() => {
|
||||
return null;
|
||||
});
|
||||
|
||||
const handlePatientAction = (action) => {
|
||||
const handlePatientAction = async (action) => {
|
||||
if (currentProcessingPatient.value) {
|
||||
processPatient(currentProcessingPatient.value, action);
|
||||
if (action === 'check-in') {
|
||||
// Periksa apakah tiket antrean ruang atau penunjang sudah dibuat
|
||||
let allPatientsArray = [];
|
||||
if (queueStore.allPatients) {
|
||||
allPatientsArray = Array.isArray(queueStore.allPatients)
|
||||
? queueStore.allPatients
|
||||
: (queueStore.allPatients.value || []);
|
||||
}
|
||||
|
||||
const hasGeneratedNextQueue = allPatientsArray.some(p =>
|
||||
(p.sourcePatientNo && p.sourcePatientNo === currentProcessingPatient.value.no) ||
|
||||
(p.referencePatient && p.referencePatient === currentProcessingPatient.value.noAntrian)
|
||||
);
|
||||
|
||||
if (!hasGeneratedNextQueue) {
|
||||
showCheckInConfirmDialog.value = true;
|
||||
return; // Hentikan proses, tunggu konfirmasi user
|
||||
}
|
||||
}
|
||||
|
||||
await processPatient(currentProcessingPatient.value, action);
|
||||
broadcastUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
const confirmCheckIn = async () => {
|
||||
if (currentProcessingPatient.value) {
|
||||
await processPatient(currentProcessingPatient.value, 'check-in');
|
||||
broadcastUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -921,17 +979,51 @@ const handleProcessNext = () => {
|
||||
|
||||
const confirmAndProcess = (item, action) => {
|
||||
if (currentProcessingPatient.value) {
|
||||
let allPatientsArray = [];
|
||||
if (queueStore.allPatients) {
|
||||
allPatientsArray = Array.isArray(queueStore.allPatients)
|
||||
? queueStore.allPatients
|
||||
: (queueStore.allPatients.value || []);
|
||||
}
|
||||
|
||||
const hasGeneratedNextQueue = allPatientsArray.some(p =>
|
||||
(p.sourcePatientNo && p.sourcePatientNo === currentProcessingPatient.value.no) ||
|
||||
(p.referencePatient && p.referencePatient === currentProcessingPatient.value.noAntrian)
|
||||
);
|
||||
|
||||
pendingReplaceItem.value = item;
|
||||
pendingReplaceAction.value = action;
|
||||
showConfirmReplaceDialog.value = true;
|
||||
|
||||
if (hasGeneratedNextQueue) {
|
||||
showUnfinishedPatientDialog.value = true;
|
||||
} else {
|
||||
showConfirmReplaceDialog.value = true;
|
||||
}
|
||||
} else {
|
||||
executeProcess(item, action);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmUnfinishedAndProcess = async () => {
|
||||
if (currentProcessingPatient.value) {
|
||||
// Selesaikan pasien aktif
|
||||
await processPatient(currentProcessingPatient.value, 'check-in');
|
||||
|
||||
// Proses pasien berikutnya / yang dipilih
|
||||
executeProcess(pendingReplaceItem.value, pendingReplaceAction.value);
|
||||
|
||||
pendingReplaceItem.value = null;
|
||||
pendingReplaceAction.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const executeProcess = async (item, action) => {
|
||||
if (action === "next") {
|
||||
processNextQueue();
|
||||
await processNextQueue();
|
||||
// Auto-call setelah memproses antrean selanjutnya
|
||||
setTimeout(() => {
|
||||
handleCallPatient();
|
||||
}, 300);
|
||||
} else {
|
||||
await processPatient(item, action);
|
||||
// If action is process, auto-call the patient after a short delay
|
||||
@@ -955,40 +1047,70 @@ const handleConfirmReplace = () => {
|
||||
|
||||
const { sendViaPost } = queueStore;
|
||||
|
||||
const broadcastUpdate = async (callData = null) => {
|
||||
// Debounce timer to prevent multiple rapid broadcasts
|
||||
let _broadcastDebounceTimer = null;
|
||||
|
||||
const broadcastUpdate = async (extraData = null) => {
|
||||
// extraData can be:
|
||||
// - null: regular refresh (debounced)
|
||||
// - { noAntrian, ... }: a CALL event (immediate, bypass debounce)
|
||||
// - { loketId }: a MOVE event (immediate, bypass debounce, notify destination)
|
||||
|
||||
const isCallEvent = extraData && extraData.noAntrian; // Patient call
|
||||
const isMoveEvent = extraData && extraData.loketId && !extraData.noAntrian; // Klinik move
|
||||
const isImmediate = isCallEvent || isMoveEvent;
|
||||
|
||||
// Debounce only plain refresh events
|
||||
if (!isImmediate) {
|
||||
if (_broadcastDebounceTimer) return; // Already pending, skip
|
||||
_broadcastDebounceTimer = setTimeout(() => {
|
||||
_broadcastDebounceTimer = null;
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
try {
|
||||
const displayClientIds = [];
|
||||
|
||||
// Base broadcast IDs for this loket
|
||||
displayClientIds.push(`anjungan-loket-${loketId.value}`);
|
||||
displayClientIds.push(`anjungan-masuk-${loketId.value}`);
|
||||
// Also broadcast to clinic specific displays if needed
|
||||
// displayClientIds.push(`anjungan-klinik-${...}`);
|
||||
|
||||
console.log('📡 [AdminLoket] Broadcasting update trigger to:', displayClientIds);
|
||||
|
||||
const payload = {
|
||||
loketId: loketId.value,
|
||||
triggerRefresh: true
|
||||
};
|
||||
|
||||
// If this is a CALL event, include the patient data
|
||||
if (callData) {
|
||||
if (isCallEvent) {
|
||||
payload.callEvent = {
|
||||
...callData,
|
||||
...extraData,
|
||||
loketId: loketId.value,
|
||||
loketName: loketName.value,
|
||||
calledAt: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
// Send all broadcasts in parallel for speed
|
||||
await Promise.all(displayClientIds.map(clientId =>
|
||||
sendViaPost({
|
||||
to_client: clientId,
|
||||
data: payload
|
||||
})
|
||||
));
|
||||
// Minimal broadcast: only send to the most relevant clients
|
||||
const criticalTargets = [
|
||||
`anjungan-loket-${loketId.value}`,
|
||||
`anjungan-masuk-${loketId.value}`,
|
||||
`admin-loket-${loketId.value}`,
|
||||
];
|
||||
|
||||
// For MOVE events, also notify the destination loket
|
||||
if (isMoveEvent && extraData.loketId && String(extraData.loketId) !== String(loketId.value)) {
|
||||
const destLoketId = extraData.loketId;
|
||||
criticalTargets.push(`admin-loket-${destLoketId}`);
|
||||
criticalTargets.push(`anjungan-loket-${destLoketId}`);
|
||||
criticalTargets.push(`anjungan-masuk-${destLoketId}`);
|
||||
console.log(`📡 [AdminLoket] Patient moved: also notifying Loket ${destLoketId}`);
|
||||
}
|
||||
|
||||
console.log('📡 [AdminLoket] Broadcasting to:', criticalTargets);
|
||||
|
||||
// Send sequentially with a delay to avoid 429 Rate Limit
|
||||
for (const clientId of criticalTargets) {
|
||||
try {
|
||||
await sendViaPost({ to_client: clientId, data: payload });
|
||||
} catch (err) {
|
||||
console.warn(`Failed to broadcast to ${clientId}:`, err?.message || err);
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 80));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ [AdminLoket] Error broadcasting update:', error);
|
||||
}
|
||||
@@ -1051,6 +1173,12 @@ onMounted(() => {
|
||||
setTimeout(() => {
|
||||
queueStore.ensureInitialData();
|
||||
}, 200);
|
||||
|
||||
// Initialize centralized WebSocket and register interest
|
||||
if (loketId.value) {
|
||||
queueStore.initWebSocket(anjunganClientId.value);
|
||||
queueStore.registerInterest(loketId.value);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -1058,6 +1186,10 @@ onUnmounted(() => {
|
||||
if (broadcastChannel) {
|
||||
broadcastChannel.close();
|
||||
}
|
||||
|
||||
if (loketId.value) {
|
||||
queueStore.unregisterInterest(loketId.value);
|
||||
}
|
||||
});
|
||||
|
||||
const closeKlinikRuangDialog = () => {
|
||||
@@ -1205,7 +1337,7 @@ const buatAntreanKlinikRuang = async (klinikRuang, ruang) => {
|
||||
|
||||
console.log("📤 Sending visit ticket to API:", visitTicketBody);
|
||||
|
||||
const visitApiBase = '/visit-api';
|
||||
const visitApiBase = config.public.externalApiBaseUrl || 'http://10.10.123.135:8084/api/v1';
|
||||
const visitResponse = await fetch(
|
||||
`${visitApiBase}/visit/ticket/klinik`,
|
||||
{
|
||||
@@ -1320,8 +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">
|
||||
|
||||
@@ -699,7 +699,11 @@
|
||||
</v-snackbar>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!anjunganData" class="not-found">
|
||||
<div v-else-if="anjunganStore.isLoading" class="not-found">
|
||||
<v-progress-circular indeterminate color="primary" size="64"></v-progress-circular>
|
||||
<p class="mt-4">Memuat Konfigurasi Anjungan...</p>
|
||||
</div>
|
||||
<div v-else class="not-found">
|
||||
<v-icon size="64" color="grey">mdi-alert-circle-outline</v-icon>
|
||||
<p>Anjungan tidak ditemukan</p>
|
||||
<v-btn color="primary" variant="flat" @click="backToList">Kembali</v-btn>
|
||||
@@ -791,6 +795,10 @@ onMounted(async () => {
|
||||
// Wait for stores to be hydrated and ready
|
||||
await nextTick();
|
||||
|
||||
if (anjunganItems.value.length === 0) {
|
||||
await anjunganStore.fetchAnjungan();
|
||||
}
|
||||
|
||||
// Initial fetch
|
||||
await fetchAllData();
|
||||
|
||||
@@ -1280,6 +1288,26 @@ const registerPatient = async (
|
||||
lastRegisteredPatient.value,
|
||||
);
|
||||
|
||||
// REAL-TIME SYNC: Notify AdminLoket to refresh its patient list immediately.
|
||||
// AdminLoket listens on the deterministic client ID "admin-loket-{loketId}".
|
||||
// Without this, AdminLoket only updates on manual refresh or 30s polling.
|
||||
if (result.patient.loketId && queueStore.sendViaPost) {
|
||||
const targetLoketId = result.patient.loketId;
|
||||
console.log(`📡 [Anjungan] Notifying AdminLoket ${targetLoketId} of new patient registration...`);
|
||||
queueStore.sendViaPost({
|
||||
to_client: `admin-loket-${targetLoketId}`,
|
||||
data: {
|
||||
loketId: targetLoketId,
|
||||
triggerRefresh: true,
|
||||
source: 'anjungan-registration',
|
||||
ticket: result.patient.noAntrian?.split(' |')[0] || '',
|
||||
}
|
||||
}).catch(err => {
|
||||
// Non-critical: polling will catch this within 30s anyway
|
||||
console.warn('⚠️ [Anjungan] Could not notify AdminLoket via WS:', err?.message);
|
||||
});
|
||||
}
|
||||
|
||||
// Tampilkan dialog print - gunakan nextTick untuk memastikan reactive update
|
||||
await nextTick();
|
||||
showPrintDialog.value = true;
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { useAnjunganStore } from '@/stores/anjunganStore';
|
||||
import { useMasterStore } from '@/stores/masterStore';
|
||||
import { useRoute } from '#app';
|
||||
@@ -111,6 +111,10 @@ const anjunganStore = useAnjunganStore();
|
||||
const masterStore = useMasterStore();
|
||||
const route = useRoute();
|
||||
|
||||
onMounted(() => {
|
||||
anjunganStore.fetchAnjungan();
|
||||
});
|
||||
|
||||
// Safeguard supaya tidak undefined saat store belum terisi
|
||||
const anjunganList = computed(() => {
|
||||
const fromGetter = anjunganStore.getAllAnjungan?.value;
|
||||
|
||||
@@ -746,7 +746,11 @@
|
||||
</v-snackbar>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!anjunganData" class="not-found">
|
||||
<div v-else-if="anjunganStore.isLoading" class="not-found">
|
||||
<v-progress-circular indeterminate color="primary" size="64"></v-progress-circular>
|
||||
<p class="mt-4">Memuat Konfigurasi Anjungan...</p>
|
||||
</div>
|
||||
<div v-else class="not-found">
|
||||
<v-icon size="64" color="grey">mdi-alert-circle-outline</v-icon>
|
||||
<p>Anjungan tidak ditemukan</p>
|
||||
<v-btn color="primary" variant="flat" @click="backToList">Kembali</v-btn>
|
||||
@@ -842,6 +846,10 @@ onMounted(async () => {
|
||||
// Wait for stores to be hydrated and ready
|
||||
await nextTick();
|
||||
|
||||
if (anjunganItems.value.length === 0) {
|
||||
await anjunganStore.fetchAnjungan();
|
||||
}
|
||||
|
||||
// Initial fetch
|
||||
await fetchAllData();
|
||||
|
||||
@@ -1383,6 +1391,25 @@ const registerPatient = async (
|
||||
lastRegisteredPatient.value,
|
||||
);
|
||||
|
||||
// REAL-TIME SYNC: Notify AdminLoket to refresh its patient list immediately.
|
||||
// AdminLoket listens on the deterministic client ID "admin-loket-{loketId}".
|
||||
if (result.patient.loketId && queueStore.sendViaPost) {
|
||||
const targetLoketId = result.patient.loketId;
|
||||
console.log(`📡 [AnjunganCopy] Notifying AdminLoket ${targetLoketId} of new patient registration...`);
|
||||
queueStore.sendViaPost({
|
||||
to_client: `admin-loket-${targetLoketId}`,
|
||||
data: {
|
||||
loketId: targetLoketId,
|
||||
triggerRefresh: true,
|
||||
source: 'anjungan-registration',
|
||||
ticket: result.patient.noAntrian?.split(' |')[0] || '',
|
||||
}
|
||||
}).catch(err => {
|
||||
// Non-critical: polling will catch this within 30s anyway
|
||||
console.warn('⚠️ [AnjunganCopy] Could not notify AdminLoket via WS:', err?.message);
|
||||
});
|
||||
}
|
||||
|
||||
// Tampilkan dialog print - gunakan nextTick untuk memastikan reactive update
|
||||
await nextTick();
|
||||
showPrintDialog.value = true;
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { useAnjunganStore } from '@/stores/anjunganStore';
|
||||
import { useMasterStore } from '@/stores/masterStore';
|
||||
import { useRoute } from '#app';
|
||||
@@ -111,6 +111,10 @@ const anjunganStore = useAnjunganStore();
|
||||
const masterStore = useMasterStore();
|
||||
const route = useRoute();
|
||||
|
||||
onMounted(() => {
|
||||
anjunganStore.fetchAnjungan();
|
||||
});
|
||||
|
||||
// Safeguard supaya tidak undefined saat store belum terisi
|
||||
const anjunganList = computed(() => {
|
||||
const fromGetter = anjunganStore.getAllAnjungan?.value;
|
||||
|
||||
@@ -515,7 +515,7 @@ const initWebSocketLocal = () => {
|
||||
wsInstance = useWebSocket({
|
||||
url: wsBaseUrl,
|
||||
clientId: anjunganClientId.value,
|
||||
fallbackPostUrl: '/stats-api/ws',
|
||||
fallbackPostUrl: `${config.public.externalApiBaseUrl}/ws`,
|
||||
onMessage: (data) => {
|
||||
console.log('📨 WebSocket message received:', data)
|
||||
fetchAllData()
|
||||
@@ -600,12 +600,16 @@ onMounted(() => {
|
||||
// Initialize and connect WebSocket (Centralized)
|
||||
queueStore.initWebSocket(anjunganClientId.value);
|
||||
|
||||
// Register global interest to receive staggered bulk refreshes on generic WS messages
|
||||
queueStore.registerGlobalInterest();
|
||||
// Register specific interest for each configured loket to receive immediate WS updates
|
||||
if (configuredLoketIds.value && configuredLoketIds.value.length > 0) {
|
||||
configuredLoketIds.value.forEach(id => queueStore.registerInterest(id));
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
// Unregister global interest when leaving the page
|
||||
queueStore.unregisterGlobalInterest();
|
||||
// Unregister specific interest when leaving the page
|
||||
if (configuredLoketIds.value && configuredLoketIds.value.length > 0) {
|
||||
configuredLoketIds.value.forEach(id => queueStore.unregisterInterest(id));
|
||||
}
|
||||
});
|
||||
|
||||
updateTime();
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { useAntreanMasukScreenStore } from '@/stores/antreanMasukScreenStore';
|
||||
import { useLoketStore } from '@/stores/loketStore';
|
||||
import { useRoute } from '#app';
|
||||
@@ -111,6 +111,10 @@ const antreanMasukScreenStore = useAntreanMasukScreenStore();
|
||||
const loketStore = useLoketStore();
|
||||
const route = useRoute();
|
||||
|
||||
onMounted(() => {
|
||||
antreanMasukScreenStore.fetchAntreanMasukScreens();
|
||||
});
|
||||
|
||||
// Helper to get loket name by ID
|
||||
const getLoketNameById = (loketId) => {
|
||||
const loket = loketStore.getLoketById(loketId);
|
||||
@@ -145,7 +149,7 @@ const goNext = () => {
|
||||
};
|
||||
|
||||
const navigateToScreen = (screenId) => {
|
||||
navigateTo(`/anjungan/antreanmasuk/${screenId}`);
|
||||
navigateTo(`/Anjungan/AntreanMasuk/${screenId}`);
|
||||
};
|
||||
|
||||
const navigateToSettings = () => {
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { useScreenStore } from '@/stores/screenStore';
|
||||
import { useMasterStore } from '@/stores/masterStore';
|
||||
import { useRoute } from '#app';
|
||||
@@ -104,6 +104,10 @@ const screenStore = useScreenStore();
|
||||
const masterStore = useMasterStore();
|
||||
const route = useRoute();
|
||||
|
||||
onMounted(() => {
|
||||
screenStore.fetchScreens();
|
||||
});
|
||||
|
||||
// Safeguard supaya tidak undefined saat store belum terisi
|
||||
const screens = computed(() => {
|
||||
const fromGetter = screenStore.getAllScreens?.value;
|
||||
|
||||
@@ -160,6 +160,11 @@ useHead({
|
||||
name: 'viewport',
|
||||
content: 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover'
|
||||
}
|
||||
],
|
||||
script: [
|
||||
{
|
||||
src: 'https://code.responsivevoice.org/responsivevoice.js?key=ZeMK8Joo'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
@@ -204,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
|
||||
)
|
||||
@@ -230,6 +241,43 @@ const currentDate = ref('')
|
||||
let timeInterval = null
|
||||
const broadcastedKlinikPatient = ref(null)
|
||||
|
||||
let lastPlayedTime = 0;
|
||||
let lastPlayedNo = '';
|
||||
|
||||
const playCallVoice = (patient, tipeLayanan = null, customRoomName = null) => {
|
||||
if (!patient || (!patient.noantrian && !patient.noAntrian)) return;
|
||||
|
||||
const now = Date.now();
|
||||
const noAntrianFull = patient.noantrian || patient.noAntrian;
|
||||
|
||||
// Cegah double-play berbarengan dalam waktu 2 detik
|
||||
if (noAntrianFull === lastPlayedNo && (now - lastPlayedTime < 2000)) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastPlayedNo = noAntrianFull;
|
||||
lastPlayedTime = now;
|
||||
|
||||
if (window.responsiveVoice) {
|
||||
const noAntrianRaw = noAntrianFull.split(" |")[0];
|
||||
const formattedNo = noAntrianRaw.split('').join(' ');
|
||||
|
||||
// For clinic, destination should include Ruang and Tipe Layanan
|
||||
// Contoh: "Ruang 1, untuk Pemeriksaan Awal"
|
||||
const ruang = customRoomName || patient.ruang || patient.namaRuang || (patient.nomorRuang ? `Ruang ${patient.nomorRuang}` : 'Klinik');
|
||||
const layananText = tipeLayanan ? `untuk ${tipeLayanan}` : '';
|
||||
const destination = `${ruang}, ${layananText}`;
|
||||
|
||||
const textToSpeak = `Nomor antrean, ${formattedNo}, silahkan menuju ke, ${destination}`;
|
||||
|
||||
if (window.responsiveVoice.isPlaying()) {
|
||||
window.responsiveVoice.cancel();
|
||||
}
|
||||
|
||||
window.responsiveVoice.speak(textToSpeak, "Indonesian Female", { pitch: 1, rate: 0.85, volume: 1 });
|
||||
}
|
||||
};
|
||||
|
||||
// Watch for Klinik Calls (Cross-Device Sync) from queueStore
|
||||
watch(() => queueStore.lastKlinikCall, (newCall) => {
|
||||
if (!newCall || !newCall.noantrian) return;
|
||||
@@ -238,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,
|
||||
@@ -274,6 +330,9 @@ watch(() => queueStore.lastKlinikCall, (newCall) => {
|
||||
ruang: existingPatient?.ruang || existingPatient?.namaRuang || `Ruang ${newCall.nomorRuang || '1'}`,
|
||||
_source: 'websocket'
|
||||
};
|
||||
|
||||
// Mainkan suara panggilan
|
||||
playCallVoice(broadcastedKlinikPatient.value, newCall.tipeLayanan, broadcastedKlinikPatient.value.ruang);
|
||||
} else {
|
||||
console.log(`⏭️ [Anjungan] Skipping call for clinic ${callKode} (this screen is for ${myKode})`);
|
||||
}
|
||||
@@ -287,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
|
||||
@@ -319,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
|
||||
)
|
||||
@@ -591,7 +659,7 @@ const initWebSocketLocal = () => {
|
||||
wsInstance = useWebSocket({
|
||||
url: wsBaseUrl,
|
||||
clientId: anjunganClientId.value,
|
||||
fallbackPostUrl: '/stats-api/ws',
|
||||
fallbackPostUrl: `${config.public.externalApiBaseUrl}/ws`,
|
||||
onMessage: (data) => {
|
||||
console.log('📨 WebSocket raw message received:', data)
|
||||
let messageData = data
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
|
||||
@@ -209,6 +209,9 @@ useHead({
|
||||
name: 'viewport',
|
||||
content: 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover'
|
||||
}
|
||||
],
|
||||
script: [
|
||||
{ src: 'https://code.responsivevoice.org/responsivevoice.js?key=ZeMK8Joo' }
|
||||
]
|
||||
})
|
||||
|
||||
@@ -229,6 +232,45 @@ let pollInterval = null
|
||||
let broadcastChannel = null
|
||||
const broadcastedPatient = ref(null)
|
||||
|
||||
let lastPlayedTime = 0;
|
||||
let lastPlayedNo = '';
|
||||
|
||||
const playCallVoice = (patient, customLoketName = null) => {
|
||||
console.log("🔊 playCallVoice called for:", patient?.noAntrian);
|
||||
if (!patient || !patient.noAntrian) return;
|
||||
|
||||
const now = Date.now();
|
||||
// Cegah double-play berbarengan (dari WS dan BroadcastChannel) dalam waktu 2 detik
|
||||
if (patient.noAntrian === lastPlayedNo && (now - lastPlayedTime < 2000)) {
|
||||
console.log("🔊 Skipped double play (debounce)");
|
||||
return;
|
||||
}
|
||||
|
||||
lastPlayedNo = patient.noAntrian;
|
||||
lastPlayedTime = now;
|
||||
|
||||
console.log("🔊 responsiveVoice object exists?", !!window.responsiveVoice);
|
||||
if (window.responsiveVoice) {
|
||||
const noAntrianRaw = patient.noAntrian.split(" |")[0];
|
||||
const formattedNo = noAntrianRaw.split('').join(' ');
|
||||
const destination = customLoketName || patient.loketName || loketData.value?.namaLoket || 'Loket';
|
||||
const textToSpeak = `Nomor antrean, ${formattedNo}, silahkan menuju ke, ${destination}`;
|
||||
|
||||
console.log("🔊 Speaking:", textToSpeak);
|
||||
try {
|
||||
if (window.responsiveVoice.isPlaying()) {
|
||||
window.responsiveVoice.cancel();
|
||||
}
|
||||
|
||||
window.responsiveVoice.speak(textToSpeak, "Indonesian Female", { pitch: 1, rate: 0.85, volume: 1 });
|
||||
} catch (e) {
|
||||
console.error("🔊 ResponsiveVoice Error:", e);
|
||||
}
|
||||
} else {
|
||||
console.error("🔊 ResponsiveVoice is NOT loaded yet!");
|
||||
}
|
||||
};
|
||||
|
||||
// Get loket ID from route
|
||||
const loketId = computed(() => {
|
||||
const id = route.params.id
|
||||
@@ -685,16 +727,26 @@ const isInTTSWindow = (queue) => {
|
||||
// Nomor antrian menjadi "dipanggil" jika diproses pada AdminLoket DAN sudah dipanggil oleh admin
|
||||
const currentCalledQueue = computed(() => {
|
||||
const targetLoketId = String(loketId.value)
|
||||
const CALL_DISPLAY_DURATION = 60000 // 60 seconds
|
||||
const now = new Date()
|
||||
|
||||
// Force reactivity to time passing
|
||||
const _ = currentTime.value
|
||||
|
||||
// Prioritas 0: Broadcasted patient (Real-time from BroadcastChannel)
|
||||
// Check if the broadcast came from this loket
|
||||
if (broadcastedPatient.value) {
|
||||
const rawMsg = broadcastedPatient.value._rawMessage
|
||||
const msgLoketId = rawMsg?.loketId ? String(rawMsg.loketId) : null
|
||||
|
||||
// Only show if it matches this anjungan's loket ID
|
||||
if (msgLoketId === targetLoketId) {
|
||||
return broadcastedPatient.value
|
||||
const callTime = new Date(broadcastedPatient.value.lastCalledAt || now)
|
||||
const timeDiff = now.getTime() - callTime.getTime()
|
||||
|
||||
if (timeDiff <= CALL_DISPLAY_DURATION) {
|
||||
return broadcastedPatient.value
|
||||
} else {
|
||||
broadcastedPatient.value = null // Clear if expired
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -705,10 +757,15 @@ const currentCalledQueue = computed(() => {
|
||||
const patientLoketId = processingPatient.loketId ? String(processingPatient.loketId) : "1"
|
||||
|
||||
if (patientLoketId === targetLoketId && processingPatient.calledByAdmin && processingPatient.noAntrian && processingPatient.status === 'di-loket') {
|
||||
const klinikName = getKlinikNameFromPatient(processingPatient)
|
||||
return {
|
||||
...processingPatient,
|
||||
klinik: klinikName || processingPatient.klinik || 'Klinik'
|
||||
const callTime = processingPatient.lastCalledAt ? new Date(processingPatient.lastCalledAt) : now
|
||||
const timeDiff = now.getTime() - callTime.getTime()
|
||||
|
||||
if (timeDiff <= CALL_DISPLAY_DURATION) {
|
||||
const klinikName = getKlinikNameFromPatient(processingPatient)
|
||||
return {
|
||||
...processingPatient,
|
||||
klinik: klinikName || processingPatient.klinik || 'Klinik'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -717,10 +774,15 @@ const currentCalledQueue = computed(() => {
|
||||
const allPatientsList = filteredPatientsForLoket.value
|
||||
const dbActive = allPatientsList.find(p => p.idvisit === 8 || p.idvisit === 7)
|
||||
if (dbActive) {
|
||||
const klinikName = getKlinikNameFromPatient(dbActive)
|
||||
return {
|
||||
...dbActive,
|
||||
klinik: klinikName || dbActive.klinik || 'Klinik'
|
||||
const callTime = dbActive.lastCalledAt ? new Date(dbActive.lastCalledAt) : now
|
||||
const timeDiff = now.getTime() - callTime.getTime()
|
||||
|
||||
if (timeDiff <= CALL_DISPLAY_DURATION) {
|
||||
const klinikName = getKlinikNameFromPatient(dbActive)
|
||||
return {
|
||||
...dbActive,
|
||||
klinik: klinikName || dbActive.klinik || 'Klinik'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -914,6 +976,9 @@ watch(() => queueStore.lastGlobalCall, (newCall) => {
|
||||
...newCall,
|
||||
_source: 'websocket'
|
||||
};
|
||||
|
||||
// Mainkan suara panggilan
|
||||
playCallVoice(newCall, newCall.loketName);
|
||||
|
||||
// Sync with store using isolated key
|
||||
if (queueStore.currentProcessingPatient) {
|
||||
@@ -941,7 +1006,7 @@ const initWebSocketLocal = () => {
|
||||
wsInstance = useWebSocket({
|
||||
url: wsBaseUrl,
|
||||
clientId: anjunganClientId.value,
|
||||
fallbackPostUrl: '/stats-api/ws',
|
||||
fallbackPostUrl: `${config.public.externalApiBaseUrl}/ws`,
|
||||
onMessage: (data) => {
|
||||
console.log('📨 WebSocket message received:', data)
|
||||
const messageData = data?.data || data
|
||||
@@ -1016,6 +1081,9 @@ onMounted(async () => {
|
||||
...patient, // Spread patient data
|
||||
_rawMessage: event.data // Attach metadata for filtering
|
||||
};
|
||||
|
||||
// Mainkan suara panggilan
|
||||
playCallVoice(patient, event.data.loketName);
|
||||
|
||||
// Sync with store using isolated key
|
||||
if (queueStore.currentProcessingPatient) {
|
||||
|
||||
@@ -1669,6 +1669,7 @@
|
||||
import { ref, computed, nextTick, onMounted, onUnmounted, watch } from "vue";
|
||||
import { useQueueStore } from "@/stores/queueStore";
|
||||
import { useMasterStore } from "@/stores/masterStore";
|
||||
import { useLoketStore } from "@/stores/loketStore";
|
||||
import { useThermalPrint } from "@/composables/useThermalPrint";
|
||||
import PageHeader from "@/components/common/PageHeader.vue";
|
||||
|
||||
@@ -1679,6 +1680,7 @@ definePageMeta({
|
||||
|
||||
const queueStore = useQueueStore();
|
||||
const masterStore = useMasterStore();
|
||||
const loketStore = useLoketStore();
|
||||
const { printTicketFromPatient, isPrinting } = useThermalPrint();
|
||||
const config = useRuntimeConfig();
|
||||
const wsBaseUrl =
|
||||
@@ -1802,6 +1804,7 @@ const checkInClientId = computed(() => {
|
||||
const fetchAllData = async () => {
|
||||
console.log('🔄 CheckIn refresh: Syncing data...');
|
||||
try {
|
||||
await loketStore.fetchLoketFromAPI();
|
||||
await queueStore.fetchAllPatients();
|
||||
queueStore.ensureInitialData();
|
||||
checkAndResetDaily();
|
||||
@@ -1811,6 +1814,33 @@ const fetchAllData = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const broadcastCheckIn = async (loketId) => {
|
||||
if (!loketId) return;
|
||||
try {
|
||||
const displayClientIds = [
|
||||
`anjungan-loket-${loketId}`,
|
||||
`anjungan-masuk-${loketId}`
|
||||
];
|
||||
|
||||
const payload = {
|
||||
loketId: String(loketId),
|
||||
triggerRefresh: true
|
||||
};
|
||||
|
||||
if (queueStore.sendViaPost) {
|
||||
console.log('📡 [CheckIn] Broadcasting check-in update to:', displayClientIds);
|
||||
await Promise.all(displayClientIds.map(clientId =>
|
||||
queueStore.sendViaPost({
|
||||
to_client: clientId,
|
||||
data: payload
|
||||
})
|
||||
));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ [CheckIn] Error broadcasting update:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const isConnected = computed(() => queueStore.isWsConnected);
|
||||
|
||||
// Watch for clientId changes and reconnect if needed
|
||||
@@ -3249,6 +3279,9 @@ const onDetect = async (decodedText: string) => {
|
||||
"success",
|
||||
"mdi-check-circle",
|
||||
);
|
||||
|
||||
// Broadcast WebSocket notification to instantly update loket and antrean masuk screens
|
||||
broadcastCheckIn(checkInResult.patient.loketId);
|
||||
} else {
|
||||
// Check-in gagal (misalnya validasi di checkInPatient gagal)
|
||||
saveToHistory({
|
||||
@@ -3646,6 +3679,9 @@ const checkInManual = async () => {
|
||||
if (manualForm.value) {
|
||||
(manualForm.value as any).reset();
|
||||
}
|
||||
|
||||
// Broadcast WebSocket notification to instantly update loket and antrean masuk screens
|
||||
broadcastCheckIn(checkInResult.patient.loketId);
|
||||
} else {
|
||||
// Check-in gagal (misalnya validasi di checkInPatient gagal)
|
||||
saveToHistory({
|
||||
|
||||
+106
-37
@@ -240,11 +240,18 @@
|
||||
<div class="chart-container">
|
||||
<ClientOnly>
|
||||
<Line
|
||||
v-if="visitTrendData.labels && visitTrendData.labels.length > 0"
|
||||
v-if="visitTrendData.labels && visitTrendData.labels.length > 0 && visitTrendData.datasets[0].data.length > 0 && visitTrendData.datasets[0].data.some(v => v > 0)"
|
||||
:data="visitTrendData"
|
||||
:options="areaChartOptions"
|
||||
class="chart-wrapper"
|
||||
/>
|
||||
<div v-else class="empty-chart-state">
|
||||
<div class="empty-state-content">
|
||||
<v-icon size="64" color="grey-lighten-2" class="mb-2">mdi-chart-timeline-variant</v-icon>
|
||||
<h4 class="text-h6 text-grey-darken-1 font-weight-medium mb-1">Belum Ada Data</h4>
|
||||
<p class="text-caption text-grey">Tren kunjungan bulanan akan tampil di sini</p>
|
||||
</div>
|
||||
</div>
|
||||
<template #fallback>
|
||||
<div class="chart-loading">
|
||||
<v-progress-circular indeterminate color="primary" size="50"></v-progress-circular>
|
||||
@@ -285,11 +292,18 @@
|
||||
<div class="chart-container">
|
||||
<ClientOnly>
|
||||
<Doughnut
|
||||
v-if="paymentStatusData.labels && paymentStatusData.labels.length > 0"
|
||||
v-if="paymentStatusData.labels && paymentStatusData.labels.length > 0 && paymentStatusData.datasets[0].data.length > 0 && paymentStatusData.datasets[0].data.some(v => v > 0)"
|
||||
:data="paymentStatusData"
|
||||
:options="doughnutOptions"
|
||||
class="chart-wrapper pie-chart"
|
||||
/>
|
||||
<div v-else class="empty-chart-state">
|
||||
<div class="empty-state-content">
|
||||
<v-icon size="64" color="grey-lighten-2" class="mb-2">mdi-chart-donut-variant</v-icon>
|
||||
<h4 class="text-h6 text-grey-darken-1 font-weight-medium mb-1">Belum Ada Data</h4>
|
||||
<p class="text-caption text-grey">Distribusi metode bayar akan tampil di sini</p>
|
||||
</div>
|
||||
</div>
|
||||
<template #fallback>
|
||||
<div class="chart-loading">
|
||||
<v-progress-circular indeterminate color="success" size="50"></v-progress-circular>
|
||||
@@ -330,11 +344,18 @@
|
||||
<div class="chart-container">
|
||||
<ClientOnly>
|
||||
<Bar
|
||||
v-if="waitingTimeData.labels && waitingTimeData.labels.length > 0"
|
||||
v-if="waitingTimeData.labels && waitingTimeData.labels.length > 0 && waitingTimeData.datasets[0].data.length > 0 && waitingTimeData.datasets[0].data.some(v => v > 0)"
|
||||
:data="waitingTimeData"
|
||||
:options="horizontalBarOptions"
|
||||
class="chart-wrapper"
|
||||
/>
|
||||
<div v-else class="empty-chart-state">
|
||||
<div class="empty-state-content">
|
||||
<v-icon size="64" color="grey-lighten-2" class="mb-2">mdi-clock-outline</v-icon>
|
||||
<h4 class="text-h6 text-grey-darken-1 font-weight-medium mb-1">Belum Ada Data</h4>
|
||||
<p class="text-caption text-grey">Data waktu tunggu poli akan tampil di sini</p>
|
||||
</div>
|
||||
</div>
|
||||
<template #fallback>
|
||||
<div class="chart-loading">
|
||||
<v-progress-circular indeterminate color="secondary" size="50"></v-progress-circular>
|
||||
@@ -379,11 +400,18 @@
|
||||
<div class="chart-container">
|
||||
<ClientOnly>
|
||||
<PolarArea
|
||||
v-if="attendanceData.labels && attendanceData.labels.length > 0"
|
||||
v-if="attendanceData.labels && attendanceData.labels.length > 0 && attendanceData.datasets[0].data.length > 0 && attendanceData.datasets[0].data.some(v => v > 0)"
|
||||
:data="attendanceData"
|
||||
:options="polarAreaOptions"
|
||||
class="chart-wrapper pie-chart"
|
||||
/>
|
||||
<div v-else class="empty-chart-state">
|
||||
<div class="empty-state-content">
|
||||
<v-icon size="64" color="grey-lighten-2" class="mb-2">mdi-account-check</v-icon>
|
||||
<h4 class="text-h6 text-grey-darken-1 font-weight-medium mb-1">Belum Ada Data</h4>
|
||||
<p class="text-caption text-grey">Statistik tingkat kehadiran akan tampil di sini</p>
|
||||
</div>
|
||||
</div>
|
||||
<template #fallback>
|
||||
<div class="chart-loading">
|
||||
<v-progress-circular indeterminate color="primary" size="50"></v-progress-circular>
|
||||
@@ -499,54 +527,55 @@ const exportOptions = ref([
|
||||
const currentDate = ref('');
|
||||
const filterDateFrom = ref(dayjs().format('YYYY-MM-DD'));
|
||||
const filterDateTo = ref(dayjs().format('YYYY-MM-DD'));
|
||||
const selectedYear = ref(2025);
|
||||
const availableYears = ref([2025, 2026, 2027]);
|
||||
const currentYear = dayjs().year();
|
||||
const selectedYear = ref(currentYear);
|
||||
const availableYears = ref([currentYear - 1, currentYear, currentYear + 1]);
|
||||
|
||||
// New Stats Data - Updated Metrics
|
||||
const stats = ref([
|
||||
{
|
||||
label: 'Pasien Hari Ini',
|
||||
value: '324',
|
||||
value: '-',
|
||||
icon: 'mdi-account-heart',
|
||||
iconSize: 24,
|
||||
iconColor: 'white',
|
||||
color: 'primary',
|
||||
change: '+18.2%',
|
||||
changeType: 'positive',
|
||||
changeIcon: 'mdi-trending-up'
|
||||
change: '-',
|
||||
changeType: 'neutral',
|
||||
changeIcon: 'mdi-minus'
|
||||
},
|
||||
{
|
||||
label: 'Antrean Aktif',
|
||||
value: '47',
|
||||
value: '-',
|
||||
icon: 'mdi-clock-fast',
|
||||
iconSize: 24,
|
||||
iconColor: 'white',
|
||||
color: 'secondary',
|
||||
change: '+5',
|
||||
changeType: 'positive',
|
||||
changeIcon: 'mdi-arrow-up'
|
||||
change: '-',
|
||||
changeType: 'neutral',
|
||||
changeIcon: 'mdi-minus'
|
||||
},
|
||||
{
|
||||
label: 'Rata-rata Tunggu',
|
||||
value: '12 min',
|
||||
value: '-',
|
||||
icon: 'mdi-timer-outline',
|
||||
iconSize: 24,
|
||||
iconColor: 'white',
|
||||
color: 'success',
|
||||
change: '-3 min',
|
||||
changeType: 'positive',
|
||||
changeIcon: 'mdi-trending-down'
|
||||
change: '-',
|
||||
changeType: 'neutral',
|
||||
changeIcon: 'mdi-minus'
|
||||
},
|
||||
{
|
||||
label: 'Tingkat Kehadiran',
|
||||
value: '89.5%',
|
||||
value: '-',
|
||||
icon: 'mdi-check-circle',
|
||||
iconSize: 24,
|
||||
iconColor: 'white',
|
||||
color: 'primary',
|
||||
change: '+2.3%',
|
||||
changeType: 'positive',
|
||||
changeIcon: 'mdi-trending-up'
|
||||
change: '-',
|
||||
changeType: 'neutral',
|
||||
changeIcon: 'mdi-minus'
|
||||
}
|
||||
]);
|
||||
|
||||
@@ -556,7 +585,7 @@ const visitTrendData = ref({
|
||||
datasets: [
|
||||
{
|
||||
label: 'Pasien Umum',
|
||||
data: [850, 920, 880, 1050, 1120, 1080, 1200, 1150, 1080, 1190, 1250, 1300],
|
||||
data: [],
|
||||
backgroundColor: 'rgba(86, 126, 231, 0.2)',
|
||||
borderColor: colors.primary[500],
|
||||
borderWidth: 3,
|
||||
@@ -570,7 +599,7 @@ const visitTrendData = ref({
|
||||
},
|
||||
{
|
||||
label: 'Pasien BPJS',
|
||||
data: [1200, 1350, 1280, 1480, 1550, 1620, 1700, 1650, 1590, 1720, 1800, 1850],
|
||||
data: [],
|
||||
backgroundColor: 'rgba(255, 132, 65, 0.2)',
|
||||
borderColor: colors.secondary[500],
|
||||
borderWidth: 3,
|
||||
@@ -587,10 +616,10 @@ const visitTrendData = ref({
|
||||
|
||||
// Payment Status Data (Doughnut Chart)
|
||||
const paymentStatusData = ref({
|
||||
labels: ['BPJS Kesehatan', 'Umum/Tunai', 'Asuransi Swasta', 'Corporate'],
|
||||
labels: [],
|
||||
datasets: [
|
||||
{
|
||||
data: [1850, 680, 320, 280],
|
||||
data: [],
|
||||
backgroundColor: [
|
||||
colors.primary[500],
|
||||
colors.secondary[500],
|
||||
@@ -606,11 +635,11 @@ const paymentStatusData = ref({
|
||||
|
||||
// Waiting Time per Poli Data (Horizontal Bar Chart)
|
||||
const waitingTimeData = ref({
|
||||
labels: ['Poli Umum', 'Poli Anak', 'Poli Gigi', 'Poli Mata', 'Poli THT', 'Poli Jantung'],
|
||||
labels: [],
|
||||
datasets: [
|
||||
{
|
||||
label: 'Waktu Tunggu (menit)',
|
||||
data: [15, 12, 8, 18, 10, 22],
|
||||
data: [],
|
||||
backgroundColor: [
|
||||
colors.primary[400],
|
||||
colors.secondary[400],
|
||||
@@ -635,10 +664,10 @@ const waitingTimeData = ref({
|
||||
|
||||
// Attendance Data (Polar Area Chart)
|
||||
const attendanceData = ref({
|
||||
labels: ['Hadir Tepat Waktu', 'Hadir Terlambat', 'Tidak Hadir', 'Batal', 'Reschedule'],
|
||||
labels: [],
|
||||
datasets: [
|
||||
{
|
||||
data: [1850, 420, 280, 150, 230],
|
||||
data: [],
|
||||
backgroundColor: [
|
||||
colors.success[400],
|
||||
colors.primary[400],
|
||||
@@ -681,7 +710,7 @@ const areaChartOptions = ref({
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
titleColor: colors.neutral[900],
|
||||
bodyColor: colors.neutral[700],
|
||||
borderColor: colors.neutral[400],
|
||||
borderColor: colors.neutral[500],
|
||||
borderWidth: 1,
|
||||
padding: 12,
|
||||
boxPadding: 6,
|
||||
@@ -778,7 +807,7 @@ const doughnutOptions = ref({
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
titleColor: colors.neutral[900],
|
||||
bodyColor: colors.neutral[700],
|
||||
borderColor: colors.neutral[400],
|
||||
borderColor: colors.neutral[500],
|
||||
borderWidth: 1,
|
||||
padding: 12,
|
||||
boxPadding: 6,
|
||||
@@ -814,7 +843,7 @@ const horizontalBarOptions = ref({
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
titleColor: colors.neutral[900],
|
||||
bodyColor: colors.neutral[700],
|
||||
borderColor: colors.neutral[400],
|
||||
borderColor: colors.neutral[500],
|
||||
borderWidth: 1,
|
||||
padding: 12,
|
||||
boxPadding: 6,
|
||||
@@ -889,7 +918,7 @@ const polarAreaOptions = ref({
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
titleColor: colors.neutral[900],
|
||||
bodyColor: colors.neutral[700],
|
||||
borderColor: colors.neutral[400],
|
||||
borderColor: colors.neutral[500],
|
||||
borderWidth: 1,
|
||||
padding: 12,
|
||||
boxPadding: 6,
|
||||
@@ -998,8 +1027,24 @@ const refreshDashboardStats = async (filterParams = {}) => {
|
||||
|
||||
// 4. Update Monthly Trend (Map API into datasets)
|
||||
if (data.monthly_trend && data.monthly_trend.length > 0) {
|
||||
// Logic to update trend charts can be expanded here
|
||||
// For now, we'll keep the existing structure but show where real data fits
|
||||
// TODO: Map 'data.monthly_trend' to 'visitTrendData'
|
||||
// Example:
|
||||
// visitTrendData.value.labels = data.monthly_trend.map(m => m.month_name);
|
||||
// visitTrendData.value.datasets[0].data = data.monthly_trend.map(m => m.umum_count);
|
||||
// visitTrendData.value.datasets[1].data = data.monthly_trend.map(m => m.bpjs_count);
|
||||
}
|
||||
|
||||
// 5. Update Attendance Data
|
||||
if (data.attendance_stats && Object.keys(data.attendance_stats).length > 0) {
|
||||
// TODO: Map 'data.attendance_stats' to 'attendanceData'
|
||||
// Example:
|
||||
// attendanceData.value = {
|
||||
// labels: Object.keys(data.attendance_stats),
|
||||
// datasets: [{
|
||||
// ...attendanceData.value.datasets[0],
|
||||
// data: Object.values(data.attendance_stats)
|
||||
// }]
|
||||
// };
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -1551,7 +1596,7 @@ const downloadBlob = (blob, filename) => {
|
||||
|
||||
/* Compact Stats Cards */
|
||||
.stats-row {
|
||||
margin-top: -20px;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 12px !important;
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
@@ -1841,6 +1886,7 @@ const downloadBlob = (blob, filename) => {
|
||||
background: linear-gradient(90deg, var(--color-primary-500) 0%, var(--color-primary-600) 50%, var(--color-secondary-500) 100%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&::after {
|
||||
@@ -1853,6 +1899,7 @@ const downloadBlob = (blob, filename) => {
|
||||
background: radial-gradient(circle, rgba(58, 97, 201, 0.05) 0%, transparent 70%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@@ -2080,6 +2127,28 @@ const downloadBlob = (blob, filename) => {
|
||||
}
|
||||
}
|
||||
|
||||
.empty-chart-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 200px;
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
border-radius: 12px;
|
||||
border: 1px dashed rgba(0, 0, 0, 0.1);
|
||||
margin: 16px;
|
||||
padding: 24px;
|
||||
|
||||
.empty-state-content {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.live-chip {
|
||||
font-weight: 700;
|
||||
font-size: 11px;
|
||||
|
||||
+27
-21
@@ -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;
|
||||
}
|
||||
|
||||
@@ -348,9 +348,9 @@ const submitForm = async () => {
|
||||
|
||||
let result;
|
||||
if (isEdit.value) {
|
||||
result = anjunganStore.updateAnjungan(formData.value);
|
||||
result = await anjunganStore.updateAnjungan(formData.value);
|
||||
} else {
|
||||
result = anjunganStore.addAnjungan(formData.value);
|
||||
result = await anjunganStore.addAnjungan(formData.value);
|
||||
}
|
||||
|
||||
snackbar.value = {
|
||||
@@ -364,14 +364,10 @@ const submitForm = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (item) => {
|
||||
const handleDelete = async (item) => {
|
||||
if (confirm(`Hapus anjungan ${item.namaAnjungan}?`)) {
|
||||
const result = anjunganStore.deleteAnjungan(item.id);
|
||||
snackbar.value = {
|
||||
show: true,
|
||||
message: result.message,
|
||||
color: result.success ? 'success' : 'error',
|
||||
};
|
||||
const result = await anjunganStore.deleteAnjungan(item.id);
|
||||
snackbar.value = { show: true, message: result.message, color: result.success ? 'success' : 'error' };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -391,6 +387,7 @@ const closePreviewDialog = () => {
|
||||
// Fetch Reguler clinics from API on mount
|
||||
onMounted(async () => {
|
||||
await clinicStore.fetchRegulerClinics();
|
||||
await anjunganStore.fetchAnjungan();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -376,20 +391,61 @@
|
||||
definePageMeta({
|
||||
middleware: ['auth', 'check-page-access']
|
||||
})
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ref, onMounted, computed, watch } from 'vue';
|
||||
import { useMasterStore } from '@/stores/masterStore';
|
||||
import { useClinicStore } from '@/stores/clinicStore';
|
||||
import { useRuangStore } from '@/stores/ruangStore';
|
||||
import { useKlinikRuangStore } from '@/stores/klinikruangstore';
|
||||
|
||||
const masterStore = useMasterStore();
|
||||
const clinicStore = useClinicStore();
|
||||
const ruangStore = useRuangStore();
|
||||
const klinikRuangStore = useKlinikRuangStore();
|
||||
const page = ref(1);
|
||||
const itemsPerPage = ref(10);
|
||||
const search = ref('');
|
||||
const filteredTotal = ref(masterStore.ruangData.length);
|
||||
|
||||
import { watch } from 'vue';
|
||||
/**
|
||||
* 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 });
|
||||
@@ -511,9 +567,9 @@ const submitForm = async () => {
|
||||
|
||||
let result;
|
||||
if (isEdit.value) {
|
||||
result = masterStore.updateRuang(formData.value);
|
||||
result = await klinikRuangStore.updateKlinikRuang(formData.value.id, formData.value);
|
||||
} else {
|
||||
result = masterStore.addRuang(formData.value);
|
||||
result = await klinikRuangStore.createKlinikRuang(formData.value);
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
@@ -524,14 +580,10 @@ const submitForm = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (item) => {
|
||||
if (confirm(`Hapus ruangan ${item.namaRuang} dari klinik ${item.namaKlinik}?`)) {
|
||||
const result = masterStore.deleteRuang(item.id);
|
||||
snackbar.value = {
|
||||
show: true,
|
||||
message: result.message,
|
||||
color: result.success ? 'success' : 'error'
|
||||
};
|
||||
const handleDelete = async (item) => {
|
||||
if (confirm(`Hapus ruangan dari klinik ${item.namaKlinik}?`)) {
|
||||
const result = await klinikRuangStore.deleteKlinikRuang(item.id);
|
||||
snackbar.value = { show: true, message: result.message, color: result.success ? 'success' : 'error' };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -552,36 +604,16 @@ const closePreviewDialog = () => {
|
||||
// Fetch clinics and sync rooms on mount
|
||||
onMounted(async () => {
|
||||
console.log('🚀 MasterKlinikRuang mounted, syncing data...');
|
||||
|
||||
try {
|
||||
// 1. Fetch clinics from API (uses cache if available)
|
||||
const fetchClinicResult = await clinicStore.fetchRegulerClinics();
|
||||
console.log('📥 Clinic fetch result:', fetchClinicResult);
|
||||
|
||||
if (!fetchClinicResult.success) {
|
||||
snackbar.value = {
|
||||
show: true,
|
||||
message: `Klinik: ${fetchClinicResult.message}`,
|
||||
color: 'warning'
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Fetch and merge rooms from API
|
||||
const syncRuangResult = await ruangStore.fetchRuangFromAPI();
|
||||
console.log('🔄 Room sync result:', syncRuangResult);
|
||||
|
||||
snackbar.value = {
|
||||
show: true,
|
||||
message: syncRuangResult.message,
|
||||
color: syncRuangResult.success ? 'success' : 'error'
|
||||
};
|
||||
await Promise.all([
|
||||
clinicStore.fetchRegulerClinics(),
|
||||
klinikRuangStore.fetchKlinikRuang(),
|
||||
ruangStore.fetchRuangFromAPI(),
|
||||
]);
|
||||
console.log('✅ MasterKlinikRuang data loaded');
|
||||
} catch (error) {
|
||||
console.error('❌ Error in onMounted:', error);
|
||||
snackbar.value = {
|
||||
show: true,
|
||||
message: `Error: ${error.message}`,
|
||||
color: 'error'
|
||||
};
|
||||
snackbar.value = { show: true, message: `Error: ${error.message}`, color: 'error' };
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+13
-23
@@ -271,12 +271,16 @@
|
||||
definePageMeta({
|
||||
middleware: ['auth', 'check-page-access']
|
||||
})
|
||||
import { ref, computed } from 'vue';
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useMasterStore } from '@/stores/masterStore';
|
||||
import { useScreenStore } from '@/stores/screenStore';
|
||||
|
||||
const masterStore = useMasterStore();
|
||||
const screenStore = useScreenStore();
|
||||
|
||||
onMounted(() => {
|
||||
screenStore.fetchScreens();
|
||||
});
|
||||
const dialog = ref(false);
|
||||
const isEdit = ref(false);
|
||||
const formRef = ref(null);
|
||||
@@ -348,34 +352,20 @@ const submitForm = async () => {
|
||||
if (!valid) return;
|
||||
|
||||
if (isEdit.value) {
|
||||
// Update screen
|
||||
const result = screenStore.updateScreen(formData.value);
|
||||
snackbar.value = {
|
||||
show: true,
|
||||
message: result.message,
|
||||
color: result.success ? 'success' : 'error'
|
||||
};
|
||||
const result = await screenStore.updateScreen(formData.value);
|
||||
snackbar.value = { show: true, message: result.message, color: result.success ? 'success' : 'error' };
|
||||
} else {
|
||||
// Add new screen
|
||||
const result = screenStore.addScreen(formData.value);
|
||||
snackbar.value = {
|
||||
show: true,
|
||||
message: result.message,
|
||||
color: result.success ? 'success' : 'error'
|
||||
};
|
||||
const result = await screenStore.addScreen(formData.value);
|
||||
snackbar.value = { show: true, message: result.message, color: result.success ? 'success' : 'error' };
|
||||
}
|
||||
|
||||
closeDialog();
|
||||
if (snackbar.value.color === 'success') closeDialog();
|
||||
};
|
||||
|
||||
const handleDelete = (item) => {
|
||||
const handleDelete = async (item) => {
|
||||
if (confirm(`Hapus screen ${item.namaScreen}?`)) {
|
||||
const result = screenStore.deleteScreen(item.id);
|
||||
snackbar.value = {
|
||||
show: true,
|
||||
message: result.message,
|
||||
color: result.success ? 'success' : 'error'
|
||||
};
|
||||
const result = await screenStore.deleteScreen(item.id);
|
||||
snackbar.value = { show: true, message: result.message, color: result.success ? 'success' : 'error' };
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -316,9 +316,9 @@ const initData = async () => {
|
||||
// Call immediately (non-blocking)
|
||||
initData();
|
||||
|
||||
// Also try onMounted for good measure
|
||||
onMounted(() => {
|
||||
initData();
|
||||
onMounted(async () => {
|
||||
await antreanMasukScreenStore.fetchAntreanMasukScreens();
|
||||
initData();
|
||||
});
|
||||
|
||||
const refreshData = async () => {
|
||||
@@ -429,34 +429,20 @@ const submitForm = async () => {
|
||||
if (!valid) return;
|
||||
|
||||
if (isEdit.value) {
|
||||
// Update screen
|
||||
const result = antreanMasukScreenStore.updateAntreanMasukScreen(formData.value);
|
||||
snackbar.value = {
|
||||
show: true,
|
||||
message: result.message,
|
||||
color: result.success ? 'success' : 'error'
|
||||
};
|
||||
const result = await antreanMasukScreenStore.updateAntreanMasukScreen(formData.value);
|
||||
snackbar.value = { show: true, message: result.message, color: result.success ? 'success' : 'error' };
|
||||
} else {
|
||||
// Add new screen
|
||||
const result = antreanMasukScreenStore.addAntreanMasukScreen(formData.value);
|
||||
snackbar.value = {
|
||||
show: true,
|
||||
message: result.message,
|
||||
color: result.success ? 'success' : 'error'
|
||||
};
|
||||
const result = await antreanMasukScreenStore.addAntreanMasukScreen(formData.value);
|
||||
snackbar.value = { show: true, message: result.message, color: result.success ? 'success' : 'error' };
|
||||
}
|
||||
|
||||
closeDialog();
|
||||
if (snackbar.value.color === 'success') closeDialog();
|
||||
};
|
||||
|
||||
const handleDelete = (item) => {
|
||||
const handleDelete = async (item) => {
|
||||
if (confirm(`Hapus screen ${item.namaScreen}?`)) {
|
||||
const result = antreanMasukScreenStore.deleteAntreanMasukScreen(item.id);
|
||||
snackbar.value = {
|
||||
show: true,
|
||||
message: result.message,
|
||||
color: result.success ? 'success' : 'error'
|
||||
};
|
||||
const result = await antreanMasukScreenStore.deleteAntreanMasukScreen(item.id);
|
||||
snackbar.value = { show: true, message: result.message, color: result.success ? 'success' : 'error' };
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,556 @@
|
||||
<script setup>
|
||||
import PageHeader from '@/components/common/PageHeader.vue';
|
||||
|
||||
const currentDate = computed(() => {
|
||||
const now = new Date();
|
||||
const days = ['Minggu', 'Senin', 'Selasa', 'Rabu', 'Kamis', 'Jumat', 'Sabtu'];
|
||||
const months = ['Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni', 'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember'];
|
||||
return `${days[now.getDay()]}, ${now.getDate()} ${months[now.getMonth()]} ${now.getFullYear()}`;
|
||||
});
|
||||
|
||||
const snackbar = ref({
|
||||
show: false,
|
||||
message: '',
|
||||
color: 'success'
|
||||
});
|
||||
|
||||
// Profile data
|
||||
const profileData = reactive({
|
||||
name: 'Budi Santoso',
|
||||
registrationNumber: 'RM-2023-8812',
|
||||
nim: '3573230897851332',
|
||||
phone: '+62 812-3456-7890',
|
||||
verified: true,
|
||||
birthDate: '10 Juni 1970',
|
||||
address: 'Jalan Soekarno Hatta, no 1A, Lowokwaru, Kota Malang'
|
||||
});
|
||||
|
||||
// Family members data
|
||||
const familyMembers = reactive([
|
||||
{
|
||||
id: 2,
|
||||
name: 'Siti Aminah',
|
||||
status: 'AKTIF'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Maimunah',
|
||||
status: 'AKTIF'
|
||||
}
|
||||
]);
|
||||
|
||||
// Activity history
|
||||
const activityHistory = reactive([
|
||||
{
|
||||
id: 1,
|
||||
title: 'Member Added',
|
||||
date: '10 Okt 2023, 14:20',
|
||||
icon: 'mdi-account-plus',
|
||||
color: 'primary'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Member Data Updated',
|
||||
date: '08 Okt 2023, 09:15',
|
||||
icon: 'mdi-pencil',
|
||||
color: 'success'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Member Removed',
|
||||
date: '07 Okt 2023, 16:45',
|
||||
icon: 'mdi-account-remove',
|
||||
color: 'error'
|
||||
}
|
||||
]);
|
||||
|
||||
// Modal state
|
||||
const isModalOpen = ref(false);
|
||||
const selectedMember = ref(null);
|
||||
|
||||
const modalData = reactive({
|
||||
namaLengkap: '',
|
||||
tanggalLahir: '',
|
||||
nik: '',
|
||||
jenisKelamin: '',
|
||||
hubungan: '',
|
||||
nomorTelepon: '',
|
||||
alamat: ''
|
||||
});
|
||||
|
||||
|
||||
/** 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;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
};
|
||||
// Alamat profil dimasukkan ke addMemberForm.alamat agar bisa diedit
|
||||
addMemberForm.alamat = profileData.address;
|
||||
addMemberForm.hubungan = '';
|
||||
isAddMemberModalOpen.value = true;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 ?? '-'
|
||||
};
|
||||
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 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">
|
||||
|
||||
<!-- Profile Card -->
|
||||
<v-row class="mb-6">
|
||||
<v-col cols="12">
|
||||
<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-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>
|
||||
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<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>
|
||||
|
||||
<!-- Family Members Section -->
|
||||
<v-row class="mb-6">
|
||||
<v-col cols="12">
|
||||
<!-- 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>
|
||||
<tr>
|
||||
<th class="text-left text-uppercase">Nama Anggota</th>
|
||||
<th class="text-center text-uppercase">Status</th>
|
||||
<th class="text-center text-uppercase">Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<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-icon start size="small">{{
|
||||
member.status === 'PENDING' ? 'mdi-clock-outline' : 'mdi-check-decagram'
|
||||
}}</v-icon>
|
||||
{{ member.status }}
|
||||
</v-chip>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<div class="d-flex justify-center gap-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" @click="openEditMemberModal(member)">
|
||||
<v-icon start>mdi-pencil</v-icon>
|
||||
Edit
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Activity History Section -->
|
||||
<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-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>
|
||||
<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>
|
||||
|
||||
<div class="text-center mt-6">
|
||||
<v-btn color="primary" variant="text">
|
||||
Lihat Semua Riwayat
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- 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">{{ 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">
|
||||
|
||||
<!-- 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="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>
|
||||
|
||||
<!-- 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-card-text>
|
||||
|
||||
<!-- Modal Actions -->
|
||||
<v-divider></v-divider>
|
||||
<v-card-actions class="pa-6 justify-end">
|
||||
<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>
|
||||
|
||||
<!-- Snackbar -->
|
||||
<v-snackbar v-model="snackbar.show" :color="snackbar.color" :timeout="3000">
|
||||
<span class="body-3">{{ snackbar.message }}</span>
|
||||
<template #actions>
|
||||
<v-btn variant="text" size="small" @click="snackbar.show = false">Tutup</v-btn>
|
||||
</template>
|
||||
</v-snackbar>
|
||||
</v-container>
|
||||
</div>
|
||||
</template>
|
||||
@@ -96,12 +96,25 @@
|
||||
size="small"
|
||||
@click="openDaftarModal(item)"
|
||||
variant="flat"
|
||||
class="btn-verify"
|
||||
class="btn-verify mt-2"
|
||||
>
|
||||
<v-icon size="16" left>mdi-qrcode-scan</v-icon>
|
||||
Verifikasi
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="item.status !== 'Belum Terverifikasi'"
|
||||
class="my-2"
|
||||
color="primary"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
@click="router.push(`/VerifikasiAkun/DetailAkun`)"
|
||||
>
|
||||
<v-icon size="16" left>mdi-account-cog-outline</v-icon>
|
||||
Kelola Akun
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
|
||||
</v-data-table>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
@@ -137,6 +150,7 @@
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
|
||||
import { useDisplay } from 'vuetify';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useRouter } from 'vue-router';
|
||||
import QrcodeVue from 'qrcode.vue';
|
||||
import PageHeader from '@/components/common/PageHeader.vue';
|
||||
import QRVerificationDialog from '@/components/verification/QRVerificationDialog.vue';
|
||||
@@ -149,6 +163,7 @@ definePageMeta({
|
||||
const display = useDisplay();
|
||||
const verificationStore = useVerificationStore();
|
||||
const { patients, loading, error } = storeToRefs(verificationStore);
|
||||
const router = useRouter();
|
||||
|
||||
// Load initial patients on mount
|
||||
onMounted(() => {
|
||||
@@ -160,7 +175,7 @@ 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: 'center' },
|
||||
{ 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: 'Actions', value: 'actions', sortable: false, width: '200px', align: 'center' },
|
||||
@@ -396,10 +411,10 @@ $font-weight-semibold: 600;
|
||||
color: $neutral-800;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
text-align: center !important;
|
||||
// text-align: center !important;
|
||||
|
||||
.v-data-table-header__content {
|
||||
justify-content: center !important;
|
||||
// justify-content: center !important;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -408,20 +423,28 @@ $font-weight-semibold: 600;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
color: $neutral-900;
|
||||
text-align: center !important;
|
||||
// text-align: center !important;
|
||||
}
|
||||
|
||||
// Ensure the cell content itself is centered if it contains flex/divs
|
||||
:deep(.v-data-table__td > *) {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
// justify-content: center;
|
||||
// align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CHIPS
|
||||
// ============================================
|
||||
.chip-orange {
|
||||
background-color: #FE6B22 !important;
|
||||
color: $neutral-100 !important;
|
||||
font-weight: $font-weight-medium;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.chip-success {
|
||||
background-color: #009262 !important;
|
||||
color: $neutral-100 !important;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
+14
-1
@@ -10,7 +10,20 @@ import { aliases, mdi } from 'vuetify/iconsets/mdi-svg'
|
||||
export default defineNuxtPlugin((app) => {
|
||||
const vuetify = createVuetify({
|
||||
ssr: true,
|
||||
blueprint: md2
|
||||
blueprint: md2,
|
||||
theme: {
|
||||
themes: {
|
||||
light: {
|
||||
colors: {
|
||||
primary: '#3A5FBC',
|
||||
lightPrimary: '#DBE1FF',
|
||||
secondary: '#E65A0D',
|
||||
error: '#D82719',
|
||||
success: '#008D65',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
app.vueApp.use(vuetify)
|
||||
})
|
||||
+54
@@ -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)
|
||||
@@ -0,0 +1,8 @@
|
||||
// server/api/config/anjungan.get.ts
|
||||
import { getConfigDb, parseConfigRow } from '~/server/utils/configDb';
|
||||
|
||||
export default defineEventHandler(() => {
|
||||
const db = getConfigDb();
|
||||
const rows = db.prepare('SELECT * FROM config_anjungan ORDER BY id ASC').all();
|
||||
return { success: true, data: rows.map(parseConfigRow) };
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
// server/api/config/anjungan.post.ts
|
||||
import { getConfigDb, parseConfigRow } from '~/server/utils/configDb';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event);
|
||||
const { namaAnjungan, jenisPasien, klinik = [] } = body;
|
||||
|
||||
if (!namaAnjungan || !jenisPasien) {
|
||||
throw createError({ statusCode: 400, message: 'namaAnjungan dan jenisPasien wajib diisi' });
|
||||
}
|
||||
|
||||
const db = getConfigDb();
|
||||
const result = db.prepare(
|
||||
`INSERT INTO config_anjungan (namaAnjungan, jenisPasien, klinik) VALUES (?, ?, ?)`
|
||||
).run(namaAnjungan, jenisPasien, JSON.stringify(klinik));
|
||||
|
||||
const newRow = db.prepare('SELECT * FROM config_anjungan WHERE id = ?').get(result.lastInsertRowid);
|
||||
return { success: true, data: parseConfigRow(newRow), message: `Anjungan ${namaAnjungan} berhasil ditambahkan` };
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
// server/api/config/anjungan/[id].delete.ts
|
||||
import { getConfigDb } from '~/server/utils/configDb';
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const id = Number(getRouterParam(event, 'id'));
|
||||
if (!id) throw createError({ statusCode: 400, message: 'ID tidak valid' });
|
||||
|
||||
const db = getConfigDb();
|
||||
const existing = db.prepare('SELECT * FROM config_anjungan WHERE id = ?').get(id) as any;
|
||||
if (!existing) throw createError({ statusCode: 404, message: 'Anjungan tidak ditemukan' });
|
||||
|
||||
db.prepare('DELETE FROM config_anjungan WHERE id = ?').run(id);
|
||||
return { success: true, message: `Anjungan ${existing.namaAnjungan} berhasil dihapus` };
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
// server/api/config/anjungan/[id].put.ts
|
||||
import { getConfigDb, parseConfigRow } from '~/server/utils/configDb';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const id = Number(getRouterParam(event, 'id'));
|
||||
if (!id) throw createError({ statusCode: 400, message: 'ID tidak valid' });
|
||||
|
||||
const body = await readBody(event);
|
||||
const { namaAnjungan, jenisPasien, klinik } = body;
|
||||
|
||||
const db = getConfigDb();
|
||||
const existing = db.prepare('SELECT * FROM config_anjungan WHERE id = ?').get(id) as any;
|
||||
if (!existing) throw createError({ statusCode: 404, message: 'Anjungan tidak ditemukan' });
|
||||
|
||||
db.prepare(
|
||||
`UPDATE config_anjungan SET
|
||||
namaAnjungan = ?,
|
||||
jenisPasien = ?,
|
||||
klinik = ?,
|
||||
updatedAt = strftime('%s', 'now')
|
||||
WHERE id = ?`
|
||||
).run(
|
||||
namaAnjungan ?? existing.namaAnjungan,
|
||||
jenisPasien ?? existing.jenisPasien,
|
||||
klinik !== undefined ? JSON.stringify(klinik) : existing.klinik,
|
||||
id
|
||||
);
|
||||
|
||||
const updated = db.prepare('SELECT * FROM config_anjungan WHERE id = ?').get(id);
|
||||
return { success: true, data: parseConfigRow(updated), message: 'Anjungan berhasil diperbarui' };
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
// server/api/config/antrean-masuk-screen.get.ts
|
||||
import { getConfigDb, parseConfigRow } from '~/server/utils/configDb';
|
||||
|
||||
export default defineEventHandler(() => {
|
||||
const db = getConfigDb();
|
||||
const rows = db.prepare('SELECT * FROM config_antrean_masuk_screen ORDER BY id ASC').all();
|
||||
return { success: true, data: rows.map(parseConfigRow) };
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
// server/api/config/antrean-masuk-screen.post.ts
|
||||
import { getConfigDb, parseConfigRow } from '~/server/utils/configDb';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event);
|
||||
const { namaScreen, nomorScreen, loket = [] } = body;
|
||||
|
||||
if (!namaScreen || !nomorScreen) {
|
||||
throw createError({ statusCode: 400, message: 'namaScreen dan nomorScreen wajib diisi' });
|
||||
}
|
||||
|
||||
const db = getConfigDb();
|
||||
try {
|
||||
const result = db.prepare(
|
||||
`INSERT INTO config_antrean_masuk_screen (namaScreen, nomorScreen, loket) VALUES (?, ?, ?)`
|
||||
).run(namaScreen, nomorScreen, JSON.stringify(loket));
|
||||
|
||||
const newRow = db.prepare('SELECT * FROM config_antrean_masuk_screen WHERE id = ?').get(result.lastInsertRowid);
|
||||
return { success: true, data: parseConfigRow(newRow), message: `Screen ${namaScreen} berhasil ditambahkan` };
|
||||
} catch (e: any) {
|
||||
if (e.message?.includes('UNIQUE')) {
|
||||
throw createError({ statusCode: 409, message: `nomorScreen "${nomorScreen}" sudah digunakan` });
|
||||
}
|
||||
throw createError({ statusCode: 500, message: e.message });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
// server/api/config/antrean-masuk-screen/[id].delete.ts
|
||||
import { getConfigDb } from '~/server/utils/configDb';
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const id = Number(getRouterParam(event, 'id'));
|
||||
if (!id) throw createError({ statusCode: 400, message: 'ID tidak valid' });
|
||||
|
||||
const db = getConfigDb();
|
||||
const existing = db.prepare('SELECT * FROM config_antrean_masuk_screen WHERE id = ?').get(id) as any;
|
||||
if (!existing) throw createError({ statusCode: 404, message: 'Screen tidak ditemukan' });
|
||||
|
||||
db.prepare('DELETE FROM config_antrean_masuk_screen WHERE id = ?').run(id);
|
||||
return { success: true, message: `Screen ${existing.namaScreen} berhasil dihapus` };
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
// server/api/config/antrean-masuk-screen/[id].put.ts
|
||||
import { getConfigDb, parseConfigRow } from '~/server/utils/configDb';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const id = Number(getRouterParam(event, 'id'));
|
||||
if (!id) throw createError({ statusCode: 400, message: 'ID tidak valid' });
|
||||
|
||||
const body = await readBody(event);
|
||||
const { namaScreen, nomorScreen, loket } = body;
|
||||
|
||||
const db = getConfigDb();
|
||||
const existing = db.prepare('SELECT * FROM config_antrean_masuk_screen WHERE id = ?').get(id) as any;
|
||||
if (!existing) throw createError({ statusCode: 404, message: 'Screen tidak ditemukan' });
|
||||
|
||||
db.prepare(
|
||||
`UPDATE config_antrean_masuk_screen SET
|
||||
namaScreen = ?, nomorScreen = ?, loket = ?,
|
||||
updatedAt = strftime('%s', 'now')
|
||||
WHERE id = ?`
|
||||
).run(
|
||||
namaScreen ?? existing.namaScreen,
|
||||
nomorScreen ?? existing.nomorScreen,
|
||||
loket !== undefined ? JSON.stringify(loket) : existing.loket,
|
||||
id
|
||||
);
|
||||
|
||||
const updated = db.prepare('SELECT * FROM config_antrean_masuk_screen WHERE id = ?').get(id);
|
||||
return { success: true, data: parseConfigRow(updated), message: 'Screen berhasil diperbarui' };
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
// server/api/config/klinik-ruang.get.ts
|
||||
import { getConfigDb, parseConfigRow } from '~/server/utils/configDb';
|
||||
|
||||
export default defineEventHandler(() => {
|
||||
const db = getConfigDb();
|
||||
const rows = db.prepare('SELECT * FROM config_klinik_ruang ORDER BY id ASC').all();
|
||||
return { success: true, data: rows.map(parseConfigRow) };
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
// server/api/config/klinik-ruang.post.ts
|
||||
import { getConfigDb, parseConfigRow } from '~/server/utils/configDb';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event);
|
||||
const { kodeKlinik, namaKlinik, ruangList = [] } = body;
|
||||
|
||||
if (!kodeKlinik || !namaKlinik) {
|
||||
throw createError({ statusCode: 400, message: 'kodeKlinik dan namaKlinik wajib diisi' });
|
||||
}
|
||||
|
||||
const db = getConfigDb();
|
||||
const result = db.prepare(
|
||||
`INSERT INTO config_klinik_ruang (kodeKlinik, namaKlinik, ruangList) VALUES (?, ?, ?)`
|
||||
).run(kodeKlinik, namaKlinik, JSON.stringify(ruangList));
|
||||
|
||||
const newRow = db.prepare('SELECT * FROM config_klinik_ruang WHERE id = ?').get(result.lastInsertRowid);
|
||||
return { success: true, data: parseConfigRow(newRow), message: `Klinik Ruang ${namaKlinik} berhasil ditambahkan` };
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
// server/api/config/klinik-ruang/[id].delete.ts
|
||||
import { getConfigDb } from '~/server/utils/configDb';
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const id = Number(getRouterParam(event, 'id'));
|
||||
if (!id) throw createError({ statusCode: 400, message: 'ID tidak valid' });
|
||||
|
||||
const db = getConfigDb();
|
||||
const existing = db.prepare('SELECT * FROM config_klinik_ruang WHERE id = ?').get(id) as any;
|
||||
if (!existing) throw createError({ statusCode: 404, message: 'Klinik Ruang tidak ditemukan' });
|
||||
|
||||
db.prepare('DELETE FROM config_klinik_ruang WHERE id = ?').run(id);
|
||||
return { success: true, message: `Klinik Ruang ${existing.namaKlinik} berhasil dihapus` };
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
// server/api/config/klinik-ruang/[id].put.ts
|
||||
import { getConfigDb, parseConfigRow } from '~/server/utils/configDb';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const id = Number(getRouterParam(event, 'id'));
|
||||
if (!id) throw createError({ statusCode: 400, message: 'ID tidak valid' });
|
||||
|
||||
const body = await readBody(event);
|
||||
const { kodeKlinik, namaKlinik, ruangList } = body;
|
||||
|
||||
const db = getConfigDb();
|
||||
const existing = db.prepare('SELECT * FROM config_klinik_ruang WHERE id = ?').get(id) as any;
|
||||
if (!existing) throw createError({ statusCode: 404, message: 'Klinik Ruang tidak ditemukan' });
|
||||
|
||||
db.prepare(
|
||||
`UPDATE config_klinik_ruang SET
|
||||
kodeKlinik = ?, namaKlinik = ?, ruangList = ?,
|
||||
updatedAt = strftime('%s', 'now')
|
||||
WHERE id = ?`
|
||||
).run(
|
||||
kodeKlinik ?? existing.kodeKlinik,
|
||||
namaKlinik ?? existing.namaKlinik,
|
||||
ruangList !== undefined ? JSON.stringify(ruangList) : existing.ruangList,
|
||||
id
|
||||
);
|
||||
|
||||
const updated = db.prepare('SELECT * FROM config_klinik_ruang WHERE id = ?').get(id);
|
||||
return { success: true, data: parseConfigRow(updated), message: 'Klinik Ruang berhasil diperbarui' };
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
// server/api/config/screen.get.ts
|
||||
import { getConfigDb, parseConfigRow } from '~/server/utils/configDb';
|
||||
|
||||
export default defineEventHandler(() => {
|
||||
const db = getConfigDb();
|
||||
const rows = db.prepare('SELECT * FROM config_screen ORDER BY id ASC').all();
|
||||
return { success: true, data: rows.map(parseConfigRow) };
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
// server/api/config/screen.post.ts
|
||||
import { getConfigDb, parseConfigRow } from '~/server/utils/configDb';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event);
|
||||
const { namaScreen, nomorScreen, klinik = [] } = body;
|
||||
|
||||
if (!namaScreen || !nomorScreen) {
|
||||
throw createError({ statusCode: 400, message: 'namaScreen dan nomorScreen wajib diisi' });
|
||||
}
|
||||
|
||||
const db = getConfigDb();
|
||||
try {
|
||||
const result = db.prepare(
|
||||
`INSERT INTO config_screen (namaScreen, nomorScreen, klinik) VALUES (?, ?, ?)`
|
||||
).run(namaScreen, nomorScreen, JSON.stringify(klinik));
|
||||
|
||||
const newRow = db.prepare('SELECT * FROM config_screen WHERE id = ?').get(result.lastInsertRowid);
|
||||
return { success: true, data: parseConfigRow(newRow), message: `Screen ${namaScreen} berhasil ditambahkan` };
|
||||
} catch (e: any) {
|
||||
if (e.message?.includes('UNIQUE')) {
|
||||
throw createError({ statusCode: 409, message: `nomorScreen "${nomorScreen}" sudah digunakan` });
|
||||
}
|
||||
throw createError({ statusCode: 500, message: e.message });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
// server/api/config/screen/[id].delete.ts
|
||||
import { getConfigDb } from '~/server/utils/configDb';
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const id = Number(getRouterParam(event, 'id'));
|
||||
if (!id) throw createError({ statusCode: 400, message: 'ID tidak valid' });
|
||||
|
||||
const db = getConfigDb();
|
||||
const existing = db.prepare('SELECT * FROM config_screen WHERE id = ?').get(id) as any;
|
||||
if (!existing) throw createError({ statusCode: 404, message: 'Screen tidak ditemukan' });
|
||||
|
||||
db.prepare('DELETE FROM config_screen WHERE id = ?').run(id);
|
||||
return { success: true, message: `Screen ${existing.namaScreen} berhasil dihapus` };
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
// server/api/config/screen/[id].put.ts
|
||||
import { getConfigDb, parseConfigRow } from '~/server/utils/configDb';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const id = Number(getRouterParam(event, 'id'));
|
||||
if (!id) throw createError({ statusCode: 400, message: 'ID tidak valid' });
|
||||
|
||||
const body = await readBody(event);
|
||||
const { namaScreen, nomorScreen, klinik } = body;
|
||||
|
||||
const db = getConfigDb();
|
||||
const existing = db.prepare('SELECT * FROM config_screen WHERE id = ?').get(id);
|
||||
if (!existing) throw createError({ statusCode: 404, message: 'Screen tidak ditemukan' });
|
||||
|
||||
db.prepare(
|
||||
`UPDATE config_screen SET
|
||||
namaScreen = ?,
|
||||
nomorScreen = ?,
|
||||
klinik = ?,
|
||||
updatedAt = strftime('%s', 'now')
|
||||
WHERE id = ?`
|
||||
).run(
|
||||
namaScreen ?? (existing as any).namaScreen,
|
||||
nomorScreen ?? (existing as any).nomorScreen,
|
||||
klinik !== undefined ? JSON.stringify(klinik) : (existing as any).klinik,
|
||||
id
|
||||
);
|
||||
|
||||
const updated = db.prepare('SELECT * FROM config_screen WHERE id = ?').get(id);
|
||||
return { success: true, data: parseConfigRow(updated), message: 'Screen berhasil diperbarui' };
|
||||
});
|
||||
@@ -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' });
|
||||
});
|
||||
@@ -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'
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { defineEventHandler, readBody } from 'h3';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// Path to the mock JSON file
|
||||
const filePath = path.resolve(process.cwd(), 'public/data/patient_subspesialis.json');
|
||||
|
||||
// Ensure directory and file exist
|
||||
const ensureFileExists = () => {
|
||||
const dirPath = path.dirname(filePath);
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
if (!fs.existsSync(filePath)) {
|
||||
fs.writeFileSync(filePath, JSON.stringify({}, null, 2), 'utf-8');
|
||||
}
|
||||
};
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
ensureFileExists();
|
||||
|
||||
if (event.node.req.method === 'GET') {
|
||||
// Return all mappings
|
||||
try {
|
||||
const data = fs.readFileSync(filePath, 'utf-8');
|
||||
return JSON.parse(data);
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
if (event.node.req.method === 'POST') {
|
||||
// Save a new mapping
|
||||
try {
|
||||
const body = await readBody(event);
|
||||
const { barcode, subspesialis } = body;
|
||||
|
||||
if (!barcode || !subspesialis) {
|
||||
return { success: false, message: 'Barcode and subspesialis are required' };
|
||||
}
|
||||
|
||||
const fileData = fs.readFileSync(filePath, 'utf-8');
|
||||
const data = JSON.parse(fileData);
|
||||
|
||||
// Save mapping: barcode -> subspesialis object
|
||||
data[barcode] = subspesialis;
|
||||
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
|
||||
return { success: true, message: 'Saved successfully' };
|
||||
} catch (e) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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.`,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
// server/utils/configDb.ts
|
||||
// Shared SQLite utility for device configuration (screens, anjungan, klinik-ruang, etc.)
|
||||
// Designed to be a temporary layer - can be swapped to an external API with minimal changes.
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
import { join } from 'path';
|
||||
import { existsSync, mkdirSync } from 'fs';
|
||||
|
||||
const getDbPath = () => {
|
||||
const dbDir = join(process.cwd(), 'data');
|
||||
if (!existsSync(dbDir)) mkdirSync(dbDir, { recursive: true });
|
||||
return join(dbDir, 'users.db'); // Reuse same DB file
|
||||
};
|
||||
|
||||
// ─── Default Seed Data ───────────────────────────────────────────────────────
|
||||
// Used when tables are empty on first run. Mirrors the hardcoded defaults in Pinia stores.
|
||||
|
||||
const SEED_SCREENS = [
|
||||
{ namaScreen: 'Layar Screen 1', nomorScreen: 'SCR-001', klinik: JSON.stringify(['AN', 'AS', 'BD', 'GI', 'GR', 'GZ', 'IP', 'JT']) },
|
||||
{ namaScreen: 'Layar Screen 2', nomorScreen: 'SCR-002', klinik: JSON.stringify(['JW', 'KK', 'MT', 'SR', 'OB', 'PR']) },
|
||||
{ namaScreen: 'Layar Screen 3', nomorScreen: 'SCR-003', klinik: JSON.stringify(['RT', 'RM', 'HO']) },
|
||||
];
|
||||
|
||||
const SEED_ANJUNGAN = [
|
||||
{ namaAnjungan: 'Anjungan Reguler', jenisPasien: 'Reguler', klinik: JSON.stringify(['AK', 'AN', 'BD', 'GR', 'GM', 'GZ', 'HO', 'IP', 'JT', 'JW', 'KD', 'KK', 'KM', 'KO', 'MC', 'MT', 'ON', 'PR', 'RD', 'RM', 'RT', 'SR', 'TH']) },
|
||||
{ namaAnjungan: 'Anjungan Eksekutif', jenisPasien: 'Eksekutif', klinik: JSON.stringify(['AK', 'AN', 'BD', 'GM', 'GR', 'GZ', 'HO', 'IP', 'JT', 'JW', 'KD', 'KK', 'KM', 'KO', 'MC', 'MT', 'ON', 'PR', 'RD', 'RM', 'RT', 'SR', 'TH']) },
|
||||
];
|
||||
|
||||
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 = [
|
||||
{ kodeKlinik: 'AN', namaKlinik: 'ANAK', ruangList: JSON.stringify([{ nomorRuang: '1', namaRuang: 'R. TINDAKAN', nomorScreen: '101' }]) },
|
||||
{ kodeKlinik: 'AS', namaKlinik: 'ANESTESI', ruangList: JSON.stringify([{ nomorRuang: '1', namaRuang: 'Ruang 1', nomorScreen: '201' }, { nomorRuang: '2', namaRuang: 'Ruang 2', nomorScreen: '202' }, { nomorRuang: '3', namaRuang: 'Ruang 3', nomorScreen: '203' }]) },
|
||||
{ kodeKlinik: 'BD', namaKlinik: 'BEDAH', ruangList: JSON.stringify([{ nomorRuang: '1', namaRuang: 'Ruang Konsultasi', nomorScreen: '301' }]) },
|
||||
{ kodeKlinik: 'GR', namaKlinik: 'GERIATRI', ruangList: JSON.stringify([{ nomorRuang: '1', namaRuang: 'Ruang Pemeriksaan', nomorScreen: '401' }]) },
|
||||
{ kodeKlinik: 'GI', namaKlinik: 'GIGI DAN MULUT', ruangList: JSON.stringify([{ nomorRuang: '1', namaRuang: 'Ruang 1', nomorScreen: '501' }, { nomorRuang: '2', namaRuang: 'Ruang 2', nomorScreen: '502' }, { nomorRuang: '3', namaRuang: 'Ruang 3', nomorScreen: '503' }]) },
|
||||
];
|
||||
|
||||
// ─── DB Initialization ────────────────────────────────────────────────────────
|
||||
|
||||
let _db: InstanceType<typeof Database> | null = null;
|
||||
|
||||
export const getConfigDb = () => {
|
||||
if (_db) return _db;
|
||||
|
||||
const dbPath = getDbPath();
|
||||
const db = new Database(dbPath);
|
||||
db.pragma('journal_mode = WAL'); // Better concurrent performance
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS config_screen (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
namaScreen TEXT NOT NULL,
|
||||
nomorScreen TEXT UNIQUE NOT NULL,
|
||||
klinik TEXT DEFAULT '[]',
|
||||
createdAt INTEGER DEFAULT (strftime('%s', 'now')),
|
||||
updatedAt INTEGER DEFAULT (strftime('%s', 'now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS config_anjungan (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
namaAnjungan TEXT NOT NULL,
|
||||
jenisPasien TEXT NOT NULL,
|
||||
klinik TEXT DEFAULT '[]',
|
||||
createdAt INTEGER DEFAULT (strftime('%s', 'now')),
|
||||
updatedAt INTEGER DEFAULT (strftime('%s', 'now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS config_antrean_masuk_screen (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
namaScreen TEXT NOT NULL,
|
||||
nomorScreen TEXT UNIQUE NOT NULL,
|
||||
loket TEXT DEFAULT '[]',
|
||||
createdAt INTEGER DEFAULT (strftime('%s', 'now')),
|
||||
updatedAt INTEGER DEFAULT (strftime('%s', 'now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS config_klinik_ruang (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
kodeKlinik TEXT NOT NULL,
|
||||
namaKlinik TEXT NOT NULL,
|
||||
ruangList TEXT DEFAULT '[]',
|
||||
createdAt INTEGER DEFAULT (strftime('%s', 'now')),
|
||||
updatedAt INTEGER DEFAULT (strftime('%s', 'now'))
|
||||
);
|
||||
`);
|
||||
|
||||
// Seed defaults if tables are empty
|
||||
_seedIfEmpty(db, 'config_screen', SEED_SCREENS);
|
||||
_seedIfEmpty(db, 'config_anjungan', SEED_ANJUNGAN);
|
||||
_seedIfEmpty(db, 'config_antrean_masuk_screen', SEED_ANTREAN_MASUK_SCREENS);
|
||||
_seedIfEmpty(db, 'config_klinik_ruang', SEED_KLINIK_RUANG);
|
||||
|
||||
console.log('✅ [configDb] Config database initialized');
|
||||
_db = db;
|
||||
return db;
|
||||
};
|
||||
|
||||
function _seedIfEmpty(db: InstanceType<typeof Database>, table: string, rows: Record<string, any>[]) {
|
||||
const count = (db.prepare(`SELECT COUNT(*) as c FROM ${table}`).get() as any).c;
|
||||
if (count === 0) {
|
||||
console.log(`🌱 [configDb] Seeding ${table} with ${rows.length} default rows`);
|
||||
const keys = Object.keys(rows[0]);
|
||||
const placeholders = keys.map(() => '?').join(', ');
|
||||
const stmt = db.prepare(`INSERT INTO ${table} (${keys.join(', ')}) VALUES (${placeholders})`);
|
||||
rows.forEach(row => stmt.run(...keys.map(k => row[k])));
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helper: Parse JSON columns ───────────────────────────────────────────────
|
||||
export const parseConfigRow = (row: any) => {
|
||||
if (!row) return null;
|
||||
const parsed = { ...row };
|
||||
// Auto-parse any column that looks like a JSON array/object
|
||||
for (const key of Object.keys(parsed)) {
|
||||
if (typeof parsed[key] === 'string' && (parsed[key].startsWith('[') || parsed[key].startsWith('{'))) {
|
||||
try { parsed[key] = JSON.parse(parsed[key]); } catch {}
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
@@ -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>;
|
||||
+48
-60
@@ -1,23 +1,12 @@
|
||||
// stores/anjunganStore.js
|
||||
// Konfigurasi anjungan — data disimpan di server SQLite via /api/config/anjungan
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
export const useAnjunganStore = defineStore('anjungan', () => {
|
||||
// Seed: existing static Anjungan page sebagai id 1
|
||||
const anjunganItems = ref([
|
||||
{
|
||||
id: 1,
|
||||
namaAnjungan: 'Anjungan Reguler',
|
||||
jenisPasien: 'Reguler',
|
||||
klinik: ['AK', 'AN', 'BD', 'GR', 'GM', 'GZ', 'HO', 'IP', 'JT', 'JW', 'KD', 'KK', 'KM', 'KO', 'MC', 'MT', 'ON', 'PR', 'RD', 'RM', 'RT', 'SR', 'TH'],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
namaAnjungan: 'Anjungan Eksekutif',
|
||||
jenisPasien: 'Eksekutif',
|
||||
klinik: ['AK', 'AN', 'BD', 'GM', 'GR', 'GZ', 'HO', 'IP', 'JT', 'JW', 'KD', 'KK', 'KM', 'KO', 'MC', 'MT', 'ON', 'PR', 'RD', 'RM', 'RT', 'SR', 'TH'],
|
||||
},
|
||||
]);
|
||||
const anjunganItems = ref([]);
|
||||
const isLoading = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
const getAllAnjungan = computed(() => anjunganItems.value);
|
||||
|
||||
@@ -28,66 +17,65 @@ export const useAnjunganStore = defineStore('anjungan', () => {
|
||||
});
|
||||
};
|
||||
|
||||
const addAnjungan = (payload) => {
|
||||
const maxId =
|
||||
anjunganItems.value.length > 0
|
||||
? Math.max(...anjunganItems.value.map((a) => Number(a.id) || 0))
|
||||
: 0;
|
||||
const newId = maxId + 1;
|
||||
const newItem = {
|
||||
...payload,
|
||||
id: newId,
|
||||
};
|
||||
anjunganItems.value.push(newItem);
|
||||
return {
|
||||
success: true,
|
||||
message: `Anjungan ${newItem.namaAnjungan} berhasil ditambahkan`,
|
||||
data: newItem,
|
||||
};
|
||||
// ── Fetch from API ─────────────────────────────────────────────────────────
|
||||
const fetchAnjungan = async () => {
|
||||
if (isLoading.value) return;
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const res = await $fetch('/api/config/anjungan');
|
||||
anjunganItems.value = res.data || [];
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
console.error('❌ [anjunganStore] Gagal fetch anjungan config:', e);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const updateAnjungan = (payload) => {
|
||||
const idx = anjunganItems.value.findIndex(
|
||||
(a) => Number(a.id) === Number(payload.id)
|
||||
);
|
||||
if (idx === -1) {
|
||||
return { success: false, message: 'Anjungan tidak ditemukan' };
|
||||
// ── CRUD via API ───────────────────────────────────────────────────────────
|
||||
const addAnjungan = async (payload) => {
|
||||
try {
|
||||
const res = await $fetch('/api/config/anjungan', { method: 'POST', body: payload });
|
||||
anjunganItems.value.push(res.data);
|
||||
return { success: true, message: res.message, data: res.data };
|
||||
} catch (e) {
|
||||
return { success: false, message: e.data?.message || e.message };
|
||||
}
|
||||
anjunganItems.value[idx] = {
|
||||
...anjunganItems.value[idx],
|
||||
...payload,
|
||||
};
|
||||
return {
|
||||
success: true,
|
||||
message: `Anjungan ${payload.namaAnjungan} berhasil diperbarui`,
|
||||
};
|
||||
};
|
||||
|
||||
const deleteAnjungan = (id) => {
|
||||
const idx = anjunganItems.value.findIndex(
|
||||
(a) => Number(a.id) === Number(id)
|
||||
);
|
||||
if (idx === -1) {
|
||||
return { success: false, message: 'Anjungan tidak ditemukan' };
|
||||
const updateAnjungan = async (payload) => {
|
||||
try {
|
||||
const res = await $fetch(`/api/config/anjungan/${payload.id}`, { method: 'PUT', body: payload });
|
||||
const idx = anjunganItems.value.findIndex((a) => Number(a.id) === Number(payload.id));
|
||||
if (idx !== -1) anjunganItems.value[idx] = res.data;
|
||||
return { success: true, message: res.message };
|
||||
} catch (e) {
|
||||
return { success: false, message: e.data?.message || e.message };
|
||||
}
|
||||
};
|
||||
|
||||
const deleteAnjungan = async (id) => {
|
||||
try {
|
||||
const res = await $fetch(`/api/config/anjungan/${id}`, { method: 'DELETE' });
|
||||
anjunganItems.value = anjunganItems.value.filter((a) => Number(a.id) !== Number(id));
|
||||
return { success: true, message: res.message };
|
||||
} catch (e) {
|
||||
return { success: false, message: e.data?.message || e.message };
|
||||
}
|
||||
const name = anjunganItems.value[idx].namaAnjungan;
|
||||
anjunganItems.value.splice(idx, 1);
|
||||
return { success: true, message: `Anjungan ${name} berhasil dihapus` };
|
||||
};
|
||||
|
||||
return {
|
||||
anjunganItems,
|
||||
isLoading,
|
||||
error,
|
||||
getAllAnjungan,
|
||||
getAnjunganById,
|
||||
fetchAnjungan,
|
||||
addAnjungan,
|
||||
updateAnjungan,
|
||||
deleteAnjungan,
|
||||
};
|
||||
}, {
|
||||
persist: {
|
||||
key: 'anjungan-store-state',
|
||||
storage: typeof window !== 'undefined' ? localStorage : undefined,
|
||||
paths: ['anjunganItems'],
|
||||
},
|
||||
persist: false,
|
||||
});
|
||||
|
||||
@@ -1,25 +1,15 @@
|
||||
// stores/antreanMasukScreenStore.js
|
||||
// Konfigurasi layar antrean masuk — data disimpan di server SQLite via /api/config/antrean-masuk-screen
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
export const useAntreanMasukScreenStore = defineStore('antreanMasukScreen', () => {
|
||||
// Initial antrean masuk screen items data
|
||||
const antreanMasukScreenItems = ref([
|
||||
{
|
||||
id: 1,
|
||||
namaScreen: "Layar Antrean Masuk 1",
|
||||
nomorScreen: "AM-001",
|
||||
loket: [1, 2, 12, 14], // Array of loket IDs
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
namaScreen: "Layar Antrean Masuk 2",
|
||||
nomorScreen: "AM-002",
|
||||
loket: [3, 4],
|
||||
},
|
||||
]);
|
||||
const antreanMasukScreenItems = ref([]);
|
||||
const isLoading = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
const getAllAntreanMasukScreens = computed(() => antreanMasukScreenItems.value);
|
||||
|
||||
// Computed
|
||||
const getAntreanMasukScreenById = (id) => {
|
||||
return computed(() => {
|
||||
const targetId = Number(id);
|
||||
@@ -27,63 +17,65 @@ export const useAntreanMasukScreenStore = defineStore('antreanMasukScreen', () =
|
||||
});
|
||||
};
|
||||
|
||||
const getAllAntreanMasukScreens = computed(() => antreanMasukScreenItems.value);
|
||||
|
||||
// Actions
|
||||
const addAntreanMasukScreen = (screenPayload) => {
|
||||
// Ensure we get a valid ID even if antreanMasukScreenItems is empty
|
||||
const maxId = antreanMasukScreenItems.value.length > 0
|
||||
? Math.max(...antreanMasukScreenItems.value.map(s => s.id), 0)
|
||||
: 0;
|
||||
const newId = maxId + 1;
|
||||
// Pastikan id baru tidak tertimpa payload (payload.id bisa null)
|
||||
const newScreen = {
|
||||
...screenPayload,
|
||||
id: newId,
|
||||
};
|
||||
antreanMasukScreenItems.value.push(newScreen);
|
||||
return { success: true, message: `Screen ${newScreen.namaScreen} berhasil ditambahkan`, data: newScreen };
|
||||
// ── Fetch from API ─────────────────────────────────────────────────────────
|
||||
const fetchAntreanMasukScreens = async () => {
|
||||
if (isLoading.value) return;
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const res = await $fetch('/api/config/antrean-masuk-screen');
|
||||
antreanMasukScreenItems.value = res.data || [];
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
console.error('❌ [antreanMasukScreenStore] Gagal fetch config:', e);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const updateAntreanMasukScreen = (screenPayload) => {
|
||||
const index = antreanMasukScreenItems.value.findIndex(s => s.id === screenPayload.id);
|
||||
if (index !== -1) {
|
||||
antreanMasukScreenItems.value[index] = {
|
||||
...antreanMasukScreenItems.value[index],
|
||||
...screenPayload,
|
||||
};
|
||||
return { success: true, message: `Konfigurasi ${screenPayload.namaScreen} berhasil disimpan` };
|
||||
// ── CRUD via API ───────────────────────────────────────────────────────────
|
||||
const addAntreanMasukScreen = async (screenPayload) => {
|
||||
try {
|
||||
const res = await $fetch('/api/config/antrean-masuk-screen', { method: 'POST', body: screenPayload });
|
||||
antreanMasukScreenItems.value.push(res.data);
|
||||
return { success: true, message: res.message, data: res.data };
|
||||
} catch (e) {
|
||||
return { success: false, message: e.data?.message || e.message };
|
||||
}
|
||||
return { success: false, message: 'Screen tidak ditemukan' };
|
||||
};
|
||||
|
||||
const deleteAntreanMasukScreen = (screenId) => {
|
||||
const index = antreanMasukScreenItems.value.findIndex(s => s.id === screenId);
|
||||
if (index !== -1) {
|
||||
const screenName = antreanMasukScreenItems.value[index].namaScreen;
|
||||
antreanMasukScreenItems.value.splice(index, 1);
|
||||
return { success: true, message: `Screen ${screenName} berhasil dihapus` };
|
||||
const updateAntreanMasukScreen = async (screenPayload) => {
|
||||
try {
|
||||
const res = await $fetch(`/api/config/antrean-masuk-screen/${screenPayload.id}`, { method: 'PUT', body: screenPayload });
|
||||
const index = antreanMasukScreenItems.value.findIndex(s => s.id === screenPayload.id);
|
||||
if (index !== -1) antreanMasukScreenItems.value[index] = res.data;
|
||||
return { success: true, message: res.message };
|
||||
} catch (e) {
|
||||
return { success: false, message: e.data?.message || e.message };
|
||||
}
|
||||
};
|
||||
|
||||
const deleteAntreanMasukScreen = async (screenId) => {
|
||||
try {
|
||||
const res = await $fetch(`/api/config/antrean-masuk-screen/${screenId}`, { method: 'DELETE' });
|
||||
antreanMasukScreenItems.value = antreanMasukScreenItems.value.filter(s => s.id !== screenId);
|
||||
return { success: true, message: res.message };
|
||||
} catch (e) {
|
||||
return { success: false, message: e.data?.message || e.message };
|
||||
}
|
||||
return { success: false, message: 'Screen tidak ditemukan' };
|
||||
};
|
||||
|
||||
return {
|
||||
// State
|
||||
antreanMasukScreenItems,
|
||||
|
||||
// Computed
|
||||
isLoading,
|
||||
error,
|
||||
getAllAntreanMasukScreens,
|
||||
getAntreanMasukScreenById,
|
||||
|
||||
// Actions
|
||||
fetchAntreanMasukScreens,
|
||||
addAntreanMasukScreen,
|
||||
updateAntreanMasukScreen,
|
||||
deleteAntreanMasukScreen,
|
||||
};
|
||||
}, {
|
||||
persist: {
|
||||
key: 'antrean-masuk-screen-store-state',
|
||||
storage: typeof window !== 'undefined' ? localStorage : undefined,
|
||||
paths: ['antreanMasukScreenItems'],
|
||||
},
|
||||
persist: false,
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
export const useClinicStore = defineStore('clinic', () => {
|
||||
const config = useRuntimeConfig();
|
||||
|
||||
// Data clinics - Single source of truth untuk semua data klinik
|
||||
// Includes basic info (name, kode, icon, doctors, shifts) + master config (totalQuota, jamShiftPerHari, jadwalKlinik, tanggalTutup)
|
||||
@@ -744,7 +745,7 @@ export const useClinicStore = defineStore('clinic', () => {
|
||||
|
||||
let rawData;
|
||||
try {
|
||||
rawData = await $fetch('/klinik-api/klinik/reguler');
|
||||
rawData = await $fetch(`${config.public.verificationApiBaseUrl}/klinik/reguler`);
|
||||
} catch (error) {
|
||||
// Handle Rate Limiting with exponential backoff
|
||||
if (error.response?.status === 429 && retryCount < 3) {
|
||||
@@ -1000,6 +1001,6 @@ export const useClinicStore = defineStore('clinic', () => {
|
||||
persist: {
|
||||
key: 'clinic-store-state',
|
||||
storage: typeof window !== 'undefined' ? localStorage : undefined,
|
||||
paths: ['clinics', 'lastSyncTimestamp'],
|
||||
paths: ['lastSyncTimestamp'],
|
||||
},
|
||||
});
|
||||
+15
-2
@@ -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]) {
|
||||
@@ -184,6 +197,6 @@ export const useDoctorStore = defineStore('doctor', () => {
|
||||
}, {
|
||||
persist: {
|
||||
key: 'doctor-store',
|
||||
pick: ['doctorsByKlinikId', 'lastSyncTimestamp']
|
||||
pick: ['lastSyncTimestamp']
|
||||
}
|
||||
});
|
||||
+95
-247
@@ -1,4 +1,5 @@
|
||||
// stores/klinikRuangStore.js
|
||||
// stores/klinikruangstore.js
|
||||
// Konfigurasi mapping klinik ke ruangan — data disimpan di server SQLite via /api/config/klinik-ruang
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
import { useClinicStore } from './clinicStore';
|
||||
@@ -6,284 +7,131 @@ import { useClinicStore } from './clinicStore';
|
||||
export const useKlinikRuangStore = defineStore('klinikRuang', () => {
|
||||
const clinicStore = useClinicStore();
|
||||
|
||||
// State/Computed - List Master Klinik (disinkronkan dengan clinicStore)
|
||||
// Master klinik list (from clinicStore — in-memory, from API)
|
||||
const masterKlinikList = computed(() => {
|
||||
const baseList = typeof clinicStore.getClinicsForDropdown === 'function'
|
||||
? clinicStore.getClinicsForDropdown()
|
||||
: [];
|
||||
|
||||
return baseList.map((c) => ({
|
||||
kode: c.kode,
|
||||
nama: c.name,
|
||||
}));
|
||||
return baseList.map((c) => ({ kode: c.kode, nama: c.name }));
|
||||
});
|
||||
|
||||
// State - Klinik Ruang Data
|
||||
const klinikRuangList = ref([
|
||||
{
|
||||
id: 1,
|
||||
no: 1,
|
||||
kodeKlinik: 'AN',
|
||||
namaKlinik: 'ANAK',
|
||||
namaRuang: 'R. TINDAKAN',
|
||||
ruangList: [
|
||||
{ nomorRuang: '1', namaRuang: 'R. TINDAKAN', nomorScreen: '101' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
no: 2,
|
||||
kodeKlinik: 'AS',
|
||||
namaKlinik: 'ANESTESI',
|
||||
namaRuang: 'Ruang 1, Ruang 2, Ruang 3, Ruang 4, Ruang 5, Ruang 6',
|
||||
ruangList: [
|
||||
{ nomorRuang: '1', namaRuang: 'Ruang 1', nomorScreen: '201' },
|
||||
{ nomorRuang: '2', namaRuang: 'Ruang 2', nomorScreen: '202' },
|
||||
{ nomorRuang: '3', namaRuang: 'Ruang 3', nomorScreen: '203' },
|
||||
{ nomorRuang: '4', namaRuang: 'Ruang 4', nomorScreen: '204' },
|
||||
{ nomorRuang: '5', namaRuang: 'Ruang 5', nomorScreen: '205' },
|
||||
{ nomorRuang: '6', namaRuang: 'Ruang 6', nomorScreen: '206' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
no: 3,
|
||||
kodeKlinik: 'BD',
|
||||
namaKlinik: 'BEDAH',
|
||||
namaRuang: 'Ruang Konsultasi',
|
||||
ruangList: [
|
||||
{ nomorRuang: '1', namaRuang: 'Ruang Konsultasi', nomorScreen: '301' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
no: 4,
|
||||
kodeKlinik: 'GR',
|
||||
namaKlinik: 'GERIATRI',
|
||||
namaRuang: 'Ruang Pemeriksaan',
|
||||
ruangList: [
|
||||
{ nomorRuang: '1', namaRuang: 'Ruang Pemeriksaan', nomorScreen: '401' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
no: 5,
|
||||
kodeKlinik: 'GI',
|
||||
namaKlinik: 'GIGI DAN MULUT',
|
||||
namaRuang: 'Ruang 1, Ruang 2, Ruang 3',
|
||||
ruangList: [
|
||||
{ nomorRuang: '1', namaRuang: 'Ruang 1', nomorScreen: '501' },
|
||||
{ nomorRuang: '2', namaRuang: 'Ruang 2', nomorScreen: '502' },
|
||||
{ nomorRuang: '3', namaRuang: 'Ruang 3', nomorScreen: '503' }
|
||||
]
|
||||
},
|
||||
]);
|
||||
const klinikRuangList = ref([]);
|
||||
const isLoading = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
// Computed
|
||||
const totalKlinikRuang = computed(() => klinikRuangList.value.length);
|
||||
const totalRuangan = computed(() =>
|
||||
klinikRuangList.value.reduce((total, k) => total + (k.ruangList?.length || 0), 0)
|
||||
);
|
||||
|
||||
const totalRuangan = computed(() => {
|
||||
return klinikRuangList.value.reduce((total, klinik) => {
|
||||
return total + klinik.ruangList.length;
|
||||
}, 0);
|
||||
});
|
||||
|
||||
// Get klinik by code
|
||||
const getKlinikByCode = (kode) => {
|
||||
return klinikRuangList.value.find(k => k.kodeKlinik === kode);
|
||||
};
|
||||
|
||||
// Get all ruang for a specific klinik
|
||||
const getKlinikByCode = (kode) => klinikRuangList.value.find(k => k.kodeKlinik === kode);
|
||||
const getRuangByKlinik = (kodeKlinik) => {
|
||||
const klinik = klinikRuangList.value.find(k => k.kodeKlinik === kodeKlinik);
|
||||
return klinik ? klinik.ruangList : [];
|
||||
};
|
||||
|
||||
// Actions - CRUD Operations
|
||||
|
||||
// Create new Klinik Ruang
|
||||
const createKlinikRuang = (data) => {
|
||||
const newId = Math.max(...klinikRuangList.value.map(r => r.id), 0) + 1;
|
||||
const newNo = klinikRuangList.value.length + 1;
|
||||
|
||||
// Generate nama ruang untuk display
|
||||
const namaRuangDisplay = data.ruangList
|
||||
.map(r => r.namaRuang)
|
||||
.filter(n => n)
|
||||
.join(', ');
|
||||
|
||||
const newKlinikRuang = {
|
||||
id: newId,
|
||||
no: newNo,
|
||||
kodeKlinik: data.kodeKlinik,
|
||||
namaKlinik: data.namaKlinik,
|
||||
namaRuang: namaRuangDisplay,
|
||||
ruangList: data.ruangList
|
||||
};
|
||||
|
||||
klinikRuangList.value.push(newKlinikRuang);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Klinik Ruang ${data.namaKlinik} berhasil ditambahkan`,
|
||||
data: newKlinikRuang
|
||||
};
|
||||
};
|
||||
|
||||
// Update existing Klinik Ruang
|
||||
const updateKlinikRuang = (id, data) => {
|
||||
const index = klinikRuangList.value.findIndex(r => r.id === id);
|
||||
|
||||
if (index === -1) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Klinik Ruang tidak ditemukan'
|
||||
};
|
||||
}
|
||||
|
||||
// Generate nama ruang untuk display
|
||||
const namaRuangDisplay = data.ruangList
|
||||
.map(r => r.namaRuang)
|
||||
.filter(n => n)
|
||||
.join(', ');
|
||||
|
||||
klinikRuangList.value[index] = {
|
||||
...klinikRuangList.value[index],
|
||||
kodeKlinik: data.kodeKlinik,
|
||||
namaKlinik: data.namaKlinik,
|
||||
namaRuang: namaRuangDisplay,
|
||||
ruangList: data.ruangList
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Klinik Ruang ${data.namaKlinik} berhasil diupdate`,
|
||||
data: klinikRuangList.value[index]
|
||||
};
|
||||
};
|
||||
|
||||
// Delete Klinik Ruang
|
||||
const deleteKlinikRuang = (id) => {
|
||||
const index = klinikRuangList.value.findIndex(r => r.id === id);
|
||||
|
||||
if (index === -1) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Klinik Ruang tidak ditemukan'
|
||||
};
|
||||
}
|
||||
|
||||
const deletedKlinik = klinikRuangList.value[index];
|
||||
klinikRuangList.value.splice(index, 1);
|
||||
|
||||
// Reorder numbers
|
||||
klinikRuangList.value.forEach((r, idx) => {
|
||||
r.no = idx + 1;
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Klinik Ruang ${deletedKlinik.namaKlinik} berhasil dihapus`
|
||||
};
|
||||
};
|
||||
|
||||
// Add ruang to existing klinik
|
||||
const addRuangToKlinik = (klinikId, ruangData) => {
|
||||
const klinik = klinikRuangList.value.find(k => k.id === klinikId);
|
||||
|
||||
if (!klinik) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Klinik tidak ditemukan'
|
||||
};
|
||||
}
|
||||
|
||||
klinik.ruangList.push(ruangData);
|
||||
|
||||
// Update display name
|
||||
klinik.namaRuang = klinik.ruangList
|
||||
.map(r => r.namaRuang)
|
||||
.filter(n => n)
|
||||
.join(', ');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Ruang ${ruangData.namaRuang} berhasil ditambahkan`,
|
||||
data: klinik
|
||||
};
|
||||
};
|
||||
|
||||
// Remove ruang from klinik
|
||||
const removeRuangFromKlinik = (klinikId, ruangIndex) => {
|
||||
const klinik = klinikRuangList.value.find(k => k.id === klinikId);
|
||||
|
||||
if (!klinik) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Klinik tidak ditemukan'
|
||||
};
|
||||
}
|
||||
|
||||
if (klinik.ruangList.length <= 1) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Minimal harus ada 1 ruangan'
|
||||
};
|
||||
}
|
||||
|
||||
const removedRuang = klinik.ruangList[ruangIndex];
|
||||
klinik.ruangList.splice(ruangIndex, 1);
|
||||
|
||||
// Update display name
|
||||
klinik.namaRuang = klinik.ruangList
|
||||
.map(r => r.namaRuang)
|
||||
.filter(n => n)
|
||||
.join(', ');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Ruang ${removedRuang.namaRuang} berhasil dihapus`,
|
||||
data: klinik
|
||||
};
|
||||
};
|
||||
|
||||
// Search klinik ruang
|
||||
const searchKlinikRuang = (searchTerm) => {
|
||||
if (!searchTerm) return klinikRuangList.value;
|
||||
|
||||
const term = searchTerm.toLowerCase();
|
||||
return klinikRuangList.value.filter(k =>
|
||||
k.kodeKlinik.toLowerCase().includes(term) ||
|
||||
k.namaKlinik.toLowerCase().includes(term) ||
|
||||
k.namaRuang.toLowerCase().includes(term)
|
||||
return klinikRuangList.value.filter(k =>
|
||||
k.kodeKlinik?.toLowerCase().includes(term) ||
|
||||
k.namaKlinik?.toLowerCase().includes(term)
|
||||
);
|
||||
};
|
||||
|
||||
// ── Fetch from API ─────────────────────────────────────────────────────────
|
||||
const fetchKlinikRuang = async () => {
|
||||
if (isLoading.value) return;
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const res = await $fetch('/api/config/klinik-ruang');
|
||||
klinikRuangList.value = (res.data || []).map((item, idx) => ({
|
||||
...item,
|
||||
no: idx + 1,
|
||||
namaRuang: (item.ruangList || []).map(r => r.namaRuang).filter(Boolean).join(', '),
|
||||
}));
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
console.error('❌ [klinikRuangStore] Gagal fetch config:', e);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// ── CRUD via API ───────────────────────────────────────────────────────────
|
||||
const createKlinikRuang = async (data) => {
|
||||
try {
|
||||
const res = await $fetch('/api/config/klinik-ruang', { method: 'POST', body: data });
|
||||
const newItem = { ...res.data, no: klinikRuangList.value.length + 1, namaRuang: (res.data.ruangList || []).map(r => r.namaRuang).join(', ') };
|
||||
klinikRuangList.value.push(newItem);
|
||||
return { success: true, message: res.message, data: newItem };
|
||||
} catch (e) {
|
||||
return { success: false, message: e.data?.message || e.message };
|
||||
}
|
||||
};
|
||||
|
||||
const updateKlinikRuang = async (id, data) => {
|
||||
try {
|
||||
const res = await $fetch(`/api/config/klinik-ruang/${id}`, { method: 'PUT', body: data });
|
||||
const index = klinikRuangList.value.findIndex(r => r.id === id);
|
||||
if (index !== -1) {
|
||||
klinikRuangList.value[index] = {
|
||||
...res.data,
|
||||
no: klinikRuangList.value[index].no,
|
||||
namaRuang: (res.data.ruangList || []).map(r => r.namaRuang).join(', '),
|
||||
};
|
||||
}
|
||||
return { success: true, message: res.message, data: res.data };
|
||||
} catch (e) {
|
||||
return { success: false, message: e.data?.message || e.message };
|
||||
}
|
||||
};
|
||||
|
||||
const deleteKlinikRuang = async (id) => {
|
||||
try {
|
||||
const res = await $fetch(`/api/config/klinik-ruang/${id}`, { method: 'DELETE' });
|
||||
klinikRuangList.value = klinikRuangList.value.filter(r => r.id !== id);
|
||||
// Reorder 'no'
|
||||
klinikRuangList.value.forEach((r, idx) => { r.no = idx + 1; });
|
||||
return { success: true, message: res.message };
|
||||
} catch (e) {
|
||||
return { success: false, message: e.data?.message || e.message };
|
||||
}
|
||||
};
|
||||
|
||||
const addRuangToKlinik = async (klinikId, ruangData) => {
|
||||
const klinik = klinikRuangList.value.find(k => k.id === klinikId);
|
||||
if (!klinik) return { success: false, message: 'Klinik tidak ditemukan' };
|
||||
const newRuangList = [...(klinik.ruangList || []), ruangData];
|
||||
return updateKlinikRuang(klinikId, { ...klinik, ruangList: newRuangList });
|
||||
};
|
||||
|
||||
const removeRuangFromKlinik = async (klinikId, ruangIndex) => {
|
||||
const klinik = klinikRuangList.value.find(k => k.id === klinikId);
|
||||
if (!klinik) return { success: false, message: 'Klinik tidak ditemukan' };
|
||||
if ((klinik.ruangList || []).length <= 1) return { success: false, message: 'Minimal harus ada 1 ruangan' };
|
||||
const newRuangList = klinik.ruangList.filter((_, i) => i !== ruangIndex);
|
||||
return updateKlinikRuang(klinikId, { ...klinik, ruangList: newRuangList });
|
||||
};
|
||||
|
||||
return {
|
||||
// State
|
||||
masterKlinikList,
|
||||
klinikRuangList,
|
||||
|
||||
// Computed
|
||||
isLoading,
|
||||
error,
|
||||
totalKlinikRuang,
|
||||
totalRuangan,
|
||||
|
||||
// Getters
|
||||
getKlinikByCode,
|
||||
getRuangByKlinik,
|
||||
|
||||
// Actions
|
||||
searchKlinikRuang,
|
||||
fetchKlinikRuang,
|
||||
createKlinikRuang,
|
||||
updateKlinikRuang,
|
||||
deleteKlinikRuang,
|
||||
addRuangToKlinik,
|
||||
removeRuangFromKlinik,
|
||||
searchKlinikRuang,
|
||||
};
|
||||
}, {
|
||||
persist: {
|
||||
key: 'klinikruang-store-state',
|
||||
storage: typeof window !== 'undefined' ? localStorage : undefined,
|
||||
paths: ['klinikRuangList'],
|
||||
},
|
||||
persist: false,
|
||||
});
|
||||
+18
-31
@@ -68,6 +68,7 @@ const getPelayananContohDariAnjungan = (anjunganItems) => {
|
||||
export const useLoketStore = defineStore('loket', () => {
|
||||
const clinicStore = useClinicStore();
|
||||
const anjunganStore = useAnjunganStore();
|
||||
const config = useRuntimeConfig();
|
||||
|
||||
// ============================================
|
||||
// STATE - SEPARATED DATA SOURCES
|
||||
@@ -78,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(() => {
|
||||
@@ -289,7 +276,7 @@ export const useLoketStore = defineStore('loket', () => {
|
||||
|
||||
let rawData;
|
||||
try {
|
||||
rawData = await $fetch('/klinik-api/klinik/loket');
|
||||
rawData = await $fetch(`${config.public.verificationApiBaseUrl}/klinik/loket`);
|
||||
} catch (error) {
|
||||
if (error.response?.status === 429 && retryCount < 3) {
|
||||
const delay = (retryCount + 1) * 1500;
|
||||
@@ -428,7 +415,7 @@ export const useLoketStore = defineStore('loket', () => {
|
||||
|
||||
let rawData;
|
||||
try {
|
||||
rawData = await $fetch(`/klinik-api/klinik/loket/${loketId}`);
|
||||
rawData = await $fetch(`${config.public.verificationApiBaseUrl}/klinik/loket/${loketId}`);
|
||||
} catch (error) {
|
||||
throw new Error(`HTTP error! status: ${error.response?.status || error.message}`);
|
||||
}
|
||||
|
||||
+38
-31
@@ -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
@@ -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 };
|
||||
});
|
||||
File diff suppressed because it is too large
Load diff
Loaded 100 of 104 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in New Issue
Block a user