Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8745b22f07 | ||
|
|
b0b5200802 | ||
|
|
fec58f14b5 | ||
|
|
bbcaf9826c | ||
|
|
d0eee7d062 | ||
|
|
61772105fd |
No files matched your search
@@ -0,0 +1,304 @@
|
||||
# 📋 Project Knowledge — Web Antrean (Sistem Antrian Rumah Sakit)
|
||||
|
||||
> Dokumen ini berisi knowledge base lengkap, aturan pengkodean, dan workflow wajib untuk project **web-antrean**.
|
||||
> **WAJIB DIBACA** sebelum melakukan task apapun.
|
||||
|
||||
---
|
||||
|
||||
## 🚨 ATURAN WAJIB (MANDATORY RULES)
|
||||
|
||||
### Rule 1: Selalu Buat Implementation Plan Sebelum Eksekusi
|
||||
- **SEBELUM** menulis kode apapun, buat implementation plan di `docs/DEVPLAN.md` atau artifact
|
||||
- Plan harus mencakup: file yang akan diubah, perubahan spesifik, dan alasan
|
||||
- Tunggu persetujuan user sebelum eksekusi (kecuali fix minor/typo)
|
||||
|
||||
### Rule 2: Selalu Tambahkan Komentar di Setiap Fungsi
|
||||
- Setiap **function**, **method**, **computed**, dan **watcher** HARUS punya komentar JSDoc/TSDoc
|
||||
- Format minimum: `/** Deskripsi singkat fungsi ini */`
|
||||
- Untuk fungsi kompleks, tambahkan `@param`, `@returns`, `@example`
|
||||
- Komentar inline untuk logika yang tidak obvious
|
||||
|
||||
```javascript
|
||||
// ✅ BENAR
|
||||
/**
|
||||
* Mengambil data pasien dari API berdasarkan ID loket
|
||||
* @param {string} loketId - ID loket yang akan di-fetch
|
||||
* @returns {Promise<QueuePatient[]>} Daftar pasien dalam antrian
|
||||
*/
|
||||
const fetchPatientsForLoket = async (loketId) => { ... }
|
||||
|
||||
// ❌ SALAH — tanpa komentar
|
||||
const fetchPatientsForLoket = async (loketId) => { ... }
|
||||
```
|
||||
|
||||
### Rule 3: Selalu Update Dokumentasi Setelah Perubahan
|
||||
- **DEVLOG.md**: Tambahkan entry untuk setiap perubahan signifikan
|
||||
- **DEVPLAN.md**: Update status task yang sudah selesai
|
||||
- **PRD.md**: Update jika ada perubahan requirements/API/arsitektur
|
||||
- **AGENTS.md**: Update jika ada knowledge baru yang penting
|
||||
|
||||
### Rule 4: Selalu Baca AGENTS.md dan Skills Sebelum Eksekusi
|
||||
- Baca file ini SEBELUM mulai task apapun
|
||||
- Baca skill `web-antrean-coding-standards` untuk konvensi kode
|
||||
- Jangan mengulang kesalahan yang sudah tercatat di bagian Gotchas
|
||||
|
||||
### Rule 5: Jangan Mengulang Kesalahan yang Sudah Diketahui
|
||||
- Cek bagian **⚠️ Gotchas & Known Issues** sebelum debug
|
||||
- Cek **DEVLOG.md** untuk masalah serupa yang pernah diselesaikan
|
||||
- Jika menemukan masalah baru, catat di DEVLOG dan Gotchas
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Arsitektur & Tech Stack
|
||||
|
||||
| Layer | Teknologi |
|
||||
|---|---|
|
||||
| **Framework** | Nuxt 3 (`^3.17.7`) dengan SSR |
|
||||
| **UI Library** | Vuetify 3 (`^3.9.3`) + Material Design Icons (`@mdi/font`) |
|
||||
| **State Management** | Pinia (`^3.0.3`) + `pinia-plugin-persistedstate` |
|
||||
| **Styling** | SCSS (`sass-embedded`), design tokens di `assets/scss/` |
|
||||
| **Auth** | Keycloak OAuth2 (custom server-side implementation, bukan nuxt-auth) |
|
||||
| **Database (Server)** | SQLite via `better-sqlite3` — untuk konfigurasi device & user sync |
|
||||
| **Charting** | Chart.js + vue-chartjs + nuxt-charts |
|
||||
| **QR Code** | `html5-qrcode` (scanner) + `qrcode.vue` / `nuxt-qrcode` (generator) |
|
||||
| **WebSocket** | Custom composable (`useWebSocket.ts`) ke backend Go |
|
||||
| **Deployment** | Docker + docker-compose |
|
||||
| **Testing** | Vitest (unit) + Cypress (E2E) |
|
||||
| **Font** | Google Fonts (Inter 400-700) |
|
||||
|
||||
---
|
||||
|
||||
## 📁 Struktur Direktori
|
||||
|
||||
```
|
||||
web-antrean/
|
||||
├── app.vue # Entry point — store schema migration + loading overlay
|
||||
├── nuxt.config.ts # Konfigurasi Nuxt, Vuetify, runtime config, proxy
|
||||
├── .env # Environment variables (API URLs, auth, proxy)
|
||||
├── docker-compose.yml # Docker deployment config
|
||||
│
|
||||
├── .agents/ # Agent rules & skills
|
||||
│ ├── AGENTS.md # FILE INI — rules & knowledge base
|
||||
│ └── skills/ # Coding standards & workflow skills
|
||||
│
|
||||
├── docs/ # Dokumentasi project
|
||||
│ ├── DEVLOG.md # Development log (kronologis)
|
||||
│ ├── DEVPLAN.md # Development plan (task breakdown)
|
||||
│ └── PRD.md # Product Requirements Document
|
||||
│
|
||||
├── assets/scss/ # Design system
|
||||
│ ├── _colors.scss # Color tokens (CSS variables)
|
||||
│ ├── _variables.scss # Spacing, breakpoints, etc.
|
||||
│ ├── _typography.scss # Font styles
|
||||
│ └── main.scss # Global imports
|
||||
│
|
||||
├── components/
|
||||
│ ├── common/ # Shared: PageHeader, Avatar, AppSnackbar, SelectionDialog
|
||||
│ ├── layout/ # SideBar, ProfileMenu, ProfilePopup
|
||||
│ ├── features/ # Feature-specific (antrean, master, monitoring, queue)
|
||||
│ ├── AdminKlinik/ # Komponen admin klinik eksekutif
|
||||
│ ├── GrandPaviliun/ # Komponen Grand Paviliun (Gp*)
|
||||
│ ├── HakAkses/ # Komponen manajemen hak akses
|
||||
│ ├── MonitorPasien/ # Komponen monitoring pasien
|
||||
│ ├── checkin/ # Komponen check-in
|
||||
│ ├── verification/ # Komponen verifikasi akun
|
||||
│ └── preview/ # Komponen preview
|
||||
│
|
||||
├── composables/
|
||||
│ ├── useAuth.ts # Auth state & session management
|
||||
│ ├── useWebSocket.ts # WebSocket client dengan auto-reconnect
|
||||
│ ├── useQueueAPI.ts # API wrapper untuk antrian (verificationApiBaseUrl)
|
||||
│ ├── useVisitAPI.ts # API wrapper untuk visit (externalApiBaseUrl)
|
||||
│ ├── useQueueSync.ts # Sinkronisasi antrian antar client
|
||||
│ ├── useCheckIn.ts # Logika check-in pasien
|
||||
│ ├── useCheckInHistory.ts # Riwayat check-in
|
||||
│ ├── useClinicAPI.ts # API wrapper klinik
|
||||
│ ├── useGrandPaviliun.ts # Logika Grand Paviliun
|
||||
│ ├── useHakAkses.ts # Manajemen hak akses
|
||||
│ ├── usePermissions.ts # Permission check helper
|
||||
│ ├── useQRGenerator.ts # QR code generation
|
||||
│ ├── useQRScanner.ts # QR code scanning (html5-qrcode)
|
||||
│ ├── useThermalPrint.ts # Thermal printer (tiket antrian)
|
||||
│ ├── useSnackbar.ts # Toast notification
|
||||
│ └── useInfiniteScroll.ts # Infinite scroll pagination
|
||||
│
|
||||
├── layouts/
|
||||
│ ├── default.vue # Main layout — SideBar + middleware auth + checkPageAccess
|
||||
│ └── empty.vue # Empty layout (untuk halaman tanpa sidebar)
|
||||
│
|
||||
├── middleware/
|
||||
│ ├── auth.ts # Auth check → redirect ke /LoginPage jika belum login
|
||||
│ ├── guest.ts # Untuk halaman publik
|
||||
│ ├── checkPageAccess.ts # Cek akses halaman berdasarkan hak akses
|
||||
│ └── permissions.ts # Auto-sync permissions dari backend (DISABLED)
|
||||
│
|
||||
├── pages/ # Semua halaman (lihat detail di PRD.md section 8.1)
|
||||
│ ├── AdminKlinikRuang/ # [kodeKlinik].vue (~111KB)
|
||||
│ ├── AdminLoket/ # [id].vue (~58KB)
|
||||
│ ├── CheckInPasien/ # checkIn.vue (~230KB, LARGEST)
|
||||
│ ├── Anjungan/ # Display anjungan (kiosk)
|
||||
│ ├── Setting/ # Master data & konfigurasi
|
||||
│ └── ...
|
||||
│
|
||||
├── server/
|
||||
│ ├── api/auth/ # Auth endpoints (Keycloak OAuth flow)
|
||||
│ ├── api/users/ # User management (SQLite-backed)
|
||||
│ ├── api/config/ # Device configuration (SQLite-backed)
|
||||
│ ├── api/hak-akses/ # Hak akses management
|
||||
│ └── utils/ # configDb.ts, sessionStore.ts, userSync.ts
|
||||
│
|
||||
├── stores/ # Pinia stores (Single Source of Truth)
|
||||
│ ├── clinicStore.js # Master data klinik (~1007 lines)
|
||||
│ ├── queueStore.ts # Antrian pasien (~3302 lines, LARGEST)
|
||||
│ ├── loketStore.js # Data loket (~549 lines)
|
||||
│ ├── ruangStore.js # Data ruang klinik (~558 lines)
|
||||
│ ├── masterStore.js # BACKWARD COMPAT LAYER (jangan tulis langsung!)
|
||||
│ ├── penunjangStore.js # Data penunjang medis
|
||||
│ ├── doctorStore.js # Data dokter (fetched from API)
|
||||
│ └── ... # anjunganStore, screenStore, navItems1, dll
|
||||
│
|
||||
├── types/ # TypeScript interfaces & types
|
||||
│ ├── auth.ts, queue.ts, checkin.ts, setting.ts
|
||||
│
|
||||
└── data/
|
||||
└── users.db # SQLite database (user sync + device config)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔌 API Endpoints & External Services
|
||||
|
||||
### Environment Variables (`.env`)
|
||||
|
||||
| Variable | Deskripsi | Value (Juli 2026) |
|
||||
|---|---|---|
|
||||
| `ANTRIAN_API_URL` | API antrian utama (Go backend) | `http://10.10.150.131:8089/api/v1` |
|
||||
| `VERIFICATION_API_BASE_URL` | Alias untuk ANTRIAN_API_URL | (same) |
|
||||
| `VISIT_API_URL` | API visit/kunjungan pasien | `http://10.10.123.135:8084/api/v1` |
|
||||
| `WS_API_URL` | WebSocket URL | `ws://10.10.123.135:8084/api/v1/ws` |
|
||||
| `EXTERNAL_API_BASE_URL` | External API (JWT validation) | `http://10.10.123.135:8084` |
|
||||
| `KEYCLOAK_ISSUER` | Keycloak realm URL | `https://auth.rssa.top/realms/sandbox` |
|
||||
| `AUTH_ORIGIN` | Aplikasi origin URL | `http://10.10.150.175:3000` |
|
||||
| `HOST` | Dev server host | `http://10.10.150.175:3000` |
|
||||
|
||||
### Runtime Config (`nuxt.config.ts`)
|
||||
|
||||
```
|
||||
runtimeConfig.public.verificationApiBaseUrl → ANTRIAN_API_URL (antrian API)
|
||||
runtimeConfig.public.externalApiBaseUrl → VISIT_API_URL (visit API)
|
||||
runtimeConfig.public.wsBaseUrl → WS_API_URL (WebSocket)
|
||||
```
|
||||
|
||||
### External Backend APIs
|
||||
|
||||
1. **Antrian API** (`verificationApiBaseUrl`) — Go backend
|
||||
- `GET /klinik/reguler` — Fetch daftar klinik reguler
|
||||
- `GET /loket/ruang` — Fetch daftar ruangan per klinik
|
||||
- `GET /loket/{id}` — Fetch pasien per loket
|
||||
- `GET /dokter/{id}` — Fetch data dokter per klinik
|
||||
- `POST /tiket/generate` — Generate tiket antrian
|
||||
- `POST /tiket/checkin` — Check-in pasien
|
||||
- `GET /permission` — Fetch permission/hak akses
|
||||
|
||||
2. **Visit API** (`externalApiBaseUrl`) — Visit/kunjungan
|
||||
- `GET /visit?klinik_id={id}` — Fetch data pasien per klinik
|
||||
- `POST /external/validate-token` — Validate JWT token
|
||||
|
||||
3. **WebSocket** (`wsBaseUrl`)
|
||||
- Real-time queue updates, connected via `useWebSocket.ts`
|
||||
|
||||
> **PENTING**: Semua kode menggunakan `config.public.verificationApiBaseUrl` dengan fallback hardcoded. Saat ganti IP, ubah di `.env` lalu **restart dev server**. Nuxt hanya membaca `.env` saat startup!
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ Store Architecture
|
||||
|
||||
### Hirarki Store
|
||||
|
||||
```
|
||||
masterStore (BACKWARD COMPAT LAYER — JANGAN TULIS LANGSUNG)
|
||||
├── clinicStore → Single source of truth untuk data KLINIK
|
||||
├── loketStore → Data LOKET (counter antrian)
|
||||
├── ruangStore → Data RUANG per klinik
|
||||
└── penunjangStore → Data PENUNJANG medis
|
||||
|
||||
queueStore → Data ANTRIAN PASIEN (terbesar, 136KB)
|
||||
doctorStore → Data DOKTER (fetched per klinik)
|
||||
anjunganStore → Config ANJUNGAN (kiosk)
|
||||
screenStore → Config SCREEN display
|
||||
antreanMasukScreenStore → Config screen antrian masuk
|
||||
navItems1 → Navigation items + hak akses filter
|
||||
permissionStore → Permission data
|
||||
verificationStore → Verifikasi akun
|
||||
```
|
||||
|
||||
### Store Schema Migration
|
||||
- `app.vue` manages `STORE_SCHEMA_VERSION` (currently `3`)
|
||||
- Saat versi tidak cocok, semua localStorage Pinia di-clear
|
||||
- **Naikkan versi** saat ada perubahan struktur state
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Authentication Flow
|
||||
|
||||
1. **Login**: User klik login → `keycloak-login.ts` → redirect ke Keycloak
|
||||
2. **Callback**: Keycloak redirect back → `keycloak-callback.get.ts` → exchange code for token → create session cookie
|
||||
3. **Session**: `session.get.ts` validates cookie → returns user data
|
||||
4. **Middleware**: `auth.ts` checks session on every protected route
|
||||
5. **User Sync**: `userSync.ts` syncs Keycloak users to local SQLite DB
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Gotchas & Known Issues (HARUS DIBACA SEBELUM DEBUG)
|
||||
|
||||
1. **ENV reload**: Nuxt hanya membaca `.env` saat startup. Setelah ubah `.env`, HARUS restart dev server (`Ctrl+C` → `npm run dev`).
|
||||
|
||||
2. **Hardcoded fallback IPs**: Banyak file masih punya fallback IP lama. Saat ganti IP, ubah `.env` + restart server. JANGAN cuma ubah satu file.
|
||||
|
||||
3. **`totalQuota === 0` filter di ruangStore**: Klinik dengan `totalQuota: 0` diskip dari Admin Klinik Ruang. Ini menyembunyikan klinik yang valid tapi belum dikonfigurasi kuotanya. Trade-off yang disengaja.
|
||||
|
||||
4. **masterStore adalah proxy**: Jangan langsung tulis data ke masterStore. Selalu gunakan store yang sesuai (clinicStore, loketStore, ruangStore, penunjangStore).
|
||||
|
||||
5. **File besar** (hati-hati saat edit):
|
||||
- `CheckInPasien/checkIn.vue` (~230KB)
|
||||
- `queueStore.ts` (~136KB)
|
||||
- `AdminKlinikRuang/[kodeKlinik].vue` (~111KB)
|
||||
|
||||
6. **SQLite DB path**: `data/users.db` — digunakan untuk konfigurasi device & user sync. Di Docker, di-mount sebagai volume.
|
||||
|
||||
7. **WebSocket auto-upgrade**: `useWebSocket.ts` otomatis upgrade `ws://` ke `wss://` jika halaman diakses via HTTPS.
|
||||
|
||||
8. **Pinia persist `paths` type mismatch**: Gunakan `// @ts-ignore` untuk properti `paths` karena kompatibel runtime tapi melanggar validasi tipe.
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Key File Quick Reference
|
||||
|
||||
| Kebutuhan | File |
|
||||
|---|---|
|
||||
| Ganti API URL | `.env` → restart dev server |
|
||||
| Auth flow | `server/api/auth/` |
|
||||
| Konfigurasi Nuxt | `nuxt.config.ts` |
|
||||
| Data klinik | `stores/clinicStore.js` |
|
||||
| Data antrian | `stores/queueStore.ts` |
|
||||
| Data ruang | `stores/ruangStore.js` |
|
||||
| Data loket | `stores/loketStore.js` |
|
||||
| Sidebar/Navigation | `stores/navItems1.ts` + `components/layout/SideBar.vue` |
|
||||
| Design tokens | `assets/scss/_colors.scss`, `_variables.scss` |
|
||||
| Database schema | `server/utils/configDb.ts` |
|
||||
| User management | `server/api/users/` + `server/utils/userSync.ts` |
|
||||
| WebSocket | `composables/useWebSocket.ts` |
|
||||
| Thermal print | `composables/useThermalPrint.ts` |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Development Commands
|
||||
|
||||
```bash
|
||||
npm run dev # Start dev server
|
||||
npm run dev:https # Start with HTTPS + host 0.0.0.0
|
||||
npm run build # Production build
|
||||
npm run preview # Preview production build
|
||||
npm run test # Run Vitest
|
||||
docker compose up -d # Deploy production
|
||||
```
|
||||
@@ -0,0 +1,173 @@
|
||||
---
|
||||
name: web-antrean-coding-standards
|
||||
description: Aturan dasar pengkodean dan konvensi untuk project web-antrean berbasis Nuxt 3, Vue 3, TypeScript, Pinia, dan Vuetify.
|
||||
---
|
||||
|
||||
# 📜 Web Antrean Coding Standards
|
||||
|
||||
Skill ini berisi standar pengkodean wajib yang harus diikuti saat memodifikasi atau membuat kode baru di project `web-antrean`.
|
||||
|
||||
## 1. Komentar & Dokumentasi (WAJIB)
|
||||
- **Tiap Fungsi/Method/Computed/Watcher HARUS dikomentari.**
|
||||
- Gunakan JSDoc (`/** ... */`) untuk mendeskripsikan tujuan fungsi.
|
||||
- Jika fungsi kompleks atau memiliki parameter spesifik, gunakan tag `@param`, `@returns`.
|
||||
- Berikan komentar singkat (`//`) di dalam body fungsi jika ada logika yang tidak langsung jelas (non-obvious).
|
||||
|
||||
```typescript
|
||||
// ✅ CONTOH BENAR
|
||||
/**
|
||||
* Menghitung total antrean aktif untuk loket tertentu.
|
||||
* @param {string} loketId - ID unik dari loket.
|
||||
* @returns {number} Jumlah antrean berstatus 'menunggu'.
|
||||
*/
|
||||
const countActiveQueue = (loketId: string): number => {
|
||||
// Hanya hitung pasien yang belum dipanggil atau di-skip
|
||||
return allPatients.value.filter(p => p.loketId === loketId && p.status === 'menunggu').length;
|
||||
};
|
||||
```
|
||||
|
||||
## 2. Struktur Komponen (Vue 3 / Nuxt 3)
|
||||
- Gunakan `<script setup lang="ts">`. Sebisa mungkin gunakan TypeScript.
|
||||
- **Urutan blok dalam Vue SFC (Single File Component):**
|
||||
1. `<template>`
|
||||
2. `<script setup lang="ts">`
|
||||
3. `<style scoped>` (Lebih disukai `<style scoped lang="scss">`)
|
||||
- **Organisasi dalam `<script setup>`:**
|
||||
1. `imports` (Vue, composables, stores, types)
|
||||
2. `defineProps`, `defineEmits`, `defineExpose`
|
||||
3. State variables (`ref`, `reactive`)
|
||||
4. Computed properties (`computed`)
|
||||
5. Watchers (`watch`, `watchEffect`)
|
||||
6. Functions / Methods
|
||||
7. Lifecycle hooks (`onMounted`, `onUnmounted`)
|
||||
|
||||
## 3. Composables (`composables/`)
|
||||
- Nama file dan nama fungsi harus diawali dengan `use` (contoh: `useQueueSync.ts`, `export const useQueueSync = () => {}`).
|
||||
- Fokuskan composable pada satu fitur spesifik (contoh: `useThermalPrint` khusus print, `useWebSocket` khusus WS).
|
||||
- Jika mengembalikan state reaktif, kembalikan objek berisi `ref` atau `computed`.
|
||||
|
||||
## 4. Pinia Stores (`stores/`)
|
||||
- Gunakan format Setup Store (seperti composable) alih-alih Option Store.
|
||||
- Berikan tipe eksplisit (TypeScript) untuk state array/object yang kompleks.
|
||||
- Jangan melakukan mutasi data store lain secara langsung, gunakan actions.
|
||||
- **PENTING:** Jika ada store baru, daftarkan di `PINIA_STORE_KEYS` di `app.vue` jika perlu di-persist dan bersihkan localStorage jika `STORE_SCHEMA_VERSION` naik.
|
||||
|
||||
```typescript
|
||||
// ✅ CONTOH SETUP STORE
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
import type { QueuePatient } from '~/types/queue';
|
||||
|
||||
export const useMyStore = defineStore('myStore', () => {
|
||||
// State
|
||||
const patients = ref<QueuePatient[]>([]);
|
||||
|
||||
// Computed
|
||||
const activePatients = computed(() => patients.value.filter(p => p.status === 'menunggu'));
|
||||
|
||||
/**
|
||||
* Action untuk menambah pasien
|
||||
* @param {QueuePatient} newPatient
|
||||
*/
|
||||
const addPatient = (newPatient: QueuePatient) => {
|
||||
patients.value.push(newPatient);
|
||||
};
|
||||
|
||||
return { patients, activePatients, addPatient };
|
||||
});
|
||||
```
|
||||
|
||||
## 5. Integrasi API & Error Handling
|
||||
- Gunakan `useRuntimeConfig().public` untuk base URL (contoh: `verificationApiBaseUrl`).
|
||||
- **JANGAN** hardcode IP address (seperti `10.10.123.140`) di dalam kode komponen atau composable. Selalu ambil dari config.
|
||||
- Gunakan composable wrapper atau `useFetch`/`$fetch` dari Nuxt.
|
||||
- Harus ada blok `try...catch` untuk setiap pemanggilan API, dengan logging error yang jelas (minimal `console.error`).
|
||||
|
||||
## 6. Styling
|
||||
- Gunakan class Vuetify sebanyak mungkin (`d-flex`, `mt-4`, `text-h5`, `text-primary`).
|
||||
- Jika butuh custom style, gunakan `<style scoped lang="scss">` dan manfaatkan variabel dari `assets/scss/_colors.scss` atau `_variables.scss` jika memungkinkan.
|
||||
|
||||
## 7. Naming Conventions
|
||||
- **Komponen:** PascalCase (contoh: `PatientCard.vue`, `AdminKlinik.vue`).
|
||||
- **File/Folder (selain komponen):** camelCase atau kebab-case tergantung standar folder (Nuxt standard: pages biasanya kebab-case atau PascalCase jika spesifik view).
|
||||
- **Fungsi/Variabel:** camelCase (contoh: `fetchPatients`, `isActive`).
|
||||
- **Tipe Data (Types/Interfaces):** PascalCase (contoh: `QueuePatient`).
|
||||
|
||||
## 8. Aturan Refactoring (Refactoring Rules)
|
||||
Saat melakukan refactoring (terutama mengkonversi `.js` lama ke `.ts` atau memecah file besar), ikuti aturan wajib berikut:
|
||||
- **Incremental Refactoring (Bertahap):** Jangan merombak seluruh file raksasa (seperti `queueStore` atau `checkIn.vue`) sekaligus. Pisahkan sebagian logika (contoh: pisahkan API call ke `useQueueAPI`, WS ke `useQueueSync`) secara bertahap untuk meminimalisasi *blast radius* (kerusakan beruntun).
|
||||
- **Isolasi Logika (Separation of Concerns):** Komponen `.vue` hanya boleh berisi logika UI (presentational). Logika bisnis berat harus dipindah ke `composables/` atau `stores/`.
|
||||
- **Backward Compatibility (Kompabilitas Mundur):** Jika mengubah struktur *store* yang banyak dipakai (contoh: `masterStore`), jangan langsung menghapus properti lama. Buat *proxy/delegate* ke store baru sampai semua komponen selesai di-update.
|
||||
- **No Implicit `any`:** Saat memigrasi `.js` ke `.ts`, WAJIB mendefinisikan tipe data/interface yang jelas di direktori `types/`. Sebisa mungkin hindari penggunaan tipe `any`.
|
||||
- **Pertahankan Business Logic:** Refactoring HANYA mengubah struktur/kebersihan kode, **BUKAN** mengubah alur bisnis kecuali diminta secara spesifik. Pastikan fitur safety seperti *polling fallback 30s* atau *auto-reconnect WS* tidak terhapus tanpa sengaja.
|
||||
- **Dokumentasikan Perubahan:** Jika refactor mengubah alur arsitektur secara signifikan, selalu catat di `docs/DEVLOG.md` dan perbarui `AGENTS.md`.
|
||||
|
||||
## 9. Reusability & Prinsip DRY (Don't Repeat Yourself)
|
||||
- Jika suatu fungsi, blok UI, atau logika API dipakai di lebih dari dua tempat, **wajib** diekstrak menjadi `Composable` (untuk logika), `Component` (untuk UI), atau file utility di `utils/`.
|
||||
- Jangan melakukan copy-paste kode panjang antar halaman.
|
||||
|
||||
## 10. Penggunaan State (Lokal vs Global)
|
||||
- **Lokal (`ref` / `reactive`):** Gunakan untuk state yang hanya hidup di satu komponen (contoh: status loading tombol, form input sementara, toggle modal).
|
||||
- **Global (Pinia):** Gunakan HANYA untuk state yang perlu dibagikan lintas halaman/komponen (contoh: data pasien antrean berjalan, data user login, master data klinik).
|
||||
|
||||
## 11. Props & Emits (Komunikasi Antar Komponen)
|
||||
- Definisikan `props` secara eksplisit menggunakan `defineProps<{ ... }>()` dengan tipe data yang ketat.
|
||||
- Gunakan `defineEmits<{ ... }>()` untuk event child-to-parent. Hindari memanipulasi prop secara langsung dari child komponen (mutate prop mutation).
|
||||
|
||||
## 12. Manajemen Siklus Hidup (Lifecycle & Cleanup)
|
||||
- Jika menginisiasi *event listener* global (`window.addEventListener`), *timer* (`setInterval`), atau *WebSocket subscription* di `onMounted`, Anda **WAJIB** membersihkannya di `onUnmounted`.
|
||||
- Kegagalan melakukan *cleanup* akan menyebabkan kebocoran memori (memory leak) terutama di aplikasi SPA (Single Page Application).
|
||||
|
||||
## 13. Navigasi & Routing (Nuxt 3)
|
||||
- Gunakan `<NuxtLink>` untuk navigasi internal di template. JANGAN menggunakan tag `<a>` biasa karena akan memicu *full page reload*.
|
||||
- Untuk navigasi terprogram (programmatic) di dalam script, gunakan `navigateTo()` atau `useRouter().push()`.
|
||||
|
||||
## 14. Keamanan Dasar (Security Practices)
|
||||
- **JANGAN PERNAH** meletakkan token JWT, password, atau secret key secara hardcode di sisi klien.
|
||||
- Baca variabel sensitif melalui `useRuntimeConfig()` yang disuplai dari `.env`.
|
||||
- Selalu percayakan validasi otorisasi di sisi *server* (middleware/Nitro), jangan hanya menyembunyikan tombol di UI.
|
||||
|
||||
## 15. Penanganan Feedback UI (Error & Success Boundaries)
|
||||
- Jangan biarkan error API tertelan diam-diam (`swallowed error`).
|
||||
- Selalu tampilkan umpan balik ke pengguna jika terjadi kegagalan (misalnya menggunakan komponen `AppSnackbar` yang sudah ada).
|
||||
- Berikan indikator visual (spinner/loading state) saat memanggil API.
|
||||
|
||||
## 16. Magic Numbers & Magic Strings
|
||||
- Hindari hardcode angka atau string spesifik yang memiliki arti logika bisnis di tengah komponen (contoh: `if (status === 3)` atau `if (role === 'superadmin')`).
|
||||
- Pindahkan ke file konstan di `constants/` atau jadikan `Enum` di direktori `types/`.
|
||||
|
||||
## 17. Penggunaan Proxy API (Bypass CORS)
|
||||
- Untuk endpoint eksternal yang terhalang CORS (seperti API Visit atau API Antrian asli), panggil selalu via *Proxy Route* bawaan Nuxt Nitro (contoh: panggil `/visit-api/...` atau `/klinik-api/...`).
|
||||
- Jangan mencoba menebak atau merubah setting header CORS di sisi *client* Vue.
|
||||
|
||||
## 18. Konsistensi Design System
|
||||
- DILARANG melakukan *hardcode* warna HEX/RGB di tag `<style>` (contoh: `color: #ff0000;`).
|
||||
- Selalu gunakan SCSS variables dari `assets/scss/_colors.scss` (contoh: `color: var(--color-primary);`) atau gunakan *utility classes* bawaan Vuetify.
|
||||
|
||||
## 19. Optimasi Performa Vue (v-memo, v-once, Lazy)
|
||||
- Untuk *list rendering* yang sangat panjang (ratusan pasien), pertimbangkan penggunaan pagination, infinite scroll, atau virtual scrolling.
|
||||
- Gunakan `LazyComponent` (prefix `Lazy` di Nuxt) untuk modal atau komponen berat yang tidak langsung muncul di layar.
|
||||
|
||||
## 20. Penanganan Koneksi Real-time (WebSocket)
|
||||
- Pastikan ada mekanisme **Auto-Reconnect** dengan *backoff delay*.
|
||||
- Selalu miliki **Polling Fallback**. Jika WS mati total, sistem harus fallback mengambil data via HTTP `setInterval` setiap 30 detik agar layar antrean tidak *stuck*.
|
||||
- Cegah duplikasi data pesan (implementasi *deduplication logic* berdasarkan ID unik pesan).
|
||||
|
||||
## 21. Isolasi State per Entitas (Loket / Ruang)
|
||||
- Jangan gunakan variabel state tunggal untuk sesuatu yang berjalan paralel.
|
||||
- Contoh buruk: `currentPatient` (berisiko loket A tertukar pasien dengan loket B).
|
||||
- Contoh benar: `currentPatientPerLoket: Record<string, Patient>` (diakses dengan ID loket).
|
||||
|
||||
## 22. Format Koding & Tanda Baca
|
||||
- Ikuti standar *formatting* yang ada: indentasi 2 spasi, gunakan *single quotes* (`'`) untuk string JS/TS, dan selalu gunakan tanda titik koma (`;`) di akhir *statement* script.
|
||||
|
||||
## 23. Standar Pengujian (Testing Readiness)
|
||||
- Setiap logika kalkulasi yang rumit (contoh: perhitungan estimasi waktu tunggu, filter spesialisasi) harus ditulis murni (pure function) agar mudah di-unit test nantinya (Vitest). Jangan mencampur logika ini dengan DOM manipulation.
|
||||
|
||||
## 24. Git Workflow & Commit Messages
|
||||
- Tulis pesan *commit* yang jelas dan bermakna mengikuti pola *Conventional Commits*:
|
||||
- `feat: ...` (fitur baru)
|
||||
- `fix: ...` (perbaikan bug)
|
||||
- `refactor: ...` (perubahan struktur tanpa ubah fitur)
|
||||
- `docs: ...` (pembaruan dokumentasi)
|
||||
- Satu *commit* sebaiknya fokus pada satu perubahan logika/fitur saja.
|
||||
@@ -0,0 +1,28 @@
|
||||
// variables for Grand Paviliun (Eksekutif)
|
||||
.theme-grand-paviliun {
|
||||
--gp-primary: #3F51B5; // Indigo header
|
||||
--gp-primary-dark: #303F9F;
|
||||
--gp-bg: #F5F7FA; // Light grayish blue background
|
||||
--gp-surface: #FFFFFF;
|
||||
--gp-text-primary: #1F2937;
|
||||
--gp-text-secondary: #6B7280;
|
||||
--gp-border: #E5E7EB;
|
||||
|
||||
--gp-success: #10B981; // Selesai
|
||||
--gp-danger: #EF4444; // Pending
|
||||
--gp-warning: #F59E0B;
|
||||
--gp-info: #3B82F6;
|
||||
|
||||
// Custom colors for specific tags
|
||||
--gp-room-number: #F97316; // Orange text for RUANG 02
|
||||
|
||||
background-color: var(--gp-bg);
|
||||
min-height: 100vh;
|
||||
font-family: 'Inter', 'Roboto', sans-serif;
|
||||
}
|
||||
|
||||
// Utility classes
|
||||
.gp-text-primary { color: var(--gp-text-primary); }
|
||||
.gp-text-secondary { color: var(--gp-text-secondary); }
|
||||
.gp-font-bold { font-weight: 700; }
|
||||
.gp-font-semibold { font-weight: 600; }
|
||||
@@ -0,0 +1,174 @@
|
||||
<template>
|
||||
<div class="theme-grand-paviliun gp-container">
|
||||
<!-- Header -->
|
||||
<div class="gp-header">
|
||||
<div class="header-left">
|
||||
<v-icon size="24" color="white" class="mr-3">mdi-domain</v-icon>
|
||||
<div>
|
||||
<h1 class="header-title">Klinik Admin Grand Paviliun</h1>
|
||||
<p class="header-date">{{ currentDate }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<v-btn color="#F97316" variant="flat" class="text-white font-weight-bold">
|
||||
<v-icon start size="18">mdi-account-cog</v-icon>
|
||||
KELOLA PASIEN
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Bar -->
|
||||
<div class="gp-search-bar">
|
||||
<v-text-field
|
||||
v-model="searchQuery"
|
||||
placeholder="Cari pasien di semua ruang (barcode, nomor antrian, nama...)"
|
||||
density="compact"
|
||||
hide-details
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
variant="outlined"
|
||||
bg-color="white"
|
||||
class="search-input"
|
||||
></v-text-field>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="gp-main-content">
|
||||
<div class="kanban-board">
|
||||
<GpRoomColumn
|
||||
v-for="clinic in filteredClinics"
|
||||
:key="clinic.kodeKlinik"
|
||||
:clinic-name="clinic.namaKlinik"
|
||||
:current-patient="clinic.currentPatient"
|
||||
:queue-patients="clinic.queuePatients"
|
||||
:room-options="clinic.roomOptions"
|
||||
@pemeriksaan-awal="(p, r) => handlePemeriksaanAwal(p, clinic.kodeKlinik, r)"
|
||||
@panggil-pemeriksaan="(p, r) => handlePanggilPemeriksaan(p, clinic.kodeKlinik, r)"
|
||||
@action-pending="p => handleAction(p, 'pending', clinic.kodeKlinik)"
|
||||
@action-selesai="p => handleAction(p, 'selesai', clinic.kodeKlinik)"
|
||||
@process-patient="p => handleProcess(p, clinic.kodeKlinik, clinic.roomOptions?.[0]?.nomorRuang || '1')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Right Sidebar -->
|
||||
<GpMonitorSidebar :rooms="monitorRooms" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import GpRoomColumn from '../GrandPaviliun/GpRoomColumn.vue';
|
||||
import GpMonitorSidebar from '../GrandPaviliun/GpMonitorSidebar.vue';
|
||||
import { useGrandPaviliun } from '@/composables/useGrandPaviliun';
|
||||
import '@/assets/scss/eksekutif.scss';
|
||||
|
||||
const {
|
||||
clinics,
|
||||
monitorRooms,
|
||||
fetchEksekutifData,
|
||||
handlePemeriksaanAwal,
|
||||
handlePanggilPemeriksaan,
|
||||
processPatientAction
|
||||
} = useGrandPaviliun();
|
||||
const searchQuery = ref('');
|
||||
|
||||
const filteredClinics = computed(() => {
|
||||
if (!searchQuery.value) return clinics.value;
|
||||
const lowerSearch = searchQuery.value.toLowerCase();
|
||||
|
||||
// Filter by patient barcode, queue number, or name in the clinic columns
|
||||
return clinics.value.map(clinic => {
|
||||
const isCurrentMatch = clinic.currentPatient && (
|
||||
(clinic.currentPatient.noAntrian || '').toLowerCase().includes(lowerSearch) ||
|
||||
(clinic.currentPatient.barcode || '').toLowerCase().includes(lowerSearch) ||
|
||||
(clinic.currentPatient.name || '').toLowerCase().includes(lowerSearch)
|
||||
);
|
||||
|
||||
const matchedQueue = clinic.queuePatients.filter(p =>
|
||||
(p.noAntrian || '').toLowerCase().includes(lowerSearch) ||
|
||||
(p.barcode || '').toLowerCase().includes(lowerSearch) ||
|
||||
(p.name || '').toLowerCase().includes(lowerSearch)
|
||||
);
|
||||
|
||||
return {
|
||||
...clinic,
|
||||
currentPatient: isCurrentMatch ? clinic.currentPatient : null,
|
||||
queuePatients: matchedQueue
|
||||
};
|
||||
}).filter(c => c.currentPatient || c.queuePatients.length > 0);
|
||||
});
|
||||
|
||||
// Action Handlers
|
||||
const handleAction = async (patient, action, kodeKlinik) => {
|
||||
// Use the current room of the patient or fallback to '1'
|
||||
const nomorRuang = patient.nomorRuang || '1';
|
||||
await processPatientAction(patient, action, kodeKlinik, nomorRuang);
|
||||
};
|
||||
|
||||
const handleProcess = async (patient, kodeKlinik, fallbackRuang) => {
|
||||
const action = patient.status === 'pending' ? 'waiting' : 'proses';
|
||||
await processPatientAction(patient, action, kodeKlinik, fallbackRuang);
|
||||
};
|
||||
|
||||
// Date Formatting
|
||||
const currentDate = computed(() => {
|
||||
const options = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' };
|
||||
return new Date().toLocaleDateString('id-ID', options);
|
||||
});
|
||||
|
||||
// Load data when mounted
|
||||
fetchEksekutifData();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.gp-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background-color: var(--gp-bg, #F5F7FA);
|
||||
}
|
||||
.gp-header {
|
||||
background-color: var(--gp-primary, #3F51B5);
|
||||
color: white;
|
||||
padding: 16px 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.header-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.header-date {
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
margin: 0;
|
||||
}
|
||||
.gp-search-bar {
|
||||
padding: 12px 24px;
|
||||
background: white;
|
||||
border-bottom: 1px solid var(--gp-border, #e5e7eb);
|
||||
}
|
||||
.search-input {
|
||||
max-width: 100%;
|
||||
}
|
||||
.gp-main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
.kanban-board {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
padding: 16px 24px;
|
||||
align-items: stretch;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<div class="gp-current-patient">
|
||||
<div class="header-row">
|
||||
<span class="label">SEDANG DIPROSES</span>
|
||||
<div class="badges">
|
||||
<button class="action-badge" @click="$emit('action-pending')"><GpStatusBadge status="PENDING" /></button>
|
||||
<button class="action-badge ml-1" @click="$emit('action-selesai')"><GpStatusBadge status="SELESAI" /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="patient-big-info">
|
||||
<div class="queue-number">{{ queueNumber || '-' }}</div>
|
||||
<div class="room-indicator" v-if="roomName">{{ roomName }}</div>
|
||||
</div>
|
||||
|
||||
<div class="action-section">
|
||||
<label class="input-label">Tujukan ke ruang*</label>
|
||||
<v-select
|
||||
v-model="selectedRoom"
|
||||
:items="roomOptions"
|
||||
item-title="namaRuang"
|
||||
item-value="nomorRuang"
|
||||
placeholder="Pilih Ruang Pemeriksaan"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
class="room-select mb-3"
|
||||
></v-select>
|
||||
|
||||
<div class="action-buttons">
|
||||
<v-btn
|
||||
color="#3F51B5"
|
||||
variant="flat"
|
||||
class="flex-1 text-white text-none font-weight-bold"
|
||||
size="small"
|
||||
@click="$emit('pemeriksaan-awal')"
|
||||
>
|
||||
<v-icon start size="16">mdi-stethoscope</v-icon>
|
||||
Pemeriksaan Awal
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="#3F51B5"
|
||||
variant="flat"
|
||||
class="flex-1 text-white text-none font-weight-bold ml-2"
|
||||
size="small"
|
||||
@click="$emit('panggil-pemeriksaan')"
|
||||
>
|
||||
<v-icon start size="16">mdi-bullhorn</v-icon>
|
||||
Panggil Pemeriksaan
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import GpStatusBadge from './GpStatusBadge.vue';
|
||||
|
||||
const props = defineProps({
|
||||
queueNumber: { type: String, default: '' },
|
||||
roomName: { type: String, default: '' },
|
||||
roomOptions: { type: Array, default: () => [] }
|
||||
});
|
||||
|
||||
const emit = defineEmits(['pemeriksaan-awal', 'panggil-pemeriksaan', 'action-pending', 'action-selesai', 'update:selectedRoom']);
|
||||
|
||||
const selectedRoom = ref(props.roomOptions?.[0]?.nomorRuang || null);
|
||||
watch(selectedRoom, (newVal) => {
|
||||
emit('update:selectedRoom', newVal);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.gp-current-patient {
|
||||
background: white;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--gp-border, #e5e7eb);
|
||||
}
|
||||
.header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--gp-text-secondary, #6b7280);
|
||||
}
|
||||
.patient-big-info {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.queue-number {
|
||||
font-size: 36px;
|
||||
font-weight: 800;
|
||||
color: var(--gp-text-primary, #1f2937);
|
||||
line-height: 1.2;
|
||||
}
|
||||
.room-indicator {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--gp-room-number, #F97316);
|
||||
margin-top: 4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.input-label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--gp-text-secondary, #6b7280);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.room-select {
|
||||
/* vuetify default is fine */
|
||||
}
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
.flex-1 {
|
||||
flex: 1;
|
||||
}
|
||||
.action-badge {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
.action-badge:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<div class="gp-monitor-sidebar">
|
||||
<div class="sidebar-header">
|
||||
<v-icon size="18" class="mr-2" color="#3F51B5">mdi-monitor-dashboard</v-icon>
|
||||
<span class="title">MONITOR RUANG</span>
|
||||
</div>
|
||||
|
||||
<div class="stats-row">
|
||||
<div class="stat-box box-success">
|
||||
<div class="stat-label text-success">TERSEDIA</div>
|
||||
<div class="stat-value text-success">{{ availableCount }}</div>
|
||||
</div>
|
||||
<div class="stat-box box-danger ml-2">
|
||||
<div class="stat-label text-danger">SIBUK</div>
|
||||
<div class="stat-value text-danger">{{ busyCount }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rooms-list">
|
||||
<GpRoomMonitorCard
|
||||
v-for="room in rooms"
|
||||
:key="room.id"
|
||||
:room-name="room.name"
|
||||
:active-patient="room.activePatient"
|
||||
:clinic-name="room.clinicName"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import GpRoomMonitorCard from './GpRoomMonitorCard.vue';
|
||||
|
||||
const props = defineProps({
|
||||
rooms: { type: Array, default: () => [] }
|
||||
});
|
||||
|
||||
const availableCount = computed(() => props.rooms.filter(r => !r.activePatient).length);
|
||||
const busyCount = computed(() => props.rooms.filter(r => r.activePatient).length);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.gp-monitor-sidebar {
|
||||
width: 300px;
|
||||
background: white;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-left: 1px solid var(--gp-border, #e5e7eb);
|
||||
}
|
||||
.sidebar-header {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--gp-border, #e5e7eb);
|
||||
}
|
||||
.title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--gp-text-primary, #1f2937);
|
||||
}
|
||||
.stats-row {
|
||||
display: flex;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--gp-border, #e5e7eb);
|
||||
}
|
||||
.stat-box {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.box-success {
|
||||
background: #ECFDF5;
|
||||
border: 1px solid #D1FAE5;
|
||||
}
|
||||
.box-danger {
|
||||
background: #FFF7ED;
|
||||
border: 1px solid #FFEDD5;
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.text-success { color: var(--gp-success, #10b981); }
|
||||
.text-danger { color: var(--gp-room-number, #F97316); } /* Orange */
|
||||
|
||||
.rooms-list {
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div class="gp-queue-list-container">
|
||||
<div class="list-header">
|
||||
<span class="title">DAFTAR ANTRIAN</span>
|
||||
<span class="count-badge">{{ patients.length }}</span>
|
||||
</div>
|
||||
<div class="list-body">
|
||||
<GpQueueListItem
|
||||
v-for="patient in patients"
|
||||
:key="patient.id || patient.noAntrian"
|
||||
:queue-number="patient.noAntrian"
|
||||
:status="patient.status"
|
||||
:is-active="patient.isActive"
|
||||
@view-detail="$emit('view-detail', patient)"
|
||||
@process-patient="$emit('process-patient', patient)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import GpQueueListItem from './GpQueueListItem.vue';
|
||||
|
||||
defineProps({
|
||||
patients: { type: Array, default: () => [] }
|
||||
});
|
||||
|
||||
defineEmits(['view-detail', 'process-patient']);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.gp-queue-list-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
background: white;
|
||||
min-height: 200px;
|
||||
}
|
||||
.list-header {
|
||||
padding: 12px 16px;
|
||||
background: #EEF2F6; /* Light blue-gray from design */
|
||||
border-bottom: 1px solid var(--gp-border, #e5e7eb);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.title {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--gp-text-secondary, #6b7280);
|
||||
}
|
||||
.count-badge {
|
||||
background: #D1D5DB; /* Gray */
|
||||
color: white;
|
||||
border-radius: 12px;
|
||||
padding: 2px 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.list-body {
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<div :class="['gp-queue-list-item', { active: isActive }]">
|
||||
<div class="item-left">
|
||||
<span class="queue-number">{{ queueNumber }}</span>
|
||||
</div>
|
||||
<div class="item-right">
|
||||
<GpStatusBadge v-if="status && !isActive" :status="status" />
|
||||
<v-btn v-if="!isActive" icon="mdi-play" size="x-small" variant="text" color="success" class="ml-1" @click="$emit('process-patient')" />
|
||||
<v-btn v-if="isActive" icon="mdi-eye" size="x-small" variant="text" color="#3F51B5" @click="$emit('view-detail')" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import GpStatusBadge from './GpStatusBadge.vue';
|
||||
|
||||
defineProps({
|
||||
queueNumber: { type: String, required: true },
|
||||
status: { type: String, default: '' },
|
||||
isActive: { type: Boolean, default: false }
|
||||
});
|
||||
|
||||
defineEmits(['view-detail', 'process-patient']);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.gp-queue-list-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--gp-border, #e5e7eb);
|
||||
background-color: var(--gp-surface, #fff);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
.gp-queue-list-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.gp-queue-list-item.active {
|
||||
background-color: #E3EBFF; /* Light blue highlighting active patient */
|
||||
}
|
||||
.queue-number {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--gp-text-primary, #1f2937);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div class="gp-room-column">
|
||||
<div class="column-header">
|
||||
<v-icon size="20" class="mr-2" color="white">mdi-domain</v-icon>
|
||||
<span class="column-title">{{ clinicName.toUpperCase() }}</span>
|
||||
</div>
|
||||
|
||||
<div class="column-content">
|
||||
<template v-if="currentPatient || queuePatients.length > 0">
|
||||
<GpCurrentPatient
|
||||
v-if="currentPatient"
|
||||
:queue-number="currentPatient.noAntrian"
|
||||
:room-name="currentPatient.roomIndicator"
|
||||
:room-options="roomOptions"
|
||||
@pemeriksaan-awal="$emit('pemeriksaan-awal', currentPatient, $event)"
|
||||
@panggil-pemeriksaan="$emit('panggil-pemeriksaan', currentPatient, $event)"
|
||||
@action-pending="$emit('action-pending', currentPatient)"
|
||||
@action-selesai="$emit('action-selesai', currentPatient)"
|
||||
/>
|
||||
|
||||
<GpQueueList
|
||||
:patients="queuePatients"
|
||||
@view-detail="p => $emit('view-detail', p)"
|
||||
@process-patient="p => $emit('process-patient', p)"
|
||||
/>
|
||||
</template>
|
||||
<div v-else class="empty-column-state">
|
||||
<v-icon size="48" color="#D1D5DB" class="mb-4">mdi-account-off</v-icon>
|
||||
<div class="empty-text">TIDAK ADA PASIEN YANG DIPROSES</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import GpCurrentPatient from './GpCurrentPatient.vue';
|
||||
import GpQueueList from './GpQueueList.vue';
|
||||
|
||||
defineProps({
|
||||
clinicName: { type: String, required: true },
|
||||
currentPatient: { type: Object, default: null },
|
||||
queuePatients: { type: Array, default: () => [] },
|
||||
roomOptions: { type: Array, default: () => [] }
|
||||
});
|
||||
|
||||
defineEmits(['pemeriksaan-awal', 'panggil-pemeriksaan', 'view-detail', 'action-pending', 'action-selesai', 'process-patient']);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.gp-room-column {
|
||||
width: 380px;
|
||||
min-width: 380px;
|
||||
background: var(--gp-bg, #F5F7FA);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
margin-right: 16px;
|
||||
}
|
||||
.column-header {
|
||||
background: var(--gp-primary, #3F51B5);
|
||||
color: white;
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.column-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.column-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: white; /* Make the whole column body white */
|
||||
}
|
||||
.empty-column-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
background: white;
|
||||
}
|
||||
.empty-text {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--gp-text-secondary, #6b7280);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div class="gp-room-monitor-card" :class="{ 'is-busy': isBusy }">
|
||||
<div class="card-header">
|
||||
<span class="room-name">{{ roomName }}</span>
|
||||
<span class="room-status" :class="isBusy ? 'text-danger' : 'text-success'">
|
||||
{{ isBusy ? 'SIBUK' : 'TERSEDIA' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<span v-if="isBusy" class="active-patient">{{ activePatient }} - {{ clinicName }}</span>
|
||||
<span v-else class="waiting-text">Menunggu pasien...</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
roomName: { type: String, required: true },
|
||||
activePatient: { type: String, default: null }, // e.g. "AI002"
|
||||
clinicName: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const isBusy = computed(() => !!props.activePatient);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.gp-room-monitor-card {
|
||||
background: var(--gp-surface, #fff);
|
||||
border: 1px solid var(--gp-border, #e5e7eb);
|
||||
border-left: 4px solid var(--gp-success, #10b981);
|
||||
border-radius: 2px;
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.02);
|
||||
}
|
||||
.gp-room-monitor-card.is-busy {
|
||||
border-left-color: var(--gp-room-number, #F97316); /* Orange */
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.room-name {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--gp-text-primary, #1f2937);
|
||||
}
|
||||
.room-status {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.text-success { color: var(--gp-success, #10b981); }
|
||||
.text-danger { color: var(--gp-room-number, #F97316); } /* Matching image orange */
|
||||
|
||||
.card-body {
|
||||
font-size: 13px;
|
||||
}
|
||||
.active-patient {
|
||||
color: var(--gp-primary, #3F51B5);
|
||||
font-weight: 700;
|
||||
}
|
||||
.waiting-text {
|
||||
color: var(--gp-text-secondary, #6b7280);
|
||||
font-style: italic;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<span :class="['gp-badge', badgeClass]">
|
||||
{{ label }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
status: { type: String, required: true },
|
||||
text: { type: String, default: '' }
|
||||
});
|
||||
|
||||
const label = computed(() => props.text || props.status.toUpperCase());
|
||||
|
||||
const badgeClass = computed(() => {
|
||||
const s = props.status.toLowerCase();
|
||||
if (s === 'pending' || s === 'sibuk') return 'gp-badge-danger';
|
||||
if (s === 'selesai' || s === 'tersedia' || s === 'proses') return 'gp-badge-success';
|
||||
return 'gp-badge-default';
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.gp-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px 6px;
|
||||
border-radius: 2px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
color: white;
|
||||
}
|
||||
.gp-badge-danger { background-color: var(--gp-danger, #ef4444); }
|
||||
.gp-badge-success { background-color: var(--gp-success, #10b981); }
|
||||
.gp-badge-default { background-color: var(--gp-text-secondary, #6b7280); }
|
||||
</style>
|
||||
@@ -0,0 +1,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,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
|
||||
};
|
||||
};
|
||||
@@ -94,6 +94,14 @@ export const useQueue = (adminType = "loket", specificId = null) => {
|
||||
const isAssignedToThis = p.loketId && String(p.loketId) === String(targetId);
|
||||
if (isAssignedToThis) return true;
|
||||
|
||||
// Add strict isolation between Eksekutif and Reguler for unassigned tickets
|
||||
const isPatientEksekutif = p.noAntrian && (String(p.noAntrian).startsWith('E') || String(p.noAntrian).startsWith('F-E'));
|
||||
const isLoketEksekutif = thisLoket.tipeLoket === 'EKSEKUTIF' || thisLoket.id >= 1000;
|
||||
|
||||
if (isPatientEksekutif !== isLoketEksekutif) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isServedByThis = !p.loketId && thisLoket.pelayanan && Array.isArray(thisLoket.pelayanan) && thisLoket.pelayanan.includes(p.kodeKlinik);
|
||||
|
||||
return isServedByThis;
|
||||
|
||||
+41
-172
@@ -1,193 +1,62 @@
|
||||
// composables/useQueueAPI.ts
|
||||
// Composable untuk API calls terkait antrian pasien
|
||||
|
||||
export interface Patient {
|
||||
no: number;
|
||||
jamPanggil: string;
|
||||
barcode: string;
|
||||
noAntrian: string;
|
||||
shift: string;
|
||||
klinik: string;
|
||||
fastTrack: string;
|
||||
pembayaran: string;
|
||||
status: 'anjungan' | 'pending' | 'di-loket' | 'di-klinik' | 'selesai' | 'terlambat';
|
||||
processStage: 'loket' | 'klinik' | 'penunjang';
|
||||
createdAt: string;
|
||||
registrationType?: 'online' | 'onsite';
|
||||
visitType?: string;
|
||||
visitDate?: string;
|
||||
}
|
||||
|
||||
export interface QueueAPIResponse<T = any> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
import { useRuntimeConfig } from '#app';
|
||||
import type { QueuePatient } from '@/types/queue';
|
||||
|
||||
export const useQueueAPI = () => {
|
||||
const config = useRuntimeConfig();
|
||||
const baseURL = config.public.apiBaseUrl || '/api/queue';
|
||||
const verificationApiBase = config.public.verificationApiBaseUrl || 'http://10.10.123.140:8089/api/v1';
|
||||
const externalApiBase = config.public.externalApiBaseUrl;
|
||||
|
||||
/**
|
||||
* Fetch all patients from database
|
||||
*/
|
||||
const fetchAllPatients = async (): Promise<Patient[]> => {
|
||||
const fetchRawLoketPatients = async (loketId: string | number) => {
|
||||
try {
|
||||
const response = await $fetch<QueueAPIResponse<Patient[]>>(`${baseURL}/patients`, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
if (response.success && response.data) {
|
||||
return response.data;
|
||||
const rawData: any = await $fetch(`${verificationApiBase}/loket/${loketId}`);
|
||||
if (rawData.metadata && rawData.metadata.code !== 200) {
|
||||
throw new Error(rawData.message || 'API returned error status');
|
||||
}
|
||||
throw new Error(response.message || 'Failed to fetch patients');
|
||||
return rawData.data || [];
|
||||
} catch (error: any) {
|
||||
console.error('❌ Error fetching patients:', error);
|
||||
throw error;
|
||||
throw new Error(`HTTP error! status: ${error.response?.status || error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch single patient by ID or barcode
|
||||
*/
|
||||
const fetchPatient = async (idOrBarcode: string): Promise<Patient | null> => {
|
||||
const fetchRawClinicPatients = async (clinicId: string | number) => {
|
||||
const url = `${externalApiBase}/visit?klinik_id=${clinicId}&limit=500`;
|
||||
try {
|
||||
const response = await $fetch<QueueAPIResponse<Patient>>(`${baseURL}/patients/${idOrBarcode}`, {
|
||||
method: 'GET',
|
||||
});
|
||||
const rawResponse: any = await $fetch(url);
|
||||
return rawResponse?.data || [];
|
||||
} catch (error: any) {
|
||||
throw new Error(`HTTP error! status: ${error.response?.status || error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (response.success && response.data) {
|
||||
return response.data;
|
||||
const updateTicketStatus = async (barcode: string, statuspasien: string, statuspasien2: string, idklinikstatus: string, idklinikstatus2: string) => {
|
||||
return await $fetch(`${verificationApiBase}/tiket/update`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
barcode: barcode || "",
|
||||
statuspasien,
|
||||
statuspasien2,
|
||||
idklinikstatus,
|
||||
idklinikstatus2
|
||||
}
|
||||
return null;
|
||||
} catch (error: any) {
|
||||
console.error('❌ Error fetching patient:', error);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Create new patient (register from Anjungan)
|
||||
*/
|
||||
const createPatient = async (patientData: Partial<Patient>): Promise<Patient> => {
|
||||
try {
|
||||
const response = await $fetch<QueueAPIResponse<Patient>>(`${baseURL}/patients`, {
|
||||
method: 'POST',
|
||||
body: patientData,
|
||||
});
|
||||
|
||||
if (response.success && response.data) {
|
||||
return response.data;
|
||||
const completeTicketStatus = async (idloket: string, barcode: string, statuspasien: string, idklinikstatus: string) => {
|
||||
return await $fetch(`${verificationApiBase}/tiket/selesai`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
idloket: String(idloket || ""),
|
||||
barcode: barcode || "",
|
||||
statuspasien,
|
||||
idklinikstatus
|
||||
}
|
||||
throw new Error(response.message || 'Failed to create patient');
|
||||
} catch (error: any) {
|
||||
console.error('❌ Error creating patient:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update patient status (check-in, process, etc)
|
||||
*/
|
||||
const updatePatient = async (
|
||||
idOrBarcode: string,
|
||||
updates: Partial<Patient>
|
||||
): Promise<Patient> => {
|
||||
try {
|
||||
const response = await $fetch<QueueAPIResponse<Patient>>(
|
||||
`${baseURL}/patients/${idOrBarcode}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
body: updates,
|
||||
}
|
||||
);
|
||||
|
||||
if (response.success && response.data) {
|
||||
return response.data;
|
||||
}
|
||||
throw new Error(response.message || 'Failed to update patient');
|
||||
} catch (error: any) {
|
||||
console.error('❌ Error updating patient:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check-in patient (update status to di-loket)
|
||||
*/
|
||||
const checkInPatient = async (idOrBarcode: string): Promise<Patient> => {
|
||||
return updatePatient(idOrBarcode, { status: 'di-loket' });
|
||||
};
|
||||
|
||||
/**
|
||||
* Process patient at loket (update status and processStage)
|
||||
*/
|
||||
const processPatientAtLoket = async (
|
||||
idOrBarcode: string,
|
||||
updates: { status?: string; processStage?: string }
|
||||
): Promise<Patient> => {
|
||||
return updatePatient(idOrBarcode, updates as Partial<Patient>);
|
||||
};
|
||||
|
||||
/**
|
||||
* Sync local state with database
|
||||
*/
|
||||
const syncWithDatabase = async (localPatients: Patient[]): Promise<Patient[]> => {
|
||||
try {
|
||||
// Fetch latest from database
|
||||
const dbPatients = await fetchAllPatients();
|
||||
|
||||
// Merge strategy: prefer database data, but keep local if newer
|
||||
const merged = new Map<string, Patient>();
|
||||
|
||||
// Add database patients
|
||||
dbPatients.forEach(patient => {
|
||||
merged.set(patient.barcode, patient);
|
||||
});
|
||||
|
||||
// Add local patients that don't exist in DB or are newer
|
||||
localPatients.forEach(localPatient => {
|
||||
const existing = merged.get(localPatient.barcode);
|
||||
if (!existing || new Date(localPatient.createdAt) > new Date(existing.createdAt)) {
|
||||
merged.set(localPatient.barcode, localPatient);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(merged.values());
|
||||
} catch (error: any) {
|
||||
console.error('❌ Error syncing with database:', error);
|
||||
// Return local patients as fallback
|
||||
return localPatients;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Batch sync: save multiple patients to database
|
||||
*/
|
||||
const batchSyncPatients = async (patients: Patient[]): Promise<boolean> => {
|
||||
try {
|
||||
const response = await $fetch<QueueAPIResponse>(`${baseURL}/patients/batch`, {
|
||||
method: 'POST',
|
||||
body: { patients },
|
||||
});
|
||||
|
||||
return response.success || false;
|
||||
} catch (error: any) {
|
||||
console.error('❌ Error batch syncing patients:', error);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
fetchAllPatients,
|
||||
fetchPatient,
|
||||
createPatient,
|
||||
updatePatient,
|
||||
checkInPatient,
|
||||
processPatientAtLoket,
|
||||
syncWithDatabase,
|
||||
batchSyncPatients,
|
||||
fetchRawLoketPatients,
|
||||
fetchRawClinicPatients,
|
||||
updateTicketStatus,
|
||||
completeTicketStatus
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,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
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
|
||||
+121
-10
@@ -63,6 +63,116 @@ Format output:
|
||||
|
||||
<!-- 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
|
||||
@@ -618,13 +728,14 @@ Format output:
|
||||
|
||||
| Metrik | Value |
|
||||
|--------|-------|
|
||||
| Total hari dev | ~50+ hari (Jan 2026 — Mei 2026) |
|
||||
| Fase project | 5 fase (Setup → Fondasi → Hak Akses → Eksekutif → Stabilisasi) |
|
||||
| Fitur selesai | 8 modul utama (Anjungan, Check-in, Loket, Klinik, Penunjang, Dashboard, Setting, Hak Akses) |
|
||||
| Total commits | 80+ commits |
|
||||
| Bug ditemukan | 15+ (WebSocket, loket interference, memory, display sync) |
|
||||
| Bug diselesaikan | 15+ |
|
||||
| Halaman dibuat | 28 halaman |
|
||||
| Composables | 16 composable |
|
||||
| Pinia Stores | 13 stores |
|
||||
| Komponen | 20+ komponen |
|
||||
| Total hari dev | ~60+ hari (Jan 2026 — Juli 2026) |
|
||||
| Fase project | 7 fase (Setup → Fondasi → Hak Akses → Eksekutif → Stabilisasi → Verifikasi → Infrastruktur) |
|
||||
| Fitur selesai | 8 modul utama + dokumentasi + migrasi infra |
|
||||
| Total commits | 90+ commits |
|
||||
| Bug ditemukan | 18+ (WebSocket, loket interference, memory, display sync, missing clinics, API endpoint) |
|
||||
| Bug diselesaikan | 18+ |
|
||||
| Halaman dibuat | 28+ halaman |
|
||||
| Composables | 18 composable |
|
||||
| Pinia Stores | 14 stores |
|
||||
| Komponen | 25+ komponen |
|
||||
| Dokumentasi | AGENTS.md, DEVLOG.md, DEVPLAN.md, PRD.md, API_ENDPOINTS_CLINIC_DOCTOR.md |
|
||||
+28
-9
@@ -1,10 +1,10 @@
|
||||
# 🗺️ DEVPLAN — Development Plan
|
||||
|
||||
**Project:** Web Antrean — Sistem Manajemen Antrean Rawat Jalan RSSA
|
||||
**Version:** 1.0.0
|
||||
**Version:** 1.1.0
|
||||
**Author:** Akbar
|
||||
**Stack:** JavaScript · Vue 3 · TypeScript · Nuxt 3 · Vuetify 3 · Pinia
|
||||
**Last Updated:** 2026-05-25
|
||||
**Last Updated:** 2026-07-10
|
||||
|
||||
---
|
||||
|
||||
@@ -45,8 +45,8 @@ Format dalam tabel Markdown yang terstruktur.
|
||||
## 1. Project Timeline
|
||||
|
||||
```
|
||||
[Start]──Phase 0──Phase 1──Phase 2──Phase 3──Phase 4──Phase 5──Phase 6──[Launch]
|
||||
Setup Fondasi Inti API Hak Akses Eksekutif Stabilisasi Verifikasi
|
||||
[Start]──Phase 0──Phase 1──Phase 2──Phase 3──Phase 4──Phase 5──Phase 6──Phase 7──[Launch]
|
||||
Setup Fondasi Inti API Hak Akses Eksekutif Stabilisasi Verifikasi Infrastruktur
|
||||
```
|
||||
|
||||
| Fase | Nama | Durasi | Target | Status |
|
||||
@@ -57,7 +57,8 @@ Format dalam tabel Markdown yang terstruktur.
|
||||
| Phase 3 | Hak Akses & Keycloak | 2 Minggu | Apr 2026 | ✅ Done |
|
||||
| Phase 4 | Fitur Eksekutif & Docker | 1 Minggu | Pertengahan Mei | ✅ Done |
|
||||
| Phase 5 | Stabilisasi & Dokumentasi | 2 Minggu | Akhir Mei 2026 | ✅ Done |
|
||||
| Phase 6 | Verifikasi Akun & Kiosk | 2 Minggu | Juni 2026 | 🔄 In Progress |
|
||||
| Phase 6 | Verifikasi Akun & Kiosk | 2 Minggu | Juni 2026 | ✅ Done |
|
||||
| Phase 7 | Infrastruktur & Dokumentasi | Ongoing | Juli 2026 | 🔄 In Progress |
|
||||
|
||||
---
|
||||
|
||||
@@ -145,6 +146,19 @@ Format dalam tabel Markdown yang terstruktur.
|
||||
| Fitur pindah klinik pada dashboard admin | 8 jam | Phase 2 | ✅ Done |
|
||||
| Halaman Detail Akun Verifikasi | 4 jam | Phase 3 | ✅ Done |
|
||||
| Implementasi Proxy Routes (CORS) | 8 jam | Phase 2 | ✅ Done |
|
||||
| Refactoring queueStore.js ke TypeScript | 8 jam | Phase 2 | ✅ Done |
|
||||
| Ekstraksi useQueueSync composable | 4 jam | Phase 2 | ✅ Done |
|
||||
|
||||
### Phase 7 — Infrastruktur & Dokumentasi
|
||||
| Task | Estimasi | Dependencies | Status |
|
||||
|------|----------|--------------|--------|
|
||||
| Migrasi API endpoint (123.140 → 150.131) | 2 jam | — | ✅ Done |
|
||||
| Pembuatan Project Knowledge Base (AGENTS.md) | 3 jam | — | ✅ Done |
|
||||
| Update DEVLOG, DEVPLAN, PRD | 2 jam | — | ✅ Done |
|
||||
| Fix Admin Klinik Ruang missing clinics | 1 jam | Phase 2 | ✅ Done |
|
||||
| Eliminasi hardcoded fallback IP di source files | 4 jam | Task 1 | ⏳ Pending |
|
||||
| SQLite config → External API migration | 8 jam | Phase 2 | ⏳ Pending |
|
||||
| Production deployment & monitoring | 8 jam | All | ⏳ Pending |
|
||||
|
||||
---
|
||||
|
||||
@@ -157,12 +171,12 @@ Nuxt 3 + TypeScript
|
||||
├── Vuetify 3 (UI Framework)
|
||||
│ └── Material Design Icons
|
||||
├── Nuxt Nitro (Server Engine)
|
||||
│ └── Server Routes (CORS Proxy API)
|
||||
│ └── Server API Routes + SQLite (better-sqlite3)
|
||||
├── Auth Layer
|
||||
│ └── Keycloak SSO (OIDC/OAuth 2.0)
|
||||
├── API Layer
|
||||
│ ├── Visit API (10.10.123.135:8084)
|
||||
│ └── Antrian API (10.10.123.140:8089)
|
||||
│ └── Antrian API (10.10.150.131:8089) ← UPDATED
|
||||
└── Real-time Layer
|
||||
└── Native Browser WebSocket
|
||||
```
|
||||
@@ -194,10 +208,12 @@ npm run _command_dev
|
||||
AUTH_ORIGIN="http://10.10.150.175:3000"
|
||||
KEYCLOAK_CLIENT_ID="akbar-test"
|
||||
KEYCLOAK_ISSUER="https://auth.rssa.top/realms/sandbox"
|
||||
ANTRIAN_API_URL="http://10.10.123.140:8089/api/v1"
|
||||
ANTRIAN_API_URL="http://10.10.150.131:8089/api/v1" # UPDATED Juli 2026
|
||||
VERIFICATION_API_BASE_URL="http://10.10.150.131:8089/api/v1" # UPDATED Juli 2026
|
||||
VISIT_API_URL="http://10.10.123.135:8084/api/v1"
|
||||
WS_API_URL="ws://10.10.123.135:8084/api/v1/ws"
|
||||
PROXY_CLIENT_ORIGIN="http://10.10.150.175:3000"
|
||||
PROXY_TARGET_HOST_KLINIK_FALLBACK="10.10.150.131:8089" # UPDATED Juli 2026
|
||||
```
|
||||
|
||||
---
|
||||
@@ -232,7 +248,9 @@ docs: update DEVPLAN
|
||||
| 🔐 Auth & Roles | Keycloak jalan, routing terlindungi middleware | Apr 2026 | ✅ Done |
|
||||
| 🏥 Anjungan Ready | Pasien bisa check-in & ambil tiket lancar | Pertengahan Mei | ✅ Done |
|
||||
| 🛡️ Stability Pass | Tidak ada leak, isolasi loket aman, no WS drift | Akhir Mei 2026 | ✅ Done |
|
||||
| 🚀 Production Go-Live | Deployment docker di server production | Q2/Q3 2026 | 🔄 Pending |
|
||||
| 🔄 TypeScript Migration | queueStore refactored ke TS, composable extracted | Juni 2026 | ✅ Done |
|
||||
| 📡 API Endpoint Migration | Antrian API pindah ke server baru (150.131) | Juli 2026 | ✅ Done |
|
||||
| 🚀 Production Go-Live | Deployment docker di server production | Q3 2026 | 🔄 Pending |
|
||||
|
||||
---
|
||||
|
||||
@@ -240,4 +258,5 @@ docs: update DEVPLAN
|
||||
|
||||
| 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 |
|
||||
@@ -0,0 +1,84 @@
|
||||
# 🌿 Git Workflow & Branching Guide
|
||||
|
||||
Dokumen ini berisi panduan *step-by-step* untuk mengelola Git *branch* di project **web-antrean**. Branch utama (main) untuk project ini adalah **`Antrean-Code`**.
|
||||
|
||||
---
|
||||
|
||||
## 1. Persiapan Sebelum Mengubah Kode (Membuat Branch Baru)
|
||||
Sangat disarankan untuk tidak mengubah kode secara langsung di `Antrean-Code`. Buatlah branch baru untuk setiap fitur atau perbaikan *bug*.
|
||||
|
||||
**Langkah-langkah:**
|
||||
1. Pastikan Anda berada di branch utama dan kodenya terbaru:
|
||||
```bash
|
||||
git checkout Antrean-Code
|
||||
git pull origin Antrean-Code
|
||||
```
|
||||
2. Buat dan pindah ke branch baru (misalnya `fitur-baru`):
|
||||
```bash
|
||||
git checkout -b nama-fitur-anda
|
||||
```
|
||||
*(Contoh: `git checkout -b tes-refaktoring`)*
|
||||
|
||||
---
|
||||
|
||||
## 2. Menyimpan Perubahan (Commit)
|
||||
Setelah Anda selesai menulis kode, ikuti langkah ini untuk menyimpannya.
|
||||
|
||||
**Langkah-langkah:**
|
||||
1. Cek file apa saja yang berubah:
|
||||
```bash
|
||||
git status
|
||||
```
|
||||
2. Tambahkan file yang ingin disimpan:
|
||||
```bash
|
||||
git add .
|
||||
```
|
||||
*(Tanda `.` artinya menambahkan semua file. Anda juga bisa menyebut nama filenya satu-satu)*
|
||||
3. Lakukan commit dengan pesan yang jelas (mengikuti aturan Conventional Commits di SKILL.md):
|
||||
```bash
|
||||
git commit -m "feat: menambah fitur X"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Menggabungkan Kode ke Branch Utama (Merge ke Antrean-Code)
|
||||
Jika fitur di branch Anda sudah selesai dan teruji, saatnya menggabungkannya (*merge*) kembali ke `Antrean-Code`. Ini adalah tahapan yang baru saja kita lakukan.
|
||||
|
||||
**Langkah-langkah:**
|
||||
1. Pastikan tidak ada perubahan yang belum di-commit di branch Anda (harus clean).
|
||||
```bash
|
||||
git status
|
||||
```
|
||||
2. Pindah ke branch utama (`Antrean-Code`):
|
||||
```bash
|
||||
git checkout Antrean-Code
|
||||
```
|
||||
3. Gabungkan (*merge*) branch fitur Anda (misal `tes-refaktoring`) ke `Antrean-Code`:
|
||||
```bash
|
||||
git merge tes-refaktoring
|
||||
```
|
||||
4. Jika tidak ada konflik, Git akan otomatis menyatukan kodenya. (Jika ada konflik, Anda harus memperbaikinya secara manual di code editor, lalu `git add .` dan `git commit`).
|
||||
|
||||
---
|
||||
|
||||
## 4. Mengunggah Kode ke Server (Push ke Remote)
|
||||
Setelah merge selesai di komputer lokal, Anda harus mengunggah perubahannya ke server GitLab/GitHub.
|
||||
|
||||
**Langkah-langkah:**
|
||||
1. Pastikan Anda berada di branch `Antrean-Code`.
|
||||
2. Push ke remote origin:
|
||||
```bash
|
||||
git push origin Antrean-Code
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Membersihkan Branch (Opsional)
|
||||
Jika branch fitur sudah berhasil di-merge dan di-push, Anda bisa menghapusnya agar daftar branch lokal tetap rapi.
|
||||
```bash
|
||||
git branch -d tes-refaktoring
|
||||
```
|
||||
|
||||
---
|
||||
> **Catatan Penting:**
|
||||
> Jangan lupa untuk selalu mengacu pada `docs/DEVLOG.md` saat Anda berhasil menggabungkan fitur besar untuk mencatat apa saja yang baru dirilis.
|
||||
+8
-7
@@ -1,11 +1,11 @@
|
||||
# 📋 PRD — Product Requirements Document
|
||||
|
||||
**Project:** Web Antrean — Sistem Manajemen Antrean Rawat Jalan RSSA
|
||||
**Version:** 1.0.0
|
||||
**Status:** `In Review`
|
||||
**Version:** 1.1.0
|
||||
**Status:** `In Progress`
|
||||
**Author:** Akbar
|
||||
**Stack:** JavaScript · Vue 3 · TypeScript · Nuxt 3 · Vuetify 3 · Pinia
|
||||
**Last Updated:** 2026-05-25
|
||||
**Last Updated:** 2026-07-10
|
||||
|
||||
---
|
||||
|
||||
@@ -195,7 +195,7 @@ Rumah Sakit RSSA menghadapi masalah antrean rawat jalan yang tidak terstruktur:
|
||||
| Service | Base URL | Keterangan |
|
||||
|---------|----------|------------|
|
||||
| Visit API | `http://10.10.123.135:8084/api/v1` | Data kunjungan & antrean utama |
|
||||
| Antrian API (Klinik) | `http://10.10.123.140:8089/api/v1` | Verifikasi & data klinik/dokter |
|
||||
| Antrian API (Klinik) | `http://10.10.150.131:8089/api/v1` | Verifikasi & data klinik/dokter |
|
||||
| WebSocket | `ws://10.10.123.135:8084/api/v1/ws` | Real-time queue update |
|
||||
|
||||
### 7.3 Arsitektur Sistem
|
||||
@@ -211,8 +211,8 @@ Rumah Sakit RSSA menghadapi masalah antrean rawat jalan yang tidak terstruktur:
|
||||
┌──────────▼──────────┐ ┌────▼───────────────┐
|
||||
│ Nuxt Server (Nitro) │ │ WebSocket Server │
|
||||
│ server/api/ │ │ ws://10.10.123. │
|
||||
│ server/routes/ │ │ 135:8084/api/v1/ws│
|
||||
│ (Proxy + API layer) │ └────────────────────┘
|
||||
│ SQLite (users.db) │ │ 135:8084/api/v1/ws│
|
||||
│ (Config + Users) │ └────────────────────┘
|
||||
└──────────┬──────────┘
|
||||
│ HTTP Proxy (CORS bypass)
|
||||
┌──────────▼──────────────────────────────────┐
|
||||
@@ -227,7 +227,7 @@ Rumah Sakit RSSA menghadapi masalah antrean rawat jalan yang tidak terstruktur:
|
||||
| Proxy Path | Target Backend | Keterangan |
|
||||
|------------|---------------|------------|
|
||||
| `/visit-api/**` | `http://10.10.123.135:8084/api/v1/**` | Data kunjungan pasien |
|
||||
| `/klinik-api/**` | `http://10.10.123.140:8089/api/v1/**` | Data klinik & dokter |
|
||||
| `/klinik-api/**` | `http://10.10.150.131:8089/api/v1/**` | Data klinik & dokter |
|
||||
| `/stats-api/**` | `http://10.10.123.135:8084/api/v1/**` | Statistik dashboard |
|
||||
|
||||
### 7.5 Struktur Folder
|
||||
@@ -405,4 +405,5 @@ export interface ApiResponse<T> {
|
||||
|
||||
| Versi | Tanggal | Author | Perubahan |
|
||||
|-------|---------|--------|-----------|
|
||||
| 1.1.0 | 2026-07-10 | Akbar | Update API endpoints (150.131), arsitektur diagram, proxy routes, Phase 7 |
|
||||
| 1.0.0 | 2026-05-25 | Akbar | Initial PRD — dibuat berdasarkan kondisi project aktual |
|
||||
@@ -1,6 +1,8 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Header -->
|
||||
<AdminKlinikEksekutif v-if="jenisLayanan === 'Eksekutif'" />
|
||||
<div v-else>
|
||||
<!-- Header -->
|
||||
<PageHeader
|
||||
icon="mdi-door-open"
|
||||
:title="`Admin Klinik Ruang - ${klinikData?.namaKlinik || ''}`"
|
||||
@@ -785,6 +787,7 @@
|
||||
:color="snackbarColor"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -798,6 +801,7 @@ import { useRuangStore } from '@/stores/ruangStore';
|
||||
import PageHeader from '@/components/common/PageHeader.vue';
|
||||
import PatientCard from '@/components/features/queue/PatientCard.vue';
|
||||
import AppSnackbar from '@/components/common/AppSnackbar.vue';
|
||||
import AdminKlinikEksekutif from '@/components/AdminKlinik/AdminKlinikEksekutif.vue';
|
||||
import { useThermalPrint } from '@/composables/useThermalPrint';
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
+20
-26
@@ -709,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
|
||||
@@ -854,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)
|
||||
|
||||
@@ -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>
|
||||
+348
-219
@@ -20,16 +20,13 @@ const profileData = reactive({
|
||||
registrationNumber: 'RM-2023-8812',
|
||||
nim: '3573230897851332',
|
||||
phone: '+62 812-3456-7890',
|
||||
verified: true
|
||||
verified: true,
|
||||
birthDate: '10 Juni 1970',
|
||||
address: 'Jalan Soekarno Hatta, no 1A, Lowokwaru, Kota Malang'
|
||||
});
|
||||
|
||||
// Family members data
|
||||
const familyMembers = reactive([
|
||||
{
|
||||
id: 1,
|
||||
name: 'Andi Pratama',
|
||||
status: 'PENDING'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Siti Aminah',
|
||||
@@ -81,54 +78,179 @@ const modalData = reactive({
|
||||
alamat: ''
|
||||
});
|
||||
|
||||
const genderOptions = ['Laki-laki', 'Perempuan'];
|
||||
const relationshipOptions = ['Orang tua', 'Suami/Istri', 'Anak', 'Saudara', 'Lainnya'];
|
||||
|
||||
const openModal = (member) => {
|
||||
selectedMember.value = member;
|
||||
// Reset form
|
||||
modalData.namaLengkap = member.name;
|
||||
modalData.tanggalLahir = '';
|
||||
modalData.nik = '';
|
||||
modalData.jenisKelamin = '';
|
||||
modalData.hubungan = '';
|
||||
modalData.nomorTelepon = '';
|
||||
modalData.alamat = '';
|
||||
isModalOpen.value = true;
|
||||
/** Opsi hubungan anggota keluarga */
|
||||
const relationshipOptions = [
|
||||
'Anak Kandung',
|
||||
'Anak Tiri',
|
||||
'Anak Angkat',
|
||||
'Suami',
|
||||
'Istri',
|
||||
'Orang Tua',
|
||||
'Saudara Kandung',
|
||||
'Wali',
|
||||
'Kerabat',
|
||||
'Lainnya'
|
||||
];
|
||||
|
||||
// ── State Modal Tambah Anggota ──────────────────────────────────────────────
|
||||
|
||||
/** Mode modal: 'add' untuk tambah baru, 'edit' untuk edit data yang ada */
|
||||
const memberModalMode = ref('add');
|
||||
|
||||
/** Apakah modal tambah/edit anggota terbuka */
|
||||
const isAddMemberModalOpen = ref(false);
|
||||
|
||||
/** Judul modal yang ditampilkan sesuai mode */
|
||||
const memberModalTitle = computed(() => {
|
||||
if (memberModalMode.value === 'editProfile') return 'Edit Profil';
|
||||
if (memberModalMode.value === 'edit') return 'Edit Peserta';
|
||||
return 'Tambah Peserta';
|
||||
});
|
||||
|
||||
/** Label tombol submit modal sesuai mode */
|
||||
const memberModalSubmitLabel = computed(() =>
|
||||
memberModalMode.value === 'add' ? 'Tambah' : 'Simpan'
|
||||
);
|
||||
|
||||
/** True jika tombol submit harus disabled */
|
||||
const memberModalSubmitDisabled = computed(() => {
|
||||
if (!searchResult.value) return true;
|
||||
// Mode editProfile: cukup ada searchResult (tidak perlu hubungan)
|
||||
if (memberModalMode.value === 'editProfile') return false;
|
||||
// Mode tambah/edit anggota: wajib pilih hubungan
|
||||
return !addMemberForm.hubungan;
|
||||
});
|
||||
|
||||
/** Teks pencarian pasien (nomor RM atau NIK) */
|
||||
const searchQuery = ref('');
|
||||
|
||||
/** Loading saat sedang mencari pasien */
|
||||
const isSearching = ref(false);
|
||||
|
||||
/** Hasil pencarian / data pasien yang sedang diedit; null = belum ada data */
|
||||
const searchResult = ref(null);
|
||||
|
||||
/** Form data tambah/edit anggota — hanya hubungan & alamat yang dapat diisi */
|
||||
const addMemberForm = reactive({
|
||||
hubungan: '',
|
||||
alamat: ''
|
||||
});
|
||||
|
||||
/**
|
||||
* Membuka modal dalam mode TAMBAH dan mereset semua state pencarian.
|
||||
*/
|
||||
const openAddMemberModal = () => {
|
||||
memberModalMode.value = 'add';
|
||||
searchQuery.value = '';
|
||||
searchResult.value = null;
|
||||
addMemberForm.hubungan = '';
|
||||
addMemberForm.alamat = '';
|
||||
isAddMemberModalOpen.value = true;
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
isModalOpen.value = false;
|
||||
selectedMember.value = null;
|
||||
};
|
||||
|
||||
const handleApprove = () => {
|
||||
snackbar.value = {
|
||||
show: true,
|
||||
message: 'Pengajuan anggota baru disetujui.',
|
||||
color: 'success'
|
||||
/**
|
||||
* Membuka modal dalam mode EDIT PROFIL, mengisi data dari profileData.
|
||||
* Section pencarian dan field Hubungan disembunyikan.
|
||||
* Field Nomor Telepon & Alamat menjadi enabled.
|
||||
*/
|
||||
const openEditProfileModal = () => {
|
||||
memberModalMode.value = 'editProfile';
|
||||
searchResult.value = {
|
||||
namaLengkap: profileData.name,
|
||||
nomorRM: profileData.registrationNumber,
|
||||
tanggalLahir: profileData.birthDate,
|
||||
nik: profileData.nim,
|
||||
jenisKelamin: 'Laki-laki',
|
||||
// Nomor Telepon dimasukkan ke field yang dapat diedit
|
||||
nomorTelepon: profileData.phone
|
||||
};
|
||||
closeModal();
|
||||
// Alamat profil dimasukkan ke addMemberForm.alamat agar bisa diedit
|
||||
addMemberForm.alamat = profileData.address;
|
||||
addMemberForm.hubungan = '';
|
||||
isAddMemberModalOpen.value = true;
|
||||
};
|
||||
|
||||
const handleReject = () => {
|
||||
snackbar.value = {
|
||||
show: true,
|
||||
message: 'Pengajuan anggota baru ditolak.',
|
||||
color: 'error'
|
||||
/**
|
||||
* Membuka modal dalam mode EDIT, langsung mengisi data pasien.
|
||||
* Section pencarian disembunyikan saat mode ini.
|
||||
* @param {object} member - Data anggota yang akan diedit
|
||||
*/
|
||||
const openEditMemberModal = (member) => {
|
||||
memberModalMode.value = 'edit';
|
||||
// Isi langsung data dari member ke searchResult (sama strukturnya)
|
||||
searchResult.value = {
|
||||
namaLengkap: member.name,
|
||||
nomorRM: member.registrationNumber ?? '-',
|
||||
tanggalLahir: member.birthDate ?? '-',
|
||||
nik: member.nik ?? '-',
|
||||
jenisKelamin: member.jenisKelamin ?? '-',
|
||||
nomorTelepon: member.phone ?? '-'
|
||||
};
|
||||
closeModal();
|
||||
addMemberForm.hubungan = member.hubungan ?? '';
|
||||
addMemberForm.alamat = member.address ?? '';
|
||||
isAddMemberModalOpen.value = true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Menutup modal tambah/edit anggota.
|
||||
*/
|
||||
const closeAddMemberModal = () => {
|
||||
isAddMemberModalOpen.value = false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Mensimulasikan pencarian pasien berdasarkan nomor RM atau NIK.
|
||||
* Pada implementasi nyata, ini akan memanggil API backend.
|
||||
*/
|
||||
const handleSearchPatient = async () => {
|
||||
if (!searchQuery.value.trim()) return;
|
||||
|
||||
isSearching.value = true;
|
||||
searchResult.value = null;
|
||||
|
||||
// Simulasi delay network request
|
||||
await new Promise(resolve => setTimeout(resolve, 800));
|
||||
|
||||
// Data dummy hasil pencarian pasien
|
||||
searchResult.value = {
|
||||
namaLengkap: 'Andi Pratama',
|
||||
nomorRM: searchQuery.value,
|
||||
tanggalLahir: '12 Maret 1970',
|
||||
nik: '197054478954315',
|
||||
jenisKelamin: 'Laki-laki',
|
||||
nomorTelepon: '0812-3456-7890'
|
||||
};
|
||||
|
||||
isSearching.value = false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Menangani submit form tambah/edit anggota / edit profil.
|
||||
* Pesan snackbar menyesuaikan mode yang aktif.
|
||||
*/
|
||||
const handleAddMember = () => {
|
||||
const nama = searchResult.value?.namaLengkap;
|
||||
let message = '';
|
||||
if (memberModalMode.value === 'editProfile') {
|
||||
// Update profileData langsung dengan nilai yang sudah diubah user
|
||||
profileData.phone = searchResult.value.nomorTelepon;
|
||||
profileData.address = addMemberForm.alamat;
|
||||
message = 'Profil berhasil diperbarui.';
|
||||
} else if (memberModalMode.value === 'edit') {
|
||||
message = `Data anggota ${nama} berhasil diperbarui.`;
|
||||
} else {
|
||||
message = `Anggota keluarga ${nama} berhasil ditambahkan.`;
|
||||
}
|
||||
snackbar.value = { show: true, message, color: 'success' };
|
||||
closeAddMemberModal();
|
||||
};
|
||||
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader
|
||||
icon="mdi-shield-check"
|
||||
title="Detail Akun"
|
||||
:subtitle="currentDate"
|
||||
:show-add-button="false"
|
||||
theme="primary"
|
||||
/>
|
||||
<div style="background: var(--color-neutral-300);">
|
||||
<PageHeader icon="mdi-shield-check" title="Detail Akun" :subtitle="currentDate" :show-add-button="false"
|
||||
theme="primary" />
|
||||
|
||||
|
||||
<v-container class="py-6">
|
||||
@@ -136,54 +258,90 @@ const handleReject = () => {
|
||||
<!-- Profile Card -->
|
||||
<v-row class="mb-6">
|
||||
<v-col cols="12">
|
||||
<v-card class="pa-8" color="white" elevation="2" rounded="lg">
|
||||
<v-row class="align-center" no-gutters>
|
||||
<v-card class="pa-6" color="white" elevation="2" rounded="lg">
|
||||
|
||||
<!-- Baris 1: Avatar + Nama + Tombol Edit -->
|
||||
<v-row class="align-center mb-2" no-gutters>
|
||||
<!-- Avatar -->
|
||||
<v-col cols="auto" class="mr-6">
|
||||
<v-card height="100" width="100" class="bg-lightPrimary d-flex align-center justify-center" elevation="0" rounded="lg">
|
||||
<v-icon size="50" color="primary">mdi-account-outline</v-icon>
|
||||
<v-col cols="auto" class="mr-5">
|
||||
<v-card height="100" width="100" class="bg-lightPrimary d-flex align-center justify-center"
|
||||
elevation="0" rounded="lg">
|
||||
<v-icon size="40" color="primary">mdi-account-outline</v-icon>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<!-- Nama Lengkap & NIK -->
|
||||
<v-col cols="3">
|
||||
<div class="text-caption text-uppercase text-grey font-weight-bold">Nama Lengkap</div>
|
||||
<div class="text-h6 font-weight-bold text-primary-700 mb-4">{{ profileData.name }}</div>
|
||||
<div class="text-caption text-uppercase text-grey font-weight-bold">N.I.K</div>
|
||||
<div class="text-body2">{{ profileData.nim }}</div>
|
||||
</v-col>
|
||||
<v-col cols="max">
|
||||
<!-- Baris 2: NIK | Nomor RM | Nomor Telepon -->
|
||||
<v-row no-gutters class="mb-0">
|
||||
<v-col cols="12" class="mb-3">
|
||||
<v-row>
|
||||
<!-- Nama Lengkap -->
|
||||
<v-col>
|
||||
<div class="text-caption text-uppercase text-grey-darken-1 font-weight-bold mb-1">
|
||||
Nama Lengkap
|
||||
</div>
|
||||
<div class="text-h6 font-weight-bold text-primary">
|
||||
{{ profileData.name }}
|
||||
</div>
|
||||
</v-col>
|
||||
|
||||
<!-- Nomor RM & Nomor Telepon -->
|
||||
<v-col cols="3">
|
||||
<div class="text-caption text-uppercase text-grey font-weight-bold">Nomor RM</div>
|
||||
<div class="text-h6 font-weight-bold mb-4">{{ profileData.registrationNumber }}</div>
|
||||
<div class="text-caption text-uppercase text-grey font-weight-bold">Nomor Telepon</div>
|
||||
<div class="text-body2">{{ profileData.phone }}</div>
|
||||
</v-col>
|
||||
<!-- Tombol Edit Profil -->
|
||||
<v-col cols="auto">
|
||||
<v-btn color="primary" variant="outlined" size="small" rounded="lg"
|
||||
@click="openEditProfileModal">
|
||||
<v-icon start size="small">mdi-pencil</v-icon>
|
||||
Edit Profil
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-col>
|
||||
<v-col cols="4">
|
||||
<div class="text-caption text-uppercase text-grey-darken-1 font-weight-bold mb-1">
|
||||
NIK
|
||||
</div>
|
||||
<div class="text-body-1 font-weight-bold">{{ profileData.nim }}</div>
|
||||
</v-col>
|
||||
|
||||
<!-- Buttons -->
|
||||
<v-col cols="max" class="text-right">
|
||||
<v-chip
|
||||
v-if="profileData.verified"
|
||||
color="success"
|
||||
size="small"
|
||||
class="mb-3"
|
||||
>
|
||||
<v-icon start size="small">mdi-check-circle</v-icon>
|
||||
Terverifikasi
|
||||
</v-chip>
|
||||
<div>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
>
|
||||
<v-icon start>mdi-pencil</v-icon>
|
||||
Edit Profil
|
||||
</v-btn>
|
||||
</div>
|
||||
<v-col cols="4">
|
||||
<div class="text-caption text-uppercase text-grey-darken-1 font-weight-bold mb-1">
|
||||
Nomor RM
|
||||
</div>
|
||||
<div class="text-body-1 font-weight-bold">{{ profileData.registrationNumber }}</div>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="4">
|
||||
<div class="text-caption text-uppercase text-grey-darken-1 font-weight-bold mb-1">
|
||||
Nomor Telepon
|
||||
</div>
|
||||
<div class="text-body-1 font-weight-bold">{{ profileData.phone }}</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
|
||||
<v-divider class="my-4" />
|
||||
|
||||
<!-- Baris 3: Tanggal Lahir | Nomor Telepon (No RM) | Alamat -->
|
||||
<v-row no-gutters>
|
||||
<v-col cols="2">
|
||||
<div class="text-caption text-uppercase text-grey-darken-1 font-weight-bold mb-1">
|
||||
Tanggal Lahir
|
||||
</div>
|
||||
<div class="text-body-2">{{ profileData.birthDate }}</div>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="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>
|
||||
@@ -191,9 +349,16 @@ const handleReject = () => {
|
||||
<!-- Family Members Section -->
|
||||
<v-row class="mb-6">
|
||||
<v-col cols="12">
|
||||
<h3 class="text-h6 font-weight-bold">Anggota Keluarga Terhubung</h3>
|
||||
<!-- Header: judul + tombol Tambah Anggota -->
|
||||
<div class="d-flex align-center justify-space-between mb-1">
|
||||
<h3 class="text-h6 font-weight-bold">Anggota Keluarga Terhubung</h3>
|
||||
<v-btn color="primary" variant="flat" size="small" rounded="lg" @click="openAddMemberModal">
|
||||
<v-icon start size="small">mdi-account-plus</v-icon>
|
||||
Tambah Anggota
|
||||
</v-btn>
|
||||
</div>
|
||||
<p class="text-body-2 text-muted mb-4">Daftar anggota keluarga yang berada dalam satu kartu keluarga</p>
|
||||
|
||||
|
||||
<v-card class="pa-2">
|
||||
<v-table class="elevation-0">
|
||||
<thead>
|
||||
@@ -207,10 +372,7 @@ const handleReject = () => {
|
||||
<tr v-for="member in familyMembers" :key="member.id">
|
||||
<td class="text-body-2 font-weight-bold ">{{ member.name }}</td>
|
||||
<td class="text-center">
|
||||
<v-chip
|
||||
:color="member.status === 'PENDING' ? 'secondary' : 'success'"
|
||||
size="small"
|
||||
>
|
||||
<v-chip :color="member.status === 'PENDING' ? 'secondary' : 'success'" size="small">
|
||||
<v-icon start size="small">{{
|
||||
member.status === 'PENDING' ? 'mdi-clock-outline' : 'mdi-check-decagram'
|
||||
}}</v-icon>
|
||||
@@ -219,30 +381,12 @@ const handleReject = () => {
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<div class="d-flex justify-center gap-2">
|
||||
<v-btn
|
||||
v-if="member.status === 'PENDING'"
|
||||
size="small"
|
||||
color="primary"
|
||||
@click="openModal(member)"
|
||||
>
|
||||
<v-icon start>mdi-clipboard-check-outline</v-icon>
|
||||
Proses
|
||||
</v-btn>
|
||||
<div v-else>
|
||||
<v-btn
|
||||
size="small"
|
||||
color="error"
|
||||
variant="outlined"
|
||||
class="mr-2"
|
||||
>
|
||||
<div>
|
||||
<v-btn size="small" color="error" variant="outlined" class="mr-2">
|
||||
<v-icon start>mdi-delete</v-icon>
|
||||
Hapus
|
||||
</v-btn>
|
||||
<v-btn
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
>
|
||||
<v-btn size="small" color="primary" variant="outlined" @click="openEditMemberModal(member)">
|
||||
<v-icon start>mdi-pencil</v-icon>
|
||||
Edit
|
||||
</v-btn>
|
||||
@@ -260,26 +404,19 @@ const handleReject = () => {
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<div class="d-flex align-center mb-6">
|
||||
<v-icon class="mr-2" color="primary">mdi-history</v-icon>
|
||||
<h3 class="text-h6 font-weight-bold">Riwayat Aktivitas</h3>
|
||||
</div>
|
||||
|
||||
|
||||
<v-icon class="mr-2" color="primary">mdi-history</v-icon>
|
||||
<h3 class="text-h6 font-weight-bold">Riwayat Aktivitas</h3>
|
||||
</div>
|
||||
|
||||
|
||||
<v-card class="pa-6">
|
||||
<v-timeline
|
||||
density="compact"
|
||||
side="end">
|
||||
<v-timeline-item
|
||||
v-for="activity in activityHistory"
|
||||
:key="activity.id"
|
||||
:dot-color="activity.color"
|
||||
:icon="activity.icon"
|
||||
fill-dot
|
||||
>
|
||||
<v-timeline density="compact" side="end">
|
||||
<v-timeline-item v-for="activity in activityHistory" :key="activity.id" :dot-color="activity.color"
|
||||
:icon="activity.icon" fill-dot>
|
||||
<div class="text-subtitle-1 font-weight-bold">{{ activity.title }}</div>
|
||||
<div class="text-caption text-muted">{{ activity.date }}</div>
|
||||
</v-timeline-item>
|
||||
</v-timeline>
|
||||
</v-timeline>
|
||||
|
||||
<div class="text-center mt-6">
|
||||
<v-btn color="primary" variant="text">
|
||||
@@ -290,124 +427,116 @@ const handleReject = () => {
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Verification Modal -->
|
||||
<v-dialog v-model="isModalOpen" max-width="700px" persistent scrollable>
|
||||
<!-- Modal Tambah Anggota Keluarga -->
|
||||
<v-dialog v-model="isAddMemberModalOpen" max-width="700px" persistent scrollable>
|
||||
<v-card class="pa-0">
|
||||
<!-- Modal Header -->
|
||||
<v-card-title class="bg-primary text-white pa-6">
|
||||
<div class="d-flex justify-space-between align-center w-100">
|
||||
<h2 class="text-h5 font-weight-bold">Verifikasi Pengajuan Anggota Keluarga</h2>
|
||||
<v-btn icon="mdi-close" variant="text" @click="closeModal" class="text-white"></v-btn>
|
||||
<h2 class="text-h5 font-weight-bold">{{ memberModalTitle }}</h2>
|
||||
<v-btn icon="mdi-close" variant="text" @click="closeAddMemberModal" class="text-white"></v-btn>
|
||||
</div>
|
||||
</v-card-title>
|
||||
|
||||
<!-- Form Content - Scrollable -->
|
||||
<v-card-text class="pa-6 bg-light">
|
||||
<!-- Informasi Data Diri Section - Card -->
|
||||
<v-card class="mb-6 pa-6" variant="flat">
|
||||
<div class="d-flex align-center mb-4">
|
||||
<h3 class="text-subtitle-1 font-weight-bold text-primary">INFORMASI DATA DIRI</h3>
|
||||
</div>
|
||||
|
||||
<!-- Bagian: Data Pasien (Cari) — hanya tampil saat mode tambah -->
|
||||
<v-card v-if="memberModalMode === 'add'" class="mb-6 pa-6" variant="flat">
|
||||
<div class="d-flex align-center mb-4">
|
||||
<h3 class="text-subtitle-1 font-weight-bold text-primary">DATA PASIEN</h3>
|
||||
</div>
|
||||
<v-form>
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<v-text-field
|
||||
v-model="modalData.namaLengkap"
|
||||
label="Nama Lengkap"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-text-field
|
||||
v-model="modalData.tanggalLahir"
|
||||
label="Tanggal Lahir"
|
||||
type="date"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-select
|
||||
v-model="modalData.jenisKelamin"
|
||||
:items="genderOptions"
|
||||
label="Jenis Kelamin"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
></v-select>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" sm="6">
|
||||
<v-text-field
|
||||
v-model="modalData.nik"
|
||||
label="NIK"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-select
|
||||
v-model="modalData.hubungan"
|
||||
:items="relationshipOptions"
|
||||
label="Hubungan"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
></v-select>
|
||||
<v-text-field v-model="searchQuery" placeholder="Cari nomor RM atau NIK" variant="outlined"
|
||||
density="compact" hide-details :loading="isSearching" @keyup.enter="handleSearchPatient">
|
||||
<template #prepend-inner>
|
||||
<v-icon size="small" color="grey">mdi-magnify</v-icon>
|
||||
</template>
|
||||
<template #append-inner>
|
||||
<v-btn color="primary" variant="text" size="small" icon="mdi-magnify" :loading="isSearching"
|
||||
@click="handleSearchPatient" />
|
||||
</template>
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-form>
|
||||
</v-card>
|
||||
|
||||
<!-- Kontak & Alamat Section - Card -->
|
||||
<v-card class="mb-6 pa-6" variant="flat">
|
||||
<div class="d-flex align-center mb-4">
|
||||
<h3 class="text-subtitle-1 font-weight-bold text-primary">KONTAK & ALAMAT</h3>
|
||||
</div>
|
||||
<!-- Hasil Pencarian: tampil setelah ada hasil -->
|
||||
<template v-if="searchResult">
|
||||
|
||||
<!-- Informasi Data Diri Section -->
|
||||
<v-card class="mb-6 pa-6" variant="flat">
|
||||
<div class="d-flex align-center mb-4">
|
||||
<h3 class="text-subtitle-1 font-weight-bold text-primary">INFORMASI DATA DIRI</h3>
|
||||
</div>
|
||||
<v-form>
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-text-field v-model="searchResult.namaLengkap" label="Nama Lengkap" variant="outlined"
|
||||
density="compact" disabled />
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-text-field v-model="searchResult.nomorRM" label="Nomor RM" variant="outlined" density="compact"
|
||||
disabled />
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-text-field v-model="searchResult.tanggalLahir" label="Tanggal Lahir" variant="outlined"
|
||||
density="compact" disabled />
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-text-field v-model="searchResult.nik" label="NIK" variant="outlined" density="compact"
|
||||
disabled />
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-text-field v-model="searchResult.jenisKelamin" label="Jenis Kelamin" variant="outlined"
|
||||
density="compact" disabled />
|
||||
</v-col>
|
||||
<!-- Hubungan: hanya tampil saat mode tambah/edit anggota -->
|
||||
<v-col v-if="memberModalMode !== 'editProfile'" cols="12" sm="6">
|
||||
<v-select v-model="addMemberForm.hubungan" :items="relationshipOptions" label="Hubungan"
|
||||
variant="outlined" density="compact" />
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-form>
|
||||
</v-card>
|
||||
|
||||
<!-- Kontak & Alamat Section -->
|
||||
<v-card class="mb-6 pa-6" variant="flat">
|
||||
<div class="d-flex align-center mb-4">
|
||||
<h3 class="text-subtitle-1 font-weight-bold text-primary">KONTAK & ALAMAT</h3>
|
||||
</div>
|
||||
<v-form>
|
||||
<v-row>
|
||||
<v-col cols="6">
|
||||
<!-- Nomor Telepon: enabled saat editProfile, disabled saat mode lain -->
|
||||
<v-text-field v-model="searchResult.nomorTelepon" label="Nomor Telepon" variant="outlined"
|
||||
density="compact" :disabled="memberModalMode !== 'editProfile'" />
|
||||
</v-col>
|
||||
<v-col cols="6">
|
||||
<!-- Alamat: freetext yang dapat diisi -->
|
||||
<v-text-field v-model="addMemberForm.alamat" label="Alamat" variant="outlined"
|
||||
density="compact" />
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-form>
|
||||
</v-card>
|
||||
|
||||
</template>
|
||||
|
||||
<v-form>
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<v-text-field
|
||||
v-model="modalData.nomorTelepon"
|
||||
label="Nomor Telepon"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12">
|
||||
<v-text-field
|
||||
v-model="modalData.alamat"
|
||||
label="Alamat"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-form>
|
||||
</v-card>
|
||||
</v-card-text>
|
||||
|
||||
<!-- Modal Actions -->
|
||||
<v-divider></v-divider>
|
||||
<v-card-actions class="pa-6 justify-end">
|
||||
<v-btn
|
||||
color="error"
|
||||
variant="outlined"
|
||||
@click="handleReject"
|
||||
>
|
||||
<v-icon start>mdi-close</v-icon>
|
||||
Tolak
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="handleApprove"
|
||||
>
|
||||
<v-icon start>mdi-check</v-icon>
|
||||
Setujui
|
||||
<v-btn color="primary" variant="flat" :disabled="memberModalSubmitDisabled" @click="handleAddMember">
|
||||
<v-icon start>mdi-check-circle</v-icon>
|
||||
{{ memberModalSubmitLabel }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
|
||||
@@ -102,6 +102,7 @@
|
||||
Verifikasi
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="item.status !== 'Belum Terverifikasi'"
|
||||
class="my-2"
|
||||
color="primary"
|
||||
size="small"
|
||||
@@ -114,11 +115,6 @@
|
||||
</template>
|
||||
|
||||
|
||||
<template v-slot:item.pending="{ item }">
|
||||
<v-chip size="small" class="chip-orange">
|
||||
{{ item.pending || 2 }}
|
||||
</v-chip>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
@@ -179,10 +175,9 @@ const headers = ref([
|
||||
{ title: 'No', value: 'no', sortable: false, width: '60px', align: 'center' },
|
||||
{ title: 'Nama Pasien', value: 'nama', sortable: true, align: 'center' },
|
||||
{ title: 'No. RM', value: 'rm', sortable: true, align: 'center' },
|
||||
{ title: 'Alamat', value: 'alamat', sortable: false, align: 'left' },
|
||||
{ title: 'Alamat', value: 'alamat', sortable: false, align: 'left', width: '400px' },
|
||||
{ title: 'No. Telepon', value: 'telepon', sortable: false, align: 'center' },
|
||||
{ title: 'Status', value: 'status', sortable: true, width: '180px', align: 'center' },
|
||||
{ title: 'Pending', value: 'pending', sortable: false, width: '120px', align: 'center' },
|
||||
{ title: 'Actions', value: 'actions', sortable: false, width: '200px', align: 'center' },
|
||||
]);
|
||||
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import re
|
||||
import sys
|
||||
|
||||
path = r'e:\antrean operasi\web-antrean\stores\queueStore.ts'
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# 1. Add import for useQueueSync at the top
|
||||
if 'useQueueSync' not in content:
|
||||
content = content.replace("import { useQueueAPI } from '@/composables/useQueueAPI';", "import { useQueueAPI } from '@/composables/useQueueAPI';\nimport { useQueueSync } from '@/composables/useQueueSync';")
|
||||
|
||||
# 2. Remove the WEBSOCKET INTEGRATION block
|
||||
ws_pattern = re.compile(r' // WEBSOCKET INTEGRATION \(CENTRALIZED\).*? const disconnectWebSocket = \(\) => \{\n disconnect\(\);\n \};', re.DOTALL)
|
||||
|
||||
content = ws_pattern.sub(' // WS and AutoSync logic extracted to composables/useQueueSync.ts', content)
|
||||
|
||||
# 3. Add the initialization right before return
|
||||
init_code = """
|
||||
// Initialize Queue Sync
|
||||
const queueSync = useQueueSync({
|
||||
allPatients,
|
||||
currentProcessingPatient,
|
||||
activeLoketInterest,
|
||||
activeClinicInterest,
|
||||
globalInterestCount,
|
||||
fetchPatientsForLoket,
|
||||
fetchPatientsForClinic,
|
||||
fetchAllPatients
|
||||
});
|
||||
|
||||
const {
|
||||
isWsConnected,
|
||||
wsClientId,
|
||||
lastGlobalCall,
|
||||
lastKlinikCall,
|
||||
initWebSocket,
|
||||
disconnectWebSocket,
|
||||
sendViaPost,
|
||||
startAutoSync,
|
||||
stopAutoSync
|
||||
} = queueSync;
|
||||
|
||||
return {
|
||||
"""
|
||||
|
||||
content = content.replace(" return {\n // State\n allPatients,", init_code + " // State\n allPatients,")
|
||||
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
print("Success")
|
||||
except Exception as e:
|
||||
print("Error:", e)
|
||||
sys.exit(1)
|
||||
+14
-1
@@ -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]) {
|
||||
|
||||
+17
-16
@@ -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);
|
||||
});
|
||||
|
||||
@@ -2,43 +2,48 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useClinicStore } from './clinicStore';
|
||||
import { usePenunjangStore } from './penunjangStore';
|
||||
import { useLoketStore } from './loketStore';
|
||||
import { usePenunjangStore } from './penunjangStore';
|
||||
import { useWebSocket } from '@/composables/useWebSocket';
|
||||
import { useQueueAPI } from '@/composables/useQueueAPI';
|
||||
import { useQueueSync } from '@/composables/useQueueSync';
|
||||
import type { QueuePatient } from '@/types/queue';
|
||||
|
||||
export const useQueueStore = defineStore('queue', () => {
|
||||
const config = useRuntimeConfig();
|
||||
const clinicStore = useClinicStore();
|
||||
const penunjangStore = usePenunjangStore();
|
||||
const loketStore = useLoketStore();
|
||||
const queueAPI = useQueueAPI();
|
||||
|
||||
// ============================================
|
||||
// API INTEGRATION FOR LOKET PATIENTS
|
||||
// ============================================
|
||||
|
||||
// State untuk API patient data per loket
|
||||
const allPatients = ref([]);
|
||||
const apiPatientsPerLoket = ref({});
|
||||
const isLoadingPatients = ref(false);
|
||||
const apiPatientsError = ref(null);
|
||||
const quotaUsed = ref(5);
|
||||
const currentProcessingPatient = ref({});
|
||||
const allPatients = ref<QueuePatient[]>([]);
|
||||
const apiPatientsPerLoket = ref<Record<string, QueuePatient[]>>({});
|
||||
const isLoadingPatients = ref<boolean>(false);
|
||||
const apiPatientsError = ref<string | null>(null);
|
||||
const quotaUsed = ref<number>(5);
|
||||
const currentProcessingPatient = ref<Record<string, QueuePatient>>({});
|
||||
|
||||
const lastUpdated = ref(Date.now());
|
||||
const lastFetchTime = ref({});
|
||||
const lastGlobalFetchTime = ref(0); // Cooldown for bulk refreshes
|
||||
const lastUpdated = ref<number>(Date.now());
|
||||
const lastFetchTime = ref<Record<string, number>>({});
|
||||
const lastGlobalFetchTime = ref<number>(0); // Cooldown for bulk refreshes
|
||||
|
||||
// Scoped Refresh Logic: track which lokets are currently being viewed
|
||||
const activeLoketInterest = ref({}); // { [loketId]: count }
|
||||
const activeClinicInterest = ref({}); // { [kodeKlinik]: count }
|
||||
const globalInterestCount = ref(0); // Tracks pages that need ALL loket data (e.g. CheckInPasien)
|
||||
const activeLoketInterest = ref<Record<string, number>>({}); // { [loketId]: count }
|
||||
const activeClinicInterest = ref<Record<string, number>>({}); // { [kodeKlinik]: count }
|
||||
const globalInterestCount = ref<number>(0); // Tracks pages that need ALL loket data (e.g. CheckInPasien)
|
||||
|
||||
const registerInterest = (loketId) => {
|
||||
const registerInterest = (loketId: string | number) => {
|
||||
if (!loketId) return;
|
||||
const id = String(loketId);
|
||||
activeLoketInterest.value[id] = (activeLoketInterest.value[id] || 0) + 1;
|
||||
};
|
||||
|
||||
const unregisterInterest = (loketId) => {
|
||||
const unregisterInterest = (loketId: string | number) => {
|
||||
if (!loketId) return;
|
||||
const id = String(loketId);
|
||||
if (activeLoketInterest.value[id]) {
|
||||
@@ -49,13 +54,13 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
}
|
||||
};
|
||||
|
||||
const registerClinicInterest = (kodeKlinik) => {
|
||||
const registerClinicInterest = (kodeKlinik: string) => {
|
||||
if (!kodeKlinik) return;
|
||||
const code = String(kodeKlinik);
|
||||
activeClinicInterest.value[code] = (activeClinicInterest.value[code] || 0) + 1;
|
||||
};
|
||||
|
||||
const unregisterClinicInterest = (kodeKlinik) => {
|
||||
const unregisterClinicInterest = (kodeKlinik: string) => {
|
||||
if (!kodeKlinik) return;
|
||||
const code = String(kodeKlinik);
|
||||
if (activeClinicInterest.value[code]) {
|
||||
@@ -74,7 +79,7 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
globalInterestCount.value = Math.max(0, globalInterestCount.value - 1);
|
||||
};
|
||||
|
||||
const fetchPatientsForClinic = async (kodeKlinik, force = false) => {
|
||||
const fetchPatientsForClinic = async (kodeKlinik: string, force: boolean = false) => {
|
||||
if (!kodeKlinik) return { success: false, message: 'Kode Klinik diperlukan' };
|
||||
|
||||
isLoadingPatients.value = true;
|
||||
@@ -86,7 +91,7 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
const timeSinceLastFetch = now - lastFetch;
|
||||
|
||||
if (!force && timeSinceLastFetch < 2000) {
|
||||
console.log(`⏭️ [queueStore] Skipping fetch for clinic ${kodeKlinik} (last fetched ${Math.round(timeSinceLastFetch/1000)}s ago)`);
|
||||
// console.log(`⏭️ [queueStore] Skipping fetch for clinic ${kodeKlinik} (last fetched ${Math.round(timeSinceLastFetch/1000)}s ago)`);
|
||||
isLoadingPatients.value = false;
|
||||
return { success: true, message: 'Using cache' };
|
||||
}
|
||||
@@ -100,14 +105,9 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
throw new Error(`Klinik ID tidak ditemukan untuk kode: ${kodeKlinik}`);
|
||||
}
|
||||
|
||||
const url = `${config.public.externalApiBaseUrl}/visit?klinik_id=${clinic.id}&limit=500`;
|
||||
console.log(`🔄 [queueStore] Fetching patients for clinic ${kodeKlinik} (ID: ${clinic.id})...`);
|
||||
// console.log(`🔄 [queueStore] Fetching patients for clinic ${kodeKlinik} (ID: ${clinic.id})...`);
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
||||
|
||||
const rawResponse = await response.json();
|
||||
const data = rawResponse?.data || [];
|
||||
const data = await queueAPI.fetchRawClinicPatients(clinic.id);
|
||||
|
||||
const mappedClinicPatients = [];
|
||||
data.forEach((visit, index) => {
|
||||
@@ -270,7 +270,7 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
syncCountersWithState();
|
||||
return { success: true, message: `${mappedClinicPatients.length} pasien dimuat` };
|
||||
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error(`❌ [queueStore] Error fetching clinic patients (${kodeKlinik}):`, error);
|
||||
apiPatientsError.value = error.message;
|
||||
return { success: false, message: error.message };
|
||||
@@ -280,251 +280,6 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// WEBSOCKET INTEGRATION (CENTRALIZED)
|
||||
// ============================================
|
||||
// ============================================
|
||||
// WEBSOCKET INTEGRATION (CENTRALIZED)
|
||||
// ============================================
|
||||
const isWsConnected = ref(false);
|
||||
const wsClientId = ref(`client-${Math.random().toString(36).substring(7)}`);
|
||||
const lastGlobalCall = ref(null);
|
||||
const lastKlinikCall = ref(null);
|
||||
|
||||
const onWsMessage = (data) => {
|
||||
// Robust data extraction: some relays wrap data in another 'data' property
|
||||
let messageData = data?.data || data;
|
||||
if (messageData?.data && !messageData.callKlinikEvent && !messageData.callEvent) {
|
||||
messageData = messageData.data; // Double wrap check
|
||||
}
|
||||
|
||||
const targetLoketId = messageData?.loketId || messageData?.idloket;
|
||||
const targetKlinikId = messageData?.klinikId || messageData?.idklinik;
|
||||
|
||||
// Handle Call Events and WS messages
|
||||
if (messageData?.triggerRefresh) {
|
||||
if (messageData.klinikId) {
|
||||
console.log(`🔄 [queueStore] Received refresh trigger for clinic ${messageData.klinikId}`);
|
||||
|
||||
// Handle current processing update if provided
|
||||
if (messageData.currentProcessingUpdate) {
|
||||
console.log(`🎯 [queueStore] Applying current processing update:`, messageData.currentProcessingUpdate);
|
||||
|
||||
// Merge specifically for the keys that exist in the update
|
||||
Object.keys(messageData.currentProcessingUpdate).forEach(key => {
|
||||
currentProcessingPatient.value[key] = messageData.currentProcessingUpdate[key];
|
||||
|
||||
// Also patch the status in allPatients if possible
|
||||
const processingPatient = messageData.currentProcessingUpdate[key];
|
||||
if (processingPatient && processingPatient.no) {
|
||||
const idx = allPatients.value.findIndex(p => p.no === processingPatient.no);
|
||||
if (idx !== -1) {
|
||||
allPatients.value[idx] = { ...allPatients.value[idx], status: 'di-loket' };
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fetchPatientsForClinic(messageData.klinikId, true);
|
||||
}
|
||||
}
|
||||
if (messageData?.callEvent) {
|
||||
lastGlobalCall.value = messageData.callEvent;
|
||||
}
|
||||
|
||||
// Handle Klinik Call Events (cross-device sync for AntrianKlinikRuang display)
|
||||
if (messageData?.callKlinikEvent) {
|
||||
const ev = messageData.callKlinikEvent;
|
||||
console.log('🏥 [queueStore] Klinik call event received:', ev);
|
||||
|
||||
// PERSISTENCE FIX: Save to lastKlinikCall for displays to watch
|
||||
lastKlinikCall.value = ev;
|
||||
|
||||
// Find the patient in allPatients and patch directly for immediate UI update
|
||||
// Match by barcode (string) or target antrian number (part before |)
|
||||
const idx = allPatients.value.findIndex(p =>
|
||||
p.processStage === 'klinik-ruang' &&
|
||||
p.kodeKlinik === ev.kodeKlinik &&
|
||||
(
|
||||
(p.barcode && String(p.barcode) === String(ev.barcode)) ||
|
||||
(p.noAntrian && p.noAntrian.split(' |')[0] === ev.noantrian)
|
||||
)
|
||||
);
|
||||
|
||||
if (idx !== -1) {
|
||||
// Create a patched object to ensure reactivity
|
||||
const updatedPatient = {
|
||||
...allPatients.value[idx],
|
||||
tipeLayanan: ev.tipeLayanan,
|
||||
lastCalledAt: ev.lastCalledAt || new Date().toISOString(),
|
||||
lastCalledTipeLayanan: ev.tipeLayanan,
|
||||
status: 'di-loket',
|
||||
calledPemeriksaanAwal: ev.tipeLayanan === 'Pemeriksaan Awal' ? true : allPatients.value[idx].calledPemeriksaanAwal,
|
||||
calledTindakan: ev.tipeLayanan === 'Tindakan' ? true : allPatients.value[idx].calledTindakan
|
||||
};
|
||||
|
||||
allPatients.value[idx] = updatedPatient;
|
||||
console.log(`✅ [queueStore] Successfully patched patient ${ev.noantrian} status to di-loket (lastCalledAt: ${updatedPatient.lastCalledAt})`);
|
||||
} else {
|
||||
console.warn(`⚠️ [queueStore] Patient ${ev.noantrian} not found in store for clinic ${ev.kodeKlinik}.`);
|
||||
console.log('🧪 [queueStore] Available klinik-ruang patients in store:',
|
||||
allPatients.value
|
||||
.filter(p => p.processStage === 'klinik-ruang')
|
||||
.map(p => `[${p.kodeKlinik}] ${p.noAntrian?.split(' |')[0]} / ${p.barcode}`)
|
||||
);
|
||||
}
|
||||
}
|
||||
// TRIGGER STRATEGIC REFRESHES
|
||||
let refreshedSomething = false;
|
||||
|
||||
if (targetLoketId) {
|
||||
fetchPatientsForLoket(targetLoketId, true);
|
||||
refreshedSomething = true;
|
||||
}
|
||||
|
||||
if (targetKlinikId) {
|
||||
const interestingClinics = Object.keys(activeClinicInterest.value);
|
||||
if (interestingClinics.includes(String(targetKlinikId)) || targetKlinikId === 'broadcast') {
|
||||
const clinicToFetch = targetKlinikId === 'broadcast' ? interestingClinics[0] : targetKlinikId;
|
||||
fetchPatientsForClinic(clinicToFetch, true);
|
||||
refreshedSomething = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (globalInterestCount.value > 0) {
|
||||
fetchAllPatients();
|
||||
refreshedSomething = true;
|
||||
}
|
||||
|
||||
// ALWAYS refresh our own active interests when a WebSocket message is received,
|
||||
// because shared lists (e.g. unassigned patients in 'menunggu') might have changed.
|
||||
const interestingLokets = Object.keys(activeLoketInterest.value);
|
||||
const interestingClinics = Object.keys(activeClinicInterest.value);
|
||||
|
||||
if (interestingLokets.length > 0) {
|
||||
interestingLokets.forEach(loketId => {
|
||||
if (String(loketId) !== String(targetLoketId)) {
|
||||
fetchPatientsForLoket(loketId, true);
|
||||
refreshedSomething = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (interestingClinics.length > 0) {
|
||||
interestingClinics.forEach(kodeKlinik => {
|
||||
if (String(kodeKlinik) !== String(targetKlinikId)) {
|
||||
fetchPatientsForClinic(kodeKlinik, true);
|
||||
refreshedSomething = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!refreshedSomething) {
|
||||
console.log(`🔕 [queueStore] WS trigger received but no active interest matched. Skipping.`);
|
||||
}
|
||||
};
|
||||
|
||||
const config = useRuntimeConfig();
|
||||
const wsBaseUrl = config.public?.wsBaseUrl || "ws://10.10.123.135:8084/api/v1/ws";
|
||||
|
||||
const { connect, disconnect, sendViaPost, isConnected } = useWebSocket({
|
||||
url: wsBaseUrl,
|
||||
clientId: wsClientId,
|
||||
fallbackPostUrl: `${config.public.externalApiBaseUrl}/ws`,
|
||||
reconnectInterval: 2000, // 2 seconds between reconnect attempts
|
||||
maxReconnectAttempts: 9999, // Effectively infinite — never give up on remote machines
|
||||
onOpen: () => {
|
||||
console.log('✅ [queueStore] WebSocket connected');
|
||||
isWsConnected.value = true;
|
||||
},
|
||||
onClose: () => {
|
||||
console.log('❌ [queueStore] WebSocket disconnected');
|
||||
isWsConnected.value = false;
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error('⚠️ [queueStore] WebSocket error:', err);
|
||||
isWsConnected.value = false;
|
||||
},
|
||||
onMessage: onWsMessage
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// STORE-LEVEL AUTO-POLLING (cross-device sync fallback)
|
||||
// ============================================
|
||||
// Runs on every browser instance every 30 seconds.
|
||||
// Fetches data based on whatever active interests are registered
|
||||
// (lokets, clinics, or global). This ensures any device always
|
||||
// has fresh data regardless of WS delivery reliability.
|
||||
let _autoSyncInterval = null;
|
||||
|
||||
const startAutoSync = () => {
|
||||
// Guard: only run on client, and only start once
|
||||
if (typeof window === 'undefined') return;
|
||||
if (_autoSyncInterval) return; // Already running
|
||||
|
||||
console.log('🔄 [queueStore] Starting store-level auto-sync (30s interval)');
|
||||
|
||||
_autoSyncInterval = setInterval(async () => {
|
||||
const hasLoketInterest = Object.keys(activeLoketInterest.value).length > 0;
|
||||
const hasClinicInterest = Object.keys(activeClinicInterest.value).length > 0;
|
||||
const hasGlobalInterest = globalInterestCount.value > 0;
|
||||
|
||||
if (hasGlobalInterest) {
|
||||
fetchAllPatients();
|
||||
} else {
|
||||
if (hasLoketInterest) {
|
||||
Object.keys(activeLoketInterest.value).forEach(loketId => {
|
||||
fetchPatientsForLoket(loketId, true);
|
||||
});
|
||||
}
|
||||
if (hasClinicInterest) {
|
||||
Object.keys(activeClinicInterest.value).forEach(kodeKlinik => {
|
||||
fetchPatientsForClinic(kodeKlinik, true);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 30000); // 30 seconds
|
||||
};
|
||||
|
||||
const stopAutoSync = () => {
|
||||
if (_autoSyncInterval) {
|
||||
clearInterval(_autoSyncInterval);
|
||||
_autoSyncInterval = null;
|
||||
console.log('⏹️ [queueStore] Store-level auto-sync stopped');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize Global WebSocket
|
||||
*/
|
||||
const initWebSocket = (customClientId = null) => {
|
||||
if (isConnected.value && customClientId === wsClientId.value) {
|
||||
console.log('🔌 [queueStore] WebSocket already connected with same ID.');
|
||||
// Auto-sync should still start even if WS is already connected
|
||||
startAutoSync();
|
||||
return;
|
||||
}
|
||||
|
||||
if (customClientId) {
|
||||
wsClientId.value = customClientId;
|
||||
// Re-connect with new ID if changed
|
||||
disconnect();
|
||||
}
|
||||
|
||||
console.log(`🔌 [queueStore] Connecting to WebSocket: ${wsBaseUrl} as ${wsClientId.value}`);
|
||||
connect();
|
||||
|
||||
// Start store-level auto-polling if not already running (client-side only).
|
||||
// This guarantees cross-device sync even when WS messages are missed.
|
||||
startAutoSync();
|
||||
};
|
||||
|
||||
/**
|
||||
* Disconnect Global WebSocket
|
||||
*/
|
||||
const disconnectWebSocket = () => {
|
||||
disconnect();
|
||||
isWsConnected.value = false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sync patient status to apiPatientsPerLoket for reactivity
|
||||
@@ -602,12 +357,7 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
18: 'pemeriksaan', // PO PEMERIKSAAN
|
||||
19: 'pemeriksaan', // PS PEMERIKSAAN
|
||||
32: 'pending', // PE PEMERIKSAAN
|
||||
33: 'terlambat', // TR PEMERIKSAAN
|
||||
|
||||
// String versions for robustness
|
||||
"1": 'menunggu', "2": 'menunggu', "3": 'anjungan', "4": 'anjungan', "5": 'di-loket',
|
||||
"6": 'di-loket', "14": 'pemeriksaan', "15": 'pemeriksaan', "28": 'pending', "29": 'terlambat',
|
||||
"30": 'pending', "31": 'terlambat', "32": 'pending', "33": 'terlambat'
|
||||
33: 'terlambat' // TR PEMERIKSAAN
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -742,7 +492,7 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
/**
|
||||
* Fetch patient data untuk loket tertentu dari API
|
||||
*/
|
||||
const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
const fetchPatientsForLoket = async (loketId: string | number, force: boolean = false) => {
|
||||
if (!loketId) {
|
||||
console.error('loketId required for fetchPatientsForLoket');
|
||||
return { success: false, message: 'ID Loket diperlukan' };
|
||||
@@ -757,7 +507,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
const timeSinceLastFetch = now - lastFetch;
|
||||
|
||||
if (!force && timeSinceLastFetch < 2000 && apiPatientsPerLoket.value[loketId]) {
|
||||
console.log(`⏭️ [queueStore] Skipping fetch for loket ${loketId} (last fetched ${Math.round(timeSinceLastFetch/1000)}s ago)`);
|
||||
// console.log(`⏭️ [queueStore] Skipping fetch for loket ${loketId} (last fetched ${Math.round(timeSinceLastFetch/1000)}s ago)`);
|
||||
isLoadingPatients.value = false;
|
||||
return {
|
||||
success: true,
|
||||
@@ -773,22 +523,8 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
checkAndResetDaily();
|
||||
|
||||
try {
|
||||
console.log(`🔄 [queueStore] Fetching patients for loket ${loketId}...`);
|
||||
const apiBase = config.public.verificationApiBaseUrl || 'http://10.10.123.140:8089/api/v1';
|
||||
const response = await fetch(`${apiBase}/loket/${loketId}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const rawData = await response.json();
|
||||
|
||||
// Check response structure
|
||||
if (rawData.metadata && rawData.metadata.code !== 200) {
|
||||
throw new Error(rawData.message || 'API returned error status');
|
||||
}
|
||||
|
||||
const patientsRaw = rawData.data || [];
|
||||
// console.log(`🔄 [queueStore] Fetching patients for loket ${loketId}...`);
|
||||
const patientsRaw = await queueAPI.fetchRawLoketPatients(loketId);
|
||||
|
||||
// Fetch temporary subspesialis mapping
|
||||
let subspesialisMap = {};
|
||||
@@ -797,7 +533,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
if (subRes.ok) {
|
||||
subspesialisMap = await subRes.json();
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
console.error('Failed to fetch temporary subspesialis mapping', e);
|
||||
}
|
||||
|
||||
@@ -999,7 +735,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
|
||||
// 6. RESTORE terlambat/pending status from LocalStorage (hybrid fallback)
|
||||
// This ensures status persists even if API doesn't save it
|
||||
allPatients.value.forEach((patient, index) => {
|
||||
allPatients.value.forEach((patient: any, index: number) => {
|
||||
if (patient.barcode) {
|
||||
const storageKey = `patient-status-${patient.barcode}`;
|
||||
const savedData = localStorage.getItem(storageKey);
|
||||
@@ -1021,7 +757,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
// Clean up old data
|
||||
localStorage.removeItem(storageKey);
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
console.error('Error parsing LocalStorage data:', e);
|
||||
localStorage.removeItem(storageKey);
|
||||
}
|
||||
@@ -1041,7 +777,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
data: mappedPatients
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error(`❌ [queueStore] Error fetching patients for loket ${loketId}:`, error);
|
||||
apiPatientsError.value = error.message;
|
||||
|
||||
@@ -1066,7 +802,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
* Global fetcher for all patients across all available lokets
|
||||
* Uses staggered fetching to prevent 429 Too Many Requests errors.
|
||||
*/
|
||||
const fetchAllPatients = async (force = false) => {
|
||||
const fetchAllPatients = async (force: boolean = false) => {
|
||||
// 1. Cooldown Check: Prevent global refresh spam (max once every 5 seconds)
|
||||
const now = Date.now();
|
||||
if (!force && now - lastGlobalFetchTime.value < 5000) {
|
||||
@@ -1075,7 +811,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
}
|
||||
lastGlobalFetchTime.value = now;
|
||||
|
||||
console.log('🔄 [queueStore] Fetching all patients for all lokets (Staggered)...');
|
||||
// console.log('🔄 [queueStore] Fetching all patients for all lokets (Staggered)...');
|
||||
const allLokets = loketStore.lokets || [];
|
||||
if (allLokets.length === 0) {
|
||||
console.warn('⚠️ [queueStore] No lokets available for fetchAllPatients');
|
||||
@@ -1129,7 +865,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
/**
|
||||
* Get patients for a specific loket (from API or seed data based on loket type)
|
||||
*/
|
||||
const getPatientsForLoket = (loketId) => {
|
||||
const getPatientsForLoket = (loketId: string | number) => {
|
||||
return computed(() => {
|
||||
const loket = loketStore.getLoketById(parseInt(loketId));
|
||||
|
||||
@@ -1139,11 +875,10 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
(loket?.namaLoket || '').toUpperCase().includes('EKSEKUTIF');
|
||||
|
||||
if (isEksekutif) {
|
||||
// Return EKSEKUTIF patients from seed data
|
||||
// Return EKSEKUTIF patients assigned to this loket
|
||||
return allPatients.value.filter(p => {
|
||||
const isPembayaranEksekutif = (p.pembayaran || '').toUpperCase().includes('EKSEKUTIF') ||
|
||||
(p.pembayaran || '').toUpperCase().includes('VIP');
|
||||
return isPembayaranEksekutif && p.processStage === 'loket';
|
||||
const isMatchingLoket = String(p.loketId) === String(loketId);
|
||||
return p.processStage === 'loket' && isMatchingLoket;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1527,7 +1262,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
/**
|
||||
* Filter strictly to only show today's patients (after 2 AM)
|
||||
*/
|
||||
const isTodayPatient = (patient) => {
|
||||
const isTodayPatient = (patient: any) => {
|
||||
if (!patient) return false;
|
||||
|
||||
// Status processing overrides filter (always show if currently processing)
|
||||
@@ -1617,7 +1352,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
setTimeout(() => { isSyncing = false; }, 50);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
console.error('Error hydrating from storage event:', e);
|
||||
isSyncing = false;
|
||||
}
|
||||
@@ -1670,7 +1405,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
const resetPatients = () => {
|
||||
allPatients.value = cloneSeed();
|
||||
quotaUsed.value = 5;
|
||||
currentProcessingPatient.value = { loket: null, klinik: null, penunjang: null };
|
||||
currentProcessingPatient.value = {};
|
||||
syncCountersWithState(); // Re-initialize counters after reset
|
||||
};
|
||||
|
||||
@@ -1749,6 +1484,20 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
// Enforce clinic mapping (pelayanan)
|
||||
if (thisLoket && thisLoket.pelayanan && Array.isArray(thisLoket.pelayanan)) {
|
||||
if (thisLoket.pelayanan.includes(p.kodeKlinik)) {
|
||||
// Bypass payment check for Eksekutif patients
|
||||
const isPatientEksekutif = p.noAntrian && (p.noAntrian.startsWith('E') || p.noAntrian.startsWith('F-E'));
|
||||
const isLoketEksekutif = thisLoket.tipeLoket === 'EKSEKUTIF' || thisLoket.id >= 1000;
|
||||
|
||||
if (isPatientEksekutif && isLoketEksekutif) {
|
||||
return true;
|
||||
}
|
||||
if (!isPatientEksekutif && isLoketEksekutif) {
|
||||
return false;
|
||||
}
|
||||
if (isPatientEksekutif && !isLoketEksekutif) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// NEW: Check payment compatibility
|
||||
if (!isPaymentCompatible(p.pembayaran, thisLoket.pembayaran)) {
|
||||
return false;
|
||||
@@ -1815,20 +1564,13 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
|
||||
// POST to external API when patient is called
|
||||
try {
|
||||
const apiBase = config.public.verificationApiBaseUrl || 'http://10.10.123.140:8089/api/v1';
|
||||
fetch(`${apiBase}/tiket/update`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
barcode: nextPatient.barcode || "",
|
||||
statuspasien: "3",
|
||||
statuspasien2: "4",
|
||||
idklinikstatus: "1",
|
||||
idklinikstatus2: "1"
|
||||
})
|
||||
}).then(response => {
|
||||
queueAPI.updateTicketStatus(
|
||||
nextPatient.barcode || "",
|
||||
"3",
|
||||
"4",
|
||||
"1",
|
||||
"1"
|
||||
).then(response => {
|
||||
if (response.ok) {
|
||||
console.log(`✅ Successfully posted status update for patient ${nextPatient.barcode}`);
|
||||
} else {
|
||||
@@ -1837,7 +1579,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
}).catch(error => {
|
||||
console.error(`❌ Error posting status update for patient ${nextPatient.barcode}:`, error);
|
||||
});
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error(`❌ Error initiating status update for patient ${nextPatient.barcode}:`, error);
|
||||
}
|
||||
}
|
||||
@@ -1909,7 +1651,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
const patientsToCall = menungguList.slice(0, maxCallable);
|
||||
const callTimestamp = new Date().toISOString();
|
||||
|
||||
patientsToCall.forEach(async (patient) => {
|
||||
patientsToCall.forEach(async (patient: any) => {
|
||||
const index = allPatients.value.findIndex(p => p.no === patient.no);
|
||||
if (index !== -1) {
|
||||
const newStatus = "anjungan";
|
||||
@@ -1927,27 +1669,20 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
|
||||
// POST to external API when patient is called
|
||||
try {
|
||||
const apiBase = config.public.verificationApiBaseUrl || 'http://10.10.123.140:8089/api/v1';
|
||||
const response = await fetch(`${apiBase}/tiket/update`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
barcode: patient.barcode || "",
|
||||
statuspasien: "3",
|
||||
statuspasien2: "4",
|
||||
idklinikstatus: "1",
|
||||
idklinikstatus2: "1"
|
||||
})
|
||||
});
|
||||
const response = await queueAPI.updateTicketStatus(
|
||||
patient.barcode || "",
|
||||
"3",
|
||||
"4",
|
||||
"1",
|
||||
"1"
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
console.log(`✅ Successfully posted status update for patient ${patient.barcode}`);
|
||||
} else {
|
||||
console.error(`⚠️ Failed to post status update for patient ${patient.barcode}:`, response.status);
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error(`❌ Error posting status update for patient ${patient.barcode}:`, error);
|
||||
}
|
||||
}
|
||||
@@ -1986,19 +1721,12 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
|
||||
// POST to external API when patient finishes at loket
|
||||
try {
|
||||
const apiBase = config.public.verificationApiBaseUrl || 'http://10.10.123.140:8089/api/v1';
|
||||
fetch(`${apiBase}/tiket/selesai`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
idloket: String(patient.loketId || specificId || ""),
|
||||
barcode: patient.barcode || "",
|
||||
statuspasien: "9",
|
||||
idklinikstatus: "2"
|
||||
})
|
||||
}).then(response => {
|
||||
queueAPI.completeTicketStatus(
|
||||
String(patient.loketId || specificId || ""),
|
||||
patient.barcode || "",
|
||||
"9",
|
||||
"2"
|
||||
).then(response => {
|
||||
if (response.ok) {
|
||||
console.log(`✅ [queueStore] Successfully posted selesai status for patient ${patient.barcode}`);
|
||||
} else {
|
||||
@@ -2007,7 +1735,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
}).catch(error => {
|
||||
console.error(`❌ [queueStore] Error posting selesai status for patient ${patient.barcode}:`, error);
|
||||
});
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error(`❌ [queueStore] Error initiating selesai status update for patient ${patient.barcode}:`, error);
|
||||
}
|
||||
}
|
||||
@@ -2088,7 +1816,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
console.error(`❌ [TERLAMBAT] API rejected request:`, responseData);
|
||||
message = `Gagal: ${responseData.message || 'API error'}`;
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error(`❌ [TERLAMBAT] Error:`, error);
|
||||
message = `Error: ${error.message}`;
|
||||
}
|
||||
@@ -2145,7 +1873,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
console.error(`❌ [PENDING] API rejected request:`, responseData);
|
||||
message = `Gagal: ${responseData.message || 'API error'}`;
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error(`❌ [PENDING] Error:`, error);
|
||||
message = `Error: ${error.message}`;
|
||||
}
|
||||
@@ -2188,7 +1916,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
}).catch(error => {
|
||||
console.error(`❌ [queueStore] Error activating patient ${patient.barcode}:`, error);
|
||||
});
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error(`❌ [queueStore] Error initiating activation for patient ${patient.barcode}:`, error);
|
||||
}
|
||||
} else {
|
||||
@@ -2253,7 +1981,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
}).catch(error => {
|
||||
console.error(`❌ [queueStore] Error updating patient ${patient.barcode} to sedang diproses:`, error);
|
||||
});
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error(`❌ [queueStore] Error initiating status update for patient ${patient.barcode}:`, error);
|
||||
}
|
||||
} else {
|
||||
@@ -2483,7 +2211,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
|
||||
// Pindah pasien ke klinik ruang lain dengan nomor antrian tetap
|
||||
const pindahKlinikRuang = (patient, targetKlinikRuang, targetRuang) => {
|
||||
|
||||
const patientIndex = allPatients.value.findIndex(p => p.no === patient.no);
|
||||
if (patientIndex === -1) {
|
||||
return { success: false, message: "Pasien tidak ditemukan" };
|
||||
}
|
||||
@@ -2820,7 +2548,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
} else {
|
||||
console.log('✅ [queueStore] Successfully finished patient via API');
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error('❌ [queueStore] Error calling finish API:', error);
|
||||
// Continue with local status update even if API fails
|
||||
}
|
||||
@@ -2846,7 +2574,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error('❌ [queueStore] Error calling terlambat API:', error);
|
||||
}
|
||||
|
||||
@@ -2870,7 +2598,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error('❌ [queueStore] Error calling pending API:', error);
|
||||
}
|
||||
|
||||
@@ -2894,11 +2622,22 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
let targetLoketName = oldPatient.loket;
|
||||
|
||||
if (newKlinik.kode) {
|
||||
const isEksekutif = (oldPatient.pembayaran || '').toUpperCase().includes('EKSEKUTIF') ||
|
||||
(oldPatient.pembayaran || '').toUpperCase().includes('VIP');
|
||||
|
||||
const allLokets = loketStore.lokets || [];
|
||||
const foundLoket = allLokets.find(l =>
|
||||
l.pelayanan && Array.isArray(l.pelayanan) &&
|
||||
(l.pelayanan.includes(newKlinik.kode) || l.pelayanan.includes(newKlinik.kode?.split('-')[0]))
|
||||
);
|
||||
const foundLoket = allLokets.find(l => {
|
||||
const handlesClinic = l.pelayanan && Array.isArray(l.pelayanan) &&
|
||||
(l.pelayanan.includes(newKlinik.kode) || l.pelayanan.includes(newKlinik.kode?.split('-')[0]));
|
||||
|
||||
if (!handlesClinic) return false;
|
||||
|
||||
if (isEksekutif) {
|
||||
return l.id >= 1000 || l.tipeLoket === 'EKSEKUTIF';
|
||||
} else {
|
||||
return l.id < 1000 && l.tipeLoket !== 'EKSEKUTIF';
|
||||
}
|
||||
});
|
||||
|
||||
if (foundLoket) {
|
||||
targetLoketId = foundLoket.id;
|
||||
@@ -3067,15 +2806,31 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
// fulfill requirement: "adjust based on loket id depending on creation"
|
||||
if (!newPatient.loketId && newPatient.kodeKlinik) {
|
||||
const allLokets = loketStore.lokets || [];
|
||||
const targetLoket = allLokets.find(l =>
|
||||
l.pelayanan && Array.isArray(l.pelayanan) &&
|
||||
(l.pelayanan.includes(newPatient.kodeKlinik) || l.pelayanan.includes(newPatient.kodeKlinik?.split('-')[0]))
|
||||
);
|
||||
|
||||
if (targetLoket) {
|
||||
newPatient.loketId = targetLoket.id;
|
||||
if (!newPatient.loket) newPatient.loket = targetLoket.namaLoket;
|
||||
console.log(`✅ Auto-assigned Onsite Ticket ${newPatient.noAntrian} to Loket ${targetLoket.id} (${targetLoket.namaLoket})`);
|
||||
if (isEksekutif) {
|
||||
const targetLoket = allLokets.find(l =>
|
||||
l.pelayanan && Array.isArray(l.pelayanan) &&
|
||||
(l.pelayanan.includes(newPatient.kodeKlinik) || l.pelayanan.includes(newPatient.kodeKlinik?.split('-')[0])) &&
|
||||
(l.id >= 1000 || l.tipeLoket === 'EKSEKUTIF')
|
||||
);
|
||||
|
||||
if (targetLoket) {
|
||||
newPatient.loketId = targetLoket.id;
|
||||
if (!newPatient.loket) newPatient.loket = targetLoket.namaLoket;
|
||||
console.log(`✅ Auto-assigned Eksekutif Ticket ${newPatient.noAntrian} to Loket ${targetLoket.id} (${targetLoket.namaLoket})`);
|
||||
} else {
|
||||
console.warn(`⚠️ Warning: No Eksekutif Loket found for clinic ${newPatient.kodeKlinik}. Ticket will not be routed to any loket.`);
|
||||
}
|
||||
} else {
|
||||
const targetLoket = allLokets.find(l =>
|
||||
l.pelayanan && Array.isArray(l.pelayanan) &&
|
||||
(l.pelayanan.includes(newPatient.kodeKlinik) || l.pelayanan.includes(newPatient.kodeKlinik?.split('-')[0]))
|
||||
);
|
||||
|
||||
if (targetLoket) {
|
||||
newPatient.loketId = targetLoket.id;
|
||||
if (!newPatient.loket) newPatient.loket = targetLoket.namaLoket;
|
||||
console.log(`✅ Auto-assigned Onsite Ticket ${newPatient.noAntrian} to Loket ${targetLoket.id} (${targetLoket.namaLoket})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3238,7 +2993,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
subspesialis: subSpesialis
|
||||
})
|
||||
});
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
console.error('Failed to save temporary subSpesialis mapping', e);
|
||||
}
|
||||
}
|
||||
@@ -3250,7 +3005,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
patient: newPatient
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error('❌ [queueStore] Error generating ticket via API:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -3292,7 +3047,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
data: result
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error('❌ [queueStore] Error syncing check-in via API:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -3422,6 +3177,31 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
return { success: false, message: "Gagal memproses antrean." };
|
||||
};
|
||||
|
||||
|
||||
// Initialize Queue Sync
|
||||
const queueSync = useQueueSync({
|
||||
allPatients,
|
||||
currentProcessingPatient,
|
||||
activeLoketInterest,
|
||||
activeClinicInterest,
|
||||
globalInterestCount,
|
||||
fetchPatientsForLoket,
|
||||
fetchPatientsForClinic,
|
||||
fetchAllPatients
|
||||
});
|
||||
|
||||
const {
|
||||
isWsConnected,
|
||||
wsClientId,
|
||||
lastGlobalCall,
|
||||
lastKlinikCall,
|
||||
initWebSocket,
|
||||
disconnectWebSocket,
|
||||
sendViaPost,
|
||||
startAutoSync,
|
||||
stopAutoSync
|
||||
} = queueSync;
|
||||
|
||||
return {
|
||||
// State
|
||||
allPatients,
|
||||
@@ -3504,12 +3284,13 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
persist: {
|
||||
key: 'queue-store-state',
|
||||
storage: typeof window !== 'undefined' ? localStorage : undefined,
|
||||
// @ts-ignore - plugin version mismatch
|
||||
paths: ['quotaUsed', 'lastUpdated'],
|
||||
serializer: {
|
||||
deserialize: JSON.parse,
|
||||
serialize: JSON.stringify,
|
||||
},
|
||||
restore: (value) => {
|
||||
restore: (value: any) => {
|
||||
// Ensure allPatients is always an array
|
||||
if (value && value.allPatients && !Array.isArray(value.allPatients)) {
|
||||
value.allPatients = [];
|
||||
@@ -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[];
|
||||
}
|
||||
Reference in New Issue
Block a user