151 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
bagus-arie05 0a2453bdab update tampilan dan flow di loket dan master 2026-01-05 09:26:14 +07:00
Fanrouver b5e40c68d2 QRscan implementation 2026-01-05 08:32:59 +07:00
Fanrouver c95da96017 update user login baru dan hakakses 2025-12-18 15:11:41 +07:00
bagus-arie05 dfcd59481c update flow pasien, store & preview screen, source klinik, api dokter 2025-12-18 14:22:15 +07:00
Fanrouver d2a51f3aee update user login baru dan hakakses 2025-12-16 10:42:45 +07:00
bagus-arie05 78de0418e1 update logika dan flow anjungan, master, store 2025-12-16 09:58:25 +07:00
253 changed files with 77017 additions and 11913 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"
+9
View File
@@ -6,6 +6,12 @@
.cache
dist
# Database files
data/
*.db
*.sqlite
*.sqlite3
# Node dependencies
node_modules
@@ -22,3 +28,6 @@ logs
.env
.env.*
!.env.example
REFACTORING_STATUS.md
CHECKIN_DOCUMENTATION.md
USERLOGIN_DOCUMENTATION.md
+1
View File
@@ -0,0 +1 @@
setups.@nuxt/test-utils="3.23.0"
+260
View File
@@ -0,0 +1,260 @@
# Cara Mengakses Database better-sqlite3
## 📍 Lokasi Database
- **Path relatif**: `data/users.db`
- **Path lengkap**: `E:\PROJECT\ddddd\Web-Antrean - Copy (2)\data\users.db`
---
## 🔧 Metode 1: Menggunakan Node.js Script (Recommended)
Karena project ini sudah menggunakan `better-sqlite3`, cara termudah adalah membuat script Node.js:
### Contoh Script untuk Query Database
```javascript
// scripts/query-db.js
const Database = require('better-sqlite3');
const path = require('path');
const dbPath = path.join(__dirname, '..', 'data', 'users.db');
const db = new Database(dbPath);
// Contoh: Get all users
const users = db.prepare('SELECT * FROM users').all();
console.log('All users:', users);
// Contoh: Get user by ID
const user = db.prepare('SELECT * FROM users WHERE id = ?').get('user-id-here');
console.log('User:', user);
// Contoh: Count users
const count = db.prepare('SELECT COUNT(*) as total FROM users').get();
console.log('Total users:', count.total);
db.close();
```
**Jalankan dengan:**
```bash
node scripts/query-db.js
```
---
## 🔧 Metode 2: Menggunakan SQLite CLI
### Install SQLite CLI (jika belum ada)
**Windows:**
1. Download dari: https://www.sqlite.org/download.html
2. Atau install via Chocolatey: `choco install sqlite`
3. Atau install via Scoop: `scoop install sqlite`
**Atau gunakan npx (tidak perlu install):**
```bash
npx sqlite3 data/users.db
```
### Perintah SQLite CLI
```bash
# Buka database
sqlite3 data/users.db
# Atau dengan npx (jika SQLite tidak terinstall)
npx sqlite3 data/users.db
# Di dalam SQLite shell:
.tables # Lihat semua tabel
.schema users # Lihat schema tabel users
SELECT * FROM users; # Lihat semua data
SELECT * FROM users LIMIT 10; # Lihat 10 baris pertama
.mode column # Format output sebagai kolom
.headers on # Tampilkan header kolom
.quit # Keluar
```
---
## 🔧 Metode 3: Menggunakan GUI Tools
### 1. **DB Browser for SQLite** (Gratis, Recommended)
- Download: https://sqlitebrowser.org/
- Buka file: `data/users.db`
- Bisa edit, query, dan export data dengan mudah
### 2. **SQLiteStudio** (Gratis)
- Download: https://sqlitestudio.pl/
- Cross-platform, open source
### 3. **DBeaver** (Gratis)
- Download: https://dbeaver.io/
- Universal database tool, support banyak database termasuk SQLite
### 4. **VS Code Extension**
- Install extension: **SQLite Viewer** atau **SQLite**
- Buka file `data/users.db` langsung di VS Code
---
## 🔧 Metode 4: Menggunakan API Endpoints yang Sudah Ada
Project ini sudah punya API endpoints untuk mengakses data:
```bash
# Get all users
GET /api/users/list
# Get current user
GET /api/users/current
# Get user by ID
GET /api/users/[id]
# Create user
POST /api/users/create
# Update user
PATCH /api/users/[id]
# Delete user
DELETE /api/users/[id]
```
---
## 📊 Contoh Query Berguna
### Melihat semua users:
```sql
SELECT * FROM users;
```
### Melihat users dengan format tanggal yang readable:
```sql
SELECT
id,
namaLengkap,
namaUser,
email,
datetime(lastLogin, 'unixepoch') as lastLoginFormatted,
datetime(createdAt, 'unixepoch') as createdAtFormatted
FROM users;
```
### Mencari user berdasarkan nama:
```sql
SELECT * FROM users WHERE namaLengkap LIKE '%nama%';
```
### Mencari user berdasarkan username:
```sql
SELECT * FROM users WHERE namaUser = 'username';
```
### Melihat users yang belum login:
```sql
SELECT * FROM users WHERE lastLogin IS NULL;
```
### Melihat users yang baru dibuat:
```sql
SELECT * FROM users ORDER BY createdAt DESC LIMIT 10;
```
### Export data ke CSV:
```sql
.mode csv
.headers on
.output users_export.csv
SELECT * FROM users;
.output stdout
```
---
## ⚠️ Catatan Penting
1. **Backup sebelum edit manual**: Selalu backup database sebelum melakukan perubahan manual
2. **Tutup koneksi**: Pastikan aplikasi tidak sedang menggunakan database saat mengedit manual
3. **Format timestamps**: `lastLogin`, `createdAt`, `updatedAt` disimpan sebagai Unix timestamp (seconds)
4. **JSON fields**: `roles`, `realmRoles`, `accountRoles`, `resourceRoles`, `groups` disimpan sebagai JSON string
---
## 🛠️ Quick Access Script
Buat file `scripts/db-access.js` untuk akses cepat:
```javascript
const Database = require('better-sqlite3');
const path = require('path');
const readline = require('readline');
const dbPath = path.join(process.cwd(), 'data', 'users.db');
const db = new Database(dbPath);
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
console.log('📊 Database Access Tool');
console.log('Database path:', dbPath);
console.log('\nAvailable commands:');
console.log(' list - List all users');
console.log(' count - Count total users');
console.log(' schema - Show table schema');
console.log(' query <SQL> - Run custom SQL query');
console.log(' exit - Exit\n');
function prompt() {
rl.question('> ', (input) => {
const [cmd, ...args] = input.trim().split(' ');
try {
switch(cmd.toLowerCase()) {
case 'list':
const users = db.prepare('SELECT id, namaLengkap, namaUser, email FROM users').all();
console.table(users);
break;
case 'count':
const count = db.prepare('SELECT COUNT(*) as total FROM users').get();
console.log(`Total users: ${count.total}`);
break;
case 'schema':
const schema = db.prepare("PRAGMA table_info(users)").all();
console.table(schema);
break;
case 'query':
const sql = args.join(' ');
if (!sql) {
console.log('Error: Please provide SQL query');
break;
}
const result = db.prepare(sql).all();
console.table(result);
break;
case 'exit':
db.close();
rl.close();
return;
default:
console.log('Unknown command. Type "exit" to quit.');
}
} catch (error) {
console.error('Error:', error.message);
}
prompt();
});
}
prompt();
```
Jalankan dengan: `node scripts/db-access.js`
+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.
+61
View File
@@ -0,0 +1,61 @@
# Setup HTTPS untuk Development
Aplikasi ini sudah dikonfigurasi untuk menggunakan HTTPS di development mode agar fitur kamera bisa berfungsi.
## Cara Menggunakan
1. **Jalankan aplikasi dengan HTTPS:**
```bash
npm run dev:https
```
atau
```bash
npm run dev
```
2. **Akses aplikasi:**
- Dari komputer: `https://localhost:3001`
- Dari HP (dalam jaringan yang sama): `https://[IP-KOMPUTER]:3001`
- Contoh: `https://10.10.150.175:3001`
## Peringatan Keamanan Browser
Karena menggunakan self-signed certificate, browser akan menampilkan peringatan keamanan:
### Chrome/Edge:
1. Klik **"Advanced"** atau **"Lanjutkan ke localhost (tidak aman)"**
2. Klik **"Proceed to localhost (unsafe)"**
### Firefox:
1. Klik **"Advanced"**
2. Klik **"Accept the Risk and Continue"**
### Safari (Mac):
1. Klik **"Show Details"**
2. Klik **"visit this website"**
3. Klik **"Visit Website"** di dialog konfirmasi
### Mobile (HP):
- **Android Chrome**: Klik **"Advanced"** → **"Proceed to [IP] (unsafe)"**
- **iOS Safari**: Klik **"Advanced"** → **"Proceed to [IP]"**
## Catatan Penting
- ✅ Setelah menerima certificate, browser akan mengingatnya untuk kunjungan selanjutnya
- ✅ HTTPS diperlukan untuk akses kamera di browser modern
- ✅ Pastikan HP dan komputer dalam jaringan yang sama (WiFi yang sama)
- ✅ Gunakan IP komputer Anda, bukan `localhost` saat akses dari HP
## Troubleshooting
### Kamera masih tidak muncul?
1. Pastikan sudah menggunakan `https://` bukan `http://`
2. Cek izin kamera di browser settings
3. Pastikan tidak ada aplikasi lain yang menggunakan kamera
4. Restart browser setelah setup HTTPS pertama kali
### Tidak bisa akses dari HP?
1. Pastikan firewall tidak memblokir port 3001
2. Pastikan HP dan komputer dalam WiFi yang sama
3. Cek IP komputer dengan `ipconfig` (Windows) atau `ifconfig` (Mac/Linux)
+94
View File
@@ -0,0 +1,94 @@
# Pages List - Web Antrean Application
This document lists all pages in the application with their file paths and route paths.
## Root Pages
| Page Name | File Path | Route Path |
|-----------|-----------|------------|
| Home/Index | `pages/index.vue` | `/` |
| Login Page | `pages/LoginPage.vue` | `/login-page` |
| Dashboard | `pages/Dashboard.vue` | `/dashboard` |
| Admin Klinik | `pages/AdminKlinik.vue` | `/admin-klinik` |
| Admin Loket | `pages/AdminLoket.vue` | `/admin-loket` |
| Admin Penunjang | `pages/AdminPenunjang.vue` | `/admin-penunjang` |
| Buat Antrean | `pages/BuatAntrean.vue` | `/buat-antrean` |
| Klinik Ruang Admin | `pages/KlinikRuangAdmin.vue` | `/klinik-ruang-admin` |
| Ranap Admin | `pages/RanapAdmin.vue` | `/ranap-admin` |
## Anjungan Pages
| Page Name | File Path | Route Path |
|-----------|-----------|------------|
| Anjungan | `pages/Anjungan/Anjungan.vue` | `/anjungan` |
| Admin Anjungan | `pages/Anjungan/AdminAnjungan.vue` | `/anjungan/admin-anjungan` |
| Antrian Klinik | `pages/Anjungan/AntrianKlinik.vue` | `/anjungan/antrian-klinik` |
| Antrian Klinik Ruang | `pages/Anjungan/AntrianKlinikRuang.vue` | `/anjungan/antrian-klinik-ruang` |
| Antrian Penunjang | `pages/Anjungan/AntrianPenunjang.vue` | `/anjungan/antrian-penunjang` |
## Check In Pasien Pages
| Page Name | File Path | Route Path |
|-----------|-----------|------------|
| Check In | `pages/CheckInPasien/checkIn.vue` | `/check-in-pasien/check-in` |
## Data Pasien Pages
| Page Name | File Path | Route Path |
|-----------|-----------|------------|
| Data Pasien Index | `pages/data-pasien/index.vue` | `/data-pasien` |
| Edit Data Pasien | `pages/data-pasien/edit/[id].vue` | `/data-pasien/edit/:id` |
## Monitoring Pasien Pages
| Page Name | File Path | Route Path |
|-----------|-----------|------------|
| Monitoring Pasien | `pages/MonitoringPasien/monitoringPasien.vue` | `/monitoring-pasien/monitoring-pasien` |
| Detail Pasien | `pages/MonitoringPasien/pasien/[id].vue` | `/monitoring-pasien/pasien/:id` |
## Profile Pages
| Page Name | File Path | Route Path |
|-----------|-----------|------------|
| Profil | `pages/Profile/Profil.vue` | `/profile/profil` |
## Setting Pages
| Page Name | File Path | Route Path |
|-----------|-----------|------------|
| User Login | `pages/Setting/UserLogin.vue` | `/setting/user-login` |
| Hak Akses | `pages/Setting/HakAkses.vue` | `/setting/hak-akses` |
| Master Klinik | `pages/Setting/MasterKlinik.vue` | `/setting/master-klinik` |
| Master Klinik Ruang | `pages/Setting/MasterKlinikRuang.vue` | `/setting/master-klinik-ruang` |
| Master Loket | `pages/Setting/MasterLoket.vue` | `/setting/master-loket` |
| Master Penunjang | `pages/Setting/MasterPenunjang.vue` | `/setting/master-penunjang` |
| Screen | `pages/Setting/Screen.vue` | `/setting/screen` |
| Tambah Loket | `pages/Setting/TambahLoket.vue` | `/setting/tambah-loket` |
| Edit Loket | `pages/Setting/Edit-Loket/[id].vue` | `/setting/edit-loket/:id` |
| Screen Index | `pages/Setting/Screen/index.vue` | `/setting/screen` |
| Edit Screen | `pages/Setting/Screen/edit/[id].vue` | `/setting/screen/edit/:id` |
## Verifikasi Akun Pages
| Page Name | File Path | Route Path |
|-----------|-----------|------------|
| Verifikasi Akun | `pages/verifikasiAkun/VerifikasiAkun.vue` | `/verifikasi-akun/verifikasi-akun` |
---
## Summary
- **Total Pages**: 30+ pages
- **Dynamic Routes**: 4 pages with `[id]` parameter
- **Note**: `Pengaturan.vue.txt` appears to be a text file, not an active Vue component
## Route Path Conversion Rules
In Nuxt.js, file paths are converted to routes as follows:
- File names are converted to kebab-case
- Folders create nested routes
- `index.vue` files create routes at the folder level
- `[id].vue` files create dynamic routes with `:id` parameter
- Special characters like hyphens are preserved
+83
View File
@@ -0,0 +1,83 @@
# Placeholder API Permission
## Deskripsi
Placeholder API untuk permission digunakan untuk testing dan development ketika backend API tidak tersedia.
## Cara Menggunakan
### 1. Menggunakan Placeholder API secara Default
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
Tambahkan query parameter `usePlaceholder=true` pada request:
```
GET /api/permission?roles=superadmin&groups=STIM&usePlaceholder=true
```
### 3. Menonaktifkan Placeholder API
Tambahkan query parameter `usePlaceholder=false` pada request:
```
GET /api/permission?roles=superadmin&groups=STIM&usePlaceholder=false
```
## Data Placeholder yang Tersedia
### Role: superadmin, Group: STIM
Data placeholder mengembalikan 5 permission items:
- Halaman Utama (read: true, active: true)
- Pengaturan (read: true, active: true)
- Halaman (read: true, active: true, disable: true)
- Dashboard (read: true, active: true, disable: true)
## Mapping Pagename ke Menu Sidebar
Sistem akan otomatis memetakan pagename dari API ke nama menu di sidebar:
- "Halaman Utama" → "Dashboard"
- "Pengaturan" → "Master Data"
- "Halaman" → "Master Data"
- "Dashboard" → "Dashboard"
## Testing dengan User bayurssa
Untuk testing dengan user email "bayurssa":
1. Pastikan user memiliki role "superadmin" dan group "STIM" di Keycloak
2. Login dengan email "bayurssa" dan password "12345"
3. Sistem akan otomatis menggunakan placeholder API jika backend tidak tersedia
4. Sidebar akan terfilter berdasarkan permissions dari placeholder API
## Mapping Role dan Group
Sistem secara otomatis melakukan normalisasi untuk role dan group:
### Normalisasi Role
- `default-roles-sandbox``superadmin`
- Role lain akan digunakan apa adanya (lowercase)
### Normalisasi Group
- `Instalasi STIM``STIM`
- Group yang mengandung "Instalasi" akan diekstrak untuk mengambil bagian "STIM"
- Group lain akan digunakan apa adanya (uppercase)
### Contoh Mapping
- 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`
## Catatan
- Placeholder API hanya tersedia untuk kombinasi role dan group yang sudah didefinisikan
- 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 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>
<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 />
</v-app>
</NuxtLayout>
</NuxtLayout>
</v-app>
</template>
+50 -30
View File
@@ -4,44 +4,46 @@
--neutral-800: #4D4D4D;
--neutral-700: #717171;
--neutral-600: #89939E;
--neutral-500: #ABBED1;
--neutral-400: #E5F7FA;
--neutral-500: #CDD4DC;
--neutral-400: #E1E5EA;
--neutral-300: #F5F7FA;
--neutral-100: #FFFFFF;
/* Primary Colors (Orange) */
--primary-700: #0053AD;
--primary-600: #0663C7;
--primary-500: #0671E0;
--primary-400: #4196F0;
--primary-300: #DBEDFF;
--primary-200: #EEF5FC;
--primary-100: #EEF5FC;
/* Primary Colors (Blue) */
--primary-700: #3556AE;
--primary-600: #3A61C9;
--primary-500: #567EE7;
--primary-400: #7898EC;
--primary-300: #9AB2F1;
--primary-200: #BBCBF5;
--primary-100: #DDE5FA;
/* Secondary Colors (Blue) */
--secondary-700: #0053AD;
--secondary-600: #0671E0;
--secondary-500: #0663C7;
--secondary-400: #4196F0;
--secondary-300: #DBEDFF;
--secondary-200: #EEF5FC;
--secondary-100: #0053AD;
/* Secondary Colors (Orange) */
--secondary-700: #E65A0D;
--secondary-600: #F16F29;
--secondary-500: #FF8441;
--secondary-400: #FF9D67;
--secondary-300: #FFB58D;
--secondary-200: #FFCEB3;
--secondary-100: #FFE6D9;
/* Success Colors (Green) */
--success-700: #1B6E53;
--success-600: #009262;
--success-500: #115B43;
--success-400: #32C997;
--success-300: #84DFC1;
--success-200: #F1FBF8;
--success-700: #008D65;
--success-600: #33A484;
--success-500: #59B59B;
--success-400: #80C6B2;
--success-300: #A6D7C9;
--success-200: #CCE8E0;
--success-100: #E5F4F0;
/* Danger Colors (Red) */
--danger-700: #E01507;
--danger-600: #E02B1D;
--danger-500: #C33025;
--danger-400: #FF5A4F;
--danger-300: #F0857D;
--danger-200: #FFF1F0;
--danger-700: #D82719;
--danger-600: #DE473B;
--danger-500: #E57269;
--danger-400: #EB938C;
--danger-300: #F1B3AE;
--danger-200: #F7D4D1;
--danger-100: #FBE9E8;
}
/* Utility Classes untuk Background */
@@ -68,6 +70,7 @@
.bg-secondary-400 { background-color: var(--secondary-400); }
.bg-secondary-300 { background-color: var(--secondary-300); }
.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-600 { background-color: var(--success-600); }
@@ -75,6 +78,7 @@
.bg-success-400 { background-color: var(--success-400); }
.bg-success-300 { background-color: var(--success-300); }
.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-600 { background-color: var(--danger-600); }
@@ -82,6 +86,7 @@
.bg-danger-400 { background-color: var(--danger-400); }
.bg-danger-300 { background-color: var(--danger-300); }
.bg-danger-200 { background-color: var(--danger-200); }
.bg-danger-100 { background-color: var(--danger-100); }
/* Utility Classes untuk Text Color */
.text-neutral-900 { color: var(--neutral-900); }
@@ -95,18 +100,33 @@
.text-primary-600 { color: var(--primary-600); }
.text-primary-500 { color: var(--primary-500); }
.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-600 { color: var(--secondary-600); }
.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-600 { color: var(--success-600); }
.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-600 { color: var(--danger-600); }
.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 */
.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-700: #717171;
$neutral-600: #89939E;
$neutral-500: #ABBED1;
$neutral-400: #E5F7FA;
$neutral-500: #CDD4DC;
$neutral-400: #E1E5EA;
$neutral-300: #F5F7FA;
$neutral-100: #FFFFFF;
$primary-700: #FF9B1B;
$primary-600: #FFA532;
$primary-500: #FFB95F;
$primary-400: #FFCD8D;
$primary-300: #FFDCAF;
$primary-200: #FFE6C6;
$primary-100: #EEF5FC;
$primary-700: #3556AE;
$primary-600: #3A61C9;
$primary-500: #567EE7;
$primary-400: #7898EC;
$primary-300: #9AB2F1;
$primary-200: #BBCBF5;
$primary-100: #DDE5FA;
$primary-50: #EFF3FD;
$secondary-700: #0053AD;
$secondary-600: #0671E0;
$secondary-500: #0663C7;
$secondary-400: #4196F0;
$secondary-300: #DBEDFF;
$secondary-200: #EEF5FC;
$secondary-700: #E65A0D;
$secondary-600: #F16F29;
$secondary-500: #FF8441;
$secondary-400: #FF9D67;
$secondary-300: #FFB58D;
$secondary-200: #FFCEB3;
$secondary-100: #FFE6D9;
$secondary-50: #FFF3EC;
$success-700: #1B6E53;
$success-600: #009262;
$success-500: #115B43;
$success-400: #32C997;
$success-300: #84DFC1;
$success-200: #F1FBF8;
$success-700: #008D65;
$success-600: #33A484;
$success-500: #59B59B;
$success-400: #80C6B2;
$success-300: #A6D7C9;
$success-200: #CCE8E0;
$success-100: #E5F4F0;
$danger-700: #E01507;
$danger-600: #E02B1D;
$danger-500: #C33025;
$danger-400: #FF5A4F;
$danger-300: #F0857D;
$danger-200: #FFF1F0;
$danger-700: #D82719;
$danger-600: #DE473B;
$danger-500: #E57269;
$danger-400: #EB938C;
$danger-300: #F1B3AE;
$danger-200: #F7D4D1;
$danger-100: #FBE9E8;
// CSS Variables untuk dynamic theming
:root {
@@ -58,6 +63,7 @@ $danger-200: #FFF1F0;
--color-primary-300: #{$primary-300};
--color-primary-200: #{$primary-200};
--color-primary-100: #{$primary-100};
--color-primary-50: #{$primary-50};
// Secondary
--color-secondary-700: #{$secondary-700};
@@ -66,6 +72,8 @@ $danger-200: #FFF1F0;
--color-secondary-400: #{$secondary-400};
--color-secondary-300: #{$secondary-300};
--color-secondary-200: #{$secondary-200};
--color-secondary-100: #{$secondary-100};
--color-secondary-50: #{$secondary-50};
// Success
--color-success-700: #{$success-700};
@@ -74,6 +82,7 @@ $danger-200: #FFF1F0;
--color-success-400: #{$success-400};
--color-success-300: #{$success-300};
--color-success-200: #{$success-200};
--color-success-100: #{$success-100};
// Danger
--color-danger-700: #{$danger-700};
@@ -82,6 +91,7 @@ $danger-200: #FFF1F0;
--color-danger-400: #{$danger-400};
--color-danger-300: #{$danger-300};
--color-danger-200: #{$danger-200};
--color-danger-100: #{$danger-100};
// Theme colors (akan di-override oleh theme system)
--color-primary: #{$primary-500};
@@ -135,15 +145,30 @@ $colors: (
'primary-400': $primary-400,
'primary-300': $primary-300,
'primary-200': $primary-200,
'primary-100': $primary-100,
'primary-50': $primary-50,
'secondary-700': $secondary-700,
'secondary-600': $secondary-600,
'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-600': $success-600,
'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-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 {
+882
View File
@@ -0,0 +1,882 @@
// Check-in Page Component Styles
@import 'variables';
/* Modern Minimalist Background */
.bg-modern {
background: $bg-white;
min-height: 100vh;
max-height: 100vh;
position: relative;
overflow: hidden;
}
.no-scroll-container {
height: 100vh;
max-height: 100vh;
overflow-y: auto;
overflow-x: hidden;
}
.no-scroll-container::-webkit-scrollbar {
width: $spacing-xs;
}
.no-scroll-container::-webkit-scrollbar-track {
background: transparent;
}
.no-scroll-container::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 2px;
}
.no-overflow {
overflow: hidden !important;
height: 100vh;
max-height: 100vh;
}
.bg-modern::before {
content: '';
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background:
radial-gradient(circle at 20% 50%, rgba(21, 101, 192, 0.05) 0%, transparent 50%),
radial-gradient(circle at 80% 80%, rgba(21, 101, 192, 0.05) 0%, transparent 50%);
pointer-events: none;
z-index: 0;
}
/* Main Card dengan Glassmorphism */
.main-card {
background: rgba(255, 255, 255, 0.95) !important;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: $radius-xl !important;
box-shadow: $shadow-xl !important;
overflow: hidden;
position: relative;
z-index: $z-base;
}
/* Header Modern Minimalis */
.header-modern {
background: linear-gradient(135deg, $primary-color 0%, $primary-dark 100%);
padding: $spacing-xl $spacing-2xl $spacing-lg;
position: relative;
overflow: hidden;
}
.header-modern::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: radial-gradient(circle at 50% 0%, rgba(255, 255, 255, 0.1) 0%, transparent 70%);
pointer-events: none;
}
.header-content {
display: flex;
align-items: center;
justify-content: center;
gap: $spacing-md;
margin-bottom: $spacing-xs;
}
.icon-circle {
width: 56px;
height: 56px;
border-radius: $radius-full;
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: center;
border: 2px solid rgba(255, 255, 255, 0.3);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.header-text {
text-align: center;
}
.title-modern {
font-size: $font-3xl;
font-weight: 600;
color: white;
margin: 0;
line-height: 1.2;
}
.subtitle-modern {
font-size: $font-base;
color: rgba(255, 255, 255, 0.9);
margin: 2px 0 0;
font-weight: 400;
}
.tabs-modern {
position: relative;
z-index: $z-base;
}
.tabs-modern :deep(.v-tab) {
color: rgba(255, 255, 255, 0.8) !important;
font-weight: 500;
text-transform: none;
letter-spacing: 0;
min-width: 120px;
transition: all $transition-base;
}
.tabs-modern :deep(.v-tab:hover) {
color: rgba(255, 255, 255, 1) !important;
background: rgba(255, 255, 255, 0.1);
border-radius: $radius-sm;
}
.tabs-modern :deep(.v-tab--selected) {
color: white !important;
font-weight: 600;
}
.tabs-modern :deep(.v-slider) {
background-color: $secondary-color !important;
height: 3px;
border-radius: 2px;
}
.tab-modern {
display: flex;
align-items: center;
gap: 6px;
}
.tab-modern .v-icon {
font-size: $font-xl !important;
}
/* Content Area */
.content-modern {
background: white;
max-height: calc(100vh - 200px);
overflow-y: auto;
}
.content-modern::-webkit-scrollbar {
width: $spacing-xs;
}
.content-modern::-webkit-scrollbar-track {
background: transparent;
}
.content-modern::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 2px;
}
.tab-content {
animation: fadeIn $transition-slow ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Status Header */
.status-header {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $spacing-md;
padding: $spacing-lg;
background: linear-gradient(135deg, $bg-grey 0%, $bg-blue-light 100%);
border-radius: $radius-md;
border: 1px solid rgba(21, 101, 192, 0.1);
text-align: center;
}
.status-icon-wrapper {
width: 40px;
height: 40px;
border-radius: 10px;
background: white;
display: flex;
align-items: center;
justify-content: center;
box-shadow: $shadow-sm;
flex-shrink: 0;
margin: 0 auto;
}
.status-text {
width: 100%;
text-align: center;
}
.status-title {
font-size: $font-lg;
font-weight: 600;
color: $text-primary;
margin: 0 0 $spacing-xs;
letter-spacing: -0.3px;
text-align: center;
}
.status-subtitle {
font-size: $font-base;
color: $text-secondary;
margin: 0;
line-height: 1.4;
text-align: center;
}
/* QR Scanner Modern */
.qr-scanner-container {
display: flex;
justify-content: center;
align-items: center;
margin: $spacing-2xl 0;
}
.qr-placeholder {
width: 100%;
max-width: 300px;
height: 300px;
background: linear-gradient(135deg, $bg-grey 0%, $bg-blue-light 100%);
border-radius: $radius-lg;
position: relative;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border: 2px dashed $primary-color;
box-shadow: 0 4px 20px rgba(21, 101, 192, 0.1);
margin: 0 auto;
}
.qr-reader-container {
width: 100%;
max-width: 500px;
margin: 0 auto;
position: relative;
}
.qr-reader-wrapper {
width: 100%;
aspect-ratio: 1 / 1;
border-radius: $radius-lg;
overflow: hidden;
box-shadow: $shadow-xl;
background: #000;
position: relative;
max-width: 500px;
max-height: 500px;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid rgba(21, 101, 192, 0.2);
margin: 0 auto;
}
.qr-reader-wrapper :deep(video) {
width: 100% !important;
height: 100% !important;
border-radius: $radius-lg;
display: block !important;
object-fit: cover;
background: #000;
aspect-ratio: 1 / 1;
}
.qr-reader-wrapper :deep(canvas) {
display: none !important;
}
.qr-reader-wrapper :deep(#qr-reader__dashboard) {
display: none !important;
}
.qr-reader-wrapper :deep(#qr-reader__scan_region) {
border-radius: $radius-lg;
border: 3px solid rgba(21, 101, 192, 0.8) !important;
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.4) !important;
position: relative;
}
.qr-reader-wrapper :deep(#qr-reader__scan_region::before) {
content: '';
position: absolute;
top: -3px;
left: -3px;
right: -3px;
bottom: -3px;
border: 3px solid rgba(21, 101, 192, 0.8);
border-radius: $radius-lg;
animation: pulse-border 2s ease-in-out infinite;
}
@keyframes pulse-border {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.7;
transform: scale(1.02);
}
}
.qr-reader-wrapper :deep(#qr-reader__scan_region video) {
border-radius: $radius-lg;
width: 100% !important;
height: 100% !important;
aspect-ratio: 1 / 1;
object-fit: cover;
}
.scanner-status {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 6px;
font-size: $font-sm;
}
.scanner-instruction {
display: flex;
align-items: center;
justify-content: center;
margin-top: $spacing-md;
padding: 10px $spacing-md;
background: linear-gradient(135deg, rgba(21, 101, 192, 0.1) 0%, rgba(13, 71, 161, 0.1) 100%);
border-radius: 10px;
color: $primary-color;
font-weight: 500;
font-size: $font-sm;
text-align: center;
border: 1px solid rgba(21, 101, 192, 0.2);
}
.scanner-loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.7);
border-radius: $radius-lg;
z-index: $z-overlay;
}
.scanner-overlay {
position: absolute;
width: 80%;
height: 80%;
}
.corner {
position: absolute;
width: 40px;
height: 40px;
border: 3px solid $primary-color;
}
.corner-tl {
top: 0;
left: 0;
border-right: none;
border-bottom: none;
border-radius: $radius-sm 0 0 0;
}
.corner-tr {
top: 0;
right: 0;
border-left: none;
border-bottom: none;
border-radius: 0 $radius-sm 0 0;
}
.corner-bl {
bottom: 0;
left: 0;
border-right: none;
border-top: none;
border-radius: 0 0 0 $radius-sm;
}
.corner-br {
bottom: 0;
right: 0;
border-left: none;
border-top: none;
border-radius: 0 0 $radius-sm 0;
}
.scan-line {
position: absolute;
width: 100%;
height: 2px;
background: linear-gradient(90deg, transparent, $primary-color, transparent);
top: 0;
animation: scan 2s linear infinite;
box-shadow: 0 0 10px $primary-color;
}
@keyframes scan {
0% { top: 0; }
100% { top: 100%; }
}
.qr-icon {
position: relative;
z-index: $z-base;
animation: breathe 2s ease-in-out infinite;
}
@keyframes breathe {
0%, 100% { opacity: 0.6; }
50% { opacity: 1; }
}
/* Modern Buttons */
.btn-primary-modern {
background: linear-gradient(135deg, $primary-color 0%, $primary-dark 100%) !important;
color: white !important;
font-weight: 600;
text-transform: none;
letter-spacing: 0.3px;
border-radius: $radius-md !important;
padding: 14px $spacing-2xl !important;
transition: all $transition-bezier;
box-shadow: $shadow-primary !important;
}
.btn-primary-modern:hover {
transform: translateY(-2px);
box-shadow: $shadow-primary-hover !important;
}
.btn-primary-modern:active {
transform: translateY(0);
}
.btn-primary-modern:disabled {
opacity: 0.5;
transform: none !important;
}
.btn-stop-modern {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%) !important;
color: white !important;
font-weight: 600;
text-transform: none;
letter-spacing: 0.3px;
border-radius: $radius-md !important;
padding: 14px $spacing-2xl !important;
transition: all $transition-bezier;
box-shadow: $shadow-error !important;
}
.btn-stop-modern:hover {
transform: translateY(-2px);
box-shadow: $shadow-error-hover !important;
}
.action-buttons {
margin-top: $spacing-xl;
display: flex;
justify-content: center;
}
.btn-centered {
width: auto !important;
min-width: 240px !important;
max-width: 320px !important;
margin: 0 auto !important;
display: block !important;
}
.btn-centered-small {
width: auto !important;
min-width: 180px !important;
margin: 0 auto;
display: block;
}
.btn-test-camera {
border-color: $primary-color !important;
color: $primary-color !important;
}
.btn-test-camera :deep(.v-btn__content) {
color: $primary-color !important;
}
.btn-test-camera :deep(.v-icon) {
color: $primary-color !important;
opacity: 1 !important;
}
/* Modern Inputs */
.input-modern :deep(.v-field) {
border-radius: $radius-md;
font-size: $font-md;
background: $bg-light;
border: 1.5px solid $border-light;
transition: all $transition-base;
}
.input-modern :deep(.v-field--focused) {
background: white;
border-color: $primary-color;
box-shadow: 0 0 0 4px rgba(21, 101, 192, 0.1);
}
.input-modern :deep(.v-field__input) {
padding-top: $spacing-md;
padding-bottom: $spacing-md;
}
.input-modern :deep(.v-label) {
font-weight: 500;
color: $text-secondary;
}
.quick-actions {
margin-top: $spacing-lg;
padding-top: $spacing-lg;
border-top: 1px solid $border-light;
}
.info-card {
display: flex;
justify-content: center;
}
.info-alert-centered {
width: 100%;
}
.info-alert-centered :deep(.v-alert__content) {
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 100% !important;
}
.quick-actions .v-btn {
transition: all $transition-base;
border-radius: $radius-md !important;
border: 1.5px solid $border-light !important;
}
.quick-actions .v-btn:hover {
transform: translateY(-2px);
border-color: $primary-color !important;
box-shadow: 0 4px 12px rgba(21, 101, 192, 0.15);
}
.quick-actions .v-btn :deep(.v-icon) {
opacity: 1 !important;
color: inherit !important;
}
.quick-actions .v-btn[color="primary"] {
color: $primary-color !important;
border-color: $primary-color !important;
}
.quick-actions .v-btn[color="primary"] :deep(.v-icon) {
color: $primary-color !important;
}
.qr-code-container {
display: flex;
justify-content: center;
align-items: center;
padding: $spacing-lg;
background: white;
border-radius: $radius-md;
box-shadow: $shadow-md;
}
.qr-code-container :deep(canvas) {
border-radius: $radius-sm;
}
.qr-display {
animation: slideUp 0.5s ease-out;
}
/* Stats Footer Modern */
.stats-footer-modern {
animation: slideUp 0.5s ease-out 0.3s both;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.stat-card-modern {
background: white;
border-radius: $radius-md;
padding: $spacing-lg $spacing-md;
text-align: center;
border: 1px solid $border-light;
transition: all $transition-bezier;
position: relative;
overflow: hidden;
}
.stat-card-modern::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, $primary-color 0%, $primary-dark 100%);
transform: scaleX(0);
transition: transform $transition-base;
}
.stat-card-modern:hover {
transform: translateY(-4px);
box-shadow: $shadow-lg;
border-color: $primary-color;
}
.stat-card-modern:hover::before {
transform: scaleX(1);
}
.stat-icon-modern {
margin-bottom: $spacing-md;
display: inline-flex;
padding: $spacing-sm;
background: $bg-blue-light;
border-radius: 10px;
}
.stat-value {
font-size: $font-2xl;
font-weight: 700;
color: $text-primary;
margin-bottom: $spacing-xs;
letter-spacing: -0.5px;
}
.stat-label {
font-size: $font-sm;
color: $text-secondary;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.custom-snackbar {
margin-bottom: $spacing-lg;
margin-right: $spacing-lg;
}
.custom-snackbar :deep(.v-snackbar__wrapper) {
min-width: 300px;
}
/* History Dialog Styles */
.history-list {
max-height: 500px;
overflow-y: auto;
padding-right: $spacing-sm;
}
.history-list::-webkit-scrollbar {
width: 6px;
}
.history-list::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 10px;
}
.history-list::-webkit-scrollbar-thumb {
background: #888;
border-radius: 10px;
}
.history-list::-webkit-scrollbar-thumb:hover {
background: #555;
}
.history-item {
transition: all $transition-base;
border-left: 4px solid transparent;
}
.history-item:hover {
transform: translateX(4px);
box-shadow: $shadow-md;
}
.history-success {
border-left-color: #4caf50;
background: rgba(76, 175, 80, 0.05);
}
.history-failed {
border-left-color: #f44336;
background: rgba(244, 67, 54, 0.05);
}
.history-pending {
border-left-color: #ff9800;
background: rgba(255, 152, 0, 0.05);
}
/* Mobile Optimization */
@media (max-width: 600px) {
.v-container.no-scroll-container {
padding: $spacing-sm !important;
}
.main-card {
border-radius: $radius-lg !important;
}
.header-modern {
padding: $spacing-lg $spacing-lg $spacing-md !important;
}
.content-modern {
padding: $spacing-lg !important;
max-height: calc(100vh - 180px);
}
.header-content {
flex-direction: column;
text-align: center;
gap: $spacing-md;
}
.header-text {
text-align: center;
}
.icon-circle {
width: 56px;
height: 56px;
}
.title-modern {
font-size: $font-2xl;
}
.subtitle-modern {
font-size: $font-base;
}
.content-modern {
padding: $spacing-xl $spacing-lg !important;
}
.status-header {
padding: $spacing-lg;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: $spacing-md;
}
.status-icon-wrapper {
margin: 0 auto;
}
.status-text {
text-align: center;
width: 100%;
}
.status-title {
font-size: $font-lg;
}
.status-subtitle {
font-size: $font-base;
}
.qr-placeholder {
max-width: 100%;
height: 280px;
}
.qr-reader-container {
max-width: 100%;
}
.qr-reader-wrapper {
min-height: 300px;
max-height: 70vh;
border-radius: $radius-lg;
}
.qr-reader-wrapper :deep(video) {
max-height: 70vh;
object-fit: contain;
}
.scanner-instruction {
font-size: $font-sm;
padding: $spacing-md;
}
.stats-footer-modern {
margin-top: $spacing-sm;
}
.stat-card-modern {
padding: $spacing-md $spacing-sm;
}
.stat-value {
font-size: $font-xl;
}
.stat-label {
font-size: $font-xs;
}
.stat-icon-modern {
margin-bottom: $spacing-sm;
padding: 6px;
}
}
+230
View File
@@ -0,0 +1,230 @@
// Check-in Page Dialog Styles
@import 'variables';
.blur-dialog :deep(.v-overlay__scrim) {
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.dialog-card {
overflow: hidden;
animation: popIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
@keyframes popIn {
0% {
opacity: 0;
transform: scale(0.8) translateY(20px);
}
100% {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.dialog-header {
position: relative;
overflow: hidden;
}
.dialog-header::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(circle, rgba(255,255,255,0.2) 0%, transparent 70%);
animation: rotate 10s linear infinite;
}
@keyframes rotate {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.icon-wrapper {
display: inline-block;
animation: bounceIn 0.6s ease-out 0.2s both;
}
@keyframes bounceIn {
0% {
opacity: 0;
transform: scale(0.3);
}
50% {
transform: scale(1.05);
}
70% {
transform: scale(0.9);
}
100% {
opacity: 1;
transform: scale(1);
}
}
.dialog-icon {
filter: drop-shadow(0 4px 12px rgba(0,0,0,0.3));
}
.success-header {
background: linear-gradient(135deg, $success-color 0%, $success-dark 100%);
}
.warning-header {
background: linear-gradient(135deg, $warning-color 0%, $warning-dark 100%);
}
.error-header {
background: linear-gradient(135deg, $error-color 0%, $error-dark 100%);
}
.icon-bg-error {
background: linear-gradient(135deg, $error-color 0%, $error-dark 100%);
}
.dialog-button {
text-transform: none;
letter-spacing: 0.5px;
font-weight: 600;
transition: all $transition-base;
}
.dialog-button:hover {
transform: translateY(-2px);
}
/* Patient Info Card Styling */
.patient-info-card {
background: rgba(250, 250, 250, 0.8) !important;
border-color: $border-grey !important;
}
.patient-info-label {
color: $text-grey !important;
font-weight: 500;
opacity: 0.9;
}
.patient-info-value {
color: $text-grey-dark !important;
font-weight: 500 !important;
opacity: 0.85;
}
.patient-info-icon {
opacity: 0.7;
}
/* Decorative Circles */
.decorative-circles {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
overflow: hidden;
}
.circle {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
animation: float 6s ease-in-out infinite;
}
.circle-1 {
width: 100px;
height: 100px;
top: -50px;
left: -50px;
animation-delay: 0s;
}
.circle-2 {
width: 150px;
height: 150px;
top: 50%;
right: -75px;
animation-delay: 2s;
}
.circle-3 {
width: 80px;
height: 80px;
bottom: -40px;
left: 20%;
animation-delay: 4s;
}
@keyframes float {
0%, 100% {
transform: translate(0, 0) scale(1);
opacity: 0.3;
}
50% {
transform: translate(20px, -20px) scale(1.1);
opacity: 0.5;
}
}
/* Message Container */
.message-container {
text-align: center;
}
.message-icon {
display: flex;
justify-content: center;
align-items: center;
}
/* Status Badge */
.status-badge {
display: inline-flex;
}
/* Instruction Box */
.instruction-box {
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(0, 0, 0, 0.1);
}
.instruction-success {
background: rgba(76, 175, 80, 0.1);
border-color: rgba(76, 175, 80, 0.3);
}
.instruction-warning {
background: rgba(255, 152, 0, 0.1);
border-color: rgba(255, 152, 0, 0.3);
}
/* Icon Background */
.icon-bg {
width: 120px;
height: 120px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, $primary-color 0%, $primary-dark 100%);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
position: relative;
z-index: 1;
}
.icon-bg-success {
background: linear-gradient(135deg, $success-color 0%, $success-dark 100%);
}
.icon-bg-warning {
background: linear-gradient(135deg, $warning-color 0%, $warning-dark 100%);
}
.icon-bg-error {
background: linear-gradient(135deg, $error-color 0%, $error-dark 100%);
}
+74
View File
@@ -0,0 +1,74 @@
// Check-in Page Variables
// Colors
$primary-color: #1565C0;
$primary-dark: #0D47A1;
$secondary-color: #FB8C00;
$success-color: #66BB6A;
$success-dark: #43A047;
$warning-color: #FFA726;
$warning-dark: #FB8C00;
$error-color: #EF5350;
$error-dark: #E53935;
// Background Colors
$bg-white: #ffffff;
$bg-light: #fafafa;
$bg-grey: #f5f7fa;
$bg-blue-light: #e3f2fd;
// Text Colors
$text-primary: #1a1a1a;
$text-secondary: #6b7280;
$text-grey: #9e9e9e;
$text-grey-dark: #757575;
// Border Colors
$border-light: #e5e7eb;
$border-grey: rgba(0, 0, 0, 0.08);
// Shadow
$shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.08);
$shadow-md: 0 4px 12px rgba(0, 0, 0, 0.1);
$shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.1);
$shadow-xl: 0 8px 32px rgba(0, 0, 0, 0.1);
$shadow-primary: 0 4px 16px rgba(21, 101, 192, 0.3);
$shadow-primary-hover: 0 8px 24px rgba(21, 101, 192, 0.4);
$shadow-error: 0 4px 16px rgba(239, 68, 68, 0.3);
$shadow-error-hover: 0 8px 24px rgba(239, 68, 68, 0.4);
// Border Radius
$radius-sm: 8px;
$radius-md: 12px;
$radius-lg: 16px;
$radius-xl: 24px;
$radius-full: 50%;
// Spacing
$spacing-xs: 4px;
$spacing-sm: 8px;
$spacing-md: 12px;
$spacing-lg: 16px;
$spacing-xl: 20px;
$spacing-2xl: 24px;
$spacing-3xl: 28px;
// Font Sizes
$font-xs: 10px;
$font-sm: 11px;
$font-base: 13px;
$font-md: 15px;
$font-lg: 16px;
$font-xl: 18px;
$font-2xl: 20px;
$font-3xl: 24px;
// Transitions
$transition-fast: 0.2s ease;
$transition-base: 0.3s ease;
$transition-slow: 0.4s ease;
$transition-bezier: 0.3s cubic-bezier(0.4, 0, 0.2, 1);
// Z-index
$z-base: 1;
$z-overlay: 10;
$z-dialog: 100;
+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; }
+8 -5
View File
@@ -1,7 +1,8 @@
// Gunakan @use daripada @import
@use './variables' as *;
@use './colors' as *;
@use './typography' as *;
@use "./variables" as *;
@use "./colors" as *;
@use "./typography" as *;
@use "./pages/dashboard" as *;
// Global styles
*,
@@ -19,7 +20,9 @@ body {
padding: 0;
background-color: var(--color-background);
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
@@ -68,4 +71,4 @@ button {
opacity: 0.5;
cursor: not-allowed;
}
}
}
+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 --- */
/* Base Orange: #FF9B1B (New) */
/* Hover/Darker Orange: #E68A00 (Calculated darker shade for hover effect) */
/* --- END COLOR DEFINITIONS --- */
/* Main Background - Now Orange */
/* Main Background - Using Primary Color */
.login-background {
background: #FF9B1B; /* Solid Orange Background: #FF9B1B */
min-height: 100vh;
background: $primary-500; /* Primary-500: #567EE7 */
height: 100dvh;
position: relative;
overflow: hidden;
}
.login-background::before {
/* No radial gradients */
content: '';
content: "";
position: absolute;
top: 0;
left: 0;
@@ -26,7 +21,7 @@
background: none;
}
/* Floating Medical Icons - Subtle on Orange Background */
/* Floating Medical Icons - Subtle on Primary Background */
.floating-medical-icon {
position: absolute;
opacity: 0.8;
@@ -45,16 +40,39 @@
}
.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 */
.icon-1 { top: 15%; left: 5%; }
.icon-2 { 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%; }
.icon-1 {
top: 15%;
left: 5%;
}
.icon-2 {
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 {
@@ -66,14 +84,14 @@
}
.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);
/* box-shadow is now managed by .main-card */
}
/* Right Login Section (White) */
/* Right Login Section (Neutral-300) */
.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 */
}
@@ -84,51 +102,51 @@
/* White Dialog Styles */
.white-dialog {
background: white !important;
background: $neutral-100 !important;
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 {
color: #333; /* Dark text on white background */
font-size: 1.1rem;
color: $neutral-900; /* Neutral-900: #212121 */
font-size: 1.6rem;
font-weight: 700;
letter-spacing: 1px;
margin-bottom: 0.25rem;
}
.login-instruction-dark {
color: #777; /* Grey text for instruction on white background */
font-size: 0.8rem;
color: $neutral-700; /* Neutral-700: #717171 */
font-size: 1.1rem;
letter-spacing: 0.3px;
}
/* App Title - Dark for white background */
/* App Title - Using Primary Color */
.app-title-dark {
color: #FF9B1B; /* Orange app title: #FF9B1B */
color: $primary-500; /* Primary-500: #567EE7 */
text-shadow: none;
}
.hospital-logo {
height: 332px;
height: 180px;
width: auto;
filter: drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.1));
}
.sso-text-dark {
color: #555; /* Dark grey for SSO text */
color: $neutral-800; /* Dark grey for SSO text */
font-size: 0.95rem;
font-weight: 500;
}
/* Buttons (Orange theme) */
/* Buttons (Primary theme) */
.login-btn {
background: #FF9B1B !important; /* Solid Orange button: #FF9B1B */
color: white !important; /* White text on orange button */
background: $primary-500 !important; /* Primary-500: #567EE7 */
color: $neutral-100 !important; /* White text on primary button */
border: none;
box-shadow:
0 8px 25px rgba(255, 155, 27, 0.3), /* Orange shadow: #FF9B1B */
0 4px 12px rgba(255, 155, 27, 0.2);
0 8px 25px rgba(86, 126, 231, 0.3),
/* Primary shadow */ 0 4px 12px rgba(86, 126, 231, 0.2);
transition: all 0.3s ease;
text-transform: none;
font-size: 1rem;
@@ -137,15 +155,15 @@
.login-btn:hover {
transform: translateY(-2px);
background: #E68A00 !important; /* Slightly darker orange on hover */
background: $primary-600 !important; /* Primary-600: #3A61C9 on hover */
box-shadow:
0 12px 30px rgba(255, 155, 27, 0.4),
0 6px 15px rgba(255, 155, 27, 0.3);
0 12px 30px rgba(86, 126, 231, 0.4),
0 6px 15px rgba(86, 126, 231, 0.3);
}
.register-btn-dark {
color: #FF9B1B !important; /* Orange text for register button: #FF9B1B */
border: 2px solid #FF9B1B !important; /* Orange border: #FF9B1B */
color: $primary-500 !important; /* Primary-500: #567EE7 */
border: 2px solid $primary-500 !important; /* Primary border */
background: transparent !important;
transition: all 0.3s ease;
text-transform: none;
@@ -153,39 +171,44 @@
}
.register-btn-dark:hover {
background: rgba(255, 155, 27, 0.05) !important; /* Light orange hover background */
border-color: #E68A00 !important; /* Darker orange border on hover */
background: rgba(
86,
126,
231,
0.05
) !important; /* Light primary hover background */
border-color: $primary-600 !important; /* Primary-600 border on hover */
transform: translateY(-1px);
}
/* Custom Divider - Dark for white background */
.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;
}
/* Help Text and Links - Dark for white background */
.help-text-dark {
color: #555; /* Dark grey help text */
color: $neutral-800; /* Dark grey help text */
font-size: 0.9rem;
}
.contact-link-dark {
color: #FF9B1B !important; /* Orange link: #FF9B1B */
color: $primary-500 !important; /* Primary-500: #567EE7 */
text-decoration: underline;
text-transform: none;
font-size: 0.9rem;
}
.help-link-dark {
color: #777; /* Grey link */
color: $neutral-700; /* Grey link */
font-size: 0.85rem;
text-decoration: underline;
cursor: pointer;
}
.help-link-dark:hover {
color: #FF9B1B; /* Orange on hover: #FF9B1B */
color: $primary-500; /* Primary-500 on hover */
}
/* Transparent Background */
@@ -196,27 +219,26 @@
/* Dialog Accents */
.v-card-title .v-icon,
.v-list-item .v-icon {
color: #FF9B1B !important; /* Orange accents: #FF9B1B */
color: $primary-500 !important; /* Primary accents */
}
.v-btn[color="#FF9B1B"] {
background-color: #FF9B1B !important;
color: white !important;
background-color: $primary-500 !important;
color: $neutral-100 !important;
}
.v-alert[color="orange"] {
border-left: 8px solid #FF9B1B !important;
color: #333 !important;
border-left: 8px solid $primary-500 !important;
color: $neutral-900 !important;
}
/* Responsive Design Adjustments for single column */
@media (max-width: 960px) {
.main-card {
margin: 1rem;
max-width: 450px !important; /* Enforce max-width on smaller screens */
}
.login-section-box {
min-height: auto;
padding: 2rem !important;
@@ -224,12 +246,11 @@
}
@media (max-width: 600px) {
.main-card {
margin: 0.5rem;
}
.login-section-box {
padding: 1.5rem !important;
}
}
}
+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>
+93 -107
View File
@@ -1,124 +1,110 @@
<template>
<v-card class="pa-4 rounded-lg elevation-2">
<v-card-title class="text-h5 font-weight-bold mb-4">
Edit Hak Akses Menu | {{ localItem.namaTipeUser }}
</v-card-title>
<v-card-text>
<v-row>
<v-col cols="12">
<v-card-title class="text-subtitle-1 font-weight-bold pa-0 mb-4">Hak Akses Menu</v-card-title>
<v-table density="compact" class="elevation-1 rounded-lg">
<thead>
<tr>
<th class="text-left">No</th>
<th class="text-left">Menu</th>
<th class="text-center">Akses</th>
<th class="text-center">Lihat</th>
<th class="text-center">Tambah</th>
<th class="text-center">Edit</th>
<th class="text-center">Hapus</th>
</tr>
</thead>
<tbody>
<tr v-for="(menu, index) in localItem.hakAksesMenu" :key="menu.name">
<td>{{ index + 1 }}</td>
<td>{{ menu.name }}</td>
<td class="text-center">
<v-checkbox v-model="menu.canAccess" hide-details></v-checkbox>
</td>
<td class="text-center">
<v-checkbox v-model="menu.canView" hide-details></v-checkbox>
</td>
<td class="text-center">
<v-checkbox v-model="menu.canAdd" hide-details></v-checkbox>
</td>
<td class="text-center">
<v-checkbox v-model="menu.canEdit" hide-details></v-checkbox>
</td>
<td class="text-center">
<v-checkbox v-model="menu.canDelete" hide-details></v-checkbox>
</td>
</tr>
</tbody>
</v-table>
</v-col>
</v-row>
</v-card-text>
<v-card-actions class="d-flex justify-end pa-4">
<v-btn
color="grey-darken-1"
variant="flat"
class="text-capitalize rounded-lg mr-2"
@click="$emit('cancel')"
>
Batal
</v-btn>
<v-btn
color="orange-darken-2"
variant="flat"
class="text-capitalize rounded-lg"
@click="$emit('save', localItem)"
>
Submit
</v-btn>
</v-card-actions>
<v-card variant="outlined" class="rounded-lg overflow-hidden">
<v-table density="compact">
<thead>
<tr>
<th class="text-left font-weight-bold">Halaman</th>
<th class="text-center font-weight-bold" style="width: 120px;">Akses</th>
</tr>
</thead>
<tbody>
<template v-for="item in flatNavItems" :key="item.path || item.name">
<tr :class="{ 'bg-grey-lighten-4': !item.path }">
<td :style="{ paddingLeft: item.level * 24 + 'px' }">
<div class="d-flex align-center">
<v-icon :icon="item.icon" size="small" class="mr-2" color="grey"></v-icon>
<span :class="{ 'font-weight-bold': !item.path }">{{ item.name }}</span>
</div>
</td>
<td class="text-center">
<v-checkbox
v-if="item.path"
:model-value="isAllowed(item.path)"
@update:model-value="toggleAccess(item, !!$event)"
hide-details
density="compact"
color="primary"
class="d-inline-flex"
></v-checkbox>
<v-icon v-else icon="mdi-folder-open" size="small" color="grey-lighten-1"></v-icon>
</td>
</tr>
</template>
</tbody>
</v-table>
</v-card>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { computed } from 'vue';
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 HakAksesData {
id: number;
namaTipeUser: string;
hakAksesMenu: HakAksesMenu[];
}
// Define props with type validation
const props = defineProps({
item: {
type: Object as () => HakAksesData,
required: true,
// Add custom validator for more robust checks
validator: (value: HakAksesData) => {
return 'namaTipeUser' in value && 'hakAksesMenu' in value;
},
},
pages: {
type: Array as () => any[], // Be flexible with legacy data
default: () => []
}
});
// Define emits for clarity
const emits = defineEmits(['save', 'cancel']);
const emit = defineEmits(['update:pages']);
// Use a local copy to avoid mutating the prop directly
const localItem = ref<HakAksesData>(JSON.parse(JSON.stringify(props.item)));
const navItemsStore = useNavItemsStore();
// Watch the prop for changes and update the local copy
watch(() => props.item, (newItem) => {
localItem.value = JSON.parse(JSON.stringify(newItem));
interface FlatNavItem {
name: string;
path: string;
icon: string;
level: number;
}
const flatNavItems = computed(() => {
const result: FlatNavItem[] = [];
const walk = (items: any[], level = 0) => {
items.forEach(item => {
result.push({
name: item.name,
path: item.path,
icon: item.icon,
level
});
if (item.children && item.children.length > 0) {
walk(item.children, level + 1);
}
});
};
walk(navItemsStore.getNavItems);
return result;
});
const isAllowed = (path: string) => {
if (!props.pages || !Array.isArray(props.pages)) return false;
return props.pages.some(p => {
if (typeof p === 'string') return p === path;
if (p && typeof p === 'object' && p.path) return p.path === path;
return false;
});
};
const toggleAccess = (item: FlatNavItem, allowed: boolean) => {
let newPages = [...props.pages];
if (allowed) {
if (!newPages.includes(item.path)) {
newPages.push(item.path);
}
} else {
newPages = newPages.filter(p => p !== item.path);
}
emit('update:pages', newPages);
};
</script>
<style scoped>
.v-table :deep(th) {
font-weight: bold !important;
background-color: #f9fafb !important;
}
.v-table :deep(td) {
vertical-align: middle;
}
.v-checkbox :deep(.v-selection-control__input) {
color: #2196F3 !important;
.bg-grey-lighten-4 {
background-color: #f5f5f5;
}
</style>
+140 -21
View File
@@ -1,19 +1,30 @@
<template>
<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"
style="border-top: 8px solid #FFB95F;"
height="100%"
>
<div class="d-flex align-center justify-center mb-4">
<v-icon size="36" color="orange-lighten-2" class="mr-2">mdi-hospital-box-outline</v-icon>
<div class="text-h6 font-weight-bold text-orange-lighten-2">{{ title }}</div>
</div>
<!-- Header dengan background biru -->
<v-card-title class="ticket-header">
<div class="ticket-header-content">
<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">
<v-timeline density="compact" align="start" line-inset="12" line-color="#FFB95F">
<div class="timeline-scroll-container flex-grow-1">
<v-timeline density="compact" align="start" line-inset="12" line-color="#567EE7">
<v-timeline-item
v-for="(step, index) in steps"
:key="index"
@@ -24,12 +35,12 @@
>
<div class="d-flex flex-column align-start">
<span
class="font-weight-bold"
:class="index === currentStepIndex ? 'text-orange-lighten-1' : index < currentStepIndex ? 'text-success' : 'text-grey-darken-1'"
class="font-weight-bold"
:class="getStepTextClass(index)"
>
{{ step.label }}
</span>
<span class="text-caption text-grey-darken-1">
<span class="text-caption" :class="getStepDateClass(index)">
{{ step.date !== '-' ? `${step.date}, ${step.time}` : 'Menunggu' }}
</span>
</div>
@@ -42,13 +53,14 @@
<v-btn
size="large"
variant="tonal"
color="#FFA532"
class="mt-auto font-weight-bold"
color="#567EE7"
class="mt-auto font-weight-bold reprint-button text-secondary-500"
prepend-icon="mdi-printer"
block
>
Cetak Ulang Tiket
</v-btn>
</v-card-text>
</v-card>
</template>
@@ -59,13 +71,30 @@ const props = defineProps({
title: String,
steps: Array,
color: String,
currentStepLabel: String,
currentStepLabel: String,
queueNumber: String, // Nomor antrean untuk ditampilkan di chip
});
const currentStepIndex = computed(() => {
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) => {
if (index < currentStepIndex.value) {
return 'mdi-check';
@@ -77,22 +106,47 @@ const getStepIcon = (index) => {
};
const getStepColor = (index) => {
// Warna berbeda berdasarkan status step (dari _colors.scss)
if (index < currentStepIndex.value) {
return 'success';
return '#33A484'; // success-600 - hijau untuk completed
} 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>
<style scoped>
<style scoped lang="scss">
@import '@/assets/scss/_colors.scss';
/* =============================================== */
/* SCROLLABLE TIMELINE STYLES (Menggunakan Tinggi Tetap) */
/* =============================================== */
.timeline-scroll-container {
/* Tinggi Tetap: Memastikan tinggi card konsisten di semua skenario responsive */
height: 320px;
/* Flex grow untuk mengisi ruang yang tersedia */
flex: 1 1 auto;
min-height: 200px;
max-height: 400px;
/* Membuat konten scrollable di sumbu Y jika melebihi height */
overflow-y: auto;
@@ -127,4 +181,69 @@ const getStepColor = (index) => {
.v-timeline-item :deep(.v-timeline-item__opposite) {
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>
-416
View File
@@ -1,416 +0,0 @@
<template>
<v-menu
v-model="menu"
:close-on-content-click="false"
location="top end"
origin="bottom right"
transition="scale-transition"
open-on-hover
>
<template v-slot:activator="{ props: menuProps }">
<v-list-item
v-if="!rail"
v-bind="menuProps"
:title="user?.name || user?.preferred_username || 'User'"
:subtitle="user?.email || 'No email'"
class="pa-3 ma-1 rounded-xl profile-activator"
link
>
<template v-slot:prepend>
<div class="avatar-wrapper">
<v-avatar size="44" class="avatar-glow">
<v-img
:src="user?.picture || 'https://i.pravatar.cc/300?img=68'"
:alt="`${user?.name || 'User'} Profile`"
></v-img>
</v-avatar>
<div class="status-dot"></div>
</div>
</template>
<template v-slot:append>
<v-icon size="20" class="chevron-icon">mdi-menu-down</v-icon>
</template>
</v-list-item>
<v-btn
v-else
icon
v-bind="menuProps"
size="large"
variant="flat"
class="avatar-btn"
:title="user?.name || 'Profile'"
>
<div class="avatar-wrapper">
<v-avatar size="44" class="avatar-glow">
<v-img
:src="user?.picture || 'https://i.pravatar.cc/300?img=68'"
:alt="`${user?.name || 'User'} Profile`"
></v-img>
</v-avatar>
<div class="status-dot"></div>
</div>
</v-btn>
</template>
<v-card class="profile-card rounded-xl elevation-12" width="340">
<!-- Decorative Header -->
<div class="card-header">
<div class="header-pattern"></div>
<div class="header-content pa-6 text-center">
<div class="avatar-container mb-4">
<v-avatar size="90" class="profile-avatar">
<v-img
:src="user?.picture || 'https://i.pravatar.cc/300?img=68'"
:alt="`${user?.name || 'User'} Profile`"
></v-img>
</v-avatar>
<div class="status-badge">
<v-icon size="12" color="white">mdi-check</v-icon>
</div>
</div>
<h3 class="text-h6 font-weight-bold text-white mb-1">
{{ user?.name || user?.preferred_username || 'User' }}
</h3>
<p class="text-body-2 text-white opacity-90 mb-2">
{{ user?.email || 'No email' }}
</p>
<v-chip
size="small"
class="glass-chip"
prepend-icon="mdi-identifier"
>
{{ user?.id?.substring(0, 10) }}...
</v-chip>
</div>
</div>
<!-- Menu Items -->
<v-card-text class="pa-4">
<div class="menu-grid">
<div
class="menu-tile rounded-lg pa-4 text-center cursor-pointer"
@click="handleAction('profile')"
>
<div class="tile-icon-wrapper mb-2 mx-auto">
<v-icon size="24" color="blue-darken-2">mdi-account-circle</v-icon>
</div>
<div class="text-caption font-weight-medium">Profil</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>
<v-divider class="my-4"></v-divider>
<!-- Quick Actions -->
<div class="quick-actions">
<v-list-item
class="rounded-lg px-3 py-2 action-item"
link
@click="handleAction('notifications')"
>
<template v-slot:prepend>
<v-icon size="20" color="blue-darken-1">mdi-bell-outline</v-icon>
</template>
<v-list-item-title class="text-body-2 font-weight-medium">
Notifikasi
</v-list-item-title>
<template v-slot:append>
<v-badge color="orange-darken-2" content="3" inline></v-badge>
</template>
</v-list-item>
<v-list-item
class="rounded-lg px-3 py-2 action-item"
link
@click="handleAction('help')"
>
<template v-slot:prepend>
<v-icon size="20" color="blue-darken-1">mdi-help-circle-outline</v-icon>
</template>
<v-list-item-title class="text-body-2 font-weight-medium">
Bantuan & Dukungan
</v-list-item-title>
</v-list-item>
</div>
<v-divider class="my-4"></v-divider>
<!-- Logout Button -->
<v-btn
block
class="rounded-lg logout-btn"
variant="flat"
color="error"
prepend-icon="mdi-logout"
@click="signOut"
:disabled="isLoggingOut"
:loading="isLoggingOut"
>
{{ isLoggingOut ? 'Keluar...' : 'Keluar' }}
</v-btn>
</v-card-text>
</v-card>
</v-menu>
</template>
<script setup>
import { ref } from 'vue';
import { navigateTo } from '#app';
const props = defineProps({
user: {
type: Object,
required: true
},
rail: {
type: Boolean,
required: true
}
})
const menu = ref(false);
const darkMode = ref(false);
const isLoggingOut = ref(false);
const emit = defineEmits(['logout']);
const signOut = async () => {
if (isLoggingOut.value) return;
isLoggingOut.value = true;
menu.value = false;
try {
emit('logout');
} finally {
isLoggingOut.value = false;
}
};
const handleAction = (action) => {
switch(action) {
case 'account':
navigateTo('/Profile/Pengaturan')
break;
case 'profile':
navigateTo('/Profile/Profil')
break;
case 'notifications':
navigateTo('/notifications')
break;
case 'help':
navigateTo('/help')
break;
case 'darkMode':
darkMode.value = !darkMode.value;
return;
}
menu.value = false;
};
</script>
<style scoped>
.profile-activator {
background: linear-gradient(135deg, rgba(25, 118, 210, 0.1) 0%, rgba(245, 124, 0, 0.1) 100%);
border: 1px solid rgba(25, 118, 210, 0.2);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.profile-activator:hover {
background: linear-gradient(135deg, rgba(25, 118, 210, 0.15) 0%, rgba(245, 124, 0, 0.15) 100%);
border-color: rgba(25, 118, 210, 0.3);
transform: translateY(-2px);
}
.avatar-wrapper {
position: relative;
}
.avatar-glow {
border: 3px solid white;
box-shadow: 0 0 20px rgba(25, 118, 210, 0.4);
}
.status-dot {
position: absolute;
bottom: 2px;
right: 2px;
width: 12px;
height: 12px;
background: linear-gradient(135deg, #4caf50 0%, #8bc34a 100%);
border: 2px solid white;
border-radius: 50%;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.8;
transform: scale(1.1);
}
}
.chevron-icon {
transition: transform 0.3s ease;
}
.profile-activator:hover .chevron-icon {
transform: translateY(2px);
}
.avatar-btn {
background: transparent !important;
}
.profile-card {
overflow: hidden;
border: 1px solid rgba(25, 118, 210, 0.1);
}
.card-header {
position: relative;
background: linear-gradient(135deg, #1976d2 0%, #fb8c00 100%);
overflow: hidden;
}
.header-pattern {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
radial-gradient(circle at 20% 50%, rgba(255, 255, 255, 0.1) 0%, transparent 50%),
radial-gradient(circle at 80% 80%, rgba(255, 255, 255, 0.1) 0%, transparent 50%);
}
.header-content {
position: relative;
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 {
position: absolute;
bottom: 0;
right: 0;
width: 28px;
height: 28px;
background: linear-gradient(135deg, #4caf50 0%, #8bc34a 100%);
border: 3px solid white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.glass-chip {
background: rgba(255, 255, 255, 0.2) !important;
backdrop-filter: blur(10px);
color: white !important;
border: 1px solid rgba(255, 255, 255, 0.3);
}
.menu-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.menu-tile {
background: linear-gradient(135deg, rgba(25, 118, 210, 0.05) 0%, rgba(245, 124, 0, 0.05) 100%);
border: 1px solid rgba(25, 118, 210, 0.1);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
}
.menu-tile:hover {
background: linear-gradient(135deg, rgba(25, 118, 210, 0.1) 0%, rgba(245, 124, 0, 0.1) 100%);
border-color: rgba(25, 118, 210, 0.3);
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
}
.tile-icon-wrapper {
width: 48px;
height: 48px;
background: white;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.quick-actions {
display: flex;
flex-direction: column;
gap: 4px;
}
.action-item {
transition: all 0.2s ease;
background: transparent;
}
.action-item:hover {
background: linear-gradient(135deg, rgba(25, 118, 210, 0.05) 0%, rgba(245, 124, 0, 0.05) 100%);
transform: translateX(4px);
}
.logout-btn {
text-transform: none;
font-weight: 600;
letter-spacing: 0.5px;
box-shadow: 0 4px 12px rgba(244, 67, 54, 0.3);
}
.logout-btn:hover {
box-shadow: 0 6px 20px rgba(244, 67, 54, 0.4);
}
.cursor-pointer {
cursor: pointer;
}
.opacity-90 {
opacity: 0.9;
}
</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)
})
})
*/
+181
View File
@@ -0,0 +1,181 @@
<template>
<v-dialog
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
max-width="500"
persistent
transition="dialog-transition"
scrim="rgba(0, 0, 0, 0.5)"
class="blur-dialog"
>
<v-card class="rounded-xl dialog-card" elevation="24">
<div class="dialog-header text-center pa-8" :class="lastCheckInResult?.success && infoAction === 'checkin' ? 'success-header' : lastCheckInResult?.status === 'NOT_ALLOWED' ? 'warning-header' : 'error-header'">
<div class="decorative-circles">
<div class="circle circle-1"></div>
<div class="circle circle-2"></div>
<div class="circle circle-3"></div>
</div>
<div class="icon-wrapper mb-4">
<div class="icon-bg" :class="lastCheckInResult?.success && infoAction === 'checkin' ? 'icon-bg-success' : lastCheckInResult?.status === 'NOT_ALLOWED' ? 'icon-bg-warning' : 'icon-bg-error'">
<v-icon size="64" color="white" class="dialog-icon">
{{ lastCheckInResult?.success && infoAction === 'checkin' ? 'mdi-check-circle' : lastCheckInResult?.status === 'NOT_ALLOWED' ? 'mdi-alert-circle' : 'mdi-close-circle' }}
</v-icon>
</div>
</div>
<h2 class="text-h4 font-weight-bold text-white mb-2">
{{ lastCheckInResult?.success && infoAction === 'checkin'
? 'Check-in Berhasil!'
: lastCheckInResult?.status === 'NOT_ALLOWED'
? 'Belum Diizinkan'
: 'Check-in Gagal' }}
</h2>
<div class="status-badge mt-3">
<v-chip
:color="lastCheckInResult?.success && infoAction === 'checkin' ? 'white' : 'white'"
:text-color="lastCheckInResult?.success && infoAction === 'checkin' ? 'success' : lastCheckInResult?.status === 'NOT_ALLOWED' ? 'orange' : 'error'"
size="small"
class="font-weight-bold"
>
<v-icon start size="16">
{{ lastCheckInResult?.success && infoAction === 'checkin' ? 'mdi-check' : lastCheckInResult?.status === 'NOT_ALLOWED' ? 'mdi-clock-alert' : 'mdi-close-circle' }}
</v-icon>
{{ lastCheckInResult?.success && infoAction === 'checkin'
? 'Berhasil'
: lastCheckInResult?.status === 'NOT_ALLOWED'
? 'Menunggu'
: 'Gagal' }}
</v-chip>
</div>
</div>
<v-card-text class="pa-8">
<div class="message-container">
<div class="message-icon mb-4">
<v-icon :color="lastCheckInResult?.success && infoAction === 'checkin' ? 'success' : lastCheckInResult?.status === 'NOT_ALLOWED' ? 'orange' : 'error'" size="32">
{{ lastCheckInResult?.success && infoAction === 'checkin' ? 'mdi-check-circle' : lastCheckInResult?.status === 'NOT_ALLOWED' ? 'mdi-clock-alert' : 'mdi-close-circle' }}
</v-icon>
</div>
<div class="text-h6 font-weight-bold mb-3 text-center" style="white-space: pre-line;">{{ infoMessage }}</div>
<v-divider class="my-4"></v-divider>
<div v-if="lastCheckInResult" class="instruction-box pa-4 rounded-lg" :class="lastCheckInResult.success && infoAction === 'checkin' ? 'instruction-success' : 'instruction-warning'">
<div class="d-flex align-start">
<v-icon :color="lastCheckInResult.success && infoAction === 'checkin' ? 'success' : 'orange'" class="mr-3 mt-1">
{{ lastCheckInResult.success && infoAction === 'checkin' ? 'mdi-check-circle' : 'mdi-timer-sand' }}
</v-icon>
<div>
<p class="text-body-1 font-weight-medium mb-1">
{{ lastCheckInResult.success && infoAction === 'checkin' ? 'Status Check-in:' : 'Status:' }}
</p>
<p class="text-body-2 text-grey-darken-1">
{{ lastCheckInResult.success && infoAction === 'checkin'
? 'Check-in telah berhasil dilakukan. Pasien dapat melanjutkan ke tahap selanjutnya.'
: lastCheckInResult.status === 'NOT_ALLOWED'
? 'Mohon menunggu hingga antrean Anda dipanggil oleh petugas'
: 'Proses check-in gagal. Silakan coba lagi atau hubungi petugas.' }}
</p>
</div>
</div>
</div>
<!-- Patient Info Card -->
<v-card variant="outlined" class="mt-4 patient-info-card" color="grey-lighten-4">
<v-card-text class="py-3">
<div class="d-flex justify-space-between align-center">
<div class="d-flex align-center">
<v-icon color="primary" class="mr-2 patient-info-icon">mdi-account-circle</v-icon>
<div>
<p class="text-caption patient-info-label mb-0">ID Pasien</p>
<p class="text-body-1 patient-info-value mb-0">{{ scannedData?.split('|')[0] || 'N/A' }}</p>
</div>
</div>
<div class="text-right">
<p class="text-caption patient-info-label mb-0">Waktu Scan</p>
<p class="text-body-2 patient-info-value mb-0">{{ getCurrentTime() }}</p>
</div>
</div>
</v-card-text>
</v-card>
</div>
</v-card-text>
<v-card-actions class="pa-6 pt-0">
<v-row dense>
<v-col v-if="infoAction === 'kembali'" cols="12">
<v-btn
color="grey-darken-1"
class="text-white font-weight-bold text-none dialog-button"
size="x-large"
block
variant="flat"
@click="handleClose"
elevation="0"
prepend-icon="mdi-arrow-left"
>
Kembali
</v-btn>
</v-col>
<template v-else>
<v-col cols="12">
<v-btn
:color="lastCheckInResult?.success && infoAction === 'checkin' ? 'success' : 'primary'"
class="text-white font-weight-bold text-none dialog-button"
size="x-large"
block
variant="flat"
@click="handleClose"
elevation="4"
:prepend-icon="lastCheckInResult?.success && infoAction === 'checkin' ? 'mdi-check' : 'mdi-close'"
>
{{ lastCheckInResult?.success && infoAction === 'checkin' ? 'Tutup' : 'Tutup' }}
</v-btn>
</v-col>
</template>
</v-row>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import type { CheckInResult, InfoAction } from '~/types/checkin'
interface Props {
modelValue: boolean
lastCheckInResult: CheckInResult | null
infoMessage: string
infoAction: InfoAction
scannedData: string | null
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'close': []
}>()
const handleClose = () => {
emit('close')
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>
<style scoped lang="scss">
@import '~/assets/scss/checkin/dialogs';
</style>
+148
View File
@@ -0,0 +1,148 @@
<template>
<div class="header-modern">
<div class="header-content">
<div class="icon-circle">
<v-icon size="36" color="white">mdi-hospital-building</v-icon>
</div>
<div class="header-text">
<h1 class="title-modern">Check-in Pasien</h1>
<p class="subtitle-modern">Sistem Antrean Rumah Sakit</p>
</div>
</div>
<!-- Tabs Minimalis -->
<v-tabs
:model-value="modelValue"
@update:model-value="updateTab"
align-tabs="center"
class="tabs-modern mt-3"
bg-color="transparent"
slider-color="#FB8C00"
height="40"
>
<v-tab value="scan" class="tab-modern">
<v-icon size="20" class="mr-2">mdi-qrcode-scan</v-icon>
<span>Scan QR</span>
</v-tab>
<v-tab value="manual" class="tab-modern">
<v-icon size="20" class="mr-2">mdi-keyboard</v-icon>
<span>Manual</span>
</v-tab>
<v-tab value="generate" class="tab-modern">
<v-icon size="20" class="mr-2">mdi-qrcode</v-icon>
<span>Generate QR</span>
</v-tab>
</v-tabs>
</div>
</template>
<script setup lang="ts">
import type { TabValue } from '~/types/checkin'
interface Props {
modelValue: TabValue
}
interface Emits {
(e: 'update:modelValue', value: TabValue): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const updateTab = (value: TabValue) => {
emit('update:modelValue', value)
}
</script>
<style scoped>
/* Header Modern Minimalis */
.header-modern {
background: linear-gradient(135deg, #1565C0 0%, #0D47A1 100%);
padding: 20px 24px 16px;
position: relative;
overflow: hidden;
}
.header-modern::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: radial-gradient(circle at 50% 0%, rgba(255, 255, 255, 0.1) 0%, transparent 70%);
pointer-events: none;
}
.header-content {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 4px;
}
.icon-circle {
width: 56px;
height: 56px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: center;
border: 2px solid rgba(255, 255, 255, 0.3);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.header-text {
text-align: center;
}
.title-modern {
font-size: 24px;
font-weight: 600;
color: white;
margin: 0;
line-height: 1.2;
}
.subtitle-modern {
font-size: 13px;
color: rgba(255, 255, 255, 0.9);
margin: 2px 0 0;
font-weight: 400;
}
.tabs-modern {
position: relative;
z-index: 1;
}
.tabs-modern :deep(.v-tab) {
color: rgba(255, 255, 255, 0.8) !important;
font-weight: 500;
text-transform: none;
letter-spacing: 0;
min-width: 120px;
transition: all 0.3s ease;
}
.tabs-modern :deep(.v-tab:hover) {
color: rgba(255, 255, 255, 1) !important;
background: rgba(255, 255, 255, 0.1);
border-radius: 8px;
}
.tabs-modern :deep(.v-tab--selected) {
color: white !important;
font-weight: 600;
}
.tabs-modern :deep(.v-slider) {
background-color: #FB8C00 !important;
height: 3px;
border-radius: 2px;
}
</style>
+325
View File
@@ -0,0 +1,325 @@
<script setup lang="ts">
import QRCode from 'qrcode';
import { onMounted } from 'vue';
const props = defineProps<{
patientId: string;
status: string;
generatedQRData: string | null;
statusOptions: Array<{ title: string; value: string }>;
primaryColor: string;
generateRandomPatientId: () => string;
}>();
const emit = defineEmits<{
'update:patientId': [value: string];
'update:status': [value: string];
'generate': [];
'quick-generate': [patientId: string, status: string];
'download': [];
'copy': [];
'share': [];
'generate-random-id': [];
}>();
const localPatientId = computed({
get: () => props.patientId,
set: (value) => emit('update:patientId', value),
});
const localStatus = computed({
get: () => props.status,
set: (value) => emit('update:status', value),
});
const handleGenerate = () => {
emit('generate');
};
const handleQuickGenerate = (status: string) => {
const randomId = props.generateRandomPatientId();
emit('quick-generate', randomId, status);
};
const handleDownload = () => {
emit('download');
};
const handleCopy = () => {
emit('copy');
};
const handleShare = () => {
emit('share');
};
const handleGenerateRandomId = () => {
emit('generate-random-id');
};
const qrContainerRef = ref<HTMLDivElement | null>(null);
// Function to render QR code
const renderQRCode = async (data: string) => {
if (!data) {
return;
}
// Wait for DOM to be ready
await nextTick();
// Wait a bit more to ensure ref is mounted (especially when switching tabs)
await new Promise(resolve => setTimeout(resolve, 150));
if (!qrContainerRef.value) {
console.warn('QR container ref not available yet, retrying...');
// Retry after a short delay
setTimeout(() => {
if (qrContainerRef.value && data) {
renderQRCode(data);
}
}, 300);
return;
}
try {
// Clear previous content
qrContainerRef.value.innerHTML = '';
// Generate QR code
const qrDataUrl = await QRCode.toDataURL(data, {
errorCorrectionLevel: 'M',
type: 'image/png',
quality: 0.92,
margin: 1,
color: {
dark: '#000000',
light: '#FFFFFF',
},
width: 300,
});
// Create and append image
const img = document.createElement('img');
img.src = qrDataUrl;
img.alt = 'QR Code';
img.style.width = '100%';
img.style.maxWidth = '300px';
img.style.height = 'auto';
img.style.display = 'block';
img.style.margin = '0 auto';
qrContainerRef.value.appendChild(img);
} catch (error) {
console.error('Error generating QR code:', error);
}
};
// Watch for generatedQRData changes to render QR code
watch(() => props.generatedQRData, async (newData) => {
if (newData) {
// Use setTimeout to ensure DOM is ready, especially when switching tabs
setTimeout(() => {
renderQRCode(newData);
}, 200);
} else if (qrContainerRef.value) {
// Clear container if data is cleared
qrContainerRef.value.innerHTML = '';
}
}, { immediate: true });
// Also watch for when component is mounted and ref is ready
onMounted(() => {
if (props.generatedQRData) {
// Delay to ensure ref is mounted
setTimeout(() => {
renderQRCode(props.generatedQRData!);
}, 300);
}
});
</script>
<template>
<div class="tab-content">
<!-- Status Header -->
<div class="status-header mb-3">
<div class="status-icon-wrapper">
<v-icon :color="primaryColor" size="24">mdi-qrcode</v-icon>
</div>
<div class="status-text">
<h3 class="status-title">Generate QR Code untuk Testing</h3>
<p class="status-subtitle">Buat QR code yang bisa Anda scan di tab "Scan QR"</p>
</div>
</div>
<!-- Quick Preset Buttons -->
<div class="mb-3">
<p class="text-caption text-grey text-center mb-2">Quick Test QR Codes:</p>
<v-row dense>
<v-col cols="6">
<v-btn
variant="outlined"
color="success"
size="small"
@click="handleQuickGenerate('ALLOWED')"
class="text-none"
block
>
<v-icon start size="16">mdi-check-circle</v-icon>
Test ALLOWED
</v-btn>
</v-col>
<v-col cols="6">
<v-btn
variant="outlined"
color="warning"
size="small"
@click="handleQuickGenerate('NOT_ALLOWED')"
class="text-none"
block
>
<v-icon start size="16">mdi-clock-alert</v-icon>
Test NOT_ALLOWED
</v-btn>
</v-col>
</v-row>
</div>
<!-- Form Generate -->
<v-form @submit.prevent="handleGenerate">
<v-text-field
v-model="localPatientId"
label="ID Pasien"
placeholder="Contoh: P-123456"
prepend-inner-icon="mdi-identifier"
variant="outlined"
:color="primaryColor"
class="input-modern mb-3"
density="comfortable"
clearable
hide-details="auto"
>
<template #append-inner>
<v-btn
icon
size="small"
variant="text"
@click="handleGenerateRandomId"
title="Generate Random ID"
>
<v-icon size="20">mdi-refresh</v-icon>
</v-btn>
</template>
</v-text-field>
<v-select
v-model="localStatus"
label="Status Check-in"
:items="statusOptions"
prepend-inner-icon="mdi-shield-check"
variant="outlined"
:color="primaryColor"
class="input-modern mb-4"
density="comfortable"
hide-details="auto"
></v-select>
<div class="d-flex justify-center">
<v-btn
class="btn-primary-modern btn-centered"
size="large"
type="submit"
elevation="0"
:disabled="!localPatientId"
>
<v-icon start size="20">mdi-qrcode-plus</v-icon>
Generate QR Code
</v-btn>
</div>
</v-form>
<!-- QR Code Display -->
<div v-if="props.generatedQRData" class="qr-display mt-6">
<v-card variant="outlined" class="pa-4">
<div class="text-center">
<p class="text-subtitle-2 text-grey mb-3">QR Code Anda:</p>
<div ref="qrContainerRef" class="qr-code-container mb-4"></div>
<v-chip :color="localStatus === 'ALLOWED' ? 'success' : 'warning'" class="mb-3">
<v-icon start>{{ localStatus === 'ALLOWED' ? 'mdi-check' : 'mdi-clock-alert' }}</v-icon>
{{ localStatus === 'ALLOWED' ? 'Diizinkan Check-in' : 'Belum Diizinkan' }}
</v-chip>
<p class="text-body-2 text-grey mb-4">
Data: {{ props.generatedQRData }}
</p>
<!-- Action Buttons -->
<v-row dense>
<v-col cols="4">
<v-btn
variant="outlined"
:color="primaryColor"
block
size="small"
@click="handleDownload"
class="text-none"
>
<v-icon start size="18">mdi-download</v-icon>
Download
</v-btn>
</v-col>
<v-col cols="4">
<v-btn
variant="outlined"
:color="primaryColor"
block
size="small"
@click="handleCopy"
class="text-none"
>
<v-icon start size="18">mdi-content-copy</v-icon>
Copy
</v-btn>
</v-col>
<v-col cols="4">
<v-btn
variant="outlined"
:color="primaryColor"
block
size="small"
@click="handleShare"
class="text-none"
>
<v-icon start size="18">mdi-share-variant</v-icon>
Share
</v-btn>
</v-col>
</v-row>
</div>
</v-card>
<!-- Instructions -->
<v-alert
type="success"
variant="tonal"
class="mt-4 text-body-2"
>
<template #prepend>
<v-icon>mdi-information</v-icon>
</template>
<strong>Cara menggunakan untuk Testing:</strong>
<ol class="ml-4 mt-2">
<li>Gunakan tombol <strong>"Test ALLOWED"</strong> atau <strong>"Test NOT_ALLOWED"</strong> untuk generate QR cepat, atau isi form manual</li>
<li>Klik <strong>"Download"</strong> untuk menyimpan QR code ke komputer</li>
<li>Buka file QR code yang didownload (bisa di HP atau layar lain)</li>
<li>Pindah ke tab <strong>"Scan QR"</strong> dan scan QR code tersebut</li>
<li>Atau gunakan <strong>"Copy"</strong> untuk menyalin QR ke clipboard dan paste di aplikasi lain</li>
</ol>
</v-alert>
</div>
</div>
</template>
<style scoped lang="scss">
@import '~/assets/scss/checkin/components';
</style>
+214
View File
@@ -0,0 +1,214 @@
<template>
<v-dialog
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
max-width="800"
persistent
transition="dialog-transition"
scrim="rgba(0, 0, 0, 0.5)"
class="blur-dialog"
>
<v-card class="rounded-xl dialog-card" elevation="24">
<div class="dialog-header text-center pa-6" style="background: linear-gradient(135deg, #1565C0 0%, #0D47A1 100%);">
<h2 class="text-h5 font-weight-bold text-white mb-2">
<v-icon color="white" class="mr-2">mdi-history</v-icon>
Riwayat Check-in
</h2>
<p class="text-body-2 text-white opacity-90">Daftar check-in yang telah dilakukan</p>
</div>
<v-card-text class="pa-6">
<!-- Filter dan Search -->
<div class="mb-4">
<v-row dense>
<v-col cols="12" md="4">
<v-text-field
:model-value="search"
@update:model-value="$emit('update:search', $event)"
label="Cari ID Pasien atau Nomor Antrean"
prepend-inner-icon="mdi-magnify"
variant="outlined"
density="comfortable"
clearable
hide-details
></v-text-field>
</v-col>
<v-col cols="12" md="3">
<v-text-field
:model-value="dateFilter"
@update:model-value="$emit('update:dateFilter', $event || '')"
type="date"
label="Filter Tanggal"
variant="outlined"
density="comfortable"
clearable
hide-details
class="date-picker-field"
></v-text-field>
</v-col>
<v-col cols="12" md="2">
<v-select
:model-value="statusFilter"
@update:model-value="$emit('update:statusFilter', $event)"
label="Filter Status"
:items="statusOptions"
variant="outlined"
density="comfortable"
hide-details
clearable
></v-select>
</v-col>
<v-col cols="12" md="3">
<v-btn
color="error"
variant="outlined"
block
@click="$emit('clear')"
:disabled="history.length === 0"
>
<v-icon start>mdi-delete</v-icon>
Hapus Semua
</v-btn>
</v-col>
</v-row>
</div>
<!-- History List -->
<div v-if="filteredHistory.length > 0" class="history-list">
<v-card
v-for="(item, index) in filteredHistory"
:key="index"
variant="outlined"
class="mb-3 history-item"
:class="getStatusClass(item.status)"
>
<v-card-text class="pa-4">
<div class="d-flex justify-space-between align-start">
<div class="flex-grow-1">
<div class="d-flex align-center mb-2">
<v-chip
:color="getStatusColor(item.status)"
size="small"
class="mr-2"
>
<v-icon start size="16">{{ getStatusIcon(item.status) }}</v-icon>
{{ getStatusText(item.status) }}
</v-chip>
<v-chip
color="grey-lighten-1"
size="x-small"
variant="text"
>
{{ item.method }}
</v-chip>
</div>
<div class="mb-2">
<p class="text-body-1 font-weight-bold mb-1">
<v-icon size="18" class="mr-1" :color="primaryColor">mdi-account-circle</v-icon>
ID Pasien: {{ item.patientId }}
</p>
<p v-if="item.queueNumber" class="text-body-2 text-grey mb-1">
<v-icon size="16" class="mr-1">mdi-ticket</v-icon>
Nomor Antrean: {{ item.queueNumber }}
</p>
</div>
<div class="d-flex flex-wrap gap-2">
<v-chip
size="x-small"
variant="outlined"
color="grey-darken-1"
>
<v-icon start size="14">mdi-clock-outline</v-icon>
{{ formatDateTime(item.checkInTime) }}
</v-chip>
<v-chip
v-if="item.checkInDate"
size="x-small"
variant="outlined"
color="grey-darken-1"
>
<v-icon start size="14">mdi-calendar</v-icon>
{{ formatDate(item.checkInDate) }}
</v-chip>
</div>
</div>
<div class="ml-4">
<v-btn
icon
size="small"
variant="text"
color="error"
@click="$emit('delete-item', index)"
>
<v-icon>mdi-delete-outline</v-icon>
</v-btn>
</div>
</div>
</v-card-text>
</v-card>
</div>
<!-- Empty State -->
<div v-else class="text-center py-12">
<v-icon size="64" color="grey-lighten-1" class="mb-4">mdi-history</v-icon>
<p class="text-h6 text-grey mb-2">Belum ada riwayat check-in</p>
<p class="text-body-2 text-grey">Riwayat check-in akan muncul di sini setelah Anda melakukan check-in</p>
</div>
</v-card-text>
<v-card-actions class="pa-6 pt-0">
<v-spacer></v-spacer>
<v-btn
color="primary"
class="text-white font-weight-bold text-none"
size="large"
variant="flat"
@click="$emit('update:modelValue', false)"
prepend-icon="mdi-close"
>
Tutup
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import type { CheckInHistoryItem, HistoryStatus } from '~/types/checkin'
interface Props {
modelValue: boolean
search: string
dateFilter: string
statusFilter: HistoryStatus | ''
filteredHistory: CheckInHistoryItem[]
history: CheckInHistoryItem[] | ReadonlyArray<CheckInHistoryItem>
statusOptions: Array<{ title: string; value: string }>
primaryColor: string
getStatusColor: (status: string) => string
getStatusIcon: (status: string) => string
getStatusText: (status: string) => string
getStatusClass: (status: string) => string
formatDateTime: (dateString: string) => string
formatDate: (dateString: string) => string
}
defineProps<Props>()
defineEmits<{
'update:modelValue': [value: boolean]
'update:search': [value: string]
'update:dateFilter': [value: string]
'update:statusFilter': [value: HistoryStatus | '']
'delete-item': [index: number]
'clear': []
}>()
</script>
<style scoped lang="scss">
@import '~/assets/scss/checkin/components';
@import '~/assets/scss/checkin/dialogs';
</style>
+104
View File
@@ -0,0 +1,104 @@
<script setup lang="ts">
const props = defineProps<{
modelValue: string;
primaryColor: string;
secondaryColor: string;
}>();
const emit = defineEmits<{
'update:modelValue': [value: string];
'submit': [];
'open-history': [];
}>();
const manualForm = ref<{ validate: () => boolean; resetValidation: () => void; reset: () => void } | null>(null);
const localValue = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
});
const handleSubmit = () => {
emit('submit');
};
const handleOpenHistory = () => {
emit('open-history');
};
// Expose form ref for parent component
defineExpose({
form: manualForm,
});
</script>
<template>
<div class="tab-content">
<!-- Status Header -->
<div class="status-header mb-3">
<div class="status-icon-wrapper">
<v-icon :color="secondaryColor" size="24">mdi-keyboard</v-icon>
</div>
<div class="status-text">
<h3 class="status-title">Input Manual</h3>
<p class="status-subtitle">Masukkan nomor antrean atau ID pasien</p>
</div>
</div>
<v-form @submit.prevent="handleSubmit" ref="manualForm">
<v-text-field
v-model="localValue"
label="Nomor Antrean / ID Pasien"
placeholder="Contoh: P12345"
prepend-inner-icon="mdi-account-card-details"
variant="outlined"
:color="primaryColor"
class="input-modern mb-4"
required
:rules="[v => !!v || 'Field ini wajib diisi']"
density="comfortable"
hide-details="auto"
>
<template v-slot:append-inner>
<v-icon :color="localValue ? 'success' : 'grey'" size="20">
{{ localValue ? 'mdi-check-circle' : 'mdi-circle-outline' }}
</v-icon>
</template>
</v-text-field>
<v-btn
class="btn-primary-modern btn-centered"
size="large"
type="submit"
elevation="0"
>
<v-icon start size="20">mdi-login</v-icon>
Check-in Sekarang
</v-btn>
</v-form>
<!-- Quick Access Buttons -->
<div class="quick-actions mt-4">
<p class="text-caption text-grey text-center mb-3">Akses Cepat</p>
<v-row dense>
<v-col cols="12">
<v-btn
variant="outlined"
:color="primaryColor"
block
size="small"
class="text-none"
@click="handleOpenHistory"
>
<v-icon start size="18">mdi-history</v-icon>
Riwayat
</v-btn>
</v-col>
</v-row>
</div>
</div>
</template>
<style scoped lang="scss">
@import '~/assets/scss/checkin/components';
</style>
+179
View File
@@ -0,0 +1,179 @@
<template>
<v-dialog
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
max-width="800"
persistent
transition="dialog-transition"
scrim="rgba(0, 0, 0, 0.5)"
class="blur-dialog"
>
<v-card class="rounded-xl dialog-card" elevation="24">
<div class="dialog-header text-center pa-6" style="background: linear-gradient(135deg, #FB8C00 0%, #F57C00 100%);">
<h2 class="text-h5 font-weight-bold text-white mb-2">
<v-icon color="white" class="mr-2">mdi-qrcode-scan</v-icon>
Riwayat QR Scan
</h2>
<p class="text-body-2 text-white opacity-90">Daftar QR code yang telah di-scan</p>
</div>
<v-card-text class="pa-6">
<!-- Filter dan Search -->
<div class="mb-4">
<v-row dense>
<v-col cols="12" md="5">
<v-text-field
:model-value="search"
@update:model-value="$emit('update:search', $event)"
label="Cari Data QR Code"
prepend-inner-icon="mdi-magnify"
variant="outlined"
density="comfortable"
clearable
hide-details
></v-text-field>
</v-col>
<v-col cols="12" md="4">
<v-text-field
:model-value="dateFilter"
@update:model-value="$emit('update:dateFilter', $event || '')"
type="date"
label="Filter Tanggal"
variant="outlined"
density="comfortable"
clearable
hide-details
class="date-picker-field"
></v-text-field>
</v-col>
<v-col cols="12" md="3">
<v-btn
color="error"
variant="outlined"
block
@click="$emit('clear')"
:disabled="history.length === 0"
>
<v-icon start>mdi-delete</v-icon>
Hapus Semua
</v-btn>
</v-col>
</v-row>
</div>
<!-- QR History List -->
<div v-if="filteredHistory.length > 0" class="history-list">
<v-card
v-for="(item, index) in filteredHistory"
:key="index"
variant="outlined"
class="mb-3 history-item"
>
<v-card-text class="pa-4">
<div class="d-flex justify-space-between align-start">
<div class="flex-grow-1">
<div class="mb-2">
<p class="text-body-1 font-weight-bold mb-1">
<v-icon size="18" class="mr-1" :color="primaryColor">mdi-qrcode</v-icon>
Data QR: {{ item.data }}
</p>
</div>
<div class="d-flex flex-wrap gap-2">
<v-chip
size="x-small"
variant="outlined"
color="grey-darken-1"
>
<v-icon start size="14">mdi-clock-outline</v-icon>
{{ item.time }}
</v-chip>
<v-chip
size="x-small"
variant="outlined"
color="grey-darken-1"
>
<v-icon start size="14">mdi-calendar</v-icon>
{{ item.date }}
</v-chip>
</div>
</div>
<div class="ml-4">
<v-btn
icon
size="small"
variant="text"
color="primary"
@click="$emit('use-data', item.data)"
>
<v-icon>mdi-refresh</v-icon>
</v-btn>
<v-btn
icon
size="small"
variant="text"
color="error"
@click="$emit('delete-item', index)"
>
<v-icon>mdi-delete-outline</v-icon>
</v-btn>
</div>
</div>
</v-card-text>
</v-card>
</div>
<!-- Empty State -->
<div v-else class="text-center py-12">
<v-icon size="64" color="grey-lighten-1" class="mb-4">mdi-qrcode-scan</v-icon>
<p class="text-h6 text-grey mb-2">Belum ada riwayat QR scan</p>
<p class="text-body-2 text-grey">Riwayat QR scan akan muncul di sini setelah Anda melakukan scan</p>
</div>
</v-card-text>
<v-card-actions class="pa-6 pt-0">
<v-spacer></v-spacer>
<v-btn
color="primary"
class="text-white font-weight-bold text-none"
size="large"
variant="flat"
@click="$emit('update:modelValue', false)"
prepend-icon="mdi-close"
>
Tutup
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import type { ScannedQRHistoryItem } from '~/types/checkin'
interface Props {
modelValue: boolean
search: string
dateFilter: string
filteredHistory: ScannedQRHistoryItem[]
history: ScannedQRHistoryItem[] | ReadonlyArray<ScannedQRHistoryItem>
primaryColor: string
}
defineProps<Props>()
defineEmits<{
'update:modelValue': [value: boolean]
'update:search': [value: string]
'update:dateFilter': [value: string]
'use-data': [data: string]
'delete-item': [index: number]
'clear': []
}>()
</script>
<style scoped lang="scss">
@import '~/assets/scss/checkin/components';
@import '~/assets/scss/checkin/dialogs';
</style>
+218
View File
@@ -0,0 +1,218 @@
<script setup lang="ts">
const props = defineProps<{
isScanning: boolean;
hasCamera: boolean;
cameraChecking: boolean;
cameraReady: boolean;
primaryColor: string;
}>();
const emit = defineEmits<{
'start-scanning': [];
'stop-scanning': [];
'test-camera': [];
'open-history': [];
'open-qr-history': [];
}>();
const handleStartScanning = () => {
emit('start-scanning');
};
const handleStopScanning = () => {
emit('stop-scanning');
};
const handleTestCamera = () => {
emit('test-camera');
};
const handleOpenHistory = () => {
emit('open-history');
};
const handleOpenQRHistory = () => {
emit('open-qr-history');
};
</script>
<template>
<div class="tab-content">
<!-- Status Header -->
<div class="status-header mb-3">
<div class="status-icon-wrapper">
<v-icon :color="primaryColor" size="24">mdi-qrcode-scan</v-icon>
</div>
<div class="status-text">
<h3 class="status-title">
{{ isScanning ? 'Arahkan Kamera ke QR Code' : 'Siap untuk Scan QR Code' }}
</h3>
<p class="status-subtitle">
{{ isScanning ? 'Pastikan QR code terlihat jelas dan tidak terpotong' : 'Klik tombol di bawah untuk memulai scan' }}
</p>
</div>
</div>
<!-- Camera Status Check -->
<div v-if="cameraChecking" class="text-center mb-4">
<v-progress-circular
indeterminate
:color="primaryColor"
size="24"
class="mr-2"
></v-progress-circular>
<span class="text-body-2 text-grey">Memeriksa ketersediaan kamera...</span>
</div>
<!-- Camera Not Available Warning -->
<v-alert
v-else-if="!hasCamera && !isScanning"
type="warning"
variant="tonal"
class="mb-4"
>
<template #prepend>
<v-icon>mdi-camera-off</v-icon>
</template>
<div>
<p class="font-weight-bold mb-1">Kamera tidak terdeteksi</p>
<p class="text-body-2 mb-0">
Perangkat Anda tidak memiliki kamera atau kamera tidak dapat diakses.
Silakan gunakan tab <strong>Manual</strong> untuk input data secara manual.
</p>
</div>
</v-alert>
<!-- QR Scanner Area dengan webcam -->
<div class="qr-scanner-container mb-4">
<div v-if="!isScanning" class="qr-placeholder">
<div class="scanner-overlay">
<div class="corner corner-tl"></div>
<div class="corner corner-tr"></div>
<div class="corner corner-bl"></div>
<div class="corner corner-br"></div>
<div class="scan-line"></div>
</div>
<v-icon size="64" :color="primaryColor" class="qr-icon">mdi-qrcode-scan</v-icon>
</div>
<div v-else class="qr-reader-container">
<div class="scanner-status mb-2">
<v-chip color="success" size="small" class="mr-2">
<v-icon start size="16">mdi-camera</v-icon>
Kamera Aktif
</v-chip>
<span class="text-caption text-grey">Preview kamera sedang berjalan</span>
</div>
<div id="qr-reader" class="qr-reader-wrapper">
<div v-if="!cameraReady" class="scanner-loading-overlay">
<v-progress-circular
indeterminate
color="white"
size="48"
></v-progress-circular>
<p class="text-white mt-4">Memuat kamera...</p>
</div>
</div>
<div class="scanner-instruction">
<v-icon :color="primaryColor" size="20" class="mr-2">mdi-information</v-icon>
<span class="text-body-2">Arahkan kamera ke QR code. Pastikan QR code berada dalam kotak pemindaian.</span>
</div>
</div>
</div>
<!-- Action Button Minimalis -->
<div class="action-buttons">
<v-btn
v-if="!isScanning"
class="btn-primary-modern btn-centered"
size="large"
elevation="0"
@click="handleStartScanning"
:disabled="!hasCamera && !cameraChecking"
>
<v-icon start size="20">mdi-camera</v-icon>
{{ hasCamera ? 'Mulai Scan QR' : 'Kamera Tidak Tersedia' }}
</v-btn>
<v-btn
v-else
class="btn-stop-modern btn-centered"
size="large"
elevation="0"
@click="handleStopScanning"
>
<v-icon start size="20">mdi-camera-off</v-icon>
Stop Scan
</v-btn>
</div>
<!-- Info tambahan -->
<div class="info-card mt-3">
<v-alert
type="info"
variant="tonal"
:color="primaryColor"
class="text-body-2 info-alert-centered"
density="compact"
>
<div class="d-flex align-center justify-center">
<v-icon size="16" class="mr-2">mdi-lightbulb-outline</v-icon>
<span style="font-size: 12px;">Tips: Pastikan pencahayaan cukup untuk hasil scan optimal</span>
</div>
</v-alert>
</div>
<!-- Test Camera Button (Debug) -->
<div class="test-camera-section mt-2 mb-2">
<v-btn
variant="outlined"
color="primary"
size="small"
class="text-none btn-centered-small btn-test-camera"
@click="handleTestCamera"
>
<v-icon start size="18" color="#1565C0">mdi-camera</v-icon>
Test Kamera
</v-btn>
<p class="text-caption text-grey text-center mt-1" style="font-size: 11px;">
Klik untuk menguji apakah browser dapat mengakses kamera
</p>
</div>
<!-- Quick Access Buttons -->
<div class="quick-actions mt-4">
<p class="text-caption text-grey text-center mb-3">Akses Cepat</p>
<v-row dense>
<v-col cols="12">
<v-btn
variant="outlined"
:color="primaryColor"
block
size="small"
class="text-none"
@click="handleOpenHistory"
>
<v-icon start size="18">mdi-history</v-icon>
Riwayat Check-in
</v-btn>
</v-col>
<v-col cols="12">
<v-btn
variant="outlined"
color="primary"
block
size="small"
class="text-none"
@click="handleOpenQRHistory"
>
<v-icon start size="18" color="#1565C0">mdi-qrcode-scan</v-icon>
Riwayat QR Scan
</v-btn>
</v-col>
</v-row>
</div>
</div>
</template>
<style scoped lang="scss">
@import '~/assets/scss/checkin/components';
</style>
+146
View File
@@ -0,0 +1,146 @@
<template>
<div class="stats-footer-modern mt-3">
<v-row dense>
<v-col cols="4">
<div class="stat-card-modern">
<div class="stat-icon-modern">
<v-icon :color="primaryColor" size="20">mdi-clock-outline</v-icon>
</div>
<div class="stat-value">{{ todayCount }}</div>
<div class="stat-label">Hari Ini</div>
</div>
</v-col>
<v-col cols="4">
<div class="stat-card-modern">
<div class="stat-icon-modern">
<v-icon color="success" size="20">mdi-check-circle</v-icon>
</div>
<div class="stat-value">{{ completedCount }}</div>
<div class="stat-label">Selesai</div>
</div>
</v-col>
<v-col cols="4">
<div class="stat-card-modern">
<div class="stat-icon-modern">
<v-icon :color="secondaryColor" size="20">mdi-account-group</v-icon>
</div>
<div class="stat-value">{{ pendingCount }}</div>
<div class="stat-label">Menunggu</div>
</div>
</v-col>
</v-row>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { CheckInHistoryItem } from '~/types/checkin'
import { PRIMARY_COLOR, SECONDARY_COLOR } from '~/constants/checkin'
interface Props {
history: CheckInHistoryItem[]
primaryColor?: string
secondaryColor?: string
}
const props = withDefaults(defineProps<Props>(), {
primaryColor: PRIMARY_COLOR,
secondaryColor: SECONDARY_COLOR,
})
// Computed untuk menghitung stats
const todayCount = computed(() => {
const today = new Date().toDateString()
return props.history.filter(item => {
const itemDate = new Date(item.checkInDate).toDateString()
return itemDate === today
}).length
})
const completedCount = computed(() => {
return props.history.filter(item =>
item.status === 'success' || item.status === 'ALLOWED'
).length
})
const pendingCount = computed(() => {
return props.history.filter(item =>
item.status === 'pending' || item.status === 'NOT_ALLOWED'
).length
})
</script>
<style scoped>
/* Stats Footer Modern */
.stats-footer-modern {
animation: slideUp 0.5s ease-out 0.3s both;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.stat-card-modern {
background: white;
border-radius: 12px;
padding: 16px 12px;
text-align: center;
border: 1px solid #e5e7eb;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
}
.stat-card-modern::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, #1565C0 0%, #0D47A1 100%);
transform: scaleX(0);
transition: transform 0.3s ease;
}
.stat-card-modern:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
border-color: #1565C0;
}
.stat-card-modern:hover::before {
transform: scaleX(1);
}
.stat-icon-modern {
margin-bottom: 12px;
display: inline-flex;
padding: 8px;
background: #e3f2fd;
border-radius: 10px;
}
.stat-value {
font-size: 20px;
font-weight: 700;
color: #1a1a1a;
margin-bottom: 4px;
letter-spacing: -0.5px;
}
.stat-label {
font-size: 11px;
color: #6b7280;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
}
</style>
+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-left">
<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 class="header-text">
<h2 class="page-title">{{ title }}</h2>
@@ -33,6 +42,10 @@ defineProps({
type: String,
default: 'mdi-view-dashboard'
},
logo: {
type: String,
default: ''
},
title: {
type: String,
required: true
@@ -51,8 +64,8 @@ defineProps({
},
theme: {
type: String,
default: 'primary', // 'primary', 'secondary', 'success', or 'accent'
validator: (value) => ['primary', 'secondary', 'success', 'accent'].includes(value)
default: 'primary', // 'primary', 'secondary', 'success', 'accent', or 'warning'
validator: (value) => ['primary', 'secondary', 'success', 'accent', 'warning'].includes(value)
}
});
@@ -60,49 +73,48 @@ defineEmits(['add-click']);
</script>
<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-weight-regular: 400;
$font-weight-semibold: 600;
.page-header {
border-radius: 16px 16px 0 0;
border-radius: 0;
}
.page-header-primary {
background: linear-gradient(135deg, $primary-600 0%, $primary-700 100%);
box-shadow: 0 4px 16px rgba(255, 165, 50, 0.2);
background: linear-gradient(135deg, var(--color-primary-600) 0%, var(--color-primary-700) 100%);
box-shadow: 0 4px 16px rgba(58, 97, 201, 0.2);
}
.page-header-secondary {
background: linear-gradient(135deg, $secondary-600 0%, $secondary-700 100%);
box-shadow: 0 4px 16px rgba(6, 113, 224, 0.2);
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);
}
.page-header-success {
background: linear-gradient(135deg, $success-600 0%, $success-700 100%);
box-shadow: 0 4px 16px rgba(0, 146, 98, 0.2);
background: linear-gradient(135deg, var(--color-success-600) 0%, var(--color-success-700) 100%);
box-shadow: 0 4px 16px rgba(51, 164, 132, 0.2);
}
.page-header-accent {
background: linear-gradient(135deg, $accent-600 0%, $accent-700 100%);
box-shadow: 0 4px 16px rgba(139, 92, 246, 0.3);
// Accent tidak ada di design system, menggunakan secondary sebagai fallback
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 {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32px;
color: $neutral-100;
padding: 16px 28px;
height: 80px;
color: var(--color-neutral-100);
}
.header-left {
@@ -112,28 +124,41 @@ $font-weight-semibold: 600;
.header-icon {
background: rgba(255, 255, 255, 0.2);
border-radius: 16px;
padding: 16px;
margin-right: 20px;
border-radius: 12px;
padding: 12px;
margin-right: 16px;
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 {
font-size: 36px;
line-height: 44px;
font-size: 32px;
line-height: 40px;
font-weight: $font-weight-semibold;
margin: 0;
color: $neutral-100;
color: var(--color-neutral-100);
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.page-subtitle {
margin: 4px 0 0 0;
margin: 2px 0 0 0;
opacity: 0.9;
font-size: 16px;
line-height: 24px;
font-size: 15px;
line-height: 22px;
font-weight: $font-weight-regular;
color: $neutral-100;
color: var(--color-neutral-100);
}
.add-btn {
@@ -145,18 +170,24 @@ $font-weight-semibold: 600;
}
.add-btn-primary {
color: $primary-600 !important;
color: var(--color-primary-600) !important;
}
.add-btn-secondary {
color: $secondary-600 !important;
color: var(--color-secondary-600) !important;
}
.add-btn-success {
color: $success-600 !important;
color: var(--color-success-600) !important;
}
.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>
+48 -16
View File
@@ -1,14 +1,16 @@
<template>
<v-dialog v-model="dialogModel" max-width="800px">
<v-card>
<v-dialog v-model="dialogModel" max-width="800px" persistent>
<v-card class="dialog-card">
<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-btn>
<span>{{ title }}</span>
</v-card-title>
<v-card-text class="pa-4">
<v-divider />
<v-card-text class="dialog-content">
<v-text-field
v-model="searchModel"
:placeholder="searchPlaceholder"
@@ -94,32 +96,62 @@ const handleSelect = (item) => {
</script>
<style scoped lang="scss">
.dialog-card {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.dialog-header {
background: var(--color-neutral-300);
padding: 16px 20px;
background: linear-gradient(135deg, var(--color-secondary-600) 0%, var(--color-secondary-700) 100%);
color: var(--color-neutral-100);
padding: 20px 24px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
font-size: 18px;
font-weight: 700;
color: var(--color-neutral-900);
}
.headline-4 {
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 {
cursor: pointer;
transition: all 0.2s;
border: 2px solid var(--color-neutral-500);
transition: all 0.2s ease;
border: 1px solid var(--color-neutral-400);
background: var(--color-neutral-100);
border-radius: 8px;
}
.selection-card:hover {
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 {
font-size: 13px;
font-weight: 700;
font-size: 14px;
line-height: 20px;
font-weight: 600;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
color: var(--color-neutral-900);
}
</style>
@@ -11,7 +11,7 @@
</v-card-title>
<v-card-text class="pa-4">
<!-- Patient Info Card -->
<!-- patient info card -->
<div v-if="patient" class="patient-card mb-4">
<h4 class="card-title">Informasi Pasien</h4>
<v-row>
@@ -26,7 +26,7 @@
</v-row>
</div>
<!-- Search Field -->
<!-- search field -->
<v-text-field
v-model="searchModel"
prepend-inner-icon="mdi-magnify"
@@ -37,7 +37,7 @@
class="mb-4 search-field"
/>
<!-- Options Grid -->
<!-- options grid -->
<v-row>
<v-col
v-for="option in filteredOptions"
@@ -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']"
hide-details="auto"
class="mb-3 input-field"
placeholder="Loket 1"
placeholder="Loket A"
/>
<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>
+421 -40
View File
@@ -1,45 +1,150 @@
<template>
<v-card class="current-patient-card" elevation="0">
<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 class="patient-number mb-2">{{ patient.noAntrian.split(" |")[0] }}</div>
<div class="patient-info-text mb-3">
<div>{{ patient.barcode }}</div>
<div>{{ patient.klinik }} | {{ patient.pembayaran }}</div>
<div class="patient-header">
<div class="patient-number-wrapper">
<div class="patient-number">{{ patient.noAntrian.split(" |")[0] }}</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 class="action-grid">
<v-btn class="text-white" color="success-600" variant="flat" block size="large" @click="$emit('action', 'check-in')">
<v-icon start size="20">mdi-check</v-icon>
Selesai
</v-btn>
<v-btn class="text-white" color="primary-600" variant="flat" block size="large" @click="$emit('action', 'terlambat')">
<v-icon start size="20">mdi-clock-alert</v-icon>
Terlambat
</v-btn>
<v-btn class="text-white" color="danger-600" variant="flat" block size="large" @click="$emit('action', 'pending')">
<v-icon start size="20">mdi-pause</v-icon>
Pending
</v-btn>
<v-btn class="text-white" color="secondary-600" variant="flat" block size="large" @click="$emit('change-klinik')">
<v-icon start size="20">mdi-swap-horizontal</v-icon>
{{ changeButtonText }}
</v-btn>
<div class="create-queue-buttons mt-4">
<div class="create-buttons-container">
<v-btn
class="create-btn-ruang py-6 text-white"
color="primary-600"
:disabled="!patient"
@click="$emit('open-klinik-ruang')"
>
<v-icon size="20" class="mr-2">mdi-door</v-icon>
Buat Antrean Ruang
</v-btn>
<v-btn
class="create-btn-penunjang py-6 text-white"
color="secondary-600"
:disabled="!patient"
@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 v-else class="empty-state">
<v-icon size="48" color="grey-lighten-2">mdi-account-off-outline</v-icon>
<div class="empty-text">Tidak ada pasien yang diproses</div>
<div class="empty-text mb-4">Tidak ada pasien yang diproses</div>
<v-btn
block
class="py-6 text-white"
color="primary-600"
size="large"
:disabled="!hasNextQueue"
@click="$emit('process-next')"
>
<v-icon start>mdi-play-circle</v-icon>
Proses Antrian Berikutnya
</v-btn>
<div v-if="nextQueueInfo" class="next-queue-info mt-3">
<div class="info-text">{{ nextQueueInfo }}</div>
</div>
<div v-else-if="!hasNextQueue" class="no-next-queue mt-3">
<div class="info-text">Tidak ada antrian di loket</div>
</div>
</div>
</v-card-text>
</v-card>
</template>
<script setup>
defineProps({
import { computed } from 'vue';
const props = defineProps({
patient: {
type: Object,
default: null
@@ -52,10 +157,24 @@ defineProps({
changeButtonText: {
type: String,
default: 'Ubah Klinik'
},
hasNextQueue: {
type: Boolean,
default: false
},
nextQueueInfo: {
type: String,
default: null
}
});
defineEmits(['action', 'change-klinik']);
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>
<style scoped lang="scss">
@@ -65,6 +184,12 @@ defineEmits(['action', 'change-klinik']);
background: var(--color-neutral-100);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.section-label {
font-size: 11px;
font-weight: 700;
@@ -73,13 +198,32 @@ defineEmits(['action', 'change-klinik']);
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 {
border-radius: 8px;
padding: 16px;
padding: 20px;
}
.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 {
@@ -90,24 +234,204 @@ defineEmits(['action', 'change-klinik']);
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 {
font-size: 28px;
font-weight: 800;
font-size: 32px;
line-height: 36px;
font-weight: 700;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
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 {
font-size: 13px;
font-size: 14px;
line-height: 24px;
color: var(--color-neutral-700);
line-height: 1.5;
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 20px;
}
.action-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 12px;
.patient-barcode {
font-size: 14px;
line-height: 20px;
color: var(--color-neutral-700);
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 {
@@ -115,15 +439,72 @@ defineEmits(['action', 'change-klinik']);
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 {
font-size: 13px;
font-size: 14px;
line-height: 20px;
color: var(--color-neutral-600);
margin-top: 8px;
}
.next-queue-info,
.no-next-queue {
text-align: center;
padding: 8px;
background: var(--color-neutral-200);
border-radius: 8px;
}
.info-text {
font-size: 12px;
line-height: 16px;
color: var(--color-neutral-700);
font-weight: 600;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
@media (max-width: 960px) {
.action-grid {
grid-template-columns: 1fr;
.patient-header {
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>
+274 -131
View File
@@ -1,106 +1,112 @@
<template>
<v-card class="patient-card" elevation="0">
<v-card
class="patient-card"
elevation="2"
:class="{
'clickable-card': isClickable,
'fast-track-card': isFastTrack && !isCurrentlyProcessing,
'processing-card': isCurrentlyProcessing || patient.status === 'diproses'
}"
@click="handleCardClick"
>
<v-tooltip
activator="parent"
:text="isClickable ? 'Klik untuk proses pasien' : ''"
location="top"
:disabled="!isClickable"
/>
<v-card-text class="pa-4">
<!-- Header: Queue Number & Status -->
<div class="card-header">
<div class="queue-number">{{ patient.noAntrian.split(" |")[0] }}</div>
<v-chip
:color="getStatusColor(patient.status)"
size="small"
class="status-chip"
>
{{ getStatusLabel(patient.status) }}
</v-chip>
</div>
<!-- Header: Queue Number, Status & Fast Track Badge -->
<div class="card-header">
<div class="header-left">
<div class="queue-number">{{ patient.noAntrian.split(" |")[0] }}</div>
<div
v-if="isFastTrack"
class="fast-track-icon-wrapper"
>
<v-icon
color="#E53935"
size="14"
class="fast-track-icon"
>
mdi-flash
</v-icon>
</div>
</div>
<div class="header-right d-flex align-center" style="gap: 8px;">
<v-chip
:color="getStatusColor(patient.status)"
size="small"
class="status-chip"
>
{{ getStatusLabel(patient.status) }}
</v-chip>
</div>
</div>
<!-- Patient Info Grid -->
<div class="patient-info mt-3">
<div class="info-row">
<span class="info-label">Barcode:</span>
<span class="info-value">{{ patient.barcode }}</span>
</div>
<div class="info-row">
<span class="info-label">Jam Panggil:</span>
<span class="info-value">{{ patient.jamPanggil }}</span>
</div>
<!-- Patient Info - Compact Single Row -->
<div class="patient-info mt-3">
<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 class="info-row">
<span class="info-label">Klinik:</span>
<v-chip size="small" variant="outlined" class="klinik-chip">
{{ patient.klinik }}
</v-chip>
</div>
<div class="info-row">
<span class="info-label">Fast Track:</span>
<span class="info-value">{{ patient.fastTrack }}</span>
</div>
<div class="info-row">
<span class="info-label">Pembayaran:</span>
<span class="info-value">{{ patient.pembayaran }}</span>
</div>
</div>
<!-- Action Button -->
<div class="card-actions mt-4">
<v-btn
v-if="patient.status === 'diloket'"
block
color="primary-600"
variant="flat"
@click="$emit('action', patient, 'proses')"
>
<v-icon start size="18">mdi-account-check</v-icon>
Proses
</v-btn>
<v-btn
v-else-if="patient.status === 'terlambat'"
block
color="success-600"
variant="flat"
class="text-white"
@click="$emit('action', patient, 'aktifkan')"
>
<v-icon start size="18">mdi-check-circle</v-icon>
Aktifkan
</v-btn>
<v-btn
v-else-if="patient.status === 'pending'"
block
color="success-600"
class="text-white"
variant="flat"
@click="$emit('action', patient, 'proses')"
>
<v-icon start size="18">mdi-play-circle</v-icon>
Proses
</v-btn>
</div>
</v-card-text>
<!-- Card Menu (Optional) -->
<v-menu location="bottom end">
<template #activator="{ props }">
<v-btn
icon="mdi-dots-vertical"
variant="text"
size="small"
class="card-menu-btn"
v-bind="props"
/>
</template>
<v-list density="compact">
<v-list-item @click="$emit('view-details', patient)">
<v-list-item-title>Lihat Detail</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<div v-if="!isClickable && patient.status === 'terlambat'" class="card-actions mt-3">
<v-btn
block
color="success-600"
variant="flat"
size="small"
class="text-white"
@click.stop="$emit('action', patient, 'aktifkan')"
>
<v-icon start size="18">mdi-check-circle</v-icon>
Aktifkan
</v-btn>
</div>
</v-card-text>
</v-card>
</template>
<script setup>
import { computed } from 'vue';
import { useQueueStore } from '~/stores/queueStore';
const queueStore = useQueueStore();
const props = defineProps({
patient: {
type: Object,
@@ -108,20 +114,91 @@ const props = defineProps({
}
});
defineEmits(['action', 'view-details']);
const emit = defineEmits(['action']);
const isClickable = computed(() => {
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 = () => {
if (isClickable.value) {
emit('action', props.patient, 'proses');
}
};
const getStatusColor = (status) => {
const colors = {
diloket: "var(--color-secondary-600)",
terlambat: "var(--color-primary-600)",
pending: "var(--color-danger-600)"
'di-loket': "var(--color-primary-600)", // Blue
diproses: "var(--color-primary-600)", // Keep as Blue
terlambat: "var(--color-danger-700)", // Darker Red
pending: "var(--color-secondary-600)" // Orange
};
return colors[status] || "var(--color-neutral-600)";
};
const getStatusLabel = (status) => {
const labels = {
diloket: "Di Loket",
'di-loket': "Di Loket",
diproses: "Diproses",
terlambat: "Terlambat",
pending: "Pending"
};
@@ -137,11 +214,47 @@ const getStatusLabel = (status) => {
background: var(--color-neutral-100);
transition: all 0.2s ease;
height: 100%;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
&.clickable-card {
cursor: pointer;
&:hover {
border-color: var(--color-primary-600);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
}
&:active {
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 {
@@ -149,7 +262,13 @@ const getStatusLabel = (status) => {
justify-content: space-between;
align-items: center;
padding-bottom: 12px;
border-bottom: 1px solid var(--color-neutral-400);
border-bottom: 2px solid var(--color-neutral-400);
}
.header-left {
display: flex;
align-items: center;
gap: 8px;
}
.queue-number {
@@ -158,57 +277,81 @@ const getStatusLabel = (status) => {
color: var(--color-neutral-900);
}
.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 .fast-track-icon {
color: #E53935 !important;
transition: all 0.2s ease;
}
.status-chip {
font-weight: 600;
font-size: 11px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.patient-info {
display: flex;
flex-direction: column;
gap: 10px;
/* Tinggi dinamis berdasarkan konten - card tanpa dokter akan lebih compact */
}
.info-row {
.info-chips-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13px;
}
.info-label {
color: var(--color-neutral-600);
font-weight: 500;
min-width: 100px;
}
.info-value {
color: var(--color-neutral-900);
font-weight: 600;
text-align: right;
gap: 8px;
flex-wrap: wrap;
}
.klinik-chip {
font-size: 11px;
font-weight: 600;
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 {
text-transform: none;
font-weight: 600;
font-size: 13px;
height: 40px;
}
.card-menu-btn {
position: absolute;
top: 8px;
right: 8px;
opacity: 0;
transition: opacity 0.2s ease;
}
.patient-card:hover .card-menu-btn {
opacity: 1;
height: 36px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
</style>
+95 -11
View File
@@ -12,6 +12,10 @@
<span class="quota-label">Tersedia</span>
<span class="quota-value quota-available">{{ availableQuota }}</span>
</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">
<v-progress-linear
:model-value="quotaPercentage"
@@ -20,7 +24,8 @@
rounded
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>
@@ -29,7 +34,7 @@
color="success-600"
variant="outlined"
@click="$emit('call', 1)"
:disabled="!hasNext"
>
1
</v-btn>
@@ -53,6 +58,10 @@ const props = defineProps({
type: Number,
default: 0
},
menungguCount: {
type: Number,
default: 0
},
hasNext: {
type: Boolean,
default: false
@@ -61,6 +70,11 @@ const props = defineProps({
const availableQuota = computed(() => props.totalQuota - props.usedQuota);
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']);
</script>
@@ -70,6 +84,18 @@ defineEmits(['call']);
border-radius: 12px;
border: 1px solid var(--color-neutral-500);
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 {
@@ -81,20 +107,37 @@ defineEmits(['call']);
}
.quota-info {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
display: grid !important;
grid-template-columns: 1fr 1fr 1fr !important;
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 {
background: var(--color-neutral-300);
border-radius: 8px;
padding: 12px;
text-align: center;
background: var(--color-neutral-300) !important;
border-radius: 8px !important;
padding: 10px 6px !important;
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 {
grid-column: 1 / -1;
grid-column: 1 / 4 !important;
margin-top: 8px !important;
}
.quota-label {
@@ -107,15 +150,29 @@ defineEmits(['call']);
.quota-value {
display: block;
font-size: 24px;
font-size: 22px;
font-weight: 800;
color: var(--color-neutral-900);
line-height: 1.2;
white-space: nowrap;
}
.quota-available {
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 {
display: block;
font-size: 11px;
@@ -131,13 +188,40 @@ defineEmits(['call']);
.call-buttons .v-btn {
font-size: 16px;
line-height: 24px;
font-weight: 700;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
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) {
.call-buttons {
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>
+335 -42
View File
@@ -6,6 +6,7 @@
<div class="section-label">DATA PASIEN</div>
<div class="filters">
<!-- Status Filter -->
<v-chip-group v-model="selectedStatusModel" mandatory class="status-filter">
<v-chip
v-for="status in statusOptions"
@@ -13,36 +14,136 @@
:value="status.value"
:class="{ 'active-chip': selectedStatusModel === status.value }"
>
<v-icon v-if="status.icon" start size="16">{{ status.icon }}</v-icon>
{{ status.label }} ({{ status.count }})
</v-chip>
</v-chip-group>
</div>
<v-text-field
v-model="searchModel"
placeholder="Cari barcode, nomor antrian..."
<!-- Advanced Filters -->
<div class="advanced-filters mt-3">
<v-select
v-model="selectedKlinik"
:items="klinikOptions"
label="Filter Klinik"
density="compact"
hide-details
class="search-field"
prepend-inner-icon="mdi-magnify"
clearable
class="filter-select"
variant="outlined"
>
<template #prepend-inner>
<v-icon size="20">mdi-hospital-building</v-icon>
</template>
</v-select>
<v-select
v-model="selectedPembayaran"
:items="pembayaranOptions"
label="Filter Pembayaran"
density="compact"
hide-details
clearable
class="filter-select"
variant="outlined"
>
<template #prepend-inner>
<v-icon size="20">mdi-cash</v-icon>
</template>
</v-select>
<v-select
v-model="selectedShift"
:items="shiftOptions"
label="Filter Shift"
density="compact"
hide-details
clearable
class="filter-select"
variant="outlined"
>
<template #prepend-inner>
<v-icon size="20">mdi-clock-outline</v-icon>
</template>
</v-select>
<v-btn
v-if="hasActiveFilters"
variant="text"
color="primary-600"
size="small"
@click="clearAllFilters"
>
<v-icon start size="18">mdi-filter-off</v-icon>
Reset Filter
</v-btn>
</div>
<!-- Active Filter Tags -->
<div v-if="hasActiveFilters" class="active-filters mt-2">
<v-chip
v-if="selectedKlinik"
size="small"
closable
@click:close="selectedKlinik = null"
>
Klinik: {{ selectedKlinik }}
</v-chip>
<v-chip
v-if="selectedPembayaran"
size="small"
closable
@click:close="selectedPembayaran = null"
>
Pembayaran: {{ selectedPembayaran }}
</v-chip>
<v-chip
v-if="selectedShift"
size="small"
closable
@click:close="selectedShift = null"
>
{{ selectedShift }}
</v-chip>
<v-chip
v-if="selectedFastTrackModel"
size="small"
closable
@click:close="selectedFastTrackModel = null"
>
Fast Track: {{ selectedFastTrackModel }}
</v-chip>
</div>
</div>
<div v-if="filteredAndSearchedItems.length > 0" key="patient-list-wrapper" class="patient-list-wrapper">
<!-- Results Info -->
<div class="results-info mb-3">
<span class="results-text">
Menampilkan {{ paginatedItems.length }} dari {{ filteredAndSearchedItems.length }} pasien
</span>
</div>
<!-- Patient Cards Grid -->
<div class="patient-grid">
<PatientCard
v-for="(patient, index) in paginatedItems"
:key="`${patient.barcode}-${index}`"
:patient="patient"
@action="handleAction"
/>
</div>
</div>
<!-- Patient Cards Grid -->
<div v-if="filteredAndSearchedItems.length > 0" class="patient-grid">
<PatientCard
v-for="(patient, index) in paginatedItems"
:key="`${patient.barcode}-${index}`"
:patient="patient"
@action="handleAction"
/>
</div>
<!-- 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>
<div class="empty-text mt-3">Tidak ada data pasien</div>
<div class="empty-subtext">Data akan muncul ketika ada pasien yang terdaftar</div>
<div class="empty-text mt-3">
{{ searchModel || hasActiveFilters ? 'Tidak ada pasien yang sesuai' : 'Tidak ada data pasien' }}
</div>
<div class="empty-subtext">
{{ searchModel || hasActiveFilters ? 'Coba ubah filter atau kata kunci pencarian' : 'Data akan muncul ketika ada pasien yang terdaftar' }}
</div>
</div>
<!-- Pagination -->
@@ -79,6 +180,10 @@ const props = defineProps({
type: Number,
default: 0
},
diprosesCount: {
type: Number,
default: 0
},
terlambatCount: {
type: Number,
default: 0
@@ -87,29 +192,76 @@ const props = defineProps({
type: Number,
default: 0
},
anjunganCount: {
type: Number,
default: 0
},
showDiproses: {
type: Boolean,
default: true
},
itemsPerPage: {
type: Number,
default: 9
default: 12
},
statusLabels: {
type: Object,
default: () => ({
all: 'Semua',
diloket: 'Di Loket',
diproses: 'Diproses',
terlambat: 'Terlambat',
pending: 'Pending'
})
},
selectedFastTrack: {
type: String,
default: null
},
fastTrackOptions: {
type: Array,
default: () => []
}
});
const emit = defineEmits(['update:selectedStatus', 'update:searchQuery', 'action']);
const emit = defineEmits(['update:selectedStatus', 'update:searchQuery', 'update:selectedFastTrack', 'action']);
const currentPage = ref(1);
const selectedKlinik = ref(null);
const selectedPembayaran = ref(null);
const selectedShift = ref(null);
const selectedFastTrackModel = computed({
get: () => props.selectedFastTrack,
set: (value) => {
currentPage.value = 1;
emit('update:selectedFastTrack', value);
}
});
const selectedStatusModel = computed({
get: () => props.selectedStatus,
get: () => {
// If Fast Track is selected, return 'fasttrack' as status
if (selectedFastTrackModel.value === 'YA') {
return 'fasttrack';
}
return props.selectedStatus;
},
set: (value) => {
currentPage.value = 1; // Reset to first page on filter change
currentPage.value = 1;
// Handle Fast Track selection
if (value === 'fasttrack') {
selectedFastTrackModel.value = 'YA';
// Emit 'all' as status since Fast Track is a separate filter
emit('update:selectedStatus', 'all');
return;
}
// Reset Fast Track when other status is selected
if (selectedFastTrackModel.value) {
selectedFastTrackModel.value = null;
}
emit('update:selectedStatus', value);
}
});
@@ -117,32 +269,132 @@ const selectedStatusModel = computed({
const searchModel = computed({
get: () => props.searchQuery,
set: (value) => {
currentPage.value = 1; // Reset to first page on search
currentPage.value = 1;
emit('update:searchQuery', value);
}
});
const statusOptions = computed(() => [
{ value: 'all', label: props.statusLabels.all, count: props.items.length },
{ value: 'diloket', label: props.statusLabels.diloket, count: props.diLoketCount },
{ value: 'terlambat', label: props.statusLabels.terlambat, count: props.terlambatCount },
{ value: 'pending', label: props.statusLabels.pending, count: props.pendingCount }
]);
const statusOptions = computed(() => {
const baseOptions = [
{ value: 'all', label: props.statusLabels.all, count: props.items.length },
{ value: 'di-loket', label: props.statusLabels.diloket, count: props.diLoketCount }
];
// Tampilkan "Diproses" hanya jika:
// - label-nya didefinisikan, DAN
// - komponen mengizinkan (showDiproses = true)
if (props.showDiproses && props.statusLabels.diproses) {
baseOptions.push({
value: 'diproses',
label: props.statusLabels.diproses,
count: props.diprosesCount
});
}
// 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(
{ value: 'terlambat', label: props.statusLabels.terlambat, count: props.terlambatCount },
{ value: 'pending', label: props.statusLabels.pending, count: props.pendingCount }
);
// Add Fast Track as a status option
if (fastTrackYaCount.value > 0) {
baseOptions.push({
value: 'fasttrack',
label: 'Fast Track',
count: fastTrackYaCount.value,
icon: 'mdi-flash'
});
}
return baseOptions;
});
// Generate filter options from items
const klinikOptions = computed(() => {
const kliniks = [...new Set(props.items.map(p => p.klinik))];
return kliniks.sort();
});
const pembayaranOptions = computed(() => {
const pembayaran = [...new Set(props.items.map(p => p.pembayaran))];
return pembayaran.sort();
});
const shiftOptions = computed(() => {
const shifts = [...new Set(props.items.map(p => p.shift))];
return shifts.sort();
});
// Count Fast Track "YA"
const fastTrackYaCount = computed(() => {
return props.items.filter(p => {
const patientFastTrack = (p.fastTrack ?? "").toString().trim().toUpperCase();
return patientFastTrack === 'YA';
}).length;
});
const hasActiveFilters = computed(() => {
return !!(selectedKlinik.value || selectedPembayaran.value || selectedShift.value || selectedFastTrackModel.value);
});
const clearAllFilters = () => {
selectedKlinik.value = null;
selectedPembayaran.value = null;
selectedShift.value = null;
selectedFastTrackModel.value = null;
currentPage.value = 1;
};
const filteredItems = computed(() => {
// Handle Fast Track as a status category
if (selectedStatusModel.value === 'fasttrack') {
return props.items.filter(p => {
const patientFastTrack = (p.fastTrack ?? "").toString().trim().toUpperCase();
return patientFastTrack === 'YA';
});
}
if (selectedStatusModel.value === 'all') return props.items;
return props.items.filter(p => p.status === selectedStatusModel.value);
});
const filteredAndSearchedItems = computed(() => {
if (!searchModel.value) return filteredItems.value;
const searchLower = searchModel.value.toLowerCase();
return filteredItems.value.filter(patient =>
patient.barcode?.toLowerCase().includes(searchLower) ||
patient.noAntrian?.toLowerCase().includes(searchLower) ||
patient.klinik?.toLowerCase().includes(searchLower)
);
let result = filteredItems.value;
// Apply attribute filters
if (selectedKlinik.value) {
result = result.filter(p => p.klinik === selectedKlinik.value);
}
if (selectedPembayaran.value) {
result = result.filter(p => p.pembayaran === selectedPembayaran.value);
}
if (selectedShift.value) {
result = result.filter(p => p.shift === selectedShift.value);
}
// Fast Track filtering is now handled in filteredItems as a status category
// Apply search
if (searchModel.value) {
const searchLower = searchModel.value.toLowerCase();
result = result.filter(patient =>
patient.barcode?.toLowerCase().includes(searchLower) ||
patient.noAntrian?.toLowerCase().includes(searchLower) ||
patient.klinik?.toLowerCase().includes(searchLower)
);
}
return result;
});
const totalPages = computed(() =>
@@ -190,6 +442,7 @@ const handleAction = (patient, action) => {
.status-filter {
flex: 1;
min-width: 300px;
}
.status-filter .v-chip {
@@ -212,13 +465,44 @@ const handleAction = (patient, action) => {
border-color: var(--color-secondary-600);
}
.search-field {
max-width: 300px;
.advanced-filters {
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: center;
}
.filter-select {
min-width: 180px;
max-width: 220px;
}
.active-filters {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.results-info {
padding: 8px 12px;
background: var(--color-neutral-200);
border-radius: 8px;
display: inline-flex;
align-items: center;
border: 1px solid var(--color-neutral-400);
border-left: 3px solid var(--color-primary-600);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.results-text {
font-size: 13px;
font-weight: 600;
color: var(--color-neutral-700);
}
.patient-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
}
@@ -260,7 +544,16 @@ const handleAction = (patient, action) => {
align-items: stretch;
}
.search-field {
.status-filter {
min-width: 100%;
}
.advanced-filters {
flex-direction: column;
}
.filter-select {
width: 100%;
max-width: 100%;
}
@@ -278,7 +571,7 @@ const handleAction = (patient, action) => {
@media (min-width: 1265px) {
.patient-grid {
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));
}
}
</style>
@@ -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>
-107
View File
@@ -1,107 +0,0 @@
<!-- components/AppBar.vue -->
<template>
<v-app-bar app color="#ff9248" dark>
<v-app-bar-nav-icon @click="emit('toggle-rail')"></v-app-bar-nav-icon>
<v-toolbar-title class="ml-2 font-weight-bold">
<span class="text-blue-darken-2">Antrean</span> RSSA
</v-toolbar-title>
<v-spacer></v-spacer>
<!-- Show loading state or user info -->
<div v-if="isLoading" class="d-flex align-center">
<v-progress-circular indeterminate size="20" class="mr-2"></v-progress-circular>
<span class="mr-2">Loading...</span>
</div>
<template v-else-if="isAuthenticated && user">
<ProfilePopup
:user="user"
@logout="handleLogout"
/><template>
<v-app-bar app color="blue-grey-darken-3" dark flat>
<v-app-bar-nav-icon @click="emit('toggle-rail')"></v-app-bar-nav-icon>
<v-toolbar-title class="ml-2 font-weight-bold">
<span class="text-orange-darken-2">Antrian</span> RSSA
</v-toolbar-title>
<v-spacer></v-spacer>
<div v-if="isLoading" class="d-flex align-center">
<v-progress-circular indeterminate color="orange-darken-2" size="20" class="mr-2"></v-progress-circular>
<span class="text-caption">Loading...</span>
</div>
<template v-else-if="isAuthenticated && user">
<v-menu offset-y>
<template v-slot:activator="{ props }">
<v-btn
v-bind="props"
variant="flat"
rounded="xl"
color="transparent"
class="pa-2 text-capitalize"
>
<div class="d-flex align-center">
<v-avatar color="orange-darken-2" size="36" class="mr-2">
<span class="text-white font-weight-bold">{{ user.name?.charAt(0) || 'U' }}</span>
</v-avatar>
<span class="text-subtitle-1 font-weight-bold text-white">{{ user.name || 'User' }}</span>
<v-icon right size="20" class="ml-1">mdi-chevron-down</v-icon>
</div>
</v-btn>
</template>
<ProfilePopup
:user="user"
@logout="handleLogout"
/>
</v-menu>
</template>
<template v-else>
<v-btn @click="redirectToLogin" color="orange-darken-2" variant="flat" rounded="lg" class="text-capitalize">
<v-icon left>mdi-login</v-icon>
Login
</v-btn>
</template>
</v-app-bar>
</template>
<span class="mr-2">{{ user.name || user.preferred_username || user.email }}</span>
</template>
<template v-else>
<v-btn @click="redirectToLogin" color="white" text>
Login
</v-btn>
</template>
</v-app-bar>
</template>
<script setup>
import ProfilePopup from './ProfilePopup.vue';
// Emit untuk parent component
const emit = defineEmits(['toggle-rail']);
// Use auth composable
const { user, isAuthenticated, isLoading, checkAuth, logout } = useAuth()
// Handle logout - use the composable's logout method
const handleLogout = async () => {
console.log("🚪 AppBar logout initiated...")
try {
await logout()
} catch (error) {
console.error("❌ AppBar logout error:", error)
}
}
// Redirect to login if not authenticated
const redirectToLogin = () => {
navigateTo('/LoginPage')
}
// Check authentication on mount
onMounted(async () => {
await checkAuth()
})
</script>
+19 -11
View File
@@ -58,22 +58,22 @@
</div>
<v-list density="compact" class="pa-0">
<v-list-item
prepend-icon="mdi-account-circle"
class="px-2 rounded-lg"
link
@click="handleAction('profile')"
>
<v-list-item-title class="text-body-2">Profil</v-list-item-title>
</v-list-item>
<v-list-item
prepend-icon="mdi-cog-outline"
class="px-2 rounded-lg"
link
@click="handleAction('setting')"
>
<v-list-item-title class="text-body-2">Setting</v-list-item-title>
</v-list-item>
<v-list-item
prepend-icon="mdi-help-circle-outline"
class="px-2 rounded-lg"
link
@click="handleAction('help')"
>
<v-list-item-title class="text-body-2">Bantuan</v-list-item-title>
<v-list-item-title class="text-body-2">Pengaturan</v-list-item-title>
</v-list-item>
<v-divider class="my-2"></v-divider>
@@ -101,6 +101,7 @@
<script setup>
import { ref } from 'vue';
import { navigateTo } from '#app';
// --- PROPS & EMITS ---
const props = defineProps({
@@ -139,7 +140,14 @@ const signOut = () => {
const handleAction = (action) => {
console.log(`[PopupSidebar] Action: ${action} triggered.`);
// TODO: Tambahkan logika navigasi/fungsi di sini, misal: router.push('/settings')
switch(action) {
case 'profile':
navigateTo('/Profile/Profil');
break;
case 'setting':
navigateTo('/Profile/Pengaturan');
break;
}
menu.value = false;
};
</script>
+38 -110
View File
@@ -17,10 +17,10 @@
link
>
<template v-slot:prepend>
<div class="avatar-wrapper">
<v-avatar size="44" class="avatar-glow">
<div class="profile-avatar-container">
<v-avatar size="44" class="profile-avatar elevation-8">
<v-img
:src="user?.picture || 'https://i.pravatar.cc/300?img=68'"
:src="getAvatarSrc(user?.picture)"
:alt="`${user?.name || 'User'} Profile`"
></v-img>
</v-avatar>
@@ -41,10 +41,10 @@
class="avatar-btn"
:title="user?.name || 'Profile'"
>
<div class="avatar-wrapper">
<v-avatar size="44" class="avatar-glow">
<div class="profile-avatar-container">
<v-avatar size="44" class="profile-avatar elevation-8">
<v-img
:src="user?.picture || 'https://i.pravatar.cc/300?img=68'"
:src="getAvatarSrc(user?.picture)"
:alt="`${user?.name || 'User'} Profile`"
></v-img>
</v-avatar>
@@ -58,10 +58,10 @@
<div class="card-header">
<div class="header-pattern"></div>
<div class="header-content pa-6 text-center">
<div class="avatar-container mb-4">
<v-avatar size="90" class="profile-avatar">
<div class="profile-avatar-container mb-4">
<v-avatar size="90" class="profile-avatar elevation-8">
<v-img
:src="user?.picture || 'https://i.pravatar.cc/300?img=68'"
:src="getAvatarSrc(user?.picture)"
:alt="`${user?.name || 'User'} Profile`"
></v-img>
</v-avatar>
@@ -93,68 +93,10 @@
@click="handleAction('profile')"
>
<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 class="text-caption font-weight-medium">Profil</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>
<v-divider class="my-4"></v-divider>
<!-- Quick Actions -->
<div class="quick-actions">
<v-list-item
class="rounded-lg px-3 py-2 action-item"
link
@click="handleAction('notifications')"
>
<template v-slot:prepend>
<v-icon size="20" color="blue-darken-1">mdi-bell-outline</v-icon>
</template>
<v-list-item-title class="text-body-2 font-weight-medium">
Notifikasi
</v-list-item-title>
<template v-slot:append>
<v-badge color="orange-darken-2" content="3" inline></v-badge>
</template>
</v-list-item>
<v-list-item
class="rounded-lg px-3 py-2 action-item"
link
@click="handleAction('help')"
>
<template v-slot:prepend>
<v-icon size="20" color="blue-darken-1">mdi-help-circle-outline</v-icon>
</template>
<v-list-item-title class="text-body-2 font-weight-medium">
Bantuan & Dukungan
</v-list-item-title>
</v-list-item>
</div>
<v-divider class="my-4"></v-divider>
@@ -180,6 +122,7 @@
<script setup>
import { ref } from 'vue';
import { navigateTo } from '#app';
import { DEFAULT_AVATAR } from '@/constants/avatar';
const props = defineProps({
user: {
@@ -193,10 +136,13 @@ const props = defineProps({
})
const menu = ref(false);
const darkMode = ref(false);
const isLoggingOut = ref(false);
const emit = defineEmits(['logout']);
const getAvatarSrc = (userPicture) => {
return userPicture || DEFAULT_AVATAR;
};
const signOut = async () => {
if (isLoggingOut.value) return;
isLoggingOut.value = true;
@@ -211,47 +157,38 @@ const signOut = async () => {
const handleAction = (action) => {
switch(action) {
case 'account':
navigateTo('/Profile/Pengaturan')
break;
case 'profile':
navigateTo('/Profile/Profil')
break;
case 'notifications':
navigateTo('/notifications')
break;
case 'help':
navigateTo('/help')
break;
case 'darkMode':
darkMode.value = !darkMode.value;
return;
}
menu.value = false;
};
</script>
<style scoped>
<style scoped lang="scss">
@import '@/assets/scss/_colors.scss';
.profile-activator {
background: linear-gradient(135deg, rgba(25, 118, 210, 0.1) 0%, rgba(245, 124, 0, 0.1) 100%);
border: 1px solid rgba(25, 118, 210, 0.2);
background: linear-gradient(135deg, $primary-100 0%, $primary-50 100%);
border: 1px solid $primary-200;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.profile-activator:hover {
background: linear-gradient(135deg, rgba(25, 118, 210, 0.15) 0%, rgba(245, 124, 0, 0.15) 100%);
border-color: rgba(25, 118, 210, 0.3);
background: linear-gradient(135deg, $primary-200 0%, $primary-100 100%);
border-color: $primary-300;
transform: translateY(-2px);
}
.avatar-wrapper {
.profile-avatar-container {
position: relative;
display: inline-block;
}
.avatar-glow {
.profile-avatar {
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 {
@@ -260,7 +197,7 @@ const handleAction = (action) => {
right: 2px;
width: 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-radius: 50%;
animation: pulse 2s infinite;
@@ -291,12 +228,12 @@ const handleAction = (action) => {
.profile-card {
overflow: hidden;
border: 1px solid rgba(25, 118, 210, 0.1);
border: 1px solid $primary-200;
}
.card-header {
position: relative;
background: linear-gradient(135deg, #1976d2 0%, #fb8c00 100%);
background: linear-gradient(135deg, $primary-700 0%, $primary-500 100%);
overflow: hidden;
}
@@ -316,15 +253,6 @@ const handleAction = (action) => {
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 {
position: absolute;
@@ -332,7 +260,7 @@ const handleAction = (action) => {
right: 0;
width: 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-radius: 50%;
display: flex;
@@ -350,20 +278,20 @@ const handleAction = (action) => {
.menu-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: 1fr;
gap: 12px;
}
.menu-tile {
background: linear-gradient(135deg, rgba(25, 118, 210, 0.05) 0%, rgba(245, 124, 0, 0.05) 100%);
border: 1px solid rgba(25, 118, 210, 0.1);
background: linear-gradient(135deg, $primary-100 0%, $primary-50 100%);
border: 1px solid $primary-200;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
}
.menu-tile:hover {
background: linear-gradient(135deg, rgba(25, 118, 210, 0.1) 0%, rgba(245, 124, 0, 0.1) 100%);
border-color: rgba(25, 118, 210, 0.3);
background: linear-gradient(135deg, $primary-200 0%, $primary-100 100%);
border-color: $primary-300;
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
}
@@ -391,7 +319,7 @@ const handleAction = (action) => {
}
.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);
}
@@ -399,11 +327,11 @@ const handleAction = (action) => {
text-transform: none;
font-weight: 600;
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 {
box-shadow: 0 6px 20px rgba(244, 67, 54, 0.4);
box-shadow: 0 6px 20px rgba(222, 71, 59, 0.4);
}
.cursor-pointer {
+461 -45
View File
@@ -9,20 +9,28 @@
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
<v-list-item class="py-4 px-2" :class="{ 'text-center': rail }">
<div class="d-flex align-center" :class="{ 'justify-center': rail }">
<img
src="/Antrean Logo.png"
alt="Antrean Logo"
style="width: 72px; height: 72px; object-fit: contain"
/>
<v-list-item-title
v-if="!rail"
class="ml-2 text-h6 font-weight-bold text-blue-grey-darken-4"
>
<span class="text-orange-darken-2">Antrean</span> RSSA
</v-list-item-title>
</div>
<v-list-item class="py-4 px-3 sidebar-logo-container" :class="{ 'text-center': rail }">
<NuxtLink to="/dashboard" class="sidebar-logo-link">
<div class="d-flex align-center sidebar-logo-wrapper" :class="{ 'justify-center': rail }">
<div class="sidebar-logo-image-wrapper">
<img
src="/love logo biru small.png"
alt="Antrean Logo"
class="sidebar-logo"
:class="{ 'sidebar-logo-rail': rail }"
width="32"
height="32"
style="width: 32px; height: 32px; object-fit: contain;"
/>
</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-divider></v-divider>
@@ -33,33 +41,92 @@
v-model:opened="openedGroups"
>
<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 }">
<v-list-item
v-bind="groupProps"
:prepend-icon="item.icon"
:title="item.name"
color="orange-darken-2"
active-class="bg-orange-lighten-5 text-orange-darken-2 font-weight-bold"
class="sidebar-nav-item"
active-class="sidebar-nav-item-active"
></v-list-item>
</template>
<v-list-item
<v-tooltip
v-for="child in item.children"
:key="child.name"
:to="child.path"
:title="child.name"
:prepend-icon="child.icon"
link
class="pl-8"
color="orange-darken-2"
active-class="bg-orange-lighten-5 text-orange-darken-2 font-weight-bold"
></v-list-item>
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="pl-8 sidebar-nav-item"
active-class="sidebar-nav-item-active"
></v-list-item>
</template>
</v-tooltip>
</v-list-group>
<v-tooltip
v-else
:disabled="!rail"
open-on-hover
location="end"
:text="item.name"
@@ -71,8 +138,8 @@
:title="item.name"
:to="item.path"
link
color="orange-darken-2"
active-class="bg-orange-lighten-5 text-orange-darken-2 font-weight-bold"
class="sidebar-nav-item"
active-class="sidebar-nav-item-active"
></v-list-item>
</template>
</v-tooltip>
@@ -81,23 +148,55 @@
<v-divider></v-divider>
<v-list v-if="user" nav density="compact" class="pa-2 flex-shrink-0">
<ProfilePopup :user="user" @logout="handleLogout" :rail="rail" />
</v-list>
<v-list v-else nav density="compact" class="flex-shrink-0">
<v-list-item
@click="redirectToLogin"
prepend-icon="mdi-login"
title="Login"
link
color="blue-grey-darken-4"
></v-list-item>
</v-list>
<!-- Bottom section wrapper for profile and lock button -->
<div class="sidebar-bottom-section">
<v-list v-if="user" nav density="compact" class="pa-2">
<ProfilePopup :user="user" @logout="handleLogout" :rail="rail" />
</v-list>
<v-list v-else nav density="compact">
<v-list-item
@click="redirectToLogin"
prepend-icon="mdi-login"
title="Login"
link
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>
</template>
<script setup lang="ts">
import { defineProps, defineEmits, onMounted, ref, watch } from "vue";
import { onMounted, ref, watch } from "vue";
import { navigateTo } from "#app";
import { useLocalStorage } from "@vueuse/core";
import ProfilePopup from "./ProfilePopup.vue";
import { useAuth } from "~/composables/useAuth";
@@ -140,9 +239,15 @@ watch(
);
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 = () => {
if (props.rail) {
if (props.rail && !isLocked.value) {
isHovering.value = true;
emit("toggle-rail");
}
@@ -151,10 +256,30 @@ const handleMouseEnter = () => {
const handleMouseLeave = () => {
if (isHovering.value) {
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");
}
};
const closePopup = () => {
// Popup akan otomatis tertutup saat item diklik
};
// --- AUTH LOGIC (Kept the same) ---
const handleLogout = async () => {
@@ -175,6 +300,297 @@ onMounted(async () => {
});
</script>
<style scoped>
/* No specific overrides needed */
<style lang="scss" scoped>
@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>
+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>
<v-card-text class="pa-0 bg-white">
<v-list lines="two" class="pa-0">
<v-list-item
v-for="patient in patients"
:key="patient.rm"
class="patient-item"
:class="{ 'verified-item': patient.status === 'Terverifikasi' }"
>
<template #prepend>
<v-avatar
:color="patient.status === 'Terverifikasi' ? 'secondary-600' : 'primary-600'"
size="64"
class="patient-avatar"
>
<v-icon size="36" color="white">
{{ patient.status === 'Terverifikasi' ? 'mdi-check-decagram' : 'mdi-clock-alert' }}
</v-icon>
</v-avatar>
</template>
<v-table class="patient-table">
<!-- Table Body -->
<tbody>
<tr
v-for="patient in patients"
:key="patient.rm"
class="table-row"
:class="{ 'verified-row': patient.status === 'Terverifikasi' }"
>
<!-- Status Avatar -->
<td class="table-cell status-cell">
<div class="status-wrapper">
<v-avatar
:color="patient.status === 'Terverifikasi' ? 'secondary-600' : 'primary-600'"
size="48"
class="patient-avatar"
>
<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">
{{ patient.nama }}
</v-list-item-title>
<!-- Nama -->
<td class="table-cell">
<span class="patient-name">{{ patient.nama }}</span>
</td>
<v-list-item-subtitle class="mt-2">
<v-row dense class="patient-info">
<v-col cols="12" sm="3" md="2" class="py-1">
<v-chip size="default" color="primary-200" class="chip-rm" variant="flat">
<v-icon start size="18" color="secondary-600">mdi-file-document</v-icon>
<span class="chip-rm-text">{{ patient.rm }}</span>
</v-chip>
</v-col>
<v-col cols="12" sm="5" md="6" class="py-1 info-item">
<v-icon size="18" class="mr-2" color="neutral-600">mdi-map-marker</v-icon>
<!-- RM -->
<td class="table-cell">
<v-chip size="small" color="primary-200" class="chip-rm" variant="flat">
<v-icon start size="14" color="secondary-600">mdi-file-document</v-icon>
<span class="chip-rm-text">{{ patient.rm }}</span>
</v-chip>
</td>
<!-- Alamat -->
<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>
</v-col>
<v-col cols="12" sm="4" md="4" class="py-1 info-item">
<v-icon size="18" class="mr-2" color="neutral-600">mdi-phone</v-icon>
<span>{{ patient.telepon || 'Belum diisi' }}</span>
</v-col>
</v-row>
</v-list-item-subtitle>
</div>
</td>
<template #append>
<v-btn
v-if="patient.status === 'Belum Terverifikasi'"
color="primary-600"
size="x-large"
@click="$emit('verify', patient)"
prepend-icon="mdi-qrcode-scan"
variant="flat"
class="action-btn"
rounded="xl"
>
VERIFIKASI
</v-btn>
<v-chip
v-else
color="secondary-600"
size="x-large"
variant="flat"
class="verified-chip"
rounded="xl"
>
<v-icon start size="24">mdi-shield-check</v-icon>
VERIFIED
</v-chip>
</template>
</v-list-item>
<v-list-item v-if="patients.length === 0">
<v-list-item-title class="empty-state">
{{ emptyMessage }}
</v-list-item-title>
</v-list-item>
</v-list>
<!-- Nomor Telepon -->
<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>
</div>
</td>
<!-- Aksi -->
<td class="table-cell action-col">
<div class="action-wrapper">
<v-btn
v-if="patient.status === 'Belum Terverifikasi'"
variant="flat"
class="action-btn"
@click="$emit('verify', patient)"
>
<v-icon start size="16">mdi-qrcode-scan</v-icon>
VERIFIKASI
</v-btn>
<v-btn
v-else
color="secondary-600"
variant="flat"
class="verified-btn"
disabled
>
<v-icon start size="16">mdi-shield-check</v-icon>
VERIFIED
</v-btn>
</div>
</td>
</tr>
<!-- Empty State -->
<tr v-if="patients.length === 0">
<td colspan="6" class="empty-state-cell">
<div class="empty-state">{{ emptyMessage }}</div>
</td>
</tr>
</tbody>
</v-table>
</v-card-text>
</template>
@@ -99,12 +109,14 @@ defineEmits(['verify']);
<style scoped lang="scss">
$neutral-100: #FFFFFF;
$neutral-400: #E5F7FA;
$neutral-500: #ABBED1;
$neutral-600: #89939E;
$neutral-700: #717171;
$neutral-900: #212121;
$primary-100: #FFE8CC;
$primary-200: #FFDCAF;
$primary-600: #FFA532;
$primary-700: #FF9B1B;
$secondary-200: #EDF5FF;
$secondary-300: #DBEDFF;
$secondary-400: #B3D9FF;
@@ -116,63 +128,129 @@ $font-weight-semibold: 600;
$font-weight-bold: 700;
$font-weight-extra-bold: 800;
.patient-item {
border-bottom: 1px solid $neutral-400;
padding: 24px;
.patient-table {
font-family: $font-family-base;
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;
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;
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 {
content: '';
position: absolute;
left: 0;
top: 0;
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;
.status-wrapper {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
}
.patient-avatar {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
border: 3px solid $neutral-100;
margin: 0 auto;
}
.patient-name {
font-size: 24px;
font-size: 18px;
font-weight: $font-weight-extra-bold;
color: $neutral-900;
margin-bottom: 8px;
font-family: $font-family-base;
}
.patient-info {
font-size: 14px;
font-family: $font-family-base;
}
@@ -195,51 +273,93 @@ $font-weight-extra-bold: 800;
font-family: $font-family-base;
}
.action-wrapper {
display: flex;
align-items: center;
justify-content: flex-end;
}
.action-btn {
background-color: $primary-700 !important;
text-transform: none;
letter-spacing: 0.5px;
font-weight: $font-weight-extra-bold;
font-size: 16px;
color: $neutral-100;
padding: 12px 32px;
box-shadow: 0 2px 8px rgba(255, 155, 27, 0.25);
font-size: 14px;
color: $neutral-100 !important;
padding: 8px 24px !important;
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;
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-size: 16px;
color: $neutral-100;
padding: 12px 24px;
font-size: 14px;
color: $neutral-100 !important;
padding: 8px 24px !important;
height: 40px !important;
min-width: 150px;
font-family: $font-family-base;
opacity: 1 !important;
}
.empty-state-cell {
padding: 40px 24px;
text-align: center;
}
.empty-state {
text-align: center;
padding: 32px 0;
font-size: 18px;
color: $neutral-600;
font-weight: $font-weight-semibold;
font-family: $font-family-base;
}
// ============================================
// RESPONSIVE
// ============================================
@media (max-width: 960px) {
.patient-item {
padding: 20px;
.patient-table {
display: block;
overflow-x: auto;
}
.action-btn {
font-size: 14px;
padding: 10px 20px;
.table-cell {
padding: 20px 16px;
white-space: nowrap;
}
.header-cell {
padding: 12px 16px;
font-size: 12px;
}
.action-btn,
.verified-btn {
font-size: 12px;
}
.patient-avatar {
width: 56px !important;
height: 56px !important;
width: 44px !important;
height: 44px !important;
}
.patient-name {
font-size: 20px;
font-size: 16px;
}
.info-item {
font-size: 12px;
}
}
</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
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) {
error.value = response.error
user.value = null
return null
// The session API returns SessionResponse, or throws 401 if not authenticated
// $fetch automatically sends cookies for same-origin requests
const response = await $fetch<SessionResponse>('/api/auth/session', {
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
return response.user
// If response exists but no user, clear and return null
user.value = null
return null
} catch (fetchError: any) {
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
return null
} finally {
+203
View File
@@ -0,0 +1,203 @@
import { ref, readonly } from 'vue'
import type { CheckInResult } from '~/types/checkin'
export interface UseCheckInOptions {
showSnackbar: (title: string, message: string, color: string, icon: string) => void
saveToHistory: (item: {
patientId: string
queueNumber?: string
status: string
checkInTime: string
checkInDate: string
method: string
}) => void
saveSuccessfulScan: (qrData: string) => void
onCheckInSuccess: (result: {
success: boolean
patientId: string
status: string
message: string
action: 'checkin' | 'kembali'
}) => void
autoCloseDialog: () => void
}
export const useCheckIn = (options: UseCheckInOptions) => {
const { showSnackbar, saveToHistory, saveSuccessfulScan, onCheckInSuccess, autoCloseDialog } = options
// State
const lastCheckInResult = ref<CheckInResult | null>(null)
// Perform check-in
const performCheckIn = async (data: string, method: string = 'QR Scan'): Promise<boolean> => {
await new Promise(resolve => setTimeout(resolve, 1000))
const success = Math.random() < 0.8
// History akan disimpan di processQRCode setelah performCheckIn selesai
// Jadi kita hanya perlu return hasil check-in
return success
}
// Process QR code (rename dari onDetect)
const processQRCode = async (decodedText: string) => {
const [patientId, status] = decodedText.split('|')
// Validasi format QR code
if (!patientId || !status) {
showSnackbar('Error', 'QR Code tidak valid. Format harus: ID_PASIEN|STATUS', 'error', 'mdi-close-circle')
return
}
// Cek apakah pasien diperbolehkan check-in
const isAllowed = status === 'ALLOWED'
if (isAllowed) {
// Jika diperbolehkan, langsung proses check-in
const checkinSuccess = await performCheckIn(decodedText, 'QR Scan')
// Simpan hasil check-in
lastCheckInResult.value = {
success: checkinSuccess,
patientId: patientId,
status: status
}
// Simpan ke history check-in dengan status hasil check-in
saveToHistory({
patientId: patientId || 'Unknown',
status: checkinSuccess ? 'success' : 'failed',
checkInTime: new Date().toISOString(),
checkInDate: new Date().toISOString(),
method: 'QR Scan'
})
// Tampilkan dialog dengan hasil check-in
if (checkinSuccess) {
// Simpan QR code yang berhasil di-scan untuk mencegah scan ulang
saveSuccessfulScan(decodedText)
onCheckInSuccess({
success: true,
patientId: patientId,
status: status,
message: `✅ Check-in Berhasil!\n\nPasien ${patientId} berhasil melakukan check-in.`,
action: 'checkin'
})
} else {
onCheckInSuccess({
success: false,
patientId: patientId,
status: status,
message: `❌ Check-in Gagal!\n\nPasien ${patientId} diperbolehkan check-in, namun proses check-in gagal. Silakan coba lagi.`,
action: 'checkin'
})
}
autoCloseDialog()
} else {
// Jika belum diperbolehkan, tampilkan pesan
lastCheckInResult.value = {
success: false,
patientId: patientId,
status: status
}
// Simpan ke history check-in untuk NOT_ALLOWED
saveToHistory({
patientId: patientId || 'Unknown',
status: 'NOT_ALLOWED',
checkInTime: new Date().toISOString(),
checkInDate: new Date().toISOString(),
method: 'QR Scan'
})
onCheckInSuccess({
success: false,
patientId: patientId,
status: status,
message: `⏳ Belum Diizinkan Check-in\n\nAntrean Pasien ${patientId} belum diperbolehkan check-in. Mohon menunggu hingga antrean Anda dipanggil.`,
action: 'kembali'
})
autoCloseDialog()
}
}
// Check-in manual
const checkInManual = async (
patientId: string,
onSuccess: () => void,
onError: () => void
) => {
try {
if (!patientId || !patientId.trim()) {
showSnackbar('Error', 'Mohon isi nomor antrean atau ID pasien', 'error', 'mdi-alert')
onError()
return
}
const trimmedPatientId = patientId.trim()
// Simulasi check-in manual
const success = await performCheckIn(`${trimmedPatientId}|ALLOWED`, 'Manual')
// Simpan hasil check-in
lastCheckInResult.value = {
success: success,
patientId: trimmedPatientId,
status: 'ALLOWED',
}
// Simpan ke history check-in
saveToHistory({
patientId: trimmedPatientId,
status: success ? 'success' : 'failed',
checkInTime: new Date().toISOString(),
checkInDate: new Date().toISOString(),
method: 'Manual'
})
if (success) {
// Simpan QR code yang berhasil untuk mencegah double antrian (jika menggunakan format yang sama)
saveSuccessfulScan(`${trimmedPatientId}|ALLOWED`)
showSnackbar('Berhasil!', 'Check-in manual berhasil dilakukan.', 'success', 'mdi-check-circle')
// Update info dialog
onCheckInSuccess({
success: true,
patientId: trimmedPatientId,
status: 'ALLOWED',
message: `✅ Check-in Berhasil!\n\nPasien ${trimmedPatientId} berhasil melakukan check-in secara manual.`,
action: 'checkin'
})
autoCloseDialog()
onSuccess()
} else {
showSnackbar('Gagal!', 'Check-in manual gagal dilakukan. Silakan coba lagi!', 'error', 'mdi-close-circle')
// Update info dialog untuk gagal
onCheckInSuccess({
success: false,
patientId: trimmedPatientId,
status: 'ALLOWED',
message: `❌ Check-in Gagal!\n\nPasien ${trimmedPatientId} gagal melakukan check-in secara manual. Silakan coba lagi.`,
action: 'checkin'
})
autoCloseDialog()
onError()
}
} catch (error) {
console.error('Error in checkInManual:', error)
showSnackbar('Error', 'Terjadi kesalahan saat melakukan check-in. Silakan coba lagi.', 'error', 'mdi-alert')
onError()
}
}
return {
lastCheckInResult: readonly(lastCheckInResult),
performCheckIn,
processQRCode,
checkInManual,
}
}
+278
View File
@@ -0,0 +1,278 @@
import { ref, computed, readonly } from 'vue'
import type { CheckInHistoryItem, ScannedQRHistoryItem, HistoryStatus } from '~/types/checkin'
import { HISTORY_STORAGE_KEY, SCANNED_QR_STORAGE_KEY, MAX_HISTORY_ITEMS, MAX_SCANNED_QR_ITEMS, HISTORY_STATUS_OPTIONS } from '~/constants/checkin'
export const useCheckInHistory = () => {
const checkInHistory = ref<CheckInHistoryItem[]>([])
const scannedQRHistory = ref<ScannedQRHistoryItem[]>([])
const historySearch = ref('')
const historyStatusFilter = ref<HistoryStatus | ''>('')
const historyDateFilter = ref<string>('')
const loadHistory = () => {
if (typeof window !== 'undefined') {
const stored = localStorage.getItem(HISTORY_STORAGE_KEY)
if (stored) {
try {
checkInHistory.value = JSON.parse(stored)
} catch (e) {
console.error('Error loading history:', e)
checkInHistory.value = []
}
}
}
}
const saveToHistory = (item: CheckInHistoryItem) => {
const historyItem = {
...item,
queueNumber: item.queueNumber || `ANT-${Date.now()}`,
}
checkInHistory.value.unshift(historyItem)
// Simpan maksimal item sesuai constant
if (checkInHistory.value.length > MAX_HISTORY_ITEMS) {
checkInHistory.value = checkInHistory.value.slice(0, MAX_HISTORY_ITEMS)
}
if (typeof window !== 'undefined') {
localStorage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(checkInHistory.value))
}
}
const deleteHistoryItem = (index: number) => {
checkInHistory.value.splice(index, 1)
if (typeof window !== 'undefined') {
localStorage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(checkInHistory.value))
}
}
const clearHistory = () => {
checkInHistory.value = []
if (typeof window !== 'undefined') {
localStorage.removeItem(HISTORY_STORAGE_KEY)
}
}
const loadScannedQRHistory = () => {
if (typeof window !== 'undefined') {
const stored = localStorage.getItem(SCANNED_QR_STORAGE_KEY)
if (stored) {
try {
scannedQRHistory.value = JSON.parse(stored)
} catch (e) {
console.error('Error loading QR history:', e)
scannedQRHistory.value = []
}
} else {
scannedQRHistory.value = []
}
}
}
const saveScannedQRData = (qrData: string) => {
if (typeof window !== 'undefined') {
const scannedQRs = JSON.parse(localStorage.getItem(SCANNED_QR_STORAGE_KEY) || '[]')
scannedQRs.unshift({
data: qrData,
timestamp: new Date().toISOString(),
date: new Date().toLocaleDateString('id-ID'),
time: new Date().toLocaleTimeString('id-ID').replace(/\./g, ':')
})
// Simpan maksimal item sesuai constant
if (scannedQRs.length > MAX_SCANNED_QR_ITEMS) {
scannedQRs.pop()
}
localStorage.setItem(SCANNED_QR_STORAGE_KEY, JSON.stringify(scannedQRs))
}
}
const clearQRHistory = () => {
scannedQRHistory.value = []
if (typeof window !== 'undefined') {
localStorage.removeItem(SCANNED_QR_STORAGE_KEY)
}
}
const deleteQRHistoryItem = (index: number) => {
scannedQRHistory.value.splice(index, 1)
if (typeof window !== 'undefined') {
localStorage.setItem(SCANNED_QR_STORAGE_KEY, JSON.stringify(scannedQRHistory.value))
}
}
const filteredHistory = computed(() => {
let filtered = [...checkInHistory.value]
// Filter by search
if (historySearch.value) {
const search = historySearch.value.toLowerCase()
filtered = filtered.filter(item =>
item.patientId.toLowerCase().includes(search) ||
(item.queueNumber && item.queueNumber.toLowerCase().includes(search))
)
}
// Filter by status
if (historyStatusFilter.value) {
filtered = filtered.filter(item => {
if (historyStatusFilter.value === 'success') {
return item.status === 'ALLOWED' || item.status === 'success'
} else if (historyStatusFilter.value === 'failed') {
return item.status === 'NOT_ALLOWED' || item.status === 'failed'
}
return true
})
}
// Filter by date
if (historyDateFilter.value && historyDateFilter.value.trim() !== '') {
filtered = filtered.filter(item => {
if (!item.checkInDate) return false
try {
const itemDate = new Date(item.checkInDate)
const filterDate = new Date(historyDateFilter.value)
// Validate dates
if (isNaN(itemDate.getTime()) || isNaN(filterDate.getTime())) {
return false
}
// Compare dates (year, month, day only, ignore time)
const itemDateStr = itemDate.toISOString().substring(0, 10)
const filterDateStr = filterDate.toISOString().substring(0, 10)
return itemDateStr === filterDateStr
} catch (e) {
console.error('Error filtering by date:', e)
return false
}
})
}
return filtered
})
const filteredQRHistory = computed(() => {
let filtered = [...scannedQRHistory.value]
// Filter by search
if (historySearch.value) {
const search = historySearch.value.toLowerCase()
filtered = filtered.filter(item =>
item.data.toLowerCase().includes(search)
)
}
// Filter by date
if (historyDateFilter.value && historyDateFilter.value.trim() !== '') {
filtered = filtered.filter(item => {
if (!item.timestamp && !item.date) return false
try {
let itemDate: Date
if (item.timestamp) {
itemDate = new Date(item.timestamp)
} else {
// Parse Indonesian date format (dd/mm/yyyy or dd mmm yyyy)
itemDate = new Date(item.date)
}
const filterDate = new Date(historyDateFilter.value)
// Validate dates
if (isNaN(itemDate.getTime()) || isNaN(filterDate.getTime())) {
return false
}
// Compare dates (year, month, day only, ignore time)
const itemDateStr = itemDate.toISOString().substring(0, 10)
const filterDateStr = filterDate.toISOString().substring(0, 10)
return itemDateStr === filterDateStr
} catch (e) {
console.error('Error filtering QR history by date:', e)
return false
}
})
}
return filtered
})
const getStatusColor = (status: string) => {
if (status === 'ALLOWED' || status === 'success') return 'success'
if (status === 'NOT_ALLOWED' || status === 'failed') return 'error'
return 'warning'
}
const getStatusIcon = (status: string) => {
if (status === 'ALLOWED' || status === 'success') return 'mdi-check-circle'
if (status === 'NOT_ALLOWED' || status === 'failed') return 'mdi-close-circle'
return 'mdi-clock-alert'
}
const getStatusText = (status: string) => {
if (status === 'ALLOWED' || status === 'success') return 'Berhasil'
if (status === 'NOT_ALLOWED' || status === 'failed') return 'Gagal'
return 'Pending'
}
const getStatusClass = (status: string) => {
if (status === 'ALLOWED' || status === 'success') return 'history-success'
if (status === 'NOT_ALLOWED' || status === 'failed') return 'history-failed'
return 'history-pending'
}
const formatDateTime = (dateString: string) => {
const date = new Date(dateString)
// Format jam dengan titik dua (HH:MM:SS)
const timeString = date.toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
return timeString.replace(/\./g, ':')
}
const formatDate = (dateString: string) => {
const date = new Date(dateString)
return date.toLocaleDateString('id-ID', {
day: '2-digit',
month: 'short',
year: 'numeric'
})
}
// Load history on initialization
if (typeof window !== 'undefined') {
loadHistory()
loadScannedQRHistory()
}
return {
checkInHistory: readonly(checkInHistory),
scannedQRHistory: readonly(scannedQRHistory),
historySearch,
historyStatusFilter,
historyDateFilter,
filteredHistory,
filteredQRHistory,
loadHistory,
saveToHistory,
deleteHistoryItem,
clearHistory,
loadScannedQRHistory,
saveScannedQRData,
clearQRHistory,
deleteQRHistoryItem,
getStatusColor,
getStatusIcon,
getStatusText,
getStatusClass,
formatDateTime,
formatDate,
historyStatusOptions: [...HISTORY_STATUS_OPTIONS] as Array<{ title: string; value: 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 { 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, {
method: "GET",
+211
View File
@@ -0,0 +1,211 @@
import { ref, readonly, computed } from 'vue'
import { nextTick } from 'vue'
import type { CheckInStatus } from '~/types/checkin'
export interface UseQRGeneratorOptions {
showSnackbar: (title: string, message: string, color: string, icon: string) => void
generateRandomPatientId: () => string
}
export const useQRGenerator = (options: UseQRGeneratorOptions) => {
const { showSnackbar, generateRandomPatientId } = options
// State
const generatePatientId = ref(generateRandomPatientId())
const generateStatus = ref<CheckInStatus>('ALLOWED')
const generatedQRData = ref('')
// Generate random patient ID
const generateRandomId = () => {
generatePatientId.value = generateRandomPatientId()
}
// Quick generate QR for testing
const generateQuickQR = (patientId: string, status: string) => {
generatePatientId.value = patientId
generateStatus.value = status as CheckInStatus
generateQRCode()
}
// Generate QR Code function
const generateQRCode = async () => {
if (!generatePatientId.value) {
showSnackbar('Error', 'Mohon isi ID Pasien', 'error', 'mdi-alert')
return
}
generatedQRData.value = `${generatePatientId.value}|${generateStatus.value}`
await nextTick()
// Clear previous QR code
const qrContainer = document.getElementById('qrcode')
if (qrContainer) {
qrContainer.innerHTML = ''
try {
// Use qrcode package that's already installed
const QRCode = (await import('qrcode')).default
// Create QR code as data URL
const qrDataUrl = await QRCode.toDataURL(generatedQRData.value, {
errorCorrectionLevel: 'M',
type: 'image/png',
quality: 0.92,
margin: 1,
color: {
dark: '#000000',
light: '#FFFFFF'
},
width: 300
})
// Create img element and append to container
const img = document.createElement('img')
img.src = qrDataUrl
img.alt = 'QR Code'
img.style.width = '100%'
img.style.maxWidth = '300px'
img.style.height = 'auto'
img.style.display = 'block'
img.style.margin = '0 auto'
qrContainer.appendChild(img)
console.log('QR Code created successfully:', generatedQRData.value)
showSnackbar('Berhasil!', 'QR Code berhasil di-generate. Silakan scan untuk testing', 'success', 'mdi-check-circle')
} catch (error) {
console.error('Error creating QR code:', error)
showSnackbar('Error', 'Gagal membuat QR Code. Silakan coba lagi', 'error', 'mdi-alert')
}
} else {
showSnackbar('Error', 'Container QR Code tidak ditemukan', 'error', 'mdi-alert')
}
}
// Download QR Code
const downloadQR = async () => {
const img = document.querySelector('#qrcode img') as HTMLImageElement
if (img && img.src) {
try {
// Convert img src (data URL) to blob
const response = await fetch(img.src)
const blob = await response.blob()
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
const fileName = `QR-Test-${generatePatientId.value}-${generateStatus.value}-${Date.now()}.png`
link.download = fileName
link.href = url
link.style.display = 'none'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
showSnackbar('Berhasil!', `QR Code berhasil didownload: ${fileName}`, 'success', 'mdi-download')
} catch (error) {
console.error('Download error:', error)
// Fallback: use img src directly
const link = document.createElement('a')
link.download = `QR-Test-${generatePatientId.value}-${generateStatus.value}.png`
link.href = img.src
link.click()
showSnackbar('Berhasil!', 'QR Code berhasil didownload', 'success', 'mdi-download')
}
} else {
showSnackbar('Error', 'QR Code belum di-generate. Silakan generate terlebih dahulu', 'error', 'mdi-alert')
}
}
// Copy QR Code to Clipboard
const copyQRToClipboard = async () => {
const img = document.querySelector('#qrcode img') as HTMLImageElement
if (img && img.src) {
try {
// Convert img src to blob
const response = await fetch(img.src)
const blob = await response.blob()
try {
await navigator.clipboard.write([
new ClipboardItem({
'image/png': blob
})
])
showSnackbar('Berhasil!', 'QR Code berhasil disalin ke clipboard', 'success', 'mdi-content-copy')
} catch (err: any) {
console.error('Clipboard error:', err)
// Fallback: download instead
showSnackbar('Info', 'Copy ke clipboard tidak didukung. Gunakan tombol Download.', 'info', 'mdi-information')
}
} catch (error) {
console.error('Copy error:', error)
showSnackbar('Error', 'Gagal menyalin QR Code', 'error', 'mdi-alert')
}
} else {
showSnackbar('Error', 'QR Code belum di-generate. Silakan generate terlebih dahulu', 'error', 'mdi-alert')
}
}
// Share QR Code
const shareQR = async () => {
const img = document.querySelector('#qrcode img') as HTMLImageElement
if (img && img.src) {
try {
// Convert img src to blob
const response = await fetch(img.src)
const blob = await response.blob()
const file = new File([blob], `QR-Test-${generatePatientId.value}-${generateStatus.value}.png`, { type: 'image/png' })
if (navigator.share && navigator.canShare({ files: [file] })) {
try {
await navigator.share({
files: [file],
title: 'QR Code Check-in Test',
text: `QR Code untuk testing: ${generatedQRData.value}`
})
showSnackbar('Berhasil!', 'QR Code berhasil dibagikan', 'success', 'mdi-share')
} catch (err: any) {
if (err.name !== 'AbortError') {
// Fallback to copy or download
copyQRToClipboard()
}
}
} else {
// Fallback: try copy to clipboard
copyQRToClipboard()
}
} catch (error) {
console.error('Share error:', error)
showSnackbar('Error', 'Gagal membagikan QR Code', 'error', 'mdi-alert')
}
} else {
showSnackbar('Error', 'QR Code belum di-generate. Silakan generate terlebih dahulu', 'error', 'mdi-alert')
}
}
// Computed untuk v-model
const generatePatientIdModel = computed({
get: () => generatePatientId.value,
set: (value: string) => {
generatePatientId.value = value
}
})
const generateStatusModel = computed({
get: () => generateStatus.value,
set: (value: CheckInStatus) => {
generateStatus.value = value
}
})
return {
generatePatientId: generatePatientIdModel,
generateStatus: generateStatusModel,
generatedQRData: readonly(generatedQRData),
generateRandomId,
generateQuickQR,
generateQRCode,
downloadQR,
copyQRToClipboard,
shareQR,
}
}
+574
View File
@@ -0,0 +1,574 @@
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { QR_CODE_ID, SCAN_DEBOUNCE_MS, SUCCESSFUL_SCANS_KEY } from '~/constants/checkin'
export const useQRScanner = (
onQRDetected: (decodedText: string) => void,
showSnackbar: (title: string, message: string, color: string, icon: string) => void
) => {
// Scanner state
const isScanning = ref(false)
const hasCamera = ref(false)
const cameraChecking = ref(true)
const cameraReady = ref(false)
let html5QrCode: any = null
const qrCodeId = QR_CODE_ID
let lastScannedQR: string | null = null
let lastScanTime: number = 0
let isProcessing = false // Flag untuk mencegah pemrosesan berulang
// Daftar QR code yang sudah berhasil di-scan (untuk mencegah double antrian)
const successfulScans = ref<Set<string>>(new Set())
// Detect mobile device
const isMobile = ref(false)
if (typeof window !== 'undefined') {
isMobile.value = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) ||
(window.innerWidth <= 768)
}
// Load successful scans dari localStorage
const loadSuccessfulScans = () => {
if (typeof window !== 'undefined') {
const stored = localStorage.getItem(SUCCESSFUL_SCANS_KEY)
if (stored) {
try {
const scansArray = JSON.parse(stored)
successfulScans.value = new Set(scansArray)
} catch (e) {
console.error('Error loading successful scans:', e)
successfulScans.value = new Set()
}
}
}
}
// Simpan successful scan ke localStorage
const saveSuccessfulScan = (qrData: string) => {
if (typeof window !== 'undefined') {
successfulScans.value.add(qrData)
const scansArray = Array.from(successfulScans.value)
localStorage.setItem(SUCCESSFUL_SCANS_KEY, JSON.stringify(scansArray))
}
}
// Cek apakah QR code sudah pernah berhasil di-scan
const isQRCodeAlreadyScanned = (qrData: string): boolean => {
return successfulScans.value.has(qrData)
}
// Check camera availability
const checkCameraAvailability = async () => {
cameraChecking.value = true
hasCamera.value = false
// Check if browser supports mediaDevices
if (typeof navigator === 'undefined' || !navigator.mediaDevices) {
console.error('navigator.mediaDevices is not supported')
showSnackbar('Error', 'Browser tidak mendukung akses kamera. Pastikan menggunakan HTTPS atau localhost.', 'error', 'mdi-camera-off')
cameraChecking.value = false
return
}
try {
// First, request permission by trying to get user media
// This is required for enumerateDevices to return device labels
const stream = await navigator.mediaDevices.getUserMedia({ video: true })
// Stop the stream immediately after getting permission
stream.getTracks().forEach(track => track.stop())
// Now enumerate devices (will have labels after permission granted)
const devices = await navigator.mediaDevices.enumerateDevices()
const videoDevices = devices.filter(device => device.kind === 'videoinput')
hasCamera.value = videoDevices.length > 0
if (hasCamera.value) {
console.log(`Found ${videoDevices.length} camera(s):`, videoDevices.map(d => d.label || d.deviceId))
} else {
console.warn('No video input devices found')
showSnackbar('Warning', 'Tidak ada kamera yang terdeteksi', 'warning', 'mdi-camera-off')
}
} catch (err: any) {
console.error('Error checking camera:', err)
hasCamera.value = false
let errorMessage = 'Gagal mengakses kamera. '
if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') {
errorMessage += 'Izin kamera ditolak. Mohon izinkan akses kamera di pengaturan browser.'
} else if (err.name === 'NotFoundError' || err.name === 'DevicesNotFoundError') {
errorMessage += 'Tidak ada kamera yang ditemukan.'
} else if (err.name === 'NotReadableError' || err.name === 'TrackStartError') {
errorMessage += 'Kamera sedang digunakan aplikasi lain.'
} else if (err.name === 'OverconstrainedError' || err.name === 'ConstraintNotSatisfiedError') {
errorMessage += 'Kamera tidak memenuhi persyaratan.'
} else {
errorMessage += `Error: ${err.message || err.name}`
}
showSnackbar('Error', errorMessage, 'error', 'mdi-camera-off')
} finally {
cameraChecking.value = false
}
}
// Test camera function for debugging
const testCamera = async () => {
try {
showSnackbar('Info', 'Menguji akses kamera...', 'info', 'mdi-camera')
if (typeof navigator === 'undefined') {
showSnackbar('Error', 'navigator is undefined - pastikan di browser, bukan SSR', 'error', 'mdi-alert')
return
}
if (!navigator.mediaDevices) {
showSnackbar('Error', 'navigator.mediaDevices is undefined - pastikan menggunakan HTTPS atau localhost', 'error', 'mdi-alert')
return
}
if (!navigator.mediaDevices.getUserMedia) {
showSnackbar('Error', 'getUserMedia is not supported', 'error', 'mdi-alert')
return
}
const stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'user',
width: { ideal: 640 },
height: { ideal: 480 }
}
})
showSnackbar('Success', 'Kamera berhasil diakses!', 'success', 'mdi-check-circle')
// Stop stream after 2 seconds
setTimeout(() => {
stream.getTracks().forEach(track => track.stop())
}, 2000)
} catch (err: any) {
console.error('Test camera error:', err)
let errorMsg = `Error: ${err.name || 'Unknown'}`
if (err.message) errorMsg += ` - ${err.message}`
showSnackbar('Error', errorMsg, 'error', 'mdi-alert')
}
}
// Handle QR scan success
const handleQRScanSuccess = (decodedText: string) => {
// Validasi data QR - cek di awal sebelum log apapun
if (!decodedText || decodedText.trim() === '') {
return // Silent return untuk empty QR code
}
// Cegah pemrosesan berulang jika sedang memproses
if (isProcessing) {
return // Silent return untuk mencegah spam log
}
// Debounce: cegah scan berulang dalam waktu singkat - cek sebelum log
const now = Date.now()
if (lastScannedQR === decodedText && (now - lastScanTime) < SCAN_DEBOUNCE_MS) {
return // Silent return untuk debounce
}
// Cek apakah QR code sudah pernah berhasil di-scan (mencegah double antrian)
if (isQRCodeAlreadyScanned(decodedText)) {
console.log('⏭️ Scan diabaikan: QR code sudah pernah berhasil di-scan')
showSnackbar('Peringatan', 'QR Code ini sudah pernah berhasil di-scan. Tidak dapat digunakan lagi untuk mencegah double antrian.', 'warning', 'mdi-alert-circle')
return
}
// Set flag processing dan update last scan info
isProcessing = true
lastScannedQR = decodedText
lastScanTime = now
console.log('🎯 QR Scan Success! Data:', decodedText)
// Scanner tetap berjalan untuk memungkinkan scan QR code berikutnya
// Proses data QR melalui callback
try {
onQRDetected(decodedText)
} catch (error) {
console.error('Error processing QR data:', error)
showSnackbar('Error', 'Gagal memproses data QR Code', 'error', 'mdi-alert')
} finally {
// Reset flag setelah selesai memproses (dengan delay kecil untuk memastikan callback selesai)
setTimeout(() => {
isProcessing = false
}, SCAN_DEBOUNCE_MS)
}
}
// QR Scanner Functions
const startScanning = async () => {
// Check camera first
if (!hasCamera.value) {
await checkCameraAvailability()
if (!hasCamera.value) {
showSnackbar('Error', 'Kamera tidak tersedia. Silakan gunakan input manual atau pastikan kamera terhubung.', 'error', 'mdi-camera-off')
return
}
}
try {
// Set scanning state first to render the element
isScanning.value = true
// Wait for DOM to update and element to be rendered
await nextTick()
// Wait a bit more to ensure element is fully rendered
await new Promise(resolve => setTimeout(resolve, 100))
// Check if element exists
const qrElement = document.getElementById(qrCodeId)
if (!qrElement) {
console.error(`Element with id "${qrCodeId}" not found in DOM`)
isScanning.value = false
showSnackbar('Error', 'Element scanner tidak ditemukan. Silakan refresh halaman.', 'error', 'mdi-alert')
return
}
// Dynamic import html5-qrcode
if (!html5QrCode) {
const { Html5Qrcode: Html5QrcodeClass } = await import('html5-qrcode')
html5QrCode = new Html5QrcodeClass(qrCodeId)
console.log('Html5Qrcode initialized')
}
if (html5QrCode) {
cameraReady.value = false
// Konfigurasi kamera untuk mobile dan desktop
const cameraConfig = isMobile.value
? { facingMode: "environment" } // Mobile: gunakan kamera belakang
: { facingMode: "user" } // Desktop: gunakan kamera depan (webcam)
// Video constraints yang lebih fleksibel
const baseVideoConstraints = isMobile.value
? {
facingMode: "environment",
width: { ideal: 640, min: 320 },
height: { ideal: 480, min: 240 }
}
: {
facingMode: "user",
width: { ideal: 1280, min: 640 },
height: { ideal: 720, min: 480 }
}
// Tambahkan advanced constraints hanya jika didukung
const videoConstraints: any = { ...baseVideoConstraints }
console.log('Starting QR scanner with config:', {
isMobile: isMobile.value,
cameraConfig,
videoConstraints
})
try {
await html5QrCode.start(
cameraConfig,
{
fps: isMobile.value ? 15 : 20, // FPS optimal untuk deteksi
qrbox: function(viewfinderWidth: number, viewfinderHeight: number) {
// QR box dengan ukuran lebih besar untuk deteksi lebih mudah
const minEdgeSize = Math.min(viewfinderWidth, viewfinderHeight)
// Gunakan 80-90% dari viewfinder untuk area scanning yang lebih besar
const percentageSize = Math.floor(minEdgeSize * 0.80)
// Atau ukuran tetap yang lebih besar
const fixedSize = isMobile.value ? 250 : 250
// Gunakan yang lebih besar antara percentage atau fixed
const qrboxSize = Math.max(percentageSize, fixedSize)
// Pastikan tidak melebihi viewfinder
const finalSize = Math.min(qrboxSize, minEdgeSize * 0.80)
console.log('QR Box size:', finalSize, 'Viewfinder:', viewfinderWidth, 'x', viewfinderHeight)
return {
width: Math.max(finalSize, 250), // Minimum 250px untuk deteksi lebih baik
height: Math.max(finalSize, 250)
}
},
aspectRatio: 1.0,
disableFlip: false,
videoConstraints: videoConstraints,
rememberLastUsedCamera: true,
showTorchButtonIfSupported: true,
// Tambahkan opsi untuk meningkatkan deteksi
verbose: false // Set true untuk debugging
},
(decodedText: string, decodedResult: any) => {
// QR Code berhasil di-scan
console.log('✅ QR Code detected:', decodedText)
console.log('📊 Decoded result:', decodedResult)
// Validasi dan proses QR code
if (decodedText && decodedText.trim() !== '') {
handleQRScanSuccess(decodedText)
} else {
console.warn('Empty QR code detected')
}
},
(errorMessage: string) => {
// Log error untuk debugging - hanya log error penting
// Error ini biasanya muncul terus menerus saat tidak ada QR code yang terdeteksi
// Jadi kita filter untuk menghindari spam console
if (errorMessage) {
// Filter out common non-critical errors
const isCriticalError = !errorMessage.includes('NotFoundException') &&
!errorMessage.includes('No QR') &&
!errorMessage.includes('QR code parse error') &&
!errorMessage.includes('QR code parse error, error') &&
!errorMessage.includes('QR code decode error')
if (isCriticalError) {
console.warn('QR Scanner warning:', errorMessage)
}
}
}
)
// Set camera ready setelah sedikit delay untuk memastikan video sudah dimuat
setTimeout(() => {
cameraReady.value = true
console.log('Camera ready, scanner is active')
}, 500)
showSnackbar('Info', 'Scanner aktif. Arahkan kamera ke QR code dan pastikan pencahayaan cukup', 'info', 'mdi-camera')
} catch (cameraError: any) {
console.error('Camera error:', cameraError)
// Jika kamera belakang tidak tersedia di mobile, coba kamera depan
if (isMobile.value && cameraError.name === 'NotFoundError') {
try {
console.log('Trying front camera as fallback...')
await html5QrCode.start(
{ facingMode: "user" },
{
fps: 15,
qrbox: function(viewfinderWidth: number, viewfinderHeight: number) {
const minEdgeSize = Math.min(viewfinderWidth, viewfinderHeight)
const percentageSize = Math.floor(minEdgeSize * 0.85)
const fixedSize = 300
const qrboxSize = Math.max(percentageSize, fixedSize)
const finalSize = Math.min(qrboxSize, minEdgeSize * 0.95)
return {
width: Math.max(finalSize, 250),
height: Math.max(finalSize, 250)
}
},
aspectRatio: 1.0,
disableFlip: false,
videoConstraints: {
facingMode: "user",
width: { ideal: 640, min: 320 },
height: { ideal: 480, min: 240 }
},
rememberLastUsedCamera: true,
verbose: false
},
(decodedText: string, decodedResult: any) => {
console.log('✅ QR Code detected (front camera):', decodedText)
if (decodedText && decodedText.trim() !== '') {
handleQRScanSuccess(decodedText)
}
},
(errorMessage: string) => {
if (errorMessage) {
const isCriticalError = !errorMessage.includes('NotFoundException') &&
!errorMessage.includes('No QR') &&
!errorMessage.includes('QR code parse error') &&
!errorMessage.includes('QR code decode error')
if (isCriticalError) {
console.warn('QR Scanner (front) warning:', errorMessage)
}
}
}
)
setTimeout(() => {
cameraReady.value = true
}, 500)
showSnackbar('Info', 'Menggunakan kamera depan. Arahkan QR code ke kamera', 'info', 'mdi-camera')
} catch (fallbackError: any) {
console.error('Fallback camera also failed:', fallbackError)
throw cameraError // Throw original error
}
} else {
// Coba menggunakan deviceId langsung jika facingMode gagal
try {
console.log('Trying to get camera devices...')
const devices = await navigator.mediaDevices.enumerateDevices()
const videoDevices = devices.filter(device => device.kind === 'videoinput')
if (videoDevices.length > 0) {
// Gunakan kamera pertama yang tersedia
const deviceId = videoDevices[0].deviceId
console.log('Using device:', deviceId, videoDevices[0].label)
await html5QrCode.start(
{ deviceId: { exact: deviceId } },
{
fps: isMobile.value ? 15 : 20,
qrbox: function(viewfinderWidth: number, viewfinderHeight: number) {
const minEdgeSize = Math.min(viewfinderWidth, viewfinderHeight)
const percentageSize = Math.floor(minEdgeSize * 0.80)
const fixedSize = isMobile.value ? 300 : 400
const qrboxSize = Math.max(percentageSize, fixedSize)
const finalSize = Math.min(qrboxSize, minEdgeSize * 0.80)
return {
width: Math.max(finalSize, 250),
height: Math.max(finalSize, 250)
}
},
aspectRatio: 1.0,
disableFlip: false,
videoConstraints: {
deviceId: { exact: deviceId },
width: { ideal: isMobile.value ? 640 : 1280, min: isMobile.value ? 320 : 640 },
height: { ideal: isMobile.value ? 480 : 720, min: isMobile.value ? 240 : 480 }
},
rememberLastUsedCamera: true,
verbose: false
},
(decodedText: string, decodedResult: any) => {
console.log('✅ QR Code detected (deviceId):', decodedText)
if (decodedText && decodedText.trim() !== '') {
handleQRScanSuccess(decodedText)
}
},
(errorMessage: string) => {
if (errorMessage) {
const isCriticalError = !errorMessage.includes('NotFoundException') &&
!errorMessage.includes('No QR') &&
!errorMessage.includes('QR code parse error') &&
!errorMessage.includes('QR code decode error')
if (isCriticalError) {
console.warn('QR Scanner (deviceId) warning:', errorMessage)
}
}
}
)
setTimeout(() => {
cameraReady.value = true
}, 500)
showSnackbar('Info', 'Scanner aktif menggunakan kamera yang tersedia', 'info', 'mdi-camera')
} else {
throw cameraError
}
} catch (deviceError: any) {
console.error('DeviceId approach also failed:', deviceError)
throw cameraError
}
}
}
}
} catch (err: any) {
console.error('Error starting scanner:', err)
isScanning.value = false
cameraReady.value = false
// Don't set hasCamera to false if it was detected, just log the error
let errorMessage = 'Gagal memulai scanner. '
if (err.message && err.message.includes('not found')) {
errorMessage = 'Element scanner tidak ditemukan. Silakan refresh halaman dan coba lagi.'
console.error('Element not found error. Element ID:', qrCodeId)
} else if (err.name === 'NotAllowedError') {
errorMessage = 'Akses kamera ditolak. Mohon izinkan akses kamera di pengaturan browser.'
} else if (err.name === 'NotFoundError') {
errorMessage = 'Kamera tidak ditemukan. Silakan gunakan input manual.'
hasCamera.value = false
} else if (err.name === 'NotReadableError') {
errorMessage = 'Kamera sedang digunakan aplikasi lain. Tutup aplikasi lain yang menggunakan kamera.'
} else {
errorMessage += err.message || err.name || 'Unknown error'
}
showSnackbar('Error', errorMessage, 'error', 'mdi-alert')
}
}
const stopScanning = async () => {
const scannerInstance = html5QrCode
// Reset state immediately to update UI
isScanning.value = false
cameraReady.value = false
// Clear reference immediately to prevent re-entry
html5QrCode = null
// If no instance, nothing to clean up
if (!scannerInstance) {
return
}
try {
// Stop the scanner (this stops the camera stream)
if (typeof scannerInstance.stop === 'function') {
await scannerInstance.stop().catch((err: any) => {
// Ignore stop errors - scanner might already be stopped
console.warn('Scanner stop error (ignored):', err?.message || err)
})
}
} catch (err: any) {
// Ignore errors from stop
console.warn('Error stopping scanner (ignored):', err?.message || err)
}
try {
// Clear the scanner (this cleans up DOM and resources)
// Only try to clear if stop was successful or if clear method exists
if (typeof scannerInstance.clear === 'function') {
await scannerInstance.clear().catch((err: any) => {
// Ignore clear errors - DOM might already be cleaned up
console.warn('Scanner clear error (ignored):', err?.message || err)
})
}
} catch (err: any) {
// Ignore errors from clear
console.warn('Error clearing scanner (ignored):', err?.message || err)
}
// Show notification after cleanup attempts
try {
showSnackbar('Info', 'Scanner dihentikan', 'info', 'mdi-camera-off')
} catch (e) {
// Ignore snackbar errors
}
}
// Initialize on mount
if (typeof window !== 'undefined') {
loadSuccessfulScans()
}
return {
// State
isScanning,
hasCamera,
cameraChecking,
cameraReady,
// Functions
checkCameraAvailability,
testCamera,
startScanning,
stopScanning,
saveSuccessfulScan,
isQRCodeAlreadyScanned,
}
}
+213 -38
View File
@@ -1,9 +1,18 @@
// composables/useQueue.js
import { ref, computed } from "vue";
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 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
const snackbar = ref(false);
@@ -26,22 +35,43 @@ export const useQueue = (adminType = "loket") => {
return patients.value;
});
// Computed from store - filtered by stage
// Computed from store - isolated by specificId if provided
const currentProcessingPatient = computed(() => {
return queueStore.currentProcessingPatient[adminType];
const key = idValue.value ? `${adminType}-${idValue.value}` : adminType;
return queueStore.currentProcessingPatient[key];
});
// Derive from stagePatients
const diLoketPatients = computed(() => stagePatients.value.diLoket || []);
// Derive from stagePatients - ADD DEBUG LOGS
const diLoketPatients = computed(() => {
const patients = stagePatients.value.diLoket || [];
console.log('🔍 useQueue - diLoketPatients:', patients.length);
if (patients.length > 0) {
console.log('🔍 First diLoket patient:', patients[0]);
console.log('🔍 First diLoket patient fastTrack:', patients[0].fastTrack);
}
return patients;
});
const terlambatPatients = computed(() => stagePatients.value.terlambat || []);
const terlambatPatients = computed(() => {
const patients = stagePatients.value.terlambat || [];
console.log('🔍 useQueue - terlambatPatients:', patients.length);
return patients;
});
const pendingPatients = computed(() => stagePatients.value.pending || []);
const pendingPatients = computed(() => {
const patients = stagePatients.value.pending || [];
console.log('🔍 useQueue - pendingPatients:', patients.length);
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(() => {
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
@@ -50,28 +80,99 @@ export const useQueue = (adminType = "loket") => {
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
const resetPatients = () => queueStore.resetPatients();
// Filtered lists
const filteredKliniks = computed(() => {
if (!klinikSearch.value) return queueStore.kliniks;
return queueStore.kliniks.filter((k) =>
k.name.toLowerCase().includes(klinikSearch.value.toLowerCase())
);
let result = (queueStore.kliniks.value || queueStore.kliniks || []).filter(k => k.jenisLayanan === 'Reguler');
if (klinikSearch.value) {
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(() => {
if (!penunjangSearch.value) return queueStore.penunjangs;
return queueStore.penunjangs.filter((p) =>
p.name.toLowerCase().includes(penunjangSearch.value.toLowerCase())
);
let result = queueStore.penunjangs;
if (penunjangSearch.value) {
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(() => {
if (!changeKlinikSearch.value) return queueStore.kliniks;
return queueStore.kliniks.filter((k) =>
k.name.toLowerCase().includes(changeKlinikSearch.value.toLowerCase())
);
let result = (queueStore.kliniks.value || queueStore.kliniks || []).filter(k => k.jenisLayanan === 'Reguler');
if (changeKlinikSearch.value) {
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
@@ -81,36 +182,92 @@ export const useQueue = (adminType = "loket") => {
snackbar.value = true;
};
const callNext = () => {
const result = queueStore.callNext(adminType);
const callNext = (specificIdOverride = null) => {
const targetId = specificIdOverride || idValue.value;
const result = queueStore.callNext(adminType, targetId);
showSnackbar(result.message, result.success ? "success" : "warning");
};
const callMultiplePatients = (count) => {
const result = queueStore.callMultiplePatients(count, adminType);
const callMultiplePatients = (count, specificIdOverride = null) => {
const targetId = specificIdOverride || idValue.value;
const result = queueStore.callMultiplePatients(count, adminType, targetId);
showSnackbar(result.message, result.success ? "success" : "warning");
};
const processPatient = (patient, action) => {
const result = queueStore.processPatient(patient, action, adminType);
const processPatient = async (patient, action) => {
const result = await queueStore.processPatient(patient, action, adminType, idValue.value);
let color = "success";
if (action === "terlambat") color = "warning";
else if (action === "pending") color = "info";
showSnackbar(result.message, color);
return result;
};
// Helper function untuk mendapatkan pasien yang sedang diproses dari store
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 result = queueStore.createAntreanKlinik(klinik, 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");
showKlinikDialog.value = false;
};
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(
penunjang,
selectedPatientForPenunjang.value,
patient,
adminType
);
showSnackbar(result.message, "success");
@@ -124,15 +281,31 @@ export const useQueue = (adminType = "loket") => {
};
const changeKlinik = (klinik) => {
if (currentProcessingPatient.value) {
const result = queueStore.changeKlinik(
currentProcessingPatient.value,
klinik,
adminType
);
showSnackbar(result.message, result.success ? "success" : "error");
// Pastikan currentProcessingPatient valid, jika tidak, coba dapatkan dari store
let patient = currentProcessingPatient.value || getCurrentProcessingPatientFromStore();
if (!patient) {
showSnackbar("Tidak ada pasien yang sedang diproses", "error");
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 = async () => {
const result = await queueStore.processNextQueue(adminType, idValue.value);
showSnackbar(result.message, result.success ? "success" : "warning");
return result;
};
const getRowClass = (item) => {
@@ -160,7 +333,8 @@ export const useQueue = (adminType = "loket") => {
diLoketPatients,
terlambatPatients,
pendingPatients,
waitingPatients,
anjunganPatients,
menungguPatients,
nextPatient,
totalPasien,
quotaUsed,
@@ -178,6 +352,7 @@ export const useQueue = (adminType = "loket") => {
selectPenunjang,
openPenunjangDialog,
changeKlinik,
processNextQueue,
getRowClass,
};
};
+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
};
};
+44
View File
@@ -0,0 +1,44 @@
import { ref, computed, readonly } from 'vue'
import type { SnackbarState } from '~/types/checkin'
export const useSnackbar = () => {
const snackbar = ref<SnackbarState>({
show: false,
title: '',
message: '',
color: '',
icon: '',
timeout: 4000,
})
const showSnackbar = (
title: string,
message: string,
color: string,
icon: string,
timeout = 4000
) => {
snackbar.value = {
show: true,
title,
message,
color,
icon,
timeout,
}
}
// Computed untuk snackbar.show agar v-model bisa bekerja
const snackbarShow = computed({
get: () => snackbar.value.show,
set: (value: boolean) => {
snackbar.value.show = value
}
})
return {
snackbar: readonly(snackbar),
snackbarShow,
showSnackbar,
}
}
+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
+23
View File
@@ -0,0 +1,23 @@
export const PRIMARY_COLOR = '#1565C0'
export const SECONDARY_COLOR = '#FB8C00'
export const QR_CODE_ID = 'qr-reader'
export const SCAN_DEBOUNCE_MS = 2000 // 2 detik debounce untuk mencegah scan berulang
export const HISTORY_STORAGE_KEY = 'checkin_history'
export const SCANNED_QR_STORAGE_KEY = 'scanned_qr_data'
export const SUCCESSFUL_SCANS_KEY = 'successful_qr_scans'
export const MAX_HISTORY_ITEMS = 100
export const MAX_SCANNED_QR_ITEMS = 50
export const HISTORY_STATUS_OPTIONS = [
{ title: 'Berhasil', value: 'success' },
{ title: 'Gagal', value: 'failed' },
{ title: 'Pending', value: 'pending' }
] as const
export const QR_STATUS_OPTIONS = [
{ title: 'Diizinkan Check-in', value: 'ALLOWED' },
{ title: 'Belum Diizinkan', value: 'NOT_ALLOWED' }
] as const
+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')
})
})
Loaded 100 of 253 files, more files were not shown because too many files have changed in this diff. Show more