145 Commits
Author SHA1 Message Date
yusron.alamsyah.2311 8745b22f07 fix : calender view jadwal dokter 2026-08-03 07:58:47 +07:00
yusron.alamsyah.2311 b0b5200802 fix : page detail akun peserta 2026-07-14 09:16:12 +07:00
Fanrouver fec58f14b5 docs: add Git workflow and branching guide for team reference 2026-07-10 14:06:15 +07:00
Fanrouver bbcaf9826c update skill dan docs 2026-07-10 11:14:36 +07:00
Fanrouver d0eee7d062 feat: implement Grand Paviliun dashboard with specialized room monitoring, queue management, and modular components 2026-07-10 09:42:30 +07:00
Fanrouver 61772105fd feat: implement queue store with clinic API integration, WebSocket sync, and patient data management 2026-07-02 11:39:07 +07:00
Fanrouver e18ed186e1 feat: implement queue management store and administrative interface for patient flow control 2026-06-23 08:24:26 +07:00
yusron.alamsyah.2311 400bfcdcaf feat detail akun 2026-06-19 11:10:51 +07:00
Fanrouver 8b9c4725de feat: implement queue display and management system for loket and clinic modules voice over 2026-06-09 08:51:15 +07:00
Fanrouver f81dd57a16 pindah klinik belum service 2026-06-05 08:21:56 +07:00
Fanrouver 0928e78bec feat: implement proxy routes for APIs, add Pinia stores for loket, clinic, and queue management, and create new pages for admin and anjungan dashboard. 2026-06-03 09:18:48 +07:00
Fanrouver 047b39064d feat: implement queue display system with localized store, composables, and administrative dashboard pages 2026-06-02 09:14:38 +07:00
Fanrouver ffdb88d13c push perbaikan 2026-05-25 08:43:30 +07:00
Fanrouver ec9dac0789 push perbaikan layar informasi info klinik ruang tidak muncul 2026-05-21 10:10:46 +07:00
Fanrouver f622052c67 perbaikan bug tampilan loket 2026-05-20 13:55:15 +07:00
Fanrouver 1446e87bba tambahan subspesialis tapi masih pisah 2026-05-20 08:51:52 +07:00
Fanrouver bc74b98cfd penambahan docker dan lain lain untuk penyesuaian docker 2026-05-19 09:57:46 +07:00
Fanrouver cb3310b44b perbaikan LOAD usage memperingan penggunaan 2026-05-18 09:22:47 +07:00
Fanrouver 2bf00eff3c update hak akses dan perbaikan api pembuatan tiket 2026-04-20 09:42:03 +07:00
Fanrouver 94ff9f55d9 perbaikan ambil tiket 2026-02-24 09:12:48 +07:00
Fanrouver 4342cdcc67 feat: Introduce a new Pinia store for comprehensive patient queue management, including API integration, and add supporting admin and kiosk display pages. 2026-02-19 11:18:36 +07:00
Fanrouver 0c4019c67d feat: Implement Anjungan (Kiosk) module including clinic room selection, various queue pages, patient check-in, and associated admin interfaces. 2026-02-19 08:28:00 +07:00
Fanrouver d03fa632e7 fix: Remove 'x' from wsBaseUrl fallback in Nuxt config. 2026-02-18 14:16:56 +07:00
Fanrouver 7d07a4f00d feat: Implement the core Anjungan queue display system, including clinic room, entry, and counter queues, along with new stores, pages, and API endpoints. 2026-02-18 12:00:30 +07:00
Fanrouver f78bbea4b0 feat: Add patient verification store and configure new ekstrakExpertiseUrl and verificationApiBaseUrl endpoints. 2026-02-18 08:00:29 +07:00
Fanrouver 8c6b3fd5dc feat: Add Admin Loket detail page for managing patient queues, patient actions, and various selection dialogs. 2026-02-13 15:12:43 +07:00
Fanrouver dbc90548e2 feat: Implement comprehensive patient queue management system with Pinia store, API integration, and dedicated admin counter page. 2026-02-13 15:09:01 +07:00
Fanrouver 95ff83027c feat: Add admin counter queue page with patient and current patient cards. 2026-02-13 14:59:00 +07:00
Fanrouver 67a5514654 feat: implement AdminKlinikRuang page for managing patient queues, including processing, calling, filtering, and a global search, alongside a new queueStore. 2026-02-13 14:26:49 +07:00
Fanrouver 4df13cbe70 feat: add comprehensive queue management features for clinic rooms and counters, including admin, kiosk views, and a dedicated store. 2026-02-13 13:07:59 +07:00
Fanrouver a5ab338147 feat: introduce Anjungan pages for clinic/room and counter queues, and a patient check-in page. 2026-02-12 11:20:12 +07:00
bagus-arie05 9022cb103a Merge branch 'Antrean-Code' of https://git.rssa.top/arie.bagus.2905/web-antrean into Antrean-Code 2026-02-12 11:06:04 +07:00
bagus-arie05 4985aef14a update post API klinik dan loket di queuestore 2026-02-12 11:05:55 +07:00
Fanrouver 0e3ee37fd8 feat: Add core queue management system with Pinia store, WebSocket integration, and dedicated pages for patient check-in and kiosk display. 2026-02-12 10:06:57 +07:00
bagus-arie05 c08941aed3 update fixing status dan pasien selesai ruang 2026-02-12 09:49:05 +07:00
Fanrouver 1fbfa0e590 dasd 2026-02-12 08:30:37 +07:00
Fanrouver 7fd99343bd push update 2026-02-12 08:06:20 +07:00
Fanrouver 23164bcf2d push error missing function 2026-02-11 10:29:19 +07:00
Fanrouver a7a654b72a update WS admin loket dan checkin 2026-02-11 09:34:42 +07:00
bagus-arie05 c02905e954 fix duplication and ticket disappear in adminklinik loket 2026-02-11 08:32:03 +07:00
Fanrouver b6dc252bc9 update bug ws checkin loket 2026-02-10 15:38:23 +07:00
Fanrouver 6af3f58e89 push change sort pasien next process 2026-02-10 12:18:02 +07:00
Fanrouver e686ddad57 fix socket checkin 2026-02-10 10:10:56 +07:00
Fanrouver fb70237a12 push update socket data dan token 2026-02-10 09:51:17 +07:00
Fanrouver 101dc38910 update web socket 2026-02-09 12:26:59 +07:00
bagus-arie05 6696881ee5 update status klinik ruang 2026-02-06 11:01:02 +07:00
Fanrouver 329ac9c3b0 update anjungan tampilan 2026-02-06 10:11:31 +07:00
Fanrouver 93874d16bb update display 2026-02-06 10:05:26 +07:00
bagus-arie05 5d139c8f4a fixing admin loket dan blokade loket yang tidak tersedia 2026-02-06 10:04:06 +07:00
Fanrouver 2838016244 update verif dkk 2026-02-05 12:06:25 +07:00
bagus-arie05 e2a5d43e76 update post api status dan API data pasien ruang 2026-02-05 12:00:33 +07:00
Fanrouver c2d9023418 perbaikan API klinik ruang 2026-02-04 15:08:05 +07:00
Fanrouver cf54ded78c update tampilan layar screen 2026-02-04 10:34:33 +07:00
Fanrouver a29838e2c7 update penambahan API ruang 2026-02-04 08:40:16 +07:00
Fanrouver b327997021 update api klinik ruang 2026-02-03 15:11:55 +07:00
bagus-arie05 efeb42e0fe update logika pemanggilan admin loket by payment 2026-02-03 14:26:10 +07:00
Fanrouver 0428017cac upate session dan ws 2026-02-02 14:47:00 +07:00
Fanrouver c899a71fd8 fix https implementation and checkin 2026-02-02 11:33:19 +07:00
bagus-arie05 93ff2fc3e0 Merge branch 'Antrean-Code' of https://git.rssa.top/arie.bagus.2905/web-antrean into Antrean-Code 2026-02-02 11:07:35 +07:00
bagus-arie05 53a510f23d update status waiting dan update post data pasien 2026-02-02 11:04:28 +07:00
Fanrouver a36aa28de0 Merge branch 'Antrean-Code' of https://git.rssa.top/arie.bagus.2905/Web-Antrean into Antrean-Code 2026-01-30 15:11:28 +07:00
Fanrouver 8dd94ed744 ##update fix session dan tampilan screen 2026-01-30 15:11:17 +07:00
bagus-arie05 7ba1c5b7dd update minor display 2026-01-30 14:53:03 +07:00
Fanrouver 507f415710 PUSH UP WOKE UPDATE Api ANTREAN MASUK DAN MINOR CHANGE 2026-01-30 13:40:43 +07:00
bagus-arie05 7c3fc4bd50 Merge branch 'Antrean-Code' of https://git.rssa.top/arie.bagus.2905/web-antrean into Antrean-Code 2026-01-29 09:52:10 +07:00
bagus-arie05 59e42f31b5 update minor anjungan 2026-01-29 09:50:22 +07:00
Fanrouver 0bd5311b6b fix loop dan duplicate data 2026-01-29 09:47:08 +07:00
bagus-arie05 cccefb0775 update fetch api 2026-01-29 08:44:18 +07:00
Fanrouver 2bf38e0f63 fix html error 2026-01-29 08:20:56 +07:00
Fanrouver ae8b06d66d update layout all 2026-01-29 08:17:26 +07:00
bagus-arie05 19633afde1 update queuestore dari API 2026-01-29 08:11:30 +07:00
bagus-arie05 9f0f6a75c9 update master dan ruang based on jenis layanan 2026-01-28 08:59:27 +07:00
Fanrouver f6060451c0 push update admin loket 2026-01-27 15:21:42 +07:00
Fanrouver 083fe3e364 update api loket admin 2026-01-26 14:37:05 +07:00
bagus-arie05 6c0835227d update master klinik ruang 2026-01-26 14:35:30 +07:00
Fanrouver 525322b6f6 update sidebar checkin dan antrian loket 2026-01-26 09:46:38 +07:00
Fanrouver 75a96389fe update API master LOKET dan antrian LOKET 2026-01-23 08:24:14 +07:00
bagus-arie05 8de89cb579 update anjungan 2026-01-23 08:22:42 +07:00
bagus-arie05 e900c5a1c2 update logika penarikan API dan tampilan anjungan 2026-01-22 10:36:28 +07:00
bagus-arie05 2ccb378672 update no rm dan desain 2026-01-22 09:32:43 +07:00
Fanrouver f3b50bd0a3 upade 2026-01-21 16:38:26 +07:00
Fanrouver 9bd4c51d65 perubahan loket antrean 2026-01-21 15:18:35 +07:00
Fanrouver 0542757afa Merge branch 'Antrean-Code' of https://git.rssa.top/arie.bagus.2905/Web-Antrean into Antrean-Code 2026-01-21 14:52:21 +07:00
Fanrouver bb71955576 perubahan update sidebar dashboard monitoring dan antrean loket verifikasi 2026-01-21 14:52:15 +07:00
bagus-arie05 f7a3b20087 update icon header 2026-01-21 13:12:38 +07:00
bagus-arie05 531ca100ac update header 2026-01-21 12:58:50 +07:00
bagus-arie05 fde71116ff update header dan card dialog 2026-01-21 09:56:07 +07:00
bagus-arie05 08750087b1 update API dan header 2026-01-21 08:59:13 +07:00
Fanrouver 9d45071a36 update sidebar dan dshboard 2026-01-21 08:48:08 +07:00
Fanrouver 482294b8b7 update checkin tampilan dan function 2026-01-20 11:48:00 +07:00
Fanrouver c00c18e878 update loket antrean 2026-01-19 15:48:16 +07:00
Fanrouver 81b877bb7f update loket dan monitoring 2026-01-19 15:27:50 +07:00
Fanrouver f2efd8355e update sidebar profile dan loket 2026-01-19 10:14:05 +07:00
bagus-arie05 cbd8f44b50 update flow klinik ruang, dan design consistency 2026-01-19 10:11:09 +07:00
bagus-arie05 00bb95431c update page header 2026-01-19 09:21:30 +07:00
Fanrouver 3db912a11a sidebar change 2026-01-19 08:29:05 +07:00
Fanrouver 928be7ccd1 edit manual checkin 2026-01-15 14:55:16 +07:00
bagus-arie05 96c637618b update logika pemanggilan, sticky konten, tampilan card 2026-01-15 14:53:17 +07:00
Fanrouver 8aa5ab2ded perbaikan generate dan scan tiket 2026-01-15 14:32:21 +07:00
Fanrouver eac6529a77 Merge branch 'Antrean-Code' of https://git.rssa.top/arie.bagus.2905/Web-Antrean into Antrean-Code 2026-01-15 10:44:41 +07:00
Fanrouver e99a0ab075 login page dan check in 2026-01-15 10:35:22 +07:00
bagus-arie05 12efe0be17 update admin loket dan tampilan status fast track 2026-01-15 10:29:32 +07:00
bagus-arie05 a75be83d04 Merge branch 'Antrean-Code' of https://git.rssa.top/arie.bagus.2905/web-antrean into Antrean-Code 2026-01-15 08:01:52 +07:00
bagus-arie05 a8a55b9e24 update color palette dan konsultasi pasien 2026-01-15 08:01:37 +07:00
Fanrouver ed57e2d194 tampilan antrean masuk 2026-01-15 07:54:42 +07:00
Fanrouver 45ac7a9965 Merge branch 'Antrean-Code' of https://git.rssa.top/arie.bagus.2905/Web-Antrean into Antrean-Code 2026-01-14 15:56:59 +07:00
Fanrouver c5b623f1f6 perubahan antrean masuk 2026-01-14 15:56:56 +07:00
bagus-arie05 89c3549e07 update seed data dan fungsi pindah atau konsul klinik 2026-01-14 15:14:07 +07:00
Fanrouver 6c80c08a83 QRcode update 2026-01-14 11:04:47 +07:00
bagus-arie05 f3e90adea3 update warna dan klinik ruang 2026-01-14 08:25:47 +07:00
bagus-arie05 e80032f668 fixing layar loket dan status pasien 2026-01-13 12:12:27 +07:00
Fanrouver 9ccbabadf2 perbaikan total antrean 2026-01-13 11:40:45 +07:00
Fanrouver 0c7b33aa70 checkin tampilan 2026-01-13 11:24:01 +07:00
Fanrouver 0a8e11a28d perbaikan loket 2026-01-13 10:57:45 +07:00
Fanrouver a000ba0905 perbaikan loket 2026-01-13 10:55:55 +07:00
bagus-arie05 3817b2d3f7 update anjungan fast track dan tampilan ruang 2026-01-13 08:19:19 +07:00
Fanrouver 48e4aabd4b push loket perbaikan sedang dilayani 2026-01-12 15:35:28 +07:00
Fanrouver 42fe62cb41 push loket baru 2026-01-12 14:45:29 +07:00
Fanrouver 5c142273aa perubahan tiket dan alur antrean 2026-01-12 11:52:45 +07:00
bagus-arie05 6e7160c935 update layout klinik dan websocket layar klinik 2026-01-12 11:21:54 +07:00
bagus-arie05 27209def4b update klinik ruang dan status fast track 2026-01-12 08:51:12 +07:00
bagus-arie05 9ea8300973 klinik ruang dan update admin loket 2026-01-09 11:08:58 +07:00
Fanrouver 1b7a142162 checkin footers detail 2026-01-09 11:05:40 +07:00
Fanrouver 6d5d565aa7 Perubahan format No Antrean 2026-01-09 10:28:41 +07:00
Fanrouver 863718020b Merge branch 'Antrean-Code' of https://git.rssa.top/arie.bagus.2905/Web-Antrean into Antrean-Code 2026-01-09 08:44:31 +07:00
Fanrouver 22d7205271 update qr funtion 2026-01-09 08:44:27 +07:00
bagus-arie05 676bdc076f update print adminloket, logika klinik ruang, numbering antrian baru klinik ruang 2026-01-08 14:38:37 +07:00
bagus-arie05 14153a2f03 Merge branch 'Antrean-Code' of https://git.rssa.top/arie.bagus.2905/Web-Antrean into Antrean-Code 2026-01-08 12:53:44 +07:00
bagus-arie05 ca5913c4e6 update klinik ruang 2026-01-08 12:53:41 +07:00
Fanrouver 8a4bb44354 nav items 2026-01-08 12:50:04 +07:00
Fanrouver aeb3b4c64b Merge branch 'Antrean-Code' of https://git.rssa.top/arie.bagus.2905/Web-Antrean into Antrean-Code 2026-01-08 12:47:36 +07:00
Fanrouver 2d3d589fe6 layar baru antrean klinik dan masuk 2026-01-08 12:44:40 +07:00
bagus-arie05 ed43c69a1d update draft klinik ruang dan cetak anjungan 2026-01-08 12:40:26 +07:00
bagus-arie05 96b10b9e75 Merge branch 'Antrean-Code' of https://git.rssa.top/arie.bagus.2905/Web-Antrean into Antrean-Code 2026-01-07 11:35:58 +07:00
bagus-arie05 155e3d9ea7 update flow anjungan,checkin,admin,dan klinik ruang 2026-01-07 11:35:55 +07:00
Fanrouver a68266ca3c env.example try 2026-01-07 10:09:44 +07:00
Fanrouver 9e399c9f64 perbaikan ip 0.0.0.0 2026-01-07 09:44:54 +07:00
Fanrouver ab99ba2fe5 patch parsing JSON error 2026-01-07 07:50:25 +07:00
Fanrouver 21f6b63ce4 perbaikan keycloak dan config 2026-01-06 15:34:11 +07:00
Fanrouver bb12c9a0e9 Merge branch 'Antrean-Code' of https://git.rssa.top/arie.bagus.2905/Web-Antrean into Antrean-Code 2026-01-06 14:55:59 +07:00
Fanrouver c2b03f5621 hak akses hapus atur urutan 2026-01-06 14:55:46 +07:00
bagus-arie05 6b38359a90 update anjungan, store management 2026-01-06 14:51:28 +07:00
Fanrouver 71b9de8d21 Hakakses dan verifikasi akun 2026-01-06 14:26:19 +07:00
Fanrouver 7c8ba8d638 Merge branch 'Antrean-Code' of https://git.rssa.top/arie.bagus.2905/Web-Antrean into Antrean-Code 2026-01-05 09:46:14 +07:00
Fanrouver af855cb6c8 env. example 2026-01-05 09:45:05 +07:00
221 changed files with 65073 additions and 14748 deletions

No files matched your search

+304
View File
@@ -0,0 +1,304 @@
# 📋 Project Knowledge — Web Antrean (Sistem Antrian Rumah Sakit)
> Dokumen ini berisi knowledge base lengkap, aturan pengkodean, dan workflow wajib untuk project **web-antrean**.
> **WAJIB DIBACA** sebelum melakukan task apapun.
---
## 🚨 ATURAN WAJIB (MANDATORY RULES)
### Rule 1: Selalu Buat Implementation Plan Sebelum Eksekusi
- **SEBELUM** menulis kode apapun, buat implementation plan di `docs/DEVPLAN.md` atau artifact
- Plan harus mencakup: file yang akan diubah, perubahan spesifik, dan alasan
- Tunggu persetujuan user sebelum eksekusi (kecuali fix minor/typo)
### Rule 2: Selalu Tambahkan Komentar di Setiap Fungsi
- Setiap **function**, **method**, **computed**, dan **watcher** HARUS punya komentar JSDoc/TSDoc
- Format minimum: `/** Deskripsi singkat fungsi ini */`
- Untuk fungsi kompleks, tambahkan `@param`, `@returns`, `@example`
- Komentar inline untuk logika yang tidak obvious
```javascript
// ✅ BENAR
/**
* Mengambil data pasien dari API berdasarkan ID loket
* @param {string} loketId - ID loket yang akan di-fetch
* @returns {Promise<QueuePatient[]>} Daftar pasien dalam antrian
*/
const fetchPatientsForLoket = async (loketId) => { ... }
// ❌ SALAH — tanpa komentar
const fetchPatientsForLoket = async (loketId) => { ... }
```
### Rule 3: Selalu Update Dokumentasi Setelah Perubahan
- **DEVLOG.md**: Tambahkan entry untuk setiap perubahan signifikan
- **DEVPLAN.md**: Update status task yang sudah selesai
- **PRD.md**: Update jika ada perubahan requirements/API/arsitektur
- **AGENTS.md**: Update jika ada knowledge baru yang penting
### Rule 4: Selalu Baca AGENTS.md dan Skills Sebelum Eksekusi
- Baca file ini SEBELUM mulai task apapun
- Baca skill `web-antrean-coding-standards` untuk konvensi kode
- Jangan mengulang kesalahan yang sudah tercatat di bagian Gotchas
### Rule 5: Jangan Mengulang Kesalahan yang Sudah Diketahui
- Cek bagian **⚠️ Gotchas & Known Issues** sebelum debug
- Cek **DEVLOG.md** untuk masalah serupa yang pernah diselesaikan
- Jika menemukan masalah baru, catat di DEVLOG dan Gotchas
---
## 🏗️ Arsitektur & Tech Stack
| Layer | Teknologi |
|---|---|
| **Framework** | Nuxt 3 (`^3.17.7`) dengan SSR |
| **UI Library** | Vuetify 3 (`^3.9.3`) + Material Design Icons (`@mdi/font`) |
| **State Management** | Pinia (`^3.0.3`) + `pinia-plugin-persistedstate` |
| **Styling** | SCSS (`sass-embedded`), design tokens di `assets/scss/` |
| **Auth** | Keycloak OAuth2 (custom server-side implementation, bukan nuxt-auth) |
| **Database (Server)** | SQLite via `better-sqlite3` — untuk konfigurasi device & user sync |
| **Charting** | Chart.js + vue-chartjs + nuxt-charts |
| **QR Code** | `html5-qrcode` (scanner) + `qrcode.vue` / `nuxt-qrcode` (generator) |
| **WebSocket** | Custom composable (`useWebSocket.ts`) ke backend Go |
| **Deployment** | Docker + docker-compose |
| **Testing** | Vitest (unit) + Cypress (E2E) |
| **Font** | Google Fonts (Inter 400-700) |
---
## 📁 Struktur Direktori
```
web-antrean/
├── app.vue # Entry point — store schema migration + loading overlay
├── nuxt.config.ts # Konfigurasi Nuxt, Vuetify, runtime config, proxy
├── .env # Environment variables (API URLs, auth, proxy)
├── docker-compose.yml # Docker deployment config
├── .agents/ # Agent rules & skills
│ ├── AGENTS.md # FILE INI — rules & knowledge base
│ └── skills/ # Coding standards & workflow skills
├── docs/ # Dokumentasi project
│ ├── DEVLOG.md # Development log (kronologis)
│ ├── DEVPLAN.md # Development plan (task breakdown)
│ └── PRD.md # Product Requirements Document
├── assets/scss/ # Design system
│ ├── _colors.scss # Color tokens (CSS variables)
│ ├── _variables.scss # Spacing, breakpoints, etc.
│ ├── _typography.scss # Font styles
│ └── main.scss # Global imports
├── components/
│ ├── common/ # Shared: PageHeader, Avatar, AppSnackbar, SelectionDialog
│ ├── layout/ # SideBar, ProfileMenu, ProfilePopup
│ ├── features/ # Feature-specific (antrean, master, monitoring, queue)
│ ├── AdminKlinik/ # Komponen admin klinik eksekutif
│ ├── GrandPaviliun/ # Komponen Grand Paviliun (Gp*)
│ ├── HakAkses/ # Komponen manajemen hak akses
│ ├── MonitorPasien/ # Komponen monitoring pasien
│ ├── checkin/ # Komponen check-in
│ ├── verification/ # Komponen verifikasi akun
│ └── preview/ # Komponen preview
├── composables/
│ ├── useAuth.ts # Auth state & session management
│ ├── useWebSocket.ts # WebSocket client dengan auto-reconnect
│ ├── useQueueAPI.ts # API wrapper untuk antrian (verificationApiBaseUrl)
│ ├── useVisitAPI.ts # API wrapper untuk visit (externalApiBaseUrl)
│ ├── useQueueSync.ts # Sinkronisasi antrian antar client
│ ├── useCheckIn.ts # Logika check-in pasien
│ ├── useCheckInHistory.ts # Riwayat check-in
│ ├── useClinicAPI.ts # API wrapper klinik
│ ├── useGrandPaviliun.ts # Logika Grand Paviliun
│ ├── useHakAkses.ts # Manajemen hak akses
│ ├── usePermissions.ts # Permission check helper
│ ├── useQRGenerator.ts # QR code generation
│ ├── useQRScanner.ts # QR code scanning (html5-qrcode)
│ ├── useThermalPrint.ts # Thermal printer (tiket antrian)
│ ├── useSnackbar.ts # Toast notification
│ └── useInfiniteScroll.ts # Infinite scroll pagination
├── layouts/
│ ├── default.vue # Main layout — SideBar + middleware auth + checkPageAccess
│ └── empty.vue # Empty layout (untuk halaman tanpa sidebar)
├── middleware/
│ ├── auth.ts # Auth check → redirect ke /LoginPage jika belum login
│ ├── guest.ts # Untuk halaman publik
│ ├── checkPageAccess.ts # Cek akses halaman berdasarkan hak akses
│ └── permissions.ts # Auto-sync permissions dari backend (DISABLED)
├── pages/ # Semua halaman (lihat detail di PRD.md section 8.1)
│ ├── AdminKlinikRuang/ # [kodeKlinik].vue (~111KB)
│ ├── AdminLoket/ # [id].vue (~58KB)
│ ├── CheckInPasien/ # checkIn.vue (~230KB, LARGEST)
│ ├── Anjungan/ # Display anjungan (kiosk)
│ ├── Setting/ # Master data & konfigurasi
│ └── ...
├── server/
│ ├── api/auth/ # Auth endpoints (Keycloak OAuth flow)
│ ├── api/users/ # User management (SQLite-backed)
│ ├── api/config/ # Device configuration (SQLite-backed)
│ ├── api/hak-akses/ # Hak akses management
│ └── utils/ # configDb.ts, sessionStore.ts, userSync.ts
├── stores/ # Pinia stores (Single Source of Truth)
│ ├── clinicStore.js # Master data klinik (~1007 lines)
│ ├── queueStore.ts # Antrian pasien (~3302 lines, LARGEST)
│ ├── loketStore.js # Data loket (~549 lines)
│ ├── ruangStore.js # Data ruang klinik (~558 lines)
│ ├── masterStore.js # BACKWARD COMPAT LAYER (jangan tulis langsung!)
│ ├── penunjangStore.js # Data penunjang medis
│ ├── doctorStore.js # Data dokter (fetched from API)
│ └── ... # anjunganStore, screenStore, navItems1, dll
├── types/ # TypeScript interfaces & types
│ ├── auth.ts, queue.ts, checkin.ts, setting.ts
└── data/
└── users.db # SQLite database (user sync + device config)
```
---
## 🔌 API Endpoints & External Services
### Environment Variables (`.env`)
| Variable | Deskripsi | Value (Juli 2026) |
|---|---|---|
| `ANTRIAN_API_URL` | API antrian utama (Go backend) | `http://10.10.150.131:8089/api/v1` |
| `VERIFICATION_API_BASE_URL` | Alias untuk ANTRIAN_API_URL | (same) |
| `VISIT_API_URL` | API visit/kunjungan pasien | `http://10.10.123.135:8084/api/v1` |
| `WS_API_URL` | WebSocket URL | `ws://10.10.123.135:8084/api/v1/ws` |
| `EXTERNAL_API_BASE_URL` | External API (JWT validation) | `http://10.10.123.135:8084` |
| `KEYCLOAK_ISSUER` | Keycloak realm URL | `https://auth.rssa.top/realms/sandbox` |
| `AUTH_ORIGIN` | Aplikasi origin URL | `http://10.10.150.175:3000` |
| `HOST` | Dev server host | `http://10.10.150.175:3000` |
### Runtime Config (`nuxt.config.ts`)
```
runtimeConfig.public.verificationApiBaseUrl → ANTRIAN_API_URL (antrian API)
runtimeConfig.public.externalApiBaseUrl → VISIT_API_URL (visit API)
runtimeConfig.public.wsBaseUrl → WS_API_URL (WebSocket)
```
### External Backend APIs
1. **Antrian API** (`verificationApiBaseUrl`) — Go backend
- `GET /klinik/reguler` — Fetch daftar klinik reguler
- `GET /loket/ruang` — Fetch daftar ruangan per klinik
- `GET /loket/{id}` — Fetch pasien per loket
- `GET /dokter/{id}` — Fetch data dokter per klinik
- `POST /tiket/generate` — Generate tiket antrian
- `POST /tiket/checkin` — Check-in pasien
- `GET /permission` — Fetch permission/hak akses
2. **Visit API** (`externalApiBaseUrl`) — Visit/kunjungan
- `GET /visit?klinik_id={id}` — Fetch data pasien per klinik
- `POST /external/validate-token` — Validate JWT token
3. **WebSocket** (`wsBaseUrl`)
- Real-time queue updates, connected via `useWebSocket.ts`
> **PENTING**: Semua kode menggunakan `config.public.verificationApiBaseUrl` dengan fallback hardcoded. Saat ganti IP, ubah di `.env` lalu **restart dev server**. Nuxt hanya membaca `.env` saat startup!
---
## 🗄️ Store Architecture
### Hirarki Store
```
masterStore (BACKWARD COMPAT LAYER — JANGAN TULIS LANGSUNG)
├── clinicStore → Single source of truth untuk data KLINIK
├── loketStore → Data LOKET (counter antrian)
├── ruangStore → Data RUANG per klinik
└── penunjangStore → Data PENUNJANG medis
queueStore → Data ANTRIAN PASIEN (terbesar, 136KB)
doctorStore → Data DOKTER (fetched per klinik)
anjunganStore → Config ANJUNGAN (kiosk)
screenStore → Config SCREEN display
antreanMasukScreenStore → Config screen antrian masuk
navItems1 → Navigation items + hak akses filter
permissionStore → Permission data
verificationStore → Verifikasi akun
```
### Store Schema Migration
- `app.vue` manages `STORE_SCHEMA_VERSION` (currently `3`)
- Saat versi tidak cocok, semua localStorage Pinia di-clear
- **Naikkan versi** saat ada perubahan struktur state
---
## 🔐 Authentication Flow
1. **Login**: User klik login → `keycloak-login.ts` → redirect ke Keycloak
2. **Callback**: Keycloak redirect back → `keycloak-callback.get.ts` → exchange code for token → create session cookie
3. **Session**: `session.get.ts` validates cookie → returns user data
4. **Middleware**: `auth.ts` checks session on every protected route
5. **User Sync**: `userSync.ts` syncs Keycloak users to local SQLite DB
---
## ⚠️ Gotchas & Known Issues (HARUS DIBACA SEBELUM DEBUG)
1. **ENV reload**: Nuxt hanya membaca `.env` saat startup. Setelah ubah `.env`, HARUS restart dev server (`Ctrl+C``npm run dev`).
2. **Hardcoded fallback IPs**: Banyak file masih punya fallback IP lama. Saat ganti IP, ubah `.env` + restart server. JANGAN cuma ubah satu file.
3. **`totalQuota === 0` filter di ruangStore**: Klinik dengan `totalQuota: 0` diskip dari Admin Klinik Ruang. Ini menyembunyikan klinik yang valid tapi belum dikonfigurasi kuotanya. Trade-off yang disengaja.
4. **masterStore adalah proxy**: Jangan langsung tulis data ke masterStore. Selalu gunakan store yang sesuai (clinicStore, loketStore, ruangStore, penunjangStore).
5. **File besar** (hati-hati saat edit):
- `CheckInPasien/checkIn.vue` (~230KB)
- `queueStore.ts` (~136KB)
- `AdminKlinikRuang/[kodeKlinik].vue` (~111KB)
6. **SQLite DB path**: `data/users.db` — digunakan untuk konfigurasi device & user sync. Di Docker, di-mount sebagai volume.
7. **WebSocket auto-upgrade**: `useWebSocket.ts` otomatis upgrade `ws://` ke `wss://` jika halaman diakses via HTTPS.
8. **Pinia persist `paths` type mismatch**: Gunakan `// @ts-ignore` untuk properti `paths` karena kompatibel runtime tapi melanggar validasi tipe.
---
## 🔗 Key File Quick Reference
| Kebutuhan | File |
|---|---|
| Ganti API URL | `.env` → restart dev server |
| Auth flow | `server/api/auth/` |
| Konfigurasi Nuxt | `nuxt.config.ts` |
| Data klinik | `stores/clinicStore.js` |
| Data antrian | `stores/queueStore.ts` |
| Data ruang | `stores/ruangStore.js` |
| Data loket | `stores/loketStore.js` |
| Sidebar/Navigation | `stores/navItems1.ts` + `components/layout/SideBar.vue` |
| Design tokens | `assets/scss/_colors.scss`, `_variables.scss` |
| Database schema | `server/utils/configDb.ts` |
| User management | `server/api/users/` + `server/utils/userSync.ts` |
| WebSocket | `composables/useWebSocket.ts` |
| Thermal print | `composables/useThermalPrint.ts` |
---
## 🚀 Development Commands
```bash
npm run dev # Start dev server
npm run dev:https # Start with HTTPS + host 0.0.0.0
npm run build # Production build
npm run preview # Preview production build
npm run test # Run Vitest
docker compose up -d # Deploy production
```
@@ -0,0 +1,173 @@
---
name: web-antrean-coding-standards
description: Aturan dasar pengkodean dan konvensi untuk project web-antrean berbasis Nuxt 3, Vue 3, TypeScript, Pinia, dan Vuetify.
---
# 📜 Web Antrean Coding Standards
Skill ini berisi standar pengkodean wajib yang harus diikuti saat memodifikasi atau membuat kode baru di project `web-antrean`.
## 1. Komentar & Dokumentasi (WAJIB)
- **Tiap Fungsi/Method/Computed/Watcher HARUS dikomentari.**
- Gunakan JSDoc (`/** ... */`) untuk mendeskripsikan tujuan fungsi.
- Jika fungsi kompleks atau memiliki parameter spesifik, gunakan tag `@param`, `@returns`.
- Berikan komentar singkat (`//`) di dalam body fungsi jika ada logika yang tidak langsung jelas (non-obvious).
```typescript
// ✅ CONTOH BENAR
/**
* Menghitung total antrean aktif untuk loket tertentu.
* @param {string} loketId - ID unik dari loket.
* @returns {number} Jumlah antrean berstatus 'menunggu'.
*/
const countActiveQueue = (loketId: string): number => {
// Hanya hitung pasien yang belum dipanggil atau di-skip
return allPatients.value.filter(p => p.loketId === loketId && p.status === 'menunggu').length;
};
```
## 2. Struktur Komponen (Vue 3 / Nuxt 3)
- Gunakan `<script setup lang="ts">`. Sebisa mungkin gunakan TypeScript.
- **Urutan blok dalam Vue SFC (Single File Component):**
1. `<template>`
2. `<script setup lang="ts">`
3. `<style scoped>` (Lebih disukai `<style scoped lang="scss">`)
- **Organisasi dalam `<script setup>`:**
1. `imports` (Vue, composables, stores, types)
2. `defineProps`, `defineEmits`, `defineExpose`
3. State variables (`ref`, `reactive`)
4. Computed properties (`computed`)
5. Watchers (`watch`, `watchEffect`)
6. Functions / Methods
7. Lifecycle hooks (`onMounted`, `onUnmounted`)
## 3. Composables (`composables/`)
- Nama file dan nama fungsi harus diawali dengan `use` (contoh: `useQueueSync.ts`, `export const useQueueSync = () => {}`).
- Fokuskan composable pada satu fitur spesifik (contoh: `useThermalPrint` khusus print, `useWebSocket` khusus WS).
- Jika mengembalikan state reaktif, kembalikan objek berisi `ref` atau `computed`.
## 4. Pinia Stores (`stores/`)
- Gunakan format Setup Store (seperti composable) alih-alih Option Store.
- Berikan tipe eksplisit (TypeScript) untuk state array/object yang kompleks.
- Jangan melakukan mutasi data store lain secara langsung, gunakan actions.
- **PENTING:** Jika ada store baru, daftarkan di `PINIA_STORE_KEYS` di `app.vue` jika perlu di-persist dan bersihkan localStorage jika `STORE_SCHEMA_VERSION` naik.
```typescript
// ✅ CONTOH SETUP STORE
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import type { QueuePatient } from '~/types/queue';
export const useMyStore = defineStore('myStore', () => {
// State
const patients = ref<QueuePatient[]>([]);
// Computed
const activePatients = computed(() => patients.value.filter(p => p.status === 'menunggu'));
/**
* Action untuk menambah pasien
* @param {QueuePatient} newPatient
*/
const addPatient = (newPatient: QueuePatient) => {
patients.value.push(newPatient);
};
return { patients, activePatients, addPatient };
});
```
## 5. Integrasi API & Error Handling
- Gunakan `useRuntimeConfig().public` untuk base URL (contoh: `verificationApiBaseUrl`).
- **JANGAN** hardcode IP address (seperti `10.10.123.140`) di dalam kode komponen atau composable. Selalu ambil dari config.
- Gunakan composable wrapper atau `useFetch`/`$fetch` dari Nuxt.
- Harus ada blok `try...catch` untuk setiap pemanggilan API, dengan logging error yang jelas (minimal `console.error`).
## 6. Styling
- Gunakan class Vuetify sebanyak mungkin (`d-flex`, `mt-4`, `text-h5`, `text-primary`).
- Jika butuh custom style, gunakan `<style scoped lang="scss">` dan manfaatkan variabel dari `assets/scss/_colors.scss` atau `_variables.scss` jika memungkinkan.
## 7. Naming Conventions
- **Komponen:** PascalCase (contoh: `PatientCard.vue`, `AdminKlinik.vue`).
- **File/Folder (selain komponen):** camelCase atau kebab-case tergantung standar folder (Nuxt standard: pages biasanya kebab-case atau PascalCase jika spesifik view).
- **Fungsi/Variabel:** camelCase (contoh: `fetchPatients`, `isActive`).
- **Tipe Data (Types/Interfaces):** PascalCase (contoh: `QueuePatient`).
## 8. Aturan Refactoring (Refactoring Rules)
Saat melakukan refactoring (terutama mengkonversi `.js` lama ke `.ts` atau memecah file besar), ikuti aturan wajib berikut:
- **Incremental Refactoring (Bertahap):** Jangan merombak seluruh file raksasa (seperti `queueStore` atau `checkIn.vue`) sekaligus. Pisahkan sebagian logika (contoh: pisahkan API call ke `useQueueAPI`, WS ke `useQueueSync`) secara bertahap untuk meminimalisasi *blast radius* (kerusakan beruntun).
- **Isolasi Logika (Separation of Concerns):** Komponen `.vue` hanya boleh berisi logika UI (presentational). Logika bisnis berat harus dipindah ke `composables/` atau `stores/`.
- **Backward Compatibility (Kompabilitas Mundur):** Jika mengubah struktur *store* yang banyak dipakai (contoh: `masterStore`), jangan langsung menghapus properti lama. Buat *proxy/delegate* ke store baru sampai semua komponen selesai di-update.
- **No Implicit `any`:** Saat memigrasi `.js` ke `.ts`, WAJIB mendefinisikan tipe data/interface yang jelas di direktori `types/`. Sebisa mungkin hindari penggunaan tipe `any`.
- **Pertahankan Business Logic:** Refactoring HANYA mengubah struktur/kebersihan kode, **BUKAN** mengubah alur bisnis kecuali diminta secara spesifik. Pastikan fitur safety seperti *polling fallback 30s* atau *auto-reconnect WS* tidak terhapus tanpa sengaja.
- **Dokumentasikan Perubahan:** Jika refactor mengubah alur arsitektur secara signifikan, selalu catat di `docs/DEVLOG.md` dan perbarui `AGENTS.md`.
## 9. Reusability & Prinsip DRY (Don't Repeat Yourself)
- Jika suatu fungsi, blok UI, atau logika API dipakai di lebih dari dua tempat, **wajib** diekstrak menjadi `Composable` (untuk logika), `Component` (untuk UI), atau file utility di `utils/`.
- Jangan melakukan copy-paste kode panjang antar halaman.
## 10. Penggunaan State (Lokal vs Global)
- **Lokal (`ref` / `reactive`):** Gunakan untuk state yang hanya hidup di satu komponen (contoh: status loading tombol, form input sementara, toggle modal).
- **Global (Pinia):** Gunakan HANYA untuk state yang perlu dibagikan lintas halaman/komponen (contoh: data pasien antrean berjalan, data user login, master data klinik).
## 11. Props & Emits (Komunikasi Antar Komponen)
- Definisikan `props` secara eksplisit menggunakan `defineProps<{ ... }>()` dengan tipe data yang ketat.
- Gunakan `defineEmits<{ ... }>()` untuk event child-to-parent. Hindari memanipulasi prop secara langsung dari child komponen (mutate prop mutation).
## 12. Manajemen Siklus Hidup (Lifecycle & Cleanup)
- Jika menginisiasi *event listener* global (`window.addEventListener`), *timer* (`setInterval`), atau *WebSocket subscription* di `onMounted`, Anda **WAJIB** membersihkannya di `onUnmounted`.
- Kegagalan melakukan *cleanup* akan menyebabkan kebocoran memori (memory leak) terutama di aplikasi SPA (Single Page Application).
## 13. Navigasi & Routing (Nuxt 3)
- Gunakan `<NuxtLink>` untuk navigasi internal di template. JANGAN menggunakan tag `<a>` biasa karena akan memicu *full page reload*.
- Untuk navigasi terprogram (programmatic) di dalam script, gunakan `navigateTo()` atau `useRouter().push()`.
## 14. Keamanan Dasar (Security Practices)
- **JANGAN PERNAH** meletakkan token JWT, password, atau secret key secara hardcode di sisi klien.
- Baca variabel sensitif melalui `useRuntimeConfig()` yang disuplai dari `.env`.
- Selalu percayakan validasi otorisasi di sisi *server* (middleware/Nitro), jangan hanya menyembunyikan tombol di UI.
## 15. Penanganan Feedback UI (Error & Success Boundaries)
- Jangan biarkan error API tertelan diam-diam (`swallowed error`).
- Selalu tampilkan umpan balik ke pengguna jika terjadi kegagalan (misalnya menggunakan komponen `AppSnackbar` yang sudah ada).
- Berikan indikator visual (spinner/loading state) saat memanggil API.
## 16. Magic Numbers & Magic Strings
- Hindari hardcode angka atau string spesifik yang memiliki arti logika bisnis di tengah komponen (contoh: `if (status === 3)` atau `if (role === 'superadmin')`).
- Pindahkan ke file konstan di `constants/` atau jadikan `Enum` di direktori `types/`.
## 17. Penggunaan Proxy API (Bypass CORS)
- Untuk endpoint eksternal yang terhalang CORS (seperti API Visit atau API Antrian asli), panggil selalu via *Proxy Route* bawaan Nuxt Nitro (contoh: panggil `/visit-api/...` atau `/klinik-api/...`).
- Jangan mencoba menebak atau merubah setting header CORS di sisi *client* Vue.
## 18. Konsistensi Design System
- DILARANG melakukan *hardcode* warna HEX/RGB di tag `<style>` (contoh: `color: #ff0000;`).
- Selalu gunakan SCSS variables dari `assets/scss/_colors.scss` (contoh: `color: var(--color-primary);`) atau gunakan *utility classes* bawaan Vuetify.
## 19. Optimasi Performa Vue (v-memo, v-once, Lazy)
- Untuk *list rendering* yang sangat panjang (ratusan pasien), pertimbangkan penggunaan pagination, infinite scroll, atau virtual scrolling.
- Gunakan `LazyComponent` (prefix `Lazy` di Nuxt) untuk modal atau komponen berat yang tidak langsung muncul di layar.
## 20. Penanganan Koneksi Real-time (WebSocket)
- Pastikan ada mekanisme **Auto-Reconnect** dengan *backoff delay*.
- Selalu miliki **Polling Fallback**. Jika WS mati total, sistem harus fallback mengambil data via HTTP `setInterval` setiap 30 detik agar layar antrean tidak *stuck*.
- Cegah duplikasi data pesan (implementasi *deduplication logic* berdasarkan ID unik pesan).
## 21. Isolasi State per Entitas (Loket / Ruang)
- Jangan gunakan variabel state tunggal untuk sesuatu yang berjalan paralel.
- Contoh buruk: `currentPatient` (berisiko loket A tertukar pasien dengan loket B).
- Contoh benar: `currentPatientPerLoket: Record<string, Patient>` (diakses dengan ID loket).
## 22. Format Koding & Tanda Baca
- Ikuti standar *formatting* yang ada: indentasi 2 spasi, gunakan *single quotes* (`'`) untuk string JS/TS, dan selalu gunakan tanda titik koma (`;`) di akhir *statement* script.
## 23. Standar Pengujian (Testing Readiness)
- Setiap logika kalkulasi yang rumit (contoh: perhitungan estimasi waktu tunggu, filter spesialisasi) harus ditulis murni (pure function) agar mudah di-unit test nantinya (Vitest). Jangan mencampur logika ini dengan DOM manipulation.
## 24. Git Workflow & Commit Messages
- Tulis pesan *commit* yang jelas dan bermakna mengikuti pola *Conventional Commits*:
- `feat: ...` (fitur baru)
- `fix: ...` (perbaikan bug)
- `refactor: ...` (perubahan struktur tanpa ubah fitur)
- `docs: ...` (pembaruan dokumentasi)
- Satu *commit* sebaiknya fokus pada satu perubahan logika/fitur saja.
+36
View File
@@ -0,0 +1,36 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Database files
data/
*.db
*.sqlite
*.sqlite3
# Node dependencies
node_modules
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# IDEs and OS files
.DS_Store
.fleet
.idea
.vscode
Thumbs.db
# Local env files
.env
.env.*
!.env.example
!.env.docker.example
+87
View File
@@ -0,0 +1,87 @@
# # .env
# # A random string used to hash tokens, sign cookies, and generate cryptographic keys.
# # You can generate one here: https://generate-secret.vercel.app/32
# NUXT_AUTH_SECRET="your-super-secret-string-of-at-least-32-characters"
# # The base URL of your application
# AUTH_ORIGIN="http://localhost:3000"
# # Keycloak Credentials
# # Get these from your Keycloak client configuration
# KEYCLOAK_CLIENT_ID="nuxt-app"
# KEYCLOAK_CLIENT_SECRET="OaB90t1HEEFBn31PUX4qELWHwErnwFtg"
# KEYCLOAK_ISSUER="http://localhost:8080/realms/rssa-app"
# .env
# A random string used to hash tokens, sign cookies, and generate cryptographic keys.
# You can generate one here: https://generate-secret.vercel.app/32
NUXT_AUTH_SECRET="your-super-secret-string-of-at-least-32-characters"
# The base URL of your application
# Keycloak Credentials
# Get these from your Keycloak client configuration
# KEYCLOAK_CLIENT_ID="nuxt-app"
# KEYCLOAK_CLIENT_SECRET="OaB90t1HEEFBn31PUX4qELWHwErnwFtg"
# KEYCLOAK_ISSUER="http://localhost:8080/realms/rssa-app"
KEYCLOAK_CLIENT_ID="akbar-test"
KEYCLOAK_CLIENT_SECRET="FDyv3UYMgJOYPnvzXVVv6diRtcgEevKg"
KEYCLOAK_ISSUER="https://auth.rssa.top/realms/sandbox"
SESSION_DURATION_HOURS=1
OAUTH_STATE_DURATION_MINUTES=10
#AUTH ORIGIN UNTUK ACCESS APLIKASI login dari KEYCLOAK
# AUTH_ORIGIN="http://10.10.150.175:3000"
# AUTH_ORIGIN="0.0.0.0"
# AUTH_ORIGIN="http://localhost:3000"
AUTH_ORIGIN="https://antrean.dev.rssa.id"
# AUTH_ORIGIN="https://antrean.rssa.id"
#HOST UNTUK ACCESS APLIKASI
# HOST="http://localhost:3000"
# HOST="http://10.10.150.175:3000"
# HOST="https://antrean.rssa.id"
# HOST="https://antrean.dev.rssa.id"
HOST="0.0.0.0"
# HOST=
# Custom Keycloak logout endpoint UBAH SESUAI REALM DAN DOMAIN KEYCLOAK ANDA
KEYCLOAK_LOGOUT_URI="https://auth.rssa.top/realms/sandbox/protocol/openid-connect/logout"
# Pilih salah satu dari dua opsi di bawah ini sesuai kebutuhan Anda:
# digunakan untuk redirect setelah logout dari KEYCLOAK
# Untuk redirect tanpa query parameter:
# POST_LOGOUT_REDIRECT_URI="http://10.10.150.175:3000/LoginPage"
# POST_LOGOUT_REDIRECT_URI="http://localhost:3000/LoginPage"
POST_LOGOUT_REDIRECT_URI="https://antrean.dev.rssa.id/LoginPage"
# POST_LOGOUT_REDIRECT_URI=
# ATAU untuk redirect dengan query parameter:
# POST_LOGOUT_REDIRECT_URI="https://antrean.rssa.id/LoginPage?logout=success"
# POST_LOGOUT_REDIRECT_URI="https://antrean.dev.rssa.id/LoginPage?logout=success"
# ATAU untuk redirect dengan query parameter:
# POST_LOGOUT_REDIRECT_URI="https://antrean.rssa.id/LoginPage?logout=success"
# POST_LOGOUT_REDIRECT_URI="https://antrean.dev.rssa.id/LoginPage?logout=success"
# External API Configuration
# API eksternal untuk validasi JWT token
EXTERNAL_API_BASE_URL="http://10.10.150.100:8084"
EXTERNAL_API_TIMEOUT=10000
# API untuk ekstrak expertise dan verifikasi akun
# Digunakan di pages/verifikasiAkun/VerifikasiAkun.vue
EKSTRAK_EXPERTISE_URL="http://10.10.123.218/ekstrakexpertise"
VERIFICATION_API_BASE_URL="http://10.10.123.140:8089/api/v1"
# Simplified Modular API URLs (New Config)
ANTRIAN_API_URL="http://10.10.123.140:8089/api/v1"
VISIT_API_URL="http://10.10.123.135:8084/api/v1"
WS_API_URL="ws://10.10.123.135:8084/api/v1/ws"
+1
View File
@@ -0,0 +1 @@
setups.@nuxt/test-utils="3.23.0"
+371
View File
@@ -0,0 +1,371 @@
# Checklist Perubahan Domain
Ketika mengubah domain aplikasi, ikuti checklist berikut:
## 1. File Environment (.env)
**Ubah `AUTH_ORIGIN` sesuai domain baru:**
### Development:
```env
AUTH_ORIGIN="https://antrean.dev.rssa.id"
```
### Production:
```env
AUTH_ORIGIN="https://antrean.rssa.id"
```
**Lokasi:** `.env` (atau `.env.development` / `.env.production`)
---
## 2. Keycloak Configuration
**Di Keycloak Admin Console → Clients → [Your Client] → Settings:**
### Valid Redirect URIs:
Tambahkan:
- `https://antrean.dev.rssa.id/api/auth/keycloak-callback` (development)
- `https://antrean.rssa.id/api/auth/keycloak-callback` (production)
### Valid Post Logout Redirect URIs:
Tambahkan:
- `https://antrean.dev.rssa.id/LoginPage*` (development)
- `https://antrean.rssa.id/LoginPage*` (production)
### Web Origins:
Tambahkan:
- `https://antrean.dev.rssa.id` (development)
- `https://antrean.rssa.id` (production)
**Catatan:** Gunakan wildcard `*` untuk post logout redirect agar bisa handle query parameters.
---
## 3. nuxt.config.ts (Opsional)
**Jika menggunakan IP address untuk development:**
```typescript
devServer: {
port: 3000,
host: '0.0.0.0' // atau IP address jika perlu
}
```
**Untuk production dengan domain, biasanya tidak perlu diubah.**
---
## 4. Kubernetes Configuration
### 4.1. ConfigMap (untuk non-sensitive environment variables)
**Buat atau update ConfigMap:**
```yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: antrean-config
namespace: default # atau namespace Anda
data:
AUTH_ORIGIN: "https://antrean.rssa.id" # atau https://antrean.dev.rssa.id untuk dev
KEYCLOAK_ISSUER: "https://auth.rssa.top/realms/sandbox"
KEYCLOAK_CLIENT_ID: "akbar-test"
```
**Atau gunakan kubectl:**
```bash
kubectl create configmap antrean-config \
--from-literal=AUTH_ORIGIN=https://antrean.rssa.id \
--from-literal=KEYCLOAK_ISSUER=https://auth.rssa.top/realms/sandbox \
--from-literal=KEYCLOAK_CLIENT_ID=akbar-test \
-n <your-namespace>
```
### 4.2. Secret (untuk sensitive data)
**Buat Secret untuk credentials:**
```yaml
apiVersion: v1
kind: Secret
metadata:
name: antrean-secrets
namespace: default
type: Opaque
stringData:
KEYCLOAK_CLIENT_SECRET: "your-secret-here"
NUXT_AUTH_SECRET: "your-super-secret-string-of-at-least-32-characters"
```
**Atau gunakan kubectl:**
```bash
kubectl create secret generic antrean-secrets \
--from-literal=KEYCLOAK_CLIENT_SECRET=your-secret \
--from-literal=NUXT_AUTH_SECRET=your-auth-secret \
-n <your-namespace>
```
### 4.3. Ingress (untuk domain routing)
**Update Ingress dengan domain baru:**
```yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: antrean-ingress
namespace: default
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod" # atau issuer Anda
nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
ingressClassName: nginx # atau ingress class Anda
tls:
- hosts:
- antrean.rssa.id
- antrean.dev.rssa.id
secretName: antrean-tls-secret
rules:
- host: antrean.rssa.id
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: antrean-service
port:
number: 3000
- host: antrean.dev.rssa.id
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: antrean-dev-service
port:
number: 3000
```
### 4.4. Deployment (update environment variables)
**Update Deployment untuk menggunakan ConfigMap dan Secret:**
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: antrean-app
namespace: default
spec:
replicas: 2
selector:
matchLabels:
app: antrean
template:
metadata:
labels:
app: antrean
spec:
containers:
- name: antrean
image: your-registry/antrean:latest
ports:
- containerPort: 3000
env:
# Dari ConfigMap
- name: AUTH_ORIGIN
valueFrom:
configMapKeyRef:
name: antrean-config
key: AUTH_ORIGIN
- name: KEYCLOAK_ISSUER
valueFrom:
configMapKeyRef:
name: antrean-config
key: KEYCLOAK_ISSUER
- name: KEYCLOAK_CLIENT_ID
valueFrom:
configMapKeyRef:
name: antrean-config
key: KEYCLOAK_CLIENT_ID
# Dari Secret
- name: KEYCLOAK_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: antrean-secrets
key: KEYCLOAK_CLIENT_SECRET
- name: NUXT_AUTH_SECRET
valueFrom:
secretKeyRef:
name: antrean-secrets
key: NUXT_AUTH_SECRET
envFrom:
# Atau load semua dari ConfigMap (opsional)
# - configMapRef:
# name: antrean-config
```
**Atau update dengan kubectl:**
```bash
kubectl set env deployment/antrean-app \
AUTH_ORIGIN=https://antrean.rssa.id \
--from=configmap/antrean-config \
-n <your-namespace>
```
### 4.5. Service (biasanya tidak perlu diubah)
Service biasanya tidak perlu diubah karena hanya routing internal.
### 4.6. Rollout/Restart Deployment
**Setelah update ConfigMap/Secret, restart pods:**
```bash
# Method 1: Rolling restart
kubectl rollout restart deployment/antrean-app -n <your-namespace>
# Method 2: Delete pods (akan auto-recreate)
kubectl delete pods -l app=antrean -n <your-namespace>
# Method 3: Scale down then up
kubectl scale deployment antrean-app --replicas=0 -n <your-namespace>
kubectl scale deployment antrean-app --replicas=2 -n <your-namespace>
```
### 4.7. Verifikasi di Kubernetes
```bash
# Cek ConfigMap
kubectl get configmap antrean-config -n <namespace> -o yaml
# Cek Secret (values akan di-encode base64)
kubectl get secret antrean-secrets -n <namespace> -o yaml
# Cek Ingress
kubectl get ingress antrean-ingress -n <namespace>
# Cek pods environment
kubectl exec -it <pod-name> -n <namespace> -- env | grep AUTH_ORIGIN
# Cek logs
kubectl logs -f deployment/antrean-app -n <namespace>
```
---
## 5. Server/Deployment Configuration (Non-Kubernetes)
### Nginx/Reverse Proxy (jika ada):
- Update `server_name` dengan domain baru
- Update SSL certificate untuk domain baru
- Pastikan proxy_pass mengarah ke aplikasi yang benar
### Docker (jika ada):
- Update environment variables di docker-compose.yml atau Dockerfile
- Update port mapping jika perlu
---
## 6. DNS Configuration
- Pastikan domain sudah pointing ke IP server yang benar
- Pastikan A record atau CNAME sudah dikonfigurasi
- Tunggu DNS propagation (bisa beberapa menit sampai 24 jam)
---
## 7. SSL Certificate
- Pastikan SSL certificate sudah diinstal untuk domain baru
- Pastikan certificate valid dan tidak expired
- Untuk production, gunakan Let's Encrypt atau certificate resmi
---
## 8. Restart Server/Deployment
**PENTING:** Setelah mengubah `.env`:
1. Stop server (Ctrl+C)
2. Start server lagi (`npm run dev` atau `npm run build && npm start`)
Environment variables hanya dimuat saat server start!
---
## 9. Verifikasi
Setelah semua perubahan, verifikasi:
1. **Cek log server saat login:**
```
🔧 AUTH_ORIGIN from config: https://antrean.dev.rssa.id
🔗 Redirect URI being sent to Keycloak: https://antrean.dev.rssa.id/api/auth/keycloak-callback
```
2. **Test login flow:**
- Login harus redirect ke Keycloak
- Setelah login, harus kembali ke aplikasi
- Tidak ada error "Invalid redirect URI"
3. **Test logout flow:**
- Logout harus redirect ke Keycloak
- Setelah logout, harus kembali ke login page
- Tidak ada error "Invalid redirect URI"
---
## File yang TIDAK Perlu Diubah
✅ **Kode aplikasi** - Sudah menggunakan `config.public.authUrl` dari environment variable
✅ **Server API handlers** - Sudah menggunakan `config.public.authUrl`
✅ **Components** - Tidak ada hardcoded domain
---
## Contoh Konfigurasi Lengkap
### Development (.env.development):
```env
AUTH_ORIGIN="https://antrean.dev.rssa.id"
KEYCLOAK_CLIENT_ID="akbar-test"
KEYCLOAK_CLIENT_SECRET="your-secret"
KEYCLOAK_ISSUER="https://auth.rssa.top/realms/sandbox"
NUXT_AUTH_SECRET="your-super-secret-string-of-at-least-32-characters"
```
### Production (.env.production):
```env
AUTH_ORIGIN="https://antrean.rssa.id"
KEYCLOAK_CLIENT_ID="akbar-test"
KEYCLOAK_CLIENT_SECRET="your-secret"
KEYCLOAK_ISSUER="https://auth.rssa.top/realms/sandbox"
NUXT_AUTH_SECRET="your-super-secret-string-of-at-least-32-characters"
```
---
## Troubleshooting
### Masih redirect ke domain lama?
- ✅ Pastikan server sudah restart
- ✅ Cek `.env` file sudah benar
- ✅ Clear browser cache
- ✅ Cek Keycloak configuration sudah benar
### Error "Invalid redirect URI"?
- ✅ Pastikan URI sudah ditambahkan di Keycloak
- ✅ Pastikan format URI sama persis (dengan/tanpa trailing slash)
- ✅ Pastikan menggunakan HTTPS jika domain menggunakan HTTPS
### Session tidak tersimpan?
- ✅ Pastikan cookie settings sesuai (secure: true untuk HTTPS)
- ✅ Cek browser console untuk cookie errors
- ✅ Pastikan domain di cookie sesuai dengan domain aplikasi
+332
View File
@@ -0,0 +1,332 @@
# Dashboard Dummy Data Documentation
Dokumentasi lengkap data dummy yang digunakan di `pages/Dashboard.vue`
## 📊 Statistics Cards (Header Mini Stats)
Data statistik mini yang ditampilkan di header:
```javascript
{
pasienHariIni: 324,
antreanAktif: 47
}
```
## 📈 Main Statistics Cards
Data untuk 4 kartu statistik utama:
### 1. Pasien Hari Ini
```javascript
{
label: 'Pasien Hari Ini',
value: '324',
icon: 'mdi-account-heart',
iconColor: 'white',
color: 'primary',
change: '+18.2%',
changeType: 'positive',
changeIcon: 'mdi-trending-up'
}
```
### 2. Antrean Aktif
```javascript
{
label: 'Antrean Aktif',
value: '47',
icon: 'mdi-clock-fast',
iconColor: 'white',
color: 'secondary',
change: '+5',
changeType: 'positive',
changeIcon: 'mdi-arrow-up'
}
```
### 3. Rata-rata Tunggu
```javascript
{
label: 'Rata-rata Tunggu',
value: '12 min',
icon: 'mdi-timer-outline',
iconColor: 'white',
color: 'success',
change: '-3 min',
changeType: 'positive',
changeIcon: 'mdi-trending-down'
}
```
### 4. Tingkat Kehadiran
```javascript
{
label: 'Tingkat Kehadiran',
value: '89.5%',
icon: 'mdi-check-circle',
iconColor: 'white',
color: 'primary',
change: '+2.3%',
changeType: 'positive',
changeIcon: 'mdi-trending-up'
}
```
---
## 📊 Chart 1: Tren Kunjungan Pasien (Line/Area Chart)
Chart yang menampilkan tren kunjungan pasien bulanan untuk tahun 2025.
### Labels (Bulan)
```javascript
[
"Jan",
"Feb",
"Mar",
"Apr",
"Mei",
"Jun",
"Jul",
"Ags",
"Sep",
"Okt",
"Nov",
"Des",
];
```
### Dataset 1: Pasien Umum
```javascript
{
label: 'Pasien Umum',
data: [850, 920, 880, 1050, 1120, 1080, 1200, 1150, 1080, 1190, 1250, 1300],
backgroundColor: 'rgba(86, 126, 231, 0.2)',
borderColor: '#567EE7' // primary-500
}
```
**Total Pasien Umum 2025**: 13,070 pasien
### Dataset 2: Pasien BPJS
```javascript
{
label: 'Pasien BPJS',
data: [1200, 1350, 1280, 1480, 1550, 1620, 1700, 1650, 1590, 1720, 1800, 1850],
backgroundColor: 'rgba(255, 132, 65, 0.2)',
borderColor: '#FF8441' // secondary-500
}
```
**Total Pasien BPJS 2025**: 18,790 pasien
**Grand Total**: 31,860 pasien sepanjang tahun 2025
---
## 💰 Chart 2: Status Pembayaran (Doughnut Chart)
Distribusi metode pembayaran pasien.
### Data
```javascript
{
labels: ['BPJS Kesehatan', 'Umum/Tunai', 'Asuransi Swasta', 'Corporate'],
datasets: [{
data: [1850, 680, 320, 280]
}]
}
```
### Breakdown Persentase
| Metode Pembayaran | Jumlah | Persentase |
| ----------------- | --------- | ---------- |
| BPJS Kesehatan | 1,850 | 58.6% |
| Umum/Tunai | 680 | 21.5% |
| Asuransi Swasta | 320 | 10.1% |
| Corporate | 280 | 8.9% |
| **Total** | **3,130** | **100%** |
---
## ⏱️ Chart 3: Waktu Tunggu per Poli (Horizontal Bar Chart)
Rata-rata waktu tunggu untuk setiap poliklinik (dalam menit).
### Data
```javascript
{
labels: ['Poli Umum', 'Poli Anak', 'Poli Gigi', 'Poli Mata', 'Poli THT', 'Poli Jantung'],
datasets: [{
label: 'Waktu Tunggu (menit)',
data: [15, 12, 8, 18, 10, 22]
}]
}
```
### Ranking Waktu Tunggu
| Rank | Poliklinik | Waktu Tunggu |
| ---- | ------------ | ------------ |
| 1 | Poli Gigi | 8 menit ⚡ |
| 2 | Poli THT | 10 menit |
| 3 | Poli Anak | 12 menit |
| 4 | Poli Umum | 15 menit |
| 5 | Poli Mata | 18 menit |
| 6 | Poli Jantung | 22 menit 🐌 |
**Rata-rata Total**: 14.17 menit
---
## ✅ Chart 4: Tingkat Kehadiran (Polar Area Chart)
Status kehadiran pasien dalam seminggu.
### Data
```javascript
{
labels: ['Hadir Tepat Waktu', 'Hadir Terlambat', 'Tidak Hadir', 'Batal', 'Reschedule'],
datasets: [{
data: [1850, 420, 280, 150, 230]
}]
}
```
### Breakdown Persentase
| Status | Jumlah | Persentase |
| -------------------- | --------- | ---------- |
| Hadir Tepat Waktu ✅ | 1,850 | 62.6% |
| Hadir Terlambat ⏰ | 420 | 14.2% |
| Tidak Hadir ❌ | 280 | 9.5% |
| Batal 🚫 | 150 | 5.1% |
| Reschedule 📅 | 230 | 7.8% |
| **Total** | **2,960** | **100%** |
**Tingkat Kehadiran Total** (Hadir Tepat Waktu + Hadir Terlambat): 76.8%
---
## 🎨 Color Palette
Palet warna yang digunakan dalam dashboard:
### Primary Colors
```javascript
{
700: '#3556AE',
600: '#3A61C9',
500: '#567EE7',
400: '#7898EC',
300: '#9AB2F1',
200: '#BBCBF5'
}
```
### Secondary Colors
```javascript
{
700: '#E65A0D',
600: '#F16F29',
500: '#FF8441',
400: '#FF9D67',
300: '#FFB58D'
}
```
### Success Colors
```javascript
{
700: '#008D65',
600: '#33A484',
500: '#59B59B',
400: '#80C6B2'
}
```
### Neutral Colors
```javascript
{
100: '#FFFFFF',
300: '#F5F7FA',
500: '#CDD4DC',
700: '#717171',
900: '#212121'
}
```
---
## 📤 Export Options
Menu export data yang tersedia:
```javascript
[
{ title: "JSON (.json)", type: "json", icon: "mdi-code-json" },
{ title: "CSV (.csv)", type: "csv", icon: "mdi-file-delimited" },
{
title: "Excel (.xlsx) - Server Only",
type: "excel",
icon: "mdi-file-excel",
},
{ title: "PDF (.pdf) - Placeholder", type: "pdf", icon: "mdi-file-pdf" },
];
```
---
## 📝 Summary Insights
### Total Visitors
- **Hari Ini**: 324 pasien (+18.2%)
- **Bulan Ini** (estimasi): ~2,617 pasien
- **Tahun 2025** (dari chart): 31,860 pasien
### Performance Metrics
- **Antrean Aktif**: 47 (+5)
- **Rata-rata Waktu Tunggu**: 12 menit (-3 min improvement)
- **Tingkat Kehadiran**: 89.5% (+2.3%)
- **Kehadiran Tepat Waktu**: 62.6%
### Top Performing Clinic
- **Tercepat**: Poli Gigi (8 menit)
- **Terlambat**: Poli Jantung (22 menit)
### Payment Distribution
- **BPJS dominan**: 58.6% dari total transaksi
- **Non-BPJS**: 41.4% (Umum + Asuransi + Corporate)
---
## 🔧 Usage Notes
File ini mendokumentasikan semua data dummy yang digunakan dalam komponen Dashboard.vue. Data ini dapat digunakan sebagai:
1. **Reference** untuk struktur data API
2. **Testing data** untuk pengembangan
3. **Template** untuk implementasi data real dari backend
4. **Documentation** untuk tim pengembang
Semua data bersifat **dummy/sample** dan harus diganti dengan data real dari API/backend saat production.
+51
View File
@@ -0,0 +1,51 @@
# Dockerfile for web-antrean Nuxt.js Application
# Multi-stage build for optimized image size and native modules support
# Stage 1: Build Stage
FROM node:20-alpine AS builder
# Install build dependencies for compiling native addons (like better-sqlite3)
RUN apk add --no-cache python3 make g++ gcc libc-dev
# Set working directory
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies (including devDependencies for build)
RUN npm ci
# Copy application files
COPY . .
# Build the application for production
RUN npm run build
# Stage 2: Production Stage
FROM node:20-alpine AS runner
# Install runtime dependencies (like libc6-compat for compatibility with precompiled binaries)
RUN apk add --no-cache libc6-compat
# Set working directory
WORKDIR /app
# Copy package files
COPY package*.json ./
# Copy built output and compiled node_modules from builder stage
# This avoids needing to install build tools (g++, make, python) in this slim production stage
COPY --from=builder /app/.output /app/.output
COPY --from=builder /app/node_modules /app/node_modules
# Expose port
EXPOSE 3000
# Set environment variables
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=3000
# Start the application
CMD ["node", ".output/server/index.mjs"]
+763
View File
@@ -0,0 +1,763 @@
# API Endpoint Documentation - Hak Akses
## Deskripsi
Dokumentasi ini menjelaskan semua API endpoint yang diperlukan untuk fitur **Hak Akses** di halaman `/pages/Setting/HakAkses.vue`. Endpoint-endpoint ini akan dihubungkan ke backend API yang disediakan oleh tim backend.
---
## Base URL
```
{API_BASE_URL}/api/v1
```
**Catatan:** Ganti `{API_BASE_URL}` dengan URL backend yang sebenarnya (contoh: `http://10.10.123.140:8089/`)
---
## 1. Get List Users
Mengambil daftar semua users beserta roles, groups, dan informasi lainnya yang diperlukan untuk form hak akses.
### Endpoint
```
GET /api/users/list
```
### Request
**Headers:**
```
Content-Type: application/json
Accept: application/json
```
**Query Parameters:** Tidak ad
### Response
**Success (200 OK):**
```json
[
{
"id": "user-123",
"namaLengkap": "John Doe",
"namaUser": "johndoe",
"email": "[email protected]",
"tipeUser": "Super Admin",
"lastLogin": 1704067200,
"roles": ["superadmin", "admin"],
"realmRoles": ["default-roles-sandbox"],
"accountRoles": [],
"resourceRoles": [],
"groups": ["/Instalasi STIM/Devops/Superadmin", "/Instalasi STIM/Admin"],
"given_name": "John",
"family_name": "Doe",
"createdAt": 1704067200,
"updatedAt": 1704067200
}
]
```
**Error (500 Internal Server Error):**
```json
{
"statusCode": 500,
"statusMessage": "Failed to fetch users"
}
```
### Field Description
- `id`: Unique identifier user
- `namaLengkap`: Nama lengkap user
- `namaUser`: Username untuk login
- `email`: Email user
- `tipeUser`: Tipe/jenis user (contoh: "Super Admin", "Admin Loket", dll)
- `lastLogin`: Timestamp terakhir login (Unix timestamp dalam detik)
- `roles`: Array of role names
- `realmRoles`: Array of realm roles dari Keycloak
- `accountRoles`: Array of account roles
- `resourceRoles`: Array of resource roles
- `groups`: Array of group paths (contoh: "/Instalasi STIM/Devops/Superadmin")
- `given_name`: Nama depan
- `family_name`: Nama belakang
- `createdAt`: Timestamp pembuatan (Unix timestamp dalam detik)
- `updatedAt`: Timestamp update terakhir (Unix timestamp dalam detik)
---
## 2. Get Permissions by Role and Group
Mengambil daftar permissions dari backend berdasarkan role dan group yang dipilih.
### Endpoint
```
GET /api/v1/permission
```
### Request
**Headers:**
```
Content-Type: application/json
Accept: application/json
```
**Query Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `roles` | string | Yes | Role name (contoh: "superadmin", "admin") |
| `groups` | string | Yes | Group name atau group path (contoh: "STIM", "/Instalasi STIM/Devops/Superadmin") |
**Example:**
```
GET /api/v1/permission?roles=superadmin&groups=STIM
```
### Response
**Success (200 OK):**
```json
{
"message": "Data permission berhasil diambil",
"data": [
{
"id": 1,
"create": false,
"read": true,
"update": false,
"disable": false,
"delete": false,
"active": true,
"pagename": "Halaman Utama",
"pagesID": 1,
"level": 1,
"sort": 1,
"parent": null
},
{
"id": 2,
"create": false,
"read": true,
"update": false,
"disable": false,
"delete": false,
"active": true,
"pagename": "Pengaturan",
"pagesID": 2,
"level": 1,
"sort": 2,
"parent": null
},
{
"id": 3,
"create": true,
"read": true,
"update": true,
"disable": true,
"delete": false,
"active": true,
"pagename": "Hak Akses",
"pagesID": 3,
"level": 2,
"sort": 3,
"parent": 2
}
],
"meta": {
"count": 3,
"total": 3
}
}
```
**Error (400 Bad Request):**
```json
{
"statusCode": 400,
"statusMessage": "roles or groups parameter is required"
}
```
**Error (500 Internal Server Error):**
```json
{
"message": "Failed to fetch permissions",
"data": [],
"meta": {
"count": 0,
"total": 0
}
}
```
### Field Description
- `id`: Unique identifier permission
- `create`: Boolean, permission untuk create/tambah data
- `read`: Boolean, permission untuk read/lihat data
- `update`: Boolean, permission untuk update/edit data
- `disable`: Boolean, permission untuk disable/nonaktifkan
- `delete`: Boolean, permission untuk delete/hapus data
- `active`: Boolean, status aktif permission
- `pagename`: Nama halaman/menu (akan di-mapping ke menu sidebar)
- `pagesID`: ID halaman di sistem backend
- `level`: Level hierarki menu (1 = parent, 2 = child, dll)
- `sort`: Urutan tampil
- `parent`: ID parent menu (null jika parent menu)
---
## 3. Get List Hak Akses
Mengambil daftar semua hak akses yang sudah tersimpan.
### Endpoint
```
GET /api/v1/hak-akses
```
### Request
**Headers:**
```
Content-Type: application/json
Accept: application/json
```
**Query Parameters:** Tidak ada
### Response
**Success (200 OK):**
```json
[
{
"id": 1,
"userId": "user-123",
"namaLengkap": "John Doe",
"namaUser": "johndoe",
"tipeUser": "Super Admin",
"role": "superadmin",
"group": "STIM",
"groupPath": "/Instalasi STIM/Devops/Superadmin",
"namaTipeUser": "Super Admin",
"hakAksesMenu": [
{
"name": "Dashboard",
"canAccess": true,
"canView": true,
"canAdd": false,
"canEdit": false,
"canDelete": false
},
{
"name": "Master Data",
"canAccess": true,
"canView": true,
"canAdd": true,
"canEdit": true,
"canDelete": false
}
],
"isGroupBased": true,
"createdAt": 1704067200,
"updatedAt": 1704067200
}
]
```
**Error (500 Internal Server Error):**
```json
{
"statusCode": 500,
"statusMessage": "Failed to fetch hak akses"
}
```
### Field Description
- `id`: Unique identifier hak akses
- `userId`: ID user (optional, untuk backward compatibility)
- `namaLengkap`: Nama lengkap user (optional)
- `namaUser`: Username (optional)
- `tipeUser`: Tipe user (optional)
- `role`: Role name (required)
- `group`: Group name (required)
- `groupPath`: Full group path (optional, untuk group-based access)
- `namaTipeUser`: Nama tipe user (optional)
- `hakAksesMenu`: Array of menu permissions
- `name`: Nama menu
- `canAccess`: Boolean, apakah bisa akses menu
- `canView`: Boolean, apakah bisa lihat data
- `canAdd`: Boolean, apakah bisa tambah data
- `canEdit`: Boolean, apakah bisa edit data
- `canDelete`: Boolean, apakah bisa hapus data
- `isGroupBased`: Boolean, apakah hak akses ini group-based (true) atau individual (false)
- `createdAt`: Timestamp pembuatan (Unix timestamp dalam detik)
- `updatedAt`: Timestamp update terakhir (Unix timestamp dalam detik)
---
## 4. Create Hak Akses
Membuat hak akses baru (group-based atau individual).
### Endpoint
```
POST /api/v1/hak-akses
```
### Request
**Headers:**
```
Content-Type: application/json
Accept: application/json
```
**Body:**
```json
{
"role": "superadmin",
"group": "STIM",
"groupPath": "/Instalasi STIM/Devops/Superadmin",
"namaTipeUser": "Super Admin",
"hakAksesMenu": [
{
"name": "Dashboard",
"canAccess": true,
"canView": true,
"canAdd": false,
"canEdit": false,
"canDelete": false
},
{
"name": "Master Data",
"canAccess": true,
"canView": true,
"canAdd": true,
"canEdit": true,
"canDelete": false
}
],
"isGroupBased": true
}
```
### Response
**Success (201 Created):**
```json
{
"success": true,
"message": "Hak akses berhasil dibuat",
"data": {
"id": 1,
"role": "superadmin",
"group": "STIM",
"groupPath": "/Instalasi STIM/Devops/Superadmin",
"namaTipeUser": "Super Admin",
"hakAksesMenu": [
{
"name": "Dashboard",
"canAccess": true,
"canView": true,
"canAdd": false,
"canEdit": false,
"canDelete": false
}
],
"isGroupBased": true,
"createdAt": 1704067200,
"updatedAt": 1704067200
}
}
```
**Error (400 Bad Request):**
```json
{
"statusCode": 400,
"statusMessage": "role and group are required"
}
```
**Error (409 Conflict):**
```json
{
"statusCode": 409,
"statusMessage": "Hak akses untuk group dan role ini sudah ada"
}
```
### Field Description
**Required Fields:**
- `role`: Role name (string, required)
- `group`: Group name (string, required)
- `hakAksesMenu`: Array of menu permissions (array, required)
**Optional Fields:**
- `userId`: ID user (string, optional)
- `namaLengkap`: Nama lengkap (string, optional)
- `namaUser`: Username (string, optional)
- `tipeUser`: Tipe user (string, optional)
- `groupPath`: Full group path (string, optional)
- `namaTipeUser`: Nama tipe user (string, optional)
- `isGroupBased`: Boolean, default true (boolean, optional)
---
## 5. Update Hak Akses
Mengupdate hak akses yang sudah ada.
### Endpoint
```
PATCH /api/v1/hak-akses/{id}
```
### Request
**Headers:**
```
Content-Type: application/json
Accept: application/json
```
**Path Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | integer | Yes | ID hak akses yang akan diupdate |
**Body (semua field optional, hanya kirim field yang ingin diupdate):**
```json
{
"role": "admin",
"group": "LOKET",
"namaTipeUser": "Admin Loket",
"hakAksesMenu": [
{
"name": "Dashboard",
"canAccess": true,
"canView": true,
"canAdd": false,
"canEdit": false,
"canDelete": false
}
]
}
```
### Response
**Success (200 OK):**
```json
{
"success": true,
"message": "Hak akses berhasil diperbarui",
"data": {
"id": 1,
"role": "admin",
"group": "LOKET",
"groupPath": "/Instalasi STIM/Loket",
"namaTipeUser": "Admin Loket",
"hakAksesMenu": [
{
"name": "Dashboard",
"canAccess": true,
"canView": true,
"canAdd": false,
"canEdit": false,
"canDelete": false
}
],
"isGroupBased": true,
"createdAt": 1704067200,
"updatedAt": 1704067300
}
}
```
**Error (400 Bad Request):**
```json
{
"statusCode": 400,
"statusMessage": "ID is required"
}
```
**Error (404 Not Found):**
```json
{
"statusCode": 404,
"statusMessage": "Hak akses not found"
}
```
**Error (500 Internal Server Error):**
```json
{
"statusCode": 500,
"statusMessage": "Failed to update hak akses"
}
```
---
## 6. Delete Hak Akses
Menghapus hak akses.
### Endpoint
```
DELETE /api/v1/hak-akses/{id}
```
### Request
**Headers:**
```
Content-Type: application/json
Accept: application/json
```
**Path Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | integer | Yes | ID hak akses yang akan dihapus |
### Response
**Success (200 OK):**
```json
{
"success": true,
"message": "Hak akses berhasil dihapus"
}
```
**Error (400 Bad Request):**
```json
{
"statusCode": 400,
"statusMessage": "ID is required"
}
```
**Error (404 Not Found):**
```json
{
"statusCode": 404,
"statusMessage": "Hak akses not found"
}
```
**Error (500 Internal Server Error):**
```json
{
"statusCode": 500,
"statusMessage": "Failed to delete hak akses"
}
```
---
## Mapping Permissions ke Menu Sidebar
Sistem akan otomatis memetakan `pagename` dari API permission ke nama menu di sidebar. Mapping dilakukan dengan:
1. **Exact match**: `pagename.toLowerCase() === menu.name.toLowerCase()`
2. **Partial match**: `pagename.toLowerCase().includes(menu.name.toLowerCase())` atau sebaliknya
### Contoh Mapping:
- `pagename: "Halaman Utama"` → Menu: "Dashboard"
- `pagename: "Pengaturan"` → Menu: "Master Data"
- `pagename: "Hak Akses"` → Menu: "Hak Akses"
### Permission Mapping:
- `read: true``canView: true`
- `create: true``canAdd: true`
- `update: true``canEdit: true`
- `delete: true``canDelete: true`
- `active: true || read: true``canAccess: true`
---
## Group-Based Access
Sistem mendukung **Group-Based Access** dimana:
- Satu hak akses dapat diberikan ke semua user yang memiliki group dan role yang sama
- Setiap user dapat memiliki multiple hak akses dari berbagai group
- Hak akses disimpan dengan flag `isGroupBased: true` dan `groupPath` untuk identifikasi
### Contoh:
Jika hak akses dibuat dengan:
- `role: "superadmin"`
- `groupPath: "/Instalasi STIM/Devops/Superadmin"`
- `isGroupBased: true`
Maka semua user yang memiliki:
- Role: "superadmin" **DAN**
- Group: "/Instalasi STIM/Devops/Superadmin"
Akan otomatis mendapatkan hak akses tersebut.
---
## Error Handling
Semua endpoint harus mengembalikan error dengan format konsisten:
```json
{
"statusCode": 400|404|409|500,
"statusMessage": "Error message description"
}
```
**HTTP Status Codes:**
- `200 OK`: Request berhasil
- `201 Created`: Resource berhasil dibuat
- `400 Bad Request`: Request tidak valid (missing required fields, invalid format)
- `404 Not Found`: Resource tidak ditemukan
- `409 Conflict`: Resource sudah ada (untuk create duplicate)
- `500 Internal Server Error`: Server error
---
## Authentication
**Catatan:** Tim backend perlu menentukan apakah endpoint-endpoint ini memerlukan authentication token atau tidak. Jika diperlukan, tambahkan:
**Headers:**
```
Authorization: Bearer {access_token}
```
---
## Testing
### Contoh Request dengan cURL:
**1. Get Users List:**
```bash
curl -X GET "http://{API_BASE_URL}/api/users/list" \
-H "Content-Type: application/json"
```
**2. Get Permissions:**
```bash
curl -X GET "http://{API_BASE_URL}/api/v1/permission?roles=superadmin&groups=STIM" \
-H "Content-Type: application/json"
```
**3. Create Hak Akses:**
```bash
curl -X POST "http://{API_BASE_URL}/api/v1/hak-akses" \
-H "Content-Type: application/json" \
-d '{
"role": "superadmin",
"group": "STIM",
"groupPath": "/Instalasi STIM/Devops/Superadmin",
"namaTipeUser": "Super Admin",
"hakAksesMenu": [
{
"name": "Dashboard",
"canAccess": true,
"canView": true,
"canAdd": false,
"canEdit": false,
"canDelete": false
}
],
"isGroupBased": true
}'
```
**4. Update Hak Akses:**
```bash
curl -X PATCH "http://{API_BASE_URL}/api/v1/hak-akses/1" \
-H "Content-Type: application/json" \
-d '{
"namaTipeUser": "Updated Admin",
"hakAksesMenu": [...]
}'
```
**5. Delete Hak Akses:**
```bash
curl -X DELETE "http://{API_BASE_URL}/api/v1/hak-akses/1" \
-H "Content-Type: application/json"
```
---
## Catatan Penting
1. **Base URL**: Ganti `{API_BASE_URL}` dengan URL backend yang sebenarnya
2. **Timestamps**: Semua timestamp menggunakan format Unix timestamp (detik sejak epoch)
3. **Group Path**: Group path harus full path (contoh: "/Instalasi STIM/Devops/Superadmin") untuk group-based access
4. **Menu Permissions**: Array `hakAksesMenu` harus sesuai dengan struktur menu sidebar
5. **Validation**: Backend harus melakukan validasi untuk:
- Required fields (role, group, hakAksesMenu)
- Duplicate check untuk group+role combination (jika isGroupBased = true)
- Format data yang valid
---
## Kontak
Jika ada pertanyaan atau perubahan requirement, silakan hubungi tim frontend.
+15 -2
View File
@@ -1,21 +1,27 @@
# Placeholder API Permission # Placeholder API Permission
## Deskripsi ## Deskripsi
Placeholder API untuk permission digunakan untuk testing dan development ketika backend API tidak tersedia. Placeholder API untuk permission digunakan untuk testing dan development ketika backend API tidak tersedia.
## Cara Menggunakan ## Cara Menggunakan
### 1. Menggunakan Placeholder API secara Default ### 1. Menggunakan Placeholder API secara Default
Placeholder API akan otomatis digunakan sebagai fallback jika backend API (`http://10.10.150.131:8089/api/v1/permission`) tidak dapat diakses.
Placeholder API akan otomatis digunakan sebagai fallback jika backend API (`http://10.10.123.140:8089/api/v1/permission`) tidak dapat diakses.
### 2. Memaksa Menggunakan Placeholder API ### 2. Memaksa Menggunakan Placeholder API
Tambahkan query parameter `usePlaceholder=true` pada request: Tambahkan query parameter `usePlaceholder=true` pada request:
``` ```
GET /api/permission?roles=superadmin&groups=STIM&usePlaceholder=true GET /api/permission?roles=superadmin&groups=STIM&usePlaceholder=true
``` ```
### 3. Menonaktifkan Placeholder API ### 3. Menonaktifkan Placeholder API
Tambahkan query parameter `usePlaceholder=false` pada request: Tambahkan query parameter `usePlaceholder=false` pada request:
``` ```
GET /api/permission?roles=superadmin&groups=STIM&usePlaceholder=false GET /api/permission?roles=superadmin&groups=STIM&usePlaceholder=false
``` ```
@@ -23,7 +29,9 @@ GET /api/permission?roles=superadmin&groups=STIM&usePlaceholder=false
## Data Placeholder yang Tersedia ## Data Placeholder yang Tersedia
### Role: superadmin, Group: STIM ### Role: superadmin, Group: STIM
Data placeholder mengembalikan 5 permission items: Data placeholder mengembalikan 5 permission items:
- Halaman Utama (read: true, active: true) - Halaman Utama (read: true, active: true)
- Pengaturan (read: true, active: true) - Pengaturan (read: true, active: true)
- Halaman (read: true, active: true, disable: true) - Halaman (read: true, active: true, disable: true)
@@ -32,6 +40,7 @@ Data placeholder mengembalikan 5 permission items:
## Mapping Pagename ke Menu Sidebar ## Mapping Pagename ke Menu Sidebar
Sistem akan otomatis memetakan pagename dari API ke nama menu di sidebar: Sistem akan otomatis memetakan pagename dari API ke nama menu di sidebar:
- "Halaman Utama" → "Dashboard" - "Halaman Utama" → "Dashboard"
- "Pengaturan" → "Master Data" - "Pengaturan" → "Master Data"
- "Halaman" → "Master Data" - "Halaman" → "Master Data"
@@ -40,6 +49,7 @@ Sistem akan otomatis memetakan pagename dari API ke nama menu di sidebar:
## Testing dengan User bayurssa ## Testing dengan User bayurssa
Untuk testing dengan user email "bayurssa": Untuk testing dengan user email "bayurssa":
1. Pastikan user memiliki role "superadmin" dan group "STIM" di Keycloak 1. Pastikan user memiliki role "superadmin" dan group "STIM" di Keycloak
2. Login dengan email "bayurssa" dan password "12345" 2. Login dengan email "bayurssa" dan password "12345"
3. Sistem akan otomatis menggunakan placeholder API jika backend tidak tersedia 3. Sistem akan otomatis menggunakan placeholder API jika backend tidak tersedia
@@ -50,21 +60,24 @@ Untuk testing dengan user email "bayurssa":
Sistem secara otomatis melakukan normalisasi untuk role dan group: Sistem secara otomatis melakukan normalisasi untuk role dan group:
### Normalisasi Role ### Normalisasi Role
- `default-roles-sandbox``superadmin` - `default-roles-sandbox``superadmin`
- Role lain akan digunakan apa adanya (lowercase) - Role lain akan digunakan apa adanya (lowercase)
### Normalisasi Group ### Normalisasi Group
- `Instalasi STIM``STIM` - `Instalasi STIM``STIM`
- Group yang mengandung "Instalasi" akan diekstrak untuk mengambil bagian "STIM" - Group yang mengandung "Instalasi" akan diekstrak untuk mengambil bagian "STIM"
- Group lain akan digunakan apa adanya (uppercase) - Group lain akan digunakan apa adanya (uppercase)
### Contoh Mapping ### Contoh Mapping
- Role: `default-roles-sandbox` + Group: `Instalasi STIM` → API akan menggunakan `roles=superadmin&groups=STIM` - Role: `default-roles-sandbox` + Group: `Instalasi STIM` → API akan menggunakan `roles=superadmin&groups=STIM`
- Role: `default-roles-sandbox` + Group: `STIM` → API akan menggunakan `roles=superadmin&groups=STIM` - Role: `default-roles-sandbox` + Group: `STIM` → API akan menggunakan `roles=superadmin&groups=STIM`
## Catatan ## Catatan
- Placeholder API hanya tersedia untuk kombinasi role dan group yang sudah didefinisikan - Placeholder API hanya tersedia untuk kombinasi role dan group yang sudah didefinisikan
- Sistem akan otomatis melakukan normalisasi role dan group sebelum memanggil API - Sistem akan otomatis melakukan normalisasi role dan group sebelum memanggil API
- Jika role/group tidak ditemukan di placeholder, sistem akan mencoba menggunakan backend API - Jika role/group tidak ditemukan di placeholder, sistem akan mencoba menggunakan backend API
- Jika backend API juga gagal, sistem akan mengembalikan data kosong - Jika backend API juga gagal, sistem akan mengembalikan data kosong
+66
View File
@@ -0,0 +1,66 @@
# Dokumentasi Implementasi Proxy API (Nuxt 3)
Dokumen ini menjelaskan cara menggunakan dan menambah proxy API di file `nuxt.config.ts` untuk menangani masalah CORS dan menyederhanakan pemanggilan endpoint backend.
## 1. Menambah Proxy Baru di `nuxt.config.ts`
Jika Anda memiliki API backend lain (misalnya departemen berbeda atau IP berbeda), Anda cukup menambah baris baru di dalam objek `routeRules`.
### Contoh Konfigurasi:
```typescript
// nuxt.config.ts
export default defineNuxtConfig({
routeRules: {
// Proxy untuk Statistik (yang sudah ada)
"/stats-api/**": {
proxy: "http://10.10.150.100:8084/api/v1/**",
},
// Proxy untuk API Pasien (Contoh Tambahan)
"/patient-api/**": {
proxy: "http://10.10.150.101:8000/api/v2/**",
},
// Proxy untuk API Antrean (IP sama, prefix path berbeda)
"/queue-api/**": {
proxy: "http://10.10.150.100:8084/api/v1/queue/**",
},
},
});
```
---
## 2. Cara Menggunakan di Composable
Setelah didaftarkan di `nuxt.config.ts`, Anda tidak perlu lagi menulis IP Address di dalam kode Vue/TypeScript Anda. Gunakan path prefix yang sudah didefinisikan.
### Contoh File: `composables/usePatientAPI.ts`
```typescript
export const usePatientAPI = () => {
const fetchPatient = async (id: string) => {
// Alamat asli: http://10.10.150.101:8000/api/v2/patient/123
// Cukup panggil seperti ini:
return await $fetch(`/patient-api/patient/${id}`);
};
return { fetchPatient };
};
```
---
## 3. Keuntungan Menggunakan Proxy
1. **Menghindari CORS**: Browser tidak akan memblokir request karena seolah-olah request dikirim ke server yang sama dengan frontend.
2. **Keamanan**: IP backend asli tidak terekspos secara langsung di kode frontend (Client-side).
3. **Kemudahan Maintenance**: Jika IP Server backend berubah, Anda hanya perlu mengubah satu baris di `nuxt.config.ts` tanpa harus mencari dan mengganti di seluruh file `.vue` atau `.ts`.
---
## 4. Tips Debugging Proxy
- Jika menggunakan **Vite Dev Server**, pastikan Anda menjalankan ulang `npm run dev` setelah mengubah `nuxt.config.ts`.
- Cek tab **Network** di Developer Tools (F12). Anda akan melihat request ke `localhost:3000/stats-api/...` dan server Nuxt akan meneruskannya ke IP backend secara otomatis.
+101
View File
@@ -0,0 +1,101 @@
# Panduan Deployment Docker - web-antrean
Panduan ini berisi langkah-langkah untuk menjalankan aplikasi `web-antrean` (Nuxt 3) menggunakan Docker & Docker Compose.
---
## 📦 Prasyarat (Prerequisites)
Sebelum memulai, pastikan Anda telah menginstal:
- **Docker Desktop** (atau Docker Engine di Linux)
- **Docker Compose** (biasanya otomatis terinstal dengan Docker Desktop)
---
## 🚀 Quick Start (Mulai Cepat)
### 1. Siapkan File `.env`
Pastikan file `.env` sudah ada di root folder `web-antrean` dengan konfigurasi yang benar. Anda bisa menyalin dari template yang disediakan:
```bash
cp .env.docker.example .env
```
> [!IMPORTANT]
> Pastikan variabel `HOST` diatur ke `0.0.0.0` agar aplikasi dapat menerima request dari luar container Docker.
> Sesuaikan `AUTH_ORIGIN` dan `POST_LOGOUT_REDIRECT_URI` dengan alamat/domain server Anda (misal: `http://10.10.150.175:3000`).
### 2. Bangun dan Jalankan Container
Jalankan perintah berikut di terminal untuk melakukan build image sekaligus menjalankan container di background:
```bash
docker-compose up -d --build
```
### 3. Akses Aplikasi
Setelah container berhasil berjalan, aplikasi dapat diakses di browser melalui:
- **http://localhost:3000** (atau IP server Anda, misal: **http://10.10.150.175:3000**)
---
## 🔧 Perintah Docker yang Sering Digunakan (Useful Commands)
### Menjalankan Container (Jalankan di background)
```bash
docker-compose up -d
```
### Mematikan Container
```bash
docker-compose down
```
### Membangun Ulang & Menjalankan Ulang (Jika ada perubahan kode)
```bash
docker-compose up -d --build
```
### Melihat Log Aplikasi secara Real-time
```bash
docker-compose logs -f web-antrean
```
### Masuk ke Terminal di Dalam Container
```bash
docker exec -it web-antrean sh
```
---
## 💾 Persistensi Database (SQLite)
Aplikasi `web-antrean` menggunakan `better-sqlite3` untuk menyimpan data sinkronisasi pengguna di `/app/data/users.db`.
Agar data ini tidak hilang saat container dihapus atau dibangun ulang, kami menggunakan **Volume Mounting** di `docker-compose.yml`:
```yaml
volumes:
- ./data:/app/data
```
Ini akan otomatis memetakan folder local `./data` di host server Anda ke folder `/app/data` di dalam container. File database Anda akan tersimpan dengan aman di folder local `./data`.
---
## 🔍 Troubleshooting (Penyelesaian Masalah)
### 1. Masalah dengan Native Module compilation (`better-sqlite3`)
Jika image gagal dibangun karena error kompilasi C/C++, Dockerfile kami telah dikonfigurasi menggunakan **multi-stage build** dengan base image `node:20-alpine` dan menginstal package compiler (`make`, `g++`, `gcc`, `python3`) di builder stage. Ini menjamin proses build aman dan hasil image final tetap berukuran kecil (~150MB - 200MB).
### 2. Cek Status Container dan Health Check
Container ini dilengkapi dengan health check otomatis. Anda bisa mengecek statusnya dengan:
```bash
docker ps
```
Jika statusnya `Up (healthy)`, berarti server Nuxt Anda telah siap dan dapat diakses.
### 3. Hapus Cache Docker jika Mengalami Error Aneh
```bash
docker-compose down --rmi all --volumes
```
---
## 🛡️ Catatan Keamanan
- Jangan pernah meng-commit file `.env` ke Git repository.
- Selalu gunakan `NUXT_AUTH_SECRET` yang aman dan unik untuk setiap environment (Development/Production).
+69 -4
View File
@@ -1,7 +1,72 @@
<script setup>
import { onMounted, ref } from 'vue';
const nuxtApp = useNuxtApp();
const loading = ref(false);
nuxtApp.hook('page:start', () => {
loading.value = true;
});
nuxtApp.hook('page:finish', () => {
loading.value = false;
});
// Naikkan versi ini setiap kali ada perubahan struktur state (schema migration).
// Saat versi tidak cocok, semua cache Pinia akan dibersihkan otomatis.
const STORE_SCHEMA_VERSION = 3;
const VERSION_KEY = 'app-store-version';
// Daftar semua localStorage key yang dikelola oleh Pinia stores
const PINIA_STORE_KEYS = [
'queue-store-state',
'clinic-store-state',
'doctor-store',
'loket-store-state',
'ruang-store-state',
'klinikruang-store-state',
'antrean-masuk-screen-store',
'anjungan-store',
'screen-store',
'penunjang-store',
];
onMounted(() => {
try {
if (typeof window === 'undefined' || !window.localStorage) return;
const savedVersion = parseInt(localStorage.getItem(VERSION_KEY) || '0', 10);
if (savedVersion < STORE_SCHEMA_VERSION) {
// Versi lama terdeteksi → hapus semua cache Pinia sekaligus
console.log(`[Migration] Store schema v${savedVersion} → v${STORE_SCHEMA_VERSION}. Clearing all caches...`);
PINIA_STORE_KEYS.forEach(key => localStorage.removeItem(key));
localStorage.setItem(VERSION_KEY, String(STORE_SCHEMA_VERSION));
console.log('[Migration] Done. App will fetch fresh data from API.');
}
} catch (e) {
// Ignore storage errors (e.g., private browsing mode)
}
});
</script>
<template> <template>
<NuxtLayout> <v-app>
<v-app> <v-overlay
:model-value="loading"
class="align-center justify-center"
persistent
z-index="9999"
>
<v-progress-circular
color="primary"
indeterminate
size="64"
width="6"
></v-progress-circular>
</v-overlay>
<NuxtLayout>
<NuxtPage /> <NuxtPage />
</v-app> </NuxtLayout>
</NuxtLayout> </v-app>
</template> </template>
+50 -30
View File
@@ -4,44 +4,46 @@
--neutral-800: #4D4D4D; --neutral-800: #4D4D4D;
--neutral-700: #717171; --neutral-700: #717171;
--neutral-600: #89939E; --neutral-600: #89939E;
--neutral-500: #ABBED1; --neutral-500: #CDD4DC;
--neutral-400: #E5F7FA; --neutral-400: #E1E5EA;
--neutral-300: #F5F7FA; --neutral-300: #F5F7FA;
--neutral-100: #FFFFFF; --neutral-100: #FFFFFF;
/* Primary Colors (Orange) */ /* Primary Colors (Blue) */
--primary-700: #0053AD; --primary-700: #3556AE;
--primary-600: #0663C7; --primary-600: #3A61C9;
--primary-500: #0671E0; --primary-500: #567EE7;
--primary-400: #4196F0; --primary-400: #7898EC;
--primary-300: #DBEDFF; --primary-300: #9AB2F1;
--primary-200: #EEF5FC; --primary-200: #BBCBF5;
--primary-100: #EEF5FC; --primary-100: #DDE5FA;
/* Secondary Colors (Blue) */ /* Secondary Colors (Orange) */
--secondary-700: #0053AD; --secondary-700: #E65A0D;
--secondary-600: #0671E0; --secondary-600: #F16F29;
--secondary-500: #0663C7; --secondary-500: #FF8441;
--secondary-400: #4196F0; --secondary-400: #FF9D67;
--secondary-300: #DBEDFF; --secondary-300: #FFB58D;
--secondary-200: #EEF5FC; --secondary-200: #FFCEB3;
--secondary-100: #0053AD; --secondary-100: #FFE6D9;
/* Success Colors (Green) */ /* Success Colors (Green) */
--success-700: #1B6E53; --success-700: #008D65;
--success-600: #009262; --success-600: #33A484;
--success-500: #115B43; --success-500: #59B59B;
--success-400: #32C997; --success-400: #80C6B2;
--success-300: #84DFC1; --success-300: #A6D7C9;
--success-200: #F1FBF8; --success-200: #CCE8E0;
--success-100: #E5F4F0;
/* Danger Colors (Red) */ /* Danger Colors (Red) */
--danger-700: #E01507; --danger-700: #D82719;
--danger-600: #E02B1D; --danger-600: #DE473B;
--danger-500: #C33025; --danger-500: #E57269;
--danger-400: #FF5A4F; --danger-400: #EB938C;
--danger-300: #F0857D; --danger-300: #F1B3AE;
--danger-200: #FFF1F0; --danger-200: #F7D4D1;
--danger-100: #FBE9E8;
} }
/* Utility Classes untuk Background */ /* Utility Classes untuk Background */
@@ -68,6 +70,7 @@
.bg-secondary-400 { background-color: var(--secondary-400); } .bg-secondary-400 { background-color: var(--secondary-400); }
.bg-secondary-300 { background-color: var(--secondary-300); } .bg-secondary-300 { background-color: var(--secondary-300); }
.bg-secondary-200 { background-color: var(--secondary-200); } .bg-secondary-200 { background-color: var(--secondary-200); }
.bg-secondary-100 { background-color: var(--secondary-100); }
.bg-success-700 { background-color: var(--success-700); } .bg-success-700 { background-color: var(--success-700); }
.bg-success-600 { background-color: var(--success-600); } .bg-success-600 { background-color: var(--success-600); }
@@ -75,6 +78,7 @@
.bg-success-400 { background-color: var(--success-400); } .bg-success-400 { background-color: var(--success-400); }
.bg-success-300 { background-color: var(--success-300); } .bg-success-300 { background-color: var(--success-300); }
.bg-success-200 { background-color: var(--success-200); } .bg-success-200 { background-color: var(--success-200); }
.bg-success-100 { background-color: var(--success-100); }
.bg-danger-700 { background-color: var(--danger-700); } .bg-danger-700 { background-color: var(--danger-700); }
.bg-danger-600 { background-color: var(--danger-600); } .bg-danger-600 { background-color: var(--danger-600); }
@@ -82,6 +86,7 @@
.bg-danger-400 { background-color: var(--danger-400); } .bg-danger-400 { background-color: var(--danger-400); }
.bg-danger-300 { background-color: var(--danger-300); } .bg-danger-300 { background-color: var(--danger-300); }
.bg-danger-200 { background-color: var(--danger-200); } .bg-danger-200 { background-color: var(--danger-200); }
.bg-danger-100 { background-color: var(--danger-100); }
/* Utility Classes untuk Text Color */ /* Utility Classes untuk Text Color */
.text-neutral-900 { color: var(--neutral-900); } .text-neutral-900 { color: var(--neutral-900); }
@@ -95,18 +100,33 @@
.text-primary-600 { color: var(--primary-600); } .text-primary-600 { color: var(--primary-600); }
.text-primary-500 { color: var(--primary-500); } .text-primary-500 { color: var(--primary-500); }
.text-primary-400 { color: var(--primary-400); } .text-primary-400 { color: var(--primary-400); }
.text-primary-300 { color: var(--primary-300); }
.text-primary-200 { color: var(--primary-200); }
.text-primary-100 { color: var(--primary-100); }
.text-secondary-700 { color: var(--secondary-700); } .text-secondary-700 { color: var(--secondary-700); }
.text-secondary-600 { color: var(--secondary-600); } .text-secondary-600 { color: var(--secondary-600); }
.text-secondary-500 { color: var(--secondary-500); } .text-secondary-500 { color: var(--secondary-500); }
.text-secondary-400 { color: var(--secondary-400); }
.text-secondary-300 { color: var(--secondary-300); }
.text-secondary-200 { color: var(--secondary-200); }
.text-secondary-100 { color: var(--secondary-100); }
.text-success-700 { color: var(--success-700); } .text-success-700 { color: var(--success-700); }
.text-success-600 { color: var(--success-600); } .text-success-600 { color: var(--success-600); }
.text-success-500 { color: var(--success-500); } .text-success-500 { color: var(--success-500); }
.text-success-400 { color: var(--success-400); }
.text-success-300 { color: var(--success-300); }
.text-success-200 { color: var(--success-200); }
.text-success-100 { color: var(--success-100); }
.text-danger-700 { color: var(--danger-700); } .text-danger-700 { color: var(--danger-700); }
.text-danger-600 { color: var(--danger-600); } .text-danger-600 { color: var(--danger-600); }
.text-danger-500 { color: var(--danger-500); } .text-danger-500 { color: var(--danger-500); }
.text-danger-400 { color: var(--danger-400); }
.text-danger-300 { color: var(--danger-300); }
.text-danger-200 { color: var(--danger-200); }
.text-danger-100 { color: var(--danger-100); }
/* Utility Classes untuk Border */ /* Utility Classes untuk Border */
.border-primary-500 { border-color: var(--primary-500); } .border-primary-500 { border-color: var(--primary-500); }
+11
View File
@@ -0,0 +1,11 @@
# Avatar Images
## Default Avatar
Simpan gambar avatar default dengan nama: `default-avatar.png`
Gambar avatar default akan digunakan ketika user tidak memiliki foto profile.
## Cara Menggunakan
1. Simpan gambar avatar yang ingin digunakan dengan nama `default-avatar.png`
2. Pastikan gambar dalam format PNG dengan transparansi (jika diperlukan)
3. Ukuran gambar disarankan minimal 300x300px untuk kualitas yang baik
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

+53 -28
View File
@@ -4,39 +4,44 @@ $neutral-900: #212121;
$neutral-800: #4D4D4D; $neutral-800: #4D4D4D;
$neutral-700: #717171; $neutral-700: #717171;
$neutral-600: #89939E; $neutral-600: #89939E;
$neutral-500: #ABBED1; $neutral-500: #CDD4DC;
$neutral-400: #E5F7FA; $neutral-400: #E1E5EA;
$neutral-300: #F5F7FA; $neutral-300: #F5F7FA;
$neutral-100: #FFFFFF; $neutral-100: #FFFFFF;
$primary-700: #FF9B1B; $primary-700: #3556AE;
$primary-600: #FFA532; $primary-600: #3A61C9;
$primary-500: #FFB95F; $primary-500: #567EE7;
$primary-400: #FFCD8D; $primary-400: #7898EC;
$primary-300: #FFDCAF; $primary-300: #9AB2F1;
$primary-200: #FFE6C6; $primary-200: #BBCBF5;
$primary-100: #EEF5FC; $primary-100: #DDE5FA;
$primary-50: #EFF3FD;
$secondary-700: #0053AD; $secondary-700: #E65A0D;
$secondary-600: #0671E0; $secondary-600: #F16F29;
$secondary-500: #0663C7; $secondary-500: #FF8441;
$secondary-400: #4196F0; $secondary-400: #FF9D67;
$secondary-300: #DBEDFF; $secondary-300: #FFB58D;
$secondary-200: #EEF5FC; $secondary-200: #FFCEB3;
$secondary-100: #FFE6D9;
$secondary-50: #FFF3EC;
$success-700: #1B6E53; $success-700: #008D65;
$success-600: #009262; $success-600: #33A484;
$success-500: #115B43; $success-500: #59B59B;
$success-400: #32C997; $success-400: #80C6B2;
$success-300: #84DFC1; $success-300: #A6D7C9;
$success-200: #F1FBF8; $success-200: #CCE8E0;
$success-100: #E5F4F0;
$danger-700: #E01507; $danger-700: #D82719;
$danger-600: #E02B1D; $danger-600: #DE473B;
$danger-500: #C33025; $danger-500: #E57269;
$danger-400: #FF5A4F; $danger-400: #EB938C;
$danger-300: #F0857D; $danger-300: #F1B3AE;
$danger-200: #FFF1F0; $danger-200: #F7D4D1;
$danger-100: #FBE9E8;
// CSS Variables untuk dynamic theming // CSS Variables untuk dynamic theming
:root { :root {
@@ -58,6 +63,7 @@ $danger-200: #FFF1F0;
--color-primary-300: #{$primary-300}; --color-primary-300: #{$primary-300};
--color-primary-200: #{$primary-200}; --color-primary-200: #{$primary-200};
--color-primary-100: #{$primary-100}; --color-primary-100: #{$primary-100};
--color-primary-50: #{$primary-50};
// Secondary // Secondary
--color-secondary-700: #{$secondary-700}; --color-secondary-700: #{$secondary-700};
@@ -66,6 +72,8 @@ $danger-200: #FFF1F0;
--color-secondary-400: #{$secondary-400}; --color-secondary-400: #{$secondary-400};
--color-secondary-300: #{$secondary-300}; --color-secondary-300: #{$secondary-300};
--color-secondary-200: #{$secondary-200}; --color-secondary-200: #{$secondary-200};
--color-secondary-100: #{$secondary-100};
--color-secondary-50: #{$secondary-50};
// Success // Success
--color-success-700: #{$success-700}; --color-success-700: #{$success-700};
@@ -74,6 +82,7 @@ $danger-200: #FFF1F0;
--color-success-400: #{$success-400}; --color-success-400: #{$success-400};
--color-success-300: #{$success-300}; --color-success-300: #{$success-300};
--color-success-200: #{$success-200}; --color-success-200: #{$success-200};
--color-success-100: #{$success-100};
// Danger // Danger
--color-danger-700: #{$danger-700}; --color-danger-700: #{$danger-700};
@@ -82,6 +91,7 @@ $danger-200: #FFF1F0;
--color-danger-400: #{$danger-400}; --color-danger-400: #{$danger-400};
--color-danger-300: #{$danger-300}; --color-danger-300: #{$danger-300};
--color-danger-200: #{$danger-200}; --color-danger-200: #{$danger-200};
--color-danger-100: #{$danger-100};
// Theme colors (akan di-override oleh theme system) // Theme colors (akan di-override oleh theme system)
--color-primary: #{$primary-500}; --color-primary: #{$primary-500};
@@ -135,15 +145,30 @@ $colors: (
'primary-400': $primary-400, 'primary-400': $primary-400,
'primary-300': $primary-300, 'primary-300': $primary-300,
'primary-200': $primary-200, 'primary-200': $primary-200,
'primary-100': $primary-100,
'primary-50': $primary-50,
'secondary-700': $secondary-700, 'secondary-700': $secondary-700,
'secondary-600': $secondary-600, 'secondary-600': $secondary-600,
'secondary-500': $secondary-500, 'secondary-500': $secondary-500,
'secondary-400': $secondary-400,
'secondary-300': $secondary-300,
'secondary-200': $secondary-200,
'secondary-100': $secondary-100,
'secondary-50': $secondary-50,
'success-700': $success-700, 'success-700': $success-700,
'success-600': $success-600, 'success-600': $success-600,
'success-500': $success-500, 'success-500': $success-500,
'success-400': $success-400,
'success-300': $success-300,
'success-200': $success-200,
'success-100': $success-100,
'danger-700': $danger-700, 'danger-700': $danger-700,
'danger-600': $danger-600, 'danger-600': $danger-600,
'danger-500': $danger-500 'danger-500': $danger-500,
'danger-400': $danger-400,
'danger-300': $danger-300,
'danger-200': $danger-200,
'danger-100': $danger-100
); );
@each $name, $color in $colors { @each $name, $color in $colors {
+28
View File
@@ -0,0 +1,28 @@
// variables for Grand Paviliun (Eksekutif)
.theme-grand-paviliun {
--gp-primary: #3F51B5; // Indigo header
--gp-primary-dark: #303F9F;
--gp-bg: #F5F7FA; // Light grayish blue background
--gp-surface: #FFFFFF;
--gp-text-primary: #1F2937;
--gp-text-secondary: #6B7280;
--gp-border: #E5E7EB;
--gp-success: #10B981; // Selesai
--gp-danger: #EF4444; // Pending
--gp-warning: #F59E0B;
--gp-info: #3B82F6;
// Custom colors for specific tags
--gp-room-number: #F97316; // Orange text for RUANG 02
background-color: var(--gp-bg);
min-height: 100vh;
font-family: 'Inter', 'Roboto', sans-serif;
}
// Utility classes
.gp-text-primary { color: var(--gp-text-primary); }
.gp-text-secondary { color: var(--gp-text-secondary); }
.gp-font-bold { font-weight: 700; }
.gp-font-semibold { font-weight: 600; }
+7 -4
View File
@@ -1,7 +1,8 @@
// Gunakan @use daripada @import // Gunakan @use daripada @import
@use './variables' as *; @use "./variables" as *;
@use './colors' as *; @use "./colors" as *;
@use './typography' as *; @use "./typography" as *;
@use "./pages/dashboard" as *;
// Global styles // Global styles
*, *,
@@ -19,7 +20,9 @@ body {
padding: 0; padding: 0;
background-color: var(--color-background); background-color: var(--color-background);
color: var(--color-textPrimary); color: var(--color-textPrimary);
transition: background-color 0.3s ease, color 0.3s ease; transition:
background-color 0.3s ease,
color 0.3s ease;
} }
// Selection color // Selection color
+179
View File
@@ -0,0 +1,179 @@
@use "../variables" as *;
@use "../colors" as *;
.dashboard-container {
min-height: 100vh;
background-color: #f5f5f5;
padding: 0;
}
.dashboard-content {
width: 100%;
margin: 0;
background: white;
min-height: 100vh;
}
.dashboard-header {
background: white;
border-bottom: 1px solid #e0e0e0;
padding: 1.5rem 2rem;
h1 {
font-size: 1.25rem;
font-weight: $font-weight-medium;
color: var(--color-neutral-900);
margin: 0;
}
}
.dashboard-body {
padding: 2rem;
}
.section {
margin-bottom: 2.5rem;
&-title {
font-size: 0.875rem;
font-weight: $font-weight-semibold;
color: var(--color-neutral-900);
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid #e0e0e0;
}
}
.info-grid {
display: grid;
grid-template-columns: 140px 1fr;
gap: 0.75rem 1.5rem;
font-size: 0.875rem;
.label {
color: var(--color-neutral-700);
font-weight: $font-weight-regular;
}
.value {
color: var(--color-neutral-900);
font-weight: $font-weight-regular;
}
}
.token-section {
margin-bottom: 1.5rem;
.token-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
h4 {
font-size: 0.875rem;
font-weight: $font-weight-medium;
color: var(--color-neutral-900);
margin: 0;
}
}
.token-content {
background: #f8f9fa;
border: 1px solid #e0e0e0;
border-radius: 4px;
padding: 1rem;
font-family: 'Courier New', monospace;
font-size: 0.75rem;
line-height: 1.6;
color: var(--color-neutral-800);
word-break: break-all;
white-space: pre-wrap;
max-height: 200px;
overflow-y: auto;
&.collapsed {
max-height: 60px;
overflow: hidden;
position: relative;
&::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 30px;
background: linear-gradient(transparent, #f8f9fa);
}
}
}
.toggle-btn {
margin-top: 0.5rem;
font-size: 0.75rem;
color: var(--color-primary-600);
cursor: pointer;
background: none;
border: none;
padding: 0;
text-decoration: underline;
&:hover {
color: var(--color-primary-700);
}
}
}
.copy-btn {
font-size: 0.75rem;
padding: 0.25rem 0.75rem;
background: white;
border: 1px solid #d0d0d0;
border-radius: 4px;
color: var(--color-neutral-700);
cursor: pointer;
transition: all 0.2s;
&:hover {
background: #f5f5f5;
border-color: #b0b0b0;
}
.v-icon {
font-size: 14px;
margin-right: 4px;
}
}
.payload-section {
.payload-content {
background: #f8f9fa;
border: 1px solid #e0e0e0;
border-radius: 4px;
padding: 1rem;
font-family: 'Courier New', monospace;
font-size: 0.75rem;
line-height: 1.6;
color: var(--color-neutral-800);
max-height: 300px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
}
}
@media (max-width: $breakpoint-mobile) {
.dashboard-body {
padding: 1rem;
}
.info-grid {
grid-template-columns: 1fr;
gap: 0.5rem;
.label {
font-weight: $font-weight-semibold;
}
}
}
@@ -1,22 +1,17 @@
/* assets/styles/loginpage/login.css */ /* assets/styles/loginpage/login.scss */
@use "~/assets/scss/_colors.scss" as *;
/* --- COLOR DEFINITIONS --- */ /* Main Background - Using Primary Color */
/* Base Orange: #FF9B1B (New) */
/* Hover/Darker Orange: #E68A00 (Calculated darker shade for hover effect) */
/* --- END COLOR DEFINITIONS --- */
/* Main Background - Now Orange */
.login-background { .login-background {
background: #FF9B1B; /* Solid Orange Background: #FF9B1B */ background: $primary-500; /* Primary-500: #567EE7 */
min-height: 100vh; height: 100dvh;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
} }
.login-background::before { .login-background::before {
/* No radial gradients */ /* No radial gradients */
content: ''; content: "";
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
@@ -26,7 +21,7 @@
background: none; background: none;
} }
/* Floating Medical Icons - Subtle on Orange Background */ /* Floating Medical Icons - Subtle on Primary Background */
.floating-medical-icon { .floating-medical-icon {
position: absolute; position: absolute;
opacity: 0.8; opacity: 0.8;
@@ -45,16 +40,39 @@
} }
.floating-medical-icon .v-icon { .floating-medical-icon .v-icon {
color: rgba(255, 255, 255, 0.15) !important; /* Light white for icons on orange background */ color: rgba(
255,
255,
255,
0.15
) !important; /* Light white for icons on primary background */
} }
/* Specific icon positioning - now static */ /* Specific icon positioning - now static */
.icon-1 { top: 15%; left: 5%; } .icon-1 {
.icon-2 { top: 20%; right: 10%; } top: 15%;
.icon-3 { bottom: 10%; left: 15%; } left: 5%;
.icon-4 { top: 60%; left: 10%; } }
.icon-5 { bottom: 20%; right: 5%; } .icon-2 {
.icon-6 { top: 35%; left: 20%; } top: 20%;
right: 10%;
}
.icon-3 {
bottom: 10%;
left: 15%;
}
.icon-4 {
top: 60%;
left: 10%;
}
.icon-5 {
bottom: 20%;
right: 5%;
}
.icon-6 {
top: 35%;
left: 20%;
}
/* Main Card - White */ /* Main Card - White */
.main-card { .main-card {
@@ -66,14 +84,14 @@
} }
.white-card { .white-card {
background: white !important; /* Main card background is white */ background: $neutral-300 !important; /* Neutral-300: #F5F7FA */
border: 1px solid rgba(0, 0, 0, 0.1); border: 1px solid rgba(0, 0, 0, 0.1);
/* box-shadow is now managed by .main-card */ /* box-shadow is now managed by .main-card */
} }
/* Right Login Section (White) */ /* Right Login Section (Neutral-300) */
.login-section-box { .login-section-box {
background: white; /* Changed to white */ background: $neutral-300; /* Neutral-300: #F5F7FA */
min-height: 500px; /* Reduced min-height since there is no side-by-side comparison */ min-height: 500px; /* Reduced min-height since there is no side-by-side comparison */
} }
@@ -84,51 +102,51 @@
/* White Dialog Styles */ /* White Dialog Styles */
.white-dialog { .white-dialog {
background: white !important; background: $neutral-100 !important;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.2); box-shadow: 0 20px 50px rgba(0, 0, 0, 0.2);
} }
/* Card Header - Dark Text for white background */ /* Card Header - Using Neutral Color with Larger Font */
.welcome-title-dark { .welcome-title-dark {
color: #333; /* Dark text on white background */ color: $neutral-900; /* Neutral-900: #212121 */
font-size: 1.1rem; font-size: 1.6rem;
font-weight: 700; font-weight: 700;
letter-spacing: 1px; letter-spacing: 1px;
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
} }
.login-instruction-dark { .login-instruction-dark {
color: #777; /* Grey text for instruction on white background */ color: $neutral-700; /* Neutral-700: #717171 */
font-size: 0.8rem; font-size: 1.1rem;
letter-spacing: 0.3px; letter-spacing: 0.3px;
} }
/* App Title - Dark for white background */ /* App Title - Using Primary Color */
.app-title-dark { .app-title-dark {
color: #FF9B1B; /* Orange app title: #FF9B1B */ color: $primary-500; /* Primary-500: #567EE7 */
text-shadow: none; text-shadow: none;
} }
.hospital-logo { .hospital-logo {
height: 332px; height: 180px;
width: auto; width: auto;
filter: drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.1)); filter: drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.1));
} }
.sso-text-dark { .sso-text-dark {
color: #555; /* Dark grey for SSO text */ color: $neutral-800; /* Dark grey for SSO text */
font-size: 0.95rem; font-size: 0.95rem;
font-weight: 500; font-weight: 500;
} }
/* Buttons (Orange theme) */ /* Buttons (Primary theme) */
.login-btn { .login-btn {
background: #FF9B1B !important; /* Solid Orange button: #FF9B1B */ background: $primary-500 !important; /* Primary-500: #567EE7 */
color: white !important; /* White text on orange button */ color: $neutral-100 !important; /* White text on primary button */
border: none; border: none;
box-shadow: box-shadow:
0 8px 25px rgba(255, 155, 27, 0.3), /* Orange shadow: #FF9B1B */ 0 8px 25px rgba(86, 126, 231, 0.3),
0 4px 12px rgba(255, 155, 27, 0.2); /* Primary shadow */ 0 4px 12px rgba(86, 126, 231, 0.2);
transition: all 0.3s ease; transition: all 0.3s ease;
text-transform: none; text-transform: none;
font-size: 1rem; font-size: 1rem;
@@ -137,15 +155,15 @@
.login-btn:hover { .login-btn:hover {
transform: translateY(-2px); transform: translateY(-2px);
background: #E68A00 !important; /* Slightly darker orange on hover */ background: $primary-600 !important; /* Primary-600: #3A61C9 on hover */
box-shadow: box-shadow:
0 12px 30px rgba(255, 155, 27, 0.4), 0 12px 30px rgba(86, 126, 231, 0.4),
0 6px 15px rgba(255, 155, 27, 0.3); 0 6px 15px rgba(86, 126, 231, 0.3);
} }
.register-btn-dark { .register-btn-dark {
color: #FF9B1B !important; /* Orange text for register button: #FF9B1B */ color: $primary-500 !important; /* Primary-500: #567EE7 */
border: 2px solid #FF9B1B !important; /* Orange border: #FF9B1B */ border: 2px solid $primary-500 !important; /* Primary border */
background: transparent !important; background: transparent !important;
transition: all 0.3s ease; transition: all 0.3s ease;
text-transform: none; text-transform: none;
@@ -153,39 +171,44 @@
} }
.register-btn-dark:hover { .register-btn-dark:hover {
background: rgba(255, 155, 27, 0.05) !important; /* Light orange hover background */ background: rgba(
border-color: #E68A00 !important; /* Darker orange border on hover */ 86,
126,
231,
0.05
) !important; /* Light primary hover background */
border-color: $primary-600 !important; /* Primary-600 border on hover */
transform: translateY(-1px); transform: translateY(-1px);
} }
/* Custom Divider - Dark for white background */ /* Custom Divider - Dark for white background */
.custom-divider-dark { .custom-divider-dark {
border-color: rgba(0, 0, 0, 0.1) !important; /* Light grey divider */ border-color: $neutral-400 !important; /* Light grey divider */
opacity: 1 !important; opacity: 1 !important;
} }
/* Help Text and Links - Dark for white background */ /* Help Text and Links - Dark for white background */
.help-text-dark { .help-text-dark {
color: #555; /* Dark grey help text */ color: $neutral-800; /* Dark grey help text */
font-size: 0.9rem; font-size: 0.9rem;
} }
.contact-link-dark { .contact-link-dark {
color: #FF9B1B !important; /* Orange link: #FF9B1B */ color: $primary-500 !important; /* Primary-500: #567EE7 */
text-decoration: underline; text-decoration: underline;
text-transform: none; text-transform: none;
font-size: 0.9rem; font-size: 0.9rem;
} }
.help-link-dark { .help-link-dark {
color: #777; /* Grey link */ color: $neutral-700; /* Grey link */
font-size: 0.85rem; font-size: 0.85rem;
text-decoration: underline; text-decoration: underline;
cursor: pointer; cursor: pointer;
} }
.help-link-dark:hover { .help-link-dark:hover {
color: #FF9B1B; /* Orange on hover: #FF9B1B */ color: $primary-500; /* Primary-500 on hover */
} }
/* Transparent Background */ /* Transparent Background */
@@ -196,22 +219,21 @@
/* Dialog Accents */ /* Dialog Accents */
.v-card-title .v-icon, .v-card-title .v-icon,
.v-list-item .v-icon { .v-list-item .v-icon {
color: #FF9B1B !important; /* Orange accents: #FF9B1B */ color: $primary-500 !important; /* Primary accents */
} }
.v-btn[color="#FF9B1B"] { .v-btn[color="#FF9B1B"] {
background-color: #FF9B1B !important; background-color: $primary-500 !important;
color: white !important; color: $neutral-100 !important;
} }
.v-alert[color="orange"] { .v-alert[color="orange"] {
border-left: 8px solid #FF9B1B !important; border-left: 8px solid $primary-500 !important;
color: #333 !important; color: $neutral-900 !important;
} }
/* Responsive Design Adjustments for single column */ /* Responsive Design Adjustments for single column */
@media (max-width: 960px) { @media (max-width: 960px) {
.main-card { .main-card {
margin: 1rem; margin: 1rem;
max-width: 450px !important; /* Enforce max-width on smaller screens */ max-width: 450px !important; /* Enforce max-width on smaller screens */
@@ -224,7 +246,6 @@
} }
@media (max-width: 600px) { @media (max-width: 600px) {
.main-card { .main-card {
margin: 0.5rem; margin: 0.5rem;
} }
+520
View File
@@ -0,0 +1,520 @@
<template>
<div>
<!-- Header -->
<div class="page-header">
<div class="header-content">
<div class="header-left">
<div class="header-icon">
<v-icon size="28" color="white">mdi-shield-lock-outline</v-icon>
</div>
<div class="header-text">
<h2 class="page-title">Manajemen Hak Akses (Keycloak Integration)</h2>
<p class="page-subtitle">Konfigurasi Akses Halaman berdasarkan Role & Group Real-time</p>
</div>
</div>
</div>
</div>
<div class="hak-akses-page pa-4">
<!-- Tabs Selection -->
<v-tabs v-model="activeTab" color="primary" class="mb-4 bg-white rounded-lg elevation-1" align-tabs="start">
<v-tab value="roles" class="text-capitalize">
<v-icon start>mdi-account-star-outline</v-icon>
Realm Roles
</v-tab>
<v-tab value="groups" class="text-capitalize">
<v-icon start>mdi-account-group-outline</v-icon>
Groups
</v-tab>
</v-tabs>
<!-- Action Bar -->
<div class="action-bar mb-4 d-flex justify-space-between align-center px-4 py-3 bg-white rounded-xl border elevation-1">
<div class="d-flex align-center gap-2">
<v-text-field
v-model="search"
prepend-inner-icon="mdi-magnify"
:label="`Cari ${activeTab === 'roles' ? 'Role' : 'Group'}...`"
variant="outlined"
density="compact"
hide-details
rounded="lg"
class="max-width-400"
></v-text-field>
<v-chip color="primary" variant="tonal" class="font-weight-bold">
{{ displayItems.length }} {{ activeTab === 'roles' ? 'Roles' : 'Groups' }}
</v-chip>
</div>
<v-btn
color="primary"
@click="loadData"
prepend-icon="mdi-sync"
variant="flat"
rounded="lg"
:loading="loading"
>
Sync Keycloak
</v-btn>
</div>
<!-- Main Content Card -->
<v-card class="rounded-xl border-0 shadow-soft overflow-hidden">
<v-data-table
:headers="headers"
:items="displayItems"
:loading="loading"
class="custom-table"
hover
>
<template v-slot:[`item.no`]="{ index }">
<span class="text-grey-darken-1 font-weight-medium">{{ index + 1 }}</span>
</template>
<template v-slot:[`item.name`]="{ item }">
<div class="d-flex align-center">
<v-icon
:icon="activeTab === 'roles' ? 'mdi-account-star' : 'mdi-folder-account'"
size="small"
color="primary"
class="mr-2"
></v-icon>
<span class="font-weight-bold text-primary-darken-1">{{ item.name }}</span>
</div>
</template>
<template v-slot:[`item.pagesCount`]="{ item }">
<v-tooltip bottom>
<template v-slot:activator="{ props }">
<v-chip
v-bind="props"
:color="item.validPagesCount > 0 ? 'success' : 'grey'"
size="small"
variant="flat"
class="font-weight-bold"
>
{{ item.validPagesCount }} Halaman
</v-chip>
</template>
<span>Akses ke {{ item.validPagesCount }} Halaman Terdaftar</span>
</v-tooltip>
</template>
<template v-slot:[`item.memberCount`]="{ item }">
<v-btn
variant="text"
color="primary"
size="small"
class="text-capitalize font-weight-bold"
prepend-icon="mdi-account-multiple-outline"
@click="viewMembers(item)"
>
Deteksi Member
</v-btn>
</template>
<template v-slot:[`item.actions`]="{ item }">
<v-btn
color="warning"
size="small"
variant="flat"
prepend-icon="mdi-shield-edit-outline"
class="text-capitalize rounded-lg mr-2"
@click="editPermissions(item)"
>
Atur Akses
</v-btn>
</template>
</v-data-table>
</v-card>
</div>
<!-- Edit Permissions Dialog -->
<v-dialog v-model="showEditDialog" max-width="900px" persistent scrollable>
<v-card class="rounded-xl overflow-hidden">
<v-card-title class="pa-6 border-b d-flex align-center bg-primary text-white">
<v-icon icon="mdi-shield-edit" class="mr-3"></v-icon>
<div>
<div class="text-h6">Konfigurasi Akses: {{ editedEntity?.name }}</div>
<div class="text-caption text-white text-opacity-75">Tentukan halaman yang dapat diakses oleh {{ activeTab }} ini</div>
</div>
<v-spacer></v-spacer>
<v-btn icon="mdi-close" variant="text" color="white" @click="showEditDialog = false"></v-btn>
</v-card-title>
<v-card-text class="pa-0">
<div class="pa-6 bg-grey-lighten-4">
<v-alert
type="info"
variant="tonal"
density="compact"
class="mb-4 rounded-lg"
icon="mdi-information-outline"
>
Jika user memiliki beberapa Role/Group, mereka akan mendapatkan akses gabungan dari semuanya.
</v-alert>
</div>
<div class="pa-6 pt-0">
<EditHakAkses
v-if="editedEntity"
:pages="editedEntity.pages"
@update:pages="editedEntity.pages = $event"
/>
</div>
</v-card-text>
<v-divider></v-divider>
<v-card-actions class="pa-6 bg-white">
<v-spacer></v-spacer>
<v-btn variant="outlined" rounded="lg" @click="showEditDialog = false" class="px-6 border-primary-lighten-4 text-primary">Batal</v-btn>
<v-btn color="primary" rounded="lg" @click="savePermissions" class="px-6 ml-2" :loading="loading">
Simpan Perubahan
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Member List Dialog -->
<v-dialog v-model="showMembersDialog" max-width="600px" scrollable>
<v-card class="rounded-xl">
<v-card-title class="pa-6 border-b d-flex align-center bg-grey-lighten-4">
<v-icon icon="mdi-account-group" color="primary" class="mr-3"></v-icon>
<div>
<div class="text-h6">Member: {{ editedEntity?.name }}</div>
<div class="text-caption text-grey-darken-1">Daftar pengguna yang memiliki {{ activeTab }} ini</div>
</div>
<v-spacer></v-spacer>
<v-btn icon="mdi-close" variant="text" @click="showMembersDialog = false"></v-btn>
</v-card-title>
<v-card-text class="pa-0">
<v-list v-if="!memberLoading && currentMembers.length > 0" lines="two" class="pa-0">
<v-list-item
v-for="(user, i) in currentMembers"
:key="user.id"
:border="i !== currentMembers.length - 1"
class="px-6 py-3"
>
<template v-slot:prepend>
<v-avatar color="primary-lighten-5" size="44">
<v-icon icon="mdi-account" color="primary"></v-icon>
</v-avatar>
</template>
<v-list-item-title class="font-weight-bold text-primary">{{ user.name }}</v-list-item-title>
<v-list-item-subtitle class="mt-1">
<v-chip size="x-small" variant="outlined" class="mr-2">{{ user.username }}</v-chip>
<span class="text-caption text-grey">{{ user.email }}</span>
</v-list-item-subtitle>
</v-list-item>
</v-list>
<div v-else-if="!memberLoading" class="pa-12 text-center">
<v-avatar color="grey-lighten-4" size="80" class="mb-4">
<v-icon icon="mdi-account-off-outline" color="grey" size="40"></v-icon>
</v-avatar>
<div class="text-grey font-weight-medium">Tidak ada pengguna yang terdeteksi</div>
</div>
<div v-else class="pa-6">
<v-skeleton-loader type="list-item-avatar-two-line@5"></v-skeleton-loader>
</div>
</v-card-text>
</v-card>
</v-dialog>
<v-snackbar v-model="snackbar.show" :color="snackbar.color" :timeout="snackbar.timeout" location="top">
{{ snackbar.message }}
<template v-slot:actions>
<v-btn icon="mdi-close" variant="text" @click="snackbar.show = false"></v-btn>
</template>
</v-snackbar>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import EditHakAkses from '@/components/HakAkses/EditHakAkses.vue';
import { useNavItemsStore } from '~/stores/navItems1';
import type { HakAkses } from '~/types/setting';
definePageMeta({
middleware: ['auth', 'check-page-access']
})
const navItemsStore = useNavItemsStore();
// State
const activeTab = ref('roles');
const hakAksesList = ref<HakAkses[]>([]);
const keycloakEntities = ref<{ roles: any[], groups: any[] }>({ roles: [], groups: [] });
const loading = ref(false);
const showEditDialog = ref(false);
const showMembersDialog = ref(false);
const editedEntity = ref<any>(null);
const currentMembers = ref<any[]>([]);
const memberLoading = ref(false);
const search = ref('');
const snackbar = ref({
show: false,
message: '',
color: 'success',
timeout: 3000
});
const showSnackbar = (message: string, color: string = 'success') => {
snackbar.value.message = message;
snackbar.value.color = color;
snackbar.value.show = true;
};
// Headers
const headers = [
{ title: 'No', key: 'no', align: 'center' as const, width: '80px' },
{ title: 'Nama', key: 'name', sortable: true },
{ title: 'Akses', key: 'pagesCount', align: 'center' as const, width: '150px' },
{ title: 'Member', key: 'memberCount', align: 'center' as const, width: '150px' },
{ title: 'Aksi', key: 'actions', align: 'center' as const, sortable: false, width: '250px' }
];
// Load everything
const loadData = async () => {
loading.value = true;
try {
// 1. Load our local mappings
const hakAksesRes = await $fetch<{ success: boolean, data: HakAkses[] }>('/api/hak-akses');
if (hakAksesRes && hakAksesRes.success) {
hakAksesList.value = hakAksesRes.data || [];
}
// 2. Load Keycloak entities
const entitiesRes = await $fetch<{ success: boolean, data: any }>('/api/hak-akses/entities');
if (entitiesRes && entitiesRes.success) {
keycloakEntities.value = entitiesRes.data;
}
} catch (error) {
console.error('Error loading data:', error);
showSnackbar('Gagal memuat data dari Keycloak', 'error');
} finally {
loading.value = false;
}
};
// Computed list based on active tab
const displayItems = computed(() => {
const list = activeTab.value === 'roles' ? keycloakEntities.value.roles : keycloakEntities.value.groups;
// Get all valid paths from current navigation store
const allNavPaths = new Set<string>();
const extractPaths = (items: any[]) => {
items.forEach((item: any) => {
if (item.path) allNavPaths.add(item.path);
if (item.children) extractPaths(item.children);
});
};
extractPaths(navItemsStore.getNavItems);
return list.map((entity: any) => {
// Find existing permission mapping
const mapping = hakAksesList.value.find(h => h.namaHakAkses === entity.name);
if (!mapping) return { ...entity, pages: [], validPagesCount: 0, status: 'tidak aktif', id_mapping: null };
// Count only valid pages that exist in navigation
const validPages = (mapping.pages || []).filter(p => {
const path = typeof p === 'string' ? p : (p as any)?.path;
return path && allNavPaths.has(path);
});
return {
...entity,
pages: mapping.pages, // Keep original for editing
validPagesCount: validPages.length,
status: mapping.status,
id_mapping: mapping.id
};
}).filter((item: any) => {
if (!search.value) return true;
return item.name.toLowerCase().includes(search.value.toLowerCase());
});
});
const editPermissions = (item: any) => {
editedEntity.value = JSON.parse(JSON.stringify(item));
showEditDialog.value = true;
};
const viewMembers = async (item: any) => {
editedEntity.value = item;
currentMembers.value = [];
showMembersDialog.value = true;
memberLoading.value = true;
try {
const res = await $fetch<{ success: boolean, data: any[] }>(`/api/hak-akses/members`, {
params: {
type: item.type,
name: item.name,
id: item.id
}
});
if (res && res.success) {
currentMembers.value = res.data;
}
} catch (error) {
console.error('Error loading members:', error);
showSnackbar('Gagal memuat member', 'error');
} finally {
memberLoading.value = false;
}
};
const savePermissions = async () => {
if (!editedEntity.value) return;
loading.value = true;
try {
const payload = {
id: editedEntity.value.id_mapping, // If null, backend create new
namaHakAkses: editedEntity.value.name,
status: 'aktif',
pages: editedEntity.value.pages
};
const response = await $fetch<{ success: boolean, message: string }>('/api/hak-akses', {
method: 'POST',
body: payload
});
if (response.success) {
showSnackbar('Hak akses berhasil diperbarui', 'success');
await loadData();
await navItemsStore.refreshNavItems();
showEditDialog.value = false;
}
} catch (error) {
console.error('Error saving:', error);
showSnackbar('Gagal menyimpan perubahan', 'error');
} finally {
loading.value = false;
}
};
onMounted(() => {
loadData();
});
</script>
<style scoped lang="scss">
// Colors from Design System
$primary-700: #3556AE;
$primary-600: #3A61C9;
$success-700: #1B6E53;
$success-600: #009262;
$neutral-100: #FFFFFF;
$neutral-800: #4D4D4D;
// Font Family & Weights
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-weight-regular: 400;
$font-weight-medium: 500;
$font-weight-semibold: 600;
// Apply font family
* {
font-family: $font-family-base;
}
// ============================================
// PAGE HEADER
// ============================================
.page-header {
background: linear-gradient(135deg, $primary-600 0%, $primary-700 100%);
border-radius: 0 !important;
box-shadow: 0 4px 16px rgba(58, 97, 201, 0.2);
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 28px;
height: 80px;
color: $neutral-100;
}
.header-left {
display: flex;
align-items: center;
}
.header-icon {
background: rgba(255, 255, 255, 0.2);
border-radius: 12px;
padding: 12px;
margin-right: 16px;
backdrop-filter: blur(10px);
}
.page-title {
font-size: 32px;
line-height: 40px;
font-weight: $font-weight-semibold;
margin: 0;
color: $neutral-100;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.page-subtitle {
margin: 2px 0 0 0;
opacity: 0.9;
font-size: 15px;
line-height: 22px;
font-weight: $font-weight-regular;
color: $neutral-100;
}
// ============================================
// ACTION BAR (matching UserLogin)
// ============================================
.action-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
background: $neutral-100;
border-radius: 12px;
border: 1px solid #E0E0E0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.action-bar-left {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.stat-chip {
color: $primary-600 !important;
font-weight: $font-weight-semibold;
font-size: 14px;
line-height: 20px;
}
.custom-table :deep(th) {
background-color: #f5f5f5 !important;
font-weight: 700 !important;
text-transform: uppercase;
font-size: 12px;
letter-spacing: 0.5px;
}
.max-width-400 {
max-width: 400px;
}
.gap-2 {
gap: 8px;
}
.border-b {
border-bottom: 1px solid rgba(0,0,0,0.12);
}
</style>
+93
View File
@@ -0,0 +1,93 @@
// server/api/hak-akses/entities.get.ts
export default defineEventHandler(async (event) => {
console.log("📥 Fetching Keycloak entities (roles & groups)");
// Get session from session store
const { getSessionFromCookie } = await import('~/server/utils/sessionStore');
const session = await getSessionFromCookie(event);
if (!session) {
throw createError({
statusCode: 401,
statusMessage: "Session expired or not found",
});
}
const config = useRuntimeConfig();
const accessToken = session.accessToken;
if (!accessToken) {
throw createError({
statusCode: 401,
statusMessage: "No access token found in session",
});
}
try {
const issuerUrl = new URL(config.keycloakIssuer);
const realm = issuerUrl.pathname.split('/').filter(Boolean).pop() || 'master';
const adminBaseUrl = `${config.keycloakIssuer.replace('/realms/' + realm, '')}/admin/realms/${realm}`;
// 1. Fetch Realm Roles
const rolesResponse = await fetch(`${adminBaseUrl}/roles`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
});
if (!rolesResponse.ok) {
throw new Error(`Failed to fetch roles: ${rolesResponse.status}`);
}
const roles = await rolesResponse.json();
// Filter out potential client-specific roles if needed, keeping realm roles
const realmRoles = roles.filter((r: any) => !r.clientRole);
// 2. Fetch Groups
const groupsResponse = await fetch(`${adminBaseUrl}/groups`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
});
if (!groupsResponse.ok) {
throw new Error(`Failed to fetch groups: ${groupsResponse.status}`);
}
const groups = await groupsResponse.json();
// 3. For each role and group, we'll need to fetch members to show the count
// NOTE: This might be expensive if there are many roles/groups.
// For now, let's just return the list and we'll fetch members on demand in the UI or in a separate task.
// To be efficient, we'll only return basic info here.
return {
success: true,
data: {
roles: realmRoles.map((r: any) => ({
id: r.id,
name: r.name,
description: r.description || '',
type: 'role'
})),
groups: groups.map((g: any) => ({
id: g.id,
name: g.name,
path: g.path,
type: 'group'
}))
}
};
} catch (error: any) {
console.error("❌ Error fetching Keycloak entities:", error);
throw createError({
statusCode: 500,
statusMessage: error.message || "Failed to fetch Keycloak entities",
});
}
});
+74
View File
@@ -0,0 +1,74 @@
// server/api/hak-akses/members.get.ts
export default defineEventHandler(async (event) => {
const query = getQuery(event);
const { type, name, id } = query;
if (!type || (!name && !id)) {
throw createError({
statusCode: 400,
statusMessage: "Type and (Name or ID) are required",
});
}
// Get session
const { getSessionFromCookie } = await import('~/server/utils/sessionStore');
const session = await getSessionFromCookie(event);
if (!session) {
throw createError({
statusCode: 401,
statusMessage: "Session expired",
});
}
const config = useRuntimeConfig();
const accessToken = session.accessToken;
try {
const issuerUrl = new URL(config.keycloakIssuer);
const realm = issuerUrl.pathname.split('/').filter(Boolean).pop() || 'master';
const adminBaseUrl = `${config.keycloakIssuer.replace('/realms/' + realm, '')}/admin/realms/${realm}`;
let url = '';
if (type === 'role') {
url = `${adminBaseUrl}/roles/${name}/users`;
} else if (type === 'group') {
url = `${adminBaseUrl}/groups/${id}/members`;
} else {
throw new Error("Invalid type");
}
const response = await fetch(url, {
method: 'GET',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`Failed to fetch members: ${response.status}`);
}
const members = await response.json();
return {
success: true,
data: members.map((m: any) => ({
id: m.id,
username: m.username,
email: m.email,
firstName: m.firstName,
lastName: m.lastName,
name: `${m.firstName || ''} ${m.lastName || ''}`.trim() || m.username
}))
};
} catch (error: any) {
console.error("❌ Error fetching members:", error);
throw createError({
statusCode: 500,
statusMessage: error.message || "Failed to fetch members",
});
}
});
+113
View File
@@ -0,0 +1,113 @@
// composables/useHakAkses.ts
// Composable for handling user permissions/access based on hakAkses
import { useAuth } from "~/composables/useAuth";
import type { HakAkses } from "~/types/setting";
export const useHakAkses = () => {
const { user, checkAuth } = useAuth();
/**
* Get all pages that user has access to based on their roles
*/
const getAllowedPages = async (): Promise<string[]> => {
// Ensure user is loaded
if (!user.value) {
await checkAuth();
}
const currentUser = user.value;
if (!currentUser) {
return [];
}
// Get roles and groups from multiple possible sources in User object
const roles = [
...(currentUser.roles || []),
...((currentUser as any).realm_access?.roles || []),
...((currentUser as any).resource_access?.['web-antrean']?.roles || [])
];
const groups = (currentUser as any).groups || [];
// Combine everything the user belongs to
const entities = [...new Set([...roles, ...groups])];
if (entities.length === 0) {
return [];
}
try {
// Fetch all hak akses data
const response = await $fetch<{ success: boolean, data: HakAkses[] }>('/api/hak-akses');
if (response && response.success && Array.isArray(response.data)) {
const hakAksesList = response.data;
// Filter hak akses that match user's entities and are active
const userHakAkses = hakAksesList.filter((hakAkses) =>
entities.includes(hakAkses.namaHakAkses) &&
hakAkses.status === 'aktif'
);
// Combine all pages from all matching hak akses
const allPages = userHakAkses.reduce((pages: string[], hakAkses) => {
if (hakAkses.pages && Array.isArray(hakAkses.pages)) {
return [...pages, ...hakAkses.pages];
}
return pages;
}, []);
// Remove duplicates
return [...new Set(allPages)];
}
return [];
} catch (error) {
console.error('Error fetching allowed pages:', error);
return [];
}
};
/**
* Check if user has access to a specific page
*/
const hasPageAccess = async (pagePath: string): Promise<boolean> => {
const allowedPages = await getAllowedPages();
return allowedPages.includes(pagePath);
};
/**
* Check if user has access to any page in a list
*/
const hasAnyPageAccess = async (pagePaths: string[]): Promise<boolean> => {
const allowedPages = await getAllowedPages();
return pagePaths.some(path => allowedPages.includes(path));
};
const allHakAksesData = ref<HakAkses[]>([]);
const isLoading = ref(false);
const fetchHakAkses = async () => {
isLoading.value = true;
try {
const response = await $fetch<{ success: boolean, data: HakAkses[] }>('/api/hak-akses');
if (response && response.success) {
allHakAksesData.value = response.data;
}
} catch (error) {
console.error('Error fetching hak akses:', error);
} finally {
isLoading.value = false;
}
};
return {
allHakAksesData,
isLoading,
fetchHakAkses,
getAllowedPages,
hasPageAccess,
hasAnyPageAccess
};
};
+38
View File
@@ -0,0 +1,38 @@
import { defineEventHandler, proxyRequest } from 'h3'
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig(event)
const targetBase = config.public.verificationApiBaseUrl || 'http://10.10.123.140:8089/api/v1'
// Extract path suffix after /klinik-api
const suffix = event.path.replace(/^\/klinik-api/, '')
const targetUrl = `${targetBase}${suffix}`
// Clone request headers and overwrite Host/Origin/Referer with backend-whitelisted values
const headers = { ...event.node.req.headers } as Record<string, string>
// Extract backend target host
try {
const urlObj = new URL(targetBase)
headers.host = urlObj.host
} catch (e) {
headers.host = config.proxyTargetHostKlinikFallback || '10.10.123.140:8089'
}
// Spoof the origin and referer to match the server's trusted/whitelisted client origin
const proxyOrigin = config.proxyClientOrigin as string
headers.origin = proxyOrigin
headers.referer = proxyOrigin.endsWith('/') ? proxyOrigin : `${proxyOrigin}/`
console.log(`[Proxy Klinik-API] Forwarding ${event.node.req.method} to: ${targetUrl}`)
console.log(`[Proxy Klinik-API] Headers - Host: ${headers.host}, Origin: ${headers.origin}`)
try {
return await proxyRequest(event, targetUrl, {
headers
})
} catch (error: any) {
console.error(`[Proxy Klinik-API] Error forwarding to ${targetUrl}:`, error)
throw error
}
})
+38
View File
@@ -0,0 +1,38 @@
import { defineEventHandler, proxyRequest } from 'h3'
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig(event)
const targetBase = config.public.externalApiBaseUrl || 'http://10.10.123.135:8084/api/v1'
// Extract path suffix after /stats-api
const suffix = event.path.replace(/^\/stats-api/, '')
const targetUrl = `${targetBase}${suffix}`
// Clone request headers and overwrite Host/Origin/Referer with backend-whitelisted values
const headers = { ...event.node.req.headers } as Record<string, string>
// Extract backend target host
try {
const urlObj = new URL(targetBase)
headers.host = urlObj.host
} catch (e) {
headers.host = config.proxyTargetHostFallback || '10.10.123.135:8084'
}
// Spoof the origin and referer to match the server's trusted/whitelisted client origin
const proxyOrigin = config.proxyClientOrigin as string
headers.origin = proxyOrigin
headers.referer = proxyOrigin.endsWith('/') ? proxyOrigin : `${proxyOrigin}/`
console.log(`[Proxy Stats-API] Forwarding ${event.node.req.method} to: ${targetUrl}`)
console.log(`[Proxy Stats-API] Headers - Host: ${headers.host}, Origin: ${headers.origin}`)
try {
return await proxyRequest(event, targetUrl, {
headers
})
} catch (error: any) {
console.error(`[Proxy Stats-API] Error forwarding to ${targetUrl}:`, error)
throw error
}
})
+38
View File
@@ -0,0 +1,38 @@
import { defineEventHandler, proxyRequest } from 'h3'
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig(event)
const targetBase = config.public.externalApiBaseUrl || 'http://10.10.123.135:8084/api/v1'
// Extract path suffix after /visit-api
const suffix = event.path.replace(/^\/visit-api/, '')
const targetUrl = `${targetBase}${suffix}`
// Clone request headers and overwrite Host/Origin/Referer with backend-whitelisted values
const headers = { ...event.node.req.headers } as Record<string, string>
// Extract backend target host
try {
const urlObj = new URL(targetBase)
headers.host = urlObj.host
} catch (e) {
headers.host = config.proxyTargetHostFallback || '10.10.123.135:8084'
}
// Spoof the origin and referer to match the server's trusted/whitelisted client origin
const proxyOrigin = config.proxyClientOrigin as string
headers.origin = proxyOrigin
headers.referer = proxyOrigin.endsWith('/') ? proxyOrigin : `${proxyOrigin}/`
console.log(`[Proxy Visit-API] Forwarding ${event.node.req.method} to: ${targetUrl}`)
console.log(`[Proxy Visit-API] Headers - Host: ${headers.host}, Origin: ${headers.origin}`)
try {
return await proxyRequest(event, targetUrl, {
headers
})
} catch (error: any) {
console.error(`[Proxy Visit-API] Error forwarding to ${targetUrl}:`, error)
throw error
}
})
@@ -0,0 +1,174 @@
<template>
<div class="theme-grand-paviliun gp-container">
<!-- Header -->
<div class="gp-header">
<div class="header-left">
<v-icon size="24" color="white" class="mr-3">mdi-domain</v-icon>
<div>
<h1 class="header-title">Klinik Admin Grand Paviliun</h1>
<p class="header-date">{{ currentDate }}</p>
</div>
</div>
<div class="header-right">
<v-btn color="#F97316" variant="flat" class="text-white font-weight-bold">
<v-icon start size="18">mdi-account-cog</v-icon>
KELOLA PASIEN
</v-btn>
</div>
</div>
<!-- Search Bar -->
<div class="gp-search-bar">
<v-text-field
v-model="searchQuery"
placeholder="Cari pasien di semua ruang (barcode, nomor antrian, nama...)"
density="compact"
hide-details
prepend-inner-icon="mdi-magnify"
variant="outlined"
bg-color="white"
class="search-input"
></v-text-field>
</div>
<!-- Main Content -->
<div class="gp-main-content">
<div class="kanban-board">
<GpRoomColumn
v-for="clinic in filteredClinics"
:key="clinic.kodeKlinik"
:clinic-name="clinic.namaKlinik"
:current-patient="clinic.currentPatient"
:queue-patients="clinic.queuePatients"
:room-options="clinic.roomOptions"
@pemeriksaan-awal="(p, r) => handlePemeriksaanAwal(p, clinic.kodeKlinik, r)"
@panggil-pemeriksaan="(p, r) => handlePanggilPemeriksaan(p, clinic.kodeKlinik, r)"
@action-pending="p => handleAction(p, 'pending', clinic.kodeKlinik)"
@action-selesai="p => handleAction(p, 'selesai', clinic.kodeKlinik)"
@process-patient="p => handleProcess(p, clinic.kodeKlinik, clinic.roomOptions?.[0]?.nomorRuang || '1')"
/>
</div>
<!-- Right Sidebar -->
<GpMonitorSidebar :rooms="monitorRooms" />
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import GpRoomColumn from '../GrandPaviliun/GpRoomColumn.vue';
import GpMonitorSidebar from '../GrandPaviliun/GpMonitorSidebar.vue';
import { useGrandPaviliun } from '@/composables/useGrandPaviliun';
import '@/assets/scss/eksekutif.scss';
const {
clinics,
monitorRooms,
fetchEksekutifData,
handlePemeriksaanAwal,
handlePanggilPemeriksaan,
processPatientAction
} = useGrandPaviliun();
const searchQuery = ref('');
const filteredClinics = computed(() => {
if (!searchQuery.value) return clinics.value;
const lowerSearch = searchQuery.value.toLowerCase();
// Filter by patient barcode, queue number, or name in the clinic columns
return clinics.value.map(clinic => {
const isCurrentMatch = clinic.currentPatient && (
(clinic.currentPatient.noAntrian || '').toLowerCase().includes(lowerSearch) ||
(clinic.currentPatient.barcode || '').toLowerCase().includes(lowerSearch) ||
(clinic.currentPatient.name || '').toLowerCase().includes(lowerSearch)
);
const matchedQueue = clinic.queuePatients.filter(p =>
(p.noAntrian || '').toLowerCase().includes(lowerSearch) ||
(p.barcode || '').toLowerCase().includes(lowerSearch) ||
(p.name || '').toLowerCase().includes(lowerSearch)
);
return {
...clinic,
currentPatient: isCurrentMatch ? clinic.currentPatient : null,
queuePatients: matchedQueue
};
}).filter(c => c.currentPatient || c.queuePatients.length > 0);
});
// Action Handlers
const handleAction = async (patient, action, kodeKlinik) => {
// Use the current room of the patient or fallback to '1'
const nomorRuang = patient.nomorRuang || '1';
await processPatientAction(patient, action, kodeKlinik, nomorRuang);
};
const handleProcess = async (patient, kodeKlinik, fallbackRuang) => {
const action = patient.status === 'pending' ? 'waiting' : 'proses';
await processPatientAction(patient, action, kodeKlinik, fallbackRuang);
};
// Date Formatting
const currentDate = computed(() => {
const options = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' };
return new Date().toLocaleDateString('id-ID', options);
});
// Load data when mounted
fetchEksekutifData();
</script>
<style scoped>
.gp-container {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
background-color: var(--gp-bg, #F5F7FA);
}
.gp-header {
background-color: var(--gp-primary, #3F51B5);
color: white;
padding: 16px 24px;
display: flex;
justify-content: space-between;
align-items: center;
}
.header-left {
display: flex;
align-items: center;
}
.header-title {
font-size: 18px;
font-weight: 700;
margin: 0;
line-height: 1.2;
}
.header-date {
font-size: 12px;
opacity: 0.8;
margin: 0;
}
.gp-search-bar {
padding: 12px 24px;
background: white;
border-bottom: 1px solid var(--gp-border, #e5e7eb);
}
.search-input {
max-width: 100%;
}
.gp-main-content {
flex: 1;
display: flex;
overflow: hidden;
}
.kanban-board {
flex: 1;
display: flex;
overflow-x: auto;
padding: 16px 24px;
align-items: stretch;
}
</style>
@@ -0,0 +1,135 @@
<template>
<div class="gp-current-patient">
<div class="header-row">
<span class="label">SEDANG DIPROSES</span>
<div class="badges">
<button class="action-badge" @click="$emit('action-pending')"><GpStatusBadge status="PENDING" /></button>
<button class="action-badge ml-1" @click="$emit('action-selesai')"><GpStatusBadge status="SELESAI" /></button>
</div>
</div>
<div class="patient-big-info">
<div class="queue-number">{{ queueNumber || '-' }}</div>
<div class="room-indicator" v-if="roomName">{{ roomName }}</div>
</div>
<div class="action-section">
<label class="input-label">Tujukan ke ruang*</label>
<v-select
v-model="selectedRoom"
:items="roomOptions"
item-title="namaRuang"
item-value="nomorRuang"
placeholder="Pilih Ruang Pemeriksaan"
density="compact"
variant="outlined"
hide-details
class="room-select mb-3"
></v-select>
<div class="action-buttons">
<v-btn
color="#3F51B5"
variant="flat"
class="flex-1 text-white text-none font-weight-bold"
size="small"
@click="$emit('pemeriksaan-awal')"
>
<v-icon start size="16">mdi-stethoscope</v-icon>
Pemeriksaan Awal
</v-btn>
<v-btn
color="#3F51B5"
variant="flat"
class="flex-1 text-white text-none font-weight-bold ml-2"
size="small"
@click="$emit('panggil-pemeriksaan')"
>
<v-icon start size="16">mdi-bullhorn</v-icon>
Panggil Pemeriksaan
</v-btn>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue';
import GpStatusBadge from './GpStatusBadge.vue';
const props = defineProps({
queueNumber: { type: String, default: '' },
roomName: { type: String, default: '' },
roomOptions: { type: Array, default: () => [] }
});
const emit = defineEmits(['pemeriksaan-awal', 'panggil-pemeriksaan', 'action-pending', 'action-selesai', 'update:selectedRoom']);
const selectedRoom = ref(props.roomOptions?.[0]?.nomorRuang || null);
watch(selectedRoom, (newVal) => {
emit('update:selectedRoom', newVal);
});
</script>
<style scoped>
.gp-current-patient {
background: white;
padding: 16px;
border-bottom: 1px solid var(--gp-border, #e5e7eb);
}
.header-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.label {
font-size: 10px;
font-weight: 700;
color: var(--gp-text-secondary, #6b7280);
}
.patient-big-info {
text-align: center;
margin-bottom: 24px;
}
.queue-number {
font-size: 36px;
font-weight: 800;
color: var(--gp-text-primary, #1f2937);
line-height: 1.2;
}
.room-indicator {
font-size: 14px;
font-weight: 700;
color: var(--gp-room-number, #F97316);
margin-top: 4px;
text-transform: uppercase;
}
.input-label {
display: block;
font-size: 11px;
font-weight: 600;
color: var(--gp-text-secondary, #6b7280);
margin-bottom: 4px;
}
.room-select {
/* vuetify default is fine */
}
.action-buttons {
display: flex;
width: 100%;
}
.flex-1 {
flex: 1;
}
.action-badge {
background: none;
border: none;
padding: 0;
cursor: pointer;
outline: none;
}
.action-badge:hover {
opacity: 0.8;
}
</style>
@@ -0,0 +1,98 @@
<template>
<div class="gp-monitor-sidebar">
<div class="sidebar-header">
<v-icon size="18" class="mr-2" color="#3F51B5">mdi-monitor-dashboard</v-icon>
<span class="title">MONITOR RUANG</span>
</div>
<div class="stats-row">
<div class="stat-box box-success">
<div class="stat-label text-success">TERSEDIA</div>
<div class="stat-value text-success">{{ availableCount }}</div>
</div>
<div class="stat-box box-danger ml-2">
<div class="stat-label text-danger">SIBUK</div>
<div class="stat-value text-danger">{{ busyCount }}</div>
</div>
</div>
<div class="rooms-list">
<GpRoomMonitorCard
v-for="room in rooms"
:key="room.id"
:room-name="room.name"
:active-patient="room.activePatient"
:clinic-name="room.clinicName"
/>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import GpRoomMonitorCard from './GpRoomMonitorCard.vue';
const props = defineProps({
rooms: { type: Array, default: () => [] }
});
const availableCount = computed(() => props.rooms.filter(r => !r.activePatient).length);
const busyCount = computed(() => props.rooms.filter(r => r.activePatient).length);
</script>
<style scoped>
.gp-monitor-sidebar {
width: 300px;
background: white;
height: 100%;
display: flex;
flex-direction: column;
border-left: 1px solid var(--gp-border, #e5e7eb);
}
.sidebar-header {
padding: 16px;
display: flex;
align-items: center;
border-bottom: 1px solid var(--gp-border, #e5e7eb);
}
.title {
font-size: 14px;
font-weight: 700;
color: var(--gp-text-primary, #1f2937);
}
.stats-row {
display: flex;
padding: 16px;
border-bottom: 1px solid var(--gp-border, #e5e7eb);
}
.stat-box {
flex: 1;
padding: 12px;
border-radius: 2px;
}
.box-success {
background: #ECFDF5;
border: 1px solid #D1FAE5;
}
.box-danger {
background: #FFF7ED;
border: 1px solid #FFEDD5;
}
.stat-label {
font-size: 10px;
font-weight: 700;
margin-bottom: 4px;
}
.stat-value {
font-size: 20px;
font-weight: 700;
}
.text-success { color: var(--gp-success, #10b981); }
.text-danger { color: var(--gp-room-number, #F97316); } /* Orange */
.rooms-list {
padding: 16px;
overflow-y: auto;
flex: 1;
}
</style>
+63
View File
@@ -0,0 +1,63 @@
<template>
<div class="gp-queue-list-container">
<div class="list-header">
<span class="title">DAFTAR ANTRIAN</span>
<span class="count-badge">{{ patients.length }}</span>
</div>
<div class="list-body">
<GpQueueListItem
v-for="patient in patients"
:key="patient.id || patient.noAntrian"
:queue-number="patient.noAntrian"
:status="patient.status"
:is-active="patient.isActive"
@view-detail="$emit('view-detail', patient)"
@process-patient="$emit('process-patient', patient)"
/>
</div>
</div>
</template>
<script setup>
import GpQueueListItem from './GpQueueListItem.vue';
defineProps({
patients: { type: Array, default: () => [] }
});
defineEmits(['view-detail', 'process-patient']);
</script>
<style scoped>
.gp-queue-list-container {
display: flex;
flex-direction: column;
flex: 1;
background: white;
min-height: 200px;
}
.list-header {
padding: 12px 16px;
background: #EEF2F6; /* Light blue-gray from design */
border-bottom: 1px solid var(--gp-border, #e5e7eb);
display: flex;
align-items: center;
gap: 8px;
}
.title {
font-size: 10px;
font-weight: 700;
color: var(--gp-text-secondary, #6b7280);
}
.count-badge {
background: #D1D5DB; /* Gray */
color: white;
border-radius: 12px;
padding: 2px 8px;
font-size: 10px;
font-weight: 700;
}
.list-body {
overflow-y: auto;
}
</style>
@@ -0,0 +1,47 @@
<template>
<div :class="['gp-queue-list-item', { active: isActive }]">
<div class="item-left">
<span class="queue-number">{{ queueNumber }}</span>
</div>
<div class="item-right">
<GpStatusBadge v-if="status && !isActive" :status="status" />
<v-btn v-if="!isActive" icon="mdi-play" size="x-small" variant="text" color="success" class="ml-1" @click="$emit('process-patient')" />
<v-btn v-if="isActive" icon="mdi-eye" size="x-small" variant="text" color="#3F51B5" @click="$emit('view-detail')" />
</div>
</div>
</template>
<script setup>
import GpStatusBadge from './GpStatusBadge.vue';
defineProps({
queueNumber: { type: String, required: true },
status: { type: String, default: '' },
isActive: { type: Boolean, default: false }
});
defineEmits(['view-detail', 'process-patient']);
</script>
<style scoped>
.gp-queue-list-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--gp-border, #e5e7eb);
background-color: var(--gp-surface, #fff);
transition: background-color 0.2s ease;
}
.gp-queue-list-item:last-child {
border-bottom: none;
}
.gp-queue-list-item.active {
background-color: #E3EBFF; /* Light blue highlighting active patient */
}
.queue-number {
font-size: 14px;
font-weight: 700;
color: var(--gp-text-primary, #1f2937);
}
</style>
+95
View File
@@ -0,0 +1,95 @@
<template>
<div class="gp-room-column">
<div class="column-header">
<v-icon size="20" class="mr-2" color="white">mdi-domain</v-icon>
<span class="column-title">{{ clinicName.toUpperCase() }}</span>
</div>
<div class="column-content">
<template v-if="currentPatient || queuePatients.length > 0">
<GpCurrentPatient
v-if="currentPatient"
:queue-number="currentPatient.noAntrian"
:room-name="currentPatient.roomIndicator"
:room-options="roomOptions"
@pemeriksaan-awal="$emit('pemeriksaan-awal', currentPatient, $event)"
@panggil-pemeriksaan="$emit('panggil-pemeriksaan', currentPatient, $event)"
@action-pending="$emit('action-pending', currentPatient)"
@action-selesai="$emit('action-selesai', currentPatient)"
/>
<GpQueueList
:patients="queuePatients"
@view-detail="p => $emit('view-detail', p)"
@process-patient="p => $emit('process-patient', p)"
/>
</template>
<div v-else class="empty-column-state">
<v-icon size="48" color="#D1D5DB" class="mb-4">mdi-account-off</v-icon>
<div class="empty-text">TIDAK ADA PASIEN YANG DIPROSES</div>
</div>
</div>
</div>
</template>
<script setup>
import GpCurrentPatient from './GpCurrentPatient.vue';
import GpQueueList from './GpQueueList.vue';
defineProps({
clinicName: { type: String, required: true },
currentPatient: { type: Object, default: null },
queuePatients: { type: Array, default: () => [] },
roomOptions: { type: Array, default: () => [] }
});
defineEmits(['pemeriksaan-awal', 'panggil-pemeriksaan', 'view-detail', 'action-pending', 'action-selesai', 'process-patient']);
</script>
<style scoped>
.gp-room-column {
width: 380px;
min-width: 380px;
background: var(--gp-bg, #F5F7FA);
border-radius: 4px;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
margin-right: 16px;
}
.column-header {
background: var(--gp-primary, #3F51B5);
color: white;
padding: 12px 16px;
display: flex;
align-items: center;
}
.column-title {
font-size: 13px;
font-weight: 700;
letter-spacing: 0.5px;
}
.column-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: white; /* Make the whole column body white */
}
.empty-column-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px;
text-align: center;
background: white;
}
.empty-text {
font-size: 11px;
font-weight: 700;
color: var(--gp-text-secondary, #6b7280);
}
</style>
@@ -0,0 +1,71 @@
<template>
<div class="gp-room-monitor-card" :class="{ 'is-busy': isBusy }">
<div class="card-header">
<span class="room-name">{{ roomName }}</span>
<span class="room-status" :class="isBusy ? 'text-danger' : 'text-success'">
{{ isBusy ? 'SIBUK' : 'TERSEDIA' }}
</span>
</div>
<div class="card-body">
<span v-if="isBusy" class="active-patient">{{ activePatient }} - {{ clinicName }}</span>
<span v-else class="waiting-text">Menunggu pasien...</span>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
roomName: { type: String, required: true },
activePatient: { type: String, default: null }, // e.g. "AI002"
clinicName: { type: String, default: '' },
});
const isBusy = computed(() => !!props.activePatient);
</script>
<style scoped>
.gp-room-monitor-card {
background: var(--gp-surface, #fff);
border: 1px solid var(--gp-border, #e5e7eb);
border-left: 4px solid var(--gp-success, #10b981);
border-radius: 2px;
padding: 12px;
margin-bottom: 12px;
box-shadow: 0 1px 2px rgba(0,0,0,0.02);
}
.gp-room-monitor-card.is-busy {
border-left-color: var(--gp-room-number, #F97316); /* Orange */
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.room-name {
font-size: 13px;
font-weight: 700;
color: var(--gp-text-primary, #1f2937);
}
.room-status {
font-size: 10px;
font-weight: 700;
}
.text-success { color: var(--gp-success, #10b981); }
.text-danger { color: var(--gp-room-number, #F97316); } /* Matching image orange */
.card-body {
font-size: 13px;
}
.active-patient {
color: var(--gp-primary, #3F51B5);
font-weight: 700;
}
.waiting-text {
color: var(--gp-text-secondary, #6b7280);
font-style: italic;
font-size: 12px;
}
</style>
@@ -0,0 +1,40 @@
<template>
<span :class="['gp-badge', badgeClass]">
{{ label }}
</span>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
status: { type: String, required: true },
text: { type: String, default: '' }
});
const label = computed(() => props.text || props.status.toUpperCase());
const badgeClass = computed(() => {
const s = props.status.toLowerCase();
if (s === 'pending' || s === 'sibuk') return 'gp-badge-danger';
if (s === 'selesai' || s === 'tersedia' || s === 'proses') return 'gp-badge-success';
return 'gp-badge-default';
});
</script>
<style scoped>
.gp-badge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px 6px;
border-radius: 2px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
color: white;
}
.gp-badge-danger { background-color: var(--gp-danger, #ef4444); }
.gp-badge-success { background-color: var(--gp-success, #10b981); }
.gp-badge-default { background-color: var(--gp-text-secondary, #6b7280); }
</style>
+75 -422
View File
@@ -1,457 +1,110 @@
<template> <template>
<v-card class="pa-6 rounded-xl elevation-4"> <v-card variant="outlined" class="rounded-lg overflow-hidden">
<v-card-title class="d-flex align-center text-h5 font-weight-bold mb-4"> <v-table density="compact">
<v-icon icon="mdi-lock-check-outline" class="mr-2 text-primary" size="28"></v-icon> <thead>
<span>Edit Hak Akses Menu</span> <tr>
</v-card-title> <th class="text-left font-weight-bold">Halaman</th>
<v-divider class="mb-4"></v-divider> <th class="text-center font-weight-bold" style="width: 120px;">Akses</th>
<v-card-text class="px-0"> </tr>
<v-row v-if="localItem.role || localItem.group" class="mb-4"> </thead>
<v-col cols="12"> <tbody>
<v-alert type="info" variant="tonal" density="compact"> <template v-for="item in flatNavItems" :key="item.path || item.name">
<strong>Role:</strong> {{ localItem.role }} | <strong>Group:</strong> {{ localItem.group }} <tr :class="{ 'bg-grey-lighten-4': !item.path }">
</v-alert> <td :style="{ paddingLeft: item.level * 24 + 'px' }">
</v-col> <div class="d-flex align-center">
</v-row> <v-icon :icon="item.icon" size="small" class="mr-2" color="grey"></v-icon>
<v-row> <span :class="{ 'font-weight-bold': !item.path }">{{ item.name }}</span>
<v-col cols="12"> </div>
<v-card-title class="text-subtitle-1 font-weight-bold pa-0 mb-4">Hak Akses Menu</v-card-title> </td>
<v-table density="comfortable" class="elevation-1 rounded-xl hak-akses-table"> <td class="text-center">
<thead> <v-checkbox
<tr> v-if="item.path"
<th class="text-left text-uppercase font-weight-bold text-grey-darken-1 kol-no">No</th> :model-value="isAllowed(item.path)"
<th class="text-left text-uppercase font-weight-bold text-grey-darken-1">Menu</th> @update:model-value="toggleAccess(item, !!$event)"
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1 kol-status">Status</th> hide-details
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1 kol-aksi">Akses</th> density="compact"
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1 kol-aksi">Lihat</th> color="primary"
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1 kol-aksi">Tambah</th> class="d-inline-flex"
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1 kol-aksi">Edit</th> ></v-checkbox>
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1 kol-aksi">Hapus</th> <v-icon v-else icon="mdi-folder-open" size="small" color="grey-lighten-1"></v-icon>
</tr> </td>
</thead> </tr>
<tbody> </template>
<template v-if="backendPermissions.length > 0"> </tbody>
<tr v-for="(perm, index) in sortedPermissions" :key="perm.id" :class="{ 'bg-grey-lighten-5': perm.level === 2 }"> </v-table>
<td class="kol-no text-center">{{ index + 1 }}</td>
<td>
<div :style="{ paddingLeft: perm.level === 2 ? '32px' : '0' }" class="d-flex align-center">
<v-icon v-if="perm.level === 2" icon="mdi-subdirectory-arrow-right" size="small" class="mr-2 text-grey"></v-icon>
<span :class="{ 'font-weight-bold': perm.level === 1 }">{{ perm.pagename }}</span>
<v-chip v-if="perm.level" size="x-small" variant="outlined" class="ml-2" color="grey">
Level {{ perm.level }}
</v-chip>
</div>
</td>
<td class="text-center kol-status">
<v-chip
:color="isPageMapped(perm.pagename) ? 'success' : 'warning'"
size="small"
variant="tonal"
>
{{ isPageMapped(perm.pagename) ? 'Mapped' : 'Not Mapped' }}
</v-chip>
</td>
<td class="text-center kol-aksi">
<div class="cek-wrapper">
<v-checkbox
v-model="perm.active"
hide-details
color="primary"
></v-checkbox>
</div>
</td>
<td class="text-center kol-aksi">
<div class="cek-wrapper">
<v-checkbox
v-model="perm.read"
hide-details
color="primary"
></v-checkbox>
</div>
</td>
<td class="text-center kol-aksi">
<div class="cek-wrapper">
<v-checkbox
v-model="perm.create"
hide-details
color="primary"
></v-checkbox>
</div>
</td>
<td class="text-center kol-aksi">
<div class="cek-wrapper">
<v-checkbox
v-model="perm.update"
hide-details
color="primary"
></v-checkbox>
</div>
</td>
<td class="text-center kol-aksi">
<div class="cek-wrapper">
<v-checkbox
v-model="perm.delete"
hide-details
color="primary"
></v-checkbox>
</div>
</td>
</tr>
</template>
<template v-else>
<tr v-for="(menu, index) in orderedMenus" :key="menu.name">
<td class="kol-no text-center">{{ index + 1 }}</td>
<td>{{ menu.name }}</td>
<td class="text-center kol-status">
<v-chip color="success" size="small" variant="tonal">
Mapped
</v-chip>
</td>
<td class="text-center kol-aksi">
<div class="cek-wrapper">
<v-checkbox v-model="menu.canAccess" hide-details color="primary"></v-checkbox>
</div>
</td>
<td class="text-center kol-aksi">
<div class="cek-wrapper">
<v-checkbox v-model="menu.canView" hide-details color="primary"></v-checkbox>
</div>
</td>
<td class="text-center kol-aksi">
<div class="cek-wrapper">
<v-checkbox v-model="menu.canAdd" hide-details color="primary"></v-checkbox>
</div>
</td>
<td class="text-center kol-aksi">
<div class="cek-wrapper">
<v-checkbox v-model="menu.canEdit" hide-details color="primary"></v-checkbox>
</div>
</td>
<td class="text-center kol-aksi">
<div class="cek-wrapper">
<v-checkbox v-model="menu.canDelete" hide-details color="primary"></v-checkbox>
</div>
</td>
</tr>
</template>
</tbody>
</v-table>
</v-col>
</v-row>
</v-card-text>
<v-card-actions class="d-flex justify-end pa-0 mt-4">
<v-btn
color="grey-darken-1"
variant="flat"
rounded="lg"
class="text-capitalize mr-2"
@click="$emit('cancel')"
>
Batal
</v-btn>
<v-btn
color="primary"
variant="flat"
rounded="lg"
class="text-capitalize"
@click="handleSave"
:loading="isSaving"
>
Submit
</v-btn>
</v-card-actions>
</v-card> </v-card>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch, onMounted } from 'vue'; import { computed } from 'vue';
import { useNavItemsStore } from '~/stores/navItems1'; import { useNavItemsStore } from '~/stores/navItems1';
// Define types for better readability and safety
interface HakAksesMenu {
name: string;
canAccess: boolean;
canView: boolean;
canAdd: boolean;
canEdit: boolean;
canDelete: boolean;
}
interface BackendPermissionItem {
id: number;
create: boolean;
read: boolean;
update: boolean;
disable: boolean;
delete: boolean;
active: boolean;
pagename: string;
pagesID: number;
level?: number;
sort?: number;
parent?: number;
}
interface HakAksesData {
id: number;
role?: string;
group?: string;
namaTipeUser: string;
hakAksesMenu: HakAksesMenu[];
}
// Define props with type validation
const props = defineProps({ const props = defineProps({
item: { pages: {
type: Object as () => HakAksesData, type: Array as () => any[], // Be flexible with legacy data
required: true, default: () => []
}, }
}); });
// Define emits for clarity const emit = defineEmits(['update:pages']);
const emits = defineEmits(['save', 'cancel']);
// Use a local copy to avoid mutating the prop directly
const localItem = ref<HakAksesData>(JSON.parse(JSON.stringify(props.item)));
const backendPermissions = ref<BackendPermissionItem[]>([]);
const isSaving = ref(false);
const navItemsStore = useNavItemsStore(); const navItemsStore = useNavItemsStore();
// Helper function to normalize group name interface FlatNavItem {
const normalizeGroup = (group: string): string => { name: string;
const normalized = group.trim(); path: string;
// Jika group mengandung "Instalasi STIM", ambil hanya "STIM" icon: string;
if (normalized.toLowerCase().includes('instalasi')) { level: number;
const parts = normalized.split(/\s+/); }
const stimIndex = parts.findIndex(p => p.toLowerCase() === 'stim');
if (stimIndex !== -1) {
return 'STIM';
}
}
// Jika group adalah "Instalasi STIM", return "STIM"
if (normalized.toLowerCase() === 'instalasi stim') {
return 'STIM';
}
return normalized.toUpperCase();
};
// Helper function to normalize role name const flatNavItems = computed(() => {
const normalizeRole = (role: string): string => { const result: FlatNavItem[] = [];
const normalized = role.toLowerCase().trim();
// Mapping khusus untuk role default
if (normalized === 'default-roles-sandbox') {
return 'superadmin';
}
return normalized;
};
// Fetch permissions from backend API when component mounts const walk = (items: any[], level = 0) => {
onMounted(async () => { items.forEach(item => {
if (localItem.value.role && localItem.value.group) { result.push({
try { name: item.name,
// Normalize role and group before making API call path: item.path,
const normalizedRole = normalizeRole(localItem.value.role); icon: item.icon,
const normalizedGroup = normalizeGroup(localItem.value.group); level
console.log('🔄 Fetching permissions with normalized values:', {
originalRole: localItem.value.role,
normalizedRole,
originalGroup: localItem.value.group,
normalizedGroup,
}); });
if (item.children && item.children.length > 0) {
const response = await $fetch<any>('/api/permission', { walk(item.children, level + 1);
query: {
roles: normalizedRole,
groups: normalizedGroup,
},
});
if (response && response.data && Array.isArray(response.data)) {
console.log(`✅ Received ${response.data.length} permissions from API`);
// Create a deep copy to avoid mutating the original
backendPermissions.value = response.data.map((perm: BackendPermissionItem) => ({
...perm,
}));
} else {
console.warn('⚠️ No data received from API or invalid structure');
} }
} catch (error) {
console.error('❌ Error fetching permissions:', error);
}
} else {
console.warn('⚠️ Role or group is missing:', {
role: localItem.value.role,
group: localItem.value.group,
}); });
} };
});
// Sort permissions: level 1 first, then level 2 grouped under their parents
const sortedPermissions = computed(() => {
if (backendPermissions.value.length === 0) return [];
const level1 = backendPermissions.value.filter(p => p.level === 1);
const level2 = backendPermissions.value.filter(p => p.level === 2);
// Sort level 1 by sort order
level1.sort((a, b) => (a.sort || 0) - (b.sort || 0));
// Sort level 2 by sort order
level2.sort((a, b) => (a.sort || 0) - (b.sort || 0));
// Build result: insert level 2 items after their parent
const result: BackendPermissionItem[] = [];
level1.forEach(parent => {
result.push(parent);
// Add children of this parent
const children = level2.filter(child => child.parent === parent.pagesID);
result.push(...children);
});
// Add any remaining level 2 items that don't have a parent match
const addedChildren = new Set(level2.filter(c => c.parent && level1.some(p => p.pagesID === c.parent)).map(c => c.id));
const remainingChildren = level2.filter(c => !addedChildren.has(c.id));
result.push(...remainingChildren);
walk(navItemsStore.getNavItems);
return result; return result;
}); });
// Cek apakah suatu pagename dari backend sudah termapping ke salah satu menu di hakAksesMenu const isAllowed = (path: string) => {
const isPageMapped = (pagename: string): boolean => { if (!props.pages || !Array.isArray(props.pages)) return false;
if (!localItem.value.hakAksesMenu || localItem.value.hakAksesMenu.length === 0) {
return props.pages.some(p => {
if (typeof p === 'string') return p === path;
if (p && typeof p === 'object' && p.path) return p.path === path;
return false; return false;
}
const lowerPage = (pagename || '').toLowerCase();
return localItem.value.hakAksesMenu.some((menu) => {
const name = (menu.name || '').toLowerCase();
return (
name === lowerPage ||
name.includes(lowerPage) ||
lowerPage.includes(name)
);
}); });
}; };
interface NavItemOrder { const toggleAccess = (item: FlatNavItem, allowed: boolean) => {
name: string; let newPages = [...props.pages];
children?: NavItemOrder[];
}
// Build a map of menu order based on the sidebar configuration so the list is always aligned if (allowed) {
const menuOrder = computed(() => { if (!newPages.includes(item.path)) {
const order: Record<string, number> = {}; newPages.push(item.path);
const walk = (items: NavItemOrder[], startIndex = 0): number => {
let idx = startIndex;
items.forEach((item) => {
order[item.name] = idx;
idx += 1;
if (item.children?.length) {
idx = walk(item.children, idx);
}
});
return idx;
};
walk(navItemsStore.navItems);
return order;
});
const orderedMenus = computed(() => {
const order = menuOrder.value;
return [...localItem.value.hakAksesMenu].sort((a, b) => {
const orderA = order[a.name] ?? Number.MAX_SAFE_INTEGER;
const orderB = order[b.name] ?? Number.MAX_SAFE_INTEGER;
return orderA - orderB;
});
});
// Handle save - convert backend permissions back to menu structure if needed
const handleSave = async () => {
isSaving.value = true;
try {
// If we have backend permissions, we need to map them back to the menu structure
if (backendPermissions.value.length > 0) {
// Update the local item with backend permissions mapped to menu structure
const updatedItem = {
...localItem.value,
backendPermissions: backendPermissions.value,
};
emits('save', updatedItem);
} else {
// Use existing menu structure
emits('save', localItem.value);
} }
} finally { } else {
isSaving.value = false; newPages = newPages.filter(p => p !== item.path);
} }
emit('update:pages', newPages);
}; };
// Watch the prop for changes and update the local copy
watch(() => props.item, (newItem) => {
localItem.value = JSON.parse(JSON.stringify(newItem));
// Re-fetch permissions if role/group changed
if (newItem.role && newItem.group) {
// Normalize role and group before making API call
const normalizedRole = normalizeRole(newItem.role);
const normalizedGroup = normalizeGroup(newItem.group);
console.log('🔄 Re-fetching permissions with normalized values:', {
originalRole: newItem.role,
normalizedRole,
originalGroup: newItem.group,
normalizedGroup,
});
$fetch<any>('/api/permission', {
query: {
roles: normalizedRole,
groups: normalizedGroup,
},
}).then(response => {
if (response && response.data && Array.isArray(response.data)) {
console.log(`✅ Received ${response.data.length} permissions from API`);
backendPermissions.value = response.data.map((perm: BackendPermissionItem) => ({
...perm,
}));
}
}).catch(error => {
console.error('❌ Error fetching permissions:', error);
});
}
}, { deep: true });
</script> </script>
<style scoped> <style scoped>
.v-table :deep(th) { .bg-grey-lighten-4 {
font-weight: bold !important; background-color: #f5f5f5;
background-color: #f9fafb !important;
}
.v-table :deep(td) {
vertical-align: middle;
}
.v-checkbox :deep(.v-selection-control__input) {
color: #2196F3 !important;
}
.hak-akses-table :deep(.kol-no) {
width: 56px;
}
.hak-akses-table :deep(.kol-aksi) {
width: 90px;
}
.hak-akses-table :deep(.kol-status) {
width: 120px;
}
.cek-wrapper {
display: flex;
justify-content: center;
align-items: center;
} }
</style> </style>
+138 -19
View File
@@ -1,19 +1,30 @@
<template> <template>
<v-card <v-card
class="pa-4 rounded-lg elevation-2 d-flex flex-column" class="rounded-lg elevation-2 d-flex flex-column ticket-card"
color="white" color="white"
style="border-top: 8px solid #FFB95F;"
height="100%" height="100%"
> >
<div class="d-flex align-center justify-center mb-4"> <!-- Header dengan background biru -->
<v-icon size="36" color="orange-lighten-2" class="mr-2">mdi-hospital-box-outline</v-icon> <v-card-title class="ticket-header">
<div class="text-h6 font-weight-bold text-orange-lighten-2">{{ title }}</div> <div class="ticket-header-content">
</div> <v-icon color="white" class="mr-2">mdi-hospital-box-outline</v-icon>
<span class="ticket-header-title">{{ cleanTitle }}</span>
<v-chip
v-if="extractedQueueNumber"
size="small"
class="ml-2 ticket-header-chip"
color="primary-700"
>
Antrean: {{ extractedQueueNumber }}
</v-chip>
</div>
</v-card-title>
<v-divider class="mb-4"></v-divider> <!-- Content -->
<v-card-text class="ticket-content pa-4">
<div class="timeline-scroll-container"> <div class="timeline-scroll-container flex-grow-1">
<v-timeline density="compact" align="start" line-inset="12" line-color="#FFB95F"> <v-timeline density="compact" align="start" line-inset="12" line-color="#567EE7">
<v-timeline-item <v-timeline-item
v-for="(step, index) in steps" v-for="(step, index) in steps"
:key="index" :key="index"
@@ -25,11 +36,11 @@
<div class="d-flex flex-column align-start"> <div class="d-flex flex-column align-start">
<span <span
class="font-weight-bold" class="font-weight-bold"
:class="index === currentStepIndex ? 'text-orange-lighten-1' : index < currentStepIndex ? 'text-success' : 'text-grey-darken-1'" :class="getStepTextClass(index)"
> >
{{ step.label }} {{ step.label }}
</span> </span>
<span class="text-caption text-grey-darken-1"> <span class="text-caption" :class="getStepDateClass(index)">
{{ step.date !== '-' ? `${step.date}, ${step.time}` : 'Menunggu' }} {{ step.date !== '-' ? `${step.date}, ${step.time}` : 'Menunggu' }}
</span> </span>
</div> </div>
@@ -42,13 +53,14 @@
<v-btn <v-btn
size="large" size="large"
variant="tonal" variant="tonal"
color="#FFA532" color="#567EE7"
class="mt-auto font-weight-bold" class="mt-auto font-weight-bold reprint-button text-secondary-500"
prepend-icon="mdi-printer" prepend-icon="mdi-printer"
block block
> >
Cetak Ulang Tiket Cetak Ulang Tiket
</v-btn> </v-btn>
</v-card-text>
</v-card> </v-card>
</template> </template>
@@ -60,12 +72,29 @@ const props = defineProps({
steps: Array, steps: Array,
color: String, color: String,
currentStepLabel: String, currentStepLabel: String,
queueNumber: String, // Nomor antrean untuk ditampilkan di chip
}); });
const currentStepIndex = computed(() => { const currentStepIndex = computed(() => {
return props.steps.findIndex(step => step.label === props.currentStepLabel); return props.steps.findIndex(step => step.label === props.currentStepLabel);
}); });
// Extract nomor antrean dari title jika queueNumber tidak disediakan
const extractedQueueNumber = computed(() => {
if (props.queueNumber) {
return props.queueNumber;
}
// Extract dari title format: "JIWA (Antrean: 1)" atau "RADIOLOGI (Antrean: 5)"
const match = props.title.match(/\(Antrean:\s*(\d+)\)/);
return match ? match[1] : null;
});
// Clean title tanpa nomor antrean untuk ditampilkan di header
const cleanTitle = computed(() => {
// Hapus bagian "(Antrean: X)" dari title
return props.title.replace(/\s*\(Antrean:\s*\d+\)/g, '').trim();
});
const getStepIcon = (index) => { const getStepIcon = (index) => {
if (index < currentStepIndex.value) { if (index < currentStepIndex.value) {
return 'mdi-check'; return 'mdi-check';
@@ -77,22 +106,47 @@ const getStepIcon = (index) => {
}; };
const getStepColor = (index) => { const getStepColor = (index) => {
// Warna berbeda berdasarkan status step (dari _colors.scss)
if (index < currentStepIndex.value) { if (index < currentStepIndex.value) {
return 'success'; return '#33A484'; // success-600 - hijau untuk completed
} else if (index === currentStepIndex.value) { } else if (index === currentStepIndex.value) {
return 'orange-lighten-1'; return '#FF8441'; // secondary-500 - orange untuk current
} }
return 'grey-lighten-1'; return '#CDD4DC'; // neutral-500 - abu-abu untuk pending
};
const getStepTextClass = (index) => {
// Warna text label sesuai dengan warna dot
if (index < currentStepIndex.value) {
return 'text-success-600'; // hijau untuk completed
} else if (index === currentStepIndex.value) {
return 'text-secondary-500'; // orange untuk current
}
return 'text-neutral-600'; // abu-abu untuk pending
};
const getStepDateClass = (index) => {
// Warna text date/time lebih terang
if (index < currentStepIndex.value) {
return 'text-success-400'; // hijau terang untuk completed
} else if (index === currentStepIndex.value) {
return 'text-secondary-400'; // orange terang untuk current
}
return 'text-neutral-500'; // abu-abu untuk pending
}; };
</script> </script>
<style scoped> <style scoped lang="scss">
@import '@/assets/scss/_colors.scss';
/* =============================================== */ /* =============================================== */
/* SCROLLABLE TIMELINE STYLES (Menggunakan Tinggi Tetap) */ /* SCROLLABLE TIMELINE STYLES (Menggunakan Tinggi Tetap) */
/* =============================================== */ /* =============================================== */
.timeline-scroll-container { .timeline-scroll-container {
/* Tinggi Tetap: Memastikan tinggi card konsisten di semua skenario responsive */ /* Flex grow untuk mengisi ruang yang tersedia */
height: 320px; flex: 1 1 auto;
min-height: 200px;
max-height: 400px;
/* Membuat konten scrollable di sumbu Y jika melebihi height */ /* Membuat konten scrollable di sumbu Y jika melebihi height */
overflow-y: auto; overflow-y: auto;
@@ -127,4 +181,69 @@ const getStepColor = (index) => {
.v-timeline-item :deep(.v-timeline-item__opposite) { .v-timeline-item :deep(.v-timeline-item__opposite) {
display: none; display: none;
} }
/* =============================================== */
/* TICKET CARD - FIXED HEIGHT */
/* =============================================== */
.ticket-card {
min-height: 550px !important;
display: flex !important;
flex-direction: column !important;
overflow: hidden;
}
/* =============================================== */
/* TICKET HEADER */
/* =============================================== */
.ticket-header {
background: linear-gradient(135deg, $primary-600 0%, $primary-700 100%);
color: $neutral-100;
padding: 16px 20px;
box-shadow: 0 2px 8px rgba(58, 97, 201, 0.2);
border-radius: 8px 8px 0 0;
}
.ticket-header-content {
display: flex;
align-items: center;
font-weight: 700;
font-size: 16px;
gap: 8px;
width: 100%;
}
.ticket-header-title {
flex: 1;
color: $neutral-100;
font-weight: 700;
}
.ticket-header-chip {
color: $neutral-100 !important;
font-weight: 600;
font-size: 12px;
}
/* =============================================== */
/* TICKET CONTENT */
/* =============================================== */
.ticket-content {
flex: 1;
display: flex;
flex-direction: column;
padding: 24px !important;
}
/* =============================================== */
/* REPRINT BUTTON - FIXED SIZE */
/* =============================================== */
.reprint-button {
min-height: 48px !important;
height: 48px !important;
max-height: 48px !important;
font-size: 14px !important;
letter-spacing: 0.5px;
flex-shrink: 0 !important;
margin-top: auto !important;
}
</style> </style>
+20
View File
@@ -0,0 +1,20 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
// Basic test to verify Vitest is working
describe('Vitest Setup', () => {
it('works correctly', () => {
expect(1 + 1).toBe(2)
})
})
// Basic test for Vue component mounting (optional, depends on having a simple component)
/*
import MyComponent from './MyComponent.vue'
describe('Component Test', () => {
it('mounts properly', () => {
const wrapper = mount(MyComponent)
expect(wrapper.exists()).toBe(true)
})
})
*/
+11 -1
View File
@@ -96,7 +96,7 @@
</div> </div>
<div class="text-right"> <div class="text-right">
<p class="text-caption patient-info-label mb-0">Waktu Scan</p> <p class="text-caption patient-info-label mb-0">Waktu Scan</p>
<p class="text-body-2 patient-info-value mb-0">{{ new Date().toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' }) }}</p> <p class="text-body-2 patient-info-value mb-0">{{ getCurrentTime() }}</p>
</div> </div>
</div> </div>
</v-card-text> </v-card-text>
@@ -164,6 +164,16 @@ const handleClose = () => {
emit('close') emit('close')
emit('update:modelValue', false) emit('update:modelValue', false)
} }
const getCurrentTime = () => {
// Format jam dengan titik dua (HH:MM:SS)
const timeString = new Date().toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
return timeString.replace(/\./g, ':')
}
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
+261
View File
@@ -0,0 +1,261 @@
<template>
<table class="ticket-table" cellpadding="0" cellspacing="0">
<tr>
<td class="ticket-content">
<div class="header">
<div class="hospital-name">RSUD DR. SAIFUL ANWAR</div>
<div class="hospital-address">Jl. Jaksa Agung Suprapto No.2, Malang</div>
<div class="ticket-title">Tiket Antrean</div>
</div>
<div class="no-antrian">{{ noAntrianDisplay }}</div>
<div class="klinik-name">{{ ruangInfo || klinik }}</div>
<div class="qr-code-section">
<div class="qr-code">
<img :src="qrCodeImage" alt="QR Code" />
</div>
<div class="barcode-number">{{ barcode }}</div>
</div>
<div class="info-section">
<div class="info-row">
<div class="info-label-wrapper">
<span class="info-label-text">Tanggal</span><span class="info-colon">:</span>
</div>
<span class="info-value">{{ tanggal }}</span>
</div>
<div class="info-row">
<div class="info-label-wrapper">
<span class="info-label-text">Waktu</span><span class="info-colon">:</span>
</div>
<span class="info-value">{{ waktu }}</span>
</div>
<div class="info-row">
<div class="info-label-wrapper">
<span class="info-label-text">Shift</span><span class="info-colon">:</span>
</div>
<span class="info-value">{{ shift }}</span>
</div>
<div class="info-row">
<div class="info-label-wrapper">
<span class="info-label-text">Pembayaran</span><span class="info-colon">:</span>
</div>
<span class="info-value">{{ pembayaran }}</span>
</div>
<div v-if="namaDokter" class="info-row">
<div class="info-label-wrapper">
<span class="info-label-text">Dokter</span><span class="info-colon">:</span>
</div>
<span class="info-value">{{ namaDokter }}</span>
</div>
</div>
<div class="footer">
<div class="footer-text">Tunjukkan tiket ini saat check-in</div>
</div>
</td>
</tr>
</table>
</template>
<script setup lang="ts">
import type { ThermalPrintData } from '@/composables/useThermalPrint';
interface Props {
data: ThermalPrintData;
qrCodeImage: string;
noAntrianDisplay: string;
ruangInfo: string;
}
const props = defineProps<Props>();
const {
barcode,
klinik,
shift,
pembayaran,
tanggal,
waktu,
namaDokter
} = props.data;
</script>
<style scoped>
.ticket-table {
width: 100%;
border-collapse: collapse;
border: 2px dashed #000;
margin: 0;
background: white;
}
.ticket-table td {
padding: 0;
border: none;
}
.ticket-content {
padding: 2mm 4mm 4mm 4mm;
text-align: center;
}
.header {
margin-bottom: 2mm;
border-bottom: 2px solid #000;
padding-bottom: 1mm;
margin-top: 0;
}
.hospital-name {
font-size: 14px;
font-weight: bold;
margin-bottom: 0.5mm;
text-transform: uppercase;
letter-spacing: 1px;
line-height: 1.0;
}
.hospital-address {
font-size: 10px;
font-weight: bold;
margin-bottom: 0.5mm;
line-height: 1.0;
}
.ticket-title {
font-size: 12px;
font-weight: bold;
margin-top: 1mm;
text-transform: uppercase;
line-height: 1.0;
}
.qr-code-section {
margin-top: 0;
margin-bottom: 0.5mm;
text-align: center;
}
.qr-code {
width: 45mm;
height: 45mm;
margin: 0 auto;
border: none;
padding: 0.5mm;
background: white;
}
.qr-code img {
width: 100%;
height: 100%;
display: block;
}
.barcode-number {
font-size: 10px;
font-weight: bold;
margin-top: -0.5mm;
word-break: break-all;
line-height: 1.0;
}
.info-section {
margin-top: 2mm;
text-align: left;
border-top: 1px dashed #000;
padding-top: 1mm;
padding-left: 3mm;
padding-right: 3mm;
}
.info-row {
margin-bottom: 0.8mm;
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 0;
line-height: 1.0;
}
.info-label-text {
font-weight: bold;
font-size: 10px;
text-align: left;
text-transform: uppercase;
word-break: keep-all;
line-height: 1.0;
}
.info-colon {
font-weight: bold;
font-size: 10px;
margin-left: auto;
padding-right: 2mm;
line-height: 1.0;
}
.info-label-wrapper {
display: flex;
width: 22mm;
flex-shrink: 0;
align-items: flex-start;
gap: 1mm;
}
.info-value {
font-size: 10px;
font-weight: bold;
text-align: right;
word-break: break-word;
line-height: 1.0;
}
.no-antrian {
font-size: 28px;
font-weight: bold;
margin: 0.3mm 0;
letter-spacing: 2px;
text-transform: uppercase;
line-height: 1.0;
}
.klinik-name {
font-size: 12px;
font-weight: bold;
margin-top: 0.3mm;
margin-bottom: 2mm;
padding-bottom: 0;
text-transform: uppercase;
overflow: visible;
line-height: 1.0;
}
.footer {
margin-top: 2mm;
padding-top: 1mm;
padding-bottom: 0;
margin-bottom: 6mm;
border-top: 2px solid #000;
font-size: 10px;
font-weight: bold;
text-align: center;
line-height: 1.0;
}
.footer-text {
margin-bottom: 0.5mm;
font-weight: bold;
line-height: 1.0;
}
.footer-text:last-child {
margin-bottom: 0;
}
</style>
+40
View File
@@ -0,0 +1,40 @@
<template>
<v-avatar :size="size" :class="avatarClass">
<v-img
v-if="src"
:src="src"
:alt="alt"
:lazy-src="defaultAvatar"
></v-img>
<v-img
v-else
:src="defaultAvatar"
:alt="alt"
></v-img>
</v-avatar>
</template>
<script setup>
import { DEFAULT_AVATAR } from '@/constants/avatar'
const props = defineProps({
src: {
type: String,
default: null
},
alt: {
type: String,
default: 'User Avatar'
},
size: {
type: [Number, String],
default: 44
},
avatarClass: {
type: String,
default: ''
}
})
const defaultAvatar = DEFAULT_AVATAR
</script>
+68 -37
View File
@@ -3,7 +3,16 @@
<div class="header-content"> <div class="header-content">
<div class="header-left"> <div class="header-left">
<div class="header-icon"> <div class="header-icon">
<v-icon size="32" color="white">{{ icon }}</v-icon> <NuxtImg
v-if="logo"
:src="logo"
:alt="title"
width="48"
height="48"
class="header-logo"
loading="eager"
/>
<v-icon v-else size="28" color="white">{{ icon }}</v-icon>
</div> </div>
<div class="header-text"> <div class="header-text">
<h2 class="page-title">{{ title }}</h2> <h2 class="page-title">{{ title }}</h2>
@@ -33,6 +42,10 @@ defineProps({
type: String, type: String,
default: 'mdi-view-dashboard' default: 'mdi-view-dashboard'
}, },
logo: {
type: String,
default: ''
},
title: { title: {
type: String, type: String,
required: true required: true
@@ -51,8 +64,8 @@ defineProps({
}, },
theme: { theme: {
type: String, type: String,
default: 'primary', // 'primary', 'secondary', 'success', or 'accent' default: 'primary', // 'primary', 'secondary', 'success', 'accent', or 'warning'
validator: (value) => ['primary', 'secondary', 'success', 'accent'].includes(value) validator: (value) => ['primary', 'secondary', 'success', 'accent', 'warning'].includes(value)
} }
}); });
@@ -60,49 +73,48 @@ defineEmits(['add-click']);
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
$neutral-100: #FFFFFF;
$primary-600: #FFA532;
$primary-700: #FF9B1B;
$secondary-600: #0671E0;
$secondary-700: #0053AD;
$success-600: #009262;
$success-700: #1B6E53;
$accent-600: #8B5CF6;
$accent-700: #7C3AED;
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; $font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-weight-regular: 400; $font-weight-regular: 400;
$font-weight-semibold: 600; $font-weight-semibold: 600;
.page-header { .page-header {
border-radius: 16px 16px 0 0; border-radius: 0;
} }
.page-header-primary { .page-header-primary {
background: linear-gradient(135deg, $primary-600 0%, $primary-700 100%); background: linear-gradient(135deg, var(--color-primary-600) 0%, var(--color-primary-700) 100%);
box-shadow: 0 4px 16px rgba(255, 165, 50, 0.2); box-shadow: 0 4px 16px rgba(58, 97, 201, 0.2);
} }
.page-header-secondary { .page-header-secondary {
background: linear-gradient(135deg, $secondary-600 0%, $secondary-700 100%); background: linear-gradient(135deg, var(--color-secondary-600) 0%, var(--color-secondary-700) 100%);
box-shadow: 0 4px 16px rgba(6, 113, 224, 0.2); box-shadow: 0 4px 16px rgba(241, 111, 41, 0.2);
} }
.page-header-success { .page-header-success {
background: linear-gradient(135deg, $success-600 0%, $success-700 100%); background: linear-gradient(135deg, var(--color-success-600) 0%, var(--color-success-700) 100%);
box-shadow: 0 4px 16px rgba(0, 146, 98, 0.2); box-shadow: 0 4px 16px rgba(51, 164, 132, 0.2);
} }
.page-header-accent { .page-header-accent {
background: linear-gradient(135deg, $accent-600 0%, $accent-700 100%); // Accent tidak ada di design system, menggunakan secondary sebagai fallback
box-shadow: 0 4px 16px rgba(139, 92, 246, 0.3); background: linear-gradient(135deg, var(--color-secondary-500) 0%, var(--color-secondary-600) 100%);
box-shadow: 0 4px 16px rgba(255, 132, 65, 0.2);
}
.page-header-warning {
// Warning tidak ada di design system, menggunakan secondary (orange) sebagai fallback
background: linear-gradient(135deg, var(--color-secondary-600) 0%, var(--color-secondary-700) 100%);
box-shadow: 0 4px 16px rgba(241, 111, 41, 0.2);
} }
.header-content { .header-content {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 32px; padding: 16px 28px;
color: $neutral-100; height: 80px;
color: var(--color-neutral-100);
} }
.header-left { .header-left {
@@ -112,28 +124,41 @@ $font-weight-semibold: 600;
.header-icon { .header-icon {
background: rgba(255, 255, 255, 0.2); background: rgba(255, 255, 255, 0.2);
border-radius: 16px; border-radius: 12px;
padding: 16px; padding: 12px;
margin-right: 20px; margin-right: 16px;
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: center;
overflow: visible;
}
.header-logo {
width: 52px;
height: 52px;
object-fit: contain;
border-radius: 50%;
max-width: 100%;
max-height: 100%;
} }
.page-title { .page-title {
font-size: 36px; font-size: 32px;
line-height: 44px; line-height: 40px;
font-weight: $font-weight-semibold; font-weight: $font-weight-semibold;
margin: 0; margin: 0;
color: $neutral-100; color: var(--color-neutral-100);
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
} }
.page-subtitle { .page-subtitle {
margin: 4px 0 0 0; margin: 2px 0 0 0;
opacity: 0.9; opacity: 0.9;
font-size: 16px; font-size: 15px;
line-height: 24px; line-height: 22px;
font-weight: $font-weight-regular; font-weight: $font-weight-regular;
color: $neutral-100; color: var(--color-neutral-100);
} }
.add-btn { .add-btn {
@@ -145,18 +170,24 @@ $font-weight-semibold: 600;
} }
.add-btn-primary { .add-btn-primary {
color: $primary-600 !important; color: var(--color-primary-600) !important;
} }
.add-btn-secondary { .add-btn-secondary {
color: $secondary-600 !important; color: var(--color-secondary-600) !important;
} }
.add-btn-success { .add-btn-success {
color: $success-600 !important; color: var(--color-success-600) !important;
} }
.add-btn-accent { .add-btn-accent {
color: $accent-600 !important; // Accent tidak ada di design system, menggunakan secondary sebagai fallback
color: var(--color-secondary-500) !important;
}
.add-btn-warning {
// Warning tidak ada di design system, menggunakan secondary (orange) sebagai fallback
color: var(--color-secondary-600) !important;
} }
</style> </style>
+48 -16
View File
@@ -1,14 +1,16 @@
<template> <template>
<v-dialog v-model="dialogModel" max-width="800px"> <v-dialog v-model="dialogModel" max-width="800px" persistent>
<v-card> <v-card class="dialog-card">
<v-card-title class="dialog-header"> <v-card-title class="dialog-header">
<v-btn icon size="small" @click="dialogModel = false"> <span class="headline-4">{{ title }}</span>
<v-btn icon variant="text" size="small" class="btn-close" @click="dialogModel = false">
<v-icon>mdi-close</v-icon> <v-icon>mdi-close</v-icon>
</v-btn> </v-btn>
<span>{{ title }}</span>
</v-card-title> </v-card-title>
<v-card-text class="pa-4"> <v-divider />
<v-card-text class="dialog-content">
<v-text-field <v-text-field
v-model="searchModel" v-model="searchModel"
:placeholder="searchPlaceholder" :placeholder="searchPlaceholder"
@@ -94,32 +96,62 @@ const handleSelect = (item) => {
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.dialog-card {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.dialog-header { .dialog-header {
background: var(--color-neutral-300); background: linear-gradient(135deg, var(--color-secondary-600) 0%, var(--color-secondary-700) 100%);
padding: 16px 20px; color: var(--color-neutral-100);
padding: 20px 24px;
display: flex; display: flex;
justify-content: space-between;
align-items: center; align-items: center;
gap: 12px; }
font-size: 18px;
font-weight: 700; .headline-4 {
color: var(--color-neutral-900); font-size: 20px;
line-height: 28px;
font-weight: 600;
margin: 0;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.btn-close {
color: var(--color-neutral-100) !important;
transition: all 0.2s ease;
}
.btn-close:hover {
background: rgba(255, 255, 255, 0.2) !important;
transform: rotate(90deg);
}
.dialog-content {
padding: 24px !important;
background: var(--color-neutral-300);
} }
.selection-card { .selection-card {
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s ease;
border: 2px solid var(--color-neutral-500); border: 1px solid var(--color-neutral-400);
background: var(--color-neutral-100); background: var(--color-neutral-100);
border-radius: 8px;
} }
.selection-card:hover { .selection-card:hover {
border-color: var(--color-secondary-600); border-color: var(--color-secondary-600);
background: var(--color-secondary-200); background: var(--color-secondary-300);
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(6, 113, 224, 0.15);
} }
.selection-name { .selection-name {
font-size: 13px; font-size: 14px;
font-weight: 700; line-height: 20px;
font-weight: 600;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
color: var(--color-neutral-900); color: var(--color-neutral-900);
} }
</style> </style>
@@ -0,0 +1,114 @@
<script setup>
/**
* DialogDetailEvent — Dialog untuk menampilkan detail jadwal dokter
* yang diklik di kalender. Mendukung tampilan Rutin dan Adhoc.
* Menggunakan v-model pattern untuk kontrol buka/tutup dialog dari parent.
*/
const props = defineProps({
/** Kontrol buka/tutup dialog (v-model) */
modelValue: { type: Boolean, default: false },
/** Data event yang sedang ditampilkan */
event: { type: Object, default: null },
});
const emit = defineEmits(['update:modelValue', 'delete']);
/** Computed v-model proxy untuk dialog visibility */
const dialogOpen = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
});
/**
* Emit event delete ke parent untuk menghapus jadwal.
*/
const handleDelete = () => {
emit('delete', props.event?.id);
dialogOpen.value = false;
};
</script>
<template>
<v-dialog v-model="dialogOpen" max-width="420px">
<v-card rounded="lg" class="pa-0">
<v-card-title class="pa-5" :class="event?.tipe === 'Adhoc' ? 'bg-secondary' : 'bg-primary'"
style="color:white;">
<div class="d-flex justify-space-between align-center w-100">
<div class="d-flex align-center gap-2">
<v-icon>{{ event?.tipe === 'Adhoc' ? 'mdi-calendar-clock' : 'mdi-calendar-sync'
}}</v-icon>
<span class="ml-2 text-h6 font-weight-bold">Jadwal {{ event?.tipe }}</span>
</div>
<v-btn icon="mdi-close" variant="text" size="small" style="color:white;"
@click="dialogOpen = false" />
</div>
</v-card-title>
<v-card-text class="pa-6" v-if="event">
<v-list density="compact" class="pa-0">
<v-list-item class="px-0">
<template #prepend>
<v-icon color="primary" size="small" class="mr-3">mdi-hospital-building</v-icon>
</template>
<div class="text-caption text-grey-darken-1">Poli / Klinik</div>
<div class="text-body-2 font-weight-bold">{{ event.poli }}</div>
</v-list-item>
<v-divider class="my-2" />
<v-list-item class="px-0">
<template #prepend>
<v-icon color="primary" size="small" class="mr-3">mdi-doctor</v-icon>
</template>
<div class="text-caption text-grey-darken-1">Dokter</div>
<div class="text-body-2 font-weight-bold">{{ event.dokter }}</div>
</v-list-item>
<v-divider class="my-2" />
<v-list-item class="px-0">
<template #prepend>
<v-icon color="primary" size="small" class="mr-3">mdi-calendar</v-icon>
</template>
<div class="text-caption text-grey-darken-1">Tanggal</div>
<div class="text-body-2 font-weight-bold">{{ event.tanggal }}</div>
</v-list-item>
<!-- Rutin: tampilkan hari -->
<template v-if="event.tipe === 'Rutin' && event.hari">
<v-divider class="my-2" />
<v-list-item class="px-0">
<template #prepend>
<v-icon color="primary" size="small" class="mr-3">mdi-calendar-week</v-icon>
</template>
<div class="text-caption text-grey-darken-1">Hari Praktek</div>
<div class="text-body-2 font-weight-bold">{{ event.hari }}</div>
</v-list-item>
</template>
<!-- Adhoc: tampilkan shift -->
<template v-if="event.tipe === 'Adhoc' && event.shift">
<v-divider class="my-2" />
<v-list-item class="px-0">
<template #prepend>
<v-icon color="secondary" size="small" class="mr-3">mdi-clock-outline</v-icon>
</template>
<div class="text-caption text-grey-darken-1">Waktu</div>
<div class="text-body-2 font-weight-bold">{{ event.shift }}</div>
</v-list-item>
</template>
</v-list>
</v-card-text>
<v-divider />
<v-card-actions class="pa-5 justify-space-between">
<v-btn color="error" variant="text" prepend-icon="mdi-trash-can-outline" @click="handleDelete">
Hapus
</v-btn>
<v-btn color="primary" variant="flat" @click="dialogOpen = false">Tutup</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
@@ -0,0 +1,336 @@
<script setup>
/**
* DialogTambahJadwal — Dialog form untuk menambahkan jadwal dokter baru.
* Mendukung dua tipe jadwal: Rutin (mingguan berulang) dan Adhoc (satu kali).
* Menggunakan v-model pattern untuk kontrol buka/tutup dialog dari parent.
*/
const DAYS_OF_WEEK = ['Minggu', 'Senin', 'Selasa', 'Rabu', 'Kamis', 'Jumat', 'Sabtu'];
const props = defineProps({
/** Kontrol buka/tutup dialog (v-model) */
modelValue: { type: Boolean, default: false },
/** Daftar klinik untuk referensi nama poli */
clinicList: { type: Array, default: () => [] },
/** ID poli yang sedang dipilih */
selectedPoli: { type: [String, Number, null], default: null },
/** Nama dokter yang sedang dipilih */
selectedDoctor: { type: [String, null], default: null },
/** Tanggal yang diklik di kalender (opsional, sebagai default adhoc) */
clickedDate: { type: [Date, null], default: null },
/** Mode tampilan saat ini ('list' atau 'calendar') */
viewMode: { type: String, default: 'list' },
/** Daftar jadwal saat ini (untuk preview kalender) */
jadwalList: { type: Array, default: () => [] },
});
const emit = defineEmits(['update:modelValue', 'save']);
/** Computed v-model proxy untuk dialog visibility */
const dialogOpen = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
});
/** State form tambah jadwal */
const form = reactive({
poliId: null,
dokter: null,
tipe: 'Rutin',
hari: '',
shifts: [{ jamMulai: '', jamSelesai: '' }],
adhocTanggal: '',
adhocShifts: [{ jamMulai: '', jamSelesai: '' }],
});
/** Computed daftar dokter berdasarkan poli yang dipilih */
const availableDoctors = computed(() => {
if (!form.poliId) return [];
const clinic = props.clinicList.find(c => c.id === form.poliId);
return clinic?.doctors || [];
});
/** Error validasi form */
const formErrors = ref({});
/**
* Format tanggal ke string YYYY-MM-DD.
* @param {Date} date
* @returns {string}
*/
const toDateStr = (date) => {
const d = new Date(date);
const tzOffset = d.getTimezoneOffset() * 60000;
return new Date(d.getTime() - tzOffset).toISOString().slice(0, 10);
};
/** Reset form saat dialog dibuka */
watch(() => props.modelValue, (open) => {
if (open) {
if (props.viewMode === 'calendar') {
form.poliId = props.selectedPoli || null;
form.dokter = props.selectedDoctor || null;
} else {
form.poliId = null;
form.dokter = null;
}
form.tipe = 'Rutin';
form.hari = '';
form.shifts = [{ jamMulai: '', jamSelesai: '' }];
formErrors.value = {};
if (props.clickedDate) {
const dateStr = toDateStr(props.clickedDate);
form.adhocTanggal = dateStr;
form.adhocShifts = [{ jamMulai: '08:00', jamSelesai: '12:00' }];
} else {
const dateStr = toDateStr(new Date());
form.adhocTanggal = dateStr;
form.adhocShifts = [{ jamMulai: '08:00', jamSelesai: '12:00' }];
}
}
});
/**
* Validasi form sebelum simpan.
* @returns {boolean} true jika valid
*/
const validateForm = () => {
const errors = {};
if (!form.poliId) errors.poli = 'Pilih poli terlebih dahulu';
if (!form.dokter) errors.dokter = 'Pilih dokter terlebih dahulu';
if (form.tipe === 'Rutin') {
if (!form.hari) errors.hari = 'Pilih hari kerja';
form.shifts.forEach((shift, idx) => {
if (!shift.jamMulai) errors[`shift_${idx}_jamMulai`] = 'Pilih jam mulai';
if (!shift.jamSelesai) errors[`shift_${idx}_jamSelesai`] = 'Pilih jam selesai';
if (shift.jamMulai && shift.jamSelesai && shift.jamMulai >= shift.jamSelesai) {
errors[`shift_${idx}_jamSelesai`] = 'Harus setelah jam mulai';
}
});
} else {
if (!form.adhocTanggal) errors.adhocTanggal = 'Pilih tanggal';
form.adhocShifts.forEach((shift, idx) => {
if (!shift.jamMulai) errors[`adhocShift_${idx}_jamMulai`] = 'Pilih jam mulai';
if (!shift.jamSelesai) errors[`adhocShift_${idx}_jamSelesai`] = 'Pilih jam selesai';
if (shift.jamMulai && shift.jamSelesai && shift.jamMulai >= shift.jamSelesai) {
errors[`adhocShift_${idx}_jamSelesai`] = 'Harus setelah jam mulai';
}
});
}
formErrors.value = errors;
return Object.keys(errors).length === 0;
};
/**
* Menangani submit form — validasi lalu emit data ke parent.
*/
const handleSave = () => {
if (!validateForm()) return;
const poliName = props.clinicList.find(c => c.id === form.poliId)?.title ?? '';
const emittedData = [];
if (form.tipe === 'Rutin') {
form.shifts.forEach((shift, index) => {
emittedData.push({
id: Date.now() + index,
poliId: form.poliId,
poliName,
dokter: form.dokter,
tipe: 'Rutin',
hari: [form.hari],
jamMulai: shift.jamMulai || null,
jamSelesai: shift.jamSelesai || null,
});
});
} else {
form.adhocShifts.forEach((shift, index) => {
emittedData.push({
id: Date.now() + index,
poliId: form.poliId,
poliName,
dokter: form.dokter,
tipe: 'Adhoc',
mulai: `${form.adhocTanggal}T${shift.jamMulai}:00`,
selesai: `${form.adhocTanggal}T${shift.jamSelesai}:00`,
});
});
}
emit('save', emittedData);
dialogOpen.value = false;
};
import MiniCalendarPreview from './MiniCalendarPreview.vue';
</script>
<template>
<v-dialog v-model="dialogOpen" max-width="520px" persistent>
<v-card rounded="lg" class="pa-0">
<!-- Header -->
<v-card-title class="bg-primary text-white pa-5">
<div class="d-flex justify-space-between align-center w-100">
<div class="d-flex align-center gap-2">
<v-icon>mdi-calendar-plus</v-icon>
<span class="text-h6 font-weight-bold">Tambah Jadwal Dokter</span>
</div>
<v-btn icon="mdi-close" variant="text" size="small" class="text-white"
@click="dialogOpen = false" />
</div>
</v-card-title>
<v-card-text class="pa-6">
<!-- Tipe Jadwal -->
<div class="text-caption text-grey-darken-1 mb-2 font-weight-medium">Tipe Jadwal</div>
<v-btn-toggle v-model="form.tipe" mandatory color="primary" variant="outlined" class="mb-5 w-100"
divided>
<v-btn value="Rutin" class="flex-grow-1">
<v-icon start>mdi-calendar-sync</v-icon>
Rutin
</v-btn>
<v-btn value="Adhoc" class="flex-grow-1">
<v-icon start>mdi-calendar-clock</v-icon>
Adhoc
</v-btn>
</v-btn-toggle>
<!-- Info poli & dokter -->
<v-row class="mb-4">
<v-col cols="12" sm="12">
<div class="text-caption text-grey mb-1">Poli / Klinik <span class="text-error">*</span></div>
<v-select v-model="form.poliId" :items="clinicList" item-title="title" item-value="id"
density="compact" variant="outlined" hide-details="auto" placeholder="Pilih Poli"
prepend-inner-icon="mdi-hospital-building" @update:model-value="form.dokter = null"
:error-messages="formErrors.poli" />
</v-col>
<v-col cols="12" sm="12">
<div class="text-caption text-grey mb-1">Dokter <span class="text-error">*</span></div>
<v-select v-model="form.dokter" :items="availableDoctors" density="compact" variant="outlined"
hide-details="auto" placeholder="Pilih Dokter" prepend-inner-icon="mdi-doctor"
:disabled="!form.poliId" :error-messages="formErrors.dokter" />
</v-col>
</v-row>
<!-- Form Rutin -->
<template v-if="form.tipe === 'Rutin'">
<div class="text-caption text-grey-darken-1 mb-3 font-weight-medium">Pilih Hari Kerja</div>
<div class="d-flex flex-wrap gap-2 mb-1">
<v-chip class="mr-1" v-for="day in DAYS_OF_WEEK" :key="day"
:color="form.hari === day ? 'primary' : 'primary'"
:variant="form.hari === day ? 'flat' : 'outlined'" size="small" style="cursor:pointer;"
@click="form.hari = day">
{{ day }}
</v-chip>
</div>
<div v-if="formErrors.hari" class="text-caption text-error mb-4">{{ formErrors.hari }}</div>
<MiniCalendarPreview v-if="form.poliId && form.dokter" :tipe="form.tipe" :hari="form.hari"
:adhoc-tanggal="form.adhocTanggal" :jadwal-list="jadwalList" :poli-id="form.poliId"
:dokter="form.dokter" />
<!-- Jam required -->
<div v-for="(shift, index) in form.shifts" :key="index" class="d-flex align-center gap-4 mt-4">
<div class="flex-grow-1">
<v-row>
<v-col cols="6">
<div class="text-caption text-grey mb-1">Jam Mulai</div>
<v-text-field v-model="shift.jamMulai" type="time" variant="outlined"
density="compact" hide-details required
:error-messages="formErrors[`shift_${index}_jamMulai`]" />
<div v-if="formErrors[`shift_${index}_jamMulai`]"
class="text-caption text-error mt-1">{{ formErrors[`shift_${index}_jamMulai`] }}
</div>
</v-col>
<v-col cols="6">
<div class="text-caption text-grey mb-1">Jam Selesai</div>
<v-text-field v-model="shift.jamSelesai" type="time" variant="outlined"
density="compact" hide-details required
:error-messages="formErrors[`shift_${index}_jamSelesai`]" />
<div v-if="formErrors[`shift_${index}_jamSelesai`]"
class="text-caption text-error mt-1">{{ formErrors[`shift_${index}_jamSelesai`]
}}</div>
</v-col>
</v-row>
</div>
<div class="mt-5" v-if="form.shifts.length > 1">
<v-btn icon="mdi-trash-can-outline" variant="text" color="error" size="small"
@click="form.shifts.splice(index, 1)" />
</div>
</div>
<div class="mt-4">
<v-btn variant="outlined" color="primary" size="small" class="text-none"
@click="form.shifts.push({ jamMulai: '', jamSelesai: '' })">
+ Tambah Jadwal {{ form.hari ? `Hari ${form.hari}` : '' }}
</v-btn>
</div>
</template>
<!-- Form Adhoc -->
<template v-else>
<v-row class="mb-2">
<v-col cols="12">
<v-text-field v-model="form.adhocTanggal" label="Tanggal" type="date" variant="outlined"
density="compact" prepend-inner-icon="mdi-calendar" hide-details="auto"
:error-messages="formErrors.adhocTanggal" />
</v-col>
<v-col cols="12">
<MiniCalendarPreview v-if="form.poliId && form.dokter" :tipe="form.tipe" :hari="form.hari"
:adhoc-tanggal="form.adhocTanggal" :jadwal-list="jadwalList" :poli-id="form.poliId"
:dokter="form.dokter" />
</v-col>
</v-row>
<div v-for="(shift, index) in form.adhocShifts" :key="index" class="d-flex align-center gap-4 mt-2">
<div class="flex-grow-1">
<v-row>
<v-col cols="6">
<div class="text-caption text-grey mb-1">Jam Mulai</div>
<v-text-field v-model="shift.jamMulai" type="time" variant="outlined"
density="compact" hide-details required
:error-messages="formErrors[`adhocShift_${index}_jamMulai`]" />
<div v-if="formErrors[`adhocShift_${index}_jamMulai`]"
class="text-caption text-error mt-1">{{
formErrors[`adhocShift_${index}_jamMulai`] }}</div>
</v-col>
<v-col cols="6">
<div class="text-caption text-grey mb-1">Jam Selesai</div>
<v-text-field v-model="shift.jamSelesai" type="time" variant="outlined"
density="compact" hide-details required
:error-messages="formErrors[`adhocShift_${index}_jamSelesai`]" />
<div v-if="formErrors[`adhocShift_${index}_jamSelesai`]"
class="text-caption text-error mt-1">{{
formErrors[`adhocShift_${index}_jamSelesai`] }}</div>
</v-col>
</v-row>
</div>
<div class="mt-5" v-if="form.adhocShifts.length > 1">
<v-btn icon="mdi-trash-can-outline" variant="text" color="error" size="small"
@click="form.adhocShifts.splice(index, 1)" />
</div>
</div>
<div class="mt-4">
<v-btn variant="outlined" color="primary" size="small" class="text-none"
@click="form.adhocShifts.push({ jamMulai: '', jamSelesai: '' })">
+ Tambah Shift Adhoc
</v-btn>
</div>
</template>
</v-card-text>
<v-divider />
<v-card-actions class="pa-5 justify-end gap-2">
<v-btn variant="text" color="grey" @click="dialogOpen = false">Batal</v-btn>
<v-btn color="primary" variant="flat" prepend-icon="mdi-content-save" @click="handleSave">
Simpan Jadwal
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
@@ -0,0 +1,344 @@
<script setup>
/**
* JadwalDokterCalendar — Mode tampilan kalender untuk jadwal dokter.
* Menampilkan filter poli/dokter dan v-calendar Vuetify dengan event
* jadwal rutin (primary) dan adhoc (secondary).
* Menerima data dari parent dan emit interaksi kembali ke parent.
*/
const MONTH_NAMES = [
'Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni',
'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember'
];
const props = defineProps({
/** Daftar klinik/poli untuk filter */
clinicList: { type: Array, default: () => [] },
/** Daftar event kalender yang sudah di-generate oleh parent */
calendarEvents: { type: Array, default: () => [] },
});
const emit = defineEmits([
'update:selectedPoli',
'update:selectedDoctor',
'date-click',
'event-click',
]);
// ── Filter State (local, synced ke parent via emit) ──────────────────────
/** Poli yang dipilih di mode calendar */
const selectedPoli = defineModel('selectedPoli', { type: [String, Number, null], default: null });
/** Dokter yang dipilih di mode calendar */
const selectedDoctor = defineModel('selectedDoctor', { type: [String, null], default: null });
const selectedElement = ref(null)
const selectedOpen = ref(false)
/**
* Daftar dokter berdasarkan poli yang dipilih.
* @returns {string[]} Kosong jika poli belum dipilih
*/
const doctorList = computed(() => {
if (!selectedPoli.value) return [];
const clinic = props.clinicList.find(c => c.id === selectedPoli.value);
return clinic?.doctors ?? [];
});
/** Reset dokter saat poli berubah */
watch(selectedPoli, () => { selectedDoctor.value = null; });
// ── Navigasi Kalender ────────────────────────────────────────────────────
const isDailyEventsOpen = ref(false);
const dailyEvents = ref([]);
const selectedDateFormatted = ref('');
/** Tanggal referensi untuk navigasi bulan */
const viewDate = ref(new Date());
/**
* Key dinamis untuk v-calendar. Vuetify v-calendar punya internal navigation
* state sendiri — mengubah :model-value saja tidak cukup. Dengan mengganti :key,
* kita paksa komponen re-mount sehingga bulan yang tampil ikut berubah.
*/
const calendarKey = computed(() =>
`cal-${viewDate.value.getFullYear()}-${viewDate.value.getMonth()}`
);
/** Judul bulan & tahun aktif */
const calendarTitle = computed(() =>
`${MONTH_NAMES[viewDate.value.getMonth()]} ${viewDate.value.getFullYear()}`
);
/** Navigasi ke bulan sebelumnya */
const prevMonth = () => {
const d = new Date(viewDate.value);
d.setMonth(d.getMonth() - 1);
viewDate.value = new Date(d);
};
/** Navigasi ke bulan berikutnya */
const nextMonth = () => {
const d = new Date(viewDate.value);
d.setMonth(d.getMonth() + 1);
viewDate.value = new Date(d);
};
/**
* Menangani klik pada tanggal di kalender.
* @param {Date|string|object} info - Payload dari @click:date
*/
const handleDateClick = (info, { date }) => {
emit('date-click', date);
};
/**
* Menangani klik pada event di kalender.
* @param {object} payload - Payload dari @click:event v-calendar
*/
const handleEventClick = (nativeEvent, { event }) => {
const ev = event;
if (!ev?.extendedProps) return;
const d = ev.start instanceof Date ? ev.start : new Date(ev.start);
emit('event-click', {
...ev.extendedProps,
tanggal: d.toLocaleDateString('id-ID', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }),
});
};
/**
* Menangani klik "+X lainnya" pada kalender.
* @param {Date|string|object} info - Payload dari @click:more
*/
const handleMoreClick = (nativeEvent, { date }) => {
selectedDateFormatted.value = date;
console.log("date", date)
console.log("calendarEvents.value", props.calendarEvents)
dailyEvents.value = props.calendarEvents.filter(e =>
e.start.toISOString().split("T")[0] === selectedDateFormatted.value
);
const open = () => {
// selectedEvent.value = event
selectedElement.value = nativeEvent.target
requestAnimationFrame(() => requestAnimationFrame(() => selectedOpen.value = true))
}
if (selectedOpen.value) {
selectedOpen.value = false
requestAnimationFrame(() => requestAnimationFrame(() => open()))
} else {
open()
}
nativeEvent.stopPropagation()
};
/**
* Menangani klik pada event di dalam dialog harian.
*/
const handleDailyEventClick = (ev) => {
isDailyEventsOpen.value = false;
const payload = { event: ev };
handleEventClick(payload);
};
</script>
<template>
<div>
<!-- Filter Card -->
<v-row class="mb-2">
<v-col cols="12">
<v-card class="pa-6" color="white">
<div class="d-flex align-center mb-4">
<v-icon color="primary" class="mr-2">mdi-filter-variant</v-icon>
<h3 class="text-subtitle-1 font-weight-bold text-primary">FILTER JADWAL</h3>
</div>
<v-row>
<v-col cols="12" sm="6">
<v-select v-model="selectedPoli" :items="clinicList" item-title="title" item-value="id"
label="Pilih Poli / Klinik" variant="outlined" density="compact" clearable
prepend-inner-icon="mdi-hospital-building" hide-details />
</v-col>
<v-col cols="12" sm="6">
<v-select v-model="selectedDoctor" :items="doctorList" label="Pilih Dokter"
variant="outlined" density="compact" clearable prepend-inner-icon="mdi-doctor"
:disabled="!selectedPoli" hide-details
:placeholder="selectedPoli ? 'Pilih dokter...' : 'Pilih poli terlebih dahulu'" />
</v-col>
</v-row>
<div v-if="selectedPoli || selectedDoctor" class="mt-4 d-flex align-center gap-2 flex-wrap">
<span class="text-caption text-grey-darken-1">Filter aktif:</span>
<v-chip v-if="selectedPoli" size="small" color="primary" variant="tonal" closable
@click:close="selectedPoli = null">
<v-icon start size="x-small">mdi-hospital-building</v-icon>
{{clinicList.find(c => c.id === selectedPoli)?.title}}
</v-chip>
<v-chip v-if="selectedDoctor" size="small" color="secondary" variant="tonal" closable
@click:close="selectedDoctor = null">
<v-icon start size="x-small">mdi-doctor</v-icon>
{{ selectedDoctor }}
</v-chip>
</div>
</v-card>
</v-col>
</v-row>
<!-- Calendar Card -->
<v-row v-if="selectedDoctor && selectedPoli">
<v-col cols="12">
<v-card elevation="2" rounded="lg" color="white">
<!-- Toolbar -->
<div class="d-flex align-center justify-space-between px-6 pt-5 pb-4">
<div class="d-flex align-center">
<v-btn icon="mdi-chevron-left" variant="text" size="small" @click="prevMonth" />
<v-btn icon="mdi-chevron-right" variant="text" size="small" @click="nextMonth" />
<span class="text-body-1 font-weight-bold" style="min-width:150px;text-align:center;">
{{ calendarTitle }}
</span>
</div>
<!-- Legend -->
<div class="d-flex align-center gap-3">
<div class="d-flex align-center gap-1">
<span class="legend-dot bg-primary"></span>
<span class="text-caption ml-1">Rutin</span>
</div>
<div class="d-flex align-center gap-1 ml-1">
<span class="legend-dot bg-secondary"></span>
<span class="text-caption ml-1">Adhoc</span>
</div>
</div>
</div>
<v-divider />
<!-- v-calendar Vuetify -->
<v-calendar :interval-height="100" locale="id" :key="calendarKey" :model-value="viewDate"
:events="calendarEvents" view-mode="month" event-more-text="{0} lainnya" class="jadwal-calendar"
@click:date="handleDateClick" @click:event="handleEventClick" @click:more="handleMoreClick" />
<v-menu v-model="selectedOpen" :activator="selectedElement" :close-on-content-click="false"
location="end">
<v-card color="grey-lighten-4" min-width="350px" flat>
<v-toolbar color="primary" dark>
<v-btn icon>
<v-icon>mdi-calendar</v-icon>
</v-btn>
<v-toolbar-title>Jadwal Lainnya</v-toolbar-title>
</v-toolbar>
<v-card-text style="max-height: 300px; overflow-y: auto;">
<v-list v-if="dailyEvents.length > 0" lines="two" bg-color="transparent">
<v-list-item v-for="(ev, idx) in dailyEvents" :key="idx" class="mb-3 rounded border"
:class="ev.color === 'primary' ? 'border-primary' : 'border-secondary'"
@click="handleDailyEventClick(ev)" hover>
<v-list-item-title class="font-weight-bold">
<v-chip :color="ev.color" size="small" class="mr-2">{{
ev.extendedProps?.tipe }}</v-chip>
{{ ev.name }}
</v-list-item-title>
<v-list-item-subtitle class="mt-1">
<v-icon size="x-small" class="mr-1">mdi-doctor</v-icon>{{
ev.extendedProps?.dokter }}
</v-list-item-subtitle>
<v-list-item-subtitle>
<v-icon size="x-small" class="mr-1">mdi-hospital-building</v-icon>{{
ev.extendedProps?.poli }}
</v-list-item-subtitle>
</v-list-item>
</v-list>
<div v-else class="text-center pa-6 text-grey">
Tidak ada jadwal tambahan.
</div>
</v-card-text>
</v-card>
</v-menu>
</v-card>
</v-col>
</v-row>
<template v-else>
<v-card min-height="400px">
<v-card-text>
<v-row>
<v-col cols="12">
<div class="text-center pa-12 d-flex align-center justify-center" style="height: 400px;">
<div>
<v-icon size="48" color="grey-lighten-1"
class="mb-2">mdi-calendar-blank-outline</v-icon>
<div class="text-body-2 text-grey-lighten-1">Silahkan Pilih Poli dan Dokter terlebih
dahulu</div>
</div>
</div>
</v-col>
</v-row>
</v-card-text>
</v-card>
</template>
</div>
</template>
<style scoped>
:deep(.v-calendar-weekly__day) {
height: 125px;
}
.jadwal-calendar {
border-radius: 0 0 8px 8px;
}
.legend-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 3px;
}
/* Header hari */
:deep(.v-calendar-month__weekday) {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #283593;
background-color: #e8eaf6;
padding: 6px 0;
}
/* Nomor tanggal */
:deep(.v-calendar-month__day-label) {
font-size: 0.8rem;
font-weight: 500;
color: #424242;
cursor: pointer;
}
:deep(.v-calendar-month__day-label:hover) {
background-color: #e8eaf6;
border-radius: 50%;
}
/* Hari ini */
:deep(.v-calendar-month__day--today .v-calendar-month__day-label) {
background-color: #1a237e;
color: white;
border-radius: 50%;
}
/* Event */
:deep(.v-calendar-month__event) {
font-size: 0.68rem !important;
font-weight: 600;
border-radius: 4px !important;
cursor: pointer;
transition: opacity 0.15s ease;
}
:deep(.v-calendar-month__event:hover) {
opacity: 0.82;
}
</style>
@@ -0,0 +1,313 @@
<script setup>
/**
* JadwalDokterList — Mode tampilan daftar (tabel) untuk jadwal dokter.
* Menampilkan filter (poli klinik, tanggal, nama dokter) dan tabel
* dengan kolom NO, POLI, NAMA DOKTER, JADWAL RUTIN, AKSI.
* Mode ini menjadi tampilan default halaman Jadwal Dokter.
*/
const props = defineProps({
/** Daftar klinik/poli dari store */
clinicList: { type: Array, default: () => [] },
/** Daftar jadwal yang sudah ditambahkan */
jadwalList: { type: Array, default: () => [] },
});
const emit = defineEmits(['edit', 'add']);
// ── Filter State ─────────────────────────────────────────────────────────
/** Poli yang dipilih untuk filter */
const filterPoli = ref(null);
/** Tanggal yang dipilih untuk filter */
const filterDate = ref(null);
/** Keyword pencarian nama dokter */
const filterDokter = ref('');
// ── Pagination ───────────────────────────────────────────────────────────
/** Halaman aktif saat ini */
const currentPage = ref(1);
/** Jumlah baris per halaman */
const itemsPerPage = ref(10);
/** Opsi jumlah baris per halaman */
const perPageOptions = [5, 10, 25, 50];
// ── Mapping Hari ─────────────────────────────────────────────────────────
const DAY_MAP = {
0: 'Minggu', 1: 'Senin', 2: 'Selasa', 3: 'Rabu',
4: 'Kamis', 5: 'Jumat', 6: 'Sabtu'
};
// ── Computed: Data Tabel ─────────────────────────────────────────────────
/**
* Membangun data tabel dari jadwalList, dikelompokkan per dokter + poli.
* Setiap baris menampilkan 1 dokter dengan ringkasan jadwal rutinnya.
* @returns {Array<{ id, poliName, poliId, dokter, jadwalRutin, raw }>}
*/
const tableData = computed(() => {
/** Gabungkan jadwal berdasarkan key unik dokter+poli */
const grouped = {};
props.jadwalList.forEach(j => {
const key = `${j.poliId}-${j.dokter}`;
if (!grouped[key]) {
grouped[key] = {
id: j.id,
poliName: j.poliName,
poliId: j.poliId,
dokter: j.dokter,
hariSet: new Set(),
jamMulai: null,
jamSelesai: null,
raw: [],
};
}
grouped[key].raw.push(j);
if (j.tipe === 'Rutin' && j.hari) {
j.hari.forEach(h => grouped[key].hariSet.add(h));
if (j.jamMulai) grouped[key].jamMulai = j.jamMulai;
if (j.jamSelesai) grouped[key].jamSelesai = j.jamSelesai;
}
});
return Object.values(grouped).map(g => {
const hariArr = Array.from(g.hariSet);
/** Format jadwal rutin: "Senin - Kamis\n08:00 - 14:00 WIB" */
let jadwalRutin = '';
if (hariArr.length > 0) {
jadwalRutin = hariArr.join(', ');
if (g.jamMulai && g.jamSelesai) {
jadwalRutin += `\n${g.jamMulai} - ${g.jamSelesai} WIB`;
}
} else {
jadwalRutin = '-';
}
return {
id: g.id,
poliName: g.poliName,
poliId: g.poliId,
dokter: g.dokter,
jadwalRutin,
raw: g.raw,
};
});
});
/**
* Data tabel yang sudah difilter berdasarkan poli, tanggal, dan keyword nama dokter.
* @returns {Array}
*/
const filteredData = computed(() => {
let data = tableData.value;
// Filter poli
if (filterPoli.value) {
data = data.filter(d => d.poliId === filterPoli.value);
}
// Filter tanggal — cocokkan dengan hari dari jadwal rutin
if (filterDate.value) {
const date = new Date(filterDate.value);
const dayName = DAY_MAP[date.getDay()];
data = data.filter(d =>
d.raw.some(j => j.tipe === 'Rutin' && j.hari?.includes(dayName))
);
}
// Filter nama dokter
if (filterDokter.value) {
const keyword = filterDokter.value.toLowerCase();
data = data.filter(d => d.dokter.toLowerCase().includes(keyword));
}
return data;
});
/** Total data setelah filter */
const totalItems = computed(() => filteredData.value.length);
/** Total halaman */
const totalPages = computed(() => Math.max(1, Math.ceil(totalItems.value / itemsPerPage.value)));
/** Data untuk halaman aktif (sliced) */
const paginatedData = computed(() => {
const start = (currentPage.value - 1) * itemsPerPage.value;
return filteredData.value.slice(start, start + itemsPerPage.value);
});
/** Label info pagination: "Menampilkan 1-10 dari 124 dokter spesialis" */
const paginationLabel = computed(() => {
if (totalItems.value === 0) return 'Tidak ada data';
const start = (currentPage.value - 1) * itemsPerPage.value + 1;
const end = Math.min(currentPage.value * itemsPerPage.value, totalItems.value);
return `Menampilkan ${start}-${end} dari ${totalItems.value} dokter spesialis`;
});
/** Reset halaman saat filter berubah */
watch([filterPoli, filterDate, filterDokter], () => {
currentPage.value = 1;
});
/**
* Navigasi ke halaman tertentu.
* @param {number} page
*/
const goToPage = (page) => {
if (page >= 1 && page <= totalPages.value) {
currentPage.value = page;
}
};
</script>
<template>
<div>
<!-- Filter Card -->
<v-card class="pa-5 mb-6" elevation="2" rounded="lg" color="white">
<v-row align="center">
<v-col cols="12" sm="4">
<div class="text-caption text-grey-darken-1 mb-1 font-weight-medium">Poli Klinik</div>
<v-select v-model="filterPoli" :items="clinicList" item-title="title" item-value="id"
placeholder="Semua Poli" variant="outlined" density="compact" clearable
prepend-inner-icon="mdi-hospital-building" hide-details />
</v-col>
<v-col cols="12" sm="4">
<div class="text-caption text-grey-darken-1 mb-1 font-weight-medium">Tanggal</div>
<v-text-field v-model="filterDate" type="date" placeholder="Pilih Tanggal" variant="outlined"
density="compact" clearable prepend-inner-icon="mdi-calendar" hide-details />
</v-col>
<v-col cols="12" sm="4">
<div class="text-caption text-grey-darken-1 mb-1 font-weight-medium">Nama Dokter</div>
<v-text-field v-model="filterDokter" placeholder="Cari Nama Dokter..." variant="outlined"
density="compact" clearable prepend-inner-icon="mdi-account-search" hide-details />
</v-col>
</v-row>
</v-card>
<!-- Tabel Jadwal -->
<v-card elevation="2" rounded="lg" color="white">
<v-table class="jadwal-table">
<thead>
<tr>
<th class="text-left" style="width: 60px;">NO</th>
<th class="text-left" style="width: 140px;">POLI</th>
<th class="text-left">NAMA DOKTER</th>
<th class="text-left" style="width: 200px;">JADWAL RUTIN</th>
<th class="text-center" style="width: 100px;">AKSI</th>
</tr>
</thead>
<tbody>
<tr v-if="paginatedData.length === 0">
<td colspan="5" class="text-center pa-8 text-grey">
<v-icon size="48" color="grey-lighten-1" class="mb-2">mdi-calendar-blank-outline</v-icon>
<div class="text-body-2">Belum ada data jadwal dokter</div>
<div class="text-caption text-grey-lighten-1">Tambahkan jadwal dokter melalui mode
kalender</div>
</td>
</tr>
<tr v-for="(item, index) in paginatedData" :key="item.id" class="jadwal-row">
<td class="text-body-2 font-weight-medium">
{{ (currentPage - 1) * itemsPerPage + index + 1 }}
</td>
<td>
<v-chip size="small" color="primary" variant="tonal">
{{ item.poliName }}
</v-chip>
</td>
<td class="text-body-2 font-weight-bold">
{{ item.dokter }}
</td>
<td>
<div class="text-body-2" style="white-space: pre-line; line-height: 1.5;">
{{ item.jadwalRutin }}
</div>
</td>
<td class="text-center">
<v-btn variant="outlined" size="small" color="primary" rounded="lg"
prepend-icon="mdi-pencil" @click="emit('edit', item)">
Edit
</v-btn>
</td>
</tr>
</tbody>
</v-table>
<!-- Pagination Footer -->
<v-divider />
<div class="d-flex align-center justify-space-between px-4 py-3 pagination-footer">
<span class="text-caption text-grey-darken-1">{{ paginationLabel }}</span>
<div class="d-flex align-center gap-3">
<div class="d-flex align-center gap-2">
<span class="text-caption text-grey-darken-1 mr-2">Baris per halaman:</span>
<v-select v-model="itemsPerPage" :items="perPageOptions" variant="plain" density="compact"
hide-details style="max-width: 70px;" />
</div>
<div class="d-flex align-center gap-1">
<v-btn icon size="x-small" variant="text" :disabled="currentPage <= 1" @click="goToPage(1)">
<v-icon size="16">mdi-page-first</v-icon>
</v-btn>
<v-btn icon size="x-small" variant="text" :disabled="currentPage <= 1"
@click="goToPage(currentPage - 1)">
<v-icon size="16">mdi-chevron-left</v-icon>
</v-btn>
<v-btn icon size="x-small" variant="text" :disabled="currentPage >= totalPages"
@click="goToPage(currentPage + 1)">
<v-icon size="16">mdi-chevron-right</v-icon>
</v-btn>
<v-btn icon size="x-small" variant="text" :disabled="currentPage >= totalPages"
@click="goToPage(totalPages)">
<v-icon size="16">mdi-page-last</v-icon>
</v-btn>
</div>
</div>
</div>
</v-card>
</div>
</template>
<style scoped>
.jadwal-table {
border-radius: 12px 12px 0 0;
}
.jadwal-table thead th {
background-color: #f5f6fa !important;
color: #5a607f !important;
font-weight: 700 !important;
font-size: 0.75rem !important;
letter-spacing: 0.04em;
text-transform: uppercase;
border-bottom: 2px solid #e8eaf0 !important;
padding: 14px 16px !important;
}
.jadwal-row {
transition: background-color 0.15s ease;
}
.jadwal-row:hover {
background-color: #f8f9fd;
}
.jadwal-row td {
padding: 16px !important;
border-bottom: 1px solid #f0f1f5 !important;
vertical-align: middle;
}
.pagination-footer {
background-color: #fafbfc;
border-radius: 0 0 12px 12px;
}
</style>
@@ -0,0 +1,259 @@
<script setup>
import { ref, computed } from 'vue';
const props = defineProps({
tipe: { type: String, default: 'Rutin' },
hari: { type: String, default: '' },
adhocTanggal: { type: String, default: '' },
jadwalList: { type: Array, default: () => [] },
poliId: { type: [String, Number, null], default: null },
dokter: { type: String, default: null }
});
const MONTH_NAMES = [
'Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni',
'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember'
];
const DAY_MAP = {
'Minggu': 0, 'Senin': 1, 'Selasa': 2, 'Rabu': 3,
'Kamis': 4, 'Jumat': 5, 'Sabtu': 6
};
const viewDate = ref(new Date());
const calendarKey = computed(() =>
`cal-mini-${viewDate.value.getFullYear()}-${viewDate.value.getMonth()}`
);
const calendarTitle = computed(() =>
`${MONTH_NAMES[viewDate.value.getMonth()]} ${viewDate.value.getFullYear()}`
);
const prevMonth = () => {
const d = new Date(viewDate.value);
d.setMonth(d.getMonth() - 1);
viewDate.value = new Date(d);
};
const nextMonth = () => {
const d = new Date(viewDate.value);
d.setMonth(d.getMonth() + 1);
viewDate.value = new Date(d);
};
const toDateStr = (date) => {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
};
const calendarEvents = computed(() => {
const events = [];
const year = viewDate.value.getFullYear();
const month = viewDate.value.getMonth();
// 1. Generate existing events for the selected doctor
const filtered = props.jadwalList.filter(j => {
if (props.poliId && j.poliId !== props.poliId) return false;
if (props.dokter && j.dokter !== props.dokter) return false;
return true;
});
filtered.forEach(jadwal => {
if (jadwal.tipe === 'Rutin') {
for (let mOffset = -1; mOffset <= 1; mOffset++) {
const tYear = month + mOffset < 0 ? year - 1 : month + mOffset > 11 ? year + 1 : year;
const tMonth = (month + mOffset + 12) % 12;
const daysInMonth = new Date(tYear, tMonth + 1, 0).getDate();
for (let d = 1; d <= daysInMonth; d++) {
const date = new Date(tYear, tMonth, d);
const dayName = Object.keys(DAY_MAP).find(k => DAY_MAP[k] === date.getDay());
let jadwalHariArray = [];
if (Array.isArray(jadwal.hari)) {
jadwalHariArray = jadwal.hari;
} else if (typeof jadwal.hari === 'string') {
jadwalHariArray = [jadwal.hari];
}
if (!jadwalHariArray.includes(dayName)) continue;
const dateStr = toDateStr(date);
const jamLabel = jadwal.jamMulai && jadwal.jamSelesai
? ` ${jadwal.jamMulai} - ${jadwal.jamSelesai}`
: '';
events.push({
name: `${jamLabel}`,
start: jadwal.jamMulai ? new Date(`${dateStr}T${jadwal.jamMulai}:00`) : date,
end: jadwal.jamSelesai ? new Date(`${dateStr}T${jadwal.jamSelesai}:00`) : date,
color: 'primary',
allDay: !jadwal.jamMulai,
});
}
}
} else {
const adhocMulaiTime = jadwal.mulai ? jadwal.mulai.split('T')[1]?.slice(0, 5) : '';
const adhocSelesaiTime = jadwal.selesai ? jadwal.selesai.split('T')[1]?.slice(0, 5) : '';
const adhocJamLabel = adhocMulaiTime && adhocSelesaiTime
? ` ${adhocMulaiTime} - ${adhocSelesaiTime}`
: '';
events.push({
name: `${adhocJamLabel}`,
start: new Date(jadwal.mulai),
end: new Date(jadwal.selesai),
color: 'secondary',
allDay: false,
});
}
});
return events;
});
const calendarRef = ref(null);
const updateHighlight = () => {
// Wait for calendar to fully render internally
setTimeout(() => {
const calendarEl = calendarRef.value?.$el || document.querySelector('.mini-calendar');
if (!calendarEl) return;
// Remove previous highlights
const previous = calendarEl.querySelectorAll('.adhoc-highlight, .rutin-highlight');
previous.forEach(el => {
el.classList.remove('adhoc-highlight', 'rutin-highlight');
el.style.border = '';
el.style.borderRadius = '';
el.style.color = '';
el.style.fontWeight = '';
});
const allDays = calendarEl.querySelectorAll('.v-calendar-weekly__day');
if (allDays.length === 0) return;
if (props.tipe === 'Rutin' && props.hari) {
const targetCol = DAY_MAP[props.hari];
allDays.forEach((dayEl, index) => {
// Check if index matches column and it's not from adjacent month
if (index % 7 === targetCol && !dayEl.classList.contains('v-calendar-weekly__day--adjacent')) {
const labelBtn = dayEl.querySelector('.v-calendar-weekly__day-label .v-btn, .v-calendar-weekly__day-label');
if (labelBtn) {
labelBtn.classList.add('rutin-highlight');
// Fallback inline styles in case CSS scoped issue occurs
labelBtn.style.border = '2px solid rgb(var(--v-theme-primary))';
labelBtn.style.borderRadius = '50%';
labelBtn.style.color = 'rgb(var(--v-theme-primary))';
labelBtn.style.fontWeight = 'bold';
labelBtn.style.width = '28px';
labelBtn.style.height = '28px';
labelBtn.style.display = 'flex';
labelBtn.style.alignItems = 'center';
labelBtn.style.justifyContent = 'center';
labelBtn.style.margin = '2px auto';
}
}
});
} else if (props.tipe === 'Adhoc' && props.adhocTanggal) {
const adhocDate = new Date(props.adhocTanggal);
if (adhocDate.getMonth() !== viewDate.value.getMonth() || adhocDate.getFullYear() !== viewDate.value.getFullYear()) {
return;
}
const dayToHighlight = adhocDate.getDate().toString();
allDays.forEach(dayEl => {
if (!dayEl.classList.contains('v-calendar-weekly__day--adjacent')) {
const labelTextEl = dayEl.querySelector('.v-calendar-weekly__day-label .v-btn__content, .v-calendar-weekly__day-label');
const labelBtn = dayEl.querySelector('.v-calendar-weekly__day-label .v-btn, .v-calendar-weekly__day-label');
if (labelTextEl && labelTextEl.textContent.trim() === dayToHighlight) {
labelBtn.classList.add('adhoc-highlight');
labelBtn.style.border = '2px solid rgb(var(--v-theme-primary))';
labelBtn.style.borderRadius = '50%';
labelBtn.style.color = 'rgb(var(--v-theme-primary))';
labelBtn.style.fontWeight = 'bold';
labelBtn.style.width = '28px';
labelBtn.style.height = '28px';
labelBtn.style.display = 'flex';
labelBtn.style.alignItems = 'center';
labelBtn.style.justifyContent = 'center';
labelBtn.style.margin = '2px auto';
}
}
});
}
}, 150);
};
import { watch, nextTick } from 'vue';
watch([() => props.adhocTanggal, () => props.hari, () => props.tipe, viewDate], () => {
nextTick(() => updateHighlight());
}, { immediate: true });
</script>
<template>
<v-card class="mt-4 border border-grey-lighten-3 rounded-lg" elevation="0">
<div class="d-flex align-center justify-space-between px-4 pt-3 pb-2">
<div class="d-flex align-center">
<v-btn icon="mdi-chevron-left" variant="text" size="x-small" @click="prevMonth" />
<v-btn icon="mdi-chevron-right" variant="text" size="x-small" @click="nextMonth" />
<span class="text-caption font-weight-bold mx-2" style="min-width:100px;text-align:center;">
{{ calendarTitle }}
</span>
</div>
</div>
<!-- We use a custom class to inject CSS for highlighted days if they have preview event -->
<v-calendar ref="calendarRef" :interval-height="40" locale="id" :key="calendarKey" :model-value="viewDate"
:events="calendarEvents" view-mode="month"
:class="['mini-calendar', props.tipe === 'Rutin' && props.hari ? 'preview-hari-' + props.hari : '']" />
</v-card>
</template>
<style scoped>
:deep(.v-calendar-weekly__day) {
height: 70px !important;
}
.mini-calendar {
border-radius: 0 0 8px 8px;
background-color: white !important;
}
/* Header hari */
:deep(.v-calendar-weekly__weekday) {
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
color: #555;
background-color: #f5f5f5;
padding: 4px 0;
}
/* Nomor tanggal */
:deep(.v-calendar-weekly__day-label) {
font-size: 0.8rem;
font-weight: 600;
color: #424242;
transition: all 0.2s ease;
}
/* Lingkaran Preview via JS Class */
:deep(.rutin-highlight),
:deep(.adhoc-highlight) {
border: 2px solid rgb(var(--v-theme-primary)) !important;
border-radius: 50% !important;
color: rgb(var(--v-theme-primary)) !important;
font-weight: bold !important;
}
/* Event styles */
:deep(.v-calendar-weekly__event) {
font-size: 0.6rem !important;
font-weight: 600;
border-radius: 4px !important;
padding: 0 4px !important;
margin-bottom: 2px !important;
min-height: 16px !important;
}
</style>
@@ -22,7 +22,7 @@
:rules="[v => !!v || 'Nama loket harus diisi']" :rules="[v => !!v || 'Nama loket harus diisi']"
hide-details="auto" hide-details="auto"
class="mb-3 input-field" class="mb-3 input-field"
placeholder="Loket 1" placeholder="Loket A"
/> />
<v-text-field <v-text-field
@@ -0,0 +1,156 @@
<template>
<v-dialog v-model="internalModel" max-width="500px">
<v-card class="dialog-card overflow-hidden rounded-xl" elevation="0">
<!-- Solid Blue Header -->
<v-card-title class="bg-primary-600 text-white py-4 px-6 d-flex justify-space-between align-center">
<div class="d-flex align-center">
<v-icon start icon="mdi-alert-circle-outline" class="mr-2"></v-icon>
<span class="text-h6 font-weight-bold">Konfirmasi Selesai</span>
</div>
<v-btn icon="mdi-close" variant="text" size="small" @click="internalModel = false" class="text-white"></v-btn>
</v-card-title>
<v-card-text class="dialog-content-premium pa-6 text-center">
<div class="text-center mb-6 text-uppercase font-weight-bold text-caption text-neutral-600" style="letter-spacing: 1px;">
Antrean Belum Dibuat
</div>
<p class="text-body-1 mb-6">
Tiket antrean ruang atau penunjang <strong>belum dibuat</strong> untuk pasien ini.
</p>
<div class="comparison-container mb-6 mx-auto" style="max-width: 220px; justify-content: center;">
<div class="comparison-item current">
<div class="item-label text-center">PASIEN AKTIF</div>
<div class="item-card">
<div class="item-number text-danger-700">{{ patient?.noAntrian?.split(' |')[0] || '-' }}</div>
<div class="item-status">Sedang Diproses</div>
</div>
</div>
</div>
<p class="text-body-2 text-neutral-600">
Apakah Anda yakin ingin menyelesaikan pelayanan untuk pasien ini?
</p>
</v-card-text>
<v-divider />
<v-card-actions class="pa-4 bg-light justify-center">
<v-btn
class="px-8 font-weight-bold rounded-lg text-none btn-batal"
variant="outlined"
@click="internalModel = false"
size="large"
>
Batal
</v-btn>
<v-btn
class="px-8 font-weight-bold ml-4 rounded-lg text-none btn-confirm"
variant="flat"
@click="confirm"
size="large"
elevation="2"
>
Ya, Selesaikan
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
patient: {
type: Object,
default: null
}
});
const emit = defineEmits(['update:modelValue', 'confirm']);
const internalModel = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
});
const confirm = () => {
emit('confirm');
internalModel.value = false;
};
</script>
<style scoped>
.dialog-card {
/* Remove border to prevent white outline around blue header */
}
.bg-light {
background-color: var(--color-neutral-50) !important;
}
.comparison-container {
display: flex;
align-items: center;
gap: 12px;
background: white;
padding: 16px;
border-radius: 16px;
border: 1px solid var(--color-neutral-300);
}
.comparison-item {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.item-label {
font-size: 10px;
font-weight: 800;
color: var(--color-neutral-600);
letter-spacing: 0.05em;
}
.item-card {
background: white;
padding: 12px;
border-radius: 12px;
border: 1px solid var(--color-neutral-400);
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
text-align: center;
}
.item-number {
font-size: 24px;
font-weight: 800;
line-height: 1;
margin-bottom: 4px;
}
.item-status {
font-size: 11px;
color: var(--color-neutral-600);
font-weight: 600;
}
.text-danger-700 {
color: var(--color-danger-700) !important;
}
.btn-batal {
border-color: var(--color-neutral-300) !important;
color: var(--color-neutral-700) !important;
}
.btn-confirm {
background-color: var(--color-primary-600) !important;
color: white !important;
}
</style>
+381 -39
View File
@@ -1,32 +1,118 @@
<template> <template>
<v-card class="current-patient-card" elevation="0"> <v-card class="current-patient-card" elevation="0">
<v-card-text class="pa-4"> <v-card-text class="pa-4">
<div class="section-label mb-3">SEDANG DIPROSES</div> <div class="section-header mb-3">
<div class="section-label">SEDANG DIPROSES</div>
<v-btn
v-if="patient"
class="call-button text-white"
color="primary-500"
variant="flat"
size="small"
@click="$emit('call')"
>
<v-icon start size="18">mdi-bullhorn</v-icon>
Panggil
</v-btn>
</div>
<div v-if="patient" class="patient-details" :class="`patient-details-${theme}`"> <div v-if="patient" class="patient-details" :class="`patient-details-${theme}`">
<div class="patient-number mb-2">{{ patient.noAntrian.split(" |")[0] }}</div> <div class="patient-header">
<div class="patient-info-text mb-3"> <div class="patient-number-wrapper">
<div>{{ patient.barcode }}</div> <div class="patient-number">{{ patient.noAntrian.split(" |")[0] }}</div>
<div>{{ patient.klinik }} | {{ patient.pembayaran }}</div> <div
v-if="isFastTrack"
class="fast-track-icon-wrapper"
>
<v-icon
color="#E53935"
size="18"
class="fast-track-icon"
>
mdi-flash
</v-icon>
</div>
</div>
<div class="action-icons">
<v-btn
class="action-icon-btn text-white"
color="secondary-600"
variant="flat"
size="small"
@click="$emit('change-klinik')"
>
<v-icon size="16">mdi-swap-horizontal</v-icon>
<span class="btn-label">{{ changeButtonText }}</span>
</v-btn>
<v-btn
class="action-icon-btn text-white"
color="primary-600"
variant="flat"
size="small"
@click="$emit('action', 'terlambat')"
>
<v-icon size="16">mdi-clock-alert</v-icon>
<span class="btn-label">Terlambat</span>
</v-btn>
<v-btn
class="action-icon-btn text-white"
color="danger-600"
variant="flat"
size="small"
@click="$emit('action', 'pending')"
>
<v-icon size="16">mdi-pause</v-icon>
<span class="btn-label">Pending</span>
</v-btn>
<v-btn
class="action-icon-btn text-white"
color="success-600"
variant="flat"
size="small"
@click="$emit('action', 'check-in')"
>
<v-icon size="16">mdi-check</v-icon>
<span class="btn-label">Selesai</span>
</v-btn>
</div>
</div>
<div class="patient-info-text">
<div class="patient-barcode-rm d-flex align-center">
<div class="patient-barcode">{{ patient.barcode }}</div>
<v-chip v-if="patient.noRM" size="x-small" color="primary-600" variant="flat" class="text-white ml-2">
RM: {{ patient.noRM }}
</v-chip>
</div>
<div class="patient-klinik-pembayaran">{{ patient.klinik }} | {{ patient.pembayaran }}</div>
<div v-if="patient.referencePatient" class="patient-reference d-flex align-center gap-1">
<v-icon size="13" color="primary-600">mdi-link</v-icon>
<span class="reference-label">Ref. Loket:</span>
<span class="reference-value text-primary-600">{{ patient.referencePatient }}</span>
</div>
</div> </div>
<div class="action-grid">
<v-btn class="text-white" color="success-600" variant="flat" block size="large" @click="$emit('action', 'check-in')"> <div class="create-queue-buttons mt-4">
<v-icon start size="20">mdi-check</v-icon> <div class="create-buttons-container">
Selesai <v-btn
</v-btn> class="create-btn-ruang py-6 text-white"
<v-btn class="text-white" color="primary-600" variant="flat" block size="large" @click="$emit('action', 'terlambat')"> color="primary-600"
<v-icon start size="20">mdi-clock-alert</v-icon> :disabled="!patient"
Terlambat @click="$emit('open-klinik-ruang')"
</v-btn> >
<v-btn class="text-white" color="danger-600" variant="flat" block size="large" @click="$emit('action', 'pending')"> <v-icon size="20" class="mr-2">mdi-door</v-icon>
<v-icon start size="20">mdi-pause</v-icon> Buat Antrean Ruang
Pending </v-btn>
</v-btn> <v-btn
<v-btn class="text-white" color="secondary-600" variant="flat" block size="large" @click="$emit('change-klinik')"> class="create-btn-penunjang py-6 text-white"
<v-icon start size="20">mdi-swap-horizontal</v-icon> color="secondary-600"
{{ changeButtonText }} :disabled="!patient"
</v-btn> @click="$emit('open-penunjang')"
>
<v-icon size="20" class="mr-2">mdi-clipboard-pulse</v-icon>
Buat Antrean Penunjang
</v-btn>
</div>
</div> </div>
</div> </div>
@@ -56,7 +142,9 @@
</template> </template>
<script setup> <script setup>
defineProps({ import { computed } from 'vue';
const props = defineProps({
patient: { patient: {
type: Object, type: Object,
default: null default: null
@@ -80,7 +168,13 @@ defineProps({
} }
}); });
defineEmits(['action', 'change-klinik', 'process-next']); const isFastTrack = computed(() => {
if (!props.patient) return false;
const fastTrack = (props.patient.fastTrack ?? "").toString().trim().toUpperCase();
return fastTrack === 'YA';
});
defineEmits(['action', 'change-klinik', 'process-next', 'call', 'open-klinik-ruang', 'open-penunjang']);
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@@ -90,6 +184,12 @@ defineEmits(['action', 'change-klinik', 'process-next']);
background: var(--color-neutral-100); background: var(--color-neutral-100);
} }
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.section-label { .section-label {
font-size: 11px; font-size: 11px;
font-weight: 700; font-weight: 700;
@@ -98,13 +198,32 @@ defineEmits(['action', 'change-klinik', 'process-next']);
text-transform: uppercase; text-transform: uppercase;
} }
.call-button {
text-transform: none;
font-weight: 600;
font-size: 14px;
line-height: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: all 0.2s ease;
}
.call-button:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.call-button:active {
transform: translateY(0);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.patient-details { .patient-details {
border-radius: 8px; border-radius: 8px;
padding: 16px; padding: 20px;
} }
.patient-details-primary { .patient-details-primary {
background: linear-gradient(135deg, var(--color-primary-200) 0%, var(--color-primary-300) 100%); background: linear-gradient(135deg, var(--color-primary-100) 0%, var(--color-primary-100) 100%);
} }
.patient-details-secondary { .patient-details-secondary {
@@ -115,24 +234,204 @@ defineEmits(['action', 'change-klinik', 'process-next']);
background: linear-gradient(135deg, var(--color-accent-200) 0%, var(--color-accent-300) 100%); background: linear-gradient(135deg, var(--color-accent-200) 0%, var(--color-accent-300) 100%);
} }
.patient-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
gap: 12px;
}
.patient-number-wrapper {
display: flex;
align-items: center;
gap: 8px;
}
.patient-number { .patient-number {
font-size: 28px; font-size: 32px;
font-weight: 800; line-height: 36px;
font-weight: 700;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
color: var(--color-neutral-900); color: var(--color-neutral-900);
line-height: 1; }
.fast-track-icon-wrapper {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(239, 83, 80, 0.15);
border: 2px solid rgba(229, 57, 53, 0.4);
transition: all 0.2s ease;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(229, 57, 53, 0.2);
}
.fast-track-icon-wrapper:hover {
background: #E53935;
border-color: #E53935;
transform: scale(1.1);
box-shadow: 0 4px 12px rgba(229, 57, 53, 0.4);
}
.fast-track-icon-wrapper .fast-track-icon {
color: #E53935 !important;
transition: all 0.2s ease;
}
.fast-track-icon-wrapper:hover .fast-track-icon {
color: white !important;
}
.action-icons {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.action-icon-btn {
min-width: auto !important;
width: auto !important;
height: 32px !important;
padding: 0 12px !important;
border-radius: 16px !important;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.12);
transition: all 0.2s ease;
text-transform: none !important;
display: flex !important;
align-items: center !important;
gap: 4px;
}
.btn-label {
font-size: 10px;
font-weight: 600;
white-space: nowrap;
}
.action-icon-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.18);
}
.action-icon-btn:active {
transform: translateY(0);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
} }
.patient-info-text { .patient-info-text {
font-size: 13px; font-size: 14px;
line-height: 24px;
color: var(--color-neutral-700); color: var(--color-neutral-700);
line-height: 1.5; display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 20px;
} }
.action-grid { .patient-barcode {
display: grid; font-size: 14px;
grid-template-columns: 1fr 1fr; line-height: 20px;
gap: 8px; color: var(--color-neutral-700);
margin-top: 12px; font-weight: 400;
}
.patient-klinik-pembayaran {
font-size: 15px;
line-height: 22px;
color: var(--color-neutral-800);
font-weight: 600;
}
.patient-reference {
font-size: 12px;
line-height: 18px;
color: var(--color-neutral-700);
}
.reference-label {
font-weight: 600;
color: var(--color-neutral-700);
}
.reference-value {
font-weight: 700;
font-size: 13px;
}
.create-queue-buttons {
margin-top: 0;
}
.create-buttons-container {
display: flex;
gap: 16px;
width: 100%;
}
.create-queue-buttons .v-btn {
flex: 1;
text-transform: none;
font-weight: 600;
font-size: 15px;
line-height: 22px;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
transition: all 0.2s ease;
border-radius: 8px;
justify-content: center !important;
text-align: center;
display: flex !important;
align-items: center !important;
}
.create-queue-buttons .v-btn :deep(.v-btn__content) {
justify-content: center !important;
width: 100%;
display: flex !important;
align-items: center !important;
flex: 1;
}
.create-queue-buttons .v-btn :deep(.v-icon) {
flex-shrink: 0;
}
.create-btn-ruang {
background-color: var(--color-primary-600) !important;
color: var(--color-neutral-100) !important;
}
.create-btn-ruang:hover:not(:disabled) {
background-color: var(--color-primary-700) !important;
transform: translateY(-1px);
box-shadow: 0 4px 10px rgba(58, 97, 201, 0.25);
}
.create-btn-penunjang {
background-color: var(--color-secondary-600) !important;
color: var(--color-neutral-100) !important;
}
.create-btn-penunjang:hover:not(:disabled) {
background-color: var(--color-secondary-700) !important;
transform: translateY(-1px);
box-shadow: 0 4px 10px rgba(241, 111, 41, 0.25);
}
.create-queue-buttons .v-btn:active:not(:disabled) {
transform: translateY(0);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
}
.create-queue-buttons .v-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
} }
.empty-state { .empty-state {
@@ -140,8 +439,28 @@ defineEmits(['action', 'change-klinik', 'process-next']);
padding: 32px 16px; padding: 32px 16px;
} }
.empty-state .v-btn {
text-transform: none;
font-weight: 600;
font-size: 16px;
line-height: 24px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: all 0.2s ease;
}
.empty-state .v-btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.empty-state .v-btn:active:not(:disabled) {
transform: translateY(0);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.empty-text { .empty-text {
font-size: 13px; font-size: 14px;
line-height: 20px;
color: var(--color-neutral-600); color: var(--color-neutral-600);
margin-top: 8px; margin-top: 8px;
} }
@@ -156,13 +475,36 @@ defineEmits(['action', 'change-klinik', 'process-next']);
.info-text { .info-text {
font-size: 12px; font-size: 12px;
line-height: 16px;
color: var(--color-neutral-700); color: var(--color-neutral-700);
font-weight: 600; font-weight: 600;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
} }
@media (max-width: 960px) { @media (max-width: 960px) {
.action-grid { .patient-header {
grid-template-columns: 1fr; flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.action-icons {
width: 100%;
justify-content: flex-start;
gap: 6px;
}
.action-icon-btn {
flex: 1 1 auto;
min-width: 0 !important;
max-width: none !important;
}
}
@media (max-width: 600px) {
.create-buttons-container {
flex-direction: column;
gap: 12px;
} }
} }
</style> </style>
+207 -91
View File
@@ -1,57 +1,85 @@
<template> <template>
<v-tooltip <v-card
:text="isClickable ? 'Klik untuk proses pasien' : ''" class="patient-card"
location="top" elevation="2"
:disabled="!isClickable" :class="{
'clickable-card': isClickable,
'fast-track-card': isFastTrack && !isCurrentlyProcessing,
'processing-card': isCurrentlyProcessing || patient.status === 'diproses'
}"
@click="handleCardClick"
> >
<template #activator="{ props: tooltipProps }"> <v-tooltip
<v-card activator="parent"
class="patient-card" :text="isClickable ? 'Klik untuk proses pasien' : ''"
elevation="2" location="top"
:class="{ 'clickable-card': isClickable }" :disabled="!isClickable"
@click="handleCardClick" />
v-bind="isClickable ? tooltipProps : {}" <v-card-text class="pa-4">
>
<v-card-text class="pa-4">
<!-- Header: Queue Number, Status & Fast Track Badge --> <!-- Header: Queue Number, Status & Fast Track Badge -->
<div class="card-header"> <div class="card-header">
<div class="header-left"> <div class="header-left">
<div class="queue-number">{{ patient.noAntrian.split(" |")[0] }}</div> <div class="queue-number">{{ patient.noAntrian.split(" |")[0] }}</div>
<v-icon <div
v-if="patient.fastTrack === 'YA'" v-if="isFastTrack"
color="warning" class="fast-track-icon-wrapper"
size="20"
class="fast-track-icon"
> >
mdi-flash <v-icon
</v-icon> color="#E53935"
size="14"
class="fast-track-icon"
>
mdi-flash
</v-icon>
</div>
</div> </div>
<v-chip <div class="header-right d-flex align-center" style="gap: 8px;">
:color="getStatusColor(patient.status)" <v-chip
size="small" :color="getStatusColor(patient.status)"
class="status-chip" size="small"
> class="status-chip"
{{ getStatusLabel(patient.status) }} >
</v-chip> {{ getStatusLabel(patient.status) }}
</div>
<!-- Patient Info Grid - Simplified -->
<div class="patient-info mt-3">
<div class="info-row">
<span class="info-label">Jam Panggil:</span>
<span class="info-value">{{ patient.jamPanggil }}</span>
</div>
<div class="info-row">
<span class="info-label">Klinik:</span>
<v-chip size="small" variant="outlined" class="klinik-chip">
{{ patient.klinik }}
</v-chip> </v-chip>
</div> </div>
</div>
<div class="info-row"> <!-- Patient Info - Compact Single Row -->
<span class="info-label">Pembayaran:</span> <div class="patient-info mt-3">
<span class="info-value">{{ patient.pembayaran }}</span> <div class="info-chips-row">
<v-chip
size="small"
variant="outlined"
class="klinik-chip"
>
{{ patient.klinik }}
</v-chip>
<!-- Sub Spesialis -->
<v-chip
v-if="patient.ruang"
color="primary-600"
variant="outlined"
size="small"
class="subspesialis-chip font-weight-bold"
style="height: 24px; font-size: 11px;"
>
{{ patient.ruang }}
</v-chip>
<!-- Tipe Layanan atau Jenis Pasien -->
<v-chip
v-if="displayJenisPasien"
size="small"
:color="getJenisPasienColor(displayJenisPasien)"
:variant="getJenisPasienVariant(displayJenisPasien)"
:class="getJenisPasienClass(displayJenisPasien)"
>
{{ displayJenisPasien }}
</v-chip>
</div>
<!-- Nama Dokter untuk Pasien Eksekutif -->
<div v-if="isEksekutif && patient.namaDokter" class="doctor-info mt-2">
<v-icon size="16" color="primary" class="doctor-icon">mdi-doctor</v-icon>
<span class="doctor-name">{{ patient.namaDokter }}</span>
</div> </div>
</div> </div>
@@ -70,13 +98,14 @@
</v-btn> </v-btn>
</div> </div>
</v-card-text> </v-card-text>
</v-card> </v-card>
</template>
</v-tooltip>
</template> </template>
<script setup> <script setup>
import { computed } from 'vue'; import { computed } from 'vue';
import { useQueueStore } from '~/stores/queueStore';
const queueStore = useQueueStore();
const props = defineProps({ const props = defineProps({
patient: { patient: {
@@ -88,9 +117,68 @@ const props = defineProps({
const emit = defineEmits(['action']); const emit = defineEmits(['action']);
const isClickable = computed(() => { const isClickable = computed(() => {
return props.patient.status === 'diloket' || props.patient.status === 'pending'; return props.patient.status === 'di-loket' || props.patient.status === 'pending';
}); });
const isFastTrack = computed(() => {
const fastTrack = (props.patient.fastTrack ?? "").toString().trim().toUpperCase();
return fastTrack === 'YA';
});
const isCurrentlyProcessing = computed(() => {
const currentPatient = queueStore.currentProcessingPatient?.loket;
if (!currentPatient || !props.patient) return false;
return currentPatient.no === props.patient.no ||
currentPatient.barcode === props.patient.barcode;
});
const isEksekutif = computed(() => {
const pembayaran = (props.patient.pembayaran ?? "").toString().trim().toUpperCase();
return pembayaran === 'EKSEKUTIF';
});
// Computed untuk menentukan jenis pasien yang ditampilkan
const displayJenisPasien = computed(() => {
// Prioritas 1: Tipe Layanan jika ada
if (props.patient.tipeLayanan) {
return props.patient.tipeLayanan;
}
// Prioritas 2: Eksekutif jika pasien eksekutif
if (isEksekutif.value) {
return 'Eksekutif';
}
// Prioritas 3: Tampilkan pembayaran sebagai fallback
if (props.patient.pembayaran) {
return props.patient.pembayaran;
}
return null;
});
// Fungsi untuk menentukan color chip berdasarkan jenis pasien
const getJenisPasienColor = (jenis) => {
if (jenis === 'Pemeriksaan Awal') return 'primary';
if (jenis === 'Tindakan') return 'secondary';
if (jenis === 'Eksekutif') return 'warning';
if (jenis === 'Fast Track') return 'warning';
// Untuk pembayaran biasa (BPJS, UMUM), gunakan neutral-600 untuk kontras
return 'success-500';
};
// Fungsi untuk menentukan variant chip berdasarkan jenis pasien
const getJenisPasienVariant = (jenis) => {
// Semua jenis pasien menggunakan flat (filled) untuk kontras dengan klinik (outlined)
return 'flat';
};
// Fungsi untuk menentukan class chip berdasarkan jenis pasien
const getJenisPasienClass = (jenis) => {
if (jenis === 'Pemeriksaan Awal' || jenis === 'Tindakan' || jenis === 'Eksekutif' || jenis === 'Fast Track') {
return 'text-white layanan-chip';
}
// Untuk pembayaran biasa (BPJS, UMUM), gunakan text putih untuk kontras dengan background
return 'text-white layanan-chip';
};
const handleCardClick = () => { const handleCardClick = () => {
if (isClickable.value) { if (isClickable.value) {
emit('action', props.patient, 'proses'); emit('action', props.patient, 'proses');
@@ -99,17 +187,17 @@ const handleCardClick = () => {
const getStatusColor = (status) => { const getStatusColor = (status) => {
const colors = { const colors = {
diloket: "var(--color-secondary-600)", 'di-loket': "var(--color-primary-600)", // Blue
diproses: "var(--color-primary-600)", diproses: "var(--color-primary-600)", // Keep as Blue
terlambat: "var(--color-primary-600)", terlambat: "var(--color-danger-700)", // Darker Red
pending: "var(--color-danger-600)" pending: "var(--color-secondary-600)" // Orange
}; };
return colors[status] || "var(--color-neutral-600)"; return colors[status] || "var(--color-neutral-600)";
}; };
const getStatusLabel = (status) => { const getStatusLabel = (status) => {
const labels = { const labels = {
diloket: "Di Loket", 'di-loket': "Di Loket",
diproses: "Diproses", diproses: "Diproses",
terlambat: "Terlambat", terlambat: "Terlambat",
pending: "Pending" pending: "Pending"
@@ -145,6 +233,28 @@ const getStatusLabel = (status) => {
transform: translateY(0); transform: translateY(0);
} }
} }
&.fast-track-card {
background: #FBE9E8 !important;
border-color: rgba(255, 132, 65, 0.5);
&:hover {
background: #F1B3AE !important;
border-color: rgba(255, 132, 65, 0.6);
}
}
&.processing-card {
background: var(--color-primary-100) !important;
border-color: var(--color-primary-400);
box-shadow: 0 4px 12px rgba(25, 118, 210, 0.2) !important;
&:hover {
background: var(--color-primary-200) !important;
border-color: var(--color-primary-500);
box-shadow: 0 6px 16px rgba(25, 118, 210, 0.25) !important;
}
}
} }
.card-header { .card-header {
@@ -167,17 +277,24 @@ const getStatusLabel = (status) => {
color: var(--color-neutral-900); color: var(--color-neutral-900);
} }
.fast-track-icon { .fast-track-icon-wrapper {
animation: pulse 2s ease-in-out infinite; width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(239, 83, 80, 0.15);
border: 2px solid rgba(229, 57, 53, 0.4);
transition: all 0.2s ease;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(229, 57, 53, 0.2);
} }
@keyframes pulse {
0%, 100% { .fast-track-icon-wrapper .fast-track-icon {
opacity: 1; color: #E53935 !important;
} transition: all 0.2s ease;
50% {
opacity: 0.6;
}
} }
.status-chip { .status-chip {
@@ -189,46 +306,45 @@ const getStatusLabel = (status) => {
.patient-info { .patient-info {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; /* Tinggi dinamis berdasarkan konten - card tanpa dokter akan lebih compact */
} }
.info-row { .info-chips-row {
display: flex; display: flex;
justify-content: space-between;
align-items: center; align-items: center;
font-size: 13px;
gap: 8px; gap: 8px;
} flex-wrap: wrap;
.payment-info {
display: flex;
align-items: center;
gap: 4px;
}
.fast-track-badge {
font-size: 10px;
font-weight: 700;
height: 20px;
padding: 0 6px;
}
.info-label {
color: var(--color-neutral-600);
font-weight: 500;
min-width: 90px;
}
.info-value {
color: var(--color-neutral-900);
font-weight: 600;
text-align: right;
} }
.klinik-chip { .klinik-chip {
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
border: 1.5px solid currentColor; height: 24px;
border-color: var(--color-neutral-600) !important;
color: var(--color-neutral-900) !important;
background-color: transparent !important;
}
.layanan-chip {
font-size: 11px;
font-weight: 600;
height: 24px;
}
.doctor-info {
display: flex;
align-items: center;
gap: 6px;
}
.doctor-icon {
flex-shrink: 0;
}
.doctor-name {
font-size: 12px;
font-weight: 600;
color: var(--color-primary-600);
} }
.card-actions .v-btn { .card-actions .v-btn {
+94 -10
View File
@@ -12,6 +12,10 @@
<span class="quota-label">Tersedia</span> <span class="quota-label">Tersedia</span>
<span class="quota-value quota-available">{{ availableQuota }}</span> <span class="quota-value quota-available">{{ availableQuota }}</span>
</div> </div>
<div class="quota-item">
<span class="quota-label">Menunggu</span>
<span class="quota-value quota-waiting">{{ menungguCount }}</span>
</div>
<div class="quota-item full-width"> <div class="quota-item full-width">
<v-progress-linear <v-progress-linear
:model-value="quotaPercentage" :model-value="quotaPercentage"
@@ -20,7 +24,8 @@
rounded rounded
class="mt-1" class="mt-1"
/> />
<span class="quota-used">Terpakai: {{ usedQuota }}</span> <span class="quota-used">Selesai: {{ usedQuota }}</span>
<span class="quota-callable">Bisa dipanggil: {{ callableQuota }}</span>
</div> </div>
</div> </div>
@@ -53,6 +58,10 @@ const props = defineProps({
type: Number, type: Number,
default: 0 default: 0
}, },
menungguCount: {
type: Number,
default: 0
},
hasNext: { hasNext: {
type: Boolean, type: Boolean,
default: false default: false
@@ -61,6 +70,11 @@ const props = defineProps({
const availableQuota = computed(() => props.totalQuota - props.usedQuota); const availableQuota = computed(() => props.totalQuota - props.usedQuota);
const quotaPercentage = computed(() => (props.usedQuota / props.totalQuota) * 100); const quotaPercentage = computed(() => (props.usedQuota / props.totalQuota) * 100);
// Kuota yang bisa dipanggil = Tersedia - Menunggu
const callableQuota = computed(() => {
const callable = availableQuota.value - props.menungguCount;
return Math.max(0, callable);
});
defineEmits(['call']); defineEmits(['call']);
</script> </script>
@@ -70,6 +84,18 @@ defineEmits(['call']);
border-radius: 12px; border-radius: 12px;
border: 1px solid var(--color-neutral-500); border: 1px solid var(--color-neutral-500);
background: var(--color-neutral-100); background: var(--color-neutral-100);
width: 100% !important;
max-width: 100% !important;
overflow: hidden !important;
box-sizing: border-box !important;
display: block !important;
}
.queue-actions-card :deep(.v-card-text) {
padding: 16px !important;
box-sizing: border-box !important;
width: 100% !important;
max-width: 100% !important;
} }
.section-label { .section-label {
@@ -81,20 +107,37 @@ defineEmits(['call']);
} }
.quota-info { .quota-info {
display: grid; display: grid !important;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr 1fr !important;
gap: 12px; gap: 6px !important;
margin-bottom: 12px !important;
width: 100% !important;
box-sizing: border-box !important;
grid-auto-flow: row !important;
grid-auto-rows: auto !important;
} }
.quota-item { .quota-item {
background: var(--color-neutral-300); background: var(--color-neutral-300) !important;
border-radius: 8px; border-radius: 8px !important;
padding: 12px; padding: 10px 6px !important;
text-align: center; text-align: center !important;
min-width: 0 !important;
max-width: 100% !important;
width: 100% !important;
box-sizing: border-box !important;
overflow: hidden !important;
word-wrap: break-word !important;
display: flex !important;
flex-direction: column !important;
justify-content: center !important;
align-items: center !important;
flex: 1 1 0% !important;
} }
.quota-item.full-width { .quota-item.full-width {
grid-column: 1 / -1; grid-column: 1 / 4 !important;
margin-top: 8px !important;
} }
.quota-label { .quota-label {
@@ -107,15 +150,29 @@ defineEmits(['call']);
.quota-value { .quota-value {
display: block; display: block;
font-size: 24px; font-size: 22px;
font-weight: 800; font-weight: 800;
color: var(--color-neutral-900); color: var(--color-neutral-900);
line-height: 1.2;
white-space: nowrap;
} }
.quota-available { .quota-available {
color: var(--color-success-600); color: var(--color-success-600);
} }
.quota-waiting {
color: var(--color-warning-600);
}
.quota-callable {
display: block;
font-size: 11px;
color: var(--color-neutral-900);
margin-top: 4px;
font-weight: 600;
}
.quota-used { .quota-used {
display: block; display: block;
font-size: 11px; font-size: 11px;
@@ -131,13 +188,40 @@ defineEmits(['call']);
.call-buttons .v-btn { .call-buttons .v-btn {
font-size: 16px; font-size: 16px;
line-height: 24px;
font-weight: 700; font-weight: 700;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
height: 44px; height: 44px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: all 0.2s ease;
}
.call-buttons .v-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.call-buttons .v-btn:active {
transform: translateY(0);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
} }
@media (max-width: 960px) { @media (max-width: 960px) {
.call-buttons { .call-buttons {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
} }
.quota-info {
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.quota-item {
padding: 8px;
}
.quota-value {
font-size: 20px;
}
} }
</style> </style>
+37 -37
View File
@@ -18,16 +18,6 @@
{{ status.label }} ({{ status.count }}) {{ status.label }} ({{ status.count }})
</v-chip> </v-chip>
</v-chip-group> </v-chip-group>
<!-- Search Field -->
<v-text-field
v-model="searchModel"
placeholder="Cari barcode, nomor antrian..."
density="compact"
hide-details
class="search-field"
prepend-inner-icon="mdi-magnify"
/>
</div> </div>
<!-- Advanced Filters --> <!-- Advanced Filters -->
@@ -126,25 +116,27 @@
</div> </div>
</div> </div>
<!-- Results Info --> <div v-if="filteredAndSearchedItems.length > 0" key="patient-list-wrapper" class="patient-list-wrapper">
<div v-if="filteredAndSearchedItems.length > 0" class="results-info mb-3"> <!-- Results Info -->
<span class="results-text"> <div class="results-info mb-3">
Menampilkan {{ paginatedItems.length }} dari {{ filteredAndSearchedItems.length }} pasien <span class="results-text">
</span> Menampilkan {{ paginatedItems.length }} dari {{ filteredAndSearchedItems.length }} pasien
</div> </span>
</div>
<!-- Patient Cards Grid --> <!-- Patient Cards Grid -->
<div v-if="filteredAndSearchedItems.length > 0" class="patient-grid"> <div class="patient-grid">
<PatientCard <PatientCard
v-for="(patient, index) in paginatedItems" v-for="(patient, index) in paginatedItems"
:key="`${patient.barcode}-${index}`" :key="`${patient.barcode}-${index}`"
:patient="patient" :patient="patient"
@action="handleAction" @action="handleAction"
/> />
</div>
</div> </div>
<!-- Empty State --> <!-- Empty State -->
<div v-else class="empty-state"> <div v-else class="empty-state" key="patient-empty-state">
<v-icon size="64" color="neutral-500">mdi-account-search</v-icon> <v-icon size="64" color="neutral-500">mdi-account-search</v-icon>
<div class="empty-text mt-3"> <div class="empty-text mt-3">
{{ searchModel || hasActiveFilters ? 'Tidak ada pasien yang sesuai' : 'Tidak ada data pasien' }} {{ searchModel || hasActiveFilters ? 'Tidak ada pasien yang sesuai' : 'Tidak ada data pasien' }}
@@ -200,13 +192,17 @@ const props = defineProps({
type: Number, type: Number,
default: 0 default: 0
}, },
anjunganCount: {
type: Number,
default: 0
},
showDiproses: { showDiproses: {
type: Boolean, type: Boolean,
default: true default: true
}, },
itemsPerPage: { itemsPerPage: {
type: Number, type: Number,
default: 9 default: 12
}, },
statusLabels: { statusLabels: {
type: Object, type: Object,
@@ -281,7 +277,7 @@ const searchModel = computed({
const statusOptions = computed(() => { const statusOptions = computed(() => {
const baseOptions = [ const baseOptions = [
{ value: 'all', label: props.statusLabels.all, count: props.items.length }, { value: 'all', label: props.statusLabels.all, count: props.items.length },
{ value: 'diloket', label: props.statusLabels.diloket, count: props.diLoketCount } { value: 'di-loket', label: props.statusLabels.diloket, count: props.diLoketCount }
]; ];
// Tampilkan "Diproses" hanya jika: // Tampilkan "Diproses" hanya jika:
@@ -295,6 +291,16 @@ const statusOptions = computed(() => {
}); });
} }
// Tambahkan kategori "Anjungan" (pasien yang sudah dipanggil tapi belum check-in)
if (props.anjunganCount > 0) {
baseOptions.push({
value: 'anjungan',
label: 'Anjungan',
count: props.anjunganCount,
icon: 'mdi-clock-outline'
});
}
baseOptions.push( baseOptions.push(
{ value: 'terlambat', label: props.statusLabels.terlambat, count: props.terlambatCount }, { value: 'terlambat', label: props.statusLabels.terlambat, count: props.terlambatCount },
{ value: 'pending', label: props.statusLabels.pending, count: props.pendingCount } { value: 'pending', label: props.statusLabels.pending, count: props.pendingCount }
@@ -459,11 +465,6 @@ const handleAction = (patient, action) => {
border-color: var(--color-secondary-600); border-color: var(--color-secondary-600);
} }
.search-field {
max-width: 300px;
min-width: 250px;
}
.advanced-filters { .advanced-filters {
display: flex; display: flex;
gap: 12px; gap: 12px;
@@ -486,7 +487,11 @@ const handleAction = (patient, action) => {
padding: 8px 12px; padding: 8px 12px;
background: var(--color-neutral-200); background: var(--color-neutral-200);
border-radius: 8px; border-radius: 8px;
display: inline-flex;
align-items: center;
border: 1px solid var(--color-neutral-400);
border-left: 3px solid var(--color-primary-600); border-left: 3px solid var(--color-primary-600);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
} }
.results-text { .results-text {
@@ -543,11 +548,6 @@ const handleAction = (patient, action) => {
min-width: 100%; min-width: 100%;
} }
.search-field {
max-width: 100%;
min-width: 100%;
}
.advanced-filters { .advanced-filters {
flex-direction: column; flex-direction: column;
} }
@@ -0,0 +1,157 @@
<template>
<v-dialog v-model="internalModel" max-width="500px">
<v-card class="dialog-card overflow-hidden rounded-xl" elevation="0">
<!-- Solid Blue Header -->
<v-card-title class="bg-primary-600 text-white py-4 px-6 d-flex justify-space-between align-center">
<div class="d-flex align-center">
<v-icon start icon="mdi-alert-circle-outline" class="mr-2"></v-icon>
<span class="text-h6 font-weight-bold">Konfirmasi Selesai</span>
</div>
<v-btn icon="mdi-close" variant="text" size="small" @click="internalModel = false" class="text-white"></v-btn>
</v-card-title>
<v-card-text class="dialog-content-premium pa-6 text-center">
<div class="text-center mb-6 text-uppercase font-weight-bold text-caption text-neutral-600" style="letter-spacing: 1px;">
Tindakan Diperlukan
</div>
<p class="text-body-1 mb-6">
Pasien ini <strong>sudah dibuatkan</strong> tiket antrean ruang atau penunjang, namun statusnya belum diselesaikan di Loket.
</p>
<div class="comparison-container mb-6 mx-auto" style="max-width: 220px; justify-content: center;">
<div class="comparison-item current">
<div class="item-label text-center">PASIEN AKTIF</div>
<div class="item-card">
<div class="item-number text-danger-700">{{ patient?.noAntrian?.split(' |')[0] || '-' }}</div>
<div class="item-status">Menunggu Selesai</div>
</div>
</div>
</div>
<p class="text-body-2 text-neutral-600">
Apakah Anda ingin menyelesaikan pasien ini sekarang dan melanjutkan ke antrean berikutnya?
</p>
</v-card-text>
<v-divider />
<v-card-actions class="pa-4 bg-light justify-center">
<v-btn
class="px-8 font-weight-bold rounded-lg text-none btn-batal"
variant="outlined"
@click="internalModel = false"
size="large"
>
Batal
</v-btn>
<v-btn
class="px-6 font-weight-bold ml-4 rounded-lg text-none btn-confirm"
variant="flat"
@click="confirm"
size="large"
elevation="2"
>
Ya, Selesaikan
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
patient: {
type: Object,
default: null
}
});
const emit = defineEmits(['update:modelValue', 'confirm']);
const internalModel = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
});
const confirm = () => {
emit('confirm');
internalModel.value = false;
};
</script>
<style scoped>
.dialog-card {
/* No border to prevent white outline */
}
.bg-light {
background-color: var(--color-neutral-50) !important;
}
.comparison-container {
display: flex;
align-items: center;
gap: 12px;
background: white;
padding: 16px;
border-radius: 16px;
border: 1px solid var(--color-neutral-300);
}
.comparison-item {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.item-label {
font-size: 10px;
font-weight: 800;
color: var(--color-neutral-600);
letter-spacing: 0.05em;
}
.item-card {
background: white;
padding: 12px;
border-radius: 12px;
border: 1px solid var(--color-neutral-400);
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
text-align: center;
}
.item-number {
font-size: 24px;
font-weight: 800;
line-height: 1;
margin-bottom: 4px;
}
.item-status {
font-size: 11px;
color: var(--color-neutral-600);
font-weight: 600;
}
.text-danger-700 {
color: var(--color-danger-700) !important;
}
.btn-batal {
border-color: var(--color-neutral-300) !important;
color: var(--color-neutral-700) !important;
}
.btn-confirm {
background-color: var(--color-primary-600) !important;
color: white !important;
}
</style>
+38 -70
View File
@@ -17,10 +17,10 @@
link link
> >
<template v-slot:prepend> <template v-slot:prepend>
<div class="avatar-wrapper"> <div class="profile-avatar-container">
<v-avatar size="44" class="avatar-glow"> <v-avatar size="44" class="profile-avatar elevation-8">
<v-img <v-img
:src="user?.picture || 'https://i.pravatar.cc/300?img=68'" :src="getAvatarSrc(user?.picture)"
:alt="`${user?.name || 'User'} Profile`" :alt="`${user?.name || 'User'} Profile`"
></v-img> ></v-img>
</v-avatar> </v-avatar>
@@ -41,10 +41,10 @@
class="avatar-btn" class="avatar-btn"
:title="user?.name || 'Profile'" :title="user?.name || 'Profile'"
> >
<div class="avatar-wrapper"> <div class="profile-avatar-container">
<v-avatar size="44" class="avatar-glow"> <v-avatar size="44" class="profile-avatar elevation-8">
<v-img <v-img
:src="user?.picture || 'https://i.pravatar.cc/300?img=68'" :src="getAvatarSrc(user?.picture)"
:alt="`${user?.name || 'User'} Profile`" :alt="`${user?.name || 'User'} Profile`"
></v-img> ></v-img>
</v-avatar> </v-avatar>
@@ -58,10 +58,10 @@
<div class="card-header"> <div class="card-header">
<div class="header-pattern"></div> <div class="header-pattern"></div>
<div class="header-content pa-6 text-center"> <div class="header-content pa-6 text-center">
<div class="avatar-container mb-4"> <div class="profile-avatar-container mb-4">
<v-avatar size="90" class="profile-avatar"> <v-avatar size="90" class="profile-avatar elevation-8">
<v-img <v-img
:src="user?.picture || 'https://i.pravatar.cc/300?img=68'" :src="getAvatarSrc(user?.picture)"
:alt="`${user?.name || 'User'} Profile`" :alt="`${user?.name || 'User'} Profile`"
></v-img> ></v-img>
</v-avatar> </v-avatar>
@@ -93,34 +93,10 @@
@click="handleAction('profile')" @click="handleAction('profile')"
> >
<div class="tile-icon-wrapper mb-2 mx-auto"> <div class="tile-icon-wrapper mb-2 mx-auto">
<v-icon size="24" color="blue-darken-2">mdi-account-circle</v-icon> <v-icon size="24" color="primary-600">mdi-account-circle</v-icon>
</div> </div>
<div class="text-caption font-weight-medium">Profil</div> <div class="text-caption font-weight-medium">Profil</div>
</div> </div>
<div
class="menu-tile rounded-lg pa-4 text-center cursor-pointer"
@click="handleAction('account')"
>
<div class="tile-icon-wrapper mb-2 mx-auto">
<v-icon size="24" color="orange-darken-2">mdi-cog-outline</v-icon>
</div>
<div class="text-caption font-weight-medium">Pengaturan</div>
</div>
<!-- <div
class="menu-tile rounded-lg pa-4 text-center cursor-pointer"
@click="handleAction('darkMode')"
>
<div class="tile-icon-wrapper mb-2 mx-auto">
<v-icon size="24" :color="darkMode ? 'deep-purple-darken-2' : 'amber-darken-2'">
{{ darkMode ? 'mdi-moon-waning-crescent' : 'mdi-white-balance-sunny' }}
</v-icon>
</div>
<div class="text-caption font-weight-medium">
{{ darkMode ? 'Gelap' : 'Terang' }}
</div>
</div> -->
</div> </div>
<v-divider class="my-4"></v-divider> <v-divider class="my-4"></v-divider>
@@ -146,6 +122,7 @@
<script setup> <script setup>
import { ref } from 'vue'; import { ref } from 'vue';
import { navigateTo } from '#app'; import { navigateTo } from '#app';
import { DEFAULT_AVATAR } from '@/constants/avatar';
const props = defineProps({ const props = defineProps({
user: { user: {
@@ -159,10 +136,13 @@ const props = defineProps({
}) })
const menu = ref(false); const menu = ref(false);
const darkMode = ref(false);
const isLoggingOut = ref(false); const isLoggingOut = ref(false);
const emit = defineEmits(['logout']); const emit = defineEmits(['logout']);
const getAvatarSrc = (userPicture) => {
return userPicture || DEFAULT_AVATAR;
};
const signOut = async () => { const signOut = async () => {
if (isLoggingOut.value) return; if (isLoggingOut.value) return;
isLoggingOut.value = true; isLoggingOut.value = true;
@@ -177,41 +157,38 @@ const signOut = async () => {
const handleAction = (action) => { const handleAction = (action) => {
switch(action) { switch(action) {
case 'account':
navigateTo('/Profile/Pengaturan')
break;
case 'profile': case 'profile':
navigateTo('/Profile/Profil') navigateTo('/Profile/Profil')
break; break;
case 'darkMode':
darkMode.value = !darkMode.value;
return;
} }
menu.value = false; menu.value = false;
}; };
</script> </script>
<style scoped> <style scoped lang="scss">
@import '@/assets/scss/_colors.scss';
.profile-activator { .profile-activator {
background: linear-gradient(135deg, rgba(25, 118, 210, 0.1) 0%, rgba(245, 124, 0, 0.1) 100%); background: linear-gradient(135deg, $primary-100 0%, $primary-50 100%);
border: 1px solid rgba(25, 118, 210, 0.2); border: 1px solid $primary-200;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
} }
.profile-activator:hover { .profile-activator:hover {
background: linear-gradient(135deg, rgba(25, 118, 210, 0.15) 0%, rgba(245, 124, 0, 0.15) 100%); background: linear-gradient(135deg, $primary-200 0%, $primary-100 100%);
border-color: rgba(25, 118, 210, 0.3); border-color: $primary-300;
transform: translateY(-2px); transform: translateY(-2px);
} }
.avatar-wrapper { .profile-avatar-container {
position: relative; position: relative;
display: inline-block;
} }
.avatar-glow { .profile-avatar {
border: 3px solid white; border: 3px solid white;
box-shadow: 0 0 20px rgba(25, 118, 210, 0.4); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
} }
.status-dot { .status-dot {
@@ -220,7 +197,7 @@ const handleAction = (action) => {
right: 2px; right: 2px;
width: 12px; width: 12px;
height: 12px; height: 12px;
background: linear-gradient(135deg, #4caf50 0%, #8bc34a 100%); background: linear-gradient(135deg, $success-600 0%, $success-400 100%);
border: 2px solid white; border: 2px solid white;
border-radius: 50%; border-radius: 50%;
animation: pulse 2s infinite; animation: pulse 2s infinite;
@@ -251,12 +228,12 @@ const handleAction = (action) => {
.profile-card { .profile-card {
overflow: hidden; overflow: hidden;
border: 1px solid rgba(25, 118, 210, 0.1); border: 1px solid $primary-200;
} }
.card-header { .card-header {
position: relative; position: relative;
background: linear-gradient(135deg, #1976d2 0%, #fb8c00 100%); background: linear-gradient(135deg, $primary-700 0%, $primary-500 100%);
overflow: hidden; overflow: hidden;
} }
@@ -276,15 +253,6 @@ const handleAction = (action) => {
z-index: 1; z-index: 1;
} }
.avatar-container {
position: relative;
display: inline-block;
}
.profile-avatar {
border: 4px solid rgba(255, 255, 255, 0.3);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
}
.status-badge { .status-badge {
position: absolute; position: absolute;
@@ -292,7 +260,7 @@ const handleAction = (action) => {
right: 0; right: 0;
width: 28px; width: 28px;
height: 28px; height: 28px;
background: linear-gradient(135deg, #4caf50 0%, #8bc34a 100%); background: linear-gradient(135deg, $success-600 0%, $success-400 100%);
border: 3px solid white; border: 3px solid white;
border-radius: 50%; border-radius: 50%;
display: flex; display: flex;
@@ -310,20 +278,20 @@ const handleAction = (action) => {
.menu-grid { .menu-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr); grid-template-columns: 1fr;
gap: 12px; gap: 12px;
} }
.menu-tile { .menu-tile {
background: linear-gradient(135deg, rgba(25, 118, 210, 0.05) 0%, rgba(245, 124, 0, 0.05) 100%); background: linear-gradient(135deg, $primary-100 0%, $primary-50 100%);
border: 1px solid rgba(25, 118, 210, 0.1); border: 1px solid $primary-200;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer; cursor: pointer;
} }
.menu-tile:hover { .menu-tile:hover {
background: linear-gradient(135deg, rgba(25, 118, 210, 0.1) 0%, rgba(245, 124, 0, 0.1) 100%); background: linear-gradient(135deg, $primary-200 0%, $primary-100 100%);
border-color: rgba(25, 118, 210, 0.3); border-color: $primary-300;
transform: translateY(-4px); transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1); box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
} }
@@ -351,7 +319,7 @@ const handleAction = (action) => {
} }
.action-item:hover { .action-item:hover {
background: linear-gradient(135deg, rgba(25, 118, 210, 0.05) 0%, rgba(245, 124, 0, 0.05) 100%); background: linear-gradient(135deg, $primary-100 0%, $primary-50 100%);
transform: translateX(4px); transform: translateX(4px);
} }
@@ -359,11 +327,11 @@ const handleAction = (action) => {
text-transform: none; text-transform: none;
font-weight: 600; font-weight: 600;
letter-spacing: 0.5px; letter-spacing: 0.5px;
box-shadow: 0 4px 12px rgba(244, 67, 54, 0.3); box-shadow: 0 4px 12px rgba(222, 71, 59, 0.3);
} }
.logout-btn:hover { .logout-btn:hover {
box-shadow: 0 6px 20px rgba(244, 67, 54, 0.4); box-shadow: 0 6px 20px rgba(222, 71, 59, 0.4);
} }
.cursor-pointer { .cursor-pointer {
+461 -45
View File
@@ -9,20 +9,28 @@
@mouseenter="handleMouseEnter" @mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave" @mouseleave="handleMouseLeave"
> >
<v-list-item class="py-4 px-2" :class="{ 'text-center': rail }"> <v-list-item class="py-4 px-3 sidebar-logo-container" :class="{ 'text-center': rail }">
<div class="d-flex align-center" :class="{ 'justify-center': rail }"> <NuxtLink to="/dashboard" class="sidebar-logo-link">
<img <div class="d-flex align-center sidebar-logo-wrapper" :class="{ 'justify-center': rail }">
src="/Antrean Logo.png" <div class="sidebar-logo-image-wrapper">
alt="Antrean Logo" <img
style="width: 72px; height: 72px; object-fit: contain" src="/love logo biru small.png"
/> alt="Antrean Logo"
<v-list-item-title class="sidebar-logo"
v-if="!rail" :class="{ 'sidebar-logo-rail': rail }"
class="ml-2 text-h6 font-weight-bold text-blue-grey-darken-4" width="32"
> height="32"
<span class="text-orange-darken-2">Antrean</span> RSSA style="width: 32px; height: 32px; object-fit: contain;"
</v-list-item-title> />
</div> </div>
<v-list-item-title
v-if="!rail"
class="ml-2 text-h6 font-weight-bold sidebar-title"
>
<span class="sidebar-title-accent">Antrean</span> RSSA
</v-list-item-title>
</div>
</NuxtLink>
</v-list-item> </v-list-item>
<v-divider></v-divider> <v-divider></v-divider>
@@ -33,33 +41,92 @@
v-model:opened="openedGroups" v-model:opened="openedGroups"
> >
<template v-for="item in items" :key="item.name"> <template v-for="item in items" :key="item.name">
<v-list-group v-if="item.children" :value="item.name" :disabled="rail"> <!-- Popup Menu ketika locked dan rail -->
<v-menu
v-if="item.children && rail && isLocked"
location="end"
offset="8"
>
<template #activator="{ props: menuProps }">
<v-tooltip
open-on-hover
location="end"
:text="item.name"
>
<template #activator="{ props: tooltipProps }">
<v-list-item
v-bind="{ ...menuProps, ...tooltipProps }"
:prepend-icon="item.icon"
:title="item.name"
class="sidebar-nav-item"
active-class="sidebar-nav-item-active"
></v-list-item>
</template>
</v-tooltip>
</template>
<v-list density="compact" class="sidebar-popup-menu">
<v-tooltip
v-for="child in item.children"
:key="child.name"
open-on-hover
location="end"
:text="child.name"
>
<template #activator="{ props: tooltipProps }">
<v-list-item
v-bind="tooltipProps"
:to="child.path"
:title="child.name"
:prepend-icon="child.icon"
link
class="sidebar-nav-item"
active-class="sidebar-nav-item-active"
@click="closePopup"
></v-list-item>
</template>
</v-tooltip>
</v-list>
</v-menu>
<!-- Dropdown normal ketika tidak locked atau expanded -->
<v-list-group
v-else-if="item.children"
:value="item.name"
:disabled="rail"
>
<template v-slot:activator="{ props: groupProps }"> <template v-slot:activator="{ props: groupProps }">
<v-list-item <v-list-item
v-bind="groupProps" v-bind="groupProps"
:prepend-icon="item.icon" :prepend-icon="item.icon"
:title="item.name" :title="item.name"
color="orange-darken-2" class="sidebar-nav-item"
active-class="bg-orange-lighten-5 text-orange-darken-2 font-weight-bold" active-class="sidebar-nav-item-active"
></v-list-item> ></v-list-item>
</template> </template>
<v-list-item <v-tooltip
v-for="child in item.children" v-for="child in item.children"
:key="child.name" :key="child.name"
:to="child.path" open-on-hover
:title="child.name" location="end"
:prepend-icon="child.icon" :text="child.name"
link >
class="pl-8" <template #activator="{ props: tooltipProps }">
color="orange-darken-2" <v-list-item
active-class="bg-orange-lighten-5 text-orange-darken-2 font-weight-bold" v-bind="tooltipProps"
></v-list-item> :to="child.path"
:title="child.name"
:prepend-icon="child.icon"
link
class="pl-8 sidebar-nav-item"
active-class="sidebar-nav-item-active"
></v-list-item>
</template>
</v-tooltip>
</v-list-group> </v-list-group>
<v-tooltip <v-tooltip
v-else v-else
:disabled="!rail"
open-on-hover open-on-hover
location="end" location="end"
:text="item.name" :text="item.name"
@@ -71,8 +138,8 @@
:title="item.name" :title="item.name"
:to="item.path" :to="item.path"
link link
color="orange-darken-2" class="sidebar-nav-item"
active-class="bg-orange-lighten-5 text-orange-darken-2 font-weight-bold" active-class="sidebar-nav-item-active"
></v-list-item> ></v-list-item>
</template> </template>
</v-tooltip> </v-tooltip>
@@ -81,23 +148,55 @@
<v-divider></v-divider> <v-divider></v-divider>
<v-list v-if="user" nav density="compact" class="pa-2 flex-shrink-0"> <!-- Bottom section wrapper for profile and lock button -->
<ProfilePopup :user="user" @logout="handleLogout" :rail="rail" /> <div class="sidebar-bottom-section">
</v-list> <v-list v-if="user" nav density="compact" class="pa-2">
<v-list v-else nav density="compact" class="flex-shrink-0"> <ProfilePopup :user="user" @logout="handleLogout" :rail="rail" />
<v-list-item </v-list>
@click="redirectToLogin" <v-list v-else nav density="compact">
prepend-icon="mdi-login" <v-list-item
title="Login" @click="redirectToLogin"
link prepend-icon="mdi-login"
color="blue-grey-darken-4" title="Login"
></v-list-item> link
</v-list> class="sidebar-login-item"
></v-list-item>
</v-list>
<!-- Custom Lock Sidebar Toggle (Lock-to-Rail) -->
<div class="sidebar-lock-wrapper" :class="{ 'sidebar-lock-rail': rail }">
<v-tooltip
open-on-hover
location="end"
:text="isLocked ? 'Aktifkan Hover' : 'Matikan Hover'"
>
<template #activator="{ props: tooltipProps }">
<div
v-bind="tooltipProps"
class="custom-dock-lock"
:class="{ 'is-locked': isLocked }"
@click="handleLockToggle(!isLocked)"
>
<div class="dock-lock-icon-box">
<v-icon size="20">
{{ isLocked ? 'mdi-dock-right' : 'mdi-dock-left' }}
</v-icon>
</div>
<div v-if="!rail" class="dock-lock-details">
<span class="dock-lock-label">Sidebar Auto-Expand</span>
<span class="dock-lock-status">{{ isLocked ? 'Nonaktif' : 'Aktif' }}</span>
</div>
</div>
</template>
</v-tooltip>
</div>
</div>
</v-navigation-drawer> </v-navigation-drawer>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { defineProps, defineEmits, onMounted, ref, watch } from "vue"; import { onMounted, ref, watch } from "vue";
import { navigateTo } from "#app"; import { navigateTo } from "#app";
import { useLocalStorage } from "@vueuse/core";
import ProfilePopup from "./ProfilePopup.vue"; import ProfilePopup from "./ProfilePopup.vue";
import { useAuth } from "~/composables/useAuth"; import { useAuth } from "~/composables/useAuth";
@@ -140,9 +239,15 @@ watch(
); );
const isHovering = ref(false); const isHovering = ref(false);
const isLocked = useLocalStorage("sidebar-locked", false);
// Reset hover state when locked
watch(isLocked, (val) => {
if (val) isHovering.value = false;
});
const handleMouseEnter = () => { const handleMouseEnter = () => {
if (props.rail) { if (props.rail && !isLocked.value) {
isHovering.value = true; isHovering.value = true;
emit("toggle-rail"); emit("toggle-rail");
} }
@@ -151,10 +256,30 @@ const handleMouseEnter = () => {
const handleMouseLeave = () => { const handleMouseLeave = () => {
if (isHovering.value) { if (isHovering.value) {
isHovering.value = false; isHovering.value = false;
// Only toggle rail if not locked
if (!isLocked.value) {
emit("toggle-rail");
}
}
};
const handleLockToggle = (value?: boolean | null) => {
const lockValue = value ?? !isLocked.value;
isLocked.value = lockValue;
// Cleanup hovering state
isHovering.value = false;
// Jika di-lock, pastikan sidebar kembali ke rail mode
if (lockValue && !props.rail) {
emit("toggle-rail"); emit("toggle-rail");
} }
}; };
const closePopup = () => {
// Popup akan otomatis tertutup saat item diklik
};
// --- AUTH LOGIC (Kept the same) --- // --- AUTH LOGIC (Kept the same) ---
const handleLogout = async () => { const handleLogout = async () => {
@@ -175,6 +300,297 @@ onMounted(async () => {
}); });
</script> </script>
<style scoped> <style lang="scss" scoped>
/* No specific overrides needed */ @use '~/assets/scss/_colors.scss' as *;
:deep(.v-navigation-drawer) {
position: fixed !important;
top: 0 !important;
left: 0 !important;
height: 100vh !important;
z-index: 1000 !important;
}
.sidebar-bottom-section {
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.sidebar-logo-container {
overflow: visible !important;
height: 80px !important;
:deep(.v-list-item__content) {
overflow: visible !important;
}
&.text-center {
display: flex;
justify-content: center;
align-items: center;
padding-left: 8px !important;
padding-right: 8px !important;
}
}
.sidebar-logo-link {
text-decoration: none;
color: inherit;
display: block;
width: 100%;
cursor: pointer;
transition: opacity 0.2s ease;
overflow: visible;
&:hover {
opacity: 0.8;
}
&:active {
opacity: 0.6;
}
}
.sidebar-logo-wrapper {
width: 100%;
overflow: visible;
&.justify-center {
justify-content: center;
width: 100%;
margin: 0 auto;
}
}
.sidebar-logo-image-wrapper {
display: flex;
align-items: center;
justify-content: center;
min-width: 40px;
padding: 2px;
overflow: visible;
}
.sidebar-logo {
width: 32px;
height: 32px;
min-width: 32px;
min-height: 32px;
object-fit: contain;
display: block;
cursor: pointer;
transition: transform 0.2s ease;
flex-shrink: 0;
&:hover {
transform: scale(1.05);
}
}
.sidebar-logo-rail {
margin: 0 auto;
width: auto;
max-width: none;
}
.sidebar-title {
color: var(--color-neutral-800);
}
.sidebar-title-accent {
color: var(--color-primary-600);
}
.sidebar-nav-item {
color: var(--color-neutral-700);
:deep(.v-list-item__prepend) {
.v-icon {
color: var(--color-neutral-600);
}
}
&:hover {
background-color: var(--color-neutral-300);
color: var(--color-primary-600);
:deep(.v-list-item__prepend) {
.v-icon {
color: var(--color-primary-600);
}
}
}
}
.sidebar-nav-item-active {
background-color: var(--color-primary-50) !important;
color: var(--color-primary-600) !important;
font-weight: bold;
:deep(.v-list-item__prepend) {
.v-icon {
color: var(--color-primary-600) !important;
}
}
}
.sidebar-login-item {
color: var(--color-neutral-800);
:deep(.v-list-item__prepend) {
.v-icon {
color: var(--color-neutral-700);
}
}
&:hover {
background-color: var(--color-neutral-300);
color: var(--color-primary-600);
:deep(.v-list-item__prepend) {
.v-icon {
color: var(--color-primary-600);
}
}
}
}
.sidebar-lock-wrapper {
padding: 12px;
display: flex;
justify-content: flex-start;
align-items: center;
transition: all 0.3s ease;
border-top: 1px solid var(--color-neutral-300);
&.sidebar-lock-rail {
padding: 12px 6px;
justify-content: center;
border-top: none;
}
}
.custom-dock-lock {
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
padding: 8px;
border-radius: 12px;
transition: all 0.2s ease;
user-select: none;
width: 100%;
border: 1px solid transparent;
&:hover {
background-color: var(--color-neutral-300);
}
&.is-locked {
background-color: var(--color-danger-100);
border-color: var(--color-danger-200);
.dock-lock-icon-box {
color: var(--color-danger-600);
}
.dock-lock-status {
color: var(--color-danger-600);
}
&:hover {
background-color: var(--color-danger-200);
}
}
}
.dock-lock-icon-box {
display: flex;
align-items: center;
justify-content: center;
color: var(--color-primary-600);
transition: all 0.3s ease;
flex-shrink: 0;
}
.dock-lock-details {
display: flex;
flex-direction: column;
line-height: 1.2;
}
.dock-lock-label {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--color-neutral-600);
}
.dock-lock-status {
font-size: 0.85rem;
font-weight: 600;
color: var(--color-primary-600);
}
.sidebar-lock-rail {
.custom-dock-lock {
width: 44px;
height: 44px;
padding: 0;
justify-content: center;
border-radius: 10px;
}
.dock-lock-details {
display: none;
}
}
.sidebar-popup-menu {
min-width: 200px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
background-color: var(--color-neutral-100);
:deep(.v-list-item) {
min-height: 40px;
padding-left: 16px;
padding-right: 16px;
&.sidebar-nav-item {
color: var(--color-neutral-700);
.v-list-item__prepend {
.v-icon {
color: var(--color-neutral-600);
}
}
&:hover {
background-color: var(--color-neutral-300);
color: var(--color-primary-600);
.v-list-item__prepend {
.v-icon {
color: var(--color-primary-600);
}
}
}
&.sidebar-nav-item-active {
background-color: var(--color-primary-50) !important;
color: var(--color-primary-600) !important;
font-weight: bold;
.v-list-item__prepend {
.v-icon {
color: var(--color-primary-600) !important;
}
}
}
}
}
}
</style> </style>
+541
View File
@@ -0,0 +1,541 @@
<template>
<div class="loket-preview-container">
<!-- Loading State -->
<div v-if="isLoading" class="loading-overlay">
<v-progress-circular indeterminate color="primary" size="60"></v-progress-circular>
<p class="mt-4">Memuat data loket...</p>
</div>
<!-- Header -->
<div v-else class="preview-header">
<div class="header-left">
<div class="logo-circle">
<v-icon size="40" color="primary">mdi-hospital-box</v-icon>
</div>
<div class="header-text">
<h1 class="hospital-name">ANTRIAN LOKET</h1>
<p class="display-subtitle">RSUD dr. Saiful Anwar Provinsi Jawa Timur</p>
<p v-if="loketData" class="loket-info">{{ loketData.namaLoket }}</p>
</div>
</div>
<div class="header-right">
<div class="datetime-display">
<div class="time-large">{{ currentTime }}</div>
<div class="date-small">{{ currentDate }}</div>
</div>
</div>
</div>
<!-- Main Grid - Display Klinik -->
<div v-if="!isLoading" class="kliniks-grid">
<div
v-for="klinik in displayedClinics"
:key="klinik.name"
class="klinik-box"
>
<!-- Klinik Header -->
<div class="klinik-header-bar">
<span class="klinik-title">{{ klinik.name }}</span>
<v-chip size="small" class="klinik-count">
<v-icon size="16" color="white" class="mr-1">mdi-account-multiple</v-icon>
<span class="count-text">{{ klinik.totalQueues }}</span>
</v-chip>
</div>
<!-- Queue Content -->
<div class="klinik-content">
<!-- Current Serving Queue (Large) -->
<div class="current-serving-section">
<div class="current-label">SEDANG DILAYANI</div>
<div
v-if="klinik.currentQueue"
class="current-number-large"
>
{{ klinik.currentQueue.noAntrian.split(' |')[0] }}
</div>
<div v-else class="current-waiting-text">
MENUNGGU PANGGILAN
</div>
</div>
<!-- All Queues Grid (Small) -->
<div
v-if="klinik.allQueues && klinik.allQueues.length > 0"
class="all-queues-grid"
:style="getGridStyle(klinik.allQueues.length)"
>
<div
v-for="queue in klinik.allQueues"
:key="`queue-${queue.no}`"
class="queue-grid-item"
:class="{
'is-current': queue.no === klinik.currentQueue?.no,
}"
>
{{ queue.noAntrian.split(' |')[0] }}
</div>
</div>
<!-- Empty State -->
<div v-if="!klinik.currentQueue && (!klinik.allQueues || klinik.allQueues.length === 0)" class="empty-state">
<v-icon size="48" color="grey-lighten-3">mdi-clock-outline</v-icon>
<p class="empty-text">Tidak Ada Antrian</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useLoketStore } from '@/stores/loketStore'
const props = defineProps({
loketId: {
type: Number,
required: true
}
})
const loketStore = useLoketStore()
const currentTime = ref('')
const currentDate = ref('')
const isLoading = ref(true)
let timeInterval = null
// Get loket data
const loketData = computed(() => {
return loketStore.getLoketById(props.loketId) || null
})
// Mock queue data untuk preview
const mockQueues = ref([
{ no: 1, noAntrian: 'RT001', klinik: 'R. TINDAKAN', status: 'current' },
{ no: 2, noAntrian: 'RT002', klinik: 'R. TINDAKAN', status: 'anjungan' },
{ no: 3, noAntrian: 'RD001', klinik: 'RADIOTERAPI', status: 'anjungan' },
{ no: 4, noAntrian: 'RM001', klinik: 'REHAB MEDIK', status: 'anjungan' },
])
// Simulasi klinik dengan queue
const displayedClinics = computed(() => {
// Gunakan pelayanan dari loketData jika available, atau gunakan contoh default
const pelayanan = loketData.value?.pelayanan || ['RT', 'RD', 'RM']
// Map pelayanan ke nama klinik untuk preview
const klinikNames = {
'RT': 'R. TINDAKAN',
'RD': 'RADIOTERAPI',
'RM': 'REHAB MEDIK',
'RA': 'RADIOTERAPI',
'IP': 'RAWAT INAP',
'RJ': 'RAWAT JALAN',
}
const clinicsMap = new Map()
// Create mock clinic entries berdasarkan pelayanan
pelayanan.forEach(kode => {
const klinikName = klinikNames[kode] || kode
if (!clinicsMap.has(klinikName)) {
clinicsMap.set(klinikName, {
name: klinikName,
code: kode,
allQueues: [],
currentQueue: null,
totalQueues: 0
})
}
})
// Distribute mock queues
mockQueues.value.forEach(queue => {
const klinikEntry = clinicsMap.get(queue.klinik)
if (klinikEntry) {
klinikEntry.allQueues.push(queue)
if (queue.status === 'current') {
klinikEntry.currentQueue = queue
}
}
})
// Calculate totals
clinicsMap.forEach(clinic => {
clinic.totalQueues = clinic.allQueues.length
})
return Array.from(clinicsMap.values())
})
const getGridStyle = (queueCount) => {
if (queueCount <= 3) return { gridTemplateColumns: `repeat(${queueCount}, 1fr)` }
if (queueCount <= 6) return { gridTemplateColumns: 'repeat(3, 1fr)' }
return { gridTemplateColumns: 'repeat(4, 1fr)' }
}
const updateTime = () => {
const now = new Date()
currentTime.value = now.toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
currentDate.value = now.toLocaleDateString('id-ID', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric'
})
}
onMounted(async () => {
try {
// Fetch loket data dari API terlebih dahulu
console.log('📊 [LoketPreview] Fetching loket data for ID:', props.loketId);
const result = await loketStore.fetchLoketFromAPI(true)
if (result.success) {
console.log('✅ [LoketPreview] Loket data fetched successfully:', result.message);
} else {
console.warn('⚠️ [LoketPreview] Failed to fetch loket data:', result.message);
}
} catch (error) {
console.error('❌ [LoketPreview] Error fetching loket data:', error);
} finally {
isLoading.value = false
// Start timer setelah data di-fetch
updateTime()
timeInterval = setInterval(updateTime, 1000)
}
})
</script>
<style scoped lang="scss">
// Color variables
$primary-600: #3A61C9;
$primary-700: #3556AE;
$primary-50: #F0F4FF;
$primary-200: #9AB2F1;
$neutral-100: #FFFFFF;
$neutral-300: #F5F7FA;
$neutral-600: #89939E;
$neutral-700: #717171;
$neutral-900: #212121;
$warning-600: #FF9800;
$warning-700: #F57C00;
$warning-100: #FFF3E0;
.loket-preview-container {
background: $neutral-300;
padding: 24px;
font-family: 'Inter', 'Roboto', sans-serif;
display: flex;
flex-direction: column;
gap: 20px;
min-height: 100%;
position: relative;
}
/* ========== LOADING STATE ========== */
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.95);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 100;
border-radius: 8px;
backdrop-filter: blur(5px);
}
.loading-overlay p {
color: $neutral-700;
font-size: 16px;
font-weight: 500;
}
/* ========== HEADER ========== */
.preview-header {
display: flex;
justify-content: space-between;
align-items: center;
background: linear-gradient(135deg, $primary-600 0%, $primary-700 100%);
border-radius: 16px;
padding: 24px 40px;
box-shadow: 0 8px 24px rgba(33, 150, 243, 0.3);
}
.header-left {
display: flex;
align-items: center;
gap: 24px;
}
.logo-circle {
background: rgba(255, 255, 255, 0.95);
width: 80px;
height: 80px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(10px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.hospital-name {
font-size: 48px;
font-weight: 800;
color: $neutral-100;
margin: 0;
letter-spacing: 2px;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
line-height: 1;
}
.display-subtitle {
font-size: 20px;
font-weight: 600;
color: $neutral-100;
margin: 6px 0 0 0;
opacity: 0.95;
}
.loket-info {
font-size: 16px;
font-weight: 500;
color: $neutral-100;
margin: 4px 0 0 0;
opacity: 0.85;
}
.header-right {
text-align: right;
}
.datetime-display {
text-align: right;
}
.time-large {
font-size: 56px;
font-weight: 900;
color: $neutral-100;
letter-spacing: 2px;
line-height: 1;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.date-small {
font-size: 18px;
color: $neutral-100;
margin-top: 8px;
font-weight: 500;
opacity: 0.95;
}
/* ========== KLINIKS GRID ========== */
.kliniks-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 20px;
flex: 1;
}
.klinik-box {
background: $neutral-100;
border: 2px solid $primary-200;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(33, 150, 243, 0.12);
min-height: 320px;
display: flex;
flex-direction: column;
}
.klinik-header-bar {
background: linear-gradient(135deg, $primary-600 0%, $primary-700 100%);
padding: 18px 22px;
display: flex;
justify-content: space-between;
align-items: center;
}
.klinik-title {
font-size: 24px;
font-weight: 800;
color: $neutral-100;
letter-spacing: 0.5px;
text-transform: uppercase;
line-height: 1.2;
flex: 1;
}
.klinik-count {
background: rgba(255, 255, 255, 0.2) !important;
color: $neutral-100 !important;
font-weight: 700;
backdrop-filter: blur(10px);
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px !important;
}
.count-text {
font-size: 18px;
font-weight: 800;
}
.klinik-content {
flex: 1;
display: flex;
flex-direction: column;
padding: 22px;
background: $primary-50;
gap: 12px;
}
/* Current Serving Section */
.current-serving-section {
text-align: center;
}
.current-label {
font-size: 16px;
font-weight: 700;
color: $primary-600;
margin-bottom: 12px;
letter-spacing: 1px;
text-transform: uppercase;
}
.current-number-large {
font-size: 80px;
font-weight: 900;
color: $neutral-900;
letter-spacing: 4px;
line-height: 1;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.current-waiting-text {
font-size: 32px;
font-weight: 700;
color: $neutral-600;
letter-spacing: 1px;
padding: 40px 0;
opacity: 0.7;
}
/* All Queues Grid */
.all-queues-grid {
display: grid;
gap: 10px;
margin-top: 12px;
}
.queue-grid-item {
background: $neutral-100;
border: 2px solid $neutral-600;
border-radius: 8px;
padding: 10px 8px;
text-align: center;
font-size: 16px;
font-weight: 700;
color: $neutral-700;
transition: all 0.3s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
&.is-current {
background: $warning-100;
border: 3px solid $warning-600;
color: $warning-700;
font-weight: 800;
box-shadow: 0 2px 6px rgba(255, 152, 0, 0.3);
}
}
.empty-state {
text-align: center;
padding: 32px 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.empty-text {
font-size: 16px;
color: $neutral-600;
margin-top: 12px;
font-weight: 600;
}
/* Responsive */
@media (max-width: 1366px) {
.hospital-name {
font-size: 36px;
}
.time-large {
font-size: 48px;
}
.klinik-title {
font-size: 20px;
}
.current-number-large {
font-size: 72px;
}
.current-waiting-text {
font-size: 24px;
padding: 30px 0;
}
}
@media (max-width: 768px) {
.loket-preview-container {
padding: 16px;
}
.preview-header {
flex-direction: column;
text-align: center;
gap: 16px;
}
.header-left {
flex-direction: column;
}
.header-right {
width: 100%;
}
.kliniks-grid {
grid-template-columns: 1fr;
}
.hospital-name {
font-size: 28px;
}
.time-large {
font-size: 36px;
}
.current-number-large {
font-size: 60px;
}
}
</style>
@@ -1,83 +1,93 @@
<template> <template>
<v-card-text class="pa-0 bg-white"> <v-card-text class="pa-0 bg-white">
<v-list lines="two" class="pa-0"> <v-table class="patient-table">
<v-list-item <!-- Table Body -->
v-for="patient in patients" <tbody>
:key="patient.rm" <tr
class="patient-item" v-for="patient in patients"
:class="{ 'verified-item': patient.status === 'Terverifikasi' }" :key="patient.rm"
> class="table-row"
<template #prepend> :class="{ 'verified-row': patient.status === 'Terverifikasi' }"
<v-avatar >
:color="patient.status === 'Terverifikasi' ? 'secondary-600' : 'primary-600'" <!-- Status Avatar -->
size="64" <td class="table-cell status-cell">
class="patient-avatar" <div class="status-wrapper">
> <v-avatar
<v-icon size="36" color="white"> :color="patient.status === 'Terverifikasi' ? 'secondary-600' : 'primary-600'"
{{ patient.status === 'Terverifikasi' ? 'mdi-check-decagram' : 'mdi-clock-alert' }} size="48"
</v-icon> class="patient-avatar"
</v-avatar> >
</template> <v-icon size="24" color="white">
{{ patient.status === 'Terverifikasi' ? 'mdi-check-decagram' : 'mdi-clock-alert' }}
</v-icon>
</v-avatar>
</div>
</td>
<v-list-item-title class="patient-name"> <!-- Nama -->
{{ patient.nama }} <td class="table-cell">
</v-list-item-title> <span class="patient-name">{{ patient.nama }}</span>
</td>
<v-list-item-subtitle class="mt-2"> <!-- RM -->
<v-row dense class="patient-info"> <td class="table-cell">
<v-col cols="12" sm="3" md="2" class="py-1"> <v-chip size="small" color="primary-200" class="chip-rm" variant="flat">
<v-chip size="default" color="primary-200" class="chip-rm" variant="flat"> <v-icon start size="14" color="secondary-600">mdi-file-document</v-icon>
<v-icon start size="18" color="secondary-600">mdi-file-document</v-icon> <span class="chip-rm-text">{{ patient.rm }}</span>
<span class="chip-rm-text">{{ patient.rm }}</span> </v-chip>
</v-chip> </td>
</v-col>
<v-col cols="12" sm="5" md="6" class="py-1 info-item"> <!-- Alamat -->
<v-icon size="18" class="mr-2" color="neutral-600">mdi-map-marker</v-icon> <td class="table-cell">
<div class="info-item">
<v-icon size="14" class="mr-2" color="neutral-600">mdi-map-marker</v-icon>
<span>{{ patient.alamat }}</span> <span>{{ patient.alamat }}</span>
</v-col> </div>
</td>
<v-col cols="12" sm="4" md="4" class="py-1 info-item"> <!-- Nomor Telepon -->
<v-icon size="18" class="mr-2" color="neutral-600">mdi-phone</v-icon> <td class="table-cell">
<div class="info-item">
<v-icon size="14" class="mr-2" color="neutral-600">mdi-phone</v-icon>
<span>{{ patient.telepon || 'Belum diisi' }}</span> <span>{{ patient.telepon || 'Belum diisi' }}</span>
</v-col> </div>
</v-row> </td>
</v-list-item-subtitle>
<template #append> <!-- Aksi -->
<v-btn <td class="table-cell action-col">
v-if="patient.status === 'Belum Terverifikasi'" <div class="action-wrapper">
color="primary-600" <v-btn
size="x-large" v-if="patient.status === 'Belum Terverifikasi'"
@click="$emit('verify', patient)" variant="flat"
prepend-icon="mdi-qrcode-scan" class="action-btn"
variant="flat" @click="$emit('verify', patient)"
class="action-btn" >
rounded="xl" <v-icon start size="16">mdi-qrcode-scan</v-icon>
> VERIFIKASI
VERIFIKASI </v-btn>
</v-btn>
<v-chip <v-btn
v-else v-else
color="secondary-600" color="secondary-600"
size="x-large" variant="flat"
variant="flat" class="verified-btn"
class="verified-chip" disabled
rounded="xl" >
> <v-icon start size="16">mdi-shield-check</v-icon>
<v-icon start size="24">mdi-shield-check</v-icon> VERIFIED
VERIFIED </v-btn>
</v-chip> </div>
</template> </td>
</v-list-item> </tr>
<v-list-item v-if="patients.length === 0"> <!-- Empty State -->
<v-list-item-title class="empty-state"> <tr v-if="patients.length === 0">
{{ emptyMessage }} <td colspan="6" class="empty-state-cell">
</v-list-item-title> <div class="empty-state">{{ emptyMessage }}</div>
</v-list-item> </td>
</v-list> </tr>
</tbody>
</v-table>
</v-card-text> </v-card-text>
</template> </template>
@@ -99,12 +109,14 @@ defineEmits(['verify']);
<style scoped lang="scss"> <style scoped lang="scss">
$neutral-100: #FFFFFF; $neutral-100: #FFFFFF;
$neutral-400: #E5F7FA; $neutral-400: #E5F7FA;
$neutral-500: #ABBED1;
$neutral-600: #89939E; $neutral-600: #89939E;
$neutral-700: #717171; $neutral-700: #717171;
$neutral-900: #212121; $neutral-900: #212121;
$primary-100: #FFE8CC; $primary-100: #FFE8CC;
$primary-200: #FFDCAF; $primary-200: #FFDCAF;
$primary-600: #FFA532; $primary-600: #FFA532;
$primary-700: #FF9B1B;
$secondary-200: #EDF5FF; $secondary-200: #EDF5FF;
$secondary-300: #DBEDFF; $secondary-300: #DBEDFF;
$secondary-400: #B3D9FF; $secondary-400: #B3D9FF;
@@ -116,63 +128,129 @@ $font-weight-semibold: 600;
$font-weight-bold: 700; $font-weight-bold: 700;
$font-weight-extra-bold: 800; $font-weight-extra-bold: 800;
.patient-item { .patient-table {
border-bottom: 1px solid $neutral-400; font-family: $font-family-base;
padding: 24px; width: 100%;
border-collapse: collapse;
table-layout: auto;
:deep(thead),
:deep(tbody),
:deep(tr) {
margin: 0;
padding: 0;
}
}
// ============================================
// TABLE HEADER
// ============================================
.table-header {
background-color: $neutral-400;
}
.header-cell {
padding: 16px 24px !important;
text-align: left;
font-weight: $font-weight-bold;
font-size: 14px;
color: $neutral-900;
text-transform: uppercase;
letter-spacing: 0.5px;
font-family: $font-family-base;
border-bottom: 2px solid $neutral-500;
vertical-align: middle;
&.status-header-hidden {
padding: 16px 24px !important;
width: 96px;
min-width: 96px;
max-width: 96px;
}
&.action-col {
text-align: right;
padding-right: 24px !important;
}
}
// ============================================
// TABLE BODY
// ============================================
.table-row {
transition: all 0.3s ease; transition: all 0.3s ease;
position: relative; position: relative;
overflow: hidden;
&:hover {
background-color: $primary-100 !important;
&::before {
transform: scaleY(1);
}
}
&::before {
content: '';
position: absolute;
left: 0;
top: 0;
height: 100%;
width: 4px;
background: $primary-600;
transform: scaleY(0);
transition: transform 0.3s ease;
}
}
.verified-row {
background-color: $secondary-200 !important;
&::before {
background: $secondary-600 !important;
}
&:hover {
background-color: $secondary-300 !important;
}
}
.table-cell {
padding: 16px 24px !important;
vertical-align: middle;
font-family: $font-family-base; font-family: $font-family-base;
border-bottom: 1px solid $neutral-400;
&.status-cell {
text-align: center;
padding: 16px 24px !important;
width: 96px;
min-width: 96px;
max-width: 96px;
}
&.action-col {
text-align: right;
padding-right: 24px !important;
}
} }
.patient-item::before { .status-wrapper {
content: ''; display: flex;
position: absolute; justify-content: center;
left: 0; align-items: center;
top: 0; width: 100%;
height: 100%;
width: 4px;
background: $primary-600;
transform: scaleY(0);
transition: transform 0.3s ease;
}
.patient-item:hover::before {
transform: scaleY(1);
}
.patient-item:hover {
background: $primary-100 !important;
transform: translateX(4px);
}
.verified-item {
background: $secondary-200 !important;
}
.verified-item::before {
background: $secondary-600 !important;
}
.verified-item:hover {
background: $secondary-300 !important;
} }
.patient-avatar { .patient-avatar {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
border: 3px solid $neutral-100; border: 3px solid $neutral-100;
margin: 0 auto;
} }
.patient-name { .patient-name {
font-size: 24px; font-size: 18px;
font-weight: $font-weight-extra-bold; font-weight: $font-weight-extra-bold;
color: $neutral-900; color: $neutral-900;
margin-bottom: 8px;
font-family: $font-family-base;
}
.patient-info {
font-size: 14px;
font-family: $font-family-base; font-family: $font-family-base;
} }
@@ -195,51 +273,93 @@ $font-weight-extra-bold: 800;
font-family: $font-family-base; font-family: $font-family-base;
} }
.action-wrapper {
display: flex;
align-items: center;
justify-content: flex-end;
}
.action-btn { .action-btn {
background-color: $primary-700 !important;
text-transform: none; text-transform: none;
letter-spacing: 0.5px; letter-spacing: 0.5px;
font-weight: $font-weight-extra-bold; font-weight: $font-weight-extra-bold;
font-size: 16px; font-size: 14px;
color: $neutral-100; color: $neutral-100 !important;
padding: 12px 32px; padding: 8px 24px !important;
box-shadow: 0 2px 8px rgba(255, 155, 27, 0.25); height: 40px !important;
min-width: 150px;
box-shadow: 0 4px 12px rgba(255, 155, 27, 0.4), 0 2px 6px rgba(255, 155, 27, 0.3);
font-family: $font-family-base; font-family: $font-family-base;
transition: all 0.3s ease;
} }
.verified-chip { .action-btn:hover {
background-color: #E68A00 !important;
box-shadow: 0 6px 16px rgba(255, 155, 27, 0.5), 0 3px 8px rgba(255, 155, 27, 0.4);
transform: translateY(-2px);
}
.verified-btn {
text-transform: none;
letter-spacing: 0.5px;
font-weight: $font-weight-extra-bold; font-weight: $font-weight-extra-bold;
font-size: 16px; font-size: 14px;
color: $neutral-100; color: $neutral-100 !important;
padding: 12px 24px; padding: 8px 24px !important;
height: 40px !important;
min-width: 150px;
font-family: $font-family-base; font-family: $font-family-base;
opacity: 1 !important;
}
.empty-state-cell {
padding: 40px 24px;
text-align: center;
} }
.empty-state { .empty-state {
text-align: center;
padding: 32px 0;
font-size: 18px; font-size: 18px;
color: $neutral-600; color: $neutral-600;
font-weight: $font-weight-semibold; font-weight: $font-weight-semibold;
font-family: $font-family-base; font-family: $font-family-base;
} }
// ============================================
// RESPONSIVE
// ============================================
@media (max-width: 960px) { @media (max-width: 960px) {
.patient-item { .patient-table {
padding: 20px; display: block;
overflow-x: auto;
} }
.action-btn { .table-cell {
font-size: 14px; padding: 20px 16px;
padding: 10px 20px; white-space: nowrap;
}
.header-cell {
padding: 12px 16px;
font-size: 12px;
}
.action-btn,
.verified-btn {
font-size: 12px;
} }
.patient-avatar { .patient-avatar {
width: 56px !important; width: 44px !important;
height: 56px !important; height: 44px !important;
} }
.patient-name { .patient-name {
font-size: 20px; font-size: 16px;
}
.info-item {
font-size: 12px;
} }
} }
</style> </style>
+105
View File
@@ -0,0 +1,105 @@
// composables/useAPI.ts
// Generic API composable untuk semua API calls
// Base composable yang bisa di-extend untuk specific resources
export interface APIResponse<T = any> {
success: boolean;
data?: T;
message?: string;
error?: string;
}
export const useAPI = () => {
const config = useRuntimeConfig();
const baseURL = config.public.apiBaseUrl || '/api';
/**
* Generic GET request
*/
const get = async <T = any>(endpoint: string, options: any = {}): Promise<T> => {
try {
const response = await $fetch<APIResponse<T>>(`${baseURL}${endpoint}`, {
method: 'GET',
...options,
});
if (response.success && response.data !== undefined) {
return response.data;
}
throw new Error(response.message || 'Failed to fetch data');
} catch (error: any) {
console.error(`❌ Error GET ${endpoint}:`, error);
throw error;
}
};
/**
* Generic POST request
*/
const post = async <T = any>(endpoint: string, body: any, options: any = {}): Promise<T> => {
try {
const response = await $fetch<APIResponse<T>>(`${baseURL}${endpoint}`, {
method: 'POST',
body,
...options,
});
if (response.success && response.data !== undefined) {
return response.data;
}
throw new Error(response.message || 'Failed to create data');
} catch (error: any) {
console.error(`❌ Error POST ${endpoint}:`, error);
throw error;
}
};
/**
* Generic PATCH request
*/
const patch = async <T = any>(endpoint: string, body: any, options: any = {}): Promise<T> => {
try {
const response = await $fetch<APIResponse<T>>(`${baseURL}${endpoint}`, {
method: 'PATCH',
body,
...options,
});
if (response.success && response.data !== undefined) {
return response.data;
}
throw new Error(response.message || 'Failed to update data');
} catch (error: any) {
console.error(`❌ Error PATCH ${endpoint}:`, error);
throw error;
}
};
/**
* Generic DELETE request
*/
const del = async <T = any>(endpoint: string, options: any = {}): Promise<T> => {
try {
const response = await $fetch<APIResponse<T>>(`${baseURL}${endpoint}`, {
method: 'DELETE',
...options,
});
if (response.success) {
return response.data as T;
}
throw new Error(response.message || 'Failed to delete data');
} catch (error: any) {
console.error(`❌ Error DELETE ${endpoint}:`, error);
throw error;
}
};
return {
get,
post,
patch,
delete: del,
};
};
+21 -8
View File
@@ -16,19 +16,32 @@ export const useAuth = () => {
isLoading.value = true isLoading.value = true
clearError() clearError()
const response = await $fetch<SessionResponse>('/api/auth/session') const headers = import.meta.server ? useRequestHeaders(['cookie']) as Record<string, string> : {}
if (response.success === false && response.error) { // The session API returns SessionResponse, or throws 401 if not authenticated
error.value = response.error // $fetch automatically sends cookies for same-origin requests
user.value = null const response = await $fetch<SessionResponse>('/api/auth/session', {
return null headers,
credentials: 'include' // Explicitly include cookies (though $fetch does this by default)
})
// Handle response structure - session API returns { success, user, ... }
if (response && response.user) {
user.value = response.user
return response.user
} }
user.value = response.user // If response exists but no user, clear and return null
return response.user user.value = null
return null
} catch (fetchError: any) { } catch (fetchError: any) {
console.error('Session check failed:', fetchError) console.error('Session check failed:', fetchError)
error.value = 'Failed to check authentication status' // 401 errors are expected when not authenticated
if (fetchError.statusCode === 401 || fetchError.status === 401) {
error.value = null // Don't show error for expected unauthenticated state
} else {
error.value = 'Failed to check authentication status'
}
user.value = null user.value = null
return null return null
} finally { } finally {
+4 -2
View File
@@ -78,7 +78,7 @@ export const useCheckInHistory = () => {
data: qrData, data: qrData,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
date: new Date().toLocaleDateString('id-ID'), date: new Date().toLocaleDateString('id-ID'),
time: new Date().toLocaleTimeString('id-ID') time: new Date().toLocaleTimeString('id-ID').replace(/\./g, ':')
}) })
// Simpan maksimal item sesuai constant // Simpan maksimal item sesuai constant
@@ -227,11 +227,13 @@ export const useCheckInHistory = () => {
const formatDateTime = (dateString: string) => { const formatDateTime = (dateString: string) => {
const date = new Date(dateString) const date = new Date(dateString)
return date.toLocaleTimeString('id-ID', { // Format jam dengan titik dua (HH:MM:SS)
const timeString = date.toLocaleTimeString('id-ID', {
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
second: '2-digit' second: '2-digit'
}) })
return timeString.replace(/\./g, ':')
} }
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => {
+160
View File
@@ -0,0 +1,160 @@
// composables/useClinicAPI.ts
// Composable untuk API calls terkait klinik/poli
import { useAPI } from './useAPI';
export interface Clinic {
id: number;
kode: string;
name: string;
subtitle?: string;
icon?: string;
shift?: string;
schedule?: string;
available?: boolean;
doctors?: string[];
shifts?: Array<{ name: string; quota: number }>;
totalQuota?: number;
jamShiftList?: Array<{ dari: string; sampai: string; kuota: number }>;
autoShift?: boolean;
jadwalKlinik?: string[];
}
export const useClinicAPI = () => {
const api = useAPI();
/**
* Fetch all clinics from database
*/
const fetchAllClinics = async (): Promise<Clinic[]> => {
try {
return await api.get<Clinic[]>('/clinics');
} catch (error: any) {
console.error('❌ Error fetching clinics:', error);
throw error;
}
};
/**
* Fetch single clinic by ID
*/
const fetchClinic = async (id: number): Promise<Clinic | null> => {
try {
return await api.get<Clinic>(`/clinics/${id}`);
} catch (error: any) {
console.error('❌ Error fetching clinic:', error);
return null;
}
};
/**
* Fetch clinics by kode
*/
const fetchClinicByKode = async (kode: string): Promise<Clinic | null> => {
try {
return await api.get<Clinic>(`/clinics/kode/${kode}`);
} catch (error: any) {
console.error('❌ Error fetching clinic by kode:', error);
return null;
}
};
/**
* Create new clinic
*/
const createClinic = async (clinicData: Partial<Clinic>): Promise<Clinic> => {
try {
return await api.post<Clinic>('/clinics', clinicData);
} catch (error: any) {
console.error('❌ Error creating clinic:', error);
throw error;
}
};
/**
* Update clinic
*/
const updateClinic = async (id: number, updates: Partial<Clinic>): Promise<Clinic> => {
try {
return await api.patch<Clinic>(`/clinics/${id}`, updates);
} catch (error: any) {
console.error('❌ Error updating clinic:', error);
throw error;
}
};
/**
* Delete clinic
*/
const deleteClinic = async (id: number): Promise<boolean> => {
try {
await api.delete(`/clinics/${id}`);
return true;
} catch (error: any) {
console.error('❌ Error deleting clinic:', error);
return false;
}
};
/**
* Get clinics for dropdown (formatted for dropdown use)
*/
const fetchClinicsForDropdown = async (): Promise<Clinic[]> => {
try {
const clinics = await fetchAllClinics();
// Format sesuai kebutuhan dropdown
return clinics.map(clinic => ({
id: clinic.id,
name: clinic.name,
kode: clinic.kode,
icon: clinic.icon,
available: clinic.available ?? true,
}));
} catch (error: any) {
console.error('❌ Error fetching clinics for dropdown:', error);
return [];
}
};
/**
* Sync local clinics with database
*/
const syncWithDatabase = async (localClinics: Clinic[]): Promise<Clinic[]> => {
try {
const dbClinics = await fetchAllClinics();
// Merge strategy: prefer database data
const merged = new Map<number, Clinic>();
// Add database clinics
dbClinics.forEach(clinic => {
merged.set(clinic.id, clinic);
});
// Add local clinics that don't exist in DB
localClinics.forEach(localClinic => {
if (!merged.has(localClinic.id)) {
merged.set(localClinic.id, localClinic);
}
});
return Array.from(merged.values());
} catch (error: any) {
console.error('❌ Error syncing clinics with database:', error);
// Return local clinics as fallback
return localClinics;
}
};
return {
fetchAllClinics,
fetchClinic,
fetchClinicByKode,
createClinic,
updateClinic,
deleteClinic,
fetchClinicsForDropdown,
syncWithDatabase,
};
};
+211
View File
@@ -0,0 +1,211 @@
import { ref, computed } from 'vue';
import { useQueueStore } from '@/stores/queueStore';
import { useMasterStore } from '@/stores/masterStore';
import { useClinicStore } from '@/stores/clinicStore';
export const useGrandPaviliun = () => {
const queueStore = useQueueStore();
const masterStore = useMasterStore();
const clinicStore = useClinicStore();
const loading = ref(false);
// Expose methods for UI
const fetchEksekutifData = async () => {
loading.value = true;
try {
// Pastikan data master ruangan sudah dimuat
if (!masterStore.ruangData || masterStore.ruangData.length === 0) {
// Assume it's loaded in index or master layouts, but fallback if not
}
const eksekutifClinics = masterStore.ruangData.filter(r => r.jenisLayanan === 'Eksekutif');
// Ambil data antrean untuk semua klinik eksekutif
const promises = eksekutifClinics.map(clinic => {
queueStore.registerClinicInterest(clinic.kodeKlinik);
return queueStore.fetchPatientsForClinic(clinic.kodeKlinik);
});
await Promise.all(promises);
// Connect WebSocket if not connected
if (!queueStore.isWsConnected) {
queueStore.initWebSocket('admin-klinik-ruang-eksekutif');
}
} catch (e) {
console.error('Error fetching eksekutif data:', e);
} finally {
loading.value = false;
}
};
// Computed data for UI
const clinics = computed(() => {
const eksekutifClinics = (masterStore.ruangData || []).filter(r => r.jenisLayanan === 'Eksekutif');
return eksekutifClinics.map(clinic => {
// Find all patients for this clinic
const clinicPatients = queueStore.allPatients.filter(p => p.kodeKlinik === clinic.kodeKlinik && p.processStage === 'klinik-ruang' && p.status !== 'processed');
// Find current processing patient (we take the first room's current patient if multiple, or global)
// Since design shows 1 column per clinic, we find the first patient marked as processing.
// Usually, queueStore.currentProcessingPatient is keyed by `klinik-ruang-${kodeKlinik}-${nomorRuang}`
let currentPatientObj = null;
// We look through clinic rooms to find a processing patient
for (const ruang of clinic.ruangList) {
const key = `klinik-ruang-${clinic.kodeKlinik}-${ruang.nomorRuang}`;
const processing = queueStore.currentProcessingPatient[key];
if (processing) {
currentPatientObj = {
...processing,
roomIndicator: `RUANG ${ruang.nomorRuang}`,
nomorRuang: ruang.nomorRuang
};
break; // Stop at first one for the column view
}
}
// Get queue patients (those not currently processing)
const currentPatientNo = currentPatientObj ? currentPatientObj.no : null;
const queuePatients = clinicPatients
.filter(p => p.no !== currentPatientNo)
.map(p => ({
...p,
isActive: false // Could be based on selection later
}));
return {
kodeKlinik: clinic.kodeKlinik,
namaKlinik: clinic.namaKlinik,
currentPatient: currentPatientObj,
queuePatients: queuePatients,
roomOptions: clinic.ruangList
};
});
});
const monitorRooms = computed(() => {
const rooms = [];
let idCounter = 1;
const eksekutifClinics = (masterStore.ruangData || []).filter(r => r.jenisLayanan === 'Eksekutif');
eksekutifClinics.forEach(clinic => {
clinic.ruangList.forEach(ruang => {
const key = `klinik-ruang-${clinic.kodeKlinik}-${ruang.nomorRuang}`;
const processing = queueStore.currentProcessingPatient[key];
// As user specified: Monitoring only shows data when 'Panggil Pemeriksaan' (Tindakan) is true
// Assuming calledTindakan flag exists on processing patient
const isActiveInMonitoring = processing && processing.calledTindakan;
rooms.push({
id: idCounter++,
name: `RUANG ${ruang.nomorRuang}`,
activePatient: isActiveInMonitoring ? (processing.noAntrian?.split(" |")[0] || processing.barcode) : null,
clinicName: clinic.namaKlinik
});
});
});
return rooms;
});
// Actions
const handlePemeriksaanAwal = (patient, kodeKlinik, nomorRuang) => {
if (!patient || !kodeKlinik || !nomorRuang) return;
const roomInfo = masterStore.ruangData
.find(c => c.kodeKlinik === kodeKlinik)
?.ruangList.find(r => String(r.nomorRuang) === String(nomorRuang));
if (!roomInfo) return;
const wsData = {
action: 'callPemeriksaanAwal',
patientId: patient.barcode || patient.visitId,
patientCode: patient.noAntrian?.split(" |")[0] || patient.barcode,
patientName: patient.name || '',
roomName: roomInfo.namaRuang,
clinicCode: kodeKlinik,
tipePanggilan: 'Pemeriksaan Awal'
};
queueStore.sendViaPost(wsData);
// Update local state to mark as called
if (patient.no) {
const idx = queueStore.allPatients.findIndex(p => p.no === patient.no);
if (idx !== -1) {
queueStore.allPatients[idx].calledPemeriksaanAwal = true;
}
}
};
const handlePanggilPemeriksaan = (patient, kodeKlinik, nomorRuang) => {
if (!patient || !kodeKlinik || !nomorRuang) return;
const roomInfo = masterStore.ruangData
.find(c => c.kodeKlinik === kodeKlinik)
?.ruangList.find(r => String(r.nomorRuang) === String(nomorRuang));
if (!roomInfo) return;
const wsData = {
action: 'callTindakan',
patientId: patient.barcode || patient.visitId,
patientCode: patient.noAntrian?.split(" |")[0] || patient.barcode,
patientName: patient.name || '',
roomName: roomInfo.namaRuang,
clinicCode: kodeKlinik,
tipePanggilan: 'Tindakan'
};
queueStore.sendViaPost(wsData);
// Update local state to mark as called
if (patient.no) {
const idx = queueStore.allPatients.findIndex(p => p.no === patient.no);
if (idx !== -1) {
queueStore.allPatients[idx].calledTindakan = true;
}
}
};
const processPatientAction = async (patient, action, kodeKlinik, nomorRuang) => {
if (!patient || !kodeKlinik || !nomorRuang) return { success: false, message: 'Data tidak lengkap' };
const result = await queueStore.processPatientKlinikRuang(
patient,
action,
kodeKlinik,
nomorRuang
);
if (action === 'pending' && result.success) {
const patientIndex = queueStore.allPatients.findIndex(p => p.no === patient.no);
if (patientIndex !== -1) {
queueStore.allPatients[patientIndex] = {
...queueStore.allPatients[patientIndex],
status: 'pending'
};
const key = `klinik-ruang-${kodeKlinik}-${nomorRuang}`;
queueStore.currentProcessingPatient[key] = null;
}
}
return result;
};
return {
clinics,
monitorRooms,
loading,
fetchEksekutifData,
handlePemeriksaanAwal,
handlePanggilPemeriksaan,
processPatientAction
};
};
+119
View File
@@ -0,0 +1,119 @@
// composables/useHakAkses.ts
// Composable for handling user permissions/access based on hakAkses
import { useAuth } from "~/composables/useAuth";
import type { HakAkses } from "~/types/setting";
export const useHakAkses = () => {
const { user, checkAuth } = useAuth();
/**
* Get all pages that user has access to based on their roles
*/
const getAllowedPages = async (): Promise<string[]> => {
// Ensure user is loaded
if (!user.value) {
await checkAuth();
}
const currentUser = user.value;
if (!currentUser) {
return [];
}
// Get roles and groups from multiple possible sources in User object
const roles = [
...(currentUser.roles || []),
...((currentUser as any).realm_access?.roles || []),
...((currentUser as any).resource_access?.['web-antrean']?.roles || [])
];
const groups = (currentUser as any).groups || [];
// Combine everything the user belongs to: Roles, Groups, and their own Username
const entities = [...new Set([
(currentUser as any).namaUser, // Individual user mapping support
...roles,
...groups
])].filter(Boolean);
if (entities.length === 0) {
return [];
}
try {
const headers = import.meta.server ? useRequestHeaders(['cookie']) as Record<string, string> : {}
// Fetch all hak akses data
const response = await $fetch<{ success: boolean, data: HakAkses[] }>('/api/hak-akses', { headers });
if (response && response.success && Array.isArray(response.data)) {
const hakAksesList = response.data;
// Filter hak akses that match user's entities and are active
const userHakAkses = hakAksesList.filter((hakAkses) =>
entities.includes(hakAkses.namaHakAkses) &&
hakAkses.status === 'aktif'
);
// Combine all pages from all matching hak akses
const allPages = userHakAkses.reduce((pages: string[], hakAkses) => {
if (hakAkses.pages && Array.isArray(hakAkses.pages)) {
return [...pages, ...hakAkses.pages];
}
return pages;
}, []);
// Remove duplicates
return [...new Set(allPages)];
}
return [];
} catch (error) {
console.error('Error fetching allowed pages:', error);
return [];
}
};
/**
* Check if user has access to a specific page
*/
const hasPageAccess = async (pagePath: string): Promise<boolean> => {
const allowedPages = await getAllowedPages();
return allowedPages.includes(pagePath);
};
/**
* Check if user has access to any page in a list
*/
const hasAnyPageAccess = async (pagePaths: string[]): Promise<boolean> => {
const allowedPages = await getAllowedPages();
return pagePaths.some(path => allowedPages.includes(path));
};
const allHakAksesData = ref<HakAkses[]>([]);
const isLoading = ref(false);
const fetchHakAkses = async () => {
isLoading.value = true;
try {
const headers = import.meta.server ? useRequestHeaders(['cookie']) as Record<string, string> : {}
const response = await $fetch<{ success: boolean, data: HakAkses[] }>('/api/hak-akses', { headers });
if (response && response.success) {
allHakAksesData.value = response.data;
}
} catch (error) {
console.error('Error fetching hak akses:', error);
} finally {
isLoading.value = false;
}
};
return {
allHakAksesData,
isLoading,
fetchHakAkses,
getAllowedPages,
hasPageAccess,
hasAnyPageAccess
};
};
+81
View File
@@ -0,0 +1,81 @@
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
/**
* Reusable Infinite Scroll Composable
* @param {import('vue').Ref<Array>} sourceData - The full source array of data
* @param {number} pageSize - Number of items to load per "page"
* @param {Object} options - Additional options
* @returns {Object} { visibleItems, targetRef, loadMore, reset }
*/
export function useInfiniteScroll(sourceData, pageSize = 20, options = {}) {
const visibleCount = ref(pageSize);
const targetRef = ref(null);
// Compute visible items based on current count
const visibleItems = computed(() => {
const data = sourceData.value || [];
return data.slice(0, visibleCount.value);
});
// Check if there are more items to load
const hasMore = computed(() => {
const data = sourceData.value || [];
return visibleCount.value < data.length;
});
const loadMore = () => {
if (hasMore.value) {
visibleCount.value += pageSize;
}
};
const reset = () => {
visibleCount.value = pageSize;
};
// Setup IntersectionObserver
let observer = null;
onMounted(() => {
observer = new IntersectionObserver((entries) => {
const entry = entries[0];
if (entry.isIntersecting && hasMore.value) {
loadMore();
}
}, {
root: null,
rootMargin: '100px', // Preload before reaching bottom
threshold: 0.1,
...options
});
if (targetRef.value) {
observer.observe(targetRef.value);
}
});
onUnmounted(() => {
if (observer) observer.disconnect();
});
// Watch for targetRef changes (e.g., if valid status changes)
watch(targetRef, (el) => {
if (observer) {
observer.disconnect();
if (el) observer.observe(el);
}
});
// Reset when source data changes significantly (optional, depending on use case)
// watch(sourceData, () => {
// reset(); // Uncomment if you want to reset scroll on data refresh
// });
return {
visibleItems,
targetRef,
loadMore,
reset,
hasMore
};
}
+4 -1
View File
@@ -17,7 +17,10 @@ export const usePermission = () => {
*/ */
const fetchPermission = async (path: string) => { const fetchPermission = async (path: string) => {
const { group, role } = parsePath(path); const { group, role } = parsePath(path);
const url = `http://10.10.150.131:8080/api/permission?roles=${role}&groups=${group}`; const config = useRuntimeConfig();
const apiBase = config.public.verificationApiBaseUrl || 'http://10.10.123.140:8089/api/v1';
const origin = new URL(apiBase).origin;
const url = `${origin}/api/permission?roles=${role}&groups=${group}`;
const { data, error } = await useFetch(url, { const { data, error } = await useFetch(url, {
method: "GET", method: "GET",
+186 -36
View File
@@ -1,9 +1,18 @@
// composables/useQueue.js // composables/useQueue.js
import { ref, computed } from "vue"; import { ref, computed } from "vue";
import { useQueueStore } from "../stores/queueStore"; import { useQueueStore } from "../stores/queueStore";
import { useLoketStore } from "../stores/loketStore";
export const useQueue = (adminType = "loket") => { export const useQueue = (adminType = "loket", specificId = null) => {
const queueStore = useQueueStore(); const queueStore = useQueueStore();
const loketStore = useLoketStore();
// Normalize specificId to a reactive value
const idValue = computed(() => {
if (typeof specificId === 'function') return specificId();
if (specificId && typeof specificId === 'object' && 'value' in specificId) return specificId.value;
return specificId;
});
// Local state // Local state
const snackbar = ref(false); const snackbar = ref(false);
@@ -26,9 +35,10 @@ export const useQueue = (adminType = "loket") => {
return patients.value; return patients.value;
}); });
// Computed from store - filtered by stage // Computed from store - isolated by specificId if provided
const currentProcessingPatient = computed(() => { const currentProcessingPatient = computed(() => {
return queueStore.currentProcessingPatient[adminType]; const key = idValue.value ? `${adminType}-${idValue.value}` : adminType;
return queueStore.currentProcessingPatient[key];
}); });
// Derive from stagePatients - ADD DEBUG LOGS // Derive from stagePatients - ADD DEBUG LOGS
@@ -54,10 +64,14 @@ export const useQueue = (adminType = "loket") => {
return patients; return patients;
}); });
const waitingPatients = computed(() => stagePatients.value.waiting || []); const anjunganPatients = computed(() => stagePatients.value.anjungan || []);
// Pasien yang belum dipanggil (status "menunggu")
const menungguPatients = computed(() => stagePatients.value.menunggu || []);
const nextPatient = computed(() => { const nextPatient = computed(() => {
return waitingPatients.value[0] || null; // Prioritaskan pasien menunggu, baru anjungan
return menungguPatients.value[0] || anjunganPatients.value[0] || null;
}); });
// Total pasien hanya untuk stage admin ini // Total pasien hanya untuk stage admin ini
@@ -66,31 +80,99 @@ export const useQueue = (adminType = "loket") => {
return total.value; return total.value;
}); });
const quotaUsed = computed(() => queueStore.quotaUsed); // Helper to check if a patient is relevant to the current admin view
const isPatientRelevant = (p) => {
if (p.processStage !== adminType) return false;
const targetId = idValue.value;
if (!targetId) return true; // No specific ID, show all for this stage
if (adminType === 'loket') {
const thisLoket = loketStore.getLoketById(parseInt(targetId));
if (!thisLoket) return String(p.loketId) === String(targetId);
// Must be assigned to this loket OR unassigned but clinic is served by this loket
const isAssignedToThis = p.loketId && String(p.loketId) === String(targetId);
if (isAssignedToThis) return true;
// Add strict isolation between Eksekutif and Reguler for unassigned tickets
const isPatientEksekutif = p.noAntrian && (String(p.noAntrian).startsWith('E') || String(p.noAntrian).startsWith('F-E'));
const isLoketEksekutif = thisLoket.tipeLoket === 'EKSEKUTIF' || thisLoket.id >= 1000;
if (isPatientEksekutif !== isLoketEksekutif) {
return false;
}
const isServedByThis = !p.loketId && thisLoket.pelayanan && Array.isArray(thisLoket.pelayanan) && thisLoket.pelayanan.includes(p.kodeKlinik);
return isServedByThis;
}
if (adminType === 'klinik') {
// Filter by clinic code or clinic name
return String(p.kodeKlinik) === String(targetId) || String(p.klinik) === String(targetId);
}
if (adminType === 'penunjang') {
// Penunjang usually uses clinic name as the identifier in seeds
return String(p.klinik) === String(targetId) || String(p.kodeKlinik) === String(targetId);
}
return true;
};
// 1. Menunggu Count (Backlog ONLY - Strictly status 'menunggu')
const menungguCount = computed(() => {
let list = menungguPatients.value || [];
return list.filter(isPatientRelevant).length;
});
// 2. Calculated Quota Used (Dynamic - any ticket NOT 'menunggu' counts as used)
const calculatedQuotaUsed = computed(() => {
const list = queueStore.allPatients || [];
return list.filter(p => {
if (!isPatientRelevant(p)) return false;
// Status is NOT 'menunggu' (meaning it's being served or finished)
return p.status !== 'menunggu';
}).length;
});
const quotaUsed = computed(() => calculatedQuotaUsed.value);
// Expose dev helper to refresh seed data // Expose dev helper to refresh seed data
const resetPatients = () => queueStore.resetPatients(); const resetPatients = () => queueStore.resetPatients();
// Filtered lists // Filtered lists
const filteredKliniks = computed(() => { const filteredKliniks = computed(() => {
if (!klinikSearch.value) return queueStore.kliniks; let result = (queueStore.kliniks.value || queueStore.kliniks || []).filter(k => k.jenisLayanan === 'Reguler');
return queueStore.kliniks.filter((k) => if (klinikSearch.value) {
k.name.toLowerCase().includes(klinikSearch.value.toLowerCase()) result = result.filter((k) =>
); k.name.toLowerCase().includes(klinikSearch.value.toLowerCase())
);
}
// Sort alphabetically by name
return [...result].sort((a, b) => (a.name || "").localeCompare(b.name || ""));
}); });
const filteredPenunjangs = computed(() => { const filteredPenunjangs = computed(() => {
if (!penunjangSearch.value) return queueStore.penunjangs; let result = queueStore.penunjangs;
return queueStore.penunjangs.filter((p) => if (penunjangSearch.value) {
p.name.toLowerCase().includes(penunjangSearch.value.toLowerCase()) result = result.filter((p) =>
); p.name.toLowerCase().includes(penunjangSearch.value.toLowerCase())
);
}
// Sort alphabetically by name
return [...result].sort((a, b) => (a.name || "").localeCompare(b.name || ""));
}); });
const filteredChangeKliniks = computed(() => { const filteredChangeKliniks = computed(() => {
if (!changeKlinikSearch.value) return queueStore.kliniks; let result = (queueStore.kliniks.value || queueStore.kliniks || []).filter(k => k.jenisLayanan === 'Reguler');
return queueStore.kliniks.filter((k) => if (changeKlinikSearch.value) {
k.name.toLowerCase().includes(changeKlinikSearch.value.toLowerCase()) result = result.filter((k) =>
); k.name.toLowerCase().includes(changeKlinikSearch.value.toLowerCase())
);
}
// Sort alphabetically by name
return [...result].sort((a, b) => (a.name || "").localeCompare(b.name || ""));
}); });
// Methods // Methods
@@ -100,36 +182,92 @@ export const useQueue = (adminType = "loket") => {
snackbar.value = true; snackbar.value = true;
}; };
const callNext = () => { const callNext = (specificIdOverride = null) => {
const result = queueStore.callNext(adminType); const targetId = specificIdOverride || idValue.value;
const result = queueStore.callNext(adminType, targetId);
showSnackbar(result.message, result.success ? "success" : "warning"); showSnackbar(result.message, result.success ? "success" : "warning");
}; };
const callMultiplePatients = (count) => { const callMultiplePatients = (count, specificIdOverride = null) => {
const result = queueStore.callMultiplePatients(count, adminType); const targetId = specificIdOverride || idValue.value;
const result = queueStore.callMultiplePatients(count, adminType, targetId);
showSnackbar(result.message, result.success ? "success" : "warning"); showSnackbar(result.message, result.success ? "success" : "warning");
}; };
const processPatient = (patient, action) => { const processPatient = async (patient, action) => {
const result = queueStore.processPatient(patient, action, adminType); const result = await queueStore.processPatient(patient, action, adminType, idValue.value);
let color = "success"; let color = "success";
if (action === "terlambat") color = "warning"; if (action === "terlambat") color = "warning";
else if (action === "pending") color = "info"; else if (action === "pending") color = "info";
showSnackbar(result.message, color); showSnackbar(result.message, color);
return result;
};
// Helper function untuk mendapatkan pasien yang sedang diproses dari store
const getCurrentProcessingPatientFromStore = () => {
try {
const key = idValue.value ? `${adminType}-${idValue.value}` : adminType;
const processingPatient = queueStore.currentProcessingPatient?.[key];
if (!processingPatient) return null;
// Dapatkan data terbaru dari allPatients langsung (bukan dari getPatientsByStage yang sudah difilter)
// allPatients adalah ref yang di-export dari Pinia store, bisa diakses langsung atau dengan .value
// Coba akses langsung dulu, jika undefined baru coba dengan .value
let allPatients = [];
if (queueStore.allPatients) {
// Jika allPatients adalah ref (punya .value)
allPatients = Array.isArray(queueStore.allPatients)
? queueStore.allPatients
: (queueStore.allPatients.value || []);
}
// Cari pasien berdasarkan no, barcode, atau noAntrian
const latestPatient = allPatients.find(
p => (p && p.no === processingPatient.no) ||
(p && p.barcode && p.barcode === processingPatient.barcode) ||
(p && p.noAntrian && p.noAntrian === processingPatient.noAntrian)
);
// Jika ditemukan, return data terbaru, jika tidak return data dari currentProcessingPatient
return latestPatient || processingPatient;
} catch (error) {
console.error('Error in getCurrentProcessingPatientFromStore:', error);
// Fallback: return processingPatient dari store jika ada
return queueStore.currentProcessingPatient?.[adminType] || null;
}
}; };
const selectKlinik = (klinik) => { const selectKlinik = (klinik) => {
const result = queueStore.createAntreanKlinik(klinik, currentProcessingPatient.value, adminType); // Pastikan currentProcessingPatient valid, jika tidak, coba dapatkan dari store
let patient = currentProcessingPatient.value || getCurrentProcessingPatientFromStore();
if (!patient) {
showSnackbar("Tidak ada pasien yang sedang diproses", "error");
showKlinikDialog.value = false;
return;
}
const result = queueStore.createAntreanKlinik(klinik, patient, adminType);
showSnackbar(result.message, "success"); showSnackbar(result.message, "success");
showKlinikDialog.value = false; showKlinikDialog.value = false;
}; };
const selectPenunjang = (penunjang) => { const selectPenunjang = (penunjang) => {
// Pastikan currentProcessingPatient valid, jika tidak, coba dapatkan dari store
let patient = currentProcessingPatient.value || getCurrentProcessingPatientFromStore();
if (!patient) {
showSnackbar("Tidak ada pasien yang sedang diproses", "error");
showPenunjangDialog.value = false;
selectedPatientForPenunjang.value = null;
return;
}
const result = queueStore.createAntreanPenunjang( const result = queueStore.createAntreanPenunjang(
penunjang, penunjang,
currentProcessingPatient.value, patient,
adminType adminType
); );
showSnackbar(result.message, "success"); showSnackbar(result.message, "success");
@@ -143,20 +281,31 @@ export const useQueue = (adminType = "loket") => {
}; };
const changeKlinik = (klinik) => { const changeKlinik = (klinik) => {
if (currentProcessingPatient.value) { // Pastikan currentProcessingPatient valid, jika tidak, coba dapatkan dari store
const result = queueStore.changeKlinik( let patient = currentProcessingPatient.value || getCurrentProcessingPatientFromStore();
currentProcessingPatient.value,
klinik, if (!patient) {
adminType showSnackbar("Tidak ada pasien yang sedang diproses", "error");
);
showSnackbar(result.message, result.success ? "success" : "error");
showChangeKlinikDialog.value = false; showChangeKlinikDialog.value = false;
return { success: false };
} }
const result = queueStore.changeKlinik(
patient,
klinik,
adminType,
idValue.value
);
showSnackbar(result.message, result.success ? "success" : "error");
showChangeKlinikDialog.value = false;
return result;
}; };
const processNextQueue = () => {
const result = queueStore.processNextQueue(adminType); const processNextQueue = async () => {
const result = await queueStore.processNextQueue(adminType, idValue.value);
showSnackbar(result.message, result.success ? "success" : "warning"); showSnackbar(result.message, result.success ? "success" : "warning");
return result;
}; };
const getRowClass = (item) => { const getRowClass = (item) => {
@@ -184,7 +333,8 @@ export const useQueue = (adminType = "loket") => {
diLoketPatients, diLoketPatients,
terlambatPatients, terlambatPatients,
pendingPatients, pendingPatients,
waitingPatients, anjunganPatients,
menungguPatients,
nextPatient, nextPatient,
totalPasien, totalPasien,
quotaUsed, quotaUsed,
+62
View File
@@ -0,0 +1,62 @@
import { useRuntimeConfig } from '#app';
import type { QueuePatient } from '@/types/queue';
export const useQueueAPI = () => {
const config = useRuntimeConfig();
const verificationApiBase = config.public.verificationApiBaseUrl || 'http://10.10.123.140:8089/api/v1';
const externalApiBase = config.public.externalApiBaseUrl;
const fetchRawLoketPatients = async (loketId: string | number) => {
try {
const rawData: any = await $fetch(`${verificationApiBase}/loket/${loketId}`);
if (rawData.metadata && rawData.metadata.code !== 200) {
throw new Error(rawData.message || 'API returned error status');
}
return rawData.data || [];
} catch (error: any) {
throw new Error(`HTTP error! status: ${error.response?.status || error.message}`);
}
};
const fetchRawClinicPatients = async (clinicId: string | number) => {
const url = `${externalApiBase}/visit?klinik_id=${clinicId}&limit=500`;
try {
const rawResponse: any = await $fetch(url);
return rawResponse?.data || [];
} catch (error: any) {
throw new Error(`HTTP error! status: ${error.response?.status || error.message}`);
}
};
const updateTicketStatus = async (barcode: string, statuspasien: string, statuspasien2: string, idklinikstatus: string, idklinikstatus2: string) => {
return await $fetch(`${verificationApiBase}/tiket/update`, {
method: 'POST',
body: {
barcode: barcode || "",
statuspasien,
statuspasien2,
idklinikstatus,
idklinikstatus2
}
});
};
const completeTicketStatus = async (idloket: string, barcode: string, statuspasien: string, idklinikstatus: string) => {
return await $fetch(`${verificationApiBase}/tiket/selesai`, {
method: 'POST',
body: {
idloket: String(idloket || ""),
barcode: barcode || "",
statuspasien,
idklinikstatus
}
});
};
return {
fetchRawLoketPatients,
fetchRawClinicPatients,
updateTicketStatus,
completeTicketStatus
};
};
+237
View File
@@ -0,0 +1,237 @@
import { ref } from 'vue';
import { useRuntimeConfig } from '#app';
import { useWebSocket } from '@/composables/useWebSocket';
import type { QueuePatient } from '@/types/queue';
export interface QueueSyncDeps {
allPatients: Ref<QueuePatient[]>;
currentProcessingPatient: Ref<Record<string, QueuePatient>>;
activeLoketInterest: Ref<Record<string, number>>;
activeClinicInterest: Ref<Record<string, number>>;
globalInterestCount: Ref<number>;
fetchPatientsForLoket: (id: string | number, force?: boolean) => void;
fetchPatientsForClinic: (id: string, force?: boolean) => void;
fetchAllPatients: () => void;
}
export const useQueueSync = (deps: QueueSyncDeps) => {
const isWsConnected = ref<boolean>(false);
const wsClientId = ref<string>(`client-${Math.random().toString(36).substring(7)}`);
const lastGlobalCall = ref<any>(null);
const lastKlinikCall = ref<any>(null);
const onWsMessage = (data: any) => {
// Robust data extraction: some relays wrap data in another 'data' property
let messageData = data?.data || data;
if (messageData?.data && !messageData.callKlinikEvent && !messageData.callEvent) {
messageData = messageData.data; // Double wrap check
}
const targetLoketId = messageData?.loketId || messageData?.idloket;
const targetKlinikId = messageData?.klinikId || messageData?.idklinik;
// Handle Call Events and WS messages
if (messageData?.triggerRefresh) {
if (messageData.klinikId) {
// console.log(`🔄 [queueSync] Received refresh trigger for clinic ${messageData.klinikId}`);
// Handle current processing update if provided
if (messageData.currentProcessingUpdate) {
// console.log(`🎯 [queueSync] Applying current processing update:`, messageData.currentProcessingUpdate);
Object.keys(messageData.currentProcessingUpdate).forEach(key => {
deps.currentProcessingPatient.value[key] = messageData.currentProcessingUpdate[key];
// Also patch the status in allPatients if possible
const processingPatient = messageData.currentProcessingUpdate[key];
if (processingPatient && processingPatient.no) {
const idx = deps.allPatients.value.findIndex(p => p.no === processingPatient.no);
if (idx !== -1) {
deps.allPatients.value[idx] = { ...deps.allPatients.value[idx], status: 'di-loket' };
}
}
});
}
deps.fetchPatientsForClinic(messageData.klinikId, true);
}
}
if (messageData?.callEvent) {
lastGlobalCall.value = messageData.callEvent;
}
// Handle Klinik Call Events (cross-device sync for AntrianKlinikRuang display)
if (messageData?.callKlinikEvent) {
const ev = messageData.callKlinikEvent;
// console.log('🏥 [queueSync] Klinik call event received:', ev);
// PERSISTENCE FIX: Save to lastKlinikCall for displays to watch
lastKlinikCall.value = ev;
// Find the patient in allPatients and patch directly for immediate UI update
const idx = deps.allPatients.value.findIndex(p =>
p.processStage === 'klinik-ruang' &&
p.kodeKlinik === ev.kodeKlinik &&
(
(p.barcode && String(p.barcode) === String(ev.barcode)) ||
(p.noAntrian && p.noAntrian.split(' |')[0] === ev.noantrian)
)
);
if (idx !== -1) {
const updatedPatient = {
...deps.allPatients.value[idx],
tipeLayanan: ev.tipeLayanan,
lastCalledAt: ev.lastCalledAt || new Date().toISOString(),
lastCalledTipeLayanan: ev.tipeLayanan,
status: 'di-loket' as any,
calledPemeriksaanAwal: ev.tipeLayanan === 'Pemeriksaan Awal' ? true : deps.allPatients.value[idx].calledPemeriksaanAwal,
calledTindakan: ev.tipeLayanan === 'Tindakan' ? true : deps.allPatients.value[idx].calledTindakan
};
deps.allPatients.value[idx] = updatedPatient;
// console.log(`✅ [queueSync] Successfully patched patient ${ev.noantrian} status to di-loket (lastCalledAt: ${updatedPatient.lastCalledAt})`);
} else {
console.warn(`⚠️ [queueSync] Patient ${ev.noantrian} not found in store for clinic ${ev.kodeKlinik}.`);
}
}
// TRIGGER STRATEGIC REFRESHES
let refreshedSomething = false;
if (targetLoketId) {
deps.fetchPatientsForLoket(targetLoketId, true);
refreshedSomething = true;
}
if (targetKlinikId) {
const interestingClinics = Object.keys(deps.activeClinicInterest.value);
if (interestingClinics.includes(String(targetKlinikId)) || targetKlinikId === 'broadcast') {
const clinicToFetch = targetKlinikId === 'broadcast' ? interestingClinics[0] : targetKlinikId;
deps.fetchPatientsForClinic(clinicToFetch, true);
refreshedSomething = true;
}
}
if (deps.globalInterestCount.value > 0) {
deps.fetchAllPatients();
refreshedSomething = true;
}
// ALWAYS refresh our own active interests when a WebSocket message is received
const interestingLokets = Object.keys(deps.activeLoketInterest.value);
const interestingClinics = Object.keys(deps.activeClinicInterest.value);
if (interestingLokets.length > 0) {
interestingLokets.forEach(loketId => {
if (String(loketId) !== String(targetLoketId)) {
deps.fetchPatientsForLoket(loketId, true);
refreshedSomething = true;
}
});
}
if (interestingClinics.length > 0) {
interestingClinics.forEach(kodeKlinik => {
if (String(kodeKlinik) !== String(targetKlinikId)) {
deps.fetchPatientsForClinic(kodeKlinik, true);
refreshedSomething = true;
}
});
}
if (!refreshedSomething) {
// console.log(`🔕 [queueSync] WS trigger received but no active interest matched. Skipping.`);
}
};
const config = useRuntimeConfig();
const wsBaseUrl = config.public?.wsBaseUrl || "ws://10.10.123.135:8084/api/v1/ws";
const { connect, disconnect, sendViaPost, isConnected } = useWebSocket({
url: wsBaseUrl,
clientId: wsClientId,
fallbackPostUrl: `${config.public.externalApiBaseUrl}/ws`,
reconnectInterval: 2000,
maxReconnectAttempts: 9999,
onOpen: () => {
// console.log('✅ [queueSync] WebSocket connected');
isWsConnected.value = true;
},
onClose: () => {
// console.log('❌ [queueSync] WebSocket disconnected');
isWsConnected.value = false;
},
onError: (err: any) => {
console.error('⚠️ [queueSync] WebSocket error:', err);
isWsConnected.value = false;
},
onMessage: onWsMessage
});
let _autoSyncInterval: any = null;
const startAutoSync = () => {
if (typeof window === 'undefined') return;
if (_autoSyncInterval) return;
// console.log('🔄 [queueSync] Starting store-level auto-sync (30s interval)');
_autoSyncInterval = setInterval(async () => {
const hasLoketInterest = Object.keys(deps.activeLoketInterest.value).length > 0;
const hasClinicInterest = Object.keys(deps.activeClinicInterest.value).length > 0;
const hasGlobalInterest = deps.globalInterestCount.value > 0;
if (hasGlobalInterest) {
deps.fetchAllPatients();
} else {
if (hasLoketInterest) {
Object.keys(deps.activeLoketInterest.value).forEach(loketId => {
deps.fetchPatientsForLoket(loketId, true);
});
}
if (hasClinicInterest) {
Object.keys(deps.activeClinicInterest.value).forEach(kodeKlinik => {
deps.fetchPatientsForClinic(kodeKlinik, true);
});
}
}
}, 30000); // 30 seconds
};
const stopAutoSync = () => {
if (_autoSyncInterval) {
clearInterval(_autoSyncInterval);
_autoSyncInterval = null;
// console.log('⏹️ [queueSync] Store-level auto-sync stopped');
}
};
const initWebSocket = (customClientId: string | null = null) => {
if (isConnected.value && customClientId === wsClientId.value) {
// console.log('🔌 [queueSync] WebSocket already connected with same ID.');
startAutoSync();
return;
}
if (customClientId) {
wsClientId.value = customClientId;
disconnect();
}
// console.log(`🔌 [queueSync] Connecting to WebSocket: ${wsBaseUrl} as ${wsClientId.value}`);
connect();
startAutoSync();
};
return {
isWsConnected,
wsClientId,
lastGlobalCall,
lastKlinikCall,
initWebSocket,
disconnectWebSocket: disconnect,
sendViaPost,
startAutoSync,
stopAutoSync
};
};
+636
View File
@@ -0,0 +1,636 @@
import { ref } from 'vue';
import QRCode from 'qrcode';
export interface ThermalPrintData {
noAntrian: string;
barcode: string;
klinik: string;
ruang?: string;
nomorRuang?: string;
shift: string;
pembayaran: string;
tanggal: string;
waktu: string;
namaDokter?: string;
status?: 'ALLOWED' | 'NOT_ALLOWED';
}
export const useThermalPrint = () => {
const isPrinting = ref(false);
// NOTE: generateBarcode telah dihapus dari useThermalPrint
// Gunakan generateBarcode dari queueStore.js untuk konsistensi
// Semua barcode generation harus melalui queueStore.generateBarcode()
/**
* Generate QR Code data URL
*/
const generateQRCode = async (data: string): Promise<string> => {
try {
const qrDataUrl = await QRCode.toDataURL(data, {
errorCorrectionLevel: 'M', // Medium error correction (better for standard cameras)
type: 'image/png',
quality: 1,
margin: 2, // Added margin (quiet zone) for better scanner detection
width: 400, // High quality width for clear printing
version: 3,
color: {
dark: '#000000',
light: '#FFFFFF'
}
});
return qrDataUrl;
} catch (error) {
console.error('Error generating QR Code:', error);
throw error;
}
};
/**
* Format date untuk tampilan
* Format: senin, 12 Jan 2026
*/
const formatDate = (dateString: string): string => {
try {
let date = dateString ? new Date(dateString) : new Date();
if (isNaN(date.getTime())) {
date = new Date();
}
// Array nama hari dalam bahasa Indonesia (lowercase)
const days = ['Minggu', 'Senin', 'Selasa', 'Rabu', 'Kamis', 'Jumat', 'Sabtu'];
// Array nama bulan singkat (title case)
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun', 'Jul', 'Agu', 'Sep', 'Okt', 'Nov', 'Des'];
const dayName = days[date.getDay()];
const day = date.getDate();
const month = months[date.getMonth()];
const year = date.getFullYear(); // Tahun 4 digit
return `${dayName}, ${day} ${month} ${year}`;
} catch (error) {
// Fallback jika error
const date = new Date();
const days = ['minggu', 'senin', 'selasa', 'rabu', 'kamis', 'jumat', 'sabtu'];
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun', 'Jul', 'Agu', 'Sep', 'Okt', 'Nov', 'Des'];
const dayName = days[date.getDay()];
const day = date.getDate();
const month = months[date.getMonth()];
const year = date.getFullYear();
return `${dayName}, ${day} ${month} ${year}`;
}
};
/**
* Format time untuk tampilan
*/
const formatTime = (dateString: string): string => {
try {
let date = dateString ? new Date(dateString) : new Date();
if (isNaN(date.getTime())) {
date = new Date();
}
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${hours}:${minutes}`;
} catch (error) {
const date = new Date();
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${hours}:${minutes}`;
}
};
/**
* Generate HTML untuk thermal print
*/
const generatePrintHTML = async (data: ThermalPrintData): Promise<string> => {
// QR Code data: hanya barcode saja (status akan dicek real-time saat check-in dari queueStore)
// Format: BARCODE (contoh: 250811100163)
// Alasan: Status bisa berubah setelah tiket di-print (dari 'menunggu' jadi 'waiting' setelah dipanggil)
// Status akan dicek langsung dari queueStore saat scan QR untuk akurasi real-time
// Normalize barcode - pastikan tidak ada whitespace dan valid
const qrData = String(data.barcode || '').trim();
if (!qrData) {
throw new Error('Barcode tidak valid untuk QR code');
}
console.log('📱 Generating QR code for ticket with barcode:', qrData);
const qrCodeImage = await generateQRCode(qrData);
console.log('✅ QR code generated successfully');
// Format nomor antrian (hilangkan bagian "| Onsite - barcode")
const noAntrianDisplay = data.noAntrian.split(' |')[0];
// Format informasi ruang: "Poli Anak - Ruang A" atau "Poli Anak - Gastro-Hepatologi"
let ruangInfo = '';
// 1. Jika ada data.ruang dan bukan sekadar kata "Ruang X" (berarti ini subspesialis)
if (data.ruang && !data.ruang.toLowerCase().startsWith('ruang')) {
ruangInfo = `${data.klinik} - ${data.ruang}`;
}
// 2. Fallback: gunakan nomorRuang untuk dikonversi ke abjad (Ruang A, Ruang B, dll)
else if (data.klinik && data.nomorRuang) {
const ruangNumber = parseInt(data.nomorRuang) || 1;
const ruangLetter = String.fromCharCode(64 + ruangNumber); // 64 = '@', 65 = 'A'
ruangInfo = `${data.klinik} - Ruang ${ruangLetter}`;
}
// 3. Fallback: gunakan data.ruang yang ada
else if (data.klinik && data.ruang) {
ruangInfo = `${data.klinik} - ${data.ruang}`;
}
// 4. Default: hanya nama klinik
else if (data.klinik) {
ruangInfo = data.klinik;
}
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Nomor Antrian - ${noAntrianDisplay}</title>
<style>
@page {
size: 80mm auto;
margin: 0;
padding: 0;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
body {
width: 80mm;
max-width: 80mm;
margin: 0;
padding: 2mm 4mm 6mm 4mm;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 12px;
font-weight: bold;
line-height: 1.0;
background: white;
color: black;
}
.ticket-table {
width: 100%;
border-collapse: collapse;
border: 2px dashed #000;
margin: 0;
background: white;
}
.ticket-table td {
padding: 0;
border: none;
}
.ticket-content {
padding: 2mm 4mm 4mm 4mm;
text-align: center;
}
.header {
margin-bottom: 2mm;
border-bottom: 2px solid #000;
padding-bottom: 1mm;
margin-top: 0;
}
.hospital-name {
font-size: 14px;
font-weight: bold;
margin-bottom: 0.5mm;
text-transform: uppercase;
letter-spacing: 1px;
line-height: 1.0;
}
.hospital-address {
font-size: 10px;
font-weight: bold;
margin-bottom: 0.5mm;
line-height: 1.0;
}
.ticket-title {
font-size: 12px;
font-weight: bold;
margin-top: 1mm;
text-transform: uppercase;
line-height: 1.0;
}
.qr-code-section {
margin-top: 0;
margin-bottom: 0.5mm;
text-align: center;
}
.qr-code {
width: 45mm;
height: 45mm;
margin: 0 auto;
border: none;
padding: 0.5mm;
background: white;
}
.qr-code img {
width: 100%;
height: 100%;
display: block;
}
.barcode-number {
font-size: 10px;
font-weight: bold;
margin-top: -0.5mm;
word-break: break-all;
line-height: 1.0;
}
.info-section {
margin-top: 2mm;
text-align: left;
border-top: 1px dashed #000;
padding-top: 1mm;
padding-left: 3mm;
padding-right: 3mm;
}
.info-row {
margin-bottom: 0.8mm;
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 0;
line-height: 1.0;
}
.info-label-text {
font-weight: bold;
font-size: 10px;
text-align: left;
text-transform: uppercase;
word-break: keep-all;
line-height: 1.0;
}
.info-colon {
font-weight: bold;
font-size: 10px;
margin-left: auto;
padding-right: 2mm;
line-height: 1.0;
}
.info-label-wrapper {
display: flex;
width: 22mm;
flex-shrink: 0;
align-items: flex-start;
gap: 1mm;
}
.info-value {
font-size: 10px;
font-weight: bold;
text-align: right;
word-break: break-word;
line-height: 1.0;
}
.no-antrian {
font-size: 28px;
font-weight: bold;
margin: 0.3mm 0;
letter-spacing: 2px;
text-transform: uppercase;
line-height: 1.0;
}
.klinik-name {
font-size: 12px;
font-weight: bold;
margin-top: 0.3mm;
margin-bottom: 2mm;
padding-bottom: 0;
text-transform: uppercase;
overflow: visible;
line-height: 1.0;
}
.footer {
margin-top: 2mm;
padding-top: 1mm;
padding-bottom: 0;
margin-bottom: 6mm;
border-top: 2px solid #000;
font-size: 10px;
font-weight: bold;
text-align: center;
line-height: 1.0;
}
.footer-text {
margin-bottom: 0.5mm;
font-weight: bold;
line-height: 1.0;
}
.footer-text:last-child {
margin-bottom: 0;
}
/* Feed lines untuk auto cutter (space setelah footer) */
.cut-section {
margin-top: 0;
padding-top: 0;
height: 0;
display: block;
page-break-after: always;
visibility: hidden;
}
@media print {
body {
margin: 0;
padding: 2mm 4mm 6mm 4mm;
}
.ticket-table {
border: none;
}
.ticket-content {
padding: 2mm 4mm 4mm 4mm;
}
/* Feed setelah print untuk auto cutter */
.ticket-table::after {
content: '';
display: block;
height: 10mm;
min-height: 10mm;
page-break-after: always;
}
@page {
size: 80mm auto;
margin: 2mm 0 6mm 0;
/* Margin top kecil, margin bottom besar untuk feed auto cutter */
}
/* Feed tambahan di akhir untuk memastikan kertas cukup untuk dipotong */
.cut-section {
height: 0 !important;
min-height: 0 !important;
display: block !important;
page-break-after: always !important;
visibility: hidden !important;
}
/* Pastikan tidak ada page break di tengah content */
.ticket-content > * {
page-break-inside: avoid;
}
}
</style>
</head>
<body>
<table class="ticket-table" cellpadding="0" cellspacing="0">
<tr>
<td class="ticket-content">
<div class="header">
<div class="hospital-name">RSUD DR. SAIFUL ANWAR</div>
<div class="hospital-address">Jl. Jaksa Agung Suprapto No.2, Malang</div>
<div class="ticket-title">Tiket Antrean</div>
</div>
<div class="no-antrian">${noAntrianDisplay}</div>
${ruangInfo ? `<div class="klinik-name">${ruangInfo}</div>` : `<div class="klinik-name">${data.klinik}</div>`}
<div class="qr-code-section">
<div class="qr-code">
<img src="${qrCodeImage}" alt="QR Code" />
</div>
<div class="barcode-number">${data.barcode}</div>
</div>
<div class="info-section">
<div class="info-row">
<div class="info-label-wrapper">
<span class="info-label-text">Tanggal</span><span class="info-colon">:</span>
</div>
<span class="info-value">${data.tanggal}</span>
</div>
<div class="info-row">
<div class="info-label-wrapper">
<span class="info-label-text">Waktu</span><span class="info-colon">:</span>
</div>
<span class="info-value">${data.waktu}</span>
</div>
<div class="info-row">
<div class="info-label-wrapper">
<span class="info-label-text">Shift</span><span class="info-colon">:</span>
</div>
<span class="info-value">${data.shift}</span>
</div>
<div class="info-row">
<div class="info-label-wrapper">
<span class="info-label-text">Pembayaran</span><span class="info-colon">:</span>
</div>
<span class="info-value">${data.pembayaran}</span>
</div>
${data.namaDokter ? `
<div class="info-row">
<div class="info-label-wrapper">
<span class="info-label-text">Dokter</span><span class="info-colon">:</span>
</div>
<span class="info-value">${data.namaDokter}</span>
</div>
` : ''}
</div>
<div class="footer">
<div class="footer-text">Tunjukkan tiket ini saat check-in</div>
</div>
<!-- Feed lines untuk auto cutter (space setelah footer untuk cutter) -->
<div class="cut-section"></div>
<!-- ESC/POS Auto Cut Command Comment (untuk printer driver yang support) -->
<!-- ESC/POS Commands for EPSON M352A: -->
<!-- ESC i = Full Cut (0x1B 0x69) -->
<!-- ESC m = Partial Cut (0x1B 0x6D) -->
<!-- GS V 0 = Full Cut (0x1D 0x56 0x00) -->
<!-- GS V 1 = Partial Cut (0x1D 0x56 0x01) -->
</td>
</tr>
</table>
</body>
</html>
`;
return html;
};
/**
* Print thermal ticket
*/
const printTicket = async (data: ThermalPrintData): Promise<boolean> => {
try {
isPrinting.value = true;
// Generate HTML
const html = await generatePrintHTML(data);
// Create print window
const printWindow = window.open('', '_blank', 'width=300,height=400');
if (!printWindow) {
throw new Error('Tidak dapat membuka window print. Pastikan pop-up diizinkan.');
}
printWindow.document.write(html);
printWindow.document.close();
// Wait for images to load
await new Promise((resolve) => {
printWindow.onload = () => {
setTimeout(() => {
printWindow.focus();
// Add event listener untuk afterprint (auto cut trigger)
const handleAfterPrint = () => {
console.log('Print completed. Auto cutter should activate.');
console.log('Note: For EPSON M352A, ensure printer settings have auto cut enabled.');
console.log('ESC/POS Cut Command: ESC i (0x1B 0x69) for full cut');
};
printWindow.addEventListener('afterprint', handleAfterPrint);
// Print ke printer default
// Catatan: Window.print() akan menggunakan printer default yang sudah di-set di sistem
// Jika user ingin mengubah printer, mereka perlu mengubah default printer di Windows/OS Settings terlebih dahulu
printWindow.print();
// Note: Untuk printer thermal EPSON M352A, auto cutter akan otomatis aktif jika:
// 1. Driver printer sudah dikonfigurasi untuk auto cut
// 2. Printer settings di Windows/OS sudah enable auto cut
// 3. CSS feed lines di atas sudah cukup untuk trigger auto cut
//
// ESC/POS Commands yang didukung:
// - ESC i (0x1B 0x69): Full Cut
// - ESC m (0x1B 0x6D): Partial Cut
// - GS V 0 (0x1D 0x56 0x00): Full Cut
// - GS V 1 (0x1D 0x56 0x01): Partial Cut
// Close window after print (optional)
// Delay lebih lama untuk memastikan print dan cut selesai
setTimeout(() => {
printWindow.removeEventListener('afterprint', handleAfterPrint);
printWindow.close();
}, 1500);
resolve(true);
}, 500);
};
});
return true;
} catch (error) {
console.error('Error printing ticket:', error);
throw error;
} finally {
isPrinting.value = false;
}
};
/**
* Print ticket dengan data dari queueStore patient object
*/
const printTicketFromPatient = async (patient: any): Promise<boolean> => {
// Determine tanggal kunjungan
let tanggalKunjungan: string;
if (patient.visitDate) {
// Jika ada visitDate (untuk jadwal lain), gunakan itu
tanggalKunjungan = formatDate(patient.visitDate);
} else if (patient.visitType === 'SEKARANG' || !patient.visitType) {
// Jika kunjungan hari ini
tanggalKunjungan = formatDate(new Date().toISOString());
} else {
// Fallback ke tanggal sekarang
tanggalKunjungan = formatDate(new Date().toISOString());
}
// Determine waktu
let waktu: string;
if (patient.createdAt) {
waktu = formatTime(patient.createdAt);
} else if (patient.jamPanggil) {
// Gunakan jamPanggil jika ada
waktu = patient.jamPanggil;
} else {
waktu = formatTime(new Date().toISOString());
}
// Status tidak lagi disertakan dalam QR code
// Status akan dicek real-time dari queueStore saat scan QR
// Format QR code: hanya BARCODE saja untuk fleksibilitas status yang bisa berubah
// Normalize barcode - pastikan format konsisten (trim whitespace)
const barcode = String(patient.barcode || '').trim();
if (!barcode) {
console.error('❌ Barcode pasien tidak valid untuk print ticket:', patient);
throw new Error('Barcode pasien tidak valid');
}
console.log('🖨️ Printing ticket for patient:', {
noAntrian: patient.noAntrian,
barcode: barcode,
status: patient.status,
klinik: patient.klinik
});
const printData: ThermalPrintData = {
noAntrian: patient.noAntrian || '',
barcode: barcode,
klinik: patient.klinik || '',
ruang: patient.ruang || undefined,
nomorRuang: patient.nomorRuang || undefined,
shift: patient.shift || 'Shift 1',
pembayaran: patient.pembayaran || '',
tanggal: tanggalKunjungan,
waktu: waktu,
namaDokter: patient.namaDokter || undefined,
// Status tidak digunakan lagi, hanya untuk kompatibilitas interface
status: undefined
};
return await printTicket(printData);
};
return {
isPrinting,
printTicket,
printTicketFromPatient,
generateQRCode
};
};
+54
View File
@@ -0,0 +1,54 @@
// composables/useVisitAPI.ts
export interface VisitStats {
today_patients: number;
today_active_queues_by_service: Record<string, number>;
average_waiting_seconds: number;
monthly_trend: Array<{
count: number;
month: string;
payment_type: string;
}>;
total_by_payment_type: Record<string, number>;
payment_type_distribution_percent: Record<string, number>;
average_waiting_by_service_seconds: Record<string, number>;
from: string;
to: string;
}
export const useVisitAPI = () => {
const config = useRuntimeConfig();
// We use the configured external API URL directly
const statsURL = `${config.public.externalApiBaseUrl}/visit/stats`;
/**
* Fetch visit statistics
*/
const fetchStats = async (filters: { from?: string; to?: string; service_code?: string; payment_type?: string } = {}): Promise<VisitStats | null> => {
try {
const queryParams = new URLSearchParams();
if (filters.from) queryParams.append('from', filters.from);
if (filters.to) queryParams.append('to', filters.to);
if (filters.service_code) queryParams.append('service_code', filters.service_code);
if (filters.payment_type) queryParams.append('payment_type', filters.payment_type);
const url = `${statsURL}${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
const response = await $fetch<{ message: string; data: VisitStats }>(url, {
method: 'GET',
});
if (response && response.data) {
return response.data;
}
return null;
} catch (error: any) {
console.error('❌ Error fetching visit stats:', error);
return null;
}
};
return {
fetchStats,
};
};
+205
View File
@@ -0,0 +1,205 @@
// composables/useWebSocket.ts
import { ref, computed, onUnmounted, getCurrentInstance, toValue } from 'vue'
export interface WebSocketConfig {
url: string | any
clientId: string | any
onMessage?: (data: any) => void
onOpen?: () => void
onClose?: () => void
onError?: (error: Event) => void
reconnectInterval?: number
maxReconnectAttempts?: number
fallbackPostUrl?: string // Optional: use a specific URL for the POST fallback (e.g., a proxy)
}
export interface WebSocketMessage {
to_client: string
data: any
}
export const useWebSocket = (config: WebSocketConfig) => {
const ws = ref<WebSocket | null>(null)
const isConnected = ref(false)
const reconnectAttempts = ref(0)
const reconnectTimer = ref<NodeJS.Timeout | null>(null)
const currentUrl = computed(() => toValue(config.url))
const currentClientId = computed(() => toValue(config.clientId))
const wsUrl = computed(() => {
let baseUrl = currentUrl.value
// Automatically upgrade to secure protocols if caller uses HTTPS
if (typeof window !== 'undefined' && window.location.protocol === 'https:') {
if (baseUrl.startsWith('ws://')) {
baseUrl = baseUrl.replace('ws://', 'wss://')
} else if (baseUrl.startsWith('http://')) {
baseUrl = baseUrl.replace('http://', 'https://')
}
}
const url = new URL(baseUrl)
url.searchParams.set('client_id', currentClientId.value)
return url.toString()
})
const clearHandlers = (wsInstance: WebSocket | null) => {
if (wsInstance) {
wsInstance.onopen = null
wsInstance.onmessage = null
wsInstance.onclose = null
wsInstance.onerror = null
}
}
const connect = () => {
try {
// Clear any pending reconnect timer first
if (reconnectTimer.value) {
clearTimeout(reconnectTimer.value)
reconnectTimer.value = null
}
// Close existing connection if any, and CLEAN HANDLERS to prevent recursion
if (ws.value) {
if (ws.value.readyState !== WebSocket.CLOSED) {
// console.log('🔌 Closing existing WebSocket before new connection...')
clearHandlers(ws.value)
ws.value.close()
}
ws.value = null
}
const connectionUrl = wsUrl.value
ws.value = new WebSocket(connectionUrl)
ws.value.onopen = () => {
// console.log('✅ WebSocket connected:', currentClientId.value)
isConnected.value = true
reconnectAttempts.value = 0
config.onOpen?.()
}
ws.value.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
config.onMessage?.(data)
} catch (error) {
console.error('Error parsing WebSocket message:', error)
}
}
ws.value.onclose = () => {
// console.log('❌ WebSocket closed:', currentClientId.value)
isConnected.value = false
config.onClose?.()
// Attempt to reconnect only if we didn't just reach max attempts
// The reconnectTimer is cleared in disconnect() and at start of connect()
if (reconnectAttempts.value < (config.maxReconnectAttempts || 5)) {
reconnectAttempts.value++
const interval = config.reconnectInterval || 3000
// console.log(`⏳ Reconnecting in ${interval}ms... Attempt ${reconnectAttempts.value}`)
reconnectTimer.value = setTimeout(() => {
connect()
}, interval)
} else {
console.error('Max reconnection attempts reached')
}
}
ws.value.onerror = (error) => {
console.error('⚠️ WebSocket error:', error)
config.onError?.(error)
}
} catch (error) {
console.error('❌ Error creating WebSocket:', error)
config.onError?.(error as Event)
}
}
const disconnect = () => {
if (reconnectTimer.value) {
clearTimeout(reconnectTimer.value)
reconnectTimer.value = null
}
if (ws.value) {
// console.log('🔌 Manual disconnect: Cleaning handlers and closing...')
clearHandlers(ws.value)
ws.value.close()
ws.value = null
}
isConnected.value = false
reconnectAttempts.value = 0
}
const sendMessage = (message: any) => {
if (ws.value && ws.value.readyState === WebSocket.OPEN) {
ws.value.send(JSON.stringify(message))
return true
}
console.warn('WebSocket is not connected')
return false
}
// Send data via POST to WebSocket server
const sendViaPost = async (message: WebSocketMessage) => {
try {
let postUrl = ''
if (config.fallbackPostUrl) {
// Use the explicitly provided fallback URL (likely a relative proxy path)
postUrl = config.fallbackPostUrl
} else {
// Derive post URL from WebSocket URL (legacy behavior)
let baseUrl = currentUrl.value
if (baseUrl.startsWith('ws://')) {
baseUrl = baseUrl.replace('ws://', 'http://')
} else if (baseUrl.startsWith('wss://')) {
baseUrl = baseUrl.replace('wss://', 'https://')
}
const urlObj = new URL(baseUrl)
postUrl = `${urlObj.protocol}//${urlObj.host}${urlObj.pathname}`
}
const response = await fetch(postUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(message),
})
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error')
console.error('❌ POST Error response:', errorText)
throw new Error(`HTTP error! status: ${response.status}, message: ${errorText}`)
}
const result = await response.json().catch(() => ({ success: true }))
return result
} catch (error) {
console.error('❌ Error sending message via POST:', error)
throw error
}
}
if (getCurrentInstance()) {
onUnmounted(() => {
disconnect()
})
}
return {
ws: computed(() => ws.value),
isConnected: computed(() => isConnected.value),
connect,
disconnect,
sendMessage,
sendViaPost,
reconnectAttempts: computed(() => reconnectAttempts.value),
}
}
+5
View File
@@ -0,0 +1,5 @@
// Default avatar image path
// Simpan gambar avatar default di: assets/images/default-avatar.png
import defaultAvatarImage from '@/assets/images/default-avatar.png'
export const DEFAULT_AVATAR = defaultAvatarImage
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from "cypress";
export default defineConfig({
e2e: {
baseUrl: "http://10.10.150.175:3000",
supportFile: false,
specPattern: "cypress/e2e/**/*.cy.{js,jsx,ts,tsx}",
},
component: {
devServer: {
framework: "vue",
bundler: "vite",
},
},
});
+7
View File
@@ -0,0 +1,7 @@
describe('Login Page', () => {
it('should load the login page', () => {
cy.visit('/')
// Adjust this to match a selector on your home or login page
cy.contains('h1', /login/i).should('be.visible')
})
})
+5
View File
@@ -0,0 +1,5 @@
{
"name": "Using fixtures to represent data",
"email": "[email protected]",
"body": "Fixtures are a great way to mock data for responses to routes"
}
+37
View File
@@ -0,0 +1,37 @@
/// <reference types="cypress" />
// ***********************************************
// This example commands.ts shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add('login', (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
//
// declare global {
// namespace Cypress {
// interface Chainable {
// login(email: string, password: string): Chainable<void>
// drag(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
// dismiss(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
// visit(originalFn: CommandOriginalFn, url: string, options: Partial<VisitOptions>): Chainable<Element>
// }
// }
// }
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Components App</title>
</head>
<body>
<div data-cy-root></div>
</body>
</html>
+36
View File
@@ -0,0 +1,36 @@
// ***********************************************************
// This example support/component.ts is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import './commands'
import { mount } from 'cypress/vue'
// Augment the Cypress namespace to include type definitions for
// your custom command.
// Alternatively, can be defined in cypress/support/component.d.ts
// with a <reference path="./component" /> at the top of your spec.
declare global {
namespace Cypress {
interface Chainable {
mount: typeof mount
}
}
}
Cypress.Commands.add('mount', mount)
// Example use:
// cy.mount(MyComponent)
+1
View File
@@ -0,0 +1 @@
{"message":"Data visit berhasil diambil","data":[{"id":446,"visit_code":26021300007,"registration_datetime":"2026-02-13T03:40:42Z","active":true,"healthcare_services":[{"id":528,"ticket":"RN007","check_in":true,"check_in_datetime":"2026-02-13T03:41:04Z","datetime_start":"2026-02-13T03:40:42Z","healtcare_service_code":"RN","active":true,"fk_ms_healthcare_service_id":11,"healthcare_service_name":"ANAK","shift_number":1,"available_start_time":"07:00:00","fk_ref_payment_type_id":2,"payment_type_name":"JKN","fk_ms_registration_counter_id":14,"registration_counter_name":"LOKET 14 REG","visit_statuses":[{"id":2174,"fk_ref_visit_status_id":3,"datetime":"2026-02-13T03:40:49.674718Z","visit_status_name":"PG ANJUNGAN","visit_status_description":"Panggilan loket ke Anjungan"},{"id":2182,"fk_ref_visit_status_id":28,"datetime":"2026-02-13T03:47:18.423472Z","visit_status_name":"PE PENDAFTARAN","visit_status_description":"Pending Pendaftaran"},{"id":2177,"fk_ref_visit_status_id":6,"datetime":"2026-02-13T03:41:04.275703Z","visit_status_name":"RT PENDAFTARAN","visit_status_description":"Tunggu Panggilan Loket"},{"id":2175,"fk_ref_visit_status_id":4,"datetime":"2026-02-13T03:40:49.676542Z","visit_status_name":"RT CHECK-IN","visit_status_description":"Tunggu Pasien Check-In"},{"id":2173,"fk_ref_visit_status_id":2,"datetime":"2026-02-13T03:40:42.946501Z","visit_status_name":"RT ANJUNGAN","visit_status_description":"Ruang Tunggu Anjungan"},{"id":2172,"fk_ref_visit_status_id":1,"datetime":"2026-02-13T03:40:42.944133Z","visit_status_name":"CT ANJUNGAN","visit_status_description":"Cetak Tiket Antrian"},{"id":2184,"fk_ref_visit_status_id":8,"datetime":"2026-02-13T03:47:20.080008Z","visit_status_name":"PO PENDAFTARAN","visit_status_description":"Proses Pendaftaran"},{"id":2183,"fk_ref_visit_status_id":28,"datetime":"2026-02-13T03:47:18.425723Z","visit_status_name":"PE PENDAFTARAN","visit_status_description":"Pending Pendaftaran"},{"id":2176,"fk_ref_visit_status_id":5,"datetime":"2026-02-13T03:41:04.274554Z","visit_status_name":"PS CHECK-IN","visit_status_description":"Pasien Sudah Check-In"},{"id":2181,"fk_ref_visit_status_id":8,"datetime":"2026-02-13T03:47:14.538472Z","visit_status_name":"PO PENDAFTARAN","visit_status_description":"Proses Pendaftaran"}]}]}],"meta":{"limit":10,"offset":0,"total":2139,"total_pages":214,"current_page":1,"has_next":true,"has_prev":false}}
+50
View File
@@ -0,0 +1,50 @@
services:
web-antrean:
build:
context: .
dockerfile: Dockerfile
container_name: web-antrean
ports:
- "3000:3000"
env_file:
- .env
environment:
- NODE_ENV=production
- HOST=0.0.0.0
- PORT=3000
- NUXT_AUTH_SECRET=${NUXT_AUTH_SECRET}
- NUXT_KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID}
- NUXT_KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET}
- NUXT_KEYCLOAK_ISSUER=${KEYCLOAK_ISSUER}
- NUXT_KEYCLOAK_LOGOUT_URI=${KEYCLOAK_LOGOUT_URI}
- NUXT_POST_LOGOUT_REDIRECT_URI=${POST_LOGOUT_REDIRECT_URI}
- NUXT_PUBLIC_AUTH_URL=${AUTH_ORIGIN}
- NUXT_EXTERNAL_API_BASE_URL=${EXTERNAL_API_BASE_URL}
- NUXT_PUBLIC_VERIFICATION_API_BASE_URL=${VERIFICATION_API_BASE_URL}
- NUXT_PUBLIC_ANTRIAN_API_URL=${ANTRIAN_API_URL}
- NUXT_PUBLIC_VISIT_API_URL=${VISIT_API_URL}
- NUXT_PUBLIC_WS_API_URL=${WS_API_URL}
- NUXT_SESSION_DURATION_HOURS=${SESSION_DURATION_HOURS}
- NUXT_OAUTH_STATE_DURATION_MINUTES=${OAUTH_STATE_DURATION_MINUTES}
- NUXT_PROXY_CLIENT_ORIGIN=${PROXY_CLIENT_ORIGIN}
- NUXT_PROXY_TARGET_HOST_FALLBACK=${PROXY_TARGET_HOST_FALLBACK}
- NUXT_PROXY_TARGET_HOST_KLINIK_FALLBACK=${PROXY_TARGET_HOST_KLINIK_FALLBACK}
volumes:
# Mount local data directory to persist the sqlite users database
- ./data:/app/data
restart: unless-stopped
networks:
- antrean-network
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/', (r) => {process.exit(r.statusCode < 500 ? 0 : 1)})"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
antrean-network:
driver: bridge
volumes:
data:
File diff suppressed because it is too large Load diff
+741
View File
@@ -0,0 +1,741 @@
# 📓 DEVLOG — Development Log
**Project:** Web Antrean — Sistem Manajemen Antrean Rawat Jalan RSSA
**Author:** Akbar
**Stack:** JavaScript · Vue 3 · TypeScript · Nuxt 3 · Vuetify 3 · Pinia
---
## 🧠 AI Prompt — Cara Menggunakan Dokumen Ini
> Salin prompt berikut ke Claude untuk membantu mengisi DEVLOG harian:
```
Kamu adalah technical writer. Bantu aku menulis DEVLOG untuk hari ini.
Context:
- Project: Web Antrean — Sistem Manajemen Antrean Rawat Jalan RSSA
- Stack: Nuxt 3, Vue 3, TypeScript, Vuetify 3, Pinia, WebSocket, Keycloak
- Yang dikerjakan hari ini: [ceritakan bebas]
- Masalah yang ditemui: [ceritakan]
- Solusi yang diterapkan: [ceritakan]
Format output:
## [Tanggal] — [Judul Singkat]
**Yang dikerjakan:** ...
**Keputusan teknis:** ...
**Masalah & solusi:** ...
**Besok:** ...
```
---
## Format Entry
```markdown
## [YYYY-MM-DD] — [Judul Singkat Pekerjaan]
**Sprint/Phase:** [nama sprint atau fase]
**Durasi:** [X jam]
**Status:** ✅ Done | 🔄 In Progress | ⏸ Blocked
### Yang Dikerjakan
- ...
### Keputusan Teknis
> Jelaskan keputusan arsitektur/implementasi penting dan alasannya
### Masalah & Solusi
| Masalah | Solusi | Referensi |
|---------|--------|-----------|
| | | |
### Besok
- [ ] ...
### Referensi
- [link]
```
---
## Log Entries
<!-- Tambahkan entry baru di bawah baris ini, urutan terbaru di atas -->
## 2026-07-10 — Migrasi API Endpoint & Project Knowledge Base
**Sprint/Phase:** Phase 7 — Infrastruktur & Dokumentasi
**Durasi:** ~3 jam
**Status:** ✅ Done
### Yang Dikerjakan
- Migrasi endpoint API Antrian dari `10.10.123.140:8089` ke `10.10.150.131:8089` di `.env`
- `VERIFICATION_API_BASE_URL`
- `ANTRIAN_API_URL`
- `PROXY_TARGET_HOST_KLINIK_FALLBACK`
- Pembuatan **Project Knowledge Base** (`AGENTS.md`) di `.agents/` untuk agent AI context
- Dokumentasi lengkap arsitektur, store hierarchy, API endpoints, auth flow, gotchas
- File ini otomatis dimuat sebagai context di setiap session
- Update DEVLOG, DEVPLAN, dan PRD berdasarkan kondisi project terkini
### Keputusan Teknis
> **API endpoint migration**: Backend Antrian API dipindah ke server baru (`10.10.150.131`). Semua konfigurasi ada di `.env` — perubahan `.env` membutuhkan restart dev server karena Nuxt membaca env hanya saat startup. Masih ada technical debt berupa hardcoded fallback IP lama di ~25 file source code.
> **Project Knowledge Base**: Dibuat `.agents/AGENTS.md` sebagai single knowledge document yang mencakup arsitektur, store hierarchy, API config, auth flow, dan known gotchas. Ini mempercepat onboarding dan mengurangi kesalahan saat AI agent bekerja di codebase.
### Masalah & Solusi
| Masalah | Solusi | Referensi |
|---------|--------|-----------|
| API masih connect ke IP lama setelah ubah `.env` | Restart dev server (Ctrl+C → `npm run dev`). Nuxt hanya baca `.env` saat startup | `.env`, `nuxt.config.ts` |
| Hardcoded fallback IP di ~25 file | Documented sebagai tech debt. Ideally semua fallback dihapus, hanya andalkan `.env` | `AGENTS.md` |
| Banyak klinik tidak muncul di Admin Klinik Ruang | Filter `totalQuota === 0` di `ruangStore.js` menyembunyikan klinik dari API yang belum dikonfigurasi | `stores/ruangStore.js` |
### Besok
- [ ] Eliminasi hardcoded fallback IP lama di semua source files
- [ ] Verifikasi koneksi ke endpoint baru (`10.10.150.131:8089`)
- [ ] Investigasi klinik yang hilang dari Admin Klinik Ruang karena filter `totalQuota`
---
## 2026-07-09 — Fix Admin Klinik Ruang Missing Clinics
**Sprint/Phase:** Phase 7 — Bug Fixing
**Durasi:** ~1 jam
**Status:** ✅ Done
### Yang Dikerjakan
- Investigasi bug di halaman Admin Klinik Ruang (`/adminklinikruang`) — hanya 5 klinik muncul dari total ~23 klinik
- Root cause: Filter `totalQuota === 0` di `ruangStore.js` (`fetchRuangFromAPI()`) menyaring klinik yang:
1. Tidak ditemukan di clinicStore (default `totalQuota: 0`)
2. Belum dikonfigurasi kuotanya di seed data
- Fix: Menghapus kedua filter `totalQuota === 0` (line 387-391 dan line 480-483)
- User kemudian me-revert fix ini (mengembalikan filter) — menunjukkan filter ini memang diperlukan untuk business logic tertentu
### Keputusan Teknis
> Filter `totalQuota === 0` ternyata berfungsi ganda: (1) menyembunyikan klinik yang memang tidak aktif, (2) tapi juga menyembunyikan klinik baru dari API yang belum dikonfigurasi. Trade-off ini perlu didiskusikan dengan PO.
### Masalah & Solusi
| Masalah | Solusi | Referensi |
|---------|--------|-----------|
| Admin Klinik Ruang hanya tampil 5 klinik | Ditemukan filter `totalQuota === 0` di `ruangStore.fetchRuangFromAPI()` | `stores/ruangStore.js:387-391, 480-483` |
| Fix di-revert oleh user | Filter diperlukan untuk business logic — perlu pendekatan alternatif (misal: tampilkan tapi tandai sebagai "belum dikonfigurasi") | — |
---
## 2026-06-23 — Refactoring queueStore.js ke TypeScript (Phase 2: WebSocket & Typings)
**Sprint/Phase:** Phase 6 (Verifikasi Akun & Kiosk) / Refactoring Technical Debt
**Durasi:** 1 jam
**Status:** ✅ Done
### Yang Dikerjakan
- Membuat composable mandiri `composables/useQueueSync.ts` dan memindahkan lebih dari 200 baris logika *WebSocket* (`onWsMessage`, `connect`, `startAutoSync`) dari dalam *store*.
- Menjalankan *scripting* untuk me-resolve sebagian besar error peringatan `implicit any type` dan `catch(error: any)` di dalam `queueStore.ts`.
- Menerapkan injeksi dependencies dari `queueStore` ke dalam parameter `useQueueSync()` tanpa memutus *reactivity* dari Pinia state.
### Keputusan Teknis
> **Dependency Injection pada Composable:** Karena fungsionalitas WebSocket membutuhkan akses terhadap state (`allPatients`, `currentProcessingPatient`) dan actions (`fetchPatientsForLoket`, dll), maka variabel-variabel tersebut dilempar (injected) melalui parameter `deps` ke dalam fungsi `useQueueSync(deps)`. Hal ini mencegah masalah *circular dependencies* di ekosistem Nuxt/Pinia.
### Masalah & Solusi
| Masalah | Solusi | Referensi |
|---------|--------|-----------|
| Pinia-Plugin-PersistedState *type mismatch* pada `paths` config | Menambahkan flag `// @ts-ignore` untuk properti `paths` karena kompatibel secara *runtime* namun melanggar validasi tipe di rilis plugin yang terpasang. | QMD: Technical Debt |
### Besok
- Menguji secara manual interaksi antar *terminal* (Anjungan, Loket, Klinik) untuk memverifikasi fungsionalitas `useQueueSync`.
- Menulis Unit Tests (jika platform sudah siap).
---
## 2026-06-23 — Refactoring queueStore.js ke TypeScript (Phase 1)
**Sprint/Phase:** Phase 6 (Verifikasi Akun & Kiosk) / Refactoring Technical Debt
**Durasi:** 1 jam
**Status:** 🔄 In Progress
### Yang Dikerjakan
- Mengubah ekstensi file `queueStore.js` menjadi `queueStore.ts`.
- Membuat file definisi tipe antrean di `types/queue.ts` (`QueuePatient`).
- Mengekstrak raw fetch API calls (Loket, Visit, Update, Selesai Tiket) ke composable mandiri `composables/useQueueAPI.ts`.
- Melakukan transisi pada fungsi-fungsi besar (`fetchPatientsForLoket`, `callNext`, `callMultiplePatients`, `processPatient`) di dalam `queueStore.ts` untuk menggunakan `useQueueAPI` guna memangkas boilerplate fetch.
### Keputusan Teknis
> **Incremental Refactoring:** Menghindari pecahnya `queueStore` secara drastis dengan tetap mempertahankan satu *store instance* (`useQueueStore`) namun mengabstraksi beban logikanya keluar (*API* & *Sync layer*). Ini meminimalisasi *blast radius* / *breaking changes* ke komponen UI yang sudah jalan.
### Masalah & Solusi
| Masalah | Solusi | Referensi |
|---------|--------|-----------|
| Pembengkakan ukuran *store file* (>3500 baris, 145KB). | Mulai memisahkan logika pemanggilan API eksternal dan mendefinisikan strukturnya dengan TypeScript. | QMD: Technical Debt |
### Besok
- Membuat tipe yang strict untuk `state` dan seluruh *method signatures* di `queueStore.ts`.
- Mengekstraksi fungsionalitas WebSocket ke `composables/useQueueSync.ts`.
- Jika sudah ringan, memecah *store* menjadi `loketQueueStore` dan `clinicQueueStore`.
---
## 2026-06-19 — Fitur Detail Akun Verifikasi
**Sprint/Phase:** Phase 6 — Verifikasi Akun
**Durasi:** ~4 jam
**Status:** ✅ Done
### Yang Dikerjakan
- Penambahan halaman `DetailAkun.vue` (`400bfcd`)
- Update `VerifikasiAkun.vue` untuk integrasi dengan detail akun
- Modifikasi konfigurasi Vuetify di `plugins/vuetify.ts`
### Keputusan Teknis
> Pembuatan halaman detail akun terpisah untuk memberikan view lengkap informasi akun yang sedang diverifikasi, memperbaiki user experience admin saat memvalidasi data user.
---
## 2026-06-0809 — Voice Over Antrean & Manajemen Loket/Klinik
**Sprint/Phase:** Phase 5 — Stabilisasi & Kiosk Features
**Durasi:** ~12 jam
**Status:** ✅ Done
### Yang Dikerjakan
- Implementasi sistem notifikasi suara (voice over) untuk antrean loket dan klinik (`8b9c472`)
- Update UI `AdminLoket/[id].vue` dan `Anjungan/AntrianLoket/[id].vue` untuk support voice announcement
- Modifikasi `queueStore.js` untuk integrasi status antrean
- Resolusi isu WebSocket dan CORS policy pada endpoint api antrean
### Keputusan Teknis
> Menggunakan voice synthesis/announcement otomatis untuk pendaftaran antrean agar pasien dengan keterbatasan visual dapat dipanggil secara otomatis dan jelas di area loket maupun klinik.
---
## 2026-06-0205 — Proxy Routes & Fitur Pindah Klinik
**Sprint/Phase:** Phase 5 — Integrasi API & Queue System
**Durasi:** ~16 jam
**Status:** ✅ Done
### Yang Dikerjakan
- Implementasi proxy routes untuk `klinik-api`, `stats-api`, `visit-api` menghindari CORS issues (`0928e78`)
- Fitur pindah klinik pada dashboard admin (`f81dd57`)
- Penambahan dialog konfirmasi (CheckIn, Unfinished Patient) di queue features
- Store lokalisasi menggunakan Pinia untuk loket, klinik, dan queue management (`047b390`)
- Pembaruan DEVLOG, DEVPLAN, PRD, QMD, dan EVLOG
### Keputusan Teknis
> Menggunakan proxy routes di sisi server (Nitro) untuk mem-bypass CORS ketika memanggil backend API. Hal ini memperkuat keamanan dan menstabilkan fetch data dari aplikasi Vue frontend ke service backend.
## 2026-05-25 — Perbaikan & Dokumentasi Project
**Sprint/Phase:** Phase 5 — Stabilisasi & Dokumentasi
**Durasi:** ~4 jam
**Status:** 🔄 In Progress
### Yang Dikerjakan
- Push perbaikan berbagai bug (`ffdb88d`)
- Pembuatan dokumentasi project: PRD, QMD, DEVLOG
- Stabilisasi WebSocket sync dengan deterministic client ID
- Penambahan polling fallback 30 detik di semua admin interface
### Keputusan Teknis
> Membuat dokumentasi lengkap (PRD, QMD, DEVLOG) untuk meningkatkan maintainability project ke depan. Semua dokumen diisi berdasarkan kondisi aktual project, bukan template kosong.
### Masalah & Solusi
| Masalah | Solusi | Referensi |
|---------|--------|-----------|
| Dokumentasi project belum ada | Buat PRD, QMD, DEVLOG di folder `docs/` | — |
### Besok
- [ ] Review dan finalisasi dokumen
- [ ] Lanjutkan stabilisasi fitur penunjang
---
## 2026-05-22 — Stabilisasi WebSocket & Tooltips Dashboard
**Sprint/Phase:** Phase 5 — Stabilisasi
**Durasi:** ~8 jam
**Status:** ✅ Done
### Yang Dikerjakan
- Standardisasi header konfigurasi (origin & referer) di `stats-api`, `visit-api`, `klinik-api` ke `http://10.10.150.175:3000`
- Penambahan tooltips di komponen Dashboard
- Standardisasi WebSocket client ID menjadi deterministic untuk reliable cross-device messaging
- Implementasi polling fallback 30 detik di semua admin interface
- Audit semua halaman yang bergantung WebSocket
### Keputusan Teknis
> WebSocket client ID diubah dari random ke deterministic agar server bisa menargetkan pesan ke device tertentu. Polling 30 detik ditambahkan sebagai safety net jika WebSocket disconnect — ini menghindari data drift antar display.
### Masalah & Solusi
| Masalah | Solusi | Referensi |
|---------|--------|-----------|
| Header origin/referer tidak konsisten antar proxy route | Standardisasi ke `http://10.10.150.175:3000` di semua route handler | `server/routes/` |
| Data antrean tidak sinkron antar display saat WS drop | Polling fallback 30 detik + deterministic WS client ID | `useWebSocket.ts` |
---
## 2026-05-21 — Perbaikan Layar Info & Isolasi Loket
**Sprint/Phase:** Phase 5 — Stabilisasi
**Durasi:** ~6 jam
**Status:** ✅ Done
### Yang Dikerjakan
- Fix: info klinik ruang tidak muncul di layar informasi (`ec9dac0`)
- Isolasi state `currentProcessingPatient` menggunakan unique storage key per loket
- Perbaikan `processNextQueue` dan `callNext` — strict filter berdasarkan `loketId`
- Validasi WebSocket event handler agar tidak ada update lintas loket yang tidak sah
- Memastikan `allPatients` tetap single source of truth
### Keputusan Teknis
> Masalah kritis: loket A memproses pasien loket B karena `currentProcessingPatient` menggunakan key yang sama. Solusi: setiap loket mendapat unique persisted state key (`currentPatient_loket_{id}`).
### Masalah & Solusi
| Masalah | Solusi | Referensi |
|---------|--------|-----------|
| Cross-loket interference pada `currentProcessingPatient` | Unique storage key per loket di persisted state | `queueStore.js` |
| `processNextQueue` memproses pasien dari loket lain | Strict filter berdasarkan `loketId` dan service mapping | `useQueue.js` |
| WebSocket event mengupdate loket yang salah | Guard check di event handler | `useWebSocket.ts` |
---
## 2026-05-20 — Subspesialis & Perbaikan Bug Loket
**Sprint/Phase:** Phase 4 — Fitur Eksekutif
**Durasi:** ~7 jam
**Status:** ✅ Done
### Yang Dikerjakan
- Penambahan fitur subspesialis di PatientCard (`1446e87`)
- Display subspesialis sebagai pill/chip di samping status badge
- Perbaikan bug tampilan loket (`f622052`)
- UI enhancement: PatientCard menampilkan info ruang/subspesialis secara kondisional
### Keputusan Teknis
> Subspesialis ditampilkan sebagai pill/chip di PatientCard agar admin klinik bisa langsung melihat spesialisasi pasien tanpa perlu buka detail. Conditional rendering — tampilkan ruang ATAU subspesialis, tergantung data yang tersedia.
### Masalah & Solusi
| Masalah | Solusi | Referensi |
|---------|--------|-----------|
| Info subspesialis tidak tampil di card pasien | Tambah conditional pill/chip di `PatientCard.vue` | `components/features/queue/PatientCard.vue` |
| Bug tampilan loket | Fix layout dan data binding | `pages/AdminLoket.vue` |
---
## 2026-05-19 — Docker & Anjungan Eksekutif
**Sprint/Phase:** Phase 4 — Fitur Eksekutif & Deployment
**Durasi:** ~8 jam
**Status:** ✅ Done
### Yang Dikerjakan
- Penambahan Dockerfile dan docker-compose untuk deployment (`bc74b98`)
- Refinement UI anjungan eksekutif — pilihan subspesialis
- Peningkatan kontras dan visibilitas button seleksi (border tebal, shadow, hover state)
- Implementasi handler function untuk selection state yang robust
### Keputusan Teknis
> Docker ditambahkan untuk standardisasi deployment. Anjungan eksekutif mendapat UI khusus dengan radio-style button yang lebih intuitif — target user adalah pasien dengan tech level rendah, jadi kontras dan ukuran harus maksimal.
### Masalah & Solusi
| Masalah | Solusi | Referensi |
|---------|--------|-----------|
| Deployment manual dan tidak konsisten | Dockerfile + docker-compose | `Dockerfile`, `docker-compose.yml` |
| Button seleksi subspesialis kurang kontras | Thicker border, deeper shadow, distinct hover/selected state | Anjungan pages |
---
## 2026-05-18 — Optimasi Performa & Memory
**Sprint/Phase:** Phase 5 — Stabilisasi
**Durasi:** ~6 jam
**Status:** ✅ Done
### Yang Dikerjakan
- Perbaikan memory usage dan load performance (`cb3310b`)
- Verifikasi status kunjungan operasi
- Pengecekan semua API endpoint yang sudah terintegrasi
- Pembersihan `console.log` verbose di high-frequency path
### Keputusan Teknis
> Mengurangi memory footprint dengan: (1) membersihkan verbose logging di WebSocket handler dan QR scanner, (2) implementasi blacklist untuk endpoint yang terus-menerus gagal agar tidak spam request.
### Masalah & Solusi
| Masalah | Solusi | Referensi |
|---------|--------|-----------|
| `doctorStore.js` terus retry endpoint yang 500 | Implementasi blacklist endpoint gagal | `stores/doctorStore.js` |
| `console.log` verbose di `useWebSocket.ts` | Cleanup logging di high-frequency path | `composables/useWebSocket.ts` |
| Memory usage terus naik | Reduce excessive API retry + cleanup logging | — |
---
## 2026-04-20 — Update Hak Akses & Perbaikan Tiket
**Sprint/Phase:** Phase 3 — Hak Akses & Permission
**Durasi:** ~6 jam
**Status:** ✅ Done
### Yang Dikerjakan
- Update sistem hak akses berbasis Keycloak role & group (`2bf00ef`)
- Perbaikan API pembuatan tiket antrean
- Standardisasi user role slugs dari Keycloak groups
- Implementasi slugification utility untuk role normalization
### Keputusan Teknis
> Role dari Keycloak group path (`/Instalasi STIM/Devops/Superadmin`) di-slugify menjadi URL-friendly (`instalasi-stim-devops-superadmin`) untuk konsistensi di permission API dan internal user data.
### Masalah & Solusi
| Masalah | Solusi | Referensi |
|---------|--------|-----------|
| Role dari Keycloak tidak URL-friendly | Implementasi slugification utility | `composables/useHakAkses.ts` |
| Tiket antrean gagal digenerate | Perbaikan API endpoint pembuatan tiket | `server/api/` |
---
## 2026-04-15 — Update Favicon
**Sprint/Phase:** Phase 3 — Polish
**Durasi:** ~1 jam
**Status:** ✅ Done
### Yang Dikerjakan
- Ganti favicon aplikasi web-antrean
- Update konfigurasi `app.head` di `nuxt.config.ts` untuk mengarah ke file icon baru
### Keputusan Teknis
> Favicon diletakkan di folder `public/` dan direferensi via `nuxt.config.ts``app.head.link`.
---
## 2026-04-13 — Integrasi Keycloak Role Access
**Sprint/Phase:** Phase 3 — Hak Akses & Permission
**Durasi:** ~8 jam
**Status:** ✅ Done
### Yang Dikerjakan
- Verifikasi dan standardisasi akses Keycloak role
- Implementasi middleware `auth.ts`, `guest.ts`, `permissions.ts`, `checkPageAccess.ts`
- Setup permission store (`permissionStore.ts`)
- Integrasi permission API endpoint (`server/api/permission.get.ts`)
### Keputusan Teknis
> Menggunakan 4 layer middleware: (1) `auth.ts` — cek login, (2) `guest.ts` — redirect jika sudah login, (3) `permissions.ts` — cek hak akses halaman, (4) `checkPageAccess.ts` — validasi granular per page. Model ini memastikan defense-in-depth untuk access control.
### Masalah & Solusi
| Masalah | Solusi | Referensi |
|---------|--------|-----------|
| Role dari Keycloak format path, bukan slug | Konversi ke slug untuk matching di permission API | `middleware/permissions.ts` |
---
## 2026-02-24 — Perbaikan Ambil Tiket
**Sprint/Phase:** Phase 2 — Fitur Inti
**Durasi:** ~4 jam
**Status:** ✅ Done
### Yang Dikerjakan
- Fix bug pengambilan tiket antrean di anjungan (`94ff9f5`)
- Perbaikan flow generate nomor antrean
### Masalah & Solusi
| Masalah | Solusi | Referensi |
|---------|--------|-----------|
| Tiket gagal digenerate pada kondisi tertentu | Fix logic generate nomor antrean | `composables/useQRGenerator.ts` |
---
## 2026-02-19 — Implementasi Anjungan (Kiosk) & Queue Store
**Sprint/Phase:** Phase 2 — Fitur Inti
**Durasi:** ~10 jam
**Status:** ✅ Done
### Yang Dikerjakan
- Implementasi modul Anjungan (Kiosk) lengkap (`0c4019c`)
- Halaman pilih klinik, klinik ruang, penunjang
- Halaman antrian per klinik, klinik ruang, penunjang
- Check-in pasien via QR
- Admin anjungan
- Implementasi Pinia store baru untuk queue management (`4342cdc`)
- Integrasi dengan Visit API dan Antrian API
- WebSocket integration untuk real-time update
- Halaman admin dan kiosk display
### Keputusan Teknis
> `queueStore.js` dibuat sebagai central store untuk semua operasi antrean — ini menjadi single source of truth untuk `allPatients`. Keputusan ini mempermudah sinkronisasi real-time tapi membuat file menjadi sangat besar (saat ini 141KB). Perlu dipecah di masa depan.
---
## 2026-02-18 — Core Anjungan Display & Verification
**Sprint/Phase:** Phase 2 — Fitur Inti
**Durasi:** ~8 jam
**Status:** ✅ Done
### Yang Dikerjakan
- Implementasi core display system anjungan (`7d07a4f`)
- Display antrean klinik ruang
- Display antrean masuk
- Display antrean loket
- Tambah verification store dan konfigurasi endpoint baru (`f78bbea`)
- `verificationApiBaseUrl``http://10.10.123.140:8089/api/v1`
- `ekstrakExpertiseUrl` endpoint
- Fix wsBaseUrl fallback di Nuxt config (`d03fa63`)
### Keputusan Teknis
> Dual backend API architecture: Visit API (port 8084) untuk data kunjungan utama, Antrian API (port 8089) untuk verifikasi dan data klinik/dokter. Proxy layer Nitro digunakan untuk bypass CORS dan menyembunyikan IP backend dari client.
---
## 2026-02-13 — Admin Loket, Klinik Ruang & Queue Management
**Sprint/Phase:** Phase 2 — Fitur Inti
**Durasi:** ~10 jam
**Status:** ✅ Done
### Yang Dikerjakan
- Implementasi halaman Admin Loket detail (`8c6b3fd`)
- Manajemen antrian pasien per loket
- Aksi pasien: panggil, skip, recall, selesai
- Dialog seleksi (pilih loket, pilih layanan)
- Implementasi `AdminKlinikRuang` page (`67a5514`)
- Manajemen antrian per klinik ruang
- Processing, calling, filtering, global search
- Implementasi komprehensif patient queue management (`dbc9054`)
- Pinia store untuk queue
- API integration
- Admin counter page
- Tambah komponen `PatientCard`, `CurrentPatientCard` (`95ff830`)
- Fitur queue management untuk klinik ruang dan counter (`4df13cb`)
### Keputusan Teknis
> Memisahkan queue components menjadi 4 komponen: `PatientCard` (card per pasien), `CurrentPatientCard` (pasien sedang dilayani), `QueueActionsCard` (tombol aksi), `TabelPatientData` (tabel lengkap). Ini memastikan reusability di halaman admin loket, klinik, dan penunjang.
---
## 2026-02-12 — Anjungan Pages, WebSocket & Queue API
**Sprint/Phase:** Phase 2 — Fitur Inti
**Durasi:** ~10 jam
**Status:** ✅ Done
### Yang Dikerjakan
- Introduce halaman Anjungan untuk antrian klinik, klinik ruang, dan counter (`a5ab338`)
- Tambah halaman check-in pasien
- Implementasi core queue management system dengan WebSocket (`0e3ee37`)
- Pinia store untuk queue
- WebSocket integration
- Halaman check-in dan kiosk display
- Update post API klinik dan loket di queueStore (`4985aef`)
- Fix status pasien selesai di klinik ruang (`c08941a`)
- Various merge dan bugfix
### Keputusan Teknis
> WebSocket dipilih sebagai primary communication channel untuk real-time sync karena latensi < 2 detik krusial di rumah sakit — pasien harus langsung melihat nomor panggil berubah di layar display.
---
## 2026-02-1011 — WebSocket Bug Fixing
**Sprint/Phase:** Phase 2 — Stabilisasi WebSocket
**Durasi:** ~12 jam (2 hari)
**Status:** ✅ Done
### Yang Dikerjakan
- Fix bug WebSocket di check-in loket (`b6dc252`)
- Fix socket check-in flow (`e686dda`)
- Update socket data dan token handling (`fb70237`)
- Fix duplikasi data dan ticket menghilang di admin klinik loket (`c02905e`)
- Update WS untuk admin loket dan check-in (`a7a654b`)
- Fix missing function error (`23164bc`)
- Perbaikan sorting pasien di next process (`6af3f58`)
### Masalah & Solusi
| Masalah | Solusi | Referensi |
|---------|--------|-----------|
| Duplikasi tiket di admin klinik | Fix logic deduplication di WebSocket handler | `useWebSocket.ts` |
| Tiket menghilang setelah check-in | Fix state update flow — pastikan re-render setelah WS event | `queueStore.js` |
| Socket check-in tidak trigger update | Fix event listener binding | `composables/useCheckIn.ts` |
---
## 2026-02-09 — Integrasi WebSocket
**Sprint/Phase:** Phase 2 — Real-time
**Durasi:** ~6 jam
**Status:** ✅ Done
### Yang Dikerjakan
- Setup dan implementasi WebSocket client (`101dc38`)
- Koneksi ke `ws://10.10.123.135:8084/api/v1/ws`
- Implementasi `useWebSocket.ts` composable
### Keputusan Teknis
> Menggunakan native browser WebSocket API dibungkus composable (`useWebSocket.ts`) daripada library seperti `socket.io` — mengurangi dependency dan lebih ringan untuk environment LAN internal.
---
## 2026-02-0206 — API Integration & Display Screen
**Sprint/Phase:** Phase 2 — Integrasi API
**Durasi:** ~20 jam (5 hari)
**Status:** ✅ Done
### Yang Dikerjakan
- Update tampilan layar screen display (`cf54ded`)
- Penambahan API klinik ruang (`a29838e`, `b327997`, `c2d9023`)
- Update logika pemanggilan admin loket by payment (`efeb42e`)
- Fix HTTPS implementation dan check-in (`c899a71`)
- Update session dan WebSocket (`0428017`)
- Update status klinik ruang (`6696881`)
- Update tampilan anjungan (`329ac9c`)
- Fixing admin loket dan blokade loket yang tidak tersedia (`5d139c8`)
- Update verifikasi dan lainnya (`2838016`)
- Update post API status dan data pasien ruang (`e2a5d43`)
### Keputusan Teknis
> Logika pemanggilan admin loket diubah menjadi payment-based — pasien yang sudah bayar diprioritaskan. Loket yang tidak tersedia di-blokade otomatis agar petugas tidak melihat loket yang bukan tanggung jawabnya.
---
## 2026-01-2830 — Master Data & API Queue Store
**Sprint/Phase:** Phase 1 — Fondasi
**Durasi:** ~12 jam (3 hari)
**Status:** ✅ Done
### Yang Dikerjakan
- Update master data berdasarkan jenis layanan (`9f0f6a7`)
- Update API antrean masuk dan minor changes (`507f415`)
- Fix session dan tampilan screen (`8dd94ed`)
- Fix loop dan duplikasi data (`0bd5311`)
- Update fetch API (`cccefb0`)
- Update layout semua halaman (`ae8b06d`)
- Update queueStore dari API (`19633af`)
- Minor update anjungan (`59e42f3`)
---
## 2026-01-2227 — Admin Loket & API Integrasi
**Sprint/Phase:** Phase 1 — Fondasi
**Durasi:** ~16 jam (4 hari)
**Status:** ✅ Done
### Yang Dikerjakan
- Update logika penarikan API dan tampilan anjungan (`e900c5a`)
- Update No RM dan desain (`2ccb378`)
- Update API master loket dan antrian loket (`75a9638`)
- Update anjungan (`8de89cb`)
- Update master klinik ruang (`6c08352`)
- Update sidebar check-in dan antrian loket (`525322b`)
- Update API loket admin (`083fe3e`)
- Push update admin loket (`f606045`)
---
## 2026-01-1921 — Layout, Sidebar, Dashboard & Monitoring
**Sprint/Phase:** Phase 1 — Fondasi
**Durasi:** ~20 jam (3 hari)
**Status:** ✅ Done
### Yang Dikerjakan
- Update flow klinik ruang dan design consistency (`cbd8f44`)
- Update sidebar, profile, dan loket (`f2efd83`)
- Update loket dan monitoring (`81b877b`, `c00c18e`)
- Update page header komponen (`00bb954`)
- Sidebar change (`3db912a`)
- Update header, card, dialog (`fde7111`, `531ca10`, `f7a3b20`)
- Perubahan update sidebar, dashboard, monitoring, antrean loket, verifikasi (`bb71955`)
- Update check-in tampilan dan function (`482294b`)
### Keputusan Teknis
> Layout system menggunakan Vuetify navigation drawer (`SideBar.vue`) + custom `PageHeader.vue`. Design consistency diterapkan via SCSS variables (`_variables.scss`, `_colors.scss`) untuk warna, spacing, dan typography.
---
## 2026-01-15 — Login Page, Check-in & Antrean Masuk
**Sprint/Phase:** Phase 1 — Fondasi
**Durasi:** ~8 jam
**Status:** ✅ Done
### Yang Dikerjakan
- Implementasi login page dan check-in (`e99a0ab`)
- Update color palette dan konsultasi pasien (`a8a55b9`)
- Tampilan antrean masuk (`ed57e2d`)
- Edit manual check-in (`928be7c`)
- Update logika pemanggilan, sticky konten, tampilan card (`96c6376`)
- Perbaikan generate dan scan tiket QR (`8aa5ab2`)
### Keputusan Teknis
> QR code digunakan untuk tiket antrean — pasien mendapat QR saat ambil antrean, lalu scan QR saat check-in. Library `html5-qrcode` dipilih untuk scanner dan `qrcode.vue` untuk generator.
---
## 2026-01-1214 — Loket, Klinik Ruang & Fast Track
**Sprint/Phase:** Phase 1 — Fondasi
**Durasi:** ~16 jam (3 hari)
**Status:** ✅ Done
### Yang Dikerjakan
- Update layout klinik dan WebSocket layar klinik (`6e7160c`)
- Update klinik ruang dan status fast track (`27209de`)
- Perubahan tiket dan alur antrean (`5c14227`)
- Push loket baru (`42fe62c`)
- Loket perbaikan "sedang dilayani" (`48e4aab`)
- Anjungan fast track dan tampilan ruang (`3817b2d`)
- Perbaikan loket dan total antrean (`a000ba0`, `0a8e11a`, `9ccbaba`)
- Check-in tampilan (`0c7b33a`)
- Fixing layar loket dan status pasien (`e80032f`)
- QRcode update (`6c80c08`)
- Update warna dan klinik ruang (`f3e90ad`)
- Perubahan antrean masuk (`c5b623f`)
- Update seed data dan fungsi pindah/konsul klinik (`89c3549`)
---
## 2026-01-0809 — Klinik Ruang, QR & Print
**Sprint/Phase:** Phase 1 — Fondasi
**Durasi:** ~12 jam (2 hari)
**Status:** ✅ Done
### Yang Dikerjakan
- Layar baru antrean klinik dan antrean masuk (`2d3d589`)
- Update klinik ruang (`ca5913c`)
- Nav items (`8a4bb44`)
- Update print admin loket, logika klinik ruang, numbering antrian baru (`676bdc0`)
- Update QR function (`22d7205`)
- Perubahan format No Antrean (`6d5d565`)
- Check-in footers detail (`1b7a142`)
- Klinik ruang dan update admin loket (`9ea8300`)
### Keputusan Teknis
> Format nomor antrean: `[KodeKlinik]-[NomorUrut]` (contoh: `PDL-001`). Thermal print diimplementasi via `useThermalPrint.ts` composable untuk cetak tiket langsung dari browser ke printer thermal via Web API.
---
<!-- Tambahkan entry baru di atas baris ini, urutan terbaru di atas -->
---
## 📊 Summary Stats
| Metrik | Value |
|--------|-------|
| Total hari dev | ~60+ hari (Jan 2026 — Juli 2026) |
| Fase project | 7 fase (Setup → Fondasi → Hak Akses → Eksekutif → Stabilisasi → Verifikasi → Infrastruktur) |
| Fitur selesai | 8 modul utama + dokumentasi + migrasi infra |
| Total commits | 90+ commits |
| Bug ditemukan | 18+ (WebSocket, loket interference, memory, display sync, missing clinics, API endpoint) |
| Bug diselesaikan | 18+ |
| Halaman dibuat | 28+ halaman |
| Composables | 18 composable |
| Pinia Stores | 14 stores |
| Komponen | 25+ komponen |
| Dokumentasi | AGENTS.md, DEVLOG.md, DEVPLAN.md, PRD.md, API_ENDPOINTS_CLINIC_DOCTOR.md |
+262
View File
@@ -0,0 +1,262 @@
# 🗺️ DEVPLAN — Development Plan
**Project:** Web Antrean — Sistem Manajemen Antrean Rawat Jalan RSSA
**Version:** 1.1.0
**Author:** Akbar
**Stack:** JavaScript · Vue 3 · TypeScript · Nuxt 3 · Vuetify 3 · Pinia
**Last Updated:** 2026-07-10
---
## 🧠 AI Prompt — Cara Menggunakan Dokumen Ini
> Salin prompt berikut ke Claude untuk membantu membuat dev plan:
```
Kamu adalah tech lead senior fullstack (Nuxt 3, Vue 3, TypeScript).
Project: Web Antrean — Sistem Manajemen Antrean Rawat Jalan RSSA
Deskripsi: Digitalisasi alur antrean pasien rawat jalan di rumah sakit
Deadline: Mei 2026 (Go-Live)
Tim: 1 Fullstack Engineer (Akbar), PO Tim RSSA
Fitur yang harus dibangun:
1. Anjungan mandiri (kiosk registrasi pasien, pilih klinik/subspesialis)
2. Check-in pasien via QR code
3. Manajemen antrean loket & display screen
4. Manajemen antrean klinik & ruang
5. Manajemen antrean penunjang
6. Real-time sync via WebSocket
7. Dashboard monitoring & statistik
8. Manajemen user, master data, dan hak akses (Keycloak)
Buatkan:
1. Breakdown fase development (Phase 0 s/d launch)
2. Task list per fase dengan estimasi
3. Urutan prioritas fitur (MoSCoW)
4. Technical dependencies antar task
5. Milestone dan checkpoint
Format dalam tabel Markdown yang terstruktur.
```
---
## 1. Project Timeline
```
[Start]──Phase 0──Phase 1──Phase 2──Phase 3──Phase 4──Phase 5──Phase 6──Phase 7──[Launch]
Setup Fondasi Inti API Hak Akses Eksekutif Stabilisasi Verifikasi Infrastruktur
```
| Fase | Nama | Durasi | Target | Status |
|------|------|--------|--------|--------|
| Phase 0 | Setup & Arsitektur | 1 Minggu | Awal Jan 2026 | ✅ Done |
| Phase 1 | Fondasi (Loket, Klinik, QR) | 3 Minggu | Jan 2026 | ✅ Done |
| Phase 2 | Fitur Inti (Anjungan, WS, API) | 3 Minggu | Feb 2026 | ✅ Done |
| Phase 3 | Hak Akses & Keycloak | 2 Minggu | Apr 2026 | ✅ Done |
| Phase 4 | Fitur Eksekutif & Docker | 1 Minggu | Pertengahan Mei | ✅ Done |
| Phase 5 | Stabilisasi & Dokumentasi | 2 Minggu | Akhir Mei 2026 | ✅ Done |
| Phase 6 | Verifikasi Akun & Kiosk | 2 Minggu | Juni 2026 | ✅ Done |
| Phase 7 | Infrastruktur & Dokumentasi | Ongoing | Juli 2026 | 🔄 In Progress |
---
## 2. Feature Prioritization (MoSCoW)
| Priority | Fitur | Estimasi | Phase |
|----------|-------|----------|-------|
| 🔴 Must Have | Modul Anjungan (Pilih klinik, ambil tiket, cetak) | 40 jam | 2 |
| 🔴 Must Have | Modul Loket (Panggil, skip, recall, next) | 30 jam | 1 & 2 |
| 🔴 Must Have | Check-in via QR Code | 20 jam | 1 |
| 🔴 Must Have | Sinkronisasi Real-time via WebSocket | 30 jam | 2 |
| 🔴 Must Have | Integrasi API Eksternal (Visit API, Antrian API) | 40 jam | 2 |
| 🔴 Must Have | Manajemen Hak Akses Keycloak | 25 jam | 3 |
| 🟡 Should Have | Antrean Klinik & Penunjang | 30 jam | 1 & 4 |
| 🟡 Should Have | Dashboard Monitoring & Statistik | 15 jam | 1 |
| 🟡 Should Have | Pilihan Subspesialis (Klinik Eksekutif) | 10 jam | 4 |
| 🟢 Could Have | Notifikasi Suara Panggilan (Text-to-Speech) | 15 jam | Future |
| ⬜ Won't Have | Aplikasi Mobile Native | — | Out of scope |
| ⬜ Won't Have | Integrasi Billing / Pembayaran Langsung | — | Out of scope |
---
## 3. Task Breakdown
### Phase 0 — Setup & Arsitektur
| Task | Estimasi | Dependencies | Status |
|------|----------|--------------|--------|
| Init Nuxt 3 + TypeScript | 2 jam | — | ✅ Done |
| Setup Vuetify 3 + SCSS variables | 4 jam | Task 1 | ✅ Done |
| Setup Pinia state management | 2 jam | Task 1 | ✅ Done |
| Setup Vue Router (Nuxt pages/layout) | 3 jam | Task 1 | ✅ Done |
| Konfigurasi Proxy API di `nuxt.config.ts` | 3 jam | Task 1 | ✅ Done |
### Phase 1 — Fondasi (Loket, Klinik, QR)
| Task | Estimasi | Dependencies | Status |
|------|----------|--------------|--------|
| UI Layout, Sidebar, PageHeader | 8 jam | Phase 0 | ✅ Done |
| Komponen PatientCard & Tabel | 8 jam | Phase 0 | ✅ Done |
| UI Admin Loket & Admin Klinik | 12 jam | Task 2 | ✅ Done |
| Check-in Pasien UI & QR Scanner (`html5-qrcode`) | 12 jam | Phase 0 | ✅ Done |
| Generator Tiket QR (`qrcode.vue`) | 6 jam | Phase 0 | ✅ Done |
| Cetak Tiket Thermal (`useThermalPrint.ts`) | 8 jam | Phase 0 | ✅ Done |
### Phase 2 — Fitur Inti (Anjungan, WS, API)
| Task | Estimasi | Dependencies | Status |
|------|----------|--------------|--------|
| Integrasi Visit API & Antrian API | 16 jam | Phase 0 | ✅ Done |
| Setup WebSocket client (`useWebSocket.ts`) | 8 jam | Task 1 | ✅ Done |
| Layar Display Antrean (Klinik & Loket) | 12 jam | Task 2 | ✅ Done |
| Modul Anjungan Mandiri (UI & Logic) | 16 jam | Task 1 | ✅ Done |
| Centralized `queueStore` | 12 jam | Task 1, 2 | ✅ Done |
| Bugfix duplikasi data & WS handling | 12 jam | Task 2, 5 | ✅ Done |
### Phase 3 — Hak Akses & Keycloak
| Task | Estimasi | Dependencies | Status |
|------|----------|--------------|--------|
| Konfigurasi Keycloak SSO di frontend | 8 jam | — | ✅ Done |
| Middleware Auth & Guest | 4 jam | Task 1 | ✅ Done |
| CRUD Master Data (Klinik, Loket, Screen) | 12 jam | Phase 1 | ✅ Done |
| Manajemen Hak Akses User/Group (UI & API) | 12 jam | Task 1 | ✅ Done |
| Middleware Permission & Page Access Guard | 8 jam | Task 4 | ✅ Done |
### Phase 4 — Fitur Eksekutif & Docker
| Task | Estimasi | Dependencies | Status |
|------|----------|--------------|--------|
| UI Pemilihan Subspesialis di Anjungan | 8 jam | Phase 2 | ✅ Done |
| Conditional display spesialis di PatientCard | 4 jam | Task 1 | ✅ Done |
| Setup Dockerfile & docker-compose | 4 jam | — | ✅ Done |
| Update konfigurasi environment untuk container | 4 jam | Task 3 | ✅ Done |
### Phase 5 — Stabilisasi & QA
| Task | Estimasi | Dependencies | Status |
|------|----------|--------------|--------|
| Standardisasi WS Client ID (Deterministic) | 4 jam | Phase 2 | ✅ Done |
| Polling fallback 30s untuk layar display | 6 jam | Phase 2 | ✅ Done |
| Isolasi state loket (`currentProcessingPatient`) | 6 jam | Phase 2 | ✅ Done |
| Blacklist handler untuk endpoint gagal 500 | 4 jam | Phase 2 | ✅ Done |
| Dokumentasi PRD, QMD, DEVLOG, EVLOG | 8 jam | — | ✅ Done |
| Setup Unit Testing & E2E framework | 4 jam | — | ✅ Done |
### Phase 6 — Verifikasi Akun & Kiosk
| Task | Estimasi | Dependencies | Status |
|------|----------|--------------|--------|
| Sistem notifikasi suara (voice over) loket & klinik | 12 jam | Phase 2 | ✅ Done |
| Fitur pindah klinik pada dashboard admin | 8 jam | Phase 2 | ✅ Done |
| Halaman Detail Akun Verifikasi | 4 jam | Phase 3 | ✅ Done |
| Implementasi Proxy Routes (CORS) | 8 jam | Phase 2 | ✅ Done |
| Refactoring queueStore.js ke TypeScript | 8 jam | Phase 2 | ✅ Done |
| Ekstraksi useQueueSync composable | 4 jam | Phase 2 | ✅ Done |
### Phase 7 — Infrastruktur & Dokumentasi
| Task | Estimasi | Dependencies | Status |
|------|----------|--------------|--------|
| Migrasi API endpoint (123.140 → 150.131) | 2 jam | — | ✅ Done |
| Pembuatan Project Knowledge Base (AGENTS.md) | 3 jam | — | ✅ Done |
| Update DEVLOG, DEVPLAN, PRD | 2 jam | — | ✅ Done |
| Fix Admin Klinik Ruang missing clinics | 1 jam | Phase 2 | ✅ Done |
| Eliminasi hardcoded fallback IP di source files | 4 jam | Task 1 | ⏳ Pending |
| SQLite config → External API migration | 8 jam | Phase 2 | ⏳ Pending |
| Production deployment & monitoring | 8 jam | All | ⏳ Pending |
---
## 4. Technical Dependencies
```text
Nuxt 3 + TypeScript
├── Pinia (State Management)
│ └── pinia-plugin-persistedstate (localStorage)
├── Vuetify 3 (UI Framework)
│ └── Material Design Icons
├── Nuxt Nitro (Server Engine)
│ └── Server API Routes + SQLite (better-sqlite3)
├── Auth Layer
│ └── Keycloak SSO (OIDC/OAuth 2.0)
├── API Layer
│ ├── Visit API (10.10.123.135:8084)
│ └── Antrian API (10.10.150.131:8089) ← UPDATED
└── Real-time Layer
└── Native Browser WebSocket
```
---
## 5. Environment Setup
### Local Development
```bash
# Clone repo
git clone https://git.rssa.top/arie.bagus.2905/web-antrean
cd web-antrean
# Install dependencies
npm install
# Copy env
cp .env.example .env
# Jalankan dev server dengan host spesifik
npm run dev
# ATAU
npm run _command_dev
```
### Environment Variables (.env)
```env
AUTH_ORIGIN="http://10.10.150.175:3000"
KEYCLOAK_CLIENT_ID="akbar-test"
KEYCLOAK_ISSUER="https://auth.rssa.top/realms/sandbox"
ANTRIAN_API_URL="http://10.10.150.131:8089/api/v1" # UPDATED Juli 2026
VERIFICATION_API_BASE_URL="http://10.10.150.131:8089/api/v1" # UPDATED Juli 2026
VISIT_API_URL="http://10.10.123.135:8084/api/v1"
WS_API_URL="ws://10.10.123.135:8084/api/v1/ws"
PROXY_CLIENT_ORIGIN="http://10.10.150.175:3000"
PROXY_TARGET_HOST_KLINIK_FALLBACK="10.10.150.131:8089" # UPDATED Juli 2026
```
---
## 6. Git Strategy
### Branch Naming
```
main → production (stable)
Antrean-Code → development / staging
feature/xxx → fitur baru (contoh: feature/anjungan-eksekutif)
fix/xxx → bug fix (contoh: fix/ws-duplicate)
```
### Commit Convention
Saat ini commit message banyak menggunakan free-text (contoh: `push perbaikan`, `update bug ws`). Ke depannya, disarankan menggunakan **Conventional Commits**:
```
feat: tambah halaman dashboard
fix: perbaiki duplikasi antrean di loket
chore: update docker config
docs: update DEVPLAN
```
---
## 7. Milestones & Checkpoints
| Milestone | Kriteria | Target Tanggal | Status |
|-----------|----------|----------------|--------|
| 🏁 Project Kickoff | Setup Nuxt, Layout, Styling | Jan 2026 | ✅ Done |
| 🔌 API & WS Connected | Data mengalir dari backend, WS sync jalan | Feb 2026 | ✅ Done |
| 🔐 Auth & Roles | Keycloak jalan, routing terlindungi middleware | Apr 2026 | ✅ Done |
| 🏥 Anjungan Ready | Pasien bisa check-in & ambil tiket lancar | Pertengahan Mei | ✅ Done |
| 🛡️ Stability Pass | Tidak ada leak, isolasi loket aman, no WS drift | Akhir Mei 2026 | ✅ Done |
| 🔄 TypeScript Migration | queueStore refactored ke TS, composable extracted | Juni 2026 | ✅ Done |
| 📡 API Endpoint Migration | Antrian API pindah ke server baru (150.131) | Juli 2026 | ✅ Done |
| 🚀 Production Go-Live | Deployment docker di server production | Q3 2026 | 🔄 Pending |
---
## 8. Changelog
| Versi | Tanggal | Author | Perubahan |
|-------|---------|--------|-----------|
| 1.1.0 | 2026-07-10 | Akbar | Phase 7: migrasi API, knowledge base, update docs |
| 1.0.0 | 2026-05-25 | Akbar | Initial plan — direkonstruksi berdasarkan timeline aktual |
+544
View File
@@ -0,0 +1,544 @@
# 🚨 EVLOG — Event & Error Log
**Project:** Web Antrean — Sistem Manajemen Antrean Rawat Jalan RSSA
**Author:** Akbar
**Stack:** JavaScript · Vue 3 · TypeScript · Nuxt 3 · Vuetify 3 · Pinia
---
## 🧠 AI Prompt — Cara Menggunakan Dokumen Ini
> Salin prompt berikut ke Claude untuk menganalisis dan mendokumentasikan error:
```
Kamu adalah senior engineer Nuxt 3 + TypeScript. Aku menemukan error berikut:
Error message: [paste error]
Stack trace: [paste stack trace]
Context: [dimana terjadi, langkah reproduksi]
Tech: Nuxt 3, Vue 3, TypeScript, Vuetify 3, Pinia, WebSocket, Keycloak
Bantu aku:
1. Analisis root cause error ini
2. Berikan solusi step-by-step
3. Sarankan cara mencegah error serupa
4. Format hasilnya untuk EVLOG dalam Markdown
```
---
## Severity Legend
| Level | Icon | Deskripsi | SLA |
|-------|------|-----------|-----|
| Critical | 🔴 | App down / data loss / security | < 4 jam |
| High | 🟠 | Fitur utama break | < 1 hari |
| Medium | 🟡 | Fitur minor terganggu | < 3 hari |
| Low | 🟢 | UI/kosmetik | Backlog |
---
## Format Entry
```markdown
### [EV-XXX] — [Judul Singkat Error/Event]
| Field | Value |
|-------|-------|
| **ID** | EV-XXX |
| **Tanggal** | YYYY-MM-DD |
| **Severity** | 🔴 Critical / 🟠 High / 🟡 Medium / 🟢 Low |
| **Environment** | Development / Staging / Production |
| **Status** | 🔍 Investigating / 🔧 In Fix / ✅ Resolved / ⏭ Wontfix |
| **Reporter** | Akbar |
**Error Message:**
> [paste error message]
**Langkah Reproduksi:**
1. ...
**Root Cause:**
> ...
**Solusi / Fix:**
> ...
**Prevention:**
> ...
**Related:** [commit / file]
```
---
## Event Log
<!-- Tambahkan entry baru di bawah baris ini, urutan terbaru di atas -->
---
### [EV-012] — Info Klinik Ruang Tidak Muncul di Layar Display
| Field | Value |
|-------|-------|
| **ID** | EV-012 |
| **Tanggal** | 2026-05-21 |
| **Severity** | 🟠 High |
| **Environment** | Development |
| **Status** | ✅ Resolved |
| **Reporter** | Akbar |
**Error Message:**
> Layar informasi klinik ruang menampilkan data kosong — tidak ada info klinik ruang yang muncul.
**Langkah Reproduksi:**
1. Buka halaman display screen klinik ruang
2. Data klinik ruang seharusnya muncul
3. Layar kosong — tidak ada informasi tampil
**Root Cause:**
> Data binding untuk info klinik ruang tidak ter-update setelah fetch dari API. Kemungkinan reactive state tidak di-watch dengan benar sehingga UI tidak re-render saat data berubah.
**Solusi / Fix:**
> Perbaikan data binding dan reactive state untuk informasi klinik ruang pada layar display.
**Related:** Commit `ec9dac0` — "push perbaikan layar informasi info klinik ruang tidak muncul"
---
### [EV-011] — Data Antrean Tidak Sinkron Antar Display Screen
| Field | Value |
|-------|-------|
| **ID** | EV-011 |
| **Tanggal** | 2026-05-22 |
| **Severity** | 🔴 Critical |
| **Environment** | Development |
| **Status** | ✅ Resolved |
| **Reporter** | Akbar |
**Error Message:**
> Display screen di lokasi berbeda menampilkan nomor panggil yang berbeda. Admin memanggil pasien tapi layar display tidak update, atau update terlambat > 10 detik.
**Langkah Reproduksi:**
1. Buka admin loket di PC A
2. Buka display screen di TV/monitor B
3. Panggil pasien berikutnya di PC A
4. Display di monitor B tidak menampilkan nomor panggil terbaru
**Root Cause:**
> WebSocket client ID menggunakan random ID, sehingga server tidak bisa menargetkan pesan ke device tertentu secara reliable. Saat koneksi putus lalu reconnect, client ID berubah dan device kehilangan subscription.
**Solusi / Fix:**
```typescript
// ❌ Before — random client ID
const clientId = `client_${Math.random().toString(36).substr(2, 9)}`
// ✅ After — deterministic client ID berdasarkan page + device
const clientId = `${pageType}_${loketId || 'global'}_${deviceFingerprint}`
```
Ditambahkan juga polling fallback setiap 30 detik sebagai safety net.
**Prevention:**
> Selalu gunakan deterministic identifier untuk WebSocket client. Implementasi polling fallback untuk semua halaman yang bergantung pada real-time data.
**Related:** Conversation `6fe234cf` — "Stabilizing WebSocket Queue Synchronization"
---
### [EV-010] — Cross-Loket Interference pada Patient Processing
| Field | Value |
|-------|-------|
| **ID** | EV-010 |
| **Tanggal** | 2026-05-21 |
| **Severity** | 🔴 Critical |
| **Environment** | Development |
| **Status** | ✅ Resolved |
| **Reporter** | Akbar |
**Error Message:**
> Loket A memproses pasien yang seharusnya milik Loket B. `currentProcessingPatient` menampilkan pasien dari loket yang salah.
**Langkah Reproduksi:**
1. Login sebagai admin loket A di browser tab 1
2. Login sebagai admin loket B di browser tab 2
3. Panggil pasien di loket A
4. Loket B menampilkan pasien yang sama sebagai "sedang diproses"
**Root Cause:**
> `currentProcessingPatient` menggunakan key persisted state yang sama untuk semua loket. Karena `pinia-plugin-persistedstate` menyimpan ke `localStorage` dengan key yang sama, operasi di satu loket menimpa state loket lain.
**Solusi / Fix:**
```javascript
// ❌ Before — shared key
persist: { key: 'currentProcessingPatient' }
// ✅ After — unique key per loket
persist: { key: `currentPatient_loket_${loketId}` }
```
Ditambahkan juga strict filter di `processNextQueue` dan `callNext` berdasarkan `loketId`, serta guard check di WebSocket event handler.
**Prevention:**
> Setiap state yang bersifat per-instance (per loket, per klinik) HARUS menggunakan unique key. Jangan pernah share persisted state key antar instance.
**Related:** Conversation `7a37e693` — "Isolating Loket Queue Operations"
---
### [EV-009] — Bug Tampilan Loket
| Field | Value |
|-------|-------|
| **ID** | EV-009 |
| **Tanggal** | 2026-05-20 |
| **Severity** | 🟡 Medium |
| **Environment** | Development |
| **Status** | ✅ Resolved |
| **Reporter** | Akbar |
**Error Message:**
> Layout admin loket tidak render dengan benar — elemen UI tumpang tindih atau alignment salah.
**Langkah Reproduksi:**
1. Login sebagai admin loket
2. Buka halaman `/admin-loket`
3. Layout tampilan loket tidak sesuai desain
**Root Cause:**
> CSS layout issue dan data binding yang tidak sinkron dengan state loket.
**Solusi / Fix:**
> Perbaikan layout dan data binding di halaman admin loket.
**Related:** Commit `f622052` — "perbaikan bug tampilan loket"
---
### [EV-008] — Memory Leak & Request Spam dari doctorStore
| Field | Value |
|-------|-------|
| **ID** | EV-008 |
| **Tanggal** | 2026-05-18 |
| **Severity** | 🟠 High |
| **Environment** | Development |
| **Status** | ✅ Resolved |
| **Reporter** | Akbar |
**Error Message:**
```
Internal Server Error (500) — /klinik-api/doctors/...
```
Error terjadi berulang-ulang tanpa henti, menyebabkan request spam ke backend.
**Langkah Reproduksi:**
1. Buka halaman yang memuat `doctorStore.js`
2. Backend endpoint `/doctors` return 500
3. Store terus retry tanpa batas
4. Network tab penuh dengan request gagal, memory usage naik terus
**Root Cause:**
> `doctorStore.js` tidak memiliki mekanisme backoff atau blacklist untuk endpoint yang gagal. Setiap kali fetch gagal, store langsung retry tanpa delay, menyebabkan infinite loop request ke server.
**Solusi / Fix:**
```javascript
// ✅ Implementasi blacklist endpoint gagal
const blacklistedEndpoints = new Set()
async function fetchDoctors(endpoint) {
if (blacklistedEndpoints.has(endpoint)) {
return [] // skip, sudah di-blacklist
}
try {
return await $fetch(endpoint)
} catch (error) {
if (error.status === 500) {
blacklistedEndpoints.add(endpoint)
console.warn(`Endpoint blacklisted: ${endpoint}`)
}
return []
}
}
```
**Prevention:**
> Semua API fetch HARUS memiliki: (1) error handling, (2) retry limit atau backoff, (3) blacklist mechanism untuk persistent failures. Jangan pernah retry tanpa batas.
**Related:** Conversation `c7502197` — "Optimizing Web Antrean Memory Usage", commit `cb3310b`
---
### [EV-007] — Console.log Verbose Memperlambat Performa
| Field | Value |
|-------|-------|
| **ID** | EV-007 |
| **Tanggal** | 2026-05-18 |
| **Severity** | 🟢 Low |
| **Environment** | Development |
| **Status** | ✅ Resolved |
| **Reporter** | Akbar |
**Error Message:**
> Tidak ada error message, tapi browser DevTools penuh dengan log output, menyebabkan performa menurun terutama di device yang lebih lambat.
**Langkah Reproduksi:**
1. Buka halaman yang menggunakan WebSocket atau QR scanner
2. Buka browser DevTools → Console
3. Log membanjir setiap detik dari `useWebSocket.ts` dan QR scanner di `checkIn.vue`
**Root Cause:**
> `console.log` debugging statements di high-frequency code paths (WebSocket message handler yang dipanggil per-detik, QR scanner frame processing) tidak dihapus setelah debugging selesai.
**Solusi / Fix:**
> Cleanup semua `console.log` di:
> - `composables/useWebSocket.ts` — WebSocket message handler
> - `pages/CheckInPasien/checkIn.vue` — QR scanner frame processing
> Hanya sisakan `console.warn` dan `console.error` untuk kondisi yang benar-benar perlu.
**Prevention:**
> Gunakan convention: `console.log` hanya untuk debugging sementara, `console.warn`/`console.error` untuk production logging. Tambahkan lint rule atau pre-commit hook untuk mendeteksi `console.log` yang tersisa.
**Related:** Conversation `c7502197` — "Optimizing Web Antrean Memory Usage"
---
### [EV-006] — Header Origin/Referer Tidak Konsisten di Proxy Routes
| Field | Value |
|-------|-------|
| **ID** | EV-006 |
| **Tanggal** | 2026-05-22 |
| **Severity** | 🟡 Medium |
| **Environment** | Development |
| **Status** | ✅ Resolved |
| **Reporter** | Akbar |
**Error Message:**
> CORS error atau origin validation failure saat memanggil backend API melalui proxy routes. Beberapa request berhasil, beberapa gagal secara intermittent.
**Langkah Reproduksi:**
1. Panggil API via proxy route `stats-api`, `visit-api`, atau `klinik-api`
2. Beberapa request gagal dengan CORS error
3. Request yang sama kadang berhasil, kadang gagal
**Root Cause:**
> Setiap proxy route handler (`server/routes/stats-api/`, `visit-api/`, `klinik-api/`) menggunakan header origin dan referer yang berbeda-beda. Backend melakukan validasi origin, dan header yang tidak konsisten menyebabkan intermittent failure.
**Solusi / Fix:**
> Standardisasi semua proxy route handler agar menggunakan origin dan referer yang sama:
```typescript
// ✅ Semua proxy routes menggunakan header yang sama
headers: {
'Origin': 'http://10.10.150.175:3000',
'Referer': 'http://10.10.150.175:3000',
}
```
**Prevention:**
> Buat shared utility function untuk proxy header configuration agar semua route handler menggunakan config yang sama. Hindari copy-paste header config per file.
**Related:** Conversation `97867794` — "Standardizing Header Configurations Across APIs"
---
### [EV-005] — Duplikasi Tiket di Admin Klinik Loket
| Field | Value |
|-------|-------|
| **ID** | EV-005 |
| **Tanggal** | 2026-02-11 |
| **Severity** | 🟠 High |
| **Environment** | Development |
| **Status** | ✅ Resolved |
| **Reporter** | Akbar |
**Error Message:**
> Daftar pasien di admin klinik menampilkan tiket yang sama dua kali. Setelah check-in, tiket pasien menghilang dari daftar.
**Langkah Reproduksi:**
1. Pasien ambil antrean di anjungan
2. Buka admin klinik loket
3. Pasien yang sama muncul 2x di daftar
4. Setelah check-in, tiket menghilang dari daftar
**Root Cause:**
> WebSocket event handler menambahkan pasien ke `allPatients` tanpa deduplication check. Saat event masuk bersamaan dari multiple sources (initial load + WebSocket), data terduplikasi. Saat check-in, state update menghapus entry yang salah.
**Solusi / Fix:**
```javascript
// ✅ Deduplication check sebelum add ke allPatients
function addPatient(patient) {
const exists = allPatients.value.find(p => p.nomorAntrean === patient.nomorAntrean)
if (!exists) {
allPatients.value.push(patient)
} else {
// Update existing instead of duplicate
Object.assign(exists, patient)
}
}
```
**Prevention:**
> Semua operasi mutasi pada `allPatients` harus melalui function yang melakukan deduplication check. Jangan pernah langsung `push` tanpa cek duplikat.
**Related:** Commit `c02905e` — "fix duplication and ticket disappear in adminklinik loket"
---
### [EV-004] — WebSocket Check-in Tidak Trigger UI Update
| Field | Value |
|-------|-------|
| **ID** | EV-004 |
| **Tanggal** | 2026-02-10 |
| **Severity** | 🟠 High |
| **Environment** | Development |
| **Status** | ✅ Resolved |
| **Reporter** | Akbar |
**Error Message:**
> Pasien check-in via QR berhasil (API return success), tapi status di admin loket tidak berubah. UI tetap menampilkan pasien sebagai "belum hadir".
**Langkah Reproduksi:**
1. Pasien scan QR di halaman check-in
2. API return success — pasien tercatat hadir
3. Buka admin loket — status pasien masih "belum hadir"
4. Perlu manual refresh untuk melihat update
**Root Cause:**
> WebSocket event listener untuk check-in event tidak ter-bind dengan benar. Event `checkin_success` diterima oleh WebSocket, tapi handler tidak melakukan state update ke Pinia store.
**Solusi / Fix:**
> Fix event listener binding di `useWebSocket.ts` — pastikan `checkin_success` event memicu update pada `queueStore.allPatients`.
**Prevention:**
> Setiap WebSocket event type harus memiliki dedicated handler yang ter-test. Buat mapping event → handler yang eksplisit.
**Related:** Commits `e686dda`, `b6dc252` — "fix socket checkin", "update bug ws checkin loket"
---
### [EV-003] — HTTPS Implementation Gagal di Check-in
| Field | Value |
|-------|-------|
| **ID** | EV-003 |
| **Tanggal** | 2026-02-02 |
| **Severity** | 🟡 Medium |
| **Environment** | Development |
| **Status** | ✅ Resolved |
| **Reporter** | Akbar |
**Error Message:**
> Check-in via QR tidak berfungsi saat HTTPS aktif. Kamera QR scanner tidak bisa diakses.
**Langkah Reproduksi:**
1. Aktifkan HTTPS di dev server
2. Buka halaman check-in
3. QR scanner gagal — kamera tidak muncul
**Root Cause:**
> Browser memerlukan HTTPS untuk akses kamera (MediaDevices API), tapi mixed content policy memblokir request ke backend HTTP. Konfigurasi HTTPS tidak lengkap — SSL certificate self-signed tidak dipercaya browser.
**Solusi / Fix:**
> Fix implementasi HTTPS menggunakan `@vitejs/plugin-basic-ssl` di `nuxt.config.ts`. Konfigurasi dev server untuk HTTPS dengan self-signed cert yang di-trust browser.
**Prevention:**
> Saat menggunakan Web API yang memerlukan secure context (kamera, geolocation), pastikan HTTPS sudah configured end-to-end termasuk backend.
**Related:** Commit `c899a71` — "fix https implementation and checkin"
---
### [EV-002] — Loop & Duplikasi Data di Fetch API
| Field | Value |
|-------|-------|
| **ID** | EV-002 |
| **Tanggal** | 2026-01-29 |
| **Severity** | 🟠 High |
| **Environment** | Development |
| **Status** | ✅ Resolved |
| **Reporter** | Akbar |
**Error Message:**
> Data pasien terduplikasi di daftar antrean. Fetch API dipanggil berulang-ulang dalam loop.
**Langkah Reproduksi:**
1. Buka halaman yang memuat data antrean
2. Data pasien muncul berkali-kali
3. Network tab menunjukkan API dipanggil berulang-ulang
**Root Cause:**
> `watch` atau `computed` yang bergantung pada reactive state memicu re-fetch setiap kali state berubah, dan hasil fetch mengubah state lagi — menyebabkan infinite loop.
**Solusi / Fix:**
> Fix logic watch/computed agar tidak circular. Tambahkan guard condition untuk mencegah re-fetch saat data sudah di-load.
**Prevention:**
> Hindari circular dependency antara watcher dan state mutation. Gunakan flag `isLoading` untuk mencegah concurrent fetch.
**Related:** Commit `0bd5311` — "fix loop dan duplicate data"
---
### [EV-001] — Session & Tampilan Screen Tidak Sinkron
| Field | Value |
|-------|-------|
| **ID** | EV-001 |
| **Tanggal** | 2026-01-30 |
| **Severity** | 🟡 Medium |
| **Environment** | Development |
| **Status** | ✅ Resolved |
| **Reporter** | Akbar |
**Error Message:**
> Display screen menampilkan data lama setelah session expire. Setelah re-login, data tidak refresh otomatis.
**Langkah Reproduksi:**
1. Login dan buka display screen
2. Tunggu session expire (1 jam)
3. Re-login
4. Display masih menampilkan data dari session sebelumnya
**Root Cause:**
> Pinia persisted state menyimpan data lama di `localStorage`. Saat session expire dan user re-login, store tidak di-reset — data lama masih tampil.
**Solusi / Fix:**
> Reset persisted state saat login baru. Pastikan display screen melakukan fresh fetch setelah session recovery.
**Prevention:**
> Implementasi session lifecycle hooks: on session expire → clear stale state, on re-login → fresh fetch semua data.
**Related:** Commit `8dd94ed` — "update fix session dan tampilan screen"
---
## 📊 Error Statistics
| Bulan | Critical | High | Medium | Low | Total |
|-------|---------|------|--------|-----|-------|
| Jan 2026 | 0 | 1 | 1 | 0 | 2 |
| Feb 2026 | 0 | 3 | 1 | 0 | 4 |
| Mar 2026 | 0 | 0 | 0 | 0 | 0 |
| Apr 2026 | 0 | 0 | 0 | 0 | 0 |
| Mei 2026 | 2 | 2 | 1 | 1 | 6 |
| **Total** | **2** | **6** | **3** | **1** | **12** |
---
## 🔁 Recurring Issues
> Daftar error yang muncul lebih dari sekali — kandidat untuk refactor/improvement permanen.
| Error Pattern | Frekuensi | Action |
|---------------|-----------|--------|
| WebSocket disconnect → data tidak sinkron | 3x (EV-004, EV-011, EV-012) | ✅ Implemented: polling fallback 30 detik + auto-reconnect + deterministic client ID |
| Duplikasi data pasien di daftar | 2x (EV-002, EV-005) | ✅ Implemented: deduplication check di `allPatients` mutation |
| Session/state stale setelah reconnect | 2x (EV-001, EV-011) | ✅ Implemented: fresh fetch on reconnect + state reset on re-login |
| Request spam ke endpoint yang gagal | 1x (EV-008) | ✅ Implemented: blacklist + retry limit. **Monitor untuk recurring** |
| Cross-instance state conflict (loket) | 1x (EV-010) | ✅ Implemented: unique persisted state key per instance. **Audit untuk klinik/penunjang** |
+84
View File
@@ -0,0 +1,84 @@
# 🌿 Git Workflow & Branching Guide
Dokumen ini berisi panduan *step-by-step* untuk mengelola Git *branch* di project **web-antrean**. Branch utama (main) untuk project ini adalah **`Antrean-Code`**.
---
## 1. Persiapan Sebelum Mengubah Kode (Membuat Branch Baru)
Sangat disarankan untuk tidak mengubah kode secara langsung di `Antrean-Code`. Buatlah branch baru untuk setiap fitur atau perbaikan *bug*.
**Langkah-langkah:**
1. Pastikan Anda berada di branch utama dan kodenya terbaru:
```bash
git checkout Antrean-Code
git pull origin Antrean-Code
```
2. Buat dan pindah ke branch baru (misalnya `fitur-baru`):
```bash
git checkout -b nama-fitur-anda
```
*(Contoh: `git checkout -b tes-refaktoring`)*
---
## 2. Menyimpan Perubahan (Commit)
Setelah Anda selesai menulis kode, ikuti langkah ini untuk menyimpannya.
**Langkah-langkah:**
1. Cek file apa saja yang berubah:
```bash
git status
```
2. Tambahkan file yang ingin disimpan:
```bash
git add .
```
*(Tanda `.` artinya menambahkan semua file. Anda juga bisa menyebut nama filenya satu-satu)*
3. Lakukan commit dengan pesan yang jelas (mengikuti aturan Conventional Commits di SKILL.md):
```bash
git commit -m "feat: menambah fitur X"
```
---
## 3. Menggabungkan Kode ke Branch Utama (Merge ke Antrean-Code)
Jika fitur di branch Anda sudah selesai dan teruji, saatnya menggabungkannya (*merge*) kembali ke `Antrean-Code`. Ini adalah tahapan yang baru saja kita lakukan.
**Langkah-langkah:**
1. Pastikan tidak ada perubahan yang belum di-commit di branch Anda (harus clean).
```bash
git status
```
2. Pindah ke branch utama (`Antrean-Code`):
```bash
git checkout Antrean-Code
```
3. Gabungkan (*merge*) branch fitur Anda (misal `tes-refaktoring`) ke `Antrean-Code`:
```bash
git merge tes-refaktoring
```
4. Jika tidak ada konflik, Git akan otomatis menyatukan kodenya. (Jika ada konflik, Anda harus memperbaikinya secara manual di code editor, lalu `git add .` dan `git commit`).
---
## 4. Mengunggah Kode ke Server (Push ke Remote)
Setelah merge selesai di komputer lokal, Anda harus mengunggah perubahannya ke server GitLab/GitHub.
**Langkah-langkah:**
1. Pastikan Anda berada di branch `Antrean-Code`.
2. Push ke remote origin:
```bash
git push origin Antrean-Code
```
---
## 5. Membersihkan Branch (Opsional)
Jika branch fitur sudah berhasil di-merge dan di-push, Anda bisa menghapusnya agar daftar branch lokal tetap rapi.
```bash
git branch -d tes-refaktoring
```
---
> **Catatan Penting:**
> Jangan lupa untuk selalu mengacu pada `docs/DEVLOG.md` saat Anda berhasil menggabungkan fitur besar untuk mencatat apa saja yang baru dirilis.
+409
View File
@@ -0,0 +1,409 @@
# 📋 PRD — Product Requirements Document
**Project:** Web Antrean — Sistem Manajemen Antrean Rawat Jalan RSSA
**Version:** 1.1.0
**Status:** `In Progress`
**Author:** Akbar
**Stack:** JavaScript · Vue 3 · TypeScript · Nuxt 3 · Vuetify 3 · Pinia
**Last Updated:** 2026-07-10
---
## 🧠 AI Prompt — Cara Menggunakan Dokumen Ini
> Salin prompt berikut ke Claude / ChatGPT untuk membantu melanjutkan PRD:
```
Kamu adalah product manager senior. Bantu aku mengembangkan PRD untuk project berikut:
- Nama project: Web Antrean — Sistem Manajemen Antrean Rawat Jalan RSSA
- Tujuan utama: Digitalisasi alur antrean pasien rawat jalan di rumah sakit
- Target pengguna: Pasien, Admin Loket, Admin Klinik, Admin Penunjang, Superadmin
- Tech stack: Nuxt 3, Vue 3, TypeScript, Vuetify 3, Pinia, WebSocket, Keycloak SSO
Buatkan:
1. Problem statement yang tajam
2. User stories tambahan dengan acceptance criteria
3. Risiko teknis dan mitigasinya
Format output dalam Markdown.
```
---
## 1. Overview
### 1.1 Executive Summary
Web Antrean adalah aplikasi manajemen antrean rawat jalan berbasis web yang dibangun untuk Rumah Sakit RSSA. Sistem ini mendigitalisasi seluruh alur antrean pasien mulai dari pendaftaran via anjungan mandiri, pemanggilan pasien di loket dan klinik, hingga monitoring real-time oleh administrator. Aplikasi ini kritis karena menggantikan sistem antrean manual yang tidak efisien dan rawan error.
### 1.2 Problem Statement
Rumah Sakit RSSA menghadapi masalah antrean rawat jalan yang tidak terstruktur: pasien tidak mengetahui posisi antrean mereka, petugas loket kesulitan mengelola urutan panggil, dan tidak ada visibilitas real-time lintas unit (loket, klinik, penunjang). Kondisi ini menyebabkan penumpukan pasien, waktu tunggu tidak terprediksi, dan pengalaman buruk bagi pasien.
### 1.3 Goals & Success Metrics
| # | Goal | Metrik | Target |
|---|------|--------|--------|
| 1 | Mengurangi waktu tunggu pasien | Rata-rata waktu tunggu per sesi | < 30 menit |
| 2 | Digitalisasi antrean loket & klinik | % proses antrean via sistem | 100% |
| 3 | Real-time sync antar perangkat | Latensi update antrean via WebSocket | < 2 detik |
| 4 | Kemandirian pasien dalam ambil antrean | % pasien gunakan anjungan mandiri | > 80% |
| 5 | Stabilitas sistem | Uptime saat jam operasional | > 99.5% |
### 1.4 Non-Goals (Out of Scope)
- [ ] Integrasi dengan sistem pembayaran / billing
- [ ] Manajemen rekam medis (EMR)
- [ ] Aplikasi mobile native (iOS/Android)
- [ ] Antrean rawat inap (ranap) — fitur ranap admin tersedia tapi belum final
- [ ] Sistem appointment/penjadwalan janji temu online
---
## 2. Stakeholders
| Role | Nama | Tanggung Jawab |
|------|------|----------------|
| Product Owner | Tim RSSA | Prioritas fitur & validasi kebutuhan |
| Fullstack Engineer | Akbar | Arsitektur, implementasi, & deployment |
| UI/UX Designer | — | Desain antarmuka (dikerjakan oleh engineer) |
| QA Engineer | — | Pengujian via Cypress & Vitest |
| System Admin | Tim IT RSSA | Infrastruktur, server, & jaringan internal |
---
## 3. User Personas
### 🧑 Persona 1: Pasien Rawat Jalan
- **Goals:** Mendapatkan nomor antrean dengan mudah, mengetahui posisi antrean, dan dipanggil tepat waktu
- **Pain Points:** Tidak tahu urutan antrean, harus menunggu tanpa informasi, bingung harus ke mana
- **Tech Level:** Rendah — menggunakan anjungan layar sentuh di rumah sakit
### 🧑 Persona 2: Petugas Loket
- **Goals:** Memanggil pasien secara urut, memproses lebih dari satu loket secara paralel, melihat daftar antrean real-time
- **Pain Points:** Antrean manual rawan konflik antar loket, sulit pantau siapa yang sudah dipanggil
- **Tech Level:** Menengah — menggunakan PC/tablet di meja loket
### 🧑 Persona 3: Admin Klinik / Dokter
- **Goals:** Melihat daftar pasien yang akan dilayani di kliniknya, memanggil pasien sesuai urutan, mencatat status kunjungan
- **Pain Points:** Tidak tahu berapa pasien tersisa, data pasien tidak sinkron antar perangkat
- **Tech Level:** Menengah
### 🧑 Persona 4: Superadmin / Admin IT
- **Goals:** Mengelola master data (klinik, loket, penunjang, screen), mengatur hak akses user, memonitor seluruh antrean
- **Pain Points:** Tidak ada dashboard terpusat, perubahan konfigurasi butuh restart sistem
- **Tech Level:** Tinggi
---
## 4. User Stories
| ID | Epic | Story | Priority | Acceptance Criteria | Status |
|----|------|-------|----------|---------------------|--------|
| US-001 | Anjungan | Sebagai pasien, saya ingin mengambil nomor antrean via anjungan mandiri agar tidak perlu antri ke loket | 🔴 High | - [ ] Pasien dapat memilih klinik tujuan<br>- [ ] Sistem generate nomor antrean unik<br>- [ ] Tiket dicetak atau ditampilkan di layar | `Done` |
| US-002 | Anjungan | Sebagai pasien eksekutif, saya ingin memilih sub-spesialis di anjungan agar antrean sesuai dokter yang dituju | 🔴 High | - [ ] Daftar subspesialis tampil berdasarkan klinik<br>- [ ] Pilihan tersimpan ke nomor antrean | `Done` |
| US-003 | Check-in | Sebagai pasien, saya ingin check-in via scan QR agar kedatangan saya tercatat di sistem | 🔴 High | - [ ] QR scanner aktif via kamera<br>- [ ] Status pasien berubah menjadi "hadir"<br>- [ ] Notifikasi berhasil muncul | `Done` |
| US-004 | Loket | Sebagai petugas loket, saya ingin memanggil pasien berikutnya agar antrean berjalan terurut | 🔴 High | - [ ] Tombol "Panggil Berikutnya" tersedia<br>- [ ] Nomor antrean tampil di display screen<br>- [ ] Sync real-time via WebSocket | `Done` |
| US-005 | Klinik | Sebagai admin klinik, saya ingin melihat daftar pasien yang akan dilayani agar saya bisa mempersiapkan pelayanan | 🔴 High | - [ ] Daftar pasien tampil dengan status terkini<br>- [ ] Update otomatis tanpa refresh manual | `Done` |
| US-006 | Monitoring | Sebagai superadmin, saya ingin melihat dashboard antrean seluruh unit agar dapat memantau kondisi operasional | 🟡 Medium | - [ ] Statistik antrean per unit tersedia<br>- [ ] Data diperbarui real-time | `Done` |
| US-007 | Setting | Sebagai superadmin, saya ingin mengatur hak akses per user/group agar keamanan data terjaga | 🟡 Medium | - [ ] CRUD hak akses per role & group Keycloak<br>- [ ] Perubahan langsung efektif tanpa restart | `Done` |
| US-008 | Penunjang | Sebagai admin penunjang, saya ingin mengelola antrean unit penunjang (lab, radiologi) agar terpisah dari antrean klinik | 🟡 Medium | - [ ] Antrean penunjang terpisah per unit<br>- [ ] Admin hanya melihat unit penunjangnya | `In Progress` |
---
## 5. Functional Requirements
### 5.1 Modul: Anjungan Mandiri
- **FR-01:** Pasien dapat memilih klinik tujuan dari daftar klinik aktif
- **FR-02:** Sistem mengenerate nomor antrean berformat `[KodeKlinik]-[Nomor]` (contoh: `PDL-001`)
- **FR-03:** Anjungan mendukung pilihan subspesialis untuk klinik eksekutif
- **FR-04:** Tiket antrean dapat dicetak via thermal printer
- **FR-05:** Anjungan dapat dikonfigurasi per tipe (klinik, penunjang, klinik ruang)
### 5.2 Modul: Check-in Pasien
- **FR-06:** Pasien dapat check-in mandiri via scan QR code
- **FR-07:** Sistem memvalidasi QR dan mengupdate status kunjungan ke "hadir"
- **FR-08:** Riwayat check-in pasien dapat dilihat oleh petugas
### 5.3 Modul: Loket
- **FR-09:** Petugas loket dapat memanggil pasien berikutnya sesuai urutan antrean
- **FR-10:** Setiap loket memiliki state antrean yang terisolasi (tidak interferensi antar loket)
- **FR-11:** Petugas dapat skip, recall, atau selesaikan pasien
- **FR-12:** Display nomor panggil sinkron via WebSocket ke layar display
- **FR-12b:** Pemanggilan antrean dilengkapi dengan notifikasi suara (voice over) otomatis
### 5.4 Modul: Klinik / Dokter
- **FR-13:** Admin klinik melihat daftar pasien berdasarkan klinik yang diampu
- **FR-14:** Status pasien (menunggu, dipanggil, selesai) dapat diupdate
- **FR-15:** Pemanggilan pasien di klinik sync ke display screen ruangan
### 5.5 Modul: Dashboard & Monitoring
- **FR-16:** Dashboard menampilkan statistik antrean real-time (total, menunggu, selesai)
- **FR-17:** Superadmin dapat melihat data seluruh unit sekaligus
- **FR-18:** Data diperbarui via WebSocket + polling fallback 30 detik
### 5.6 Modul: Setting & Master Data
- **FR-19:** CRUD Master Klinik, Loket, Penunjang, Klinik Ruang, Screen
- **FR-20:** Manajemen hak akses berbasis Keycloak role & group
- **FR-21:** Konfigurasi screen display untuk setiap loket/klinik
- **FR-22:** Manajemen user login dan sesi
### 5.7 Modul: Penunjang
- **FR-23:** Antrean unit penunjang (lab, radiologi, dll) dikelola terpisah
- **FR-24:** Admin penunjang hanya mengakses unit yang menjadi tanggung jawabnya
---
## 6. Non-Functional Requirements
| Kategori | Requirement | Target |
|----------|-------------|--------|
| Performance | WebSocket latency update antrean | < 2 detik |
| Performance | Waktu load halaman utama | < 3 detik |
| Performance | Polling fallback interval | 30 detik |
| Security | Auth method | Keycloak SSO (OAuth 2.0 / OIDC) |
| Security | Session duration | 1 jam (configurable) |
| Security | Hak akses berbasis role & group | Role-Based + Group-Based |
| Availability | Uptime jam operasional (06.0021.00) | > 99.5% |
| Accessibility | Anjungan — touch target size | > 44px (mudah dioperasikan pasien) |
| Scalability | Jumlah koneksi WebSocket simultan | > 50 device sekaligus |
| Compatibility | Browser support | Chrome, Edge (terbaru) |
| Network | Operasi di jaringan LAN internal RS | ✅ Fully LAN-based |
---
## 7. Technical Specifications
### 7.1 Tech Stack
| Layer | Teknologi |
|-------|-----------|
| Frontend Framework | Nuxt 3 (SSR — server-side rendering) |
| UI Framework | Vue 3 · Vuetify 3 |
| Language | TypeScript · JavaScript |
| State Management | Pinia + pinia-plugin-persistedstate |
| Styling | SCSS (Vuetify override) + Material Design Icons |
| Real-time | WebSocket (native browser API via `useWebSocket.ts`) |
| Auth | Keycloak SSO (OAuth 2.0 / OIDC) |
| Charts | Chart.js · vue-chartjs · nuxt-charts |
| QR | html5-qrcode (scanner) · qrcode.vue (generator) |
| Print | Thermal printer via `useThermalPrint.ts` |
| Date | Day.js |
| Icons | FontAwesome · Material Design Icons |
| Testing | Vitest · Cypress |
| Fonts | Inter (Google Fonts) |
### 7.2 Backend APIs (Eksternal)
| Service | Base URL | Keterangan |
|---------|----------|------------|
| Visit API | `http://10.10.123.135:8084/api/v1` | Data kunjungan & antrean utama |
| Antrian API (Klinik) | `http://10.10.150.131:8089/api/v1` | Verifikasi & data klinik/dokter |
| WebSocket | `ws://10.10.123.135:8084/api/v1/ws` | Real-time queue update |
### 7.3 Arsitektur Sistem
```
┌─────────────────────────────────────────────┐
│ Nuxt 3 Frontend (SSR/CSR) │
│ Pages · Components · Stores (Pinia) │
│ Composables: useWebSocket · useQueue · │
│ useCheckIn · useThermalPrint · useQRScanner │
└──────────┬──────────────────┬────────────────┘
│ $fetch / useFetch │ WebSocket
┌──────────▼──────────┐ ┌────▼───────────────┐
│ Nuxt Server (Nitro) │ │ WebSocket Server │
│ server/api/ │ │ ws://10.10.123. │
│ SQLite (users.db) │ │ 135:8084/api/v1/ws│
│ (Config + Users) │ └────────────────────┘
└──────────┬──────────┘
│ HTTP Proxy (CORS bypass)
┌──────────▼──────────────────────────────────┐
│ Backend Services (Eksternal) │
│ Visit API (8084) · Antrian API (8089) │
│ Keycloak SSO (auth.rssa.top) │
└─────────────────────────────────────────────┘
```
### 7.4 Proxy Routes (Nuxt Server)
| Proxy Path | Target Backend | Keterangan |
|------------|---------------|------------|
| `/visit-api/**` | `http://10.10.123.135:8084/api/v1/**` | Data kunjungan pasien |
| `/klinik-api/**` | `http://10.10.150.131:8089/api/v1/**` | Data klinik & dokter |
| `/stats-api/**` | `http://10.10.123.135:8084/api/v1/**` | Statistik dashboard |
### 7.5 Struktur Folder
```
web-antrean/
├── assets/scss/ ← Global styles & variables
├── components/ ← Reusable UI components
├── composables/ ← useWebSocket, useQueue, useCheckIn,
│ useThermalPrint, useQRScanner, useAPI
├── layouts/ ← Layout default & admin
├── middleware/ ← Auth & permission guards
├── pages/ ← Semua halaman (lihat seksi 8.1)
├── server/
│ ├── api/ ← Internal API (auth, hak-akses, queue, users)
│ └── routes/ ← Proxy routes (visit-api, klinik-api, stats-api)
├── stores/ ← Pinia stores (queue, clinic, loket, dll)
├── types/ ← TypeScript interfaces & types
├── public/ ← Static assets (favicon, logo)
├── nuxt.config.ts ← Konfigurasi utama Nuxt
└── .env ← Environment variables (tidak di-commit)
```
### 7.6 Internal API Endpoints (Nuxt Nitro)
| Method | Endpoint | Deskripsi | Auth |
|--------|----------|-----------|------|
| GET | `/api/permission` | Ambil permissions berdasarkan role & group | ✅ |
| GET | `/api/hak-akses` | Daftar semua hak akses | ✅ |
| POST | `/api/hak-akses` | Buat hak akses baru | ✅ |
| PATCH | `/api/hak-akses/:id` | Update hak akses | ✅ |
| DELETE | `/api/hak-akses/:id` | Hapus hak akses | ✅ |
| GET | `/api/users/list` | Daftar user dari Keycloak | ✅ |
| POST | `/api/auth/login` | Login via Keycloak SSO | ❌ |
| POST | `/api/auth/logout` | Logout & invalidate session | ✅ |
| POST | `/api/external/validate-token` | Validasi JWT token eksternal | ✅ |
| GET | `/api/config/...` | Konfigurasi aplikasi | ✅ |
### 7.7 TypeScript Types Utama
```typescript
// types/user.ts
export interface User {
id: string
namaLengkap: string
namaUser: string
email: string
tipeUser: string
roles: string[]
groups: string[]
lastLogin: number
createdAt: number
}
// types/queue.ts
export interface QueuePatient {
nomorAntrean: string
namaPassien: string
noRM: string
klinik: string
status: 'menunggu' | 'dipanggil' | 'selesai' | 'skip'
loketId?: string
subspesialis?: string
createdAt: Date
}
// types/permission.ts
export interface Permission {
id: number
pagename: string
read: boolean
create: boolean
update: boolean
delete: boolean
disable: boolean
active: boolean
level: number
parent: number | null
}
// types/api.ts
export interface ApiResponse<T> {
data: T
message: string
success: boolean
}
```
---
## 8. UI/UX
### 8.1 Halaman & Routes
| Halaman | Route | Auth | Role |
|---------|-------|------|------|
| Landing / Home | `/` | ❌ | Public |
| Login | `/login-page` | ❌ | Public |
| Dashboard | `/dashboard` | ✅ | Superadmin |
| Admin Klinik | `/admin-klinik` | ✅ | Admin Klinik |
| Admin Loket | `/admin-loket` | ✅ | Admin Loket |
| Admin Penunjang | `/admin-penunjang` | ✅ | Admin Penunjang |
| Buat Antrean | `/buat-antrean` | ✅ | Loket |
| Klinik Ruang Admin | `/klinik-ruang-admin` | ✅ | Admin Klinik Ruang |
| Ranap Admin | `/ranap-admin` | ✅ | Admin Ranap |
| Anjungan Utama | `/anjungan` | ❌ | Kiosk |
| Admin Anjungan | `/anjungan/admin-anjungan` | ✅ | Superadmin |
| Antrian Klinik | `/anjungan/antrian-klinik` | ❌ | Kiosk |
| Antrian Klinik Ruang | `/anjungan/antrian-klinik-ruang` | ❌ | Kiosk |
| Antrian Penunjang | `/anjungan/antrian-penunjang` | ❌ | Kiosk |
| Antrean Masuk Screen | `/anjungan/antrean-masuk` | ❌ | Display |
| Check In Pasien | `/check-in-pasien/check-in` | ❌ | Kiosk |
| Data Pasien | `/data-pasien` | ✅ | Admin |
| Edit Data Pasien | `/data-pasien/edit/:id` | ✅ | Admin |
| Monitoring Pasien | `/monitoring-pasien/monitoring-pasien` | ✅ | Admin |
| Detail Pasien | `/monitoring-pasien/pasien/:id` | ✅ | Admin |
| Profil | `/profile/profil` | ✅ | Semua |
| User Login | `/setting/user-login` | ✅ | Superadmin |
| Hak Akses | `/setting/hak-akses` | ✅ | Superadmin |
| Master Klinik | `/setting/master-klinik` | ✅ | Superadmin |
| Master Klinik Ruang | `/setting/master-klinik-ruang` | ✅ | Superadmin |
| Master Loket | `/setting/master-loket` | ✅ | Superadmin |
| Master Penunjang | `/setting/master-penunjang` | ✅ | Superadmin |
| Screen Settings | `/setting/screen` | ✅ | Superadmin |
| Verifikasi Akun | `/verifikasi-akun/verifikasi-akun` | ❌ | Public |
| Detail Akun Verifikasi | `/verifikasi-akun/detail-akun` | ✅ | Admin |
### 8.2 Breakpoints
| Nama | Size | Keterangan |
|------|------|------------|
| Mobile | < 640px | Tidak diprioritaskan (akses via PC/tablet) |
| Tablet | 6401024px | Anjungan & loket (tablet) |
| Desktop | > 1024px | Admin & monitoring |
### 8.3 Design System
- **Framework UI:** Vuetify 3 (Material Design 3)
- **Font:** Inter (400, 500, 600, 700)
- **Icons:** Material Design Icons (`@mdi/font`) + FontAwesome
- **Color scheme:** Mengikuti theme Vuetify (light/dark configurable)
- **SCSS Variables:** Didefinisikan di `assets/scss/_variables.scss` & `_colors.scss`
---
## 9. Risks
| Risk | Likelihood | Impact | Mitigasi |
|------|-----------|--------|----------|
| WebSocket disconnect saat jaringan LAN tidak stabil | Tinggi | Tinggi | Polling fallback 30 detik + auto-reconnect logic |
| Backend API eksternal down (Visit API / Antrian API) | Sedang | Tinggi | Error handling graceful, retry logic, blacklist endpoint gagal |
| Konflik antrean antar loket (race condition) | Sedang | Tinggi | State isolasi per `loketId`, strict filtering di `processNextQueue` |
| Sesi Keycloak expire saat jam operasional | Sedang | Sedang | Session duration dikonfigurasi 1 jam, refresh token otomatis |
| Thermal printer tidak kompatibel di semua device | Rendah | Sedang | Uji di device target sebelum go-live, fallback ke tampilan layar |
| Data antrean tidak sinkron antar display screen | Sedang | Tinggi | WebSocket deterministic client ID + polling fallback |
| Kapasitas WebSocket server saat pasien peak | Rendah | Tinggi | Monitor jumlah koneksi, koordinasi dengan tim backend |
---
## 10. Deployment
| Item | Detail |
|------|--------|
| Server | VPS / Server internal RSSA |
| Domain dev | `http://10.10.150.175:3000` |
| Domain staging | `https://antrean.dev.rssa.id` |
| Domain prod | `https://antrean.rssa.id` |
| Containerisasi | Docker + docker-compose |
| Auth Server | Keycloak (`https://auth.rssa.top/realms/sandbox`) |
| Build command | `nuxt build` |
| Start command | `node .output/server/index.mjs` |
---
## 11. Changelog
| Versi | Tanggal | Author | Perubahan |
|-------|---------|--------|-----------|
| 1.1.0 | 2026-07-10 | Akbar | Update API endpoints (150.131), arsitektur diagram, proxy routes, Phase 7 |
| 1.0.0 | 2026-05-25 | Akbar | Initial PRD — dibuat berdasarkan kondisi project aktual |
+394
View File
@@ -0,0 +1,394 @@
# ✅ QMD — Quality Management Document
**Project:** Web Antrean — Sistem Manajemen Antrean Rawat Jalan RSSA
**Version:** 1.0.0
**Author:** Akbar
**Stack:** JavaScript · Vue 3 · TypeScript · Nuxt 3 · Vuetify 3 · Pinia
**Last Updated:** 2026-05-25
---
## 🧠 AI Prompt — Cara Menggunakan Dokumen Ini
> Salin prompt berikut ke Claude untuk membantu QA planning:
```
Kamu adalah QA engineer senior untuk project Nuxt 3 + Vue 3 + TypeScript.
Project: Web Antrean — Sistem manajemen antrean rawat jalan RSSA.
Fitur utama:
- Anjungan mandiri (kiosk registrasi pasien, pilih klinik/subspesialis)
- Check-in pasien via QR code
- Manajemen antrean loket (panggil, skip, recall, selesai)
- Manajemen antrean klinik & penunjang
- Real-time sync via WebSocket + polling fallback
- Dashboard monitoring & statistik
- Setting: master data (klinik, loket, penunjang, screen), hak akses (Keycloak role/group)
- Cetak tiket via thermal printer
Bantu aku membuat:
1. Test plan lengkap (unit, integration, E2E)
2. Test cases untuk fitur di atas
3. Definition of Done (DoD) per story
4. Checklist code review untuk Vue 3 + TypeScript
5. Standar kualitas kode (naming, linting, typing)
Format dalam tabel Markdown. Tool: Vitest, Cypress, Vue Test Utils, happy-dom.
```
---
## 1. Quality Objectives
| Objektif | Target | Cara Ukur |
|----------|--------|-----------|
| Test Coverage (Unit) | > 60% | Vitest coverage report (`npx vitest run --coverage`) |
| Bug Rate (prod) | < 5 bug/sprint | Manual tracking / issue log |
| Code Review | 100% PR di-review | Git workflow |
| TypeScript strict | Minimal `any` type | ESLint + `tsc --noEmit` |
| WebSocket Reliability | > 99% message delivery | Monitoring log + polling fallback |
| Page Load | < 3 detik | Lighthouse / manual timing di jaringan LAN |
---
## 2. Testing Strategy
### 2.1 Piramida Testing
```
/E2E\ ← Cypress (browser real)
/──────\
/ Integ \ ← Vitest + Nuxt Test Utils
/──────────\
/ Unit Test \ ← Vitest + Vue Test Utils + happy-dom
/______________\
```
### 2.2 Test Toolchain
| Tipe | Tool | Config File | Status |
|------|------|-------------|--------|
| Unit | Vitest + happy-dom | `vitest.config.ts` | ✅ Terkonfigurasi |
| Component | Vue Test Utils (`@vue/test-utils`) | — | ✅ Terinstal |
| E2E | Cypress | `cypress.config.ts` | ✅ Terkonfigurasi |
| Component (Cypress) | Cypress Component Testing | `cypress.config.ts``component` | ✅ Terkonfigurasi |
| Linting | ESLint (Nuxt preset) | `eslint.config.mjs` | ✅ Terkonfigurasi |
| Type Check | TypeScript (via Nuxt) | `tsconfig.json` → extends `.nuxt/tsconfig.json` | ✅ |
| Test Environment | happy-dom | `vitest.config.ts``environment: 'happy-dom'` | ✅ |
### 2.3 Test Commands
```bash
# Unit & Component Tests
npm run test # vitest (watch mode)
npm run test:ui # vitest --ui (browser UI)
# E2E Tests
npm run cypress:open # Cypress interactive
npm run cypress:run # Cypress headless
# Linting
npx eslint .
# Type Check
npx nuxi typecheck
```
---
## 3. Test Cases
### 3.1 Unit Tests — Composables
| ID | Modul | Test Case | Expected | Status |
|----|-------|-----------|----------|--------|
| UT-001 | `useAuth.ts` | Login — mengembalikan user data setelah Keycloak auth | `user.value` tidak null, memiliki `id`, `roles`, `groups` | `Todo` |
| UT-002 | `useAuth.ts` | Logout — session di-clear dan redirect ke login page | `user.value` menjadi null, navigasi ke `/LoginPage` | `Todo` |
| UT-003 | `useQueue.js` | `processNextQueue()` — mengambil pasien berikutnya sesuai loketId | Return pasien dengan status `menunggu` dan `loketId` yang cocok | `Todo` |
| UT-004 | `useQueue.js` | `processNextQueue()` — skip pasien dari loket lain | Pasien dari loket lain tidak terproses | `Todo` |
| UT-005 | `useWebSocket.ts` | Koneksi sukses — state connected | `isConnected.value === true` setelah open event | `Todo` |
| UT-006 | `useWebSocket.ts` | Auto-reconnect setelah disconnect | Reconnect attempt dalam < 5 detik | `Todo` |
| UT-007 | `useCheckIn.ts` | Check-in valid QR — update status pasien | Status pasien berubah ke `hadir`, return success | `Todo` |
| UT-008 | `useCheckIn.ts` | Check-in invalid QR — error handling | Return error message, status tidak berubah | `Todo` |
| UT-009 | `useQRScanner.ts` | Inisialisasi scanner — kamera aktif | Scanner instance terbuat tanpa error | `Todo` |
| UT-010 | `useHakAkses.ts` | Fetch permissions — mapping role ke menu | Menu permissions sesuai dengan role & group user | `Todo` |
| UT-011 | `useThermalPrint.ts` | Generate tiket — format nomor antrean benar | Output mengandung kode klinik + nomor urut | `Todo` |
| UT-012 | `useClinicAPI.ts` | Fetch daftar klinik — return data klinik aktif | Array klinik tidak kosong, setiap item punya `id` & `nama` | `Todo` |
### 3.2 Unit Tests — Stores (Pinia)
| ID | Modul | Test Case | Expected | Status |
|----|-------|-----------|----------|--------|
| UT-013 | `queueStore.js` | `allPatients` — menyimpan & mengembalikan daftar pasien | State `allPatients` terisi array setelah fetch | `Todo` |
| UT-014 | `queueStore.js` | `currentProcessingPatient` — isolasi per loket | Setiap loket punya key unik di persisted state | `Todo` |
| UT-015 | `clinicStore.js` | Fetch daftar klinik dari API | `clinics` terisi data dari klinik-api | `Todo` |
| UT-016 | `doctorStore.js` | Blacklist endpoint gagal | Endpoint yang 500 di-blacklist, tidak di-retry spam | `Todo` |
| UT-017 | `loketStore.js` | State loket terisolasi antar loket | Operasi di loket A tidak mempengaruhi loket B | `Todo` |
| UT-018 | `masterStore.js` | CRUD master klinik | Create, read, update, delete berjalan tanpa error | `Todo` |
| UT-019 | `permissionStore.ts` | Load permission sesuai role | Permission loaded dan accessible via getter | `Todo` |
### 3.3 Unit Tests — Middleware
| ID | Modul | Test Case | Expected | Status |
|----|-------|-----------|----------|--------|
| UT-020 | `auth.ts` | User belum login → redirect ke `/LoginPage` | `navigateTo('/LoginPage')` dipanggil | `Todo` |
| UT-021 | `auth.ts` | User sudah login → lanjut ke halaman tujuan | Tidak ada redirect | `Todo` |
| UT-022 | `guest.ts` | User sudah login akses `/LoginPage` → redirect ke `/dashboard` | `navigateTo('/dashboard')` dipanggil | `Todo` |
| UT-023 | `permissions.ts` | User tanpa akses ke halaman → redirect/block | Akses ditolak, redirect ke halaman authorized | `Todo` |
| UT-024 | `checkPageAccess.ts` | Validasi hak akses per halaman berdasarkan group | Halaman hanya bisa diakses sesuai permission | `Todo` |
### 3.4 Component Tests
| ID | Komponen | Skenario | Expected | Status |
|----|----------|----------|----------|--------|
| CT-001 | `PatientCard.vue` | Render data pasien lengkap | Nama, noRM, nomor antrean, status, subspesialis tampil | `Todo` |
| CT-002 | `PatientCard.vue` | Status badge warna sesuai status | `menunggu` = kuning, `dipanggil` = biru, `selesai` = hijau | `Todo` |
| CT-003 | `CurrentPatientCard.vue` | Tampilkan pasien yang sedang diproses | Data pasien aktif tampil dengan aksi (selesai, skip) | `Todo` |
| CT-004 | `QueueActionsCard.vue` | Tombol aksi antrean (panggil, skip, recall) | Semua tombol render dan emit event yang benar | `Todo` |
| CT-005 | `TabelPatientData.vue` | Render tabel daftar pasien | Kolom: nama, noRM, antrean, status, aksi tampil benar | `Todo` |
| CT-006 | `SideBar.vue` | Menu render sesuai hak akses user | Menu yang tidak diizinkan tidak tampil | `Todo` |
| CT-007 | `PageHeader.vue` | Render judul halaman dan breadcrumb | Judul dan navigasi sesuai route aktif | `Todo` |
| CT-008 | `AppSnackbar.vue` | Notifikasi muncul dan auto-dismiss | Snackbar tampil 3 detik lalu hilang | `Todo` |
| CT-009 | `SelectionDialog.vue` | Dialog pilihan dengan konfirmasi | Pilihan terseleksi, emit event saat konfirmasi | `Todo` |
| CT-010 | `ProfileMenu.vue` | Tampil info user dan tombol logout | Nama user tampil, klik logout memanggil `useAuth().logout()` | `Todo` |
### 3.5 E2E Tests (Cypress)
| ID | Flow | Steps | Expected | Status |
|----|------|-------|----------|--------|
| E2E-001 | Login | 1. Buka `/` 2. Redirect ke `/LoginPage` 3. Klik login Keycloak 4. Isi credentials | Redirect ke `/dashboard`, user session aktif | `Skeleton` |
| E2E-002 | Anjungan — Ambil Antrean | 1. Buka `/anjungan` 2. Pilih klinik 3. Konfirmasi | Nomor antrean di-generate, tiket tampil | `Todo` |
| E2E-003 | Anjungan Eksekutif — Pilih Subspesialis | 1. Buka `/anjungan` 2. Pilih klinik eksekutif 3. Pilih subspesialis 4. Konfirmasi | Antrean tercipta dengan subspesialis terpilih | `Todo` |
| E2E-004 | Check-in QR | 1. Buka `/check-in-pasien/check-in` 2. Scan QR valid | Status pasien update ke "hadir", notifikasi sukses | `Todo` |
| E2E-005 | Loket — Panggil Pasien | 1. Login sebagai admin loket 2. Buka `/admin-loket` 3. Klik "Panggil Berikutnya" | Pasien berikutnya tampil di current patient card | `Todo` |
| E2E-006 | Loket — Skip & Recall | 1. Panggil pasien 2. Klik skip 3. Klik recall | Pasien di-skip lalu bisa di-recall kembali | `Todo` |
| E2E-007 | Klinik — Lihat Daftar Pasien | 1. Login sebagai admin klinik 2. Buka `/admin-klinik` | Daftar pasien klinik tampil sesuai klinik user | `Todo` |
| E2E-008 | Dashboard — Statistik | 1. Login sebagai superadmin 2. Buka `/dashboard` | Chart statistik dan data antrean tampil | `Todo` |
| E2E-009 | Setting — CRUD Master Klinik | 1. Buka `/setting/master-klinik` 2. Tambah klinik 3. Edit 4. Hapus | Data klinik berhasil CRUD tanpa error | `Todo` |
| E2E-010 | Setting — Hak Akses | 1. Buka `/setting/hak-akses` 2. Pilih role & group 3. Set permission 4. Simpan | Hak akses tersimpan dan efektif | `Todo` |
| E2E-011 | WebSocket Sync | 1. Buka admin loket di tab A 2. Buka display screen di tab B 3. Panggil pasien di tab A | Tab B menampilkan nomor panggil dalam < 2 detik | `Todo` |
| E2E-012 | Auth Guard | 1. Tanpa login, akses `/dashboard` | Redirect ke `/LoginPage` | `Todo` |
---
## 4. Definition of Done (DoD)
Sebuah task/story dianggap **Done** jika:
- [ ] Code sudah diimplementasi dan berjalan di dev server
- [ ] Unit test ditulis untuk logic kritis (composable, store)
- [ ] TypeScript: tidak ada error pada `npx nuxi typecheck`
- [ ] ESLint: tidak ada error (`npx eslint .`)
- [ ] Code review dilakukan minimal 1 orang
- [ ] Tested manual di browser Chrome (target utama)
- [ ] WebSocket sync diverifikasi antar device (jika fitur terkait real-time)
- [ ] Responsive layout dicek untuk tablet (anjungan) dan desktop (admin)
- [ ] Tidak ada `console.log` debugging yang tertinggal di production path
- [ ] State antrean terisolasi per loket (jika fitur terkait loket)
- [ ] Polling fallback 30 detik berfungsi sebagai safety net
---
## 5. Code Review Checklist
### General
- [ ] Logic mudah dibaca dan dipahami
- [ ] Tidak ada dead code atau file `.txt` backup yang tersisa
- [ ] Error handling ada di semua `$fetch` / `useFetch` call
- [ ] Tidak ada hardcoded IP/URL (gunakan `.env` + `runtimeConfig`)
- [ ] Tidak ada `console.log` di high-frequency path (WebSocket handler, polling loop)
### Vue 3 + TypeScript
- [ ] `<script setup>` digunakan (Composition API)
- [ ] Props & emits memiliki type yang jelas
- [ ] Minimal penggunaan `any` type (justified jika ada)
- [ ] Composables dipakai untuk logic reusable (`composables/use*.ts`)
- [ ] Reactive data menggunakan `ref()` / `reactive()` dengan benar
- [ ] `watch` / `computed` digunakan daripada manual mutation
### Nuxt 3 Specific
- [ ] Data fetching pakai `$fetch` / `useFetch` / `useAsyncData`
- [ ] Middleware auth terpasang di route yang memerlukan login
- [ ] Server routes (proxy) mengikuti konvensi `server/routes/[prefix]/[...].ts`
- [ ] Internal API mengikuti konvensi `server/api/[resource].[method].ts`
- [ ] Environment variables diakses via `useRuntimeConfig()`
### Pinia Store
- [ ] Store menggunakan `defineStore()` dengan naming `use[Name]Store`
- [ ] State yang perlu persisten menggunakan `pinia-plugin-persistedstate`
- [ ] Key persisted state unik per loket/klinik (hindari konflik antar instance)
- [ ] `allPatients` tetap single source of truth (tidak duplikasi state)
### WebSocket
- [ ] Client ID deterministic (bukan random) untuk targetable messaging
- [ ] Auto-reconnect logic aktif
- [ ] Polling fallback 30 detik sebagai safety net
- [ ] Event handler tidak melakukan full re-render (surgical update)
---
## 6. Standar Kode
### Naming Convention
| Tipe | Konvensi | Contoh (Aktual) |
|------|----------|-----------------|
| Page (Vue) | PascalCase | `Dashboard.vue`, `AdminLoket.vue` |
| Component | PascalCase | `PatientCard.vue`, `QueueActionsCard.vue` |
| Composable | camelCase + `use` prefix | `useWebSocket.ts`, `useQueue.js` |
| Store (Pinia) | camelCase + `Store` suffix | `queueStore.js`, `clinicStore.js` |
| Middleware | camelCase | `auth.ts`, `permissions.ts` |
| Type/Interface | PascalCase | `User`, `Permission`, `ApiResponse<T>` |
| Constants | SCREAMING_SNAKE | `API_BASE_URL`, `WS_API_URL` |
| CSS Class | kebab-case | `.patient-card`, `.queue-actions` |
| Server API | `[resource].[method].ts` | `permission.get.ts`, `validate-token.post.ts` |
| Server Proxy Route | `[...].ts` di folder prefix | `server/routes/visit-api/[...].ts` |
### File Organization Rules
```
# ✅ Good — composable terpisah untuk concern berbeda
composables/
useAuth.ts ← Authentication logic
useWebSocket.ts ← WebSocket connection management
useQueue.js ← Queue business logic
useCheckIn.ts ← Check-in flow
useThermalPrint.ts ← Thermal printer integration
# ✅ Good — komponen terorganisir per fitur
components/
common/ ← Reusable (AppSnackbar, Avatar, PageHeader)
layout/ ← Layout (SideBar, ProfileMenu)
features/
queue/ ← PatientCard, CurrentPatientCard, QueueActionsCard
antrean/ ← Komponen anjungan
master/ ← Komponen setting master data
monitoring/ ← Komponen monitoring pasien
# ❌ Bad — logic besar langsung di <script setup> page
pages/AdminLoket.vue ← Jangan taruh >100 baris logic di sini, extract ke composable
```
### Code Pattern — Composable
```typescript
// ✅ Good — typed composable dengan error handling
export function useClinicAPI() {
const config = useRuntimeConfig()
async function fetchClinics(): Promise<Clinic[]> {
try {
const data = await $fetch('/klinik-api/clinics')
return data
} catch (error) {
console.error('Failed to fetch clinics:', error)
return []
}
}
return { fetchClinics }
}
// ❌ Bad — untyped, no error handling
export function useClinicAPI() {
async function fetchClinics() {
const data = await $fetch('/klinik-api/clinics') // bisa crash
return data
}
return { fetchClinics }
}
```
---
## 7. CI/CD Quality Gates
> **Catatan:** CI/CD belum diimplementasi. Berikut rencana pipeline saat siap.
```yaml
# GitHub Actions — quality checks (planned)
name: Quality Gate
on:
push:
branches: [main, dev]
pull_request:
branches: [main]
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install Dependencies
run: npm ci
- name: Type Check
run: npx nuxi typecheck
- name: Lint
run: npx eslint .
- name: Unit Tests
run: npx vitest run --coverage
- name: Build
run: npx nuxt build
```
### Manual Quality Gate (Saat Ini)
Sebelum merge / deploy, lakukan checklist manual berikut:
- [ ] `npx nuxi typecheck` — tidak ada error
- [ ] `npx eslint .` — tidak ada error
- [ ] `npm run test` — semua test pass
- [ ] `npm run build` — build sukses tanpa error
- [ ] Test manual di browser Chrome di jaringan LAN (`http://10.10.150.175:3000`)
- [ ] Verifikasi WebSocket sync antara admin & display screen
- [ ] Pastikan Docker build berhasil (`docker compose build`)
---
## 8. Bug Severity Matrix
| Level | Deskripsi | Contoh di Web Antrean | SLA Fix |
|-------|-----------|----------------------|---------|
| 🔴 Critical | App crash / data loss / security breach | WebSocket down total → antrean tidak sync, Keycloak auth bypass, pasien kehilangan antrean | < 4 jam |
| 🟠 High | Fitur utama tidak bisa dipakai | Tombol panggil pasien tidak berfungsi, anjungan tidak bisa generate antrean, QR scanner error | < 1 hari |
| 🟡 Medium | Fitur minor terganggu, ada workaround | Thermal print gagal (pasien masih bisa lihat di layar), statistik dashboard delayed | < 3 hari |
| 🟢 Low | UI/kosmetik, tidak mengganggu fungsi | Alignment card tidak rapi, warna badge sedikit off, tooltip tidak muncul | Backlog |
### Known Issues & Mitigasi
| Issue | Severity | Mitigasi Saat Ini |
|-------|----------|-------------------|
| WebSocket disconnect saat jaringan LAN tidak stabil | 🔴 | Auto-reconnect + polling fallback 30 detik |
| Endpoint 500 menyebabkan request spam | 🟠 | Blacklist endpoint gagal di `doctorStore.js` |
| `console.log` verbose di WebSocket handler | 🟢 | Dibersihkan di high-frequency path |
| Race condition antar loket | 🟠 | State isolasi per `loketId` + unique storage key |
| Mixed JS/TS — beberapa store masih `.js` | 🟢 | Migrasi bertahap ke `.ts` |
---
## 9. Technical Debt Tracker
| Item | Impact | Priority | Plan |
|------|--------|----------|------|
| Store files masih `.js` (queueStore, clinicStore, dll) | Type safety rendah | 🟡 Medium | Migrasi bertahap ke TypeScript |
| `queueStore.js` terlalu besar (141KB) | Sulit maintain & test | 🟠 High | Pecah menjadi sub-stores per concern |
| Tidak ada test coverage saat ini | Regresi tidak terdeteksi | 🟠 High | Mulai dari composable kritis |
| CI/CD belum ada | Manual quality gate | 🟡 Medium | Setup GitHub Actions |
| Beberapa file backup (`.txt`, `old_*.vue`) masih ada | Noise di codebase | 🟢 Low | Cleanup & gitignore |
---
## 10. Changelog
| Versi | Tanggal | Author | Perubahan |
|-------|---------|--------|-----------|
| 1.0.0 | 2026-05-25 | Akbar | Initial QMD — dibuat berdasarkan kondisi project aktual |
+25 -220
View File
@@ -1,16 +1,14 @@
<template> <template>
<v-app id="inspire"> <SideBar
<SideBar :items="filteredNavItems"
:items="filteredNavItems" v-model:drawer="drawer"
v-model:drawer="drawer" :rail="rail"
:rail="rail" @toggle-rail="rail = !rail"
@toggle-rail="rail = !rail" />
/>
<v-main app> <v-main app>
<slot /> <slot />
</v-main> </v-main>
</v-app>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -22,7 +20,7 @@ import { useNavItemsStore } from '~/stores/navItems1';
import { useAuth } from "~/composables/useAuth"; import { useAuth } from "~/composables/useAuth";
definePageMeta({ definePageMeta({
middleware: ['auth', 'permissions'] middleware: ['auth', 'checkPageAccess']
}) })
// State for controlling the sidebar // State for controlling the sidebar
@@ -32,219 +30,26 @@ const rail = ref(true);
const navItemsStore = useNavItemsStore(); const navItemsStore = useNavItemsStore();
const { user, checkAuth } = useAuth(); const { user, checkAuth } = useAuth();
interface NavItem { // Navigation will be filtered via navItemsStore using the new hakAkses system
id: number;
name: string;
path: string;
icon: string;
children?: NavItem[];
}
interface BackendPermission { const filteredNavItems = computed(() => navItemsStore.filteredNavItems);
id: number;
create: boolean;
read: boolean;
update: boolean;
disable: boolean;
delete: boolean;
active: boolean;
pagename: string;
pagesID: number;
level?: number;
sort?: number;
parent?: number;
}
interface PermissionResponse {
message?: string;
data?: BackendPermission[];
meta?: {
count: number;
total: number;
};
error?: string;
}
// Cache for API permissions
const apiPermissions = ref<BackendPermission[]>([]);
const currentUserRoles = ref<string[]>([]);
const currentUserGroups = ref<string[]>([]);
// Get current user data with roles and groups
const fetchCurrentUserData = async () => {
try {
const userData = await $fetch('/api/users/current');
currentUserRoles.value = [
...(userData.realmRoles || []),
...(userData.roles || []),
];
// Extract groups from paths (e.g., "/Instalasi STIM/Devops/Superadmin" -> "STIM")
const groups: string[] = [];
(userData.groups || []).forEach((g: string) => {
const parts = g.split('/').filter(Boolean);
if (parts.length > 1) {
groups.push(parts[1]); // Get second part as group name
} else if (parts.length === 1) {
groups.push(parts[0]);
}
});
currentUserGroups.value = groups;
return { roles: currentUserRoles.value, groups: currentUserGroups.value };
} catch (error) {
console.error('Error fetching current user data:', error);
return { roles: [], groups: [] };
}
};
// Fetch permissions from backend API (only for nav filtering, not for saving)
// Saving is now handled by permissions middleware
const fetchPermissionsFromAPI = async () => {
const { roles, groups } = await fetchCurrentUserData();
if (roles.length === 0 || groups.length === 0) {
console.warn('No roles or groups found for current user');
return;
}
// Use first role and first group (or combine as needed)
const primaryRole = roles[0] || '';
const primaryGroup = groups[0] || '';
if (!primaryRole || !primaryGroup) {
return;
}
try {
const response = await $fetch<PermissionResponse>('/api/permission', {
query: {
roles: primaryRole,
groups: primaryGroup,
},
});
if (response && response.data && Array.isArray(response.data)) {
apiPermissions.value = response.data;
// Note: Auto-save to allHakAksesData is now handled by permissions middleware
}
} catch (error) {
console.error('Error fetching permissions from API:', error);
// Fallback to local storage if API fails
apiPermissions.value = [];
}
};
const filteredNavItems = computed(() => {
// If no API permissions, check local storage as fallback
if (apiPermissions.value.length === 0) {
const hakAksesData = useLocalStorage<any[]>('allHakAksesData', []);
const roleCandidates = [
...(user.value?.roles || []),
...(user.value?.realm_access?.roles || []),
].map((role) => role.toLowerCase());
const localPermission = hakAksesData.value.find((item) =>
roleCandidates.includes(item.role?.toLowerCase() || item.namaTipeUser?.toLowerCase())
);
if (localPermission) {
const permissionMap = new Map(
localPermission.hakAksesMenu.map((menu: any) => [menu.name.toLowerCase(), menu])
);
const applyFilter = (items: NavItem[]): NavItem[] => {
return items
.map((item) => {
const menuPerm = permissionMap.get(item.name.toLowerCase());
const filteredChildren = item.children ? applyFilter(item.children) : [];
const allowThis = menuPerm ? menuPerm.canAccess : false;
const hasChildren = filteredChildren.length > 0;
if (!allowThis && !hasChildren) return null;
return {
...item,
...(hasChildren ? { children: filteredChildren } : {}),
};
})
.filter(Boolean);
};
return applyFilter(navItemsStore.navItems);
}
// If no permissions found, show all items
return navItemsStore.navItems;
}
// Use API permissions to filter
const permissionMap = new Map(
apiPermissions.value.map((perm) => [perm.pagename.toLowerCase(), perm])
);
// Mapping untuk pagename dari API ke nama menu di sidebar
const pagenameToMenuMapping: Record<string, string[]> = {
'halaman utama': ['dashboard', 'halaman utama'],
'pengaturan': ['master data'],
'halaman': ['master data'],
'dashboard': ['dashboard'],
};
const applyFilter = (items: NavItem[]): NavItem[] => {
return items
.map((item) => {
// Try to match by pagename or menu name
let perm = permissionMap.get(item.name.toLowerCase());
// If no direct match, try fuzzy matching
if (!perm) {
perm = Array.from(permissionMap.values()).find(p => {
const pagenameLower = p.pagename?.toLowerCase() || '';
const menuNameLower = item.name.toLowerCase();
// Direct match
if (pagenameLower === menuNameLower) return true;
// Contains match
if (pagenameLower.includes(menuNameLower) || menuNameLower.includes(pagenameLower)) {
return true;
}
// Check mapping
const mappedMenus = pagenameToMenuMapping[pagenameLower];
if (mappedMenus && mappedMenus.some(m => m === menuNameLower)) {
return true;
}
return false;
});
}
const filteredChildren = item.children ? applyFilter(item.children) : [];
const allowThis = perm ? (perm.active || perm.read) : false;
const hasChildren = filteredChildren.length > 0;
// If permission allows and has children, show item with filtered children
// If permission allows but no children, show item
// If no permission but has allowed children, show item with children
if (!allowThis && !hasChildren) return null;
return {
...item,
...(hasChildren ? { children: filteredChildren } : {}),
};
})
.filter(Boolean);
};
return applyFilter(navItemsStore.navItems);
});
onMounted(async () => { onMounted(async () => {
await checkAuth(); await checkAuth();
await fetchPermissionsFromAPI(); if (user.value) {
await navItemsStore.refreshNavItems();
}
}); });
// Watch for user changes to refresh navItems
watch(() => user.value, async (newUser) => {
if (newUser) {
await navItemsStore.refreshNavItems();
} else {
// Optionally reset navItems when logged out
navItemsStore.filteredNavItems = [];
}
}, { deep: true });
</script> </script>
<style scoped> <style scoped>
+3 -5
View File
@@ -1,9 +1,7 @@
<template> <template>
<v-app> <v-main>
<v-main> <slot />
<slot /> </v-main>
</v-main>
</v-app>
</template> </template>
<style> <style>
+42 -11
View File
@@ -10,9 +10,8 @@ export default defineNuxtRouteMiddleware(async (to: RouteLocationNormalized) =>
const bypassFlag = sessionStorage.getItem('bypassRootRedirect'); const bypassFlag = sessionStorage.getItem('bypassRootRedirect');
if (bypassFlag === 'true' && to.path === '/') { if (bypassFlag === 'true' && to.path === '/') {
console.log('🔑 Bypass flag detected - allowing root access'); console.log('🔑 Bypass flag detected - allowing root access');
// Clear the flag immediately to prevent future bypasses
sessionStorage.removeItem('bypassRootRedirect'); sessionStorage.removeItem('bypassRootRedirect');
return; // Allow access without any redirect return;
} }
} }
@@ -28,29 +27,61 @@ export default defineNuxtRouteMiddleware(async (to: RouteLocationNormalized) =>
return; return;
} }
// Skip auth check if it's the development server side render pass. // On server-side, skip auth check - let client handle it
if (process.server && process.env.NODE_ENV === 'development') { if (process.server) {
console.log('⏭️ Skipping intensive check on server-side during development'); console.log('⏭️ Server-side: Skipping auth check (will verify on client)');
useAuth();
return; return;
} }
// CLIENT-SIDE ONLY from here
// Check for the authentication signal from a successful login redirect // Check for the authentication signal from a successful login redirect
const isAuthRedirect: boolean = to.query.authenticated === 'true'; const isAuthRedirect = to.query.authenticated === 'true';
if (isAuthRedirect) { if (isAuthRedirect) {
console.log('⏳ Client-side is processing a new login session, allowing the route to load...'); console.log('⏳ Processing new login session...');
return navigateTo({ path: to.path, query: {} }, { replace: true });
// Give browser time to process the cookie from redirect
await new Promise(resolve => setTimeout(resolve, 100));
try {
const { checkAuth } = useAuth();
console.log('🔍 Checking authentication after redirect...');
let user = await checkAuth();
// Retry up to 3 times if cookie not available yet
let retries = 0;
while (!user && retries < 3) {
console.log(`⚠️ Cookie not available yet, retry ${retries + 1}/3...`);
await new Promise(resolve => setTimeout(resolve, 200));
user = await checkAuth();
retries++;
}
if (user) {
console.log('✅ User authenticated after redirect:', user.name || user.email);
// Remove query parameter and allow access
return navigateTo({ path: to.path, query: {} }, { replace: true });
} else {
console.log('❌ No session after retries, redirecting to login');
return navigateTo('/LoginPage');
}
} catch (authError) {
console.error('❌ Auth check failed after redirect:', authError);
return navigateTo('/LoginPage');
}
} }
// Normal auth check for protected routes
try { try {
const { checkAuth } = useAuth(); const { checkAuth } = useAuth();
console.log('🔍 Checking authentication status using useAuth...'); console.log('🔍 Checking authentication status...');
const user = await checkAuth(); const user = await checkAuth();
if (user) { if (user) {
console.log('✅ User is authenticated:', user.name || user.preferred_username || user.email); console.log('✅ User is authenticated:', user.name || user.email);
return; return;
} else { } else {
console.log('❌ No valid session found, redirecting to login'); console.log('❌ No valid session found, redirecting to login');
+70
View File
@@ -0,0 +1,70 @@
// middleware/checkPageAccess.ts
// Middleware to check if user has access to the page based on hakAkses
export default defineNuxtRouteMiddleware(async (to, from) => {
// Skip check for public pages
const publicPaths = ['/LoginPage', '/auth/login', '/index-legacy'];
// index.vue is the debug dashboard, let's keep it accessible for now as requested
if (to.path === '/' || publicPaths.includes(to.path)) {
return;
}
// On server-side, skip access check - let client handle it
// This matches auth.ts behavior and prevents SSR failures when cookie context is missing
if (process.server) {
console.log('⏭️ Server-side: Skipping page access check (will verify on client)');
return;
}
// Import useAuth and useHakAkses
const { user, checkAuth } = useAuth();
const { getAllowedPages } = useHakAkses();
// If user not loaded, try to load
if (!user.value) {
await checkAuth();
}
// If still not authenticated, redirect to login
if (!user.value) {
return navigateTo('/LoginPage');
}
try {
const allowedPages = await getAllowedPages();
const targetPath = to.path.endsWith('/') && to.path.length > 1 ? to.path.slice(0, -1) : to.path;
const targetPathLower = targetPath.toLowerCase();
const toPathLower = to.path.toLowerCase();
// Check if user has access to this page
// We also check against the raw path just in case, case-insensitive
const isAllowed = allowedPages.some(path => {
const normalizedAllowed = path.endsWith('/') && path.length > 1 ? path.slice(0, -1) : path;
const normalizedAllowedLower = normalizedAllowed.toLowerCase();
const pathLower = path.toLowerCase();
return normalizedAllowedLower === targetPathLower || pathLower === toPathLower;
});
if (!isAllowed) {
console.warn(`Access denied to ${to.path}. User allowed pages:`, allowedPages);
// Redirect to first allowed page if available, else stay/error
if (allowedPages.length > 0) {
// If dashboard is allowed, go there, else go to the first allowed one
const dashboardPath = allowedPages.find(p => p === '/' || p === '/dashboard');
return navigateTo(dashboardPath || allowedPages[0]);
} else {
// No access to any page - technically this shouldn't happen if user has roles
console.error('User has roles but no allowed pages found in configuration.');
// For now, allow root as fallback since index.vue is kept
if (to.path === '/') return;
}
}
} catch (error) {
console.error('Error checking page access:', error);
// On error, we might want to allow or block. Let's allow but log.
return;
}
});
+2 -1
View File
@@ -27,8 +27,9 @@ export default defineNuxtRouteMiddleware(async (to) => {
try { try {
console.log('🔍 Checking if user is already authenticated...'); console.log('🔍 Checking if user is already authenticated...');
const headers = import.meta.server ? useRequestHeaders(['cookie']) as Record<string, string> : {}
// The $fetch will automatically send the new user_session cookie // The $fetch will automatically send the new user_session cookie
const session = await $fetch<{ user: any } | null>('/api/auth/session').catch(() => null); const session = await $fetch<{ user: any } | null>('/api/auth/session', { headers }).catch(() => null);
if (session && session.user) { if (session && session.user) {
console.log('✅ User already authenticated, redirecting to dashboard'); console.log('✅ User already authenticated, redirecting to dashboard');
+5 -3
View File
@@ -231,9 +231,11 @@ export default defineNuxtRouteMiddleware(async (to) => {
return; return;
} }
// DISABLED: Auto-fetch permissions is now disabled
// Permissions should only be fetched manually via button click in HakAkses page
// Run async permission sync (non-blocking) // Run async permission sync (non-blocking)
// This will only run once per session due to sessionStorage check // This will only run once per session due to sessionStorage check
fetchAndSavePermissions().catch(err => { // fetchAndSavePermissions().catch(err => {
console.error('❌ [Permissions Middleware] Failed to sync permissions:', err); // console.error('❌ [Permissions Middleware] Failed to sync permissions:', err);
}); // });
}); });
+71 -44
View File
@@ -3,12 +3,11 @@ import vuetify, { transformAssetUrls } from "vite-plugin-vuetify";
export default defineNuxtConfig({ export default defineNuxtConfig({
compatibilityDate: "2025-05-15", compatibilityDate: "2025-05-15",
devtools: { devtools: {
enabled: true, enabled: process.env.ENABLE_DEVTOOLS === 'true',
timeline: { timeline: {
enabled: true, enabled: false,
}, },
}, },
app: { app: {
head: { head: {
meta: [ meta: [
@@ -16,47 +15,38 @@ export default defineNuxtConfig({
{ name: 'mobile-web-app-capable', content: 'yes' }, { name: 'mobile-web-app-capable', content: 'yes' },
{ name: 'apple-mobile-web-app-capable', content: 'yes' }, { name: 'apple-mobile-web-app-capable', content: 'yes' },
{ name: 'apple-mobile-web-app-status-bar-style', content: 'black-translucent' } { name: 'apple-mobile-web-app-status-bar-style', content: 'black-translucent' }
],
link: [
{ rel: "icon", type: "image/x-icon", href: "/love%20logo%20biru%20small.ico" },
] ]
} },
}, },
modules: [ modules: [// "@nuxt/content",
// "@nuxt/content", "@nuxt/eslint", "@nuxt/fonts", "@nuxt/icon", "@nuxt/image", "@nuxt/scripts", "@nuxt/test-utils", "@nuxt/ui", "@pinia/nuxt", "@vesp/nuxt-fontawesome", "@nuxtjs/google-fonts", async (_options, nuxt) => {
"@nuxt/eslint", nuxt.hooks.hook("vite:extendConfig", async (config) => {
"@nuxt/fonts", // @ts-expect-error
"@nuxt/icon", config.plugins.push(vuetify({ autoImport: true }));
"@nuxt/image",
"@nuxt/scripts",
"@nuxt/test-utils",
"@nuxt/ui",
"@pinia/nuxt",
"@vesp/nuxt-fontawesome",
"@nuxtjs/google-fonts",
async (_options, nuxt) => {
nuxt.hooks.hook("vite:extendConfig", async (config) => {
// @ts-expect-error
config.plugins.push(vuetify({ autoImport: true }));
// Add HTTPS plugin // Add HTTPS plugin
try { try {
// @ts-ignore // @ts-ignore
const { default: basicSsl } = await import('@vitejs/plugin-basic-ssl'); const { default: basicSsl } = await import('@vitejs/plugin-basic-ssl');
// @ts-expect-error // @ts-expect-error
config.plugins.push(basicSsl()); config.plugins.push(basicSsl());
// @ts-expect-error // @ts-expect-error
config.server = config.server || {}; config.server = config.server || {};
// @ts-expect-error // @ts-expect-error
config.server.https = true; config.server.https = true;
// @ts-expect-error // @ts-expect-error
config.server.host = '10.10.150.175'; config.server.host = '10.10.150.114';
// @ts-expect-error // @ts-expect-error
config.server.port = 3001; config.server.port = 3000;
} catch (e) { } catch (e) {
console.warn('Failed to load HTTPS plugin:', e); console.warn('Failed to load HTTPS plugin:', e);
} }
}); });
}, }, "nuxt-charts"],
],
fontawesome: { fontawesome: {
icons: { icons: {
@@ -77,10 +67,27 @@ export default defineNuxtConfig({
keycloakClientId: process.env.KEYCLOAK_CLIENT_ID, keycloakClientId: process.env.KEYCLOAK_CLIENT_ID,
keycloakClientSecret: process.env.KEYCLOAK_CLIENT_SECRET, keycloakClientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
keycloakIssuer: process.env.KEYCLOAK_ISSUER, keycloakIssuer: process.env.KEYCLOAK_ISSUER,
keycloakLogoutUri: process.env.KEYCLOAK_LOGOUT_URI, // Optional: custom logout URI
postLogoutRedirectUri: process.env.POST_LOGOUT_REDIRECT_URI, // Optional: custom post-logout redirect URI
// Session and Oauth durations
sessionDurationHours: parseInt(process.env.SESSION_DURATION_HOURS || '1', 10),
oauthStateDurationMinutes: parseInt(process.env.OAUTH_STATE_DURATION_MINUTES || '10', 10),
// External API
externalApiBaseUrl: process.env.EXTERNAL_API_BASE_URL || 'http://10.10.123.135:8084',
externalApiTimeout: parseInt(process.env.EXTERNAL_API_TIMEOUT || '10000', 10),
proxyClientOrigin: process.env.PROXY_CLIENT_ORIGIN || 'http://10.10.150.175:3000',
proxyTargetHostFallback: process.env.PROXY_TARGET_HOST_FALLBACK || '10.10.123.135:8084',
proxyTargetHostKlinikFallback: process.env.PROXY_TARGET_HOST_KLINIK_FALLBACK || '10.10.123.140:8089',
public: { public: {
authUrl: process.env.AUTH_ORIGIN, authUrl: process.env.AUTH_ORIGIN,
// authUrl: process.env.AUTH_ORIGIN || "http://10.10.150.175:3001", // authUrl: process.env.AUTH_ORIGIN || "http://10.10.150.175:3001",
// authUrl: process.env.AUTH_ORIGIN || "http://localhost:3001", // authUrl: process.env.AUTH_ORIGIN || "http://localhost:3001",
wsBaseUrl: process.env.WS_API_URL || process.env.WS_BASE_URL || 'ws://10.10.123.135:8084/api/v1/ws',
verificationApiBaseUrl: process.env.ANTRIAN_API_URL || process.env.VERIFICATION_API_BASE_URL || 'http://10.10.123.140:8089/api/v1',
externalApiBaseUrl: process.env.VISIT_API_URL || (process.env.EXTERNAL_API_BASE_URL ? `${process.env.EXTERNAL_API_BASE_URL}/api/v1` : 'http://10.10.123.135:8084/api/v1'),
}, },
}, },
@@ -93,11 +100,31 @@ export default defineNuxtConfig({
"@mdi/font/css/materialdesignicons.min.css", "@mdi/font/css/materialdesignicons.min.css",
"~/assets/scss/main.scss", "~/assets/scss/main.scss",
], ],
devServer: (() => {
const hostEnv = process.env.HOST || 'localhost';
// Parse HOST if it's a full URL (e.g., "http://10.10.150.175:3000")
let host = hostEnv;
let port = 3000;
try {
const url = new URL(hostEnv);
host = url.hostname;
port = url.port ? parseInt(url.port, 10) : 3000;
} catch {
// If HOST is not a URL, use it as-is (e.g., "10.10.150.175" or "localhost")
host = hostEnv;
}
return {
port: port,
host: host,
https: false, // Enable HTTPS
allowedHosts: ["localhost", "localhost.dev", "10.10.150.175","antrean.dev.rssa.id"]
};
})(),
devServer: {
port: 3001,
host: '10.10.150.175'
},
vite: { vite: {
css: { css: {
View File
+6939 -2300
View File
File diff suppressed because it is too large Load diff
+18 -7
View File
@@ -4,14 +4,17 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "nuxt build", "build": "nuxt build",
"_command_dev": "nuxt dev -o --host --port 3001", "_command_dev": "nuxt dev -o --host --port 3000",
"_command_dev2": "nuxt dev -o --port 3001", "_command_dev2": "nuxt dev -o --port 3000",
"_command_dev3": "nuxt dev -o --host 10.10.150.175 --port 3001",
"dev": "nuxt dev -o", "dev": "nuxt dev -o",
"dev:https": "nuxt dev -o --host localhost --port 3001", "dev:https": "nuxt dev -o --host 0.0.0.0 --port 3000",
"generate": "nuxt generate", "generate": "nuxt generate",
"preview": "nuxt preview", "preview": "nuxt preview",
"postinstall": "nuxt prepare" "postinstall": "nuxt prepare",
"test": "vitest",
"test:ui": "vitest --ui",
"cypress:open": "cypress open",
"cypress:run": "cypress run"
}, },
"dependencies": { "dependencies": {
"@fortawesome/free-brands-svg-icons": "^7.1.0", "@fortawesome/free-brands-svg-icons": "^7.1.0",
@@ -33,23 +36,31 @@
"eslint": "^9.32.0", "eslint": "^9.32.0",
"html5-qrcode": "^2.3.8", "html5-qrcode": "^2.3.8",
"nuxt": "^3.17.7", "nuxt": "^3.17.7",
"nuxt-charts": "^2.0.0",
"nuxt-qrcode": "^0.4.8", "nuxt-qrcode": "^0.4.8",
"pinia": "^3.0.3", "pinia": "^3.0.3",
"pinia-plugin-persistedstate": "^4.7.1",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"qrcode.vue": "^3.6.0", "qrcode.vue": "^3.6.0",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vue": "^3.5.18", "vue": "^3.5.18",
"vue-chartjs": "^5.3.2", "vue-chartjs": "^5.3.2",
"vue-draggable-next": "^2.3.0", "vue-draggable-next": "^2.3.0",
"vue-router": "^4.5.1" "vue-router": "^4.5.1",
"vue3-carousel": "^0.17.0"
}, },
"devDependencies": { "devDependencies": {
"@nuxtjs/google-fonts": "^3.2.0", "@nuxtjs/google-fonts": "^3.2.0",
"@types/node": "^24.7.2", "@types/node": "^24.7.2",
"@vitejs/plugin-basic-ssl": "^2.1.0", "@vitejs/plugin-basic-ssl": "^2.1.4",
"@vitest/ui": "^3.2.4",
"@vue/test-utils": "^2.4.6",
"cypress": "^15.9.0",
"happy-dom": "^20.5.0",
"sass": "^1.93.3", "sass": "^1.93.3",
"sass-embedded": "^1.89.2", "sass-embedded": "^1.89.2",
"vite-plugin-vuetify": "^2.1.2", "vite-plugin-vuetify": "^2.1.2",
"vitest": "^3.2.4",
"vuetify": "^3.9.3" "vuetify": "^3.9.3"
} }
} }
Loaded 100 of 221 files, more files were not shown because too many files have changed in this diff. Show more