13 Commits
Author SHA1 Message Date
yusron.alamsyah.2311 8745b22f07 fix : calender view jadwal dokter 2026-08-03 07:58:47 +07:00
yusron.alamsyah.2311 b0b5200802 fix : page detail akun peserta 2026-07-14 09:16:12 +07:00
Fanrouver fec58f14b5 docs: add Git workflow and branching guide for team reference 2026-07-10 14:06:15 +07:00
Fanrouver bbcaf9826c update skill dan docs 2026-07-10 11:14:36 +07:00
Fanrouver d0eee7d062 feat: implement Grand Paviliun dashboard with specialized room monitoring, queue management, and modular components 2026-07-10 09:42:30 +07:00
Fanrouver 61772105fd feat: implement queue store with clinic API integration, WebSocket sync, and patient data management 2026-07-02 11:39:07 +07:00
Fanrouver e18ed186e1 feat: implement queue management store and administrative interface for patient flow control 2026-06-23 08:24:26 +07:00
yusron.alamsyah.2311 400bfcdcaf feat detail akun 2026-06-19 11:10:51 +07:00
Fanrouver 8b9c4725de feat: implement queue display and management system for loket and clinic modules voice over 2026-06-09 08:51:15 +07:00
Fanrouver f81dd57a16 pindah klinik belum service 2026-06-05 08:21:56 +07:00
Fanrouver 0928e78bec feat: implement proxy routes for APIs, add Pinia stores for loket, clinic, and queue management, and create new pages for admin and anjungan dashboard. 2026-06-03 09:18:48 +07:00
Fanrouver 047b39064d feat: implement queue display system with localized store, composables, and administrative dashboard pages 2026-06-02 09:14:38 +07:00
Fanrouver ffdb88d13c push perbaikan 2026-05-25 08:43:30 +07:00
91 changed files with 8768 additions and 1278 deletions

No files matched your search

+304
View File
@@ -0,0 +1,304 @@
# 📋 Project Knowledge — Web Antrean (Sistem Antrian Rumah Sakit)
> Dokumen ini berisi knowledge base lengkap, aturan pengkodean, dan workflow wajib untuk project **web-antrean**.
> **WAJIB DIBACA** sebelum melakukan task apapun.
---
## 🚨 ATURAN WAJIB (MANDATORY RULES)
### Rule 1: Selalu Buat Implementation Plan Sebelum Eksekusi
- **SEBELUM** menulis kode apapun, buat implementation plan di `docs/DEVPLAN.md` atau artifact
- Plan harus mencakup: file yang akan diubah, perubahan spesifik, dan alasan
- Tunggu persetujuan user sebelum eksekusi (kecuali fix minor/typo)
### Rule 2: Selalu Tambahkan Komentar di Setiap Fungsi
- Setiap **function**, **method**, **computed**, dan **watcher** HARUS punya komentar JSDoc/TSDoc
- Format minimum: `/** Deskripsi singkat fungsi ini */`
- Untuk fungsi kompleks, tambahkan `@param`, `@returns`, `@example`
- Komentar inline untuk logika yang tidak obvious
```javascript
// ✅ BENAR
/**
* Mengambil data pasien dari API berdasarkan ID loket
* @param {string} loketId - ID loket yang akan di-fetch
* @returns {Promise<QueuePatient[]>} Daftar pasien dalam antrian
*/
const fetchPatientsForLoket = async (loketId) => { ... }
// ❌ SALAH — tanpa komentar
const fetchPatientsForLoket = async (loketId) => { ... }
```
### Rule 3: Selalu Update Dokumentasi Setelah Perubahan
- **DEVLOG.md**: Tambahkan entry untuk setiap perubahan signifikan
- **DEVPLAN.md**: Update status task yang sudah selesai
- **PRD.md**: Update jika ada perubahan requirements/API/arsitektur
- **AGENTS.md**: Update jika ada knowledge baru yang penting
### Rule 4: Selalu Baca AGENTS.md dan Skills Sebelum Eksekusi
- Baca file ini SEBELUM mulai task apapun
- Baca skill `web-antrean-coding-standards` untuk konvensi kode
- Jangan mengulang kesalahan yang sudah tercatat di bagian Gotchas
### Rule 5: Jangan Mengulang Kesalahan yang Sudah Diketahui
- Cek bagian **⚠️ Gotchas & Known Issues** sebelum debug
- Cek **DEVLOG.md** untuk masalah serupa yang pernah diselesaikan
- Jika menemukan masalah baru, catat di DEVLOG dan Gotchas
---
## 🏗️ Arsitektur & Tech Stack
| Layer | Teknologi |
|---|---|
| **Framework** | Nuxt 3 (`^3.17.7`) dengan SSR |
| **UI Library** | Vuetify 3 (`^3.9.3`) + Material Design Icons (`@mdi/font`) |
| **State Management** | Pinia (`^3.0.3`) + `pinia-plugin-persistedstate` |
| **Styling** | SCSS (`sass-embedded`), design tokens di `assets/scss/` |
| **Auth** | Keycloak OAuth2 (custom server-side implementation, bukan nuxt-auth) |
| **Database (Server)** | SQLite via `better-sqlite3` — untuk konfigurasi device & user sync |
| **Charting** | Chart.js + vue-chartjs + nuxt-charts |
| **QR Code** | `html5-qrcode` (scanner) + `qrcode.vue` / `nuxt-qrcode` (generator) |
| **WebSocket** | Custom composable (`useWebSocket.ts`) ke backend Go |
| **Deployment** | Docker + docker-compose |
| **Testing** | Vitest (unit) + Cypress (E2E) |
| **Font** | Google Fonts (Inter 400-700) |
---
## 📁 Struktur Direktori
```
web-antrean/
├── app.vue # Entry point — store schema migration + loading overlay
├── nuxt.config.ts # Konfigurasi Nuxt, Vuetify, runtime config, proxy
├── .env # Environment variables (API URLs, auth, proxy)
├── docker-compose.yml # Docker deployment config
├── .agents/ # Agent rules & skills
│ ├── AGENTS.md # FILE INI — rules & knowledge base
│ └── skills/ # Coding standards & workflow skills
├── docs/ # Dokumentasi project
│ ├── DEVLOG.md # Development log (kronologis)
│ ├── DEVPLAN.md # Development plan (task breakdown)
│ └── PRD.md # Product Requirements Document
├── assets/scss/ # Design system
│ ├── _colors.scss # Color tokens (CSS variables)
│ ├── _variables.scss # Spacing, breakpoints, etc.
│ ├── _typography.scss # Font styles
│ └── main.scss # Global imports
├── components/
│ ├── common/ # Shared: PageHeader, Avatar, AppSnackbar, SelectionDialog
│ ├── layout/ # SideBar, ProfileMenu, ProfilePopup
│ ├── features/ # Feature-specific (antrean, master, monitoring, queue)
│ ├── AdminKlinik/ # Komponen admin klinik eksekutif
│ ├── GrandPaviliun/ # Komponen Grand Paviliun (Gp*)
│ ├── HakAkses/ # Komponen manajemen hak akses
│ ├── MonitorPasien/ # Komponen monitoring pasien
│ ├── checkin/ # Komponen check-in
│ ├── verification/ # Komponen verifikasi akun
│ └── preview/ # Komponen preview
├── composables/
│ ├── useAuth.ts # Auth state & session management
│ ├── useWebSocket.ts # WebSocket client dengan auto-reconnect
│ ├── useQueueAPI.ts # API wrapper untuk antrian (verificationApiBaseUrl)
│ ├── useVisitAPI.ts # API wrapper untuk visit (externalApiBaseUrl)
│ ├── useQueueSync.ts # Sinkronisasi antrian antar client
│ ├── useCheckIn.ts # Logika check-in pasien
│ ├── useCheckInHistory.ts # Riwayat check-in
│ ├── useClinicAPI.ts # API wrapper klinik
│ ├── useGrandPaviliun.ts # Logika Grand Paviliun
│ ├── useHakAkses.ts # Manajemen hak akses
│ ├── usePermissions.ts # Permission check helper
│ ├── useQRGenerator.ts # QR code generation
│ ├── useQRScanner.ts # QR code scanning (html5-qrcode)
│ ├── useThermalPrint.ts # Thermal printer (tiket antrian)
│ ├── useSnackbar.ts # Toast notification
│ └── useInfiniteScroll.ts # Infinite scroll pagination
├── layouts/
│ ├── default.vue # Main layout — SideBar + middleware auth + checkPageAccess
│ └── empty.vue # Empty layout (untuk halaman tanpa sidebar)
├── middleware/
│ ├── auth.ts # Auth check → redirect ke /LoginPage jika belum login
│ ├── guest.ts # Untuk halaman publik
│ ├── checkPageAccess.ts # Cek akses halaman berdasarkan hak akses
│ └── permissions.ts # Auto-sync permissions dari backend (DISABLED)
├── pages/ # Semua halaman (lihat detail di PRD.md section 8.1)
│ ├── AdminKlinikRuang/ # [kodeKlinik].vue (~111KB)
│ ├── AdminLoket/ # [id].vue (~58KB)
│ ├── CheckInPasien/ # checkIn.vue (~230KB, LARGEST)
│ ├── Anjungan/ # Display anjungan (kiosk)
│ ├── Setting/ # Master data & konfigurasi
│ └── ...
├── server/
│ ├── api/auth/ # Auth endpoints (Keycloak OAuth flow)
│ ├── api/users/ # User management (SQLite-backed)
│ ├── api/config/ # Device configuration (SQLite-backed)
│ ├── api/hak-akses/ # Hak akses management
│ └── utils/ # configDb.ts, sessionStore.ts, userSync.ts
├── stores/ # Pinia stores (Single Source of Truth)
│ ├── clinicStore.js # Master data klinik (~1007 lines)
│ ├── queueStore.ts # Antrian pasien (~3302 lines, LARGEST)
│ ├── loketStore.js # Data loket (~549 lines)
│ ├── ruangStore.js # Data ruang klinik (~558 lines)
│ ├── masterStore.js # BACKWARD COMPAT LAYER (jangan tulis langsung!)
│ ├── penunjangStore.js # Data penunjang medis
│ ├── doctorStore.js # Data dokter (fetched from API)
│ └── ... # anjunganStore, screenStore, navItems1, dll
├── types/ # TypeScript interfaces & types
│ ├── auth.ts, queue.ts, checkin.ts, setting.ts
└── data/
└── users.db # SQLite database (user sync + device config)
```
---
## 🔌 API Endpoints & External Services
### Environment Variables (`.env`)
| Variable | Deskripsi | Value (Juli 2026) |
|---|---|---|
| `ANTRIAN_API_URL` | API antrian utama (Go backend) | `http://10.10.150.131:8089/api/v1` |
| `VERIFICATION_API_BASE_URL` | Alias untuk ANTRIAN_API_URL | (same) |
| `VISIT_API_URL` | API visit/kunjungan pasien | `http://10.10.123.135:8084/api/v1` |
| `WS_API_URL` | WebSocket URL | `ws://10.10.123.135:8084/api/v1/ws` |
| `EXTERNAL_API_BASE_URL` | External API (JWT validation) | `http://10.10.123.135:8084` |
| `KEYCLOAK_ISSUER` | Keycloak realm URL | `https://auth.rssa.top/realms/sandbox` |
| `AUTH_ORIGIN` | Aplikasi origin URL | `http://10.10.150.175:3000` |
| `HOST` | Dev server host | `http://10.10.150.175:3000` |
### Runtime Config (`nuxt.config.ts`)
```
runtimeConfig.public.verificationApiBaseUrl → ANTRIAN_API_URL (antrian API)
runtimeConfig.public.externalApiBaseUrl → VISIT_API_URL (visit API)
runtimeConfig.public.wsBaseUrl → WS_API_URL (WebSocket)
```
### External Backend APIs
1. **Antrian API** (`verificationApiBaseUrl`) — Go backend
- `GET /klinik/reguler` — Fetch daftar klinik reguler
- `GET /loket/ruang` — Fetch daftar ruangan per klinik
- `GET /loket/{id}` — Fetch pasien per loket
- `GET /dokter/{id}` — Fetch data dokter per klinik
- `POST /tiket/generate` — Generate tiket antrian
- `POST /tiket/checkin` — Check-in pasien
- `GET /permission` — Fetch permission/hak akses
2. **Visit API** (`externalApiBaseUrl`) — Visit/kunjungan
- `GET /visit?klinik_id={id}` — Fetch data pasien per klinik
- `POST /external/validate-token` — Validate JWT token
3. **WebSocket** (`wsBaseUrl`)
- Real-time queue updates, connected via `useWebSocket.ts`
> **PENTING**: Semua kode menggunakan `config.public.verificationApiBaseUrl` dengan fallback hardcoded. Saat ganti IP, ubah di `.env` lalu **restart dev server**. Nuxt hanya membaca `.env` saat startup!
---
## 🗄️ Store Architecture
### Hirarki Store
```
masterStore (BACKWARD COMPAT LAYER — JANGAN TULIS LANGSUNG)
├── clinicStore → Single source of truth untuk data KLINIK
├── loketStore → Data LOKET (counter antrian)
├── ruangStore → Data RUANG per klinik
└── penunjangStore → Data PENUNJANG medis
queueStore → Data ANTRIAN PASIEN (terbesar, 136KB)
doctorStore → Data DOKTER (fetched per klinik)
anjunganStore → Config ANJUNGAN (kiosk)
screenStore → Config SCREEN display
antreanMasukScreenStore → Config screen antrian masuk
navItems1 → Navigation items + hak akses filter
permissionStore → Permission data
verificationStore → Verifikasi akun
```
### Store Schema Migration
- `app.vue` manages `STORE_SCHEMA_VERSION` (currently `3`)
- Saat versi tidak cocok, semua localStorage Pinia di-clear
- **Naikkan versi** saat ada perubahan struktur state
---
## 🔐 Authentication Flow
1. **Login**: User klik login → `keycloak-login.ts` → redirect ke Keycloak
2. **Callback**: Keycloak redirect back → `keycloak-callback.get.ts` → exchange code for token → create session cookie
3. **Session**: `session.get.ts` validates cookie → returns user data
4. **Middleware**: `auth.ts` checks session on every protected route
5. **User Sync**: `userSync.ts` syncs Keycloak users to local SQLite DB
---
## ⚠️ Gotchas & Known Issues (HARUS DIBACA SEBELUM DEBUG)
1. **ENV reload**: Nuxt hanya membaca `.env` saat startup. Setelah ubah `.env`, HARUS restart dev server (`Ctrl+C``npm run dev`).
2. **Hardcoded fallback IPs**: Banyak file masih punya fallback IP lama. Saat ganti IP, ubah `.env` + restart server. JANGAN cuma ubah satu file.
3. **`totalQuota === 0` filter di ruangStore**: Klinik dengan `totalQuota: 0` diskip dari Admin Klinik Ruang. Ini menyembunyikan klinik yang valid tapi belum dikonfigurasi kuotanya. Trade-off yang disengaja.
4. **masterStore adalah proxy**: Jangan langsung tulis data ke masterStore. Selalu gunakan store yang sesuai (clinicStore, loketStore, ruangStore, penunjangStore).
5. **File besar** (hati-hati saat edit):
- `CheckInPasien/checkIn.vue` (~230KB)
- `queueStore.ts` (~136KB)
- `AdminKlinikRuang/[kodeKlinik].vue` (~111KB)
6. **SQLite DB path**: `data/users.db` — digunakan untuk konfigurasi device & user sync. Di Docker, di-mount sebagai volume.
7. **WebSocket auto-upgrade**: `useWebSocket.ts` otomatis upgrade `ws://` ke `wss://` jika halaman diakses via HTTPS.
8. **Pinia persist `paths` type mismatch**: Gunakan `// @ts-ignore` untuk properti `paths` karena kompatibel runtime tapi melanggar validasi tipe.
---
## 🔗 Key File Quick Reference
| Kebutuhan | File |
|---|---|
| Ganti API URL | `.env` → restart dev server |
| Auth flow | `server/api/auth/` |
| Konfigurasi Nuxt | `nuxt.config.ts` |
| Data klinik | `stores/clinicStore.js` |
| Data antrian | `stores/queueStore.ts` |
| Data ruang | `stores/ruangStore.js` |
| Data loket | `stores/loketStore.js` |
| Sidebar/Navigation | `stores/navItems1.ts` + `components/layout/SideBar.vue` |
| Design tokens | `assets/scss/_colors.scss`, `_variables.scss` |
| Database schema | `server/utils/configDb.ts` |
| User management | `server/api/users/` + `server/utils/userSync.ts` |
| WebSocket | `composables/useWebSocket.ts` |
| Thermal print | `composables/useThermalPrint.ts` |
---
## 🚀 Development Commands
```bash
npm run dev # Start dev server
npm run dev:https # Start with HTTPS + host 0.0.0.0
npm run build # Production build
npm run preview # Preview production build
npm run test # Run Vitest
docker compose up -d # Deploy production
```
@@ -0,0 +1,173 @@
---
name: web-antrean-coding-standards
description: Aturan dasar pengkodean dan konvensi untuk project web-antrean berbasis Nuxt 3, Vue 3, TypeScript, Pinia, dan Vuetify.
---
# 📜 Web Antrean Coding Standards
Skill ini berisi standar pengkodean wajib yang harus diikuti saat memodifikasi atau membuat kode baru di project `web-antrean`.
## 1. Komentar & Dokumentasi (WAJIB)
- **Tiap Fungsi/Method/Computed/Watcher HARUS dikomentari.**
- Gunakan JSDoc (`/** ... */`) untuk mendeskripsikan tujuan fungsi.
- Jika fungsi kompleks atau memiliki parameter spesifik, gunakan tag `@param`, `@returns`.
- Berikan komentar singkat (`//`) di dalam body fungsi jika ada logika yang tidak langsung jelas (non-obvious).
```typescript
// ✅ CONTOH BENAR
/**
* Menghitung total antrean aktif untuk loket tertentu.
* @param {string} loketId - ID unik dari loket.
* @returns {number} Jumlah antrean berstatus 'menunggu'.
*/
const countActiveQueue = (loketId: string): number => {
// Hanya hitung pasien yang belum dipanggil atau di-skip
return allPatients.value.filter(p => p.loketId === loketId && p.status === 'menunggu').length;
};
```
## 2. Struktur Komponen (Vue 3 / Nuxt 3)
- Gunakan `<script setup lang="ts">`. Sebisa mungkin gunakan TypeScript.
- **Urutan blok dalam Vue SFC (Single File Component):**
1. `<template>`
2. `<script setup lang="ts">`
3. `<style scoped>` (Lebih disukai `<style scoped lang="scss">`)
- **Organisasi dalam `<script setup>`:**
1. `imports` (Vue, composables, stores, types)
2. `defineProps`, `defineEmits`, `defineExpose`
3. State variables (`ref`, `reactive`)
4. Computed properties (`computed`)
5. Watchers (`watch`, `watchEffect`)
6. Functions / Methods
7. Lifecycle hooks (`onMounted`, `onUnmounted`)
## 3. Composables (`composables/`)
- Nama file dan nama fungsi harus diawali dengan `use` (contoh: `useQueueSync.ts`, `export const useQueueSync = () => {}`).
- Fokuskan composable pada satu fitur spesifik (contoh: `useThermalPrint` khusus print, `useWebSocket` khusus WS).
- Jika mengembalikan state reaktif, kembalikan objek berisi `ref` atau `computed`.
## 4. Pinia Stores (`stores/`)
- Gunakan format Setup Store (seperti composable) alih-alih Option Store.
- Berikan tipe eksplisit (TypeScript) untuk state array/object yang kompleks.
- Jangan melakukan mutasi data store lain secara langsung, gunakan actions.
- **PENTING:** Jika ada store baru, daftarkan di `PINIA_STORE_KEYS` di `app.vue` jika perlu di-persist dan bersihkan localStorage jika `STORE_SCHEMA_VERSION` naik.
```typescript
// ✅ CONTOH SETUP STORE
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import type { QueuePatient } from '~/types/queue';
export const useMyStore = defineStore('myStore', () => {
// State
const patients = ref<QueuePatient[]>([]);
// Computed
const activePatients = computed(() => patients.value.filter(p => p.status === 'menunggu'));
/**
* Action untuk menambah pasien
* @param {QueuePatient} newPatient
*/
const addPatient = (newPatient: QueuePatient) => {
patients.value.push(newPatient);
};
return { patients, activePatients, addPatient };
});
```
## 5. Integrasi API & Error Handling
- Gunakan `useRuntimeConfig().public` untuk base URL (contoh: `verificationApiBaseUrl`).
- **JANGAN** hardcode IP address (seperti `10.10.123.140`) di dalam kode komponen atau composable. Selalu ambil dari config.
- Gunakan composable wrapper atau `useFetch`/`$fetch` dari Nuxt.
- Harus ada blok `try...catch` untuk setiap pemanggilan API, dengan logging error yang jelas (minimal `console.error`).
## 6. Styling
- Gunakan class Vuetify sebanyak mungkin (`d-flex`, `mt-4`, `text-h5`, `text-primary`).
- Jika butuh custom style, gunakan `<style scoped lang="scss">` dan manfaatkan variabel dari `assets/scss/_colors.scss` atau `_variables.scss` jika memungkinkan.
## 7. Naming Conventions
- **Komponen:** PascalCase (contoh: `PatientCard.vue`, `AdminKlinik.vue`).
- **File/Folder (selain komponen):** camelCase atau kebab-case tergantung standar folder (Nuxt standard: pages biasanya kebab-case atau PascalCase jika spesifik view).
- **Fungsi/Variabel:** camelCase (contoh: `fetchPatients`, `isActive`).
- **Tipe Data (Types/Interfaces):** PascalCase (contoh: `QueuePatient`).
## 8. Aturan Refactoring (Refactoring Rules)
Saat melakukan refactoring (terutama mengkonversi `.js` lama ke `.ts` atau memecah file besar), ikuti aturan wajib berikut:
- **Incremental Refactoring (Bertahap):** Jangan merombak seluruh file raksasa (seperti `queueStore` atau `checkIn.vue`) sekaligus. Pisahkan sebagian logika (contoh: pisahkan API call ke `useQueueAPI`, WS ke `useQueueSync`) secara bertahap untuk meminimalisasi *blast radius* (kerusakan beruntun).
- **Isolasi Logika (Separation of Concerns):** Komponen `.vue` hanya boleh berisi logika UI (presentational). Logika bisnis berat harus dipindah ke `composables/` atau `stores/`.
- **Backward Compatibility (Kompabilitas Mundur):** Jika mengubah struktur *store* yang banyak dipakai (contoh: `masterStore`), jangan langsung menghapus properti lama. Buat *proxy/delegate* ke store baru sampai semua komponen selesai di-update.
- **No Implicit `any`:** Saat memigrasi `.js` ke `.ts`, WAJIB mendefinisikan tipe data/interface yang jelas di direktori `types/`. Sebisa mungkin hindari penggunaan tipe `any`.
- **Pertahankan Business Logic:** Refactoring HANYA mengubah struktur/kebersihan kode, **BUKAN** mengubah alur bisnis kecuali diminta secara spesifik. Pastikan fitur safety seperti *polling fallback 30s* atau *auto-reconnect WS* tidak terhapus tanpa sengaja.
- **Dokumentasikan Perubahan:** Jika refactor mengubah alur arsitektur secara signifikan, selalu catat di `docs/DEVLOG.md` dan perbarui `AGENTS.md`.
## 9. Reusability & Prinsip DRY (Don't Repeat Yourself)
- Jika suatu fungsi, blok UI, atau logika API dipakai di lebih dari dua tempat, **wajib** diekstrak menjadi `Composable` (untuk logika), `Component` (untuk UI), atau file utility di `utils/`.
- Jangan melakukan copy-paste kode panjang antar halaman.
## 10. Penggunaan State (Lokal vs Global)
- **Lokal (`ref` / `reactive`):** Gunakan untuk state yang hanya hidup di satu komponen (contoh: status loading tombol, form input sementara, toggle modal).
- **Global (Pinia):** Gunakan HANYA untuk state yang perlu dibagikan lintas halaman/komponen (contoh: data pasien antrean berjalan, data user login, master data klinik).
## 11. Props & Emits (Komunikasi Antar Komponen)
- Definisikan `props` secara eksplisit menggunakan `defineProps<{ ... }>()` dengan tipe data yang ketat.
- Gunakan `defineEmits<{ ... }>()` untuk event child-to-parent. Hindari memanipulasi prop secara langsung dari child komponen (mutate prop mutation).
## 12. Manajemen Siklus Hidup (Lifecycle & Cleanup)
- Jika menginisiasi *event listener* global (`window.addEventListener`), *timer* (`setInterval`), atau *WebSocket subscription* di `onMounted`, Anda **WAJIB** membersihkannya di `onUnmounted`.
- Kegagalan melakukan *cleanup* akan menyebabkan kebocoran memori (memory leak) terutama di aplikasi SPA (Single Page Application).
## 13. Navigasi & Routing (Nuxt 3)
- Gunakan `<NuxtLink>` untuk navigasi internal di template. JANGAN menggunakan tag `<a>` biasa karena akan memicu *full page reload*.
- Untuk navigasi terprogram (programmatic) di dalam script, gunakan `navigateTo()` atau `useRouter().push()`.
## 14. Keamanan Dasar (Security Practices)
- **JANGAN PERNAH** meletakkan token JWT, password, atau secret key secara hardcode di sisi klien.
- Baca variabel sensitif melalui `useRuntimeConfig()` yang disuplai dari `.env`.
- Selalu percayakan validasi otorisasi di sisi *server* (middleware/Nitro), jangan hanya menyembunyikan tombol di UI.
## 15. Penanganan Feedback UI (Error & Success Boundaries)
- Jangan biarkan error API tertelan diam-diam (`swallowed error`).
- Selalu tampilkan umpan balik ke pengguna jika terjadi kegagalan (misalnya menggunakan komponen `AppSnackbar` yang sudah ada).
- Berikan indikator visual (spinner/loading state) saat memanggil API.
## 16. Magic Numbers & Magic Strings
- Hindari hardcode angka atau string spesifik yang memiliki arti logika bisnis di tengah komponen (contoh: `if (status === 3)` atau `if (role === 'superadmin')`).
- Pindahkan ke file konstan di `constants/` atau jadikan `Enum` di direktori `types/`.
## 17. Penggunaan Proxy API (Bypass CORS)
- Untuk endpoint eksternal yang terhalang CORS (seperti API Visit atau API Antrian asli), panggil selalu via *Proxy Route* bawaan Nuxt Nitro (contoh: panggil `/visit-api/...` atau `/klinik-api/...`).
- Jangan mencoba menebak atau merubah setting header CORS di sisi *client* Vue.
## 18. Konsistensi Design System
- DILARANG melakukan *hardcode* warna HEX/RGB di tag `<style>` (contoh: `color: #ff0000;`).
- Selalu gunakan SCSS variables dari `assets/scss/_colors.scss` (contoh: `color: var(--color-primary);`) atau gunakan *utility classes* bawaan Vuetify.
## 19. Optimasi Performa Vue (v-memo, v-once, Lazy)
- Untuk *list rendering* yang sangat panjang (ratusan pasien), pertimbangkan penggunaan pagination, infinite scroll, atau virtual scrolling.
- Gunakan `LazyComponent` (prefix `Lazy` di Nuxt) untuk modal atau komponen berat yang tidak langsung muncul di layar.
## 20. Penanganan Koneksi Real-time (WebSocket)
- Pastikan ada mekanisme **Auto-Reconnect** dengan *backoff delay*.
- Selalu miliki **Polling Fallback**. Jika WS mati total, sistem harus fallback mengambil data via HTTP `setInterval` setiap 30 detik agar layar antrean tidak *stuck*.
- Cegah duplikasi data pesan (implementasi *deduplication logic* berdasarkan ID unik pesan).
## 21. Isolasi State per Entitas (Loket / Ruang)
- Jangan gunakan variabel state tunggal untuk sesuatu yang berjalan paralel.
- Contoh buruk: `currentPatient` (berisiko loket A tertukar pasien dengan loket B).
- Contoh benar: `currentPatientPerLoket: Record<string, Patient>` (diakses dengan ID loket).
## 22. Format Koding & Tanda Baca
- Ikuti standar *formatting* yang ada: indentasi 2 spasi, gunakan *single quotes* (`'`) untuk string JS/TS, dan selalu gunakan tanda titik koma (`;`) di akhir *statement* script.
## 23. Standar Pengujian (Testing Readiness)
- Setiap logika kalkulasi yang rumit (contoh: perhitungan estimasi waktu tunggu, filter spesialisasi) harus ditulis murni (pure function) agar mudah di-unit test nantinya (Vitest). Jangan mencampur logika ini dengan DOM manipulation.
## 24. Git Workflow & Commit Messages
- Tulis pesan *commit* yang jelas dan bermakna mengikuti pola *Conventional Commits*:
- `feat: ...` (fitur baru)
- `fix: ...` (perbaikan bug)
- `refactor: ...` (perubahan struktur tanpa ubah fitur)
- `docs: ...` (pembaruan dokumentasi)
- Satu *commit* sebaiknya fokus pada satu perubahan logika/fitur saja.
+65
View File
@@ -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 = 3;
const VERSION_KEY = 'app-store-version';
// Daftar semua localStorage key yang dikelola oleh Pinia stores
const PINIA_STORE_KEYS = [
'queue-store-state',
'clinic-store-state',
'doctor-store',
'loket-store-state',
'ruang-store-state',
'klinikruang-store-state',
'antrean-masuk-screen-store',
'anjungan-store',
'screen-store',
'penunjang-store',
];
onMounted(() => {
try {
if (typeof window === 'undefined' || !window.localStorage) return;
const savedVersion = parseInt(localStorage.getItem(VERSION_KEY) || '0', 10);
if (savedVersion < STORE_SCHEMA_VERSION) {
// Versi lama terdeteksi → hapus semua cache Pinia sekaligus
console.log(`[Migration] Store schema v${savedVersion} → v${STORE_SCHEMA_VERSION}. Clearing all caches...`);
PINIA_STORE_KEYS.forEach(key => localStorage.removeItem(key));
localStorage.setItem(VERSION_KEY, String(STORE_SCHEMA_VERSION));
console.log('[Migration] Done. App will fetch fresh data from API.');
}
} catch (e) {
// Ignore storage errors (e.g., private browsing mode)
}
});
</script>
<template>
<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>
+28
View File
@@ -0,0 +1,28 @@
// variables for Grand Paviliun (Eksekutif)
.theme-grand-paviliun {
--gp-primary: #3F51B5; // Indigo header
--gp-primary-dark: #303F9F;
--gp-bg: #F5F7FA; // Light grayish blue background
--gp-surface: #FFFFFF;
--gp-text-primary: #1F2937;
--gp-text-secondary: #6B7280;
--gp-border: #E5E7EB;
--gp-success: #10B981; // Selesai
--gp-danger: #EF4444; // Pending
--gp-warning: #F59E0B;
--gp-info: #3B82F6;
// Custom colors for specific tags
--gp-room-number: #F97316; // Orange text for RUANG 02
background-color: var(--gp-bg);
min-height: 100vh;
font-family: 'Inter', 'Roboto', sans-serif;
}
// Utility classes
.gp-text-primary { color: var(--gp-text-primary); }
.gp-text-secondary { color: var(--gp-text-secondary); }
.gp-font-bold { font-weight: 700; }
.gp-font-semibold { font-weight: 600; }
@@ -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>
+63
View File
@@ -0,0 +1,63 @@
<template>
<div class="gp-queue-list-container">
<div class="list-header">
<span class="title">DAFTAR ANTRIAN</span>
<span class="count-badge">{{ patients.length }}</span>
</div>
<div class="list-body">
<GpQueueListItem
v-for="patient in patients"
:key="patient.id || patient.noAntrian"
:queue-number="patient.noAntrian"
:status="patient.status"
:is-active="patient.isActive"
@view-detail="$emit('view-detail', patient)"
@process-patient="$emit('process-patient', patient)"
/>
</div>
</div>
</template>
<script setup>
import GpQueueListItem from './GpQueueListItem.vue';
defineProps({
patients: { type: Array, default: () => [] }
});
defineEmits(['view-detail', 'process-patient']);
</script>
<style scoped>
.gp-queue-list-container {
display: flex;
flex-direction: column;
flex: 1;
background: white;
min-height: 200px;
}
.list-header {
padding: 12px 16px;
background: #EEF2F6; /* Light blue-gray from design */
border-bottom: 1px solid var(--gp-border, #e5e7eb);
display: flex;
align-items: center;
gap: 8px;
}
.title {
font-size: 10px;
font-weight: 700;
color: var(--gp-text-secondary, #6b7280);
}
.count-badge {
background: #D1D5DB; /* Gray */
color: white;
border-radius: 12px;
padding: 2px 8px;
font-size: 10px;
font-weight: 700;
}
.list-body {
overflow-y: auto;
}
</style>
@@ -0,0 +1,47 @@
<template>
<div :class="['gp-queue-list-item', { active: isActive }]">
<div class="item-left">
<span class="queue-number">{{ queueNumber }}</span>
</div>
<div class="item-right">
<GpStatusBadge v-if="status && !isActive" :status="status" />
<v-btn v-if="!isActive" icon="mdi-play" size="x-small" variant="text" color="success" class="ml-1" @click="$emit('process-patient')" />
<v-btn v-if="isActive" icon="mdi-eye" size="x-small" variant="text" color="#3F51B5" @click="$emit('view-detail')" />
</div>
</div>
</template>
<script setup>
import GpStatusBadge from './GpStatusBadge.vue';
defineProps({
queueNumber: { type: String, required: true },
status: { type: String, default: '' },
isActive: { type: Boolean, default: false }
});
defineEmits(['view-detail', 'process-patient']);
</script>
<style scoped>
.gp-queue-list-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--gp-border, #e5e7eb);
background-color: var(--gp-surface, #fff);
transition: background-color 0.2s ease;
}
.gp-queue-list-item:last-child {
border-bottom: none;
}
.gp-queue-list-item.active {
background-color: #E3EBFF; /* Light blue highlighting active patient */
}
.queue-number {
font-size: 14px;
font-weight: 700;
color: var(--gp-text-primary, #1f2937);
}
</style>
+95
View File
@@ -0,0 +1,95 @@
<template>
<div class="gp-room-column">
<div class="column-header">
<v-icon size="20" class="mr-2" color="white">mdi-domain</v-icon>
<span class="column-title">{{ clinicName.toUpperCase() }}</span>
</div>
<div class="column-content">
<template v-if="currentPatient || queuePatients.length > 0">
<GpCurrentPatient
v-if="currentPatient"
:queue-number="currentPatient.noAntrian"
:room-name="currentPatient.roomIndicator"
:room-options="roomOptions"
@pemeriksaan-awal="$emit('pemeriksaan-awal', currentPatient, $event)"
@panggil-pemeriksaan="$emit('panggil-pemeriksaan', currentPatient, $event)"
@action-pending="$emit('action-pending', currentPatient)"
@action-selesai="$emit('action-selesai', currentPatient)"
/>
<GpQueueList
:patients="queuePatients"
@view-detail="p => $emit('view-detail', p)"
@process-patient="p => $emit('process-patient', p)"
/>
</template>
<div v-else class="empty-column-state">
<v-icon size="48" color="#D1D5DB" class="mb-4">mdi-account-off</v-icon>
<div class="empty-text">TIDAK ADA PASIEN YANG DIPROSES</div>
</div>
</div>
</div>
</template>
<script setup>
import GpCurrentPatient from './GpCurrentPatient.vue';
import GpQueueList from './GpQueueList.vue';
defineProps({
clinicName: { type: String, required: true },
currentPatient: { type: Object, default: null },
queuePatients: { type: Array, default: () => [] },
roomOptions: { type: Array, default: () => [] }
});
defineEmits(['pemeriksaan-awal', 'panggil-pemeriksaan', 'view-detail', 'action-pending', 'action-selesai', 'process-patient']);
</script>
<style scoped>
.gp-room-column {
width: 380px;
min-width: 380px;
background: var(--gp-bg, #F5F7FA);
border-radius: 4px;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
margin-right: 16px;
}
.column-header {
background: var(--gp-primary, #3F51B5);
color: white;
padding: 12px 16px;
display: flex;
align-items: center;
}
.column-title {
font-size: 13px;
font-weight: 700;
letter-spacing: 0.5px;
}
.column-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: white; /* Make the whole column body white */
}
.empty-column-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px;
text-align: center;
background: white;
}
.empty-text {
font-size: 11px;
font-weight: 700;
color: var(--gp-text-secondary, #6b7280);
}
</style>
@@ -0,0 +1,71 @@
<template>
<div class="gp-room-monitor-card" :class="{ 'is-busy': isBusy }">
<div class="card-header">
<span class="room-name">{{ roomName }}</span>
<span class="room-status" :class="isBusy ? 'text-danger' : 'text-success'">
{{ isBusy ? 'SIBUK' : 'TERSEDIA' }}
</span>
</div>
<div class="card-body">
<span v-if="isBusy" class="active-patient">{{ activePatient }} - {{ clinicName }}</span>
<span v-else class="waiting-text">Menunggu pasien...</span>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
roomName: { type: String, required: true },
activePatient: { type: String, default: null }, // e.g. "AI002"
clinicName: { type: String, default: '' },
});
const isBusy = computed(() => !!props.activePatient);
</script>
<style scoped>
.gp-room-monitor-card {
background: var(--gp-surface, #fff);
border: 1px solid var(--gp-border, #e5e7eb);
border-left: 4px solid var(--gp-success, #10b981);
border-radius: 2px;
padding: 12px;
margin-bottom: 12px;
box-shadow: 0 1px 2px rgba(0,0,0,0.02);
}
.gp-room-monitor-card.is-busy {
border-left-color: var(--gp-room-number, #F97316); /* Orange */
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.room-name {
font-size: 13px;
font-weight: 700;
color: var(--gp-text-primary, #1f2937);
}
.room-status {
font-size: 10px;
font-weight: 700;
}
.text-success { color: var(--gp-success, #10b981); }
.text-danger { color: var(--gp-room-number, #F97316); } /* Matching image orange */
.card-body {
font-size: 13px;
}
.active-patient {
color: var(--gp-primary, #3F51B5);
font-weight: 700;
}
.waiting-text {
color: var(--gp-text-secondary, #6b7280);
font-style: italic;
font-size: 12px;
}
</style>
@@ -0,0 +1,40 @@
<template>
<span :class="['gp-badge', badgeClass]">
{{ label }}
</span>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
status: { type: String, required: true },
text: { type: String, default: '' }
});
const label = computed(() => props.text || props.status.toUpperCase());
const badgeClass = computed(() => {
const s = props.status.toLowerCase();
if (s === 'pending' || s === 'sibuk') return 'gp-badge-danger';
if (s === 'selesai' || s === 'tersedia' || s === 'proses') return 'gp-badge-success';
return 'gp-badge-default';
});
</script>
<style scoped>
.gp-badge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px 6px;
border-radius: 2px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
color: white;
}
.gp-badge-danger { background-color: var(--gp-danger, #ef4444); }
.gp-badge-success { background-color: var(--gp-success, #10b981); }
.gp-badge-default { background-color: var(--gp-text-secondary, #6b7280); }
</style>
@@ -0,0 +1,114 @@
<script setup>
/**
* DialogDetailEvent Dialog untuk menampilkan detail jadwal dokter
* yang diklik di kalender. Mendukung tampilan Rutin dan Adhoc.
* Menggunakan v-model pattern untuk kontrol buka/tutup dialog dari parent.
*/
const props = defineProps({
/** Kontrol buka/tutup dialog (v-model) */
modelValue: { type: Boolean, default: false },
/** Data event yang sedang ditampilkan */
event: { type: Object, default: null },
});
const emit = defineEmits(['update:modelValue', 'delete']);
/** Computed v-model proxy untuk dialog visibility */
const dialogOpen = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
});
/**
* Emit event delete ke parent untuk menghapus jadwal.
*/
const handleDelete = () => {
emit('delete', props.event?.id);
dialogOpen.value = false;
};
</script>
<template>
<v-dialog v-model="dialogOpen" max-width="420px">
<v-card rounded="lg" class="pa-0">
<v-card-title class="pa-5" :class="event?.tipe === 'Adhoc' ? 'bg-secondary' : 'bg-primary'"
style="color:white;">
<div class="d-flex justify-space-between align-center w-100">
<div class="d-flex align-center gap-2">
<v-icon>{{ event?.tipe === 'Adhoc' ? 'mdi-calendar-clock' : 'mdi-calendar-sync'
}}</v-icon>
<span class="ml-2 text-h6 font-weight-bold">Jadwal {{ event?.tipe }}</span>
</div>
<v-btn icon="mdi-close" variant="text" size="small" style="color:white;"
@click="dialogOpen = false" />
</div>
</v-card-title>
<v-card-text class="pa-6" v-if="event">
<v-list density="compact" class="pa-0">
<v-list-item class="px-0">
<template #prepend>
<v-icon color="primary" size="small" class="mr-3">mdi-hospital-building</v-icon>
</template>
<div class="text-caption text-grey-darken-1">Poli / Klinik</div>
<div class="text-body-2 font-weight-bold">{{ event.poli }}</div>
</v-list-item>
<v-divider class="my-2" />
<v-list-item class="px-0">
<template #prepend>
<v-icon color="primary" size="small" class="mr-3">mdi-doctor</v-icon>
</template>
<div class="text-caption text-grey-darken-1">Dokter</div>
<div class="text-body-2 font-weight-bold">{{ event.dokter }}</div>
</v-list-item>
<v-divider class="my-2" />
<v-list-item class="px-0">
<template #prepend>
<v-icon color="primary" size="small" class="mr-3">mdi-calendar</v-icon>
</template>
<div class="text-caption text-grey-darken-1">Tanggal</div>
<div class="text-body-2 font-weight-bold">{{ event.tanggal }}</div>
</v-list-item>
<!-- Rutin: tampilkan hari -->
<template v-if="event.tipe === 'Rutin' && event.hari">
<v-divider class="my-2" />
<v-list-item class="px-0">
<template #prepend>
<v-icon color="primary" size="small" class="mr-3">mdi-calendar-week</v-icon>
</template>
<div class="text-caption text-grey-darken-1">Hari Praktek</div>
<div class="text-body-2 font-weight-bold">{{ event.hari }}</div>
</v-list-item>
</template>
<!-- Adhoc: tampilkan shift -->
<template v-if="event.tipe === 'Adhoc' && event.shift">
<v-divider class="my-2" />
<v-list-item class="px-0">
<template #prepend>
<v-icon color="secondary" size="small" class="mr-3">mdi-clock-outline</v-icon>
</template>
<div class="text-caption text-grey-darken-1">Waktu</div>
<div class="text-body-2 font-weight-bold">{{ event.shift }}</div>
</v-list-item>
</template>
</v-list>
</v-card-text>
<v-divider />
<v-card-actions class="pa-5 justify-space-between">
<v-btn color="error" variant="text" prepend-icon="mdi-trash-can-outline" @click="handleDelete">
Hapus
</v-btn>
<v-btn color="primary" variant="flat" @click="dialogOpen = false">Tutup</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
@@ -0,0 +1,336 @@
<script setup>
/**
* DialogTambahJadwal Dialog form untuk menambahkan jadwal dokter baru.
* Mendukung dua tipe jadwal: Rutin (mingguan berulang) dan Adhoc (satu kali).
* Menggunakan v-model pattern untuk kontrol buka/tutup dialog dari parent.
*/
const DAYS_OF_WEEK = ['Minggu', 'Senin', 'Selasa', 'Rabu', 'Kamis', 'Jumat', 'Sabtu'];
const props = defineProps({
/** Kontrol buka/tutup dialog (v-model) */
modelValue: { type: Boolean, default: false },
/** Daftar klinik untuk referensi nama poli */
clinicList: { type: Array, default: () => [] },
/** ID poli yang sedang dipilih */
selectedPoli: { type: [String, Number, null], default: null },
/** Nama dokter yang sedang dipilih */
selectedDoctor: { type: [String, null], default: null },
/** Tanggal yang diklik di kalender (opsional, sebagai default adhoc) */
clickedDate: { type: [Date, null], default: null },
/** Mode tampilan saat ini ('list' atau 'calendar') */
viewMode: { type: String, default: 'list' },
/** Daftar jadwal saat ini (untuk preview kalender) */
jadwalList: { type: Array, default: () => [] },
});
const emit = defineEmits(['update:modelValue', 'save']);
/** Computed v-model proxy untuk dialog visibility */
const dialogOpen = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
});
/** State form tambah jadwal */
const form = reactive({
poliId: null,
dokter: null,
tipe: 'Rutin',
hari: '',
shifts: [{ jamMulai: '', jamSelesai: '' }],
adhocTanggal: '',
adhocShifts: [{ jamMulai: '', jamSelesai: '' }],
});
/** Computed daftar dokter berdasarkan poli yang dipilih */
const availableDoctors = computed(() => {
if (!form.poliId) return [];
const clinic = props.clinicList.find(c => c.id === form.poliId);
return clinic?.doctors || [];
});
/** Error validasi form */
const formErrors = ref({});
/**
* Format tanggal ke string YYYY-MM-DD.
* @param {Date} date
* @returns {string}
*/
const toDateStr = (date) => {
const d = new Date(date);
const tzOffset = d.getTimezoneOffset() * 60000;
return new Date(d.getTime() - tzOffset).toISOString().slice(0, 10);
};
/** Reset form saat dialog dibuka */
watch(() => props.modelValue, (open) => {
if (open) {
if (props.viewMode === 'calendar') {
form.poliId = props.selectedPoli || null;
form.dokter = props.selectedDoctor || null;
} else {
form.poliId = null;
form.dokter = null;
}
form.tipe = 'Rutin';
form.hari = '';
form.shifts = [{ jamMulai: '', jamSelesai: '' }];
formErrors.value = {};
if (props.clickedDate) {
const dateStr = toDateStr(props.clickedDate);
form.adhocTanggal = dateStr;
form.adhocShifts = [{ jamMulai: '08:00', jamSelesai: '12:00' }];
} else {
const dateStr = toDateStr(new Date());
form.adhocTanggal = dateStr;
form.adhocShifts = [{ jamMulai: '08:00', jamSelesai: '12:00' }];
}
}
});
/**
* Validasi form sebelum simpan.
* @returns {boolean} true jika valid
*/
const validateForm = () => {
const errors = {};
if (!form.poliId) errors.poli = 'Pilih poli terlebih dahulu';
if (!form.dokter) errors.dokter = 'Pilih dokter terlebih dahulu';
if (form.tipe === 'Rutin') {
if (!form.hari) errors.hari = 'Pilih hari kerja';
form.shifts.forEach((shift, idx) => {
if (!shift.jamMulai) errors[`shift_${idx}_jamMulai`] = 'Pilih jam mulai';
if (!shift.jamSelesai) errors[`shift_${idx}_jamSelesai`] = 'Pilih jam selesai';
if (shift.jamMulai && shift.jamSelesai && shift.jamMulai >= shift.jamSelesai) {
errors[`shift_${idx}_jamSelesai`] = 'Harus setelah jam mulai';
}
});
} else {
if (!form.adhocTanggal) errors.adhocTanggal = 'Pilih tanggal';
form.adhocShifts.forEach((shift, idx) => {
if (!shift.jamMulai) errors[`adhocShift_${idx}_jamMulai`] = 'Pilih jam mulai';
if (!shift.jamSelesai) errors[`adhocShift_${idx}_jamSelesai`] = 'Pilih jam selesai';
if (shift.jamMulai && shift.jamSelesai && shift.jamMulai >= shift.jamSelesai) {
errors[`adhocShift_${idx}_jamSelesai`] = 'Harus setelah jam mulai';
}
});
}
formErrors.value = errors;
return Object.keys(errors).length === 0;
};
/**
* Menangani submit form validasi lalu emit data ke parent.
*/
const handleSave = () => {
if (!validateForm()) return;
const poliName = props.clinicList.find(c => c.id === form.poliId)?.title ?? '';
const emittedData = [];
if (form.tipe === 'Rutin') {
form.shifts.forEach((shift, index) => {
emittedData.push({
id: Date.now() + index,
poliId: form.poliId,
poliName,
dokter: form.dokter,
tipe: 'Rutin',
hari: [form.hari],
jamMulai: shift.jamMulai || null,
jamSelesai: shift.jamSelesai || null,
});
});
} else {
form.adhocShifts.forEach((shift, index) => {
emittedData.push({
id: Date.now() + index,
poliId: form.poliId,
poliName,
dokter: form.dokter,
tipe: 'Adhoc',
mulai: `${form.adhocTanggal}T${shift.jamMulai}:00`,
selesai: `${form.adhocTanggal}T${shift.jamSelesai}:00`,
});
});
}
emit('save', emittedData);
dialogOpen.value = false;
};
import MiniCalendarPreview from './MiniCalendarPreview.vue';
</script>
<template>
<v-dialog v-model="dialogOpen" max-width="520px" persistent>
<v-card rounded="lg" class="pa-0">
<!-- Header -->
<v-card-title class="bg-primary text-white pa-5">
<div class="d-flex justify-space-between align-center w-100">
<div class="d-flex align-center gap-2">
<v-icon>mdi-calendar-plus</v-icon>
<span class="text-h6 font-weight-bold">Tambah Jadwal Dokter</span>
</div>
<v-btn icon="mdi-close" variant="text" size="small" class="text-white"
@click="dialogOpen = false" />
</div>
</v-card-title>
<v-card-text class="pa-6">
<!-- Tipe Jadwal -->
<div class="text-caption text-grey-darken-1 mb-2 font-weight-medium">Tipe Jadwal</div>
<v-btn-toggle v-model="form.tipe" mandatory color="primary" variant="outlined" class="mb-5 w-100"
divided>
<v-btn value="Rutin" class="flex-grow-1">
<v-icon start>mdi-calendar-sync</v-icon>
Rutin
</v-btn>
<v-btn value="Adhoc" class="flex-grow-1">
<v-icon start>mdi-calendar-clock</v-icon>
Adhoc
</v-btn>
</v-btn-toggle>
<!-- Info poli & dokter -->
<v-row class="mb-4">
<v-col cols="12" sm="12">
<div class="text-caption text-grey mb-1">Poli / Klinik <span class="text-error">*</span></div>
<v-select v-model="form.poliId" :items="clinicList" item-title="title" item-value="id"
density="compact" variant="outlined" hide-details="auto" placeholder="Pilih Poli"
prepend-inner-icon="mdi-hospital-building" @update:model-value="form.dokter = null"
:error-messages="formErrors.poli" />
</v-col>
<v-col cols="12" sm="12">
<div class="text-caption text-grey mb-1">Dokter <span class="text-error">*</span></div>
<v-select v-model="form.dokter" :items="availableDoctors" density="compact" variant="outlined"
hide-details="auto" placeholder="Pilih Dokter" prepend-inner-icon="mdi-doctor"
:disabled="!form.poliId" :error-messages="formErrors.dokter" />
</v-col>
</v-row>
<!-- Form Rutin -->
<template v-if="form.tipe === 'Rutin'">
<div class="text-caption text-grey-darken-1 mb-3 font-weight-medium">Pilih Hari Kerja</div>
<div class="d-flex flex-wrap gap-2 mb-1">
<v-chip class="mr-1" v-for="day in DAYS_OF_WEEK" :key="day"
:color="form.hari === day ? 'primary' : 'primary'"
:variant="form.hari === day ? 'flat' : 'outlined'" size="small" style="cursor:pointer;"
@click="form.hari = day">
{{ day }}
</v-chip>
</div>
<div v-if="formErrors.hari" class="text-caption text-error mb-4">{{ formErrors.hari }}</div>
<MiniCalendarPreview v-if="form.poliId && form.dokter" :tipe="form.tipe" :hari="form.hari"
:adhoc-tanggal="form.adhocTanggal" :jadwal-list="jadwalList" :poli-id="form.poliId"
:dokter="form.dokter" />
<!-- Jam required -->
<div v-for="(shift, index) in form.shifts" :key="index" class="d-flex align-center gap-4 mt-4">
<div class="flex-grow-1">
<v-row>
<v-col cols="6">
<div class="text-caption text-grey mb-1">Jam Mulai</div>
<v-text-field v-model="shift.jamMulai" type="time" variant="outlined"
density="compact" hide-details required
:error-messages="formErrors[`shift_${index}_jamMulai`]" />
<div v-if="formErrors[`shift_${index}_jamMulai`]"
class="text-caption text-error mt-1">{{ formErrors[`shift_${index}_jamMulai`] }}
</div>
</v-col>
<v-col cols="6">
<div class="text-caption text-grey mb-1">Jam Selesai</div>
<v-text-field v-model="shift.jamSelesai" type="time" variant="outlined"
density="compact" hide-details required
:error-messages="formErrors[`shift_${index}_jamSelesai`]" />
<div v-if="formErrors[`shift_${index}_jamSelesai`]"
class="text-caption text-error mt-1">{{ formErrors[`shift_${index}_jamSelesai`]
}}</div>
</v-col>
</v-row>
</div>
<div class="mt-5" v-if="form.shifts.length > 1">
<v-btn icon="mdi-trash-can-outline" variant="text" color="error" size="small"
@click="form.shifts.splice(index, 1)" />
</div>
</div>
<div class="mt-4">
<v-btn variant="outlined" color="primary" size="small" class="text-none"
@click="form.shifts.push({ jamMulai: '', jamSelesai: '' })">
+ Tambah Jadwal {{ form.hari ? `Hari ${form.hari}` : '' }}
</v-btn>
</div>
</template>
<!-- Form Adhoc -->
<template v-else>
<v-row class="mb-2">
<v-col cols="12">
<v-text-field v-model="form.adhocTanggal" label="Tanggal" type="date" variant="outlined"
density="compact" prepend-inner-icon="mdi-calendar" hide-details="auto"
:error-messages="formErrors.adhocTanggal" />
</v-col>
<v-col cols="12">
<MiniCalendarPreview v-if="form.poliId && form.dokter" :tipe="form.tipe" :hari="form.hari"
:adhoc-tanggal="form.adhocTanggal" :jadwal-list="jadwalList" :poli-id="form.poliId"
:dokter="form.dokter" />
</v-col>
</v-row>
<div v-for="(shift, index) in form.adhocShifts" :key="index" class="d-flex align-center gap-4 mt-2">
<div class="flex-grow-1">
<v-row>
<v-col cols="6">
<div class="text-caption text-grey mb-1">Jam Mulai</div>
<v-text-field v-model="shift.jamMulai" type="time" variant="outlined"
density="compact" hide-details required
:error-messages="formErrors[`adhocShift_${index}_jamMulai`]" />
<div v-if="formErrors[`adhocShift_${index}_jamMulai`]"
class="text-caption text-error mt-1">{{
formErrors[`adhocShift_${index}_jamMulai`] }}</div>
</v-col>
<v-col cols="6">
<div class="text-caption text-grey mb-1">Jam Selesai</div>
<v-text-field v-model="shift.jamSelesai" type="time" variant="outlined"
density="compact" hide-details required
:error-messages="formErrors[`adhocShift_${index}_jamSelesai`]" />
<div v-if="formErrors[`adhocShift_${index}_jamSelesai`]"
class="text-caption text-error mt-1">{{
formErrors[`adhocShift_${index}_jamSelesai`] }}</div>
</v-col>
</v-row>
</div>
<div class="mt-5" v-if="form.adhocShifts.length > 1">
<v-btn icon="mdi-trash-can-outline" variant="text" color="error" size="small"
@click="form.adhocShifts.splice(index, 1)" />
</div>
</div>
<div class="mt-4">
<v-btn variant="outlined" color="primary" size="small" class="text-none"
@click="form.adhocShifts.push({ jamMulai: '', jamSelesai: '' })">
+ Tambah Shift Adhoc
</v-btn>
</div>
</template>
</v-card-text>
<v-divider />
<v-card-actions class="pa-5 justify-end gap-2">
<v-btn variant="text" color="grey" @click="dialogOpen = false">Batal</v-btn>
<v-btn color="primary" variant="flat" prepend-icon="mdi-content-save" @click="handleSave">
Simpan Jadwal
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
@@ -0,0 +1,344 @@
<script setup>
/**
* JadwalDokterCalendar Mode tampilan kalender untuk jadwal dokter.
* Menampilkan filter poli/dokter dan v-calendar Vuetify dengan event
* jadwal rutin (primary) dan adhoc (secondary).
* Menerima data dari parent dan emit interaksi kembali ke parent.
*/
const MONTH_NAMES = [
'Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni',
'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember'
];
const props = defineProps({
/** Daftar klinik/poli untuk filter */
clinicList: { type: Array, default: () => [] },
/** Daftar event kalender yang sudah di-generate oleh parent */
calendarEvents: { type: Array, default: () => [] },
});
const emit = defineEmits([
'update:selectedPoli',
'update:selectedDoctor',
'date-click',
'event-click',
]);
// Filter State (local, synced ke parent via emit)
/** Poli yang dipilih di mode calendar */
const selectedPoli = defineModel('selectedPoli', { type: [String, Number, null], default: null });
/** Dokter yang dipilih di mode calendar */
const selectedDoctor = defineModel('selectedDoctor', { type: [String, null], default: null });
const selectedElement = ref(null)
const selectedOpen = ref(false)
/**
* Daftar dokter berdasarkan poli yang dipilih.
* @returns {string[]} Kosong jika poli belum dipilih
*/
const doctorList = computed(() => {
if (!selectedPoli.value) return [];
const clinic = props.clinicList.find(c => c.id === selectedPoli.value);
return clinic?.doctors ?? [];
});
/** Reset dokter saat poli berubah */
watch(selectedPoli, () => { selectedDoctor.value = null; });
// Navigasi Kalender
const isDailyEventsOpen = ref(false);
const dailyEvents = ref([]);
const selectedDateFormatted = ref('');
/** Tanggal referensi untuk navigasi bulan */
const viewDate = ref(new Date());
/**
* Key dinamis untuk v-calendar. Vuetify v-calendar punya internal navigation
* state sendiri mengubah :model-value saja tidak cukup. Dengan mengganti :key,
* kita paksa komponen re-mount sehingga bulan yang tampil ikut berubah.
*/
const calendarKey = computed(() =>
`cal-${viewDate.value.getFullYear()}-${viewDate.value.getMonth()}`
);
/** Judul bulan & tahun aktif */
const calendarTitle = computed(() =>
`${MONTH_NAMES[viewDate.value.getMonth()]} ${viewDate.value.getFullYear()}`
);
/** Navigasi ke bulan sebelumnya */
const prevMonth = () => {
const d = new Date(viewDate.value);
d.setMonth(d.getMonth() - 1);
viewDate.value = new Date(d);
};
/** Navigasi ke bulan berikutnya */
const nextMonth = () => {
const d = new Date(viewDate.value);
d.setMonth(d.getMonth() + 1);
viewDate.value = new Date(d);
};
/**
* Menangani klik pada tanggal di kalender.
* @param {Date|string|object} info - Payload dari @click:date
*/
const handleDateClick = (info, { date }) => {
emit('date-click', date);
};
/**
* Menangani klik pada event di kalender.
* @param {object} payload - Payload dari @click:event v-calendar
*/
const handleEventClick = (nativeEvent, { event }) => {
const ev = event;
if (!ev?.extendedProps) return;
const d = ev.start instanceof Date ? ev.start : new Date(ev.start);
emit('event-click', {
...ev.extendedProps,
tanggal: d.toLocaleDateString('id-ID', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }),
});
};
/**
* Menangani klik "+X lainnya" pada kalender.
* @param {Date|string|object} info - Payload dari @click:more
*/
const handleMoreClick = (nativeEvent, { date }) => {
selectedDateFormatted.value = date;
console.log("date", date)
console.log("calendarEvents.value", props.calendarEvents)
dailyEvents.value = props.calendarEvents.filter(e =>
e.start.toISOString().split("T")[0] === selectedDateFormatted.value
);
const open = () => {
// selectedEvent.value = event
selectedElement.value = nativeEvent.target
requestAnimationFrame(() => requestAnimationFrame(() => selectedOpen.value = true))
}
if (selectedOpen.value) {
selectedOpen.value = false
requestAnimationFrame(() => requestAnimationFrame(() => open()))
} else {
open()
}
nativeEvent.stopPropagation()
};
/**
* Menangani klik pada event di dalam dialog harian.
*/
const handleDailyEventClick = (ev) => {
isDailyEventsOpen.value = false;
const payload = { event: ev };
handleEventClick(payload);
};
</script>
<template>
<div>
<!-- Filter Card -->
<v-row class="mb-2">
<v-col cols="12">
<v-card class="pa-6" color="white">
<div class="d-flex align-center mb-4">
<v-icon color="primary" class="mr-2">mdi-filter-variant</v-icon>
<h3 class="text-subtitle-1 font-weight-bold text-primary">FILTER JADWAL</h3>
</div>
<v-row>
<v-col cols="12" sm="6">
<v-select v-model="selectedPoli" :items="clinicList" item-title="title" item-value="id"
label="Pilih Poli / Klinik" variant="outlined" density="compact" clearable
prepend-inner-icon="mdi-hospital-building" hide-details />
</v-col>
<v-col cols="12" sm="6">
<v-select v-model="selectedDoctor" :items="doctorList" label="Pilih Dokter"
variant="outlined" density="compact" clearable prepend-inner-icon="mdi-doctor"
:disabled="!selectedPoli" hide-details
:placeholder="selectedPoli ? 'Pilih dokter...' : 'Pilih poli terlebih dahulu'" />
</v-col>
</v-row>
<div v-if="selectedPoli || selectedDoctor" class="mt-4 d-flex align-center gap-2 flex-wrap">
<span class="text-caption text-grey-darken-1">Filter aktif:</span>
<v-chip v-if="selectedPoli" size="small" color="primary" variant="tonal" closable
@click:close="selectedPoli = null">
<v-icon start size="x-small">mdi-hospital-building</v-icon>
{{clinicList.find(c => c.id === selectedPoli)?.title}}
</v-chip>
<v-chip v-if="selectedDoctor" size="small" color="secondary" variant="tonal" closable
@click:close="selectedDoctor = null">
<v-icon start size="x-small">mdi-doctor</v-icon>
{{ selectedDoctor }}
</v-chip>
</div>
</v-card>
</v-col>
</v-row>
<!-- Calendar Card -->
<v-row v-if="selectedDoctor && selectedPoli">
<v-col cols="12">
<v-card elevation="2" rounded="lg" color="white">
<!-- Toolbar -->
<div class="d-flex align-center justify-space-between px-6 pt-5 pb-4">
<div class="d-flex align-center">
<v-btn icon="mdi-chevron-left" variant="text" size="small" @click="prevMonth" />
<v-btn icon="mdi-chevron-right" variant="text" size="small" @click="nextMonth" />
<span class="text-body-1 font-weight-bold" style="min-width:150px;text-align:center;">
{{ calendarTitle }}
</span>
</div>
<!-- Legend -->
<div class="d-flex align-center gap-3">
<div class="d-flex align-center gap-1">
<span class="legend-dot bg-primary"></span>
<span class="text-caption ml-1">Rutin</span>
</div>
<div class="d-flex align-center gap-1 ml-1">
<span class="legend-dot bg-secondary"></span>
<span class="text-caption ml-1">Adhoc</span>
</div>
</div>
</div>
<v-divider />
<!-- v-calendar Vuetify -->
<v-calendar :interval-height="100" locale="id" :key="calendarKey" :model-value="viewDate"
:events="calendarEvents" view-mode="month" event-more-text="{0} lainnya" class="jadwal-calendar"
@click:date="handleDateClick" @click:event="handleEventClick" @click:more="handleMoreClick" />
<v-menu v-model="selectedOpen" :activator="selectedElement" :close-on-content-click="false"
location="end">
<v-card color="grey-lighten-4" min-width="350px" flat>
<v-toolbar color="primary" dark>
<v-btn icon>
<v-icon>mdi-calendar</v-icon>
</v-btn>
<v-toolbar-title>Jadwal Lainnya</v-toolbar-title>
</v-toolbar>
<v-card-text style="max-height: 300px; overflow-y: auto;">
<v-list v-if="dailyEvents.length > 0" lines="two" bg-color="transparent">
<v-list-item v-for="(ev, idx) in dailyEvents" :key="idx" class="mb-3 rounded border"
:class="ev.color === 'primary' ? 'border-primary' : 'border-secondary'"
@click="handleDailyEventClick(ev)" hover>
<v-list-item-title class="font-weight-bold">
<v-chip :color="ev.color" size="small" class="mr-2">{{
ev.extendedProps?.tipe }}</v-chip>
{{ ev.name }}
</v-list-item-title>
<v-list-item-subtitle class="mt-1">
<v-icon size="x-small" class="mr-1">mdi-doctor</v-icon>{{
ev.extendedProps?.dokter }}
</v-list-item-subtitle>
<v-list-item-subtitle>
<v-icon size="x-small" class="mr-1">mdi-hospital-building</v-icon>{{
ev.extendedProps?.poli }}
</v-list-item-subtitle>
</v-list-item>
</v-list>
<div v-else class="text-center pa-6 text-grey">
Tidak ada jadwal tambahan.
</div>
</v-card-text>
</v-card>
</v-menu>
</v-card>
</v-col>
</v-row>
<template v-else>
<v-card min-height="400px">
<v-card-text>
<v-row>
<v-col cols="12">
<div class="text-center pa-12 d-flex align-center justify-center" style="height: 400px;">
<div>
<v-icon size="48" color="grey-lighten-1"
class="mb-2">mdi-calendar-blank-outline</v-icon>
<div class="text-body-2 text-grey-lighten-1">Silahkan Pilih Poli dan Dokter terlebih
dahulu</div>
</div>
</div>
</v-col>
</v-row>
</v-card-text>
</v-card>
</template>
</div>
</template>
<style scoped>
:deep(.v-calendar-weekly__day) {
height: 125px;
}
.jadwal-calendar {
border-radius: 0 0 8px 8px;
}
.legend-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 3px;
}
/* Header hari */
:deep(.v-calendar-month__weekday) {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #283593;
background-color: #e8eaf6;
padding: 6px 0;
}
/* Nomor tanggal */
:deep(.v-calendar-month__day-label) {
font-size: 0.8rem;
font-weight: 500;
color: #424242;
cursor: pointer;
}
:deep(.v-calendar-month__day-label:hover) {
background-color: #e8eaf6;
border-radius: 50%;
}
/* Hari ini */
:deep(.v-calendar-month__day--today .v-calendar-month__day-label) {
background-color: #1a237e;
color: white;
border-radius: 50%;
}
/* Event */
:deep(.v-calendar-month__event) {
font-size: 0.68rem !important;
font-weight: 600;
border-radius: 4px !important;
cursor: pointer;
transition: opacity 0.15s ease;
}
:deep(.v-calendar-month__event:hover) {
opacity: 0.82;
}
</style>
@@ -0,0 +1,313 @@
<script setup>
/**
* JadwalDokterList Mode tampilan daftar (tabel) untuk jadwal dokter.
* Menampilkan filter (poli klinik, tanggal, nama dokter) dan tabel
* dengan kolom NO, POLI, NAMA DOKTER, JADWAL RUTIN, AKSI.
* Mode ini menjadi tampilan default halaman Jadwal Dokter.
*/
const props = defineProps({
/** Daftar klinik/poli dari store */
clinicList: { type: Array, default: () => [] },
/** Daftar jadwal yang sudah ditambahkan */
jadwalList: { type: Array, default: () => [] },
});
const emit = defineEmits(['edit', 'add']);
// Filter State
/** Poli yang dipilih untuk filter */
const filterPoli = ref(null);
/** Tanggal yang dipilih untuk filter */
const filterDate = ref(null);
/** Keyword pencarian nama dokter */
const filterDokter = ref('');
// Pagination
/** Halaman aktif saat ini */
const currentPage = ref(1);
/** Jumlah baris per halaman */
const itemsPerPage = ref(10);
/** Opsi jumlah baris per halaman */
const perPageOptions = [5, 10, 25, 50];
// Mapping Hari
const DAY_MAP = {
0: 'Minggu', 1: 'Senin', 2: 'Selasa', 3: 'Rabu',
4: 'Kamis', 5: 'Jumat', 6: 'Sabtu'
};
// Computed: Data Tabel
/**
* Membangun data tabel dari jadwalList, dikelompokkan per dokter + poli.
* Setiap baris menampilkan 1 dokter dengan ringkasan jadwal rutinnya.
* @returns {Array<{ id, poliName, poliId, dokter, jadwalRutin, raw }>}
*/
const tableData = computed(() => {
/** Gabungkan jadwal berdasarkan key unik dokter+poli */
const grouped = {};
props.jadwalList.forEach(j => {
const key = `${j.poliId}-${j.dokter}`;
if (!grouped[key]) {
grouped[key] = {
id: j.id,
poliName: j.poliName,
poliId: j.poliId,
dokter: j.dokter,
hariSet: new Set(),
jamMulai: null,
jamSelesai: null,
raw: [],
};
}
grouped[key].raw.push(j);
if (j.tipe === 'Rutin' && j.hari) {
j.hari.forEach(h => grouped[key].hariSet.add(h));
if (j.jamMulai) grouped[key].jamMulai = j.jamMulai;
if (j.jamSelesai) grouped[key].jamSelesai = j.jamSelesai;
}
});
return Object.values(grouped).map(g => {
const hariArr = Array.from(g.hariSet);
/** Format jadwal rutin: "Senin - Kamis\n08:00 - 14:00 WIB" */
let jadwalRutin = '';
if (hariArr.length > 0) {
jadwalRutin = hariArr.join(', ');
if (g.jamMulai && g.jamSelesai) {
jadwalRutin += `\n${g.jamMulai} - ${g.jamSelesai} WIB`;
}
} else {
jadwalRutin = '-';
}
return {
id: g.id,
poliName: g.poliName,
poliId: g.poliId,
dokter: g.dokter,
jadwalRutin,
raw: g.raw,
};
});
});
/**
* Data tabel yang sudah difilter berdasarkan poli, tanggal, dan keyword nama dokter.
* @returns {Array}
*/
const filteredData = computed(() => {
let data = tableData.value;
// Filter poli
if (filterPoli.value) {
data = data.filter(d => d.poliId === filterPoli.value);
}
// Filter tanggal cocokkan dengan hari dari jadwal rutin
if (filterDate.value) {
const date = new Date(filterDate.value);
const dayName = DAY_MAP[date.getDay()];
data = data.filter(d =>
d.raw.some(j => j.tipe === 'Rutin' && j.hari?.includes(dayName))
);
}
// Filter nama dokter
if (filterDokter.value) {
const keyword = filterDokter.value.toLowerCase();
data = data.filter(d => d.dokter.toLowerCase().includes(keyword));
}
return data;
});
/** Total data setelah filter */
const totalItems = computed(() => filteredData.value.length);
/** Total halaman */
const totalPages = computed(() => Math.max(1, Math.ceil(totalItems.value / itemsPerPage.value)));
/** Data untuk halaman aktif (sliced) */
const paginatedData = computed(() => {
const start = (currentPage.value - 1) * itemsPerPage.value;
return filteredData.value.slice(start, start + itemsPerPage.value);
});
/** Label info pagination: "Menampilkan 1-10 dari 124 dokter spesialis" */
const paginationLabel = computed(() => {
if (totalItems.value === 0) return 'Tidak ada data';
const start = (currentPage.value - 1) * itemsPerPage.value + 1;
const end = Math.min(currentPage.value * itemsPerPage.value, totalItems.value);
return `Menampilkan ${start}-${end} dari ${totalItems.value} dokter spesialis`;
});
/** Reset halaman saat filter berubah */
watch([filterPoli, filterDate, filterDokter], () => {
currentPage.value = 1;
});
/**
* Navigasi ke halaman tertentu.
* @param {number} page
*/
const goToPage = (page) => {
if (page >= 1 && page <= totalPages.value) {
currentPage.value = page;
}
};
</script>
<template>
<div>
<!-- Filter Card -->
<v-card class="pa-5 mb-6" elevation="2" rounded="lg" color="white">
<v-row align="center">
<v-col cols="12" sm="4">
<div class="text-caption text-grey-darken-1 mb-1 font-weight-medium">Poli Klinik</div>
<v-select v-model="filterPoli" :items="clinicList" item-title="title" item-value="id"
placeholder="Semua Poli" variant="outlined" density="compact" clearable
prepend-inner-icon="mdi-hospital-building" hide-details />
</v-col>
<v-col cols="12" sm="4">
<div class="text-caption text-grey-darken-1 mb-1 font-weight-medium">Tanggal</div>
<v-text-field v-model="filterDate" type="date" placeholder="Pilih Tanggal" variant="outlined"
density="compact" clearable prepend-inner-icon="mdi-calendar" hide-details />
</v-col>
<v-col cols="12" sm="4">
<div class="text-caption text-grey-darken-1 mb-1 font-weight-medium">Nama Dokter</div>
<v-text-field v-model="filterDokter" placeholder="Cari Nama Dokter..." variant="outlined"
density="compact" clearable prepend-inner-icon="mdi-account-search" hide-details />
</v-col>
</v-row>
</v-card>
<!-- Tabel Jadwal -->
<v-card elevation="2" rounded="lg" color="white">
<v-table class="jadwal-table">
<thead>
<tr>
<th class="text-left" style="width: 60px;">NO</th>
<th class="text-left" style="width: 140px;">POLI</th>
<th class="text-left">NAMA DOKTER</th>
<th class="text-left" style="width: 200px;">JADWAL RUTIN</th>
<th class="text-center" style="width: 100px;">AKSI</th>
</tr>
</thead>
<tbody>
<tr v-if="paginatedData.length === 0">
<td colspan="5" class="text-center pa-8 text-grey">
<v-icon size="48" color="grey-lighten-1" class="mb-2">mdi-calendar-blank-outline</v-icon>
<div class="text-body-2">Belum ada data jadwal dokter</div>
<div class="text-caption text-grey-lighten-1">Tambahkan jadwal dokter melalui mode
kalender</div>
</td>
</tr>
<tr v-for="(item, index) in paginatedData" :key="item.id" class="jadwal-row">
<td class="text-body-2 font-weight-medium">
{{ (currentPage - 1) * itemsPerPage + index + 1 }}
</td>
<td>
<v-chip size="small" color="primary" variant="tonal">
{{ item.poliName }}
</v-chip>
</td>
<td class="text-body-2 font-weight-bold">
{{ item.dokter }}
</td>
<td>
<div class="text-body-2" style="white-space: pre-line; line-height: 1.5;">
{{ item.jadwalRutin }}
</div>
</td>
<td class="text-center">
<v-btn variant="outlined" size="small" color="primary" rounded="lg"
prepend-icon="mdi-pencil" @click="emit('edit', item)">
Edit
</v-btn>
</td>
</tr>
</tbody>
</v-table>
<!-- Pagination Footer -->
<v-divider />
<div class="d-flex align-center justify-space-between px-4 py-3 pagination-footer">
<span class="text-caption text-grey-darken-1">{{ paginationLabel }}</span>
<div class="d-flex align-center gap-3">
<div class="d-flex align-center gap-2">
<span class="text-caption text-grey-darken-1 mr-2">Baris per halaman:</span>
<v-select v-model="itemsPerPage" :items="perPageOptions" variant="plain" density="compact"
hide-details style="max-width: 70px;" />
</div>
<div class="d-flex align-center gap-1">
<v-btn icon size="x-small" variant="text" :disabled="currentPage <= 1" @click="goToPage(1)">
<v-icon size="16">mdi-page-first</v-icon>
</v-btn>
<v-btn icon size="x-small" variant="text" :disabled="currentPage <= 1"
@click="goToPage(currentPage - 1)">
<v-icon size="16">mdi-chevron-left</v-icon>
</v-btn>
<v-btn icon size="x-small" variant="text" :disabled="currentPage >= totalPages"
@click="goToPage(currentPage + 1)">
<v-icon size="16">mdi-chevron-right</v-icon>
</v-btn>
<v-btn icon size="x-small" variant="text" :disabled="currentPage >= totalPages"
@click="goToPage(totalPages)">
<v-icon size="16">mdi-page-last</v-icon>
</v-btn>
</div>
</div>
</div>
</v-card>
</div>
</template>
<style scoped>
.jadwal-table {
border-radius: 12px 12px 0 0;
}
.jadwal-table thead th {
background-color: #f5f6fa !important;
color: #5a607f !important;
font-weight: 700 !important;
font-size: 0.75rem !important;
letter-spacing: 0.04em;
text-transform: uppercase;
border-bottom: 2px solid #e8eaf0 !important;
padding: 14px 16px !important;
}
.jadwal-row {
transition: background-color 0.15s ease;
}
.jadwal-row:hover {
background-color: #f8f9fd;
}
.jadwal-row td {
padding: 16px !important;
border-bottom: 1px solid #f0f1f5 !important;
vertical-align: middle;
}
.pagination-footer {
background-color: #fafbfc;
border-radius: 0 0 12px 12px;
}
</style>
@@ -0,0 +1,259 @@
<script setup>
import { ref, computed } from 'vue';
const props = defineProps({
tipe: { type: String, default: 'Rutin' },
hari: { type: String, default: '' },
adhocTanggal: { type: String, default: '' },
jadwalList: { type: Array, default: () => [] },
poliId: { type: [String, Number, null], default: null },
dokter: { type: String, default: null }
});
const MONTH_NAMES = [
'Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni',
'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember'
];
const DAY_MAP = {
'Minggu': 0, 'Senin': 1, 'Selasa': 2, 'Rabu': 3,
'Kamis': 4, 'Jumat': 5, 'Sabtu': 6
};
const viewDate = ref(new Date());
const calendarKey = computed(() =>
`cal-mini-${viewDate.value.getFullYear()}-${viewDate.value.getMonth()}`
);
const calendarTitle = computed(() =>
`${MONTH_NAMES[viewDate.value.getMonth()]} ${viewDate.value.getFullYear()}`
);
const prevMonth = () => {
const d = new Date(viewDate.value);
d.setMonth(d.getMonth() - 1);
viewDate.value = new Date(d);
};
const nextMonth = () => {
const d = new Date(viewDate.value);
d.setMonth(d.getMonth() + 1);
viewDate.value = new Date(d);
};
const toDateStr = (date) => {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
};
const calendarEvents = computed(() => {
const events = [];
const year = viewDate.value.getFullYear();
const month = viewDate.value.getMonth();
// 1. Generate existing events for the selected doctor
const filtered = props.jadwalList.filter(j => {
if (props.poliId && j.poliId !== props.poliId) return false;
if (props.dokter && j.dokter !== props.dokter) return false;
return true;
});
filtered.forEach(jadwal => {
if (jadwal.tipe === 'Rutin') {
for (let mOffset = -1; mOffset <= 1; mOffset++) {
const tYear = month + mOffset < 0 ? year - 1 : month + mOffset > 11 ? year + 1 : year;
const tMonth = (month + mOffset + 12) % 12;
const daysInMonth = new Date(tYear, tMonth + 1, 0).getDate();
for (let d = 1; d <= daysInMonth; d++) {
const date = new Date(tYear, tMonth, d);
const dayName = Object.keys(DAY_MAP).find(k => DAY_MAP[k] === date.getDay());
let jadwalHariArray = [];
if (Array.isArray(jadwal.hari)) {
jadwalHariArray = jadwal.hari;
} else if (typeof jadwal.hari === 'string') {
jadwalHariArray = [jadwal.hari];
}
if (!jadwalHariArray.includes(dayName)) continue;
const dateStr = toDateStr(date);
const jamLabel = jadwal.jamMulai && jadwal.jamSelesai
? ` ${jadwal.jamMulai} - ${jadwal.jamSelesai}`
: '';
events.push({
name: `${jamLabel}`,
start: jadwal.jamMulai ? new Date(`${dateStr}T${jadwal.jamMulai}:00`) : date,
end: jadwal.jamSelesai ? new Date(`${dateStr}T${jadwal.jamSelesai}:00`) : date,
color: 'primary',
allDay: !jadwal.jamMulai,
});
}
}
} else {
const adhocMulaiTime = jadwal.mulai ? jadwal.mulai.split('T')[1]?.slice(0, 5) : '';
const adhocSelesaiTime = jadwal.selesai ? jadwal.selesai.split('T')[1]?.slice(0, 5) : '';
const adhocJamLabel = adhocMulaiTime && adhocSelesaiTime
? ` ${adhocMulaiTime} - ${adhocSelesaiTime}`
: '';
events.push({
name: `${adhocJamLabel}`,
start: new Date(jadwal.mulai),
end: new Date(jadwal.selesai),
color: 'secondary',
allDay: false,
});
}
});
return events;
});
const calendarRef = ref(null);
const updateHighlight = () => {
// Wait for calendar to fully render internally
setTimeout(() => {
const calendarEl = calendarRef.value?.$el || document.querySelector('.mini-calendar');
if (!calendarEl) return;
// Remove previous highlights
const previous = calendarEl.querySelectorAll('.adhoc-highlight, .rutin-highlight');
previous.forEach(el => {
el.classList.remove('adhoc-highlight', 'rutin-highlight');
el.style.border = '';
el.style.borderRadius = '';
el.style.color = '';
el.style.fontWeight = '';
});
const allDays = calendarEl.querySelectorAll('.v-calendar-weekly__day');
if (allDays.length === 0) return;
if (props.tipe === 'Rutin' && props.hari) {
const targetCol = DAY_MAP[props.hari];
allDays.forEach((dayEl, index) => {
// Check if index matches column and it's not from adjacent month
if (index % 7 === targetCol && !dayEl.classList.contains('v-calendar-weekly__day--adjacent')) {
const labelBtn = dayEl.querySelector('.v-calendar-weekly__day-label .v-btn, .v-calendar-weekly__day-label');
if (labelBtn) {
labelBtn.classList.add('rutin-highlight');
// Fallback inline styles in case CSS scoped issue occurs
labelBtn.style.border = '2px solid rgb(var(--v-theme-primary))';
labelBtn.style.borderRadius = '50%';
labelBtn.style.color = 'rgb(var(--v-theme-primary))';
labelBtn.style.fontWeight = 'bold';
labelBtn.style.width = '28px';
labelBtn.style.height = '28px';
labelBtn.style.display = 'flex';
labelBtn.style.alignItems = 'center';
labelBtn.style.justifyContent = 'center';
labelBtn.style.margin = '2px auto';
}
}
});
} else if (props.tipe === 'Adhoc' && props.adhocTanggal) {
const adhocDate = new Date(props.adhocTanggal);
if (adhocDate.getMonth() !== viewDate.value.getMonth() || adhocDate.getFullYear() !== viewDate.value.getFullYear()) {
return;
}
const dayToHighlight = adhocDate.getDate().toString();
allDays.forEach(dayEl => {
if (!dayEl.classList.contains('v-calendar-weekly__day--adjacent')) {
const labelTextEl = dayEl.querySelector('.v-calendar-weekly__day-label .v-btn__content, .v-calendar-weekly__day-label');
const labelBtn = dayEl.querySelector('.v-calendar-weekly__day-label .v-btn, .v-calendar-weekly__day-label');
if (labelTextEl && labelTextEl.textContent.trim() === dayToHighlight) {
labelBtn.classList.add('adhoc-highlight');
labelBtn.style.border = '2px solid rgb(var(--v-theme-primary))';
labelBtn.style.borderRadius = '50%';
labelBtn.style.color = 'rgb(var(--v-theme-primary))';
labelBtn.style.fontWeight = 'bold';
labelBtn.style.width = '28px';
labelBtn.style.height = '28px';
labelBtn.style.display = 'flex';
labelBtn.style.alignItems = 'center';
labelBtn.style.justifyContent = 'center';
labelBtn.style.margin = '2px auto';
}
}
});
}
}, 150);
};
import { watch, nextTick } from 'vue';
watch([() => props.adhocTanggal, () => props.hari, () => props.tipe, viewDate], () => {
nextTick(() => updateHighlight());
}, { immediate: true });
</script>
<template>
<v-card class="mt-4 border border-grey-lighten-3 rounded-lg" elevation="0">
<div class="d-flex align-center justify-space-between px-4 pt-3 pb-2">
<div class="d-flex align-center">
<v-btn icon="mdi-chevron-left" variant="text" size="x-small" @click="prevMonth" />
<v-btn icon="mdi-chevron-right" variant="text" size="x-small" @click="nextMonth" />
<span class="text-caption font-weight-bold mx-2" style="min-width:100px;text-align:center;">
{{ calendarTitle }}
</span>
</div>
</div>
<!-- We use a custom class to inject CSS for highlighted days if they have preview event -->
<v-calendar ref="calendarRef" :interval-height="40" locale="id" :key="calendarKey" :model-value="viewDate"
:events="calendarEvents" view-mode="month"
:class="['mini-calendar', props.tipe === 'Rutin' && props.hari ? 'preview-hari-' + props.hari : '']" />
</v-card>
</template>
<style scoped>
:deep(.v-calendar-weekly__day) {
height: 70px !important;
}
.mini-calendar {
border-radius: 0 0 8px 8px;
background-color: white !important;
}
/* Header hari */
:deep(.v-calendar-weekly__weekday) {
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
color: #555;
background-color: #f5f5f5;
padding: 4px 0;
}
/* Nomor tanggal */
:deep(.v-calendar-weekly__day-label) {
font-size: 0.8rem;
font-weight: 600;
color: #424242;
transition: all 0.2s ease;
}
/* Lingkaran Preview via JS Class */
:deep(.rutin-highlight),
:deep(.adhoc-highlight) {
border: 2px solid rgb(var(--v-theme-primary)) !important;
border-radius: 50% !important;
color: rgb(var(--v-theme-primary)) !important;
font-weight: bold !important;
}
/* Event styles */
:deep(.v-calendar-weekly__event) {
font-size: 0.6rem !important;
font-weight: 600;
border-radius: 4px !important;
padding: 0 4px !important;
margin-bottom: 2px !important;
min-height: 16px !important;
}
</style>
@@ -0,0 +1,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>
+11 -9
View File
@@ -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"
@@ -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>
+211
View File
@@ -0,0 +1,211 @@
import { ref, computed } from 'vue';
import { useQueueStore } from '@/stores/queueStore';
import { useMasterStore } from '@/stores/masterStore';
import { useClinicStore } from '@/stores/clinicStore';
export const useGrandPaviliun = () => {
const queueStore = useQueueStore();
const masterStore = useMasterStore();
const clinicStore = useClinicStore();
const loading = ref(false);
// Expose methods for UI
const fetchEksekutifData = async () => {
loading.value = true;
try {
// Pastikan data master ruangan sudah dimuat
if (!masterStore.ruangData || masterStore.ruangData.length === 0) {
// Assume it's loaded in index or master layouts, but fallback if not
}
const eksekutifClinics = masterStore.ruangData.filter(r => r.jenisLayanan === 'Eksekutif');
// Ambil data antrean untuk semua klinik eksekutif
const promises = eksekutifClinics.map(clinic => {
queueStore.registerClinicInterest(clinic.kodeKlinik);
return queueStore.fetchPatientsForClinic(clinic.kodeKlinik);
});
await Promise.all(promises);
// Connect WebSocket if not connected
if (!queueStore.isWsConnected) {
queueStore.initWebSocket('admin-klinik-ruang-eksekutif');
}
} catch (e) {
console.error('Error fetching eksekutif data:', e);
} finally {
loading.value = false;
}
};
// Computed data for UI
const clinics = computed(() => {
const eksekutifClinics = (masterStore.ruangData || []).filter(r => r.jenisLayanan === 'Eksekutif');
return eksekutifClinics.map(clinic => {
// Find all patients for this clinic
const clinicPatients = queueStore.allPatients.filter(p => p.kodeKlinik === clinic.kodeKlinik && p.processStage === 'klinik-ruang' && p.status !== 'processed');
// Find current processing patient (we take the first room's current patient if multiple, or global)
// Since design shows 1 column per clinic, we find the first patient marked as processing.
// Usually, queueStore.currentProcessingPatient is keyed by `klinik-ruang-${kodeKlinik}-${nomorRuang}`
let currentPatientObj = null;
// We look through clinic rooms to find a processing patient
for (const ruang of clinic.ruangList) {
const key = `klinik-ruang-${clinic.kodeKlinik}-${ruang.nomorRuang}`;
const processing = queueStore.currentProcessingPatient[key];
if (processing) {
currentPatientObj = {
...processing,
roomIndicator: `RUANG ${ruang.nomorRuang}`,
nomorRuang: ruang.nomorRuang
};
break; // Stop at first one for the column view
}
}
// Get queue patients (those not currently processing)
const currentPatientNo = currentPatientObj ? currentPatientObj.no : null;
const queuePatients = clinicPatients
.filter(p => p.no !== currentPatientNo)
.map(p => ({
...p,
isActive: false // Could be based on selection later
}));
return {
kodeKlinik: clinic.kodeKlinik,
namaKlinik: clinic.namaKlinik,
currentPatient: currentPatientObj,
queuePatients: queuePatients,
roomOptions: clinic.ruangList
};
});
});
const monitorRooms = computed(() => {
const rooms = [];
let idCounter = 1;
const eksekutifClinics = (masterStore.ruangData || []).filter(r => r.jenisLayanan === 'Eksekutif');
eksekutifClinics.forEach(clinic => {
clinic.ruangList.forEach(ruang => {
const key = `klinik-ruang-${clinic.kodeKlinik}-${ruang.nomorRuang}`;
const processing = queueStore.currentProcessingPatient[key];
// As user specified: Monitoring only shows data when 'Panggil Pemeriksaan' (Tindakan) is true
// Assuming calledTindakan flag exists on processing patient
const isActiveInMonitoring = processing && processing.calledTindakan;
rooms.push({
id: idCounter++,
name: `RUANG ${ruang.nomorRuang}`,
activePatient: isActiveInMonitoring ? (processing.noAntrian?.split(" |")[0] || processing.barcode) : null,
clinicName: clinic.namaKlinik
});
});
});
return rooms;
});
// Actions
const handlePemeriksaanAwal = (patient, kodeKlinik, nomorRuang) => {
if (!patient || !kodeKlinik || !nomorRuang) return;
const roomInfo = masterStore.ruangData
.find(c => c.kodeKlinik === kodeKlinik)
?.ruangList.find(r => String(r.nomorRuang) === String(nomorRuang));
if (!roomInfo) return;
const wsData = {
action: 'callPemeriksaanAwal',
patientId: patient.barcode || patient.visitId,
patientCode: patient.noAntrian?.split(" |")[0] || patient.barcode,
patientName: patient.name || '',
roomName: roomInfo.namaRuang,
clinicCode: kodeKlinik,
tipePanggilan: 'Pemeriksaan Awal'
};
queueStore.sendViaPost(wsData);
// Update local state to mark as called
if (patient.no) {
const idx = queueStore.allPatients.findIndex(p => p.no === patient.no);
if (idx !== -1) {
queueStore.allPatients[idx].calledPemeriksaanAwal = true;
}
}
};
const handlePanggilPemeriksaan = (patient, kodeKlinik, nomorRuang) => {
if (!patient || !kodeKlinik || !nomorRuang) return;
const roomInfo = masterStore.ruangData
.find(c => c.kodeKlinik === kodeKlinik)
?.ruangList.find(r => String(r.nomorRuang) === String(nomorRuang));
if (!roomInfo) return;
const wsData = {
action: 'callTindakan',
patientId: patient.barcode || patient.visitId,
patientCode: patient.noAntrian?.split(" |")[0] || patient.barcode,
patientName: patient.name || '',
roomName: roomInfo.namaRuang,
clinicCode: kodeKlinik,
tipePanggilan: 'Tindakan'
};
queueStore.sendViaPost(wsData);
// Update local state to mark as called
if (patient.no) {
const idx = queueStore.allPatients.findIndex(p => p.no === patient.no);
if (idx !== -1) {
queueStore.allPatients[idx].calledTindakan = true;
}
}
};
const processPatientAction = async (patient, action, kodeKlinik, nomorRuang) => {
if (!patient || !kodeKlinik || !nomorRuang) return { success: false, message: 'Data tidak lengkap' };
const result = await queueStore.processPatientKlinikRuang(
patient,
action,
kodeKlinik,
nomorRuang
);
if (action === 'pending' && result.success) {
const patientIndex = queueStore.allPatients.findIndex(p => p.no === patient.no);
if (patientIndex !== -1) {
queueStore.allPatients[patientIndex] = {
...queueStore.allPatients[patientIndex],
status: 'pending'
};
const key = `klinik-ruang-${kodeKlinik}-${nomorRuang}`;
queueStore.currentProcessingPatient[key] = null;
}
}
return result;
};
return {
clinics,
monitorRooms,
loading,
fetchEksekutifData,
handlePemeriksaanAwal,
handlePanggilPemeriksaan,
processPatientAction
};
};
+14 -4
View File
@@ -94,6 +94,14 @@ export const useQueue = (adminType = "loket", specificId = null) => {
const isAssignedToThis = p.loketId && String(p.loketId) === String(targetId);
if (isAssignedToThis) return true;
// Add strict isolation between Eksekutif and Reguler for unassigned tickets
const isPatientEksekutif = p.noAntrian && (String(p.noAntrian).startsWith('E') || String(p.noAntrian).startsWith('F-E'));
const isLoketEksekutif = thisLoket.tipeLoket === 'EKSEKUTIF' || thisLoket.id >= 1000;
if (isPatientEksekutif !== isLoketEksekutif) {
return false;
}
const isServedByThis = !p.loketId && thisLoket.pelayanan && Array.isArray(thisLoket.pelayanan) && thisLoket.pelayanan.includes(p.kodeKlinik);
return isServedByThis;
@@ -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
View File
@@ -1,193 +1,62 @@
// composables/useQueueAPI.ts
// Composable untuk API calls terkait antrian pasien
export interface Patient {
no: number;
jamPanggil: string;
barcode: string;
noAntrian: string;
shift: string;
klinik: string;
fastTrack: string;
pembayaran: string;
status: 'anjungan' | 'pending' | 'di-loket' | 'di-klinik' | 'selesai' | 'terlambat';
processStage: 'loket' | 'klinik' | 'penunjang';
createdAt: string;
registrationType?: 'online' | 'onsite';
visitType?: string;
visitDate?: string;
}
export interface QueueAPIResponse<T = any> {
success: boolean;
data?: T;
message?: string;
error?: string;
}
import { useRuntimeConfig } from '#app';
import type { QueuePatient } from '@/types/queue';
export const useQueueAPI = () => {
const config = useRuntimeConfig();
const baseURL = config.public.apiBaseUrl || '/api/queue';
const verificationApiBase = config.public.verificationApiBaseUrl || 'http://10.10.123.140:8089/api/v1';
const externalApiBase = config.public.externalApiBaseUrl;
/**
* Fetch all patients from database
*/
const fetchAllPatients = async (): Promise<Patient[]> => {
const fetchRawLoketPatients = async (loketId: string | number) => {
try {
const response = await $fetch<QueueAPIResponse<Patient[]>>(`${baseURL}/patients`, {
method: 'GET',
});
if (response.success && response.data) {
return response.data;
const rawData: any = await $fetch(`${verificationApiBase}/loket/${loketId}`);
if (rawData.metadata && rawData.metadata.code !== 200) {
throw new Error(rawData.message || 'API returned error status');
}
throw new Error(response.message || 'Failed to fetch patients');
return rawData.data || [];
} catch (error: any) {
console.error('❌ Error fetching patients:', error);
throw error;
throw new Error(`HTTP error! status: ${error.response?.status || error.message}`);
}
};
/**
* Fetch single patient by ID or barcode
*/
const fetchPatient = async (idOrBarcode: string): Promise<Patient | null> => {
const fetchRawClinicPatients = async (clinicId: string | number) => {
const url = `${externalApiBase}/visit?klinik_id=${clinicId}&limit=500`;
try {
const response = await $fetch<QueueAPIResponse<Patient>>(`${baseURL}/patients/${idOrBarcode}`, {
method: 'GET',
});
const rawResponse: any = await $fetch(url);
return rawResponse?.data || [];
} catch (error: any) {
throw new Error(`HTTP error! status: ${error.response?.status || error.message}`);
}
};
if (response.success && response.data) {
return response.data;
const updateTicketStatus = async (barcode: string, statuspasien: string, statuspasien2: string, idklinikstatus: string, idklinikstatus2: string) => {
return await $fetch(`${verificationApiBase}/tiket/update`, {
method: 'POST',
body: {
barcode: barcode || "",
statuspasien,
statuspasien2,
idklinikstatus,
idklinikstatus2
}
return null;
} catch (error: any) {
console.error('❌ Error fetching patient:', error);
return null;
}
});
};
/**
* Create new patient (register from Anjungan)
*/
const createPatient = async (patientData: Partial<Patient>): Promise<Patient> => {
try {
const response = await $fetch<QueueAPIResponse<Patient>>(`${baseURL}/patients`, {
method: 'POST',
body: patientData,
});
if (response.success && response.data) {
return response.data;
const completeTicketStatus = async (idloket: string, barcode: string, statuspasien: string, idklinikstatus: string) => {
return await $fetch(`${verificationApiBase}/tiket/selesai`, {
method: 'POST',
body: {
idloket: String(idloket || ""),
barcode: barcode || "",
statuspasien,
idklinikstatus
}
throw new Error(response.message || 'Failed to create patient');
} catch (error: any) {
console.error('❌ Error creating patient:', error);
throw error;
}
};
/**
* Update patient status (check-in, process, etc)
*/
const updatePatient = async (
idOrBarcode: string,
updates: Partial<Patient>
): Promise<Patient> => {
try {
const response = await $fetch<QueueAPIResponse<Patient>>(
`${baseURL}/patients/${idOrBarcode}`,
{
method: 'PATCH',
body: updates,
}
);
if (response.success && response.data) {
return response.data;
}
throw new Error(response.message || 'Failed to update patient');
} catch (error: any) {
console.error('❌ Error updating patient:', error);
throw error;
}
};
/**
* Check-in patient (update status to di-loket)
*/
const checkInPatient = async (idOrBarcode: string): Promise<Patient> => {
return updatePatient(idOrBarcode, { status: 'di-loket' });
};
/**
* Process patient at loket (update status and processStage)
*/
const processPatientAtLoket = async (
idOrBarcode: string,
updates: { status?: string; processStage?: string }
): Promise<Patient> => {
return updatePatient(idOrBarcode, updates as Partial<Patient>);
};
/**
* Sync local state with database
*/
const syncWithDatabase = async (localPatients: Patient[]): Promise<Patient[]> => {
try {
// Fetch latest from database
const dbPatients = await fetchAllPatients();
// Merge strategy: prefer database data, but keep local if newer
const merged = new Map<string, Patient>();
// Add database patients
dbPatients.forEach(patient => {
merged.set(patient.barcode, patient);
});
// Add local patients that don't exist in DB or are newer
localPatients.forEach(localPatient => {
const existing = merged.get(localPatient.barcode);
if (!existing || new Date(localPatient.createdAt) > new Date(existing.createdAt)) {
merged.set(localPatient.barcode, localPatient);
}
});
return Array.from(merged.values());
} catch (error: any) {
console.error('❌ Error syncing with database:', error);
// Return local patients as fallback
return localPatients;
}
};
/**
* Batch sync: save multiple patients to database
*/
const batchSyncPatients = async (patients: Patient[]): Promise<boolean> => {
try {
const response = await $fetch<QueueAPIResponse>(`${baseURL}/patients/batch`, {
method: 'POST',
body: { patients },
});
return response.success || false;
} catch (error: any) {
console.error('❌ Error batch syncing patients:', error);
return false;
}
});
};
return {
fetchAllPatients,
fetchPatient,
createPatient,
updatePatient,
checkInPatient,
processPatientAtLoket,
syncWithDatabase,
batchSyncPatients,
fetchRawLoketPatients,
fetchRawClinicPatients,
updateTicketStatus,
completeTicketStatus
};
};
+237
View File
@@ -0,0 +1,237 @@
import { ref } from 'vue';
import { useRuntimeConfig } from '#app';
import { useWebSocket } from '@/composables/useWebSocket';
import type { QueuePatient } from '@/types/queue';
export interface QueueSyncDeps {
allPatients: Ref<QueuePatient[]>;
currentProcessingPatient: Ref<Record<string, QueuePatient>>;
activeLoketInterest: Ref<Record<string, number>>;
activeClinicInterest: Ref<Record<string, number>>;
globalInterestCount: Ref<number>;
fetchPatientsForLoket: (id: string | number, force?: boolean) => void;
fetchPatientsForClinic: (id: string, force?: boolean) => void;
fetchAllPatients: () => void;
}
export const useQueueSync = (deps: QueueSyncDeps) => {
const isWsConnected = ref<boolean>(false);
const wsClientId = ref<string>(`client-${Math.random().toString(36).substring(7)}`);
const lastGlobalCall = ref<any>(null);
const lastKlinikCall = ref<any>(null);
const onWsMessage = (data: any) => {
// Robust data extraction: some relays wrap data in another 'data' property
let messageData = data?.data || data;
if (messageData?.data && !messageData.callKlinikEvent && !messageData.callEvent) {
messageData = messageData.data; // Double wrap check
}
const targetLoketId = messageData?.loketId || messageData?.idloket;
const targetKlinikId = messageData?.klinikId || messageData?.idklinik;
// Handle Call Events and WS messages
if (messageData?.triggerRefresh) {
if (messageData.klinikId) {
// console.log(`🔄 [queueSync] Received refresh trigger for clinic ${messageData.klinikId}`);
// Handle current processing update if provided
if (messageData.currentProcessingUpdate) {
// console.log(`🎯 [queueSync] Applying current processing update:`, messageData.currentProcessingUpdate);
Object.keys(messageData.currentProcessingUpdate).forEach(key => {
deps.currentProcessingPatient.value[key] = messageData.currentProcessingUpdate[key];
// Also patch the status in allPatients if possible
const processingPatient = messageData.currentProcessingUpdate[key];
if (processingPatient && processingPatient.no) {
const idx = deps.allPatients.value.findIndex(p => p.no === processingPatient.no);
if (idx !== -1) {
deps.allPatients.value[idx] = { ...deps.allPatients.value[idx], status: 'di-loket' };
}
}
});
}
deps.fetchPatientsForClinic(messageData.klinikId, true);
}
}
if (messageData?.callEvent) {
lastGlobalCall.value = messageData.callEvent;
}
// Handle Klinik Call Events (cross-device sync for AntrianKlinikRuang display)
if (messageData?.callKlinikEvent) {
const ev = messageData.callKlinikEvent;
// console.log('🏥 [queueSync] Klinik call event received:', ev);
// PERSISTENCE FIX: Save to lastKlinikCall for displays to watch
lastKlinikCall.value = ev;
// Find the patient in allPatients and patch directly for immediate UI update
const idx = deps.allPatients.value.findIndex(p =>
p.processStage === 'klinik-ruang' &&
p.kodeKlinik === ev.kodeKlinik &&
(
(p.barcode && String(p.barcode) === String(ev.barcode)) ||
(p.noAntrian && p.noAntrian.split(' |')[0] === ev.noantrian)
)
);
if (idx !== -1) {
const updatedPatient = {
...deps.allPatients.value[idx],
tipeLayanan: ev.tipeLayanan,
lastCalledAt: ev.lastCalledAt || new Date().toISOString(),
lastCalledTipeLayanan: ev.tipeLayanan,
status: 'di-loket' as any,
calledPemeriksaanAwal: ev.tipeLayanan === 'Pemeriksaan Awal' ? true : deps.allPatients.value[idx].calledPemeriksaanAwal,
calledTindakan: ev.tipeLayanan === 'Tindakan' ? true : deps.allPatients.value[idx].calledTindakan
};
deps.allPatients.value[idx] = updatedPatient;
// console.log(`✅ [queueSync] Successfully patched patient ${ev.noantrian} status to di-loket (lastCalledAt: ${updatedPatient.lastCalledAt})`);
} else {
console.warn(`⚠️ [queueSync] Patient ${ev.noantrian} not found in store for clinic ${ev.kodeKlinik}.`);
}
}
// TRIGGER STRATEGIC REFRESHES
let refreshedSomething = false;
if (targetLoketId) {
deps.fetchPatientsForLoket(targetLoketId, true);
refreshedSomething = true;
}
if (targetKlinikId) {
const interestingClinics = Object.keys(deps.activeClinicInterest.value);
if (interestingClinics.includes(String(targetKlinikId)) || targetKlinikId === 'broadcast') {
const clinicToFetch = targetKlinikId === 'broadcast' ? interestingClinics[0] : targetKlinikId;
deps.fetchPatientsForClinic(clinicToFetch, true);
refreshedSomething = true;
}
}
if (deps.globalInterestCount.value > 0) {
deps.fetchAllPatients();
refreshedSomething = true;
}
// ALWAYS refresh our own active interests when a WebSocket message is received
const interestingLokets = Object.keys(deps.activeLoketInterest.value);
const interestingClinics = Object.keys(deps.activeClinicInterest.value);
if (interestingLokets.length > 0) {
interestingLokets.forEach(loketId => {
if (String(loketId) !== String(targetLoketId)) {
deps.fetchPatientsForLoket(loketId, true);
refreshedSomething = true;
}
});
}
if (interestingClinics.length > 0) {
interestingClinics.forEach(kodeKlinik => {
if (String(kodeKlinik) !== String(targetKlinikId)) {
deps.fetchPatientsForClinic(kodeKlinik, true);
refreshedSomething = true;
}
});
}
if (!refreshedSomething) {
// console.log(`🔕 [queueSync] WS trigger received but no active interest matched. Skipping.`);
}
};
const config = useRuntimeConfig();
const wsBaseUrl = config.public?.wsBaseUrl || "ws://10.10.123.135:8084/api/v1/ws";
const { connect, disconnect, sendViaPost, isConnected } = useWebSocket({
url: wsBaseUrl,
clientId: wsClientId,
fallbackPostUrl: `${config.public.externalApiBaseUrl}/ws`,
reconnectInterval: 2000,
maxReconnectAttempts: 9999,
onOpen: () => {
// console.log('✅ [queueSync] WebSocket connected');
isWsConnected.value = true;
},
onClose: () => {
// console.log('❌ [queueSync] WebSocket disconnected');
isWsConnected.value = false;
},
onError: (err: any) => {
console.error('⚠️ [queueSync] WebSocket error:', err);
isWsConnected.value = false;
},
onMessage: onWsMessage
});
let _autoSyncInterval: any = null;
const startAutoSync = () => {
if (typeof window === 'undefined') return;
if (_autoSyncInterval) return;
// console.log('🔄 [queueSync] Starting store-level auto-sync (30s interval)');
_autoSyncInterval = setInterval(async () => {
const hasLoketInterest = Object.keys(deps.activeLoketInterest.value).length > 0;
const hasClinicInterest = Object.keys(deps.activeClinicInterest.value).length > 0;
const hasGlobalInterest = deps.globalInterestCount.value > 0;
if (hasGlobalInterest) {
deps.fetchAllPatients();
} else {
if (hasLoketInterest) {
Object.keys(deps.activeLoketInterest.value).forEach(loketId => {
deps.fetchPatientsForLoket(loketId, true);
});
}
if (hasClinicInterest) {
Object.keys(deps.activeClinicInterest.value).forEach(kodeKlinik => {
deps.fetchPatientsForClinic(kodeKlinik, true);
});
}
}
}, 30000); // 30 seconds
};
const stopAutoSync = () => {
if (_autoSyncInterval) {
clearInterval(_autoSyncInterval);
_autoSyncInterval = null;
// console.log('⏹️ [queueSync] Store-level auto-sync stopped');
}
};
const initWebSocket = (customClientId: string | null = null) => {
if (isConnected.value && customClientId === wsClientId.value) {
// console.log('🔌 [queueSync] WebSocket already connected with same ID.');
startAutoSync();
return;
}
if (customClientId) {
wsClientId.value = customClientId;
disconnect();
}
// console.log(`🔌 [queueSync] Connecting to WebSocket: ${wsBaseUrl} as ${wsClientId.value}`);
connect();
startAutoSync();
};
return {
isWsConnected,
wsClientId,
lastGlobalCall,
lastKlinikCall,
initWebSocket,
disconnectWebSocket: disconnect,
sendViaPost,
startAutoSync,
stopAutoSync
};
};
+17 -10
View File
@@ -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;
}
+2 -2
View File
@@ -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
+5 -5
View File
@@ -64,7 +64,7 @@ export const useWebSocket = (config: WebSocketConfig) => {
// Close existing connection if any, and CLEAN HANDLERS to prevent recursion
if (ws.value) {
if (ws.value.readyState !== WebSocket.CLOSED) {
console.log('🔌 Closing existing WebSocket before new connection...')
// console.log('🔌 Closing existing WebSocket before new connection...')
clearHandlers(ws.value)
ws.value.close()
}
@@ -75,7 +75,7 @@ export const useWebSocket = (config: WebSocketConfig) => {
ws.value = new WebSocket(connectionUrl)
ws.value.onopen = () => {
console.log('✅ WebSocket connected:', currentClientId.value)
// console.log('✅ WebSocket connected:', currentClientId.value)
isConnected.value = true
reconnectAttempts.value = 0
config.onOpen?.()
@@ -91,7 +91,7 @@ export const useWebSocket = (config: WebSocketConfig) => {
}
ws.value.onclose = () => {
console.log('❌ WebSocket closed:', currentClientId.value)
// console.log('❌ WebSocket closed:', currentClientId.value)
isConnected.value = false
config.onClose?.()
@@ -100,7 +100,7 @@ export const useWebSocket = (config: WebSocketConfig) => {
if (reconnectAttempts.value < (config.maxReconnectAttempts || 5)) {
reconnectAttempts.value++
const interval = config.reconnectInterval || 3000
console.log(`⏳ Reconnecting in ${interval}ms... Attempt ${reconnectAttempts.value}`)
// console.log(`⏳ Reconnecting in ${interval}ms... Attempt ${reconnectAttempts.value}`)
reconnectTimer.value = setTimeout(() => {
connect()
}, interval)
@@ -125,7 +125,7 @@ export const useWebSocket = (config: WebSocketConfig) => {
reconnectTimer.value = null
}
if (ws.value) {
console.log('🔌 Manual disconnect: Cleaning handlers and closing...')
// console.log('🔌 Manual disconnect: Cleaning handlers and closing...')
clearHandlers(ws.value)
ws.value.close()
ws.value = null
+3
View File
@@ -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
+741
View File
@@ -0,0 +1,741 @@
# 📓 DEVLOG — Development Log
**Project:** Web Antrean — Sistem Manajemen Antrean Rawat Jalan RSSA
**Author:** Akbar
**Stack:** JavaScript · Vue 3 · TypeScript · Nuxt 3 · Vuetify 3 · Pinia
---
## 🧠 AI Prompt — Cara Menggunakan Dokumen Ini
> Salin prompt berikut ke Claude untuk membantu mengisi DEVLOG harian:
```
Kamu adalah technical writer. Bantu aku menulis DEVLOG untuk hari ini.
Context:
- Project: Web Antrean — Sistem Manajemen Antrean Rawat Jalan RSSA
- Stack: Nuxt 3, Vue 3, TypeScript, Vuetify 3, Pinia, WebSocket, Keycloak
- Yang dikerjakan hari ini: [ceritakan bebas]
- Masalah yang ditemui: [ceritakan]
- Solusi yang diterapkan: [ceritakan]
Format output:
## [Tanggal] — [Judul Singkat]
**Yang dikerjakan:** ...
**Keputusan teknis:** ...
**Masalah & solusi:** ...
**Besok:** ...
```
---
## Format Entry
```markdown
## [YYYY-MM-DD] — [Judul Singkat Pekerjaan]
**Sprint/Phase:** [nama sprint atau fase]
**Durasi:** [X jam]
**Status:** ✅ Done | 🔄 In Progress | ⏸ Blocked
### Yang Dikerjakan
- ...
### Keputusan Teknis
> Jelaskan keputusan arsitektur/implementasi penting dan alasannya
### Masalah & Solusi
| Masalah | Solusi | Referensi |
|---------|--------|-----------|
| | | |
### Besok
- [ ] ...
### Referensi
- [link]
```
---
## Log Entries
<!-- Tambahkan entry baru di bawah baris ini, urutan terbaru di atas -->
## 2026-07-10 — Migrasi API Endpoint & Project Knowledge Base
**Sprint/Phase:** Phase 7 — Infrastruktur & Dokumentasi
**Durasi:** ~3 jam
**Status:** ✅ Done
### Yang Dikerjakan
- Migrasi endpoint API Antrian dari `10.10.123.140:8089` ke `10.10.150.131:8089` di `.env`
- `VERIFICATION_API_BASE_URL`
- `ANTRIAN_API_URL`
- `PROXY_TARGET_HOST_KLINIK_FALLBACK`
- Pembuatan **Project Knowledge Base** (`AGENTS.md`) di `.agents/` untuk agent AI context
- Dokumentasi lengkap arsitektur, store hierarchy, API endpoints, auth flow, gotchas
- File ini otomatis dimuat sebagai context di setiap session
- Update DEVLOG, DEVPLAN, dan PRD berdasarkan kondisi project terkini
### Keputusan Teknis
> **API endpoint migration**: Backend Antrian API dipindah ke server baru (`10.10.150.131`). Semua konfigurasi ada di `.env` — perubahan `.env` membutuhkan restart dev server karena Nuxt membaca env hanya saat startup. Masih ada technical debt berupa hardcoded fallback IP lama di ~25 file source code.
> **Project Knowledge Base**: Dibuat `.agents/AGENTS.md` sebagai single knowledge document yang mencakup arsitektur, store hierarchy, API config, auth flow, dan known gotchas. Ini mempercepat onboarding dan mengurangi kesalahan saat AI agent bekerja di codebase.
### Masalah & Solusi
| Masalah | Solusi | Referensi |
|---------|--------|-----------|
| API masih connect ke IP lama setelah ubah `.env` | Restart dev server (Ctrl+C → `npm run dev`). Nuxt hanya baca `.env` saat startup | `.env`, `nuxt.config.ts` |
| Hardcoded fallback IP di ~25 file | Documented sebagai tech debt. Ideally semua fallback dihapus, hanya andalkan `.env` | `AGENTS.md` |
| Banyak klinik tidak muncul di Admin Klinik Ruang | Filter `totalQuota === 0` di `ruangStore.js` menyembunyikan klinik dari API yang belum dikonfigurasi | `stores/ruangStore.js` |
### Besok
- [ ] Eliminasi hardcoded fallback IP lama di semua source files
- [ ] Verifikasi koneksi ke endpoint baru (`10.10.150.131:8089`)
- [ ] Investigasi klinik yang hilang dari Admin Klinik Ruang karena filter `totalQuota`
---
## 2026-07-09 — Fix Admin Klinik Ruang Missing Clinics
**Sprint/Phase:** Phase 7 — Bug Fixing
**Durasi:** ~1 jam
**Status:** ✅ Done
### Yang Dikerjakan
- Investigasi bug di halaman Admin Klinik Ruang (`/adminklinikruang`) — hanya 5 klinik muncul dari total ~23 klinik
- Root cause: Filter `totalQuota === 0` di `ruangStore.js` (`fetchRuangFromAPI()`) menyaring klinik yang:
1. Tidak ditemukan di clinicStore (default `totalQuota: 0`)
2. Belum dikonfigurasi kuotanya di seed data
- Fix: Menghapus kedua filter `totalQuota === 0` (line 387-391 dan line 480-483)
- User kemudian me-revert fix ini (mengembalikan filter) — menunjukkan filter ini memang diperlukan untuk business logic tertentu
### Keputusan Teknis
> Filter `totalQuota === 0` ternyata berfungsi ganda: (1) menyembunyikan klinik yang memang tidak aktif, (2) tapi juga menyembunyikan klinik baru dari API yang belum dikonfigurasi. Trade-off ini perlu didiskusikan dengan PO.
### Masalah & Solusi
| Masalah | Solusi | Referensi |
|---------|--------|-----------|
| Admin Klinik Ruang hanya tampil 5 klinik | Ditemukan filter `totalQuota === 0` di `ruangStore.fetchRuangFromAPI()` | `stores/ruangStore.js:387-391, 480-483` |
| Fix di-revert oleh user | Filter diperlukan untuk business logic — perlu pendekatan alternatif (misal: tampilkan tapi tandai sebagai "belum dikonfigurasi") | — |
---
## 2026-06-23 — Refactoring queueStore.js ke TypeScript (Phase 2: WebSocket & Typings)
**Sprint/Phase:** Phase 6 (Verifikasi Akun & Kiosk) / Refactoring Technical Debt
**Durasi:** 1 jam
**Status:** ✅ Done
### Yang Dikerjakan
- Membuat composable mandiri `composables/useQueueSync.ts` dan memindahkan lebih dari 200 baris logika *WebSocket* (`onWsMessage`, `connect`, `startAutoSync`) dari dalam *store*.
- Menjalankan *scripting* untuk me-resolve sebagian besar error peringatan `implicit any type` dan `catch(error: any)` di dalam `queueStore.ts`.
- Menerapkan injeksi dependencies dari `queueStore` ke dalam parameter `useQueueSync()` tanpa memutus *reactivity* dari Pinia state.
### Keputusan Teknis
> **Dependency Injection pada Composable:** Karena fungsionalitas WebSocket membutuhkan akses terhadap state (`allPatients`, `currentProcessingPatient`) dan actions (`fetchPatientsForLoket`, dll), maka variabel-variabel tersebut dilempar (injected) melalui parameter `deps` ke dalam fungsi `useQueueSync(deps)`. Hal ini mencegah masalah *circular dependencies* di ekosistem Nuxt/Pinia.
### Masalah & Solusi
| Masalah | Solusi | Referensi |
|---------|--------|-----------|
| Pinia-Plugin-PersistedState *type mismatch* pada `paths` config | Menambahkan flag `// @ts-ignore` untuk properti `paths` karena kompatibel secara *runtime* namun melanggar validasi tipe di rilis plugin yang terpasang. | QMD: Technical Debt |
### Besok
- Menguji secara manual interaksi antar *terminal* (Anjungan, Loket, Klinik) untuk memverifikasi fungsionalitas `useQueueSync`.
- Menulis Unit Tests (jika platform sudah siap).
---
## 2026-06-23 — Refactoring queueStore.js ke TypeScript (Phase 1)
**Sprint/Phase:** Phase 6 (Verifikasi Akun & Kiosk) / Refactoring Technical Debt
**Durasi:** 1 jam
**Status:** 🔄 In Progress
### Yang Dikerjakan
- Mengubah ekstensi file `queueStore.js` menjadi `queueStore.ts`.
- Membuat file definisi tipe antrean di `types/queue.ts` (`QueuePatient`).
- Mengekstrak raw fetch API calls (Loket, Visit, Update, Selesai Tiket) ke composable mandiri `composables/useQueueAPI.ts`.
- Melakukan transisi pada fungsi-fungsi besar (`fetchPatientsForLoket`, `callNext`, `callMultiplePatients`, `processPatient`) di dalam `queueStore.ts` untuk menggunakan `useQueueAPI` guna memangkas boilerplate fetch.
### Keputusan Teknis
> **Incremental Refactoring:** Menghindari pecahnya `queueStore` secara drastis dengan tetap mempertahankan satu *store instance* (`useQueueStore`) namun mengabstraksi beban logikanya keluar (*API* & *Sync layer*). Ini meminimalisasi *blast radius* / *breaking changes* ke komponen UI yang sudah jalan.
### Masalah & Solusi
| Masalah | Solusi | Referensi |
|---------|--------|-----------|
| Pembengkakan ukuran *store file* (>3500 baris, 145KB). | Mulai memisahkan logika pemanggilan API eksternal dan mendefinisikan strukturnya dengan TypeScript. | QMD: Technical Debt |
### Besok
- Membuat tipe yang strict untuk `state` dan seluruh *method signatures* di `queueStore.ts`.
- Mengekstraksi fungsionalitas WebSocket ke `composables/useQueueSync.ts`.
- Jika sudah ringan, memecah *store* menjadi `loketQueueStore` dan `clinicQueueStore`.
---
## 2026-06-19 — Fitur Detail Akun Verifikasi
**Sprint/Phase:** Phase 6 — Verifikasi Akun
**Durasi:** ~4 jam
**Status:** ✅ Done
### Yang Dikerjakan
- Penambahan halaman `DetailAkun.vue` (`400bfcd`)
- Update `VerifikasiAkun.vue` untuk integrasi dengan detail akun
- Modifikasi konfigurasi Vuetify di `plugins/vuetify.ts`
### Keputusan Teknis
> Pembuatan halaman detail akun terpisah untuk memberikan view lengkap informasi akun yang sedang diverifikasi, memperbaiki user experience admin saat memvalidasi data user.
---
## 2026-06-0809 — 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-0205 — 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-1011 — 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-0206 — 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-2830 — 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-2227 — 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-1921 — 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-1214 — 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-0809 — Klinik Ruang, QR & Print
**Sprint/Phase:** Phase 1 — Fondasi
**Durasi:** ~12 jam (2 hari)
**Status:** ✅ Done
### Yang Dikerjakan
- Layar baru antrean klinik dan antrean masuk (`2d3d589`)
- Update klinik ruang (`ca5913c`)
- Nav items (`8a4bb44`)
- Update print admin loket, logika klinik ruang, numbering antrian baru (`676bdc0`)
- Update QR function (`22d7205`)
- Perubahan format No Antrean (`6d5d565`)
- Check-in footers detail (`1b7a142`)
- Klinik ruang dan update admin loket (`9ea8300`)
### Keputusan Teknis
> Format nomor antrean: `[KodeKlinik]-[NomorUrut]` (contoh: `PDL-001`). Thermal print diimplementasi via `useThermalPrint.ts` composable untuk cetak tiket langsung dari browser ke printer thermal via Web API.
---
<!-- Tambahkan entry baru di atas baris ini, urutan terbaru di atas -->
---
## 📊 Summary Stats
| Metrik | Value |
|--------|-------|
| Total hari dev | ~60+ hari (Jan 2026 — Juli 2026) |
| Fase project | 7 fase (Setup → Fondasi → Hak Akses → Eksekutif → Stabilisasi → Verifikasi → Infrastruktur) |
| Fitur selesai | 8 modul utama + dokumentasi + migrasi infra |
| Total commits | 90+ commits |
| Bug ditemukan | 18+ (WebSocket, loket interference, memory, display sync, missing clinics, API endpoint) |
| Bug diselesaikan | 18+ |
| Halaman dibuat | 28+ halaman |
| Composables | 18 composable |
| Pinia Stores | 14 stores |
| Komponen | 25+ komponen |
| Dokumentasi | AGENTS.md, DEVLOG.md, DEVPLAN.md, PRD.md, API_ENDPOINTS_CLINIC_DOCTOR.md |
+262
View File
@@ -0,0 +1,262 @@
# 🗺️ DEVPLAN — Development Plan
**Project:** Web Antrean — Sistem Manajemen Antrean Rawat Jalan RSSA
**Version:** 1.1.0
**Author:** Akbar
**Stack:** JavaScript · Vue 3 · TypeScript · Nuxt 3 · Vuetify 3 · Pinia
**Last Updated:** 2026-07-10
---
## 🧠 AI Prompt — Cara Menggunakan Dokumen Ini
> Salin prompt berikut ke Claude untuk membantu membuat dev plan:
```
Kamu adalah tech lead senior fullstack (Nuxt 3, Vue 3, TypeScript).
Project: Web Antrean — Sistem Manajemen Antrean Rawat Jalan RSSA
Deskripsi: Digitalisasi alur antrean pasien rawat jalan di rumah sakit
Deadline: Mei 2026 (Go-Live)
Tim: 1 Fullstack Engineer (Akbar), PO Tim RSSA
Fitur yang harus dibangun:
1. Anjungan mandiri (kiosk registrasi pasien, pilih klinik/subspesialis)
2. Check-in pasien via QR code
3. Manajemen antrean loket & display screen
4. Manajemen antrean klinik & ruang
5. Manajemen antrean penunjang
6. Real-time sync via WebSocket
7. Dashboard monitoring & statistik
8. Manajemen user, master data, dan hak akses (Keycloak)
Buatkan:
1. Breakdown fase development (Phase 0 s/d launch)
2. Task list per fase dengan estimasi
3. Urutan prioritas fitur (MoSCoW)
4. Technical dependencies antar task
5. Milestone dan checkpoint
Format dalam tabel Markdown yang terstruktur.
```
---
## 1. Project Timeline
```
[Start]──Phase 0──Phase 1──Phase 2──Phase 3──Phase 4──Phase 5──Phase 6──Phase 7──[Launch]
Setup Fondasi Inti API Hak Akses Eksekutif Stabilisasi Verifikasi Infrastruktur
```
| Fase | Nama | Durasi | Target | Status |
|------|------|--------|--------|--------|
| Phase 0 | Setup & Arsitektur | 1 Minggu | Awal Jan 2026 | ✅ Done |
| Phase 1 | Fondasi (Loket, Klinik, QR) | 3 Minggu | Jan 2026 | ✅ Done |
| Phase 2 | Fitur Inti (Anjungan, WS, API) | 3 Minggu | Feb 2026 | ✅ Done |
| Phase 3 | Hak Akses & Keycloak | 2 Minggu | Apr 2026 | ✅ Done |
| Phase 4 | Fitur Eksekutif & Docker | 1 Minggu | Pertengahan Mei | ✅ Done |
| Phase 5 | Stabilisasi & Dokumentasi | 2 Minggu | Akhir Mei 2026 | ✅ Done |
| Phase 6 | Verifikasi Akun & Kiosk | 2 Minggu | Juni 2026 | ✅ Done |
| Phase 7 | Infrastruktur & Dokumentasi | Ongoing | Juli 2026 | 🔄 In Progress |
---
## 2. Feature Prioritization (MoSCoW)
| Priority | Fitur | Estimasi | Phase |
|----------|-------|----------|-------|
| 🔴 Must Have | Modul Anjungan (Pilih klinik, ambil tiket, cetak) | 40 jam | 2 |
| 🔴 Must Have | Modul Loket (Panggil, skip, recall, next) | 30 jam | 1 & 2 |
| 🔴 Must Have | Check-in via QR Code | 20 jam | 1 |
| 🔴 Must Have | Sinkronisasi Real-time via WebSocket | 30 jam | 2 |
| 🔴 Must Have | Integrasi API Eksternal (Visit API, Antrian API) | 40 jam | 2 |
| 🔴 Must Have | Manajemen Hak Akses Keycloak | 25 jam | 3 |
| 🟡 Should Have | Antrean Klinik & Penunjang | 30 jam | 1 & 4 |
| 🟡 Should Have | Dashboard Monitoring & Statistik | 15 jam | 1 |
| 🟡 Should Have | Pilihan Subspesialis (Klinik Eksekutif) | 10 jam | 4 |
| 🟢 Could Have | Notifikasi Suara Panggilan (Text-to-Speech) | 15 jam | Future |
| ⬜ Won't Have | Aplikasi Mobile Native | — | Out of scope |
| ⬜ Won't Have | Integrasi Billing / Pembayaran Langsung | — | Out of scope |
---
## 3. Task Breakdown
### Phase 0 — Setup & Arsitektur
| Task | Estimasi | Dependencies | Status |
|------|----------|--------------|--------|
| Init Nuxt 3 + TypeScript | 2 jam | — | ✅ Done |
| Setup Vuetify 3 + SCSS variables | 4 jam | Task 1 | ✅ Done |
| Setup Pinia state management | 2 jam | Task 1 | ✅ Done |
| Setup Vue Router (Nuxt pages/layout) | 3 jam | Task 1 | ✅ Done |
| Konfigurasi Proxy API di `nuxt.config.ts` | 3 jam | Task 1 | ✅ Done |
### Phase 1 — Fondasi (Loket, Klinik, QR)
| Task | Estimasi | Dependencies | Status |
|------|----------|--------------|--------|
| UI Layout, Sidebar, PageHeader | 8 jam | Phase 0 | ✅ Done |
| Komponen PatientCard & Tabel | 8 jam | Phase 0 | ✅ Done |
| UI Admin Loket & Admin Klinik | 12 jam | Task 2 | ✅ Done |
| Check-in Pasien UI & QR Scanner (`html5-qrcode`) | 12 jam | Phase 0 | ✅ Done |
| Generator Tiket QR (`qrcode.vue`) | 6 jam | Phase 0 | ✅ Done |
| Cetak Tiket Thermal (`useThermalPrint.ts`) | 8 jam | Phase 0 | ✅ Done |
### Phase 2 — Fitur Inti (Anjungan, WS, API)
| Task | Estimasi | Dependencies | Status |
|------|----------|--------------|--------|
| Integrasi Visit API & Antrian API | 16 jam | Phase 0 | ✅ Done |
| Setup WebSocket client (`useWebSocket.ts`) | 8 jam | Task 1 | ✅ Done |
| Layar Display Antrean (Klinik & Loket) | 12 jam | Task 2 | ✅ Done |
| Modul Anjungan Mandiri (UI & Logic) | 16 jam | Task 1 | ✅ Done |
| Centralized `queueStore` | 12 jam | Task 1, 2 | ✅ Done |
| Bugfix duplikasi data & WS handling | 12 jam | Task 2, 5 | ✅ Done |
### Phase 3 — Hak Akses & Keycloak
| Task | Estimasi | Dependencies | Status |
|------|----------|--------------|--------|
| Konfigurasi Keycloak SSO di frontend | 8 jam | — | ✅ Done |
| Middleware Auth & Guest | 4 jam | Task 1 | ✅ Done |
| CRUD Master Data (Klinik, Loket, Screen) | 12 jam | Phase 1 | ✅ Done |
| Manajemen Hak Akses User/Group (UI & API) | 12 jam | Task 1 | ✅ Done |
| Middleware Permission & Page Access Guard | 8 jam | Task 4 | ✅ Done |
### Phase 4 — Fitur Eksekutif & Docker
| Task | Estimasi | Dependencies | Status |
|------|----------|--------------|--------|
| UI Pemilihan Subspesialis di Anjungan | 8 jam | Phase 2 | ✅ Done |
| Conditional display spesialis di PatientCard | 4 jam | Task 1 | ✅ Done |
| Setup Dockerfile & docker-compose | 4 jam | — | ✅ Done |
| Update konfigurasi environment untuk container | 4 jam | Task 3 | ✅ Done |
### Phase 5 — Stabilisasi & QA
| Task | Estimasi | Dependencies | Status |
|------|----------|--------------|--------|
| Standardisasi WS Client ID (Deterministic) | 4 jam | Phase 2 | ✅ Done |
| Polling fallback 30s untuk layar display | 6 jam | Phase 2 | ✅ Done |
| Isolasi state loket (`currentProcessingPatient`) | 6 jam | Phase 2 | ✅ Done |
| Blacklist handler untuk endpoint gagal 500 | 4 jam | Phase 2 | ✅ Done |
| Dokumentasi PRD, QMD, DEVLOG, EVLOG | 8 jam | — | ✅ Done |
| Setup Unit Testing & E2E framework | 4 jam | — | ✅ Done |
### Phase 6 — Verifikasi Akun & Kiosk
| Task | Estimasi | Dependencies | Status |
|------|----------|--------------|--------|
| Sistem notifikasi suara (voice over) loket & klinik | 12 jam | Phase 2 | ✅ Done |
| Fitur pindah klinik pada dashboard admin | 8 jam | Phase 2 | ✅ Done |
| Halaman Detail Akun Verifikasi | 4 jam | Phase 3 | ✅ Done |
| Implementasi Proxy Routes (CORS) | 8 jam | Phase 2 | ✅ Done |
| Refactoring queueStore.js ke TypeScript | 8 jam | Phase 2 | ✅ Done |
| Ekstraksi useQueueSync composable | 4 jam | Phase 2 | ✅ Done |
### Phase 7 — Infrastruktur & Dokumentasi
| Task | Estimasi | Dependencies | Status |
|------|----------|--------------|--------|
| Migrasi API endpoint (123.140 → 150.131) | 2 jam | — | ✅ Done |
| Pembuatan Project Knowledge Base (AGENTS.md) | 3 jam | — | ✅ Done |
| Update DEVLOG, DEVPLAN, PRD | 2 jam | — | ✅ Done |
| Fix Admin Klinik Ruang missing clinics | 1 jam | Phase 2 | ✅ Done |
| Eliminasi hardcoded fallback IP di source files | 4 jam | Task 1 | ⏳ Pending |
| SQLite config → External API migration | 8 jam | Phase 2 | ⏳ Pending |
| Production deployment & monitoring | 8 jam | All | ⏳ Pending |
---
## 4. Technical Dependencies
```text
Nuxt 3 + TypeScript
├── Pinia (State Management)
│ └── pinia-plugin-persistedstate (localStorage)
├── Vuetify 3 (UI Framework)
│ └── Material Design Icons
├── Nuxt Nitro (Server Engine)
│ └── Server API Routes + SQLite (better-sqlite3)
├── Auth Layer
│ └── Keycloak SSO (OIDC/OAuth 2.0)
├── API Layer
│ ├── Visit API (10.10.123.135:8084)
│ └── Antrian API (10.10.150.131:8089) ← UPDATED
└── Real-time Layer
└── Native Browser WebSocket
```
---
## 5. Environment Setup
### Local Development
```bash
# Clone repo
git clone https://git.rssa.top/arie.bagus.2905/web-antrean
cd web-antrean
# Install dependencies
npm install
# Copy env
cp .env.example .env
# Jalankan dev server dengan host spesifik
npm run dev
# ATAU
npm run _command_dev
```
### Environment Variables (.env)
```env
AUTH_ORIGIN="http://10.10.150.175:3000"
KEYCLOAK_CLIENT_ID="akbar-test"
KEYCLOAK_ISSUER="https://auth.rssa.top/realms/sandbox"
ANTRIAN_API_URL="http://10.10.150.131:8089/api/v1" # UPDATED Juli 2026
VERIFICATION_API_BASE_URL="http://10.10.150.131:8089/api/v1" # UPDATED Juli 2026
VISIT_API_URL="http://10.10.123.135:8084/api/v1"
WS_API_URL="ws://10.10.123.135:8084/api/v1/ws"
PROXY_CLIENT_ORIGIN="http://10.10.150.175:3000"
PROXY_TARGET_HOST_KLINIK_FALLBACK="10.10.150.131:8089" # UPDATED Juli 2026
```
---
## 6. Git Strategy
### Branch Naming
```
main → production (stable)
Antrean-Code → development / staging
feature/xxx → fitur baru (contoh: feature/anjungan-eksekutif)
fix/xxx → bug fix (contoh: fix/ws-duplicate)
```
### Commit Convention
Saat ini commit message banyak menggunakan free-text (contoh: `push perbaikan`, `update bug ws`). Ke depannya, disarankan menggunakan **Conventional Commits**:
```
feat: tambah halaman dashboard
fix: perbaiki duplikasi antrean di loket
chore: update docker config
docs: update DEVPLAN
```
---
## 7. Milestones & Checkpoints
| Milestone | Kriteria | Target Tanggal | Status |
|-----------|----------|----------------|--------|
| 🏁 Project Kickoff | Setup Nuxt, Layout, Styling | Jan 2026 | ✅ Done |
| 🔌 API & WS Connected | Data mengalir dari backend, WS sync jalan | Feb 2026 | ✅ Done |
| 🔐 Auth & Roles | Keycloak jalan, routing terlindungi middleware | Apr 2026 | ✅ Done |
| 🏥 Anjungan Ready | Pasien bisa check-in & ambil tiket lancar | Pertengahan Mei | ✅ Done |
| 🛡️ Stability Pass | Tidak ada leak, isolasi loket aman, no WS drift | Akhir Mei 2026 | ✅ Done |
| 🔄 TypeScript Migration | queueStore refactored ke TS, composable extracted | Juni 2026 | ✅ Done |
| 📡 API Endpoint Migration | Antrian API pindah ke server baru (150.131) | Juli 2026 | ✅ Done |
| 🚀 Production Go-Live | Deployment docker di server production | Q3 2026 | 🔄 Pending |
---
## 8. Changelog
| Versi | Tanggal | Author | Perubahan |
|-------|---------|--------|-----------|
| 1.1.0 | 2026-07-10 | Akbar | Phase 7: migrasi API, knowledge base, update docs |
| 1.0.0 | 2026-05-25 | Akbar | Initial plan — direkonstruksi berdasarkan timeline aktual |
+544
View File
@@ -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** |
+84
View File
@@ -0,0 +1,84 @@
# 🌿 Git Workflow & Branching Guide
Dokumen ini berisi panduan *step-by-step* untuk mengelola Git *branch* di project **web-antrean**. Branch utama (main) untuk project ini adalah **`Antrean-Code`**.
---
## 1. Persiapan Sebelum Mengubah Kode (Membuat Branch Baru)
Sangat disarankan untuk tidak mengubah kode secara langsung di `Antrean-Code`. Buatlah branch baru untuk setiap fitur atau perbaikan *bug*.
**Langkah-langkah:**
1. Pastikan Anda berada di branch utama dan kodenya terbaru:
```bash
git checkout Antrean-Code
git pull origin Antrean-Code
```
2. Buat dan pindah ke branch baru (misalnya `fitur-baru`):
```bash
git checkout -b nama-fitur-anda
```
*(Contoh: `git checkout -b tes-refaktoring`)*
---
## 2. Menyimpan Perubahan (Commit)
Setelah Anda selesai menulis kode, ikuti langkah ini untuk menyimpannya.
**Langkah-langkah:**
1. Cek file apa saja yang berubah:
```bash
git status
```
2. Tambahkan file yang ingin disimpan:
```bash
git add .
```
*(Tanda `.` artinya menambahkan semua file. Anda juga bisa menyebut nama filenya satu-satu)*
3. Lakukan commit dengan pesan yang jelas (mengikuti aturan Conventional Commits di SKILL.md):
```bash
git commit -m "feat: menambah fitur X"
```
---
## 3. Menggabungkan Kode ke Branch Utama (Merge ke Antrean-Code)
Jika fitur di branch Anda sudah selesai dan teruji, saatnya menggabungkannya (*merge*) kembali ke `Antrean-Code`. Ini adalah tahapan yang baru saja kita lakukan.
**Langkah-langkah:**
1. Pastikan tidak ada perubahan yang belum di-commit di branch Anda (harus clean).
```bash
git status
```
2. Pindah ke branch utama (`Antrean-Code`):
```bash
git checkout Antrean-Code
```
3. Gabungkan (*merge*) branch fitur Anda (misal `tes-refaktoring`) ke `Antrean-Code`:
```bash
git merge tes-refaktoring
```
4. Jika tidak ada konflik, Git akan otomatis menyatukan kodenya. (Jika ada konflik, Anda harus memperbaikinya secara manual di code editor, lalu `git add .` dan `git commit`).
---
## 4. Mengunggah Kode ke Server (Push ke Remote)
Setelah merge selesai di komputer lokal, Anda harus mengunggah perubahannya ke server GitLab/GitHub.
**Langkah-langkah:**
1. Pastikan Anda berada di branch `Antrean-Code`.
2. Push ke remote origin:
```bash
git push origin Antrean-Code
```
---
## 5. Membersihkan Branch (Opsional)
Jika branch fitur sudah berhasil di-merge dan di-push, Anda bisa menghapusnya agar daftar branch lokal tetap rapi.
```bash
git branch -d tes-refaktoring
```
---
> **Catatan Penting:**
> Jangan lupa untuk selalu mengacu pada `docs/DEVLOG.md` saat Anda berhasil menggabungkan fitur besar untuk mencatat apa saja yang baru dirilis.
+409
View File
@@ -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.0021.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 | 6401024px | 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
View File
@@ -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 |
+3
View File
@@ -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,
+68 -14
View File
@@ -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>
+32 -17
View File
@@ -1,6 +1,8 @@
<template>
<div>
<!-- Header -->
<AdminKlinikEksekutif v-if="jenisLayanan === 'Eksekutif'" />
<div v-else>
<!-- Header -->
<PageHeader
icon="mdi-door-open"
:title="`Admin Klinik Ruang - ${klinikData?.namaKlinik || ''}`"
@@ -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(() => {
+196 -61
View File
@@ -304,6 +304,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 +341,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 +355,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 +409,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 +475,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 +498,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 +518,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 +529,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 +594,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 +709,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 +842,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 +904,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 +978,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 +1046,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 +1172,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 +1185,10 @@ onUnmounted(() => {
if (broadcastChannel) {
broadcastChannel.close();
}
if (loketId.value) {
queueStore.unregisterInterest(loketId.value);
}
});
const closeKlinikRuangDialog = () => {
@@ -1205,7 +1336,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,6 +1451,10 @@ const buatAntreanKlinikRuang = async (klinikRuang, ruang) => {
snackbarColor.value = result.success ? "success" : "error";
snackbar.value = true;
if (result.success) {
broadcastUpdate();
}
closeKlinikRuangDialog();
};
</script>
+29 -1
View File
@@ -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;
+5 -1
View File
@@ -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;
+28 -1
View File
@@ -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;
+5 -1
View File
@@ -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;
+9 -5
View File
@@ -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();
+6 -2
View File
@@ -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 = () => {
+5 -1
View File
@@ -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'
}
]
})
@@ -230,6 +235,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;
@@ -274,6 +316,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})`);
}
@@ -591,7 +636,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
+80 -12
View File
@@ -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) {
+36
View File
@@ -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
View File
@@ -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;
+383
View File
@@ -0,0 +1,383 @@
<script setup>
/**
* Halaman Jadwal Dokter Orchestrator utama.
* Menyediakan 2 mode tampilan:
* 1. List (default) tabel dengan filter poli, tanggal, nama dokter
* 2. Calendar kalender bulanan dengan event jadwal
*
* State management (jadwalList, selectedPoli, selectedDoctor) dikelola di sini
* dan diteruskan ke child component via props/defineModel.
*/
import PageHeader from '@/components/common/PageHeader.vue';
import JadwalDokterList from '@/components/features/master/jadwalDokter/JadwalDokterList.vue';
import JadwalDokterCalendar from '@/components/features/master/jadwalDokter/JadwalDokterCalendar.vue';
import DialogTambahJadwal from '@/components/features/master/jadwalDokter/DialogTambahJadwal.vue';
import DialogDetailEvent from '@/components/features/master/jadwalDokter/DialogDetailEvent.vue';
import { useClinicStore } from '@/stores/clinicStore.js';
// Tanggal Header
/** Mengembalikan string tanggal hari ini dalam format Indonesia */
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()}`;
});
// Mode Toggle
/** Mode tampilan aktif: 'list' (default) atau 'calendar' */
const viewMode = ref('list');
// Store & Data
const clinicStore = useClinicStore();
/** Daftar semua klinik/poli dari store */
const clinicList = computed(() =>
clinicStore.clinics.map(c => ({ id: c.id, title: c.name, kode: c.kode, doctors: c.doctors ?? [] }))
);
/** Poli yang dipilih (untuk mode calendar & dialog) */
const selectedPoli = ref(null);
/** Dokter yang dipilih (untuk mode calendar & dialog) */
const selectedDoctor = ref(null);
// Konstanta
const DAY_MAP = {
'Minggu': 0, 'Senin': 1, 'Selasa': 2, 'Rabu': 3,
'Kamis': 4, 'Jumat': 5, 'Sabtu': 6
};
// Storage Jadwal (local state)
/**
* Daftar jadwal yang sudah ditambahkan.
* Setiap entry: { id, poliId, poliName, dokter, tipe, hari?, jamMulai?, jamSelesai?, mulai?, selesai? }
*/
const jadwalList = ref([]);
jadwalList.value = [
{
"id": 1784689277259,
"poliId": 1000,
"poliName": "ANAK",
"dokter": "dr. Sarah Putri, Sp.A",
"tipe": "Adhoc",
"mulai": "2026-07-05T07:00:00",
"selesai": "2026-07-05T08:00:00"
},
{
"id": 1784689301842,
"poliId": 1000,
"poliName": "ANAK",
"dokter": "dr. Sarah Putri, Sp.A",
"tipe": "Rutin",
"hari": [
"Minggu",
],
"jamMulai": "10:00",
"jamSelesai": "12:00"
},
{
"id": 1784689301842,
"poliId": 1000,
"poliName": "ANAK",
"dokter": "dr. Sarah Putri, Sp.A",
"tipe": "Rutin",
"hari": [
"Kamis",
],
"jamMulai": "11:00",
"jamSelesai": "13:00"
},
{
"id": 1784689305888,
"poliId": 1000,
"poliName": "ANAK",
"dokter": "dr. Sarah Putri, Sp.A",
"tipe": "Adhoc",
"mulai": "2026-07-05T13:00:00",
"selesai": "2026-07-05T14:00:00"
},
{
"id": 178468930123,
"poliId": 1000,
"poliName": "ANAK",
"dokter": "dr. Sarah Putri, Sp.A",
"tipe": "Adhoc",
"mulai": "2026-07-05T19:00:00",
"selesai": "2026-07-05T20:00:00"
}
]
// Generate Events Kalender
/**
* Format tanggal ke string YYYY-MM-DD.
* @param {Date} date
* @returns {string}
*/
const toDateStr = (date) => {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
};
/** Tanggal referensi untuk navigasi bulan (digunakan oleh calendarEvents) */
const viewDate = ref(new Date());
/**
* Menghasilkan event kalender dari jadwalList yang tersimpan,
* difilter berdasarkan selectedPoli dan selectedDoctor.
* @returns {Array}
*/
const calendarEvents = computed(() => {
const events = [];
const year = viewDate.value.getFullYear();
const month = viewDate.value.getMonth();
const filtered = jadwalList.value.filter(j => {
if (selectedPoli.value && j.poliId !== selectedPoli.value) return false;
if (selectedDoctor.value && j.dokter !== selectedDoctor.value) return false;
return true;
});
filtered.forEach(jadwal => {
if (jadwal.tipe === 'Rutin') {
for (let mOffset = -1; mOffset <= 1; mOffset++) {
const tYear = month + mOffset < 0 ? year - 1 : month + mOffset > 11 ? year + 1 : year;
const tMonth = (month + mOffset + 12) % 12;
const daysInMonth = new Date(tYear, tMonth + 1, 0).getDate();
for (let d = 1; d <= daysInMonth; d++) {
const date = new Date(tYear, tMonth, d);
const dayName = Object.keys(DAY_MAP).find(k => DAY_MAP[k] === date.getDay());
if (!jadwal.hari.includes(dayName)) continue;
const dateStr = toDateStr(date);
/** Tambahkan jam ke title jika tersedia */
const jamLabel = jadwal.jamMulai && jadwal.jamSelesai
? ` ${jadwal.jamMulai} - ${jadwal.jamSelesai}`
: '';
events.push({
name: `${jamLabel}`,
start: jadwal.jamMulai ? new Date(`${dateStr}T${jadwal.jamMulai}:00`) : date,
end: jadwal.jamSelesai ? new Date(`${dateStr}T${jadwal.jamSelesai}:00`) : date,
color: 'primary',
allDay: !jadwal.jamMulai,
extendedProps: {
id: jadwal.id,
poli: jadwal.poliName,
dokter: jadwal.dokter,
tipe: 'Rutin',
hari: jadwal.hari.join(', '),
}
});
}
}
} else {
/** Format jam adhoc dari datetime string */
const adhocMulaiTime = jadwal.mulai ? jadwal.mulai.split('T')[1]?.slice(0, 5) : '';
const adhocSelesaiTime = jadwal.selesai ? jadwal.selesai.split('T')[1]?.slice(0, 5) : '';
const adhocJamLabel = adhocMulaiTime && adhocSelesaiTime
? ` ${adhocMulaiTime} - ${adhocSelesaiTime}`
: '';
events.push({
name: `${adhocJamLabel}`,
start: new Date(jadwal.mulai),
end: new Date(jadwal.selesai),
color: 'secondary',
allDay: false,
extendedProps: {
id: jadwal.id,
poli: jadwal.poliName,
dokter: jadwal.dokter,
tipe: 'Adhoc',
shift: `${formatDatetime(jadwal.mulai)} ${formatDatetime(jadwal.selesai)}`,
}
});
}
});
return events;
});
watch(jadwalList, () => {
console.log(jadwalList.value)
}, { deep: true })
/**
* Format ISO datetime ke string Indonesia yang ringkas.
* @param {string} isoStr
* @returns {string}
*/
const formatDatetime = (isoStr) => {
if (!isoStr) return '';
const d = new Date(isoStr);
return d.toLocaleString('id-ID', {
day: '2-digit', month: 'short', year: 'numeric',
hour: '2-digit', minute: '2-digit'
});
};
// Dialog Tambah Jadwal
/** Apakah dialog tambah jadwal terbuka */
const isAddOpen = ref(false);
/** Tanggal yang diklik sebagai default untuk form */
const clickedDate = ref(null);
/**
* Menangani klik pada tanggal di kalender.
* @param {Date} date - Tanggal yang diklik (sudah dinormalisasi oleh child)
*/
const handleDateClick = (date) => {
clickedDate.value = date;
isAddOpen.value = true;
};
/**
* Menyimpan jadwal baru ke jadwalList dari dialog.
* @param {object|Array} jadwalData - Data jadwal dari dialog
*/
const handleSaveJadwal = (jadwalData) => {
if (Array.isArray(jadwalData)) {
jadwalList.value.push(...jadwalData);
} else {
jadwalList.value.push(jadwalData);
}
};
/** Membuka dialog tambah dari tombol header */
const openAddDialog = () => {
clickedDate.value = new Date();
isAddOpen.value = true;
};
// Detail Event
/** Event yang sedang diklik */
const selectedEvent = ref(null);
/** Apakah dialog detail terbuka */
const isDetailOpen = ref(false);
/**
* Menangani klik event dari kalender.
* @param {object} eventData - Data event yang sudah dinormalisasi oleh child
*/
const handleEventClick = (eventData) => {
selectedEvent.value = eventData;
isDetailOpen.value = true;
};
/**
* Menghapus jadwal dari jadwalList berdasarkan ID event.
* @param {number} id - ID jadwal yang akan dihapus
*/
const handleDeleteJadwal = (id) => {
if (!id) return;
jadwalList.value = jadwalList.value.filter(j => j.id !== id);
};
/**
* Menangani klik edit dari mode list.
* @param {object} item - Item tabel yang akan di-edit
*/
const handleEdit = (item) => {
// Untuk saat ini, tampilkan detail event dari item pertama
if (item.raw && item.raw.length > 0) {
const first = item.raw[0];
selectedEvent.value = {
id: first.id,
poli: first.poliName,
dokter: first.dokter,
tipe: first.tipe,
hari: first.hari ? first.hari.join(', ') : null,
tanggal: new Date().toLocaleDateString('id-ID', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }),
};
isDetailOpen.value = true;
}
};
</script>
<template>
<div style="background: var(--color-neutral-300); height: 100%;">
<PageHeader icon="mdi-calendar-clock" title="Jadwal Dokter" :subtitle="currentDate" :show-add-button="false"
theme="primary">
<template #actions>
<div class="d-flex align-center gap-3">
<!-- Mode Toggle -->
<v-btn-toggle v-model="viewMode" mandatory density="compact" variant="outlined" color="white"
class="mode-toggle" divided>
<v-btn value="list" size="small">
<v-icon start size="18">mdi-format-list-bulleted</v-icon>
List
</v-btn>
<v-btn value="calendar" size="small">
<v-icon start size="18">mdi-calendar-month</v-icon>
Calendar
</v-btn>
</v-btn-toggle>
<!-- Tombol Tambah Dokter -->
<v-btn color="white" elevation="0" class="add-btn-primary ml-5" @click="openAddDialog">
<v-icon left size="20">mdi-plus-circle</v-icon>
Tambah Jadwal
</v-btn>
</div>
</template>
</PageHeader>
<v-container class="py-6">
<!-- Mode List (default) -->
<JadwalDokterList v-if="viewMode === 'list'" :clinic-list="clinicList" :jadwal-list="jadwalList"
@edit="handleEdit" @add="openAddDialog" />
<!-- Mode Calendar -->
<JadwalDokterCalendar v-else v-model:selected-poli="selectedPoli" v-model:selected-doctor="selectedDoctor"
:clinic-list="clinicList" :calendar-events="calendarEvents" @date-click="handleDateClick"
@event-click="handleEventClick" />
</v-container>
<!-- Dialog Tambah Jadwal (shared) -->
<DialogTambahJadwal v-model="isAddOpen" :view-mode="viewMode" :clinic-list="clinicList"
:selected-poli="selectedPoli" :selected-doctor="selectedDoctor" :clicked-date="clickedDate"
:jadwal-list="jadwalList"
@save="handleSaveJadwal" />
<!-- Dialog Detail Event (shared) -->
<DialogDetailEvent v-model="isDetailOpen" :event="selectedEvent" @delete="handleDeleteJadwal" />
</div>
</template>
<style scoped>
.mode-toggle {
border-radius: 8px !important;
overflow: hidden;
}
.mode-toggle .v-btn {
color: rgba(255, 255, 255, 0.8) !important;
text-transform: none !important;
font-weight: 600;
font-size: 0.8rem;
letter-spacing: 0.02em;
}
.mode-toggle .v-btn--active {
background-color: rgba(255, 255, 255, 0.2) !important;
color: white !important;
}
.add-btn-primary {
text-transform: none !important;
font-weight: 600;
font-size: 0.85rem;
letter-spacing: 0.02em;
border-radius: 8px;
}
</style>
+6 -9
View File
@@ -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>
+16 -39
View File
@@ -376,20 +376,21 @@
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';
watch(() => masterStore.ruangData.length, (newLen) => {
if (!search.value) filteredTotal.value = newLen;
}, { immediate: true });
@@ -511,9 +512,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 +525,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 +549,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
View File
@@ -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' };
}
};
+11 -25
View File
@@ -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' };
}
};
+552
View File
@@ -0,0 +1,552 @@
<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="2">
</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>
+30 -7
View File
@@ -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;
+14 -1
View File
@@ -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
View File
@@ -0,0 +1,54 @@
import re
import sys
path = r'e:\antrean operasi\web-antrean\stores\queueStore.ts'
try:
with open(path, 'r', encoding='utf-8') as f:
content = f.read()
# 1. Add import for useQueueSync at the top
if 'useQueueSync' not in content:
content = content.replace("import { useQueueAPI } from '@/composables/useQueueAPI';", "import { useQueueAPI } from '@/composables/useQueueAPI';\nimport { useQueueSync } from '@/composables/useQueueSync';")
# 2. Remove the WEBSOCKET INTEGRATION block
ws_pattern = re.compile(r' // WEBSOCKET INTEGRATION \(CENTRALIZED\).*? const disconnectWebSocket = \(\) => \{\n disconnect\(\);\n \};', re.DOTALL)
content = ws_pattern.sub(' // WS and AutoSync logic extracted to composables/useQueueSync.ts', content)
# 3. Add the initialization right before return
init_code = """
// Initialize Queue Sync
const queueSync = useQueueSync({
allPatients,
currentProcessingPatient,
activeLoketInterest,
activeClinicInterest,
globalInterestCount,
fetchPatientsForLoket,
fetchPatientsForClinic,
fetchAllPatients
});
const {
isWsConnected,
wsClientId,
lastGlobalCall,
lastKlinikCall,
initWebSocket,
disconnectWebSocket,
sendViaPost,
startAutoSync,
stopAutoSync
} = queueSync;
return {
"""
content = content.replace(" return {\n // State\n allPatients,", init_code + " // State\n allPatients,")
with open(path, 'w', encoding='utf-8') as f:
f.write(content)
print("Success")
except Exception as e:
print("Error:", e)
sys.exit(1)
+8
View File
@@ -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) };
});
+19
View File
@@ -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` };
});
+14
View File
@@ -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` };
});
+31
View File
@@ -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' };
});
+8
View File
@@ -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) };
});
+19
View File
@@ -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' };
});
+8
View File
@@ -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) };
});
+26
View File
@@ -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 });
}
});
+14
View File
@@ -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` };
});
+31
View File
@@ -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' };
});
+54
View File
@@ -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 };
}
}
});
+124
View File
@@ -0,0 +1,124 @@
// 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]) },
];
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;
};
+48 -60
View File
@@ -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,
});
+50 -58
View File
@@ -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 -2
View File
@@ -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
View File
@@ -94,6 +94,14 @@ export const useDoctorStore = defineStore('doctor', () => {
break; // Success!
} catch (err) {
lastError = err;
const status = err.response ? err.response.status : (err.status || 'unknown');
// Do not retry for 500 (Internal Server Error) or 404 (Not Found)
// as these are likely permanent failures for this specific clinic
if (status === 500 || status === 404) {
break;
}
if (i < retries) {
const delay = 500 * (i + 1);
console.warn(`⚠️ [doctorStore] Retry ${i+1}/${retries} for klinik ${idklinik} after ${delay}ms...`);
@@ -130,7 +138,12 @@ export const useDoctorStore = defineStore('doctor', () => {
return doctorNames;
} catch (error) {
const status = error.response ? error.response.status : (error.status || 'unknown');
console.error(`❌ [doctorStore] Gagal mengambil dokter untuk klinik ID ${idklinik} (${clinic.name}) - Status: ${status}`);
if (status === 500 || status === 404) {
console.warn(`⚠️ [doctorStore] Data dokter kosong/tidak ditemukan untuk klinik ID ${idklinik} (${clinic.name}) - Status: ${status}`);
} else {
console.error(`❌ [doctorStore] Gagal mengambil dokter untuk klinik ID ${idklinik} (${clinic.name}) - Status: ${status}`);
}
// Track failure for blacklisting
if (!failedClinics.value[idklinik]) {
@@ -184,6 +197,6 @@ export const useDoctorStore = defineStore('doctor', () => {
}, {
persist: {
key: 'doctor-store',
pick: ['doctorsByKlinikId', 'lastSyncTimestamp']
pick: ['lastSyncTimestamp']
}
});
+95 -247
View File
@@ -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,
});
+3 -2
View File
@@ -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
@@ -289,7 +290,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 +429,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}`);
}
+17 -16
View File
@@ -16,11 +16,11 @@ interface NavItem {
// 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" },
{
id: 3,
name: "Check In",
icon: "mdi-file-document-edit-outline",
{ id: 2, name: "Verifikasi Akun", icon: "mdi-account-check-outline", path: "/verifikasiAkun/VerifikasiAkun" },
{
id: 3,
name: "Check In",
icon: "mdi-file-document-edit-outline",
path: "/CheckInPasien/checkIn"
// badge: "3",
},
@@ -38,14 +38,14 @@ const defaultNavItems: NavItem[] = [
// { 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: "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" },
// { 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" },
{ id: 15, name: "Antrean Masuk", path: "/Anjungan/AntreanMasuk", icon: "mdi-circle-small" },
],
},
{
{
id: 15,
name: "Master Data",
icon: "mdi-cog-outline",
@@ -53,6 +53,7 @@ const defaultNavItems: NavItem[] = [
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: 25, name: "Master Jadwal Dokter", path: "/Setting/JadwalDokter", 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" },
@@ -82,7 +83,7 @@ export const useNavItemsStore = defineStore('navItems', () => {
// Jika navItems.value null, undefined, atau bukan array yang valid,
// kembalikan array default.
if (!Array.isArray(navItems.value) || navItems.value === null || navItems.value === undefined) {
return defaultNavItems;
return defaultNavItems;
}
return navItems.value;
});
@@ -106,12 +107,12 @@ export const useNavItemsStore = defineStore('navItems', () => {
async function refreshNavItems() {
const { getAllowedPages } = useHakAkses();
const allowedPages = await getAllowedPages();
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;
// 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;
}
const filterItems = (items: NavItem[]): NavItem[] => {
@@ -125,7 +126,7 @@ 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);
});
File diff suppressed because it is too large Load diff
+1 -5
View File
@@ -552,10 +552,6 @@ export const useRuangStore = defineStore('ruang', () => {
fetchRuangFromAPI,
};
}, {
persist: {
key: 'ruang-store-state',
storage: typeof window !== 'undefined' ? localStorage : undefined,
paths: ['ruangData'],
},
persist: false,
});
+51 -65
View File
@@ -1,31 +1,15 @@
// stores/screenStore.js
// Konfigurasi layar TV klinik — data disimpan di server SQLite via /api/config/screen
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useScreenStore = defineStore('screen', () => {
// Initial screen items data
const screenItems = ref([
{
id: 1,
namaScreen: "Layar Screen 1",
nomorScreen: "SCR-001",
klinik: ["AN", "AS", "BD", "GI", "GR", "GZ", "IP", "JT"],
},
{
id: 2,
namaScreen: "Layar Screen 2",
nomorScreen: "SCR-002",
klinik: ["JW", "KK", "MT", "SR", "OB", "PR"],
},
{
id: 3,
namaScreen: "Layar Screen 3",
nomorScreen: "SCR-003",
klinik: ["RT", "RM", "HO"],
},
]);
const screenItems = ref([]);
const isLoading = ref(false);
const error = ref(null);
const getAllScreens = computed(() => screenItems.value);
// Computed
const getScreenById = (id) => {
return computed(() => {
const targetId = Number(id);
@@ -33,64 +17,66 @@ export const useScreenStore = defineStore('screen', () => {
});
};
const getAllScreens = computed(() => screenItems.value);
// Actions
const addScreen = (screenPayload) => {
// Ensure we get a valid ID even if screenItems is empty
const maxId = screenItems.value.length > 0
? Math.max(...screenItems.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,
};
screenItems.value.push(newScreen);
return { success: true, message: `Screen ${newScreen.namaScreen} berhasil ditambahkan`, data: newScreen };
// ── Fetch from API ─────────────────────────────────────────────────────────
const fetchScreens = async () => {
if (isLoading.value) return;
isLoading.value = true;
error.value = null;
try {
const res = await $fetch('/api/config/screen');
screenItems.value = res.data || [];
} catch (e) {
error.value = e.message;
console.error('❌ [screenStore] Gagal fetch screen config:', e);
} finally {
isLoading.value = false;
}
};
const updateScreen = (screenPayload) => {
const index = screenItems.value.findIndex(s => s.id === screenPayload.id);
if (index !== -1) {
screenItems.value[index] = {
...screenItems.value[index],
...screenPayload,
};
return { success: true, message: `Konfigurasi ${screenPayload.namaScreen} berhasil disimpan` };
// ── CRUD via API ───────────────────────────────────────────────────────────
const addScreen = async (screenPayload) => {
try {
const res = await $fetch('/api/config/screen', { method: 'POST', body: screenPayload });
screenItems.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 deleteScreen = (screenId) => {
const index = screenItems.value.findIndex(s => s.id === screenId);
if (index !== -1) {
const screenName = screenItems.value[index].namaScreen;
screenItems.value.splice(index, 1);
return { success: true, message: `Screen ${screenName} berhasil dihapus` };
const updateScreen = async (screenPayload) => {
try {
const res = await $fetch(`/api/config/screen/${screenPayload.id}`, { method: 'PUT', body: screenPayload });
const index = screenItems.value.findIndex(s => s.id === screenPayload.id);
if (index !== -1) screenItems.value[index] = res.data;
return { success: true, message: res.message };
} catch (e) {
return { success: false, message: e.data?.message || e.message };
}
};
const deleteScreen = async (screenId) => {
try {
const res = await $fetch(`/api/config/screen/${screenId}`, { method: 'DELETE' });
screenItems.value = screenItems.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
screenItems,
// Computed
isLoading,
error,
getAllScreens,
getScreenById,
// Actions
fetchScreens,
addScreen,
updateScreen,
deleteScreen,
};
}, {
persist: {
key: 'screen-store-state',
storage: typeof window !== 'undefined' ? localStorage : undefined,
paths: ['screenItems'],
},
// No persist — data lives in the server DB
persist: false,
});
+48
View File
@@ -0,0 +1,48 @@
export interface QueuePatient {
no: number;
barcode: string;
noAntrian: string;
jamPanggil: string;
klinik: string;
kodeKlinik: string;
klinikId?: number;
healthcareServiceId?: number;
ruang?: string;
nomorRuang?: string;
kodeRuang?: string;
pembayaran?: string;
status: 'menunggu' | 'di-loket' | 'anjungan' | 'pemeriksaan' | 'pending' | 'terlambat' | 'selesai' | 'skip' | 'processed';
processStage: 'loket' | 'klinik-ruang';
createdAt: string;
visitType?: string;
noRM?: string;
fastTrack?: "YA" | "TIDAK";
registrationType?: 'api' | 'manual';
visitId?: number;
visitCode?: string;
referencePatient?: string;
loketId?: number;
calledByAdmin?: boolean;
lastCalledAt?: string;
lastCalledTipeLayanan?: string;
calledPemeriksaanAwal?: boolean;
calledTindakan?: boolean;
tipeLayanan?: string;
idtiket?: string;
ticket?: string;
posisi?: any[];
deskripsi?: string;
manuallyMoved?: boolean;
movedAt?: number;
idvisit?: number;
visitDate?: string;
namaDokter?: string | null;
penanggungJawab?: string | null;
alasanFastTrack?: string | null;
}
export interface ApiPatientResponse {
success: boolean;
message: string;
data?: QueuePatient[];
}