130 Commits
Author SHA1 Message Date
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
172 changed files with 56919 additions and 14120 deletions

No files matched your search

+36
View File
@@ -0,0 +1,36 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Database files
data/
*.db
*.sqlite
*.sqlite3
# Node dependencies
node_modules
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# IDEs and OS files
.DS_Store
.fleet
.idea
.vscode
Thumbs.db
# Local env files
.env
.env.*
!.env.example
!.env.docker.example
+87
View File
@@ -0,0 +1,87 @@
# # .env
# # A random string used to hash tokens, sign cookies, and generate cryptographic keys.
# # You can generate one here: https://generate-secret.vercel.app/32
# NUXT_AUTH_SECRET="your-super-secret-string-of-at-least-32-characters"
# # The base URL of your application
# AUTH_ORIGIN="http://localhost:3000"
# # Keycloak Credentials
# # Get these from your Keycloak client configuration
# KEYCLOAK_CLIENT_ID="nuxt-app"
# KEYCLOAK_CLIENT_SECRET="OaB90t1HEEFBn31PUX4qELWHwErnwFtg"
# KEYCLOAK_ISSUER="http://localhost:8080/realms/rssa-app"
# .env
# A random string used to hash tokens, sign cookies, and generate cryptographic keys.
# You can generate one here: https://generate-secret.vercel.app/32
NUXT_AUTH_SECRET="your-super-secret-string-of-at-least-32-characters"
# The base URL of your application
# Keycloak Credentials
# Get these from your Keycloak client configuration
# KEYCLOAK_CLIENT_ID="nuxt-app"
# KEYCLOAK_CLIENT_SECRET="OaB90t1HEEFBn31PUX4qELWHwErnwFtg"
# KEYCLOAK_ISSUER="http://localhost:8080/realms/rssa-app"
KEYCLOAK_CLIENT_ID="akbar-test"
KEYCLOAK_CLIENT_SECRET="FDyv3UYMgJOYPnvzXVVv6diRtcgEevKg"
KEYCLOAK_ISSUER="https://auth.rssa.top/realms/sandbox"
SESSION_DURATION_HOURS=1
OAUTH_STATE_DURATION_MINUTES=10
#AUTH ORIGIN UNTUK ACCESS APLIKASI login dari KEYCLOAK
# AUTH_ORIGIN="http://10.10.150.175:3000"
# AUTH_ORIGIN="0.0.0.0"
# AUTH_ORIGIN="http://localhost:3000"
AUTH_ORIGIN="https://antrean.dev.rssa.id"
# AUTH_ORIGIN="https://antrean.rssa.id"
#HOST UNTUK ACCESS APLIKASI
# HOST="http://localhost:3000"
# HOST="http://10.10.150.175:3000"
# HOST="https://antrean.rssa.id"
# HOST="https://antrean.dev.rssa.id"
HOST="0.0.0.0"
# HOST=
# Custom Keycloak logout endpoint UBAH SESUAI REALM DAN DOMAIN KEYCLOAK ANDA
KEYCLOAK_LOGOUT_URI="https://auth.rssa.top/realms/sandbox/protocol/openid-connect/logout"
# Pilih salah satu dari dua opsi di bawah ini sesuai kebutuhan Anda:
# digunakan untuk redirect setelah logout dari KEYCLOAK
# Untuk redirect tanpa query parameter:
# POST_LOGOUT_REDIRECT_URI="http://10.10.150.175:3000/LoginPage"
# POST_LOGOUT_REDIRECT_URI="http://localhost:3000/LoginPage"
POST_LOGOUT_REDIRECT_URI="https://antrean.dev.rssa.id/LoginPage"
# POST_LOGOUT_REDIRECT_URI=
# ATAU untuk redirect dengan query parameter:
# POST_LOGOUT_REDIRECT_URI="https://antrean.rssa.id/LoginPage?logout=success"
# POST_LOGOUT_REDIRECT_URI="https://antrean.dev.rssa.id/LoginPage?logout=success"
# ATAU untuk redirect dengan query parameter:
# POST_LOGOUT_REDIRECT_URI="https://antrean.rssa.id/LoginPage?logout=success"
# POST_LOGOUT_REDIRECT_URI="https://antrean.dev.rssa.id/LoginPage?logout=success"
# External API Configuration
# API eksternal untuk validasi JWT token
EXTERNAL_API_BASE_URL="http://10.10.150.100:8084"
EXTERNAL_API_TIMEOUT=10000
# API untuk ekstrak expertise dan verifikasi akun
# Digunakan di pages/verifikasiAkun/VerifikasiAkun.vue
EKSTRAK_EXPERTISE_URL="http://10.10.123.218/ekstrakexpertise"
VERIFICATION_API_BASE_URL="http://10.10.123.140:8089/api/v1"
# Simplified Modular API URLs (New Config)
ANTRIAN_API_URL="http://10.10.123.140:8089/api/v1"
VISIT_API_URL="http://10.10.123.135:8084/api/v1"
WS_API_URL="ws://10.10.123.135:8084/api/v1/ws"
+1
View File
@@ -0,0 +1 @@
setups.@nuxt/test-utils="3.23.0"
+371
View File
@@ -0,0 +1,371 @@
# Checklist Perubahan Domain
Ketika mengubah domain aplikasi, ikuti checklist berikut:
## 1. File Environment (.env)
**Ubah `AUTH_ORIGIN` sesuai domain baru:**
### Development:
```env
AUTH_ORIGIN="https://antrean.dev.rssa.id"
```
### Production:
```env
AUTH_ORIGIN="https://antrean.rssa.id"
```
**Lokasi:** `.env` (atau `.env.development` / `.env.production`)
---
## 2. Keycloak Configuration
**Di Keycloak Admin Console → Clients → [Your Client] → Settings:**
### Valid Redirect URIs:
Tambahkan:
- `https://antrean.dev.rssa.id/api/auth/keycloak-callback` (development)
- `https://antrean.rssa.id/api/auth/keycloak-callback` (production)
### Valid Post Logout Redirect URIs:
Tambahkan:
- `https://antrean.dev.rssa.id/LoginPage*` (development)
- `https://antrean.rssa.id/LoginPage*` (production)
### Web Origins:
Tambahkan:
- `https://antrean.dev.rssa.id` (development)
- `https://antrean.rssa.id` (production)
**Catatan:** Gunakan wildcard `*` untuk post logout redirect agar bisa handle query parameters.
---
## 3. nuxt.config.ts (Opsional)
**Jika menggunakan IP address untuk development:**
```typescript
devServer: {
port: 3000,
host: '0.0.0.0' // atau IP address jika perlu
}
```
**Untuk production dengan domain, biasanya tidak perlu diubah.**
---
## 4. Kubernetes Configuration
### 4.1. ConfigMap (untuk non-sensitive environment variables)
**Buat atau update ConfigMap:**
```yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: antrean-config
namespace: default # atau namespace Anda
data:
AUTH_ORIGIN: "https://antrean.rssa.id" # atau https://antrean.dev.rssa.id untuk dev
KEYCLOAK_ISSUER: "https://auth.rssa.top/realms/sandbox"
KEYCLOAK_CLIENT_ID: "akbar-test"
```
**Atau gunakan kubectl:**
```bash
kubectl create configmap antrean-config \
--from-literal=AUTH_ORIGIN=https://antrean.rssa.id \
--from-literal=KEYCLOAK_ISSUER=https://auth.rssa.top/realms/sandbox \
--from-literal=KEYCLOAK_CLIENT_ID=akbar-test \
-n <your-namespace>
```
### 4.2. Secret (untuk sensitive data)
**Buat Secret untuk credentials:**
```yaml
apiVersion: v1
kind: Secret
metadata:
name: antrean-secrets
namespace: default
type: Opaque
stringData:
KEYCLOAK_CLIENT_SECRET: "your-secret-here"
NUXT_AUTH_SECRET: "your-super-secret-string-of-at-least-32-characters"
```
**Atau gunakan kubectl:**
```bash
kubectl create secret generic antrean-secrets \
--from-literal=KEYCLOAK_CLIENT_SECRET=your-secret \
--from-literal=NUXT_AUTH_SECRET=your-auth-secret \
-n <your-namespace>
```
### 4.3. Ingress (untuk domain routing)
**Update Ingress dengan domain baru:**
```yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: antrean-ingress
namespace: default
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod" # atau issuer Anda
nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
ingressClassName: nginx # atau ingress class Anda
tls:
- hosts:
- antrean.rssa.id
- antrean.dev.rssa.id
secretName: antrean-tls-secret
rules:
- host: antrean.rssa.id
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: antrean-service
port:
number: 3000
- host: antrean.dev.rssa.id
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: antrean-dev-service
port:
number: 3000
```
### 4.4. Deployment (update environment variables)
**Update Deployment untuk menggunakan ConfigMap dan Secret:**
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: antrean-app
namespace: default
spec:
replicas: 2
selector:
matchLabels:
app: antrean
template:
metadata:
labels:
app: antrean
spec:
containers:
- name: antrean
image: your-registry/antrean:latest
ports:
- containerPort: 3000
env:
# Dari ConfigMap
- name: AUTH_ORIGIN
valueFrom:
configMapKeyRef:
name: antrean-config
key: AUTH_ORIGIN
- name: KEYCLOAK_ISSUER
valueFrom:
configMapKeyRef:
name: antrean-config
key: KEYCLOAK_ISSUER
- name: KEYCLOAK_CLIENT_ID
valueFrom:
configMapKeyRef:
name: antrean-config
key: KEYCLOAK_CLIENT_ID
# Dari Secret
- name: KEYCLOAK_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: antrean-secrets
key: KEYCLOAK_CLIENT_SECRET
- name: NUXT_AUTH_SECRET
valueFrom:
secretKeyRef:
name: antrean-secrets
key: NUXT_AUTH_SECRET
envFrom:
# Atau load semua dari ConfigMap (opsional)
# - configMapRef:
# name: antrean-config
```
**Atau update dengan kubectl:**
```bash
kubectl set env deployment/antrean-app \
AUTH_ORIGIN=https://antrean.rssa.id \
--from=configmap/antrean-config \
-n <your-namespace>
```
### 4.5. Service (biasanya tidak perlu diubah)
Service biasanya tidak perlu diubah karena hanya routing internal.
### 4.6. Rollout/Restart Deployment
**Setelah update ConfigMap/Secret, restart pods:**
```bash
# Method 1: Rolling restart
kubectl rollout restart deployment/antrean-app -n <your-namespace>
# Method 2: Delete pods (akan auto-recreate)
kubectl delete pods -l app=antrean -n <your-namespace>
# Method 3: Scale down then up
kubectl scale deployment antrean-app --replicas=0 -n <your-namespace>
kubectl scale deployment antrean-app --replicas=2 -n <your-namespace>
```
### 4.7. Verifikasi di Kubernetes
```bash
# Cek ConfigMap
kubectl get configmap antrean-config -n <namespace> -o yaml
# Cek Secret (values akan di-encode base64)
kubectl get secret antrean-secrets -n <namespace> -o yaml
# Cek Ingress
kubectl get ingress antrean-ingress -n <namespace>
# Cek pods environment
kubectl exec -it <pod-name> -n <namespace> -- env | grep AUTH_ORIGIN
# Cek logs
kubectl logs -f deployment/antrean-app -n <namespace>
```
---
## 5. Server/Deployment Configuration (Non-Kubernetes)
### Nginx/Reverse Proxy (jika ada):
- Update `server_name` dengan domain baru
- Update SSL certificate untuk domain baru
- Pastikan proxy_pass mengarah ke aplikasi yang benar
### Docker (jika ada):
- Update environment variables di docker-compose.yml atau Dockerfile
- Update port mapping jika perlu
---
## 6. DNS Configuration
- Pastikan domain sudah pointing ke IP server yang benar
- Pastikan A record atau CNAME sudah dikonfigurasi
- Tunggu DNS propagation (bisa beberapa menit sampai 24 jam)
---
## 7. SSL Certificate
- Pastikan SSL certificate sudah diinstal untuk domain baru
- Pastikan certificate valid dan tidak expired
- Untuk production, gunakan Let's Encrypt atau certificate resmi
---
## 8. Restart Server/Deployment
**PENTING:** Setelah mengubah `.env`:
1. Stop server (Ctrl+C)
2. Start server lagi (`npm run dev` atau `npm run build && npm start`)
Environment variables hanya dimuat saat server start!
---
## 9. Verifikasi
Setelah semua perubahan, verifikasi:
1. **Cek log server saat login:**
```
🔧 AUTH_ORIGIN from config: https://antrean.dev.rssa.id
🔗 Redirect URI being sent to Keycloak: https://antrean.dev.rssa.id/api/auth/keycloak-callback
```
2. **Test login flow:**
- Login harus redirect ke Keycloak
- Setelah login, harus kembali ke aplikasi
- Tidak ada error "Invalid redirect URI"
3. **Test logout flow:**
- Logout harus redirect ke Keycloak
- Setelah logout, harus kembali ke login page
- Tidak ada error "Invalid redirect URI"
---
## File yang TIDAK Perlu Diubah
✅ **Kode aplikasi** - Sudah menggunakan `config.public.authUrl` dari environment variable
✅ **Server API handlers** - Sudah menggunakan `config.public.authUrl`
✅ **Components** - Tidak ada hardcoded domain
---
## Contoh Konfigurasi Lengkap
### Development (.env.development):
```env
AUTH_ORIGIN="https://antrean.dev.rssa.id"
KEYCLOAK_CLIENT_ID="akbar-test"
KEYCLOAK_CLIENT_SECRET="your-secret"
KEYCLOAK_ISSUER="https://auth.rssa.top/realms/sandbox"
NUXT_AUTH_SECRET="your-super-secret-string-of-at-least-32-characters"
```
### Production (.env.production):
```env
AUTH_ORIGIN="https://antrean.rssa.id"
KEYCLOAK_CLIENT_ID="akbar-test"
KEYCLOAK_CLIENT_SECRET="your-secret"
KEYCLOAK_ISSUER="https://auth.rssa.top/realms/sandbox"
NUXT_AUTH_SECRET="your-super-secret-string-of-at-least-32-characters"
```
---
## Troubleshooting
### Masih redirect ke domain lama?
- ✅ Pastikan server sudah restart
- ✅ Cek `.env` file sudah benar
- ✅ Clear browser cache
- ✅ Cek Keycloak configuration sudah benar
### Error "Invalid redirect URI"?
- ✅ Pastikan URI sudah ditambahkan di Keycloak
- ✅ Pastikan format URI sama persis (dengan/tanpa trailing slash)
- ✅ Pastikan menggunakan HTTPS jika domain menggunakan HTTPS
### Session tidak tersimpan?
- ✅ Pastikan cookie settings sesuai (secure: true untuk HTTPS)
- ✅ Cek browser console untuk cookie errors
- ✅ Pastikan domain di cookie sesuai dengan domain aplikasi
+332
View File
@@ -0,0 +1,332 @@
# Dashboard Dummy Data Documentation
Dokumentasi lengkap data dummy yang digunakan di `pages/Dashboard.vue`
## 📊 Statistics Cards (Header Mini Stats)
Data statistik mini yang ditampilkan di header:
```javascript
{
pasienHariIni: 324,
antreanAktif: 47
}
```
## 📈 Main Statistics Cards
Data untuk 4 kartu statistik utama:
### 1. Pasien Hari Ini
```javascript
{
label: 'Pasien Hari Ini',
value: '324',
icon: 'mdi-account-heart',
iconColor: 'white',
color: 'primary',
change: '+18.2%',
changeType: 'positive',
changeIcon: 'mdi-trending-up'
}
```
### 2. Antrean Aktif
```javascript
{
label: 'Antrean Aktif',
value: '47',
icon: 'mdi-clock-fast',
iconColor: 'white',
color: 'secondary',
change: '+5',
changeType: 'positive',
changeIcon: 'mdi-arrow-up'
}
```
### 3. Rata-rata Tunggu
```javascript
{
label: 'Rata-rata Tunggu',
value: '12 min',
icon: 'mdi-timer-outline',
iconColor: 'white',
color: 'success',
change: '-3 min',
changeType: 'positive',
changeIcon: 'mdi-trending-down'
}
```
### 4. Tingkat Kehadiran
```javascript
{
label: 'Tingkat Kehadiran',
value: '89.5%',
icon: 'mdi-check-circle',
iconColor: 'white',
color: 'primary',
change: '+2.3%',
changeType: 'positive',
changeIcon: 'mdi-trending-up'
}
```
---
## 📊 Chart 1: Tren Kunjungan Pasien (Line/Area Chart)
Chart yang menampilkan tren kunjungan pasien bulanan untuk tahun 2025.
### Labels (Bulan)
```javascript
[
"Jan",
"Feb",
"Mar",
"Apr",
"Mei",
"Jun",
"Jul",
"Ags",
"Sep",
"Okt",
"Nov",
"Des",
];
```
### Dataset 1: Pasien Umum
```javascript
{
label: 'Pasien Umum',
data: [850, 920, 880, 1050, 1120, 1080, 1200, 1150, 1080, 1190, 1250, 1300],
backgroundColor: 'rgba(86, 126, 231, 0.2)',
borderColor: '#567EE7' // primary-500
}
```
**Total Pasien Umum 2025**: 13,070 pasien
### Dataset 2: Pasien BPJS
```javascript
{
label: 'Pasien BPJS',
data: [1200, 1350, 1280, 1480, 1550, 1620, 1700, 1650, 1590, 1720, 1800, 1850],
backgroundColor: 'rgba(255, 132, 65, 0.2)',
borderColor: '#FF8441' // secondary-500
}
```
**Total Pasien BPJS 2025**: 18,790 pasien
**Grand Total**: 31,860 pasien sepanjang tahun 2025
---
## 💰 Chart 2: Status Pembayaran (Doughnut Chart)
Distribusi metode pembayaran pasien.
### Data
```javascript
{
labels: ['BPJS Kesehatan', 'Umum/Tunai', 'Asuransi Swasta', 'Corporate'],
datasets: [{
data: [1850, 680, 320, 280]
}]
}
```
### Breakdown Persentase
| Metode Pembayaran | Jumlah | Persentase |
| ----------------- | --------- | ---------- |
| BPJS Kesehatan | 1,850 | 58.6% |
| Umum/Tunai | 680 | 21.5% |
| Asuransi Swasta | 320 | 10.1% |
| Corporate | 280 | 8.9% |
| **Total** | **3,130** | **100%** |
---
## ⏱️ Chart 3: Waktu Tunggu per Poli (Horizontal Bar Chart)
Rata-rata waktu tunggu untuk setiap poliklinik (dalam menit).
### Data
```javascript
{
labels: ['Poli Umum', 'Poli Anak', 'Poli Gigi', 'Poli Mata', 'Poli THT', 'Poli Jantung'],
datasets: [{
label: 'Waktu Tunggu (menit)',
data: [15, 12, 8, 18, 10, 22]
}]
}
```
### Ranking Waktu Tunggu
| Rank | Poliklinik | Waktu Tunggu |
| ---- | ------------ | ------------ |
| 1 | Poli Gigi | 8 menit ⚡ |
| 2 | Poli THT | 10 menit |
| 3 | Poli Anak | 12 menit |
| 4 | Poli Umum | 15 menit |
| 5 | Poli Mata | 18 menit |
| 6 | Poli Jantung | 22 menit 🐌 |
**Rata-rata Total**: 14.17 menit
---
## ✅ Chart 4: Tingkat Kehadiran (Polar Area Chart)
Status kehadiran pasien dalam seminggu.
### Data
```javascript
{
labels: ['Hadir Tepat Waktu', 'Hadir Terlambat', 'Tidak Hadir', 'Batal', 'Reschedule'],
datasets: [{
data: [1850, 420, 280, 150, 230]
}]
}
```
### Breakdown Persentase
| Status | Jumlah | Persentase |
| -------------------- | --------- | ---------- |
| Hadir Tepat Waktu ✅ | 1,850 | 62.6% |
| Hadir Terlambat ⏰ | 420 | 14.2% |
| Tidak Hadir ❌ | 280 | 9.5% |
| Batal 🚫 | 150 | 5.1% |
| Reschedule 📅 | 230 | 7.8% |
| **Total** | **2,960** | **100%** |
**Tingkat Kehadiran Total** (Hadir Tepat Waktu + Hadir Terlambat): 76.8%
---
## 🎨 Color Palette
Palet warna yang digunakan dalam dashboard:
### Primary Colors
```javascript
{
700: '#3556AE',
600: '#3A61C9',
500: '#567EE7',
400: '#7898EC',
300: '#9AB2F1',
200: '#BBCBF5'
}
```
### Secondary Colors
```javascript
{
700: '#E65A0D',
600: '#F16F29',
500: '#FF8441',
400: '#FF9D67',
300: '#FFB58D'
}
```
### Success Colors
```javascript
{
700: '#008D65',
600: '#33A484',
500: '#59B59B',
400: '#80C6B2'
}
```
### Neutral Colors
```javascript
{
100: '#FFFFFF',
300: '#F5F7FA',
500: '#CDD4DC',
700: '#717171',
900: '#212121'
}
```
---
## 📤 Export Options
Menu export data yang tersedia:
```javascript
[
{ title: "JSON (.json)", type: "json", icon: "mdi-code-json" },
{ title: "CSV (.csv)", type: "csv", icon: "mdi-file-delimited" },
{
title: "Excel (.xlsx) - Server Only",
type: "excel",
icon: "mdi-file-excel",
},
{ title: "PDF (.pdf) - Placeholder", type: "pdf", icon: "mdi-file-pdf" },
];
```
---
## 📝 Summary Insights
### Total Visitors
- **Hari Ini**: 324 pasien (+18.2%)
- **Bulan Ini** (estimasi): ~2,617 pasien
- **Tahun 2025** (dari chart): 31,860 pasien
### Performance Metrics
- **Antrean Aktif**: 47 (+5)
- **Rata-rata Waktu Tunggu**: 12 menit (-3 min improvement)
- **Tingkat Kehadiran**: 89.5% (+2.3%)
- **Kehadiran Tepat Waktu**: 62.6%
### Top Performing Clinic
- **Tercepat**: Poli Gigi (8 menit)
- **Terlambat**: Poli Jantung (22 menit)
### Payment Distribution
- **BPJS dominan**: 58.6% dari total transaksi
- **Non-BPJS**: 41.4% (Umum + Asuransi + Corporate)
---
## 🔧 Usage Notes
File ini mendokumentasikan semua data dummy yang digunakan dalam komponen Dashboard.vue. Data ini dapat digunakan sebagai:
1. **Reference** untuk struktur data API
2. **Testing data** untuk pengembangan
3. **Template** untuk implementasi data real dari backend
4. **Documentation** untuk tim pengembang
Semua data bersifat **dummy/sample** dan harus diganti dengan data real dari API/backend saat production.
+51
View File
@@ -0,0 +1,51 @@
# Dockerfile for web-antrean Nuxt.js Application
# Multi-stage build for optimized image size and native modules support
# Stage 1: Build Stage
FROM node:20-alpine AS builder
# Install build dependencies for compiling native addons (like better-sqlite3)
RUN apk add --no-cache python3 make g++ gcc libc-dev
# Set working directory
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies (including devDependencies for build)
RUN npm ci
# Copy application files
COPY . .
# Build the application for production
RUN npm run build
# Stage 2: Production Stage
FROM node:20-alpine AS runner
# Install runtime dependencies (like libc6-compat for compatibility with precompiled binaries)
RUN apk add --no-cache libc6-compat
# Set working directory
WORKDIR /app
# Copy package files
COPY package*.json ./
# Copy built output and compiled node_modules from builder stage
# This avoids needing to install build tools (g++, make, python) in this slim production stage
COPY --from=builder /app/.output /app/.output
COPY --from=builder /app/node_modules /app/node_modules
# Expose port
EXPOSE 3000
# Set environment variables
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=3000
# Start the application
CMD ["node", ".output/server/index.mjs"]
+763
View File
@@ -0,0 +1,763 @@
# API Endpoint Documentation - Hak Akses
## Deskripsi
Dokumentasi ini menjelaskan semua API endpoint yang diperlukan untuk fitur **Hak Akses** di halaman `/pages/Setting/HakAkses.vue`. Endpoint-endpoint ini akan dihubungkan ke backend API yang disediakan oleh tim backend.
---
## Base URL
```
{API_BASE_URL}/api/v1
```
**Catatan:** Ganti `{API_BASE_URL}` dengan URL backend yang sebenarnya (contoh: `http://10.10.123.140:8089/`)
---
## 1. Get List Users
Mengambil daftar semua users beserta roles, groups, dan informasi lainnya yang diperlukan untuk form hak akses.
### Endpoint
```
GET /api/users/list
```
### Request
**Headers:**
```
Content-Type: application/json
Accept: application/json
```
**Query Parameters:** Tidak ad
### Response
**Success (200 OK):**
```json
[
{
"id": "user-123",
"namaLengkap": "John Doe",
"namaUser": "johndoe",
"email": "[email protected]",
"tipeUser": "Super Admin",
"lastLogin": 1704067200,
"roles": ["superadmin", "admin"],
"realmRoles": ["default-roles-sandbox"],
"accountRoles": [],
"resourceRoles": [],
"groups": ["/Instalasi STIM/Devops/Superadmin", "/Instalasi STIM/Admin"],
"given_name": "John",
"family_name": "Doe",
"createdAt": 1704067200,
"updatedAt": 1704067200
}
]
```
**Error (500 Internal Server Error):**
```json
{
"statusCode": 500,
"statusMessage": "Failed to fetch users"
}
```
### Field Description
- `id`: Unique identifier user
- `namaLengkap`: Nama lengkap user
- `namaUser`: Username untuk login
- `email`: Email user
- `tipeUser`: Tipe/jenis user (contoh: "Super Admin", "Admin Loket", dll)
- `lastLogin`: Timestamp terakhir login (Unix timestamp dalam detik)
- `roles`: Array of role names
- `realmRoles`: Array of realm roles dari Keycloak
- `accountRoles`: Array of account roles
- `resourceRoles`: Array of resource roles
- `groups`: Array of group paths (contoh: "/Instalasi STIM/Devops/Superadmin")
- `given_name`: Nama depan
- `family_name`: Nama belakang
- `createdAt`: Timestamp pembuatan (Unix timestamp dalam detik)
- `updatedAt`: Timestamp update terakhir (Unix timestamp dalam detik)
---
## 2. Get Permissions by Role and Group
Mengambil daftar permissions dari backend berdasarkan role dan group yang dipilih.
### Endpoint
```
GET /api/v1/permission
```
### Request
**Headers:**
```
Content-Type: application/json
Accept: application/json
```
**Query Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `roles` | string | Yes | Role name (contoh: "superadmin", "admin") |
| `groups` | string | Yes | Group name atau group path (contoh: "STIM", "/Instalasi STIM/Devops/Superadmin") |
**Example:**
```
GET /api/v1/permission?roles=superadmin&groups=STIM
```
### Response
**Success (200 OK):**
```json
{
"message": "Data permission berhasil diambil",
"data": [
{
"id": 1,
"create": false,
"read": true,
"update": false,
"disable": false,
"delete": false,
"active": true,
"pagename": "Halaman Utama",
"pagesID": 1,
"level": 1,
"sort": 1,
"parent": null
},
{
"id": 2,
"create": false,
"read": true,
"update": false,
"disable": false,
"delete": false,
"active": true,
"pagename": "Pengaturan",
"pagesID": 2,
"level": 1,
"sort": 2,
"parent": null
},
{
"id": 3,
"create": true,
"read": true,
"update": true,
"disable": true,
"delete": false,
"active": true,
"pagename": "Hak Akses",
"pagesID": 3,
"level": 2,
"sort": 3,
"parent": 2
}
],
"meta": {
"count": 3,
"total": 3
}
}
```
**Error (400 Bad Request):**
```json
{
"statusCode": 400,
"statusMessage": "roles or groups parameter is required"
}
```
**Error (500 Internal Server Error):**
```json
{
"message": "Failed to fetch permissions",
"data": [],
"meta": {
"count": 0,
"total": 0
}
}
```
### Field Description
- `id`: Unique identifier permission
- `create`: Boolean, permission untuk create/tambah data
- `read`: Boolean, permission untuk read/lihat data
- `update`: Boolean, permission untuk update/edit data
- `disable`: Boolean, permission untuk disable/nonaktifkan
- `delete`: Boolean, permission untuk delete/hapus data
- `active`: Boolean, status aktif permission
- `pagename`: Nama halaman/menu (akan di-mapping ke menu sidebar)
- `pagesID`: ID halaman di sistem backend
- `level`: Level hierarki menu (1 = parent, 2 = child, dll)
- `sort`: Urutan tampil
- `parent`: ID parent menu (null jika parent menu)
---
## 3. Get List Hak Akses
Mengambil daftar semua hak akses yang sudah tersimpan.
### Endpoint
```
GET /api/v1/hak-akses
```
### Request
**Headers:**
```
Content-Type: application/json
Accept: application/json
```
**Query Parameters:** Tidak ada
### Response
**Success (200 OK):**
```json
[
{
"id": 1,
"userId": "user-123",
"namaLengkap": "John Doe",
"namaUser": "johndoe",
"tipeUser": "Super Admin",
"role": "superadmin",
"group": "STIM",
"groupPath": "/Instalasi STIM/Devops/Superadmin",
"namaTipeUser": "Super Admin",
"hakAksesMenu": [
{
"name": "Dashboard",
"canAccess": true,
"canView": true,
"canAdd": false,
"canEdit": false,
"canDelete": false
},
{
"name": "Master Data",
"canAccess": true,
"canView": true,
"canAdd": true,
"canEdit": true,
"canDelete": false
}
],
"isGroupBased": true,
"createdAt": 1704067200,
"updatedAt": 1704067200
}
]
```
**Error (500 Internal Server Error):**
```json
{
"statusCode": 500,
"statusMessage": "Failed to fetch hak akses"
}
```
### Field Description
- `id`: Unique identifier hak akses
- `userId`: ID user (optional, untuk backward compatibility)
- `namaLengkap`: Nama lengkap user (optional)
- `namaUser`: Username (optional)
- `tipeUser`: Tipe user (optional)
- `role`: Role name (required)
- `group`: Group name (required)
- `groupPath`: Full group path (optional, untuk group-based access)
- `namaTipeUser`: Nama tipe user (optional)
- `hakAksesMenu`: Array of menu permissions
- `name`: Nama menu
- `canAccess`: Boolean, apakah bisa akses menu
- `canView`: Boolean, apakah bisa lihat data
- `canAdd`: Boolean, apakah bisa tambah data
- `canEdit`: Boolean, apakah bisa edit data
- `canDelete`: Boolean, apakah bisa hapus data
- `isGroupBased`: Boolean, apakah hak akses ini group-based (true) atau individual (false)
- `createdAt`: Timestamp pembuatan (Unix timestamp dalam detik)
- `updatedAt`: Timestamp update terakhir (Unix timestamp dalam detik)
---
## 4. Create Hak Akses
Membuat hak akses baru (group-based atau individual).
### Endpoint
```
POST /api/v1/hak-akses
```
### Request
**Headers:**
```
Content-Type: application/json
Accept: application/json
```
**Body:**
```json
{
"role": "superadmin",
"group": "STIM",
"groupPath": "/Instalasi STIM/Devops/Superadmin",
"namaTipeUser": "Super Admin",
"hakAksesMenu": [
{
"name": "Dashboard",
"canAccess": true,
"canView": true,
"canAdd": false,
"canEdit": false,
"canDelete": false
},
{
"name": "Master Data",
"canAccess": true,
"canView": true,
"canAdd": true,
"canEdit": true,
"canDelete": false
}
],
"isGroupBased": true
}
```
### Response
**Success (201 Created):**
```json
{
"success": true,
"message": "Hak akses berhasil dibuat",
"data": {
"id": 1,
"role": "superadmin",
"group": "STIM",
"groupPath": "/Instalasi STIM/Devops/Superadmin",
"namaTipeUser": "Super Admin",
"hakAksesMenu": [
{
"name": "Dashboard",
"canAccess": true,
"canView": true,
"canAdd": false,
"canEdit": false,
"canDelete": false
}
],
"isGroupBased": true,
"createdAt": 1704067200,
"updatedAt": 1704067200
}
}
```
**Error (400 Bad Request):**
```json
{
"statusCode": 400,
"statusMessage": "role and group are required"
}
```
**Error (409 Conflict):**
```json
{
"statusCode": 409,
"statusMessage": "Hak akses untuk group dan role ini sudah ada"
}
```
### Field Description
**Required Fields:**
- `role`: Role name (string, required)
- `group`: Group name (string, required)
- `hakAksesMenu`: Array of menu permissions (array, required)
**Optional Fields:**
- `userId`: ID user (string, optional)
- `namaLengkap`: Nama lengkap (string, optional)
- `namaUser`: Username (string, optional)
- `tipeUser`: Tipe user (string, optional)
- `groupPath`: Full group path (string, optional)
- `namaTipeUser`: Nama tipe user (string, optional)
- `isGroupBased`: Boolean, default true (boolean, optional)
---
## 5. Update Hak Akses
Mengupdate hak akses yang sudah ada.
### Endpoint
```
PATCH /api/v1/hak-akses/{id}
```
### Request
**Headers:**
```
Content-Type: application/json
Accept: application/json
```
**Path Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | integer | Yes | ID hak akses yang akan diupdate |
**Body (semua field optional, hanya kirim field yang ingin diupdate):**
```json
{
"role": "admin",
"group": "LOKET",
"namaTipeUser": "Admin Loket",
"hakAksesMenu": [
{
"name": "Dashboard",
"canAccess": true,
"canView": true,
"canAdd": false,
"canEdit": false,
"canDelete": false
}
]
}
```
### Response
**Success (200 OK):**
```json
{
"success": true,
"message": "Hak akses berhasil diperbarui",
"data": {
"id": 1,
"role": "admin",
"group": "LOKET",
"groupPath": "/Instalasi STIM/Loket",
"namaTipeUser": "Admin Loket",
"hakAksesMenu": [
{
"name": "Dashboard",
"canAccess": true,
"canView": true,
"canAdd": false,
"canEdit": false,
"canDelete": false
}
],
"isGroupBased": true,
"createdAt": 1704067200,
"updatedAt": 1704067300
}
}
```
**Error (400 Bad Request):**
```json
{
"statusCode": 400,
"statusMessage": "ID is required"
}
```
**Error (404 Not Found):**
```json
{
"statusCode": 404,
"statusMessage": "Hak akses not found"
}
```
**Error (500 Internal Server Error):**
```json
{
"statusCode": 500,
"statusMessage": "Failed to update hak akses"
}
```
---
## 6. Delete Hak Akses
Menghapus hak akses.
### Endpoint
```
DELETE /api/v1/hak-akses/{id}
```
### Request
**Headers:**
```
Content-Type: application/json
Accept: application/json
```
**Path Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | integer | Yes | ID hak akses yang akan dihapus |
### Response
**Success (200 OK):**
```json
{
"success": true,
"message": "Hak akses berhasil dihapus"
}
```
**Error (400 Bad Request):**
```json
{
"statusCode": 400,
"statusMessage": "ID is required"
}
```
**Error (404 Not Found):**
```json
{
"statusCode": 404,
"statusMessage": "Hak akses not found"
}
```
**Error (500 Internal Server Error):**
```json
{
"statusCode": 500,
"statusMessage": "Failed to delete hak akses"
}
```
---
## Mapping Permissions ke Menu Sidebar
Sistem akan otomatis memetakan `pagename` dari API permission ke nama menu di sidebar. Mapping dilakukan dengan:
1. **Exact match**: `pagename.toLowerCase() === menu.name.toLowerCase()`
2. **Partial match**: `pagename.toLowerCase().includes(menu.name.toLowerCase())` atau sebaliknya
### Contoh Mapping:
- `pagename: "Halaman Utama"` → Menu: "Dashboard"
- `pagename: "Pengaturan"` → Menu: "Master Data"
- `pagename: "Hak Akses"` → Menu: "Hak Akses"
### Permission Mapping:
- `read: true``canView: true`
- `create: true``canAdd: true`
- `update: true``canEdit: true`
- `delete: true``canDelete: true`
- `active: true || read: true``canAccess: true`
---
## Group-Based Access
Sistem mendukung **Group-Based Access** dimana:
- Satu hak akses dapat diberikan ke semua user yang memiliki group dan role yang sama
- Setiap user dapat memiliki multiple hak akses dari berbagai group
- Hak akses disimpan dengan flag `isGroupBased: true` dan `groupPath` untuk identifikasi
### Contoh:
Jika hak akses dibuat dengan:
- `role: "superadmin"`
- `groupPath: "/Instalasi STIM/Devops/Superadmin"`
- `isGroupBased: true`
Maka semua user yang memiliki:
- Role: "superadmin" **DAN**
- Group: "/Instalasi STIM/Devops/Superadmin"
Akan otomatis mendapatkan hak akses tersebut.
---
## Error Handling
Semua endpoint harus mengembalikan error dengan format konsisten:
```json
{
"statusCode": 400|404|409|500,
"statusMessage": "Error message description"
}
```
**HTTP Status Codes:**
- `200 OK`: Request berhasil
- `201 Created`: Resource berhasil dibuat
- `400 Bad Request`: Request tidak valid (missing required fields, invalid format)
- `404 Not Found`: Resource tidak ditemukan
- `409 Conflict`: Resource sudah ada (untuk create duplicate)
- `500 Internal Server Error`: Server error
---
## Authentication
**Catatan:** Tim backend perlu menentukan apakah endpoint-endpoint ini memerlukan authentication token atau tidak. Jika diperlukan, tambahkan:
**Headers:**
```
Authorization: Bearer {access_token}
```
---
## Testing
### Contoh Request dengan cURL:
**1. Get Users List:**
```bash
curl -X GET "http://{API_BASE_URL}/api/users/list" \
-H "Content-Type: application/json"
```
**2. Get Permissions:**
```bash
curl -X GET "http://{API_BASE_URL}/api/v1/permission?roles=superadmin&groups=STIM" \
-H "Content-Type: application/json"
```
**3. Create Hak Akses:**
```bash
curl -X POST "http://{API_BASE_URL}/api/v1/hak-akses" \
-H "Content-Type: application/json" \
-d '{
"role": "superadmin",
"group": "STIM",
"groupPath": "/Instalasi STIM/Devops/Superadmin",
"namaTipeUser": "Super Admin",
"hakAksesMenu": [
{
"name": "Dashboard",
"canAccess": true,
"canView": true,
"canAdd": false,
"canEdit": false,
"canDelete": false
}
],
"isGroupBased": true
}'
```
**4. Update Hak Akses:**
```bash
curl -X PATCH "http://{API_BASE_URL}/api/v1/hak-akses/1" \
-H "Content-Type: application/json" \
-d '{
"namaTipeUser": "Updated Admin",
"hakAksesMenu": [...]
}'
```
**5. Delete Hak Akses:**
```bash
curl -X DELETE "http://{API_BASE_URL}/api/v1/hak-akses/1" \
-H "Content-Type: application/json"
```
---
## Catatan Penting
1. **Base URL**: Ganti `{API_BASE_URL}` dengan URL backend yang sebenarnya
2. **Timestamps**: Semua timestamp menggunakan format Unix timestamp (detik sejak epoch)
3. **Group Path**: Group path harus full path (contoh: "/Instalasi STIM/Devops/Superadmin") untuk group-based access
4. **Menu Permissions**: Array `hakAksesMenu` harus sesuai dengan struktur menu sidebar
5. **Validation**: Backend harus melakukan validasi untuk:
- Required fields (role, group, hakAksesMenu)
- Duplicate check untuk group+role combination (jika isGroupBased = true)
- Format data yang valid
---
## Kontak
Jika ada pertanyaan atau perubahan requirement, silakan hubungi tim frontend.
+15 -2
View File
@@ -1,21 +1,27 @@
# Placeholder API Permission
## 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.150.131:8089/api/v1/permission`) tidak dapat diakses.
Placeholder API akan otomatis digunakan sebagai fallback jika backend API (`http://10.10.123.140:8089/api/v1/permission`) tidak dapat diakses.
### 2. Memaksa Menggunakan Placeholder API
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
```
@@ -23,7 +29,9 @@ 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)
@@ -32,6 +40,7 @@ Data placeholder mengembalikan 5 permission items:
## 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"
@@ -40,6 +49,7 @@ Sistem akan otomatis memetakan pagename dari API ke nama menu di sidebar:
## 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
@@ -50,21 +60,24 @@ Untuk testing dengan user email "bayurssa":
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).
+4 -4
View File
@@ -1,7 +1,7 @@
<template>
<NuxtLayout>
<v-app>
<v-app>
<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 {
+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
};
};
+77 -424
View File
@@ -1,457 +1,110 @@
<template>
<v-card class="pa-6 rounded-xl elevation-4">
<v-card-title class="d-flex align-center text-h5 font-weight-bold mb-4">
<v-icon icon="mdi-lock-check-outline" class="mr-2 text-primary" size="28"></v-icon>
<span>Edit Hak Akses Menu</span>
</v-card-title>
<v-divider class="mb-4"></v-divider>
<v-card-text class="px-0">
<v-row v-if="localItem.role || localItem.group" class="mb-4">
<v-col cols="12">
<v-alert type="info" variant="tonal" density="compact">
<strong>Role:</strong> {{ localItem.role }} | <strong>Group:</strong> {{ localItem.group }}
</v-alert>
</v-col>
</v-row>
<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="comfortable" class="elevation-1 rounded-xl hak-akses-table">
<thead>
<tr>
<th class="text-left text-uppercase font-weight-bold text-grey-darken-1 kol-no">No</th>
<th class="text-left text-uppercase font-weight-bold text-grey-darken-1">Menu</th>
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1 kol-status">Status</th>
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1 kol-aksi">Akses</th>
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1 kol-aksi">Lihat</th>
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1 kol-aksi">Tambah</th>
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1 kol-aksi">Edit</th>
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1 kol-aksi">Hapus</th>
</tr>
</thead>
<tbody>
<template v-if="backendPermissions.length > 0">
<tr v-for="(perm, index) in sortedPermissions" :key="perm.id" :class="{ 'bg-grey-lighten-5': perm.level === 2 }">
<td class="kol-no text-center">{{ index + 1 }}</td>
<td>
<div :style="{ paddingLeft: perm.level === 2 ? '32px' : '0' }" class="d-flex align-center">
<v-icon v-if="perm.level === 2" icon="mdi-subdirectory-arrow-right" size="small" class="mr-2 text-grey"></v-icon>
<span :class="{ 'font-weight-bold': perm.level === 1 }">{{ perm.pagename }}</span>
<v-chip v-if="perm.level" size="x-small" variant="outlined" class="ml-2" color="grey">
Level {{ perm.level }}
</v-chip>
</div>
</td>
<td class="text-center kol-status">
<v-chip
:color="isPageMapped(perm.pagename) ? 'success' : 'warning'"
size="small"
variant="tonal"
>
{{ isPageMapped(perm.pagename) ? 'Mapped' : 'Not Mapped' }}
</v-chip>
</td>
<td class="text-center kol-aksi">
<div class="cek-wrapper">
<v-checkbox
v-model="perm.active"
hide-details
color="primary"
></v-checkbox>
</div>
</td>
<td class="text-center kol-aksi">
<div class="cek-wrapper">
<v-checkbox
v-model="perm.read"
hide-details
color="primary"
></v-checkbox>
</div>
</td>
<td class="text-center kol-aksi">
<div class="cek-wrapper">
<v-checkbox
v-model="perm.create"
hide-details
color="primary"
></v-checkbox>
</div>
</td>
<td class="text-center kol-aksi">
<div class="cek-wrapper">
<v-checkbox
v-model="perm.update"
hide-details
color="primary"
></v-checkbox>
</div>
</td>
<td class="text-center kol-aksi">
<div class="cek-wrapper">
<v-checkbox
v-model="perm.delete"
hide-details
color="primary"
></v-checkbox>
</div>
</td>
</tr>
</template>
<template v-else>
<tr v-for="(menu, index) in orderedMenus" :key="menu.name">
<td class="kol-no text-center">{{ index + 1 }}</td>
<td>{{ menu.name }}</td>
<td class="text-center kol-status">
<v-chip color="success" size="small" variant="tonal">
Mapped
</v-chip>
</td>
<td class="text-center kol-aksi">
<div class="cek-wrapper">
<v-checkbox v-model="menu.canAccess" hide-details color="primary"></v-checkbox>
</div>
</td>
<td class="text-center kol-aksi">
<div class="cek-wrapper">
<v-checkbox v-model="menu.canView" hide-details color="primary"></v-checkbox>
</div>
</td>
<td class="text-center kol-aksi">
<div class="cek-wrapper">
<v-checkbox v-model="menu.canAdd" hide-details color="primary"></v-checkbox>
</div>
</td>
<td class="text-center kol-aksi">
<div class="cek-wrapper">
<v-checkbox v-model="menu.canEdit" hide-details color="primary"></v-checkbox>
</div>
</td>
<td class="text-center kol-aksi">
<div class="cek-wrapper">
<v-checkbox v-model="menu.canDelete" hide-details color="primary"></v-checkbox>
</div>
</td>
</tr>
</template>
</tbody>
</v-table>
</v-col>
</v-row>
</v-card-text>
<v-card-actions class="d-flex justify-end pa-0 mt-4">
<v-btn
color="grey-darken-1"
variant="flat"
rounded="lg"
class="text-capitalize mr-2"
@click="$emit('cancel')"
>
Batal
</v-btn>
<v-btn
color="primary"
variant="flat"
rounded="lg"
class="text-capitalize"
@click="handleSave"
:loading="isSaving"
>
Submit
</v-btn>
</v-card-actions>
<v-card 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 { computed, ref, watch, onMounted } 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 BackendPermissionItem {
id: number;
create: boolean;
read: boolean;
update: boolean;
disable: boolean;
delete: boolean;
active: boolean;
pagename: string;
pagesID: number;
level?: number;
sort?: number;
parent?: number;
}
interface HakAksesData {
id: number;
role?: string;
group?: string;
namaTipeUser: string;
hakAksesMenu: HakAksesMenu[];
}
// Define props with type validation
const props = defineProps({
item: {
type: Object as () => HakAksesData,
required: true,
},
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 backendPermissions = ref<BackendPermissionItem[]>([]);
const isSaving = ref(false);
const navItemsStore = useNavItemsStore();
// Helper function to normalize group name
const normalizeGroup = (group: string): string => {
const normalized = group.trim();
// Jika group mengandung "Instalasi STIM", ambil hanya "STIM"
if (normalized.toLowerCase().includes('instalasi')) {
const parts = normalized.split(/\s+/);
const stimIndex = parts.findIndex(p => p.toLowerCase() === 'stim');
if (stimIndex !== -1) {
return 'STIM';
}
}
// Jika group adalah "Instalasi STIM", return "STIM"
if (normalized.toLowerCase() === 'instalasi stim') {
return 'STIM';
}
return normalized.toUpperCase();
};
interface FlatNavItem {
name: string;
path: string;
icon: string;
level: number;
}
// Helper function to normalize role name
const normalizeRole = (role: string): string => {
const normalized = role.toLowerCase().trim();
// Mapping khusus untuk role default
if (normalized === 'default-roles-sandbox') {
return 'superadmin';
}
return normalized;
};
// Fetch permissions from backend API when component mounts
onMounted(async () => {
if (localItem.value.role && localItem.value.group) {
try {
// Normalize role and group before making API call
const normalizedRole = normalizeRole(localItem.value.role);
const normalizedGroup = normalizeGroup(localItem.value.group);
console.log('🔄 Fetching permissions with normalized values:', {
originalRole: localItem.value.role,
normalizedRole,
originalGroup: localItem.value.group,
normalizedGroup,
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
});
const response = await $fetch<any>('/api/permission', {
query: {
roles: normalizedRole,
groups: normalizedGroup,
},
});
if (response && response.data && Array.isArray(response.data)) {
console.log(`✅ Received ${response.data.length} permissions from API`);
// Create a deep copy to avoid mutating the original
backendPermissions.value = response.data.map((perm: BackendPermissionItem) => ({
...perm,
}));
} else {
console.warn('⚠️ No data received from API or invalid structure');
if (item.children && item.children.length > 0) {
walk(item.children, level + 1);
}
} catch (error) {
console.error('❌ Error fetching permissions:', error);
}
} else {
console.warn('⚠️ Role or group is missing:', {
role: localItem.value.role,
group: localItem.value.group,
});
}
});
// Sort permissions: level 1 first, then level 2 grouped under their parents
const sortedPermissions = computed(() => {
if (backendPermissions.value.length === 0) return [];
const level1 = backendPermissions.value.filter(p => p.level === 1);
const level2 = backendPermissions.value.filter(p => p.level === 2);
// Sort level 1 by sort order
level1.sort((a, b) => (a.sort || 0) - (b.sort || 0));
// Sort level 2 by sort order
level2.sort((a, b) => (a.sort || 0) - (b.sort || 0));
// Build result: insert level 2 items after their parent
const result: BackendPermissionItem[] = [];
level1.forEach(parent => {
result.push(parent);
// Add children of this parent
const children = level2.filter(child => child.parent === parent.pagesID);
result.push(...children);
});
// Add any remaining level 2 items that don't have a parent match
const addedChildren = new Set(level2.filter(c => c.parent && level1.some(p => p.pagesID === c.parent)).map(c => c.id));
const remainingChildren = level2.filter(c => !addedChildren.has(c.id));
result.push(...remainingChildren);
};
walk(navItemsStore.getNavItems);
return result;
});
// Cek apakah suatu pagename dari backend sudah termapping ke salah satu menu di hakAksesMenu
const isPageMapped = (pagename: string): boolean => {
if (!localItem.value.hakAksesMenu || localItem.value.hakAksesMenu.length === 0) {
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 lowerPage = (pagename || '').toLowerCase();
return localItem.value.hakAksesMenu.some((menu) => {
const name = (menu.name || '').toLowerCase();
return (
name === lowerPage ||
name.includes(lowerPage) ||
lowerPage.includes(name)
);
});
};
interface NavItemOrder {
name: string;
children?: NavItemOrder[];
}
// Build a map of menu order based on the sidebar configuration so the list is always aligned
const menuOrder = computed(() => {
const order: Record<string, number> = {};
const walk = (items: NavItemOrder[], startIndex = 0): number => {
let idx = startIndex;
items.forEach((item) => {
order[item.name] = idx;
idx += 1;
if (item.children?.length) {
idx = walk(item.children, idx);
}
});
return idx;
};
walk(navItemsStore.navItems);
return order;
});
const orderedMenus = computed(() => {
const order = menuOrder.value;
return [...localItem.value.hakAksesMenu].sort((a, b) => {
const orderA = order[a.name] ?? Number.MAX_SAFE_INTEGER;
const orderB = order[b.name] ?? Number.MAX_SAFE_INTEGER;
return orderA - orderB;
});
});
// Handle save - convert backend permissions back to menu structure if needed
const handleSave = async () => {
isSaving.value = true;
try {
// If we have backend permissions, we need to map them back to the menu structure
if (backendPermissions.value.length > 0) {
// Update the local item with backend permissions mapped to menu structure
const updatedItem = {
...localItem.value,
backendPermissions: backendPermissions.value,
};
emits('save', updatedItem);
} else {
// Use existing menu structure
emits('save', localItem.value);
const toggleAccess = (item: FlatNavItem, allowed: boolean) => {
let newPages = [...props.pages];
if (allowed) {
if (!newPages.includes(item.path)) {
newPages.push(item.path);
}
} finally {
isSaving.value = false;
} else {
newPages = newPages.filter(p => p !== item.path);
}
emit('update:pages', newPages);
};
// Watch the prop for changes and update the local copy
watch(() => props.item, (newItem) => {
localItem.value = JSON.parse(JSON.stringify(newItem));
// Re-fetch permissions if role/group changed
if (newItem.role && newItem.group) {
// Normalize role and group before making API call
const normalizedRole = normalizeRole(newItem.role);
const normalizedGroup = normalizeGroup(newItem.group);
console.log('🔄 Re-fetching permissions with normalized values:', {
originalRole: newItem.role,
normalizedRole,
originalGroup: newItem.group,
normalizedGroup,
});
$fetch<any>('/api/permission', {
query: {
roles: normalizedRole,
groups: normalizedGroup,
},
}).then(response => {
if (response && response.data && Array.isArray(response.data)) {
console.log(`✅ Received ${response.data.length} permissions from API`);
backendPermissions.value = response.data.map((perm: BackendPermissionItem) => ({
...perm,
}));
}
}).catch(error => {
console.error('❌ Error fetching permissions:', error);
});
}
}, { deep: true });
</script>
<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;
}
.hak-akses-table :deep(.kol-no) {
width: 56px;
}
.hak-akses-table :deep(.kol-aksi) {
width: 90px;
}
.hak-akses-table :deep(.kol-status) {
width: 120px;
}
.cek-wrapper {
display: flex;
justify-content: center;
align-items: center;
.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>
+20
View File
@@ -0,0 +1,20 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
// Basic test to verify Vitest is working
describe('Vitest Setup', () => {
it('works correctly', () => {
expect(1 + 1).toBe(2)
})
})
// Basic test for Vue component mounting (optional, depends on having a simple component)
/*
import MyComponent from './MyComponent.vue'
describe('Component Test', () => {
it('mounts properly', () => {
const wrapper = mount(MyComponent)
expect(wrapper.exists()).toBe(true)
})
})
*/
+11 -1
View File
@@ -96,7 +96,7 @@
</div>
<div 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">{{ new Date().toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' }) }}</p>
<p class="text-body-2 patient-info-value mb-0">{{ getCurrentTime() }}</p>
</div>
</div>
</v-card-text>
@@ -164,6 +164,16 @@ 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">
+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>
@@ -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
+381 -39
View File
@@ -1,32 +1,118 @@
<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>
@@ -56,7 +142,9 @@
</template>
<script setup>
defineProps({
import { computed } from 'vue';
const props = defineProps({
patient: {
type: Object,
default: null
@@ -80,7 +168,13 @@ defineProps({
}
});
defineEmits(['action', 'change-klinik', 'process-next']);
const isFastTrack = computed(() => {
if (!props.patient) return false;
const fastTrack = (props.patient.fastTrack ?? "").toString().trim().toUpperCase();
return fastTrack === 'YA';
});
defineEmits(['action', 'change-klinik', 'process-next', 'call', 'open-klinik-ruang', 'open-penunjang']);
</script>
<style scoped lang="scss">
@@ -90,6 +184,12 @@ defineEmits(['action', 'change-klinik', 'process-next']);
background: var(--color-neutral-100);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.section-label {
font-size: 11px;
font-weight: 700;
@@ -98,13 +198,32 @@ defineEmits(['action', 'change-klinik', 'process-next']);
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 {
@@ -115,24 +234,204 @@ defineEmits(['action', 'change-klinik', 'process-next']);
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 {
@@ -140,8 +439,28 @@ defineEmits(['action', 'change-klinik', 'process-next']);
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;
}
@@ -156,13 +475,36 @@ defineEmits(['action', 'change-klinik', 'process-next']);
.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>
+205 -91
View File
@@ -1,57 +1,83 @@
<template>
<v-tooltip
:text="isClickable ? 'Klik untuk proses pasien' : ''"
location="top"
:disabled="!isClickable"
<v-card
class="patient-card"
elevation="2"
:class="{
'clickable-card': isClickable,
'fast-track-card': isFastTrack && !isCurrentlyProcessing,
'processing-card': isCurrentlyProcessing || patient.status === 'diproses'
}"
@click="handleCardClick"
>
<template #activator="{ props: tooltipProps }">
<v-card
class="patient-card"
elevation="2"
:class="{ 'clickable-card': isClickable }"
@click="handleCardClick"
v-bind="isClickable ? tooltipProps : {}"
>
<v-card-text class="pa-4">
<v-tooltip
activator="parent"
:text="isClickable ? 'Klik untuk proses pasien' : ''"
location="top"
:disabled="!isClickable"
/>
<v-card-text class="pa-4">
<!-- Header: Queue Number, Status & Fast Track Badge -->
<div class="card-header">
<div class="header-left">
<div class="queue-number">{{ patient.noAntrian.split(" |")[0] }}</div>
<v-icon
v-if="patient.fastTrack === 'YA'"
color="warning"
size="20"
class="fast-track-icon"
<div
v-if="isFastTrack"
class="fast-track-icon-wrapper"
>
mdi-flash
</v-icon>
<v-icon
color="#E53935"
size="14"
class="fast-track-icon"
>
mdi-flash
</v-icon>
</div>
</div>
<v-chip
:color="getStatusColor(patient.status)"
size="small"
class="status-chip"
>
{{ getStatusLabel(patient.status) }}
</v-chip>
</div>
<!-- Patient Info Grid - Simplified -->
<div class="patient-info mt-3">
<div class="info-row">
<span class="info-label">Jam Panggil:</span>
<span class="info-value">{{ patient.jamPanggil }}</span>
</div>
<div class="info-row">
<span class="info-label">Klinik:</span>
<v-chip size="small" variant="outlined" class="klinik-chip">
{{ patient.klinik }}
<div class="header-right d-flex align-center" style="gap: 8px;">
<v-chip
v-if="patient.ruang"
color="primary-600"
variant="outlined"
size="small"
class="subspesialis-chip text-caption font-weight-bold"
>
{{ patient.ruang }}
</v-chip>
<v-chip
:color="getStatusColor(patient.status)"
size="small"
class="status-chip"
>
{{ getStatusLabel(patient.status) }}
</v-chip>
</div>
</div>
<div class="info-row">
<span class="info-label">Pembayaran:</span>
<span class="info-value">{{ patient.pembayaran }}</span>
<!-- 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>
<!-- 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>
@@ -70,13 +96,14 @@
</v-btn>
</div>
</v-card-text>
</v-card>
</template>
</v-tooltip>
</v-card>
</template>
<script setup>
import { computed } from 'vue';
import { useQueueStore } from '~/stores/queueStore';
const queueStore = useQueueStore();
const props = defineProps({
patient: {
@@ -88,9 +115,68 @@ const props = defineProps({
const emit = defineEmits(['action']);
const isClickable = computed(() => {
return props.patient.status === 'diloket' || props.patient.status === 'pending';
return props.patient.status === 'di-loket' || props.patient.status === 'pending';
});
const isFastTrack = computed(() => {
const fastTrack = (props.patient.fastTrack ?? "").toString().trim().toUpperCase();
return fastTrack === 'YA';
});
const isCurrentlyProcessing = computed(() => {
const currentPatient = queueStore.currentProcessingPatient?.loket;
if (!currentPatient || !props.patient) return false;
return currentPatient.no === props.patient.no ||
currentPatient.barcode === props.patient.barcode;
});
const isEksekutif = computed(() => {
const pembayaran = (props.patient.pembayaran ?? "").toString().trim().toUpperCase();
return pembayaran === 'EKSEKUTIF';
});
// Computed untuk menentukan jenis pasien yang ditampilkan
const displayJenisPasien = computed(() => {
// Prioritas 1: Tipe Layanan jika ada
if (props.patient.tipeLayanan) {
return props.patient.tipeLayanan;
}
// Prioritas 2: Eksekutif jika pasien eksekutif
if (isEksekutif.value) {
return 'Eksekutif';
}
// Prioritas 3: Tampilkan pembayaran sebagai fallback
if (props.patient.pembayaran) {
return props.patient.pembayaran;
}
return null;
});
// Fungsi untuk menentukan color chip berdasarkan jenis pasien
const getJenisPasienColor = (jenis) => {
if (jenis === 'Pemeriksaan Awal') return 'primary';
if (jenis === 'Tindakan') return 'secondary';
if (jenis === 'Eksekutif') return 'warning';
if (jenis === 'Fast Track') return 'warning';
// Untuk pembayaran biasa (BPJS, UMUM), gunakan neutral-600 untuk kontras
return 'success-500';
};
// Fungsi untuk menentukan variant chip berdasarkan jenis pasien
const getJenisPasienVariant = (jenis) => {
// Semua jenis pasien menggunakan flat (filled) untuk kontras dengan klinik (outlined)
return 'flat';
};
// Fungsi untuk menentukan class chip berdasarkan jenis pasien
const getJenisPasienClass = (jenis) => {
if (jenis === 'Pemeriksaan Awal' || jenis === 'Tindakan' || jenis === 'Eksekutif' || jenis === 'Fast Track') {
return 'text-white layanan-chip';
}
// Untuk pembayaran biasa (BPJS, UMUM), gunakan text putih untuk kontras dengan background
return 'text-white layanan-chip';
};
const handleCardClick = () => {
if (isClickable.value) {
emit('action', props.patient, 'proses');
@@ -99,17 +185,17 @@ const handleCardClick = () => {
const getStatusColor = (status) => {
const colors = {
diloket: "var(--color-secondary-600)",
diproses: "var(--color-primary-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"
@@ -145,6 +231,28 @@ const getStatusLabel = (status) => {
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 {
@@ -167,17 +275,24 @@ const getStatusLabel = (status) => {
color: var(--color-neutral-900);
}
.fast-track-icon {
animation: pulse 2s ease-in-out infinite;
.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);
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
.fast-track-icon-wrapper .fast-track-icon {
color: #E53935 !important;
transition: all 0.2s ease;
}
.status-chip {
@@ -189,46 +304,45 @@ const getStatusLabel = (status) => {
.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;
gap: 8px;
}
.payment-info {
display: flex;
align-items: center;
gap: 4px;
}
.fast-track-badge {
font-size: 10px;
font-weight: 700;
height: 20px;
padding: 0 6px;
}
.info-label {
color: var(--color-neutral-600);
font-weight: 500;
min-width: 90px;
}
.info-value {
color: var(--color-neutral-900);
font-weight: 600;
text-align: right;
flex-wrap: wrap;
}
.klinik-chip {
font-size: 11px;
font-weight: 600;
border: 1.5px solid currentColor;
height: 24px;
border-color: var(--color-neutral-600) !important;
color: var(--color-neutral-900) !important;
background-color: transparent !important;
}
.layanan-chip {
font-size: 11px;
font-weight: 600;
height: 24px;
}
.doctor-info {
display: flex;
align-items: center;
gap: 6px;
}
.doctor-icon {
flex-shrink: 0;
}
.doctor-name {
font-size: 12px;
font-weight: 600;
color: var(--color-primary-600);
}
.card-actions .v-btn {
+94 -10
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>
@@ -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>
+33 -37
View File
@@ -18,16 +18,6 @@
{{ status.label }} ({{ status.count }})
</v-chip>
</v-chip-group>
<!-- Search Field -->
<v-text-field
v-model="searchModel"
placeholder="Cari barcode, nomor antrian..."
density="compact"
hide-details
class="search-field"
prepend-inner-icon="mdi-magnify"
/>
</div>
<!-- Advanced Filters -->
@@ -126,25 +116,27 @@
</div>
</div>
<!-- Results Info -->
<div v-if="filteredAndSearchedItems.length > 0" class="results-info mb-3">
<span class="results-text">
Menampilkan {{ paginatedItems.length }} dari {{ filteredAndSearchedItems.length }} pasien
</span>
</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 v-if="filteredAndSearchedItems.length > 0" class="patient-grid">
<PatientCard
v-for="(patient, index) in paginatedItems"
:key="`${patient.barcode}-${index}`"
:patient="patient"
@action="handleAction"
/>
<!-- Patient Cards Grid -->
<div class="patient-grid">
<PatientCard
v-for="(patient, index) in paginatedItems"
:key="`${patient.barcode}-${index}`"
:patient="patient"
@action="handleAction"
/>
</div>
</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">
{{ searchModel || hasActiveFilters ? 'Tidak ada pasien yang sesuai' : 'Tidak ada data pasien' }}
@@ -200,13 +192,17 @@ 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,
@@ -281,7 +277,7 @@ const searchModel = computed({
const statusOptions = computed(() => {
const baseOptions = [
{ value: 'all', label: props.statusLabels.all, count: props.items.length },
{ value: 'diloket', label: props.statusLabels.diloket, count: props.diLoketCount }
{ value: 'di-loket', label: props.statusLabels.diloket, count: props.diLoketCount }
];
// Tampilkan "Diproses" hanya jika:
@@ -295,6 +291,16 @@ const statusOptions = computed(() => {
});
}
// Tambahkan kategori "Anjungan" (pasien yang sudah dipanggil tapi belum check-in)
if (props.anjunganCount > 0) {
baseOptions.push({
value: 'anjungan',
label: 'Anjungan',
count: props.anjunganCount,
icon: 'mdi-clock-outline'
});
}
baseOptions.push(
{ value: 'terlambat', label: props.statusLabels.terlambat, count: props.terlambatCount },
{ value: 'pending', label: props.statusLabels.pending, count: props.pendingCount }
@@ -459,11 +465,6 @@ const handleAction = (patient, action) => {
border-color: var(--color-secondary-600);
}
.search-field {
max-width: 300px;
min-width: 250px;
}
.advanced-filters {
display: flex;
gap: 12px;
@@ -543,11 +544,6 @@ const handleAction = (patient, action) => {
min-width: 100%;
}
.search-field {
max-width: 100%;
min-width: 100%;
}
.advanced-filters {
flex-direction: column;
}
+38 -70
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,34 +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>
@@ -146,6 +122,7 @@
<script setup>
import { ref } from 'vue';
import { navigateTo } from '#app';
import { DEFAULT_AVATAR } from '@/constants/avatar';
const props = defineProps({
user: {
@@ -159,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;
@@ -177,41 +157,38 @@ const signOut = async () => {
const handleAction = (action) => {
switch(action) {
case 'account':
navigateTo('/Profile/Pengaturan')
break;
case 'profile':
navigateTo('/Profile/Profil')
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 {
@@ -220,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;
@@ -251,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;
}
@@ -276,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;
@@ -292,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;
@@ -310,20 +278,20 @@ const handleAction = (action) => {
.menu-grid {
display: grid;
grid-template-columns: repeat(2, 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);
}
@@ -351,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);
}
@@ -359,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 {
+4 -2
View File
@@ -78,7 +78,7 @@ export const useCheckInHistory = () => {
data: qrData,
timestamp: new Date().toISOString(),
date: new Date().toLocaleDateString('id-ID'),
time: new Date().toLocaleTimeString('id-ID')
time: new Date().toLocaleTimeString('id-ID').replace(/\./g, ':')
})
// Simpan maksimal item sesuai constant
@@ -227,11 +227,13 @@ export const useCheckInHistory = () => {
const formatDateTime = (dateString: string) => {
const date = new Date(dateString)
return date.toLocaleTimeString('id-ID', {
// Format jam dengan titik dua (HH:MM:SS)
const timeString = date.toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
return timeString.replace(/\./g, ':')
}
const formatDate = (dateString: string) => {
+160
View File
@@ -0,0 +1,160 @@
// composables/useClinicAPI.ts
// Composable untuk API calls terkait klinik/poli
import { useAPI } from './useAPI';
export interface Clinic {
id: number;
kode: string;
name: string;
subtitle?: string;
icon?: string;
shift?: string;
schedule?: string;
available?: boolean;
doctors?: string[];
shifts?: Array<{ name: string; quota: number }>;
totalQuota?: number;
jamShiftList?: Array<{ dari: string; sampai: string; kuota: number }>;
autoShift?: boolean;
jadwalKlinik?: string[];
}
export const useClinicAPI = () => {
const api = useAPI();
/**
* Fetch all clinics from database
*/
const fetchAllClinics = async (): Promise<Clinic[]> => {
try {
return await api.get<Clinic[]>('/clinics');
} catch (error: any) {
console.error('❌ Error fetching clinics:', error);
throw error;
}
};
/**
* Fetch single clinic by ID
*/
const fetchClinic = async (id: number): Promise<Clinic | null> => {
try {
return await api.get<Clinic>(`/clinics/${id}`);
} catch (error: any) {
console.error('❌ Error fetching clinic:', error);
return null;
}
};
/**
* Fetch clinics by kode
*/
const fetchClinicByKode = async (kode: string): Promise<Clinic | null> => {
try {
return await api.get<Clinic>(`/clinics/kode/${kode}`);
} catch (error: any) {
console.error('❌ Error fetching clinic by kode:', error);
return null;
}
};
/**
* Create new clinic
*/
const createClinic = async (clinicData: Partial<Clinic>): Promise<Clinic> => {
try {
return await api.post<Clinic>('/clinics', clinicData);
} catch (error: any) {
console.error('❌ Error creating clinic:', error);
throw error;
}
};
/**
* Update clinic
*/
const updateClinic = async (id: number, updates: Partial<Clinic>): Promise<Clinic> => {
try {
return await api.patch<Clinic>(`/clinics/${id}`, updates);
} catch (error: any) {
console.error('❌ Error updating clinic:', error);
throw error;
}
};
/**
* Delete clinic
*/
const deleteClinic = async (id: number): Promise<boolean> => {
try {
await api.delete(`/clinics/${id}`);
return true;
} catch (error: any) {
console.error('❌ Error deleting clinic:', error);
return false;
}
};
/**
* Get clinics for dropdown (formatted for dropdown use)
*/
const fetchClinicsForDropdown = async (): Promise<Clinic[]> => {
try {
const clinics = await fetchAllClinics();
// Format sesuai kebutuhan dropdown
return clinics.map(clinic => ({
id: clinic.id,
name: clinic.name,
kode: clinic.kode,
icon: clinic.icon,
available: clinic.available ?? true,
}));
} catch (error: any) {
console.error('❌ Error fetching clinics for dropdown:', error);
return [];
}
};
/**
* Sync local clinics with database
*/
const syncWithDatabase = async (localClinics: Clinic[]): Promise<Clinic[]> => {
try {
const dbClinics = await fetchAllClinics();
// Merge strategy: prefer database data
const merged = new Map<number, Clinic>();
// Add database clinics
dbClinics.forEach(clinic => {
merged.set(clinic.id, clinic);
});
// Add local clinics that don't exist in DB
localClinics.forEach(localClinic => {
if (!merged.has(localClinic.id)) {
merged.set(localClinic.id, localClinic);
}
});
return Array.from(merged.values());
} catch (error: any) {
console.error('❌ Error syncing clinics with database:', error);
// Return local clinics as fallback
return localClinics;
}
};
return {
fetchAllClinics,
fetchClinic,
fetchClinicByKode,
createClinic,
updateClinic,
deleteClinic,
fetchClinicsForDropdown,
syncWithDatabase,
};
};
+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",
+174 -34
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,9 +35,10 @@ 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 - ADD DEBUG LOGS
@@ -54,10 +64,14 @@ export const useQueue = (adminType = "loket") => {
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
@@ -66,31 +80,91 @@ 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;
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
@@ -100,18 +174,20 @@ 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 result = queueStore.processPatient(patient, action, adminType, idValue.value);
let color = "success";
if (action === "terlambat") color = "warning";
@@ -120,16 +196,69 @@ export const useQueue = (adminType = "loket") => {
showSnackbar(result.message, color);
};
// 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, currentProcessingPatient.value, adminType);
// Pastikan currentProcessingPatient valid, jika tidak, coba dapatkan dari store
let patient = currentProcessingPatient.value || getCurrentProcessingPatientFromStore();
if (!patient) {
showSnackbar("Tidak ada pasien yang sedang diproses", "error");
showKlinikDialog.value = false;
return;
}
const result = queueStore.createAntreanKlinik(klinik, patient, adminType);
showSnackbar(result.message, "success");
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,
currentProcessingPatient.value,
patient,
adminType
);
showSnackbar(result.message, "success");
@@ -143,19 +272,29 @@ 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 = () => {
const result = queueStore.processNextQueue(adminType);
const result = queueStore.processNextQueue(adminType, idValue.value);
showSnackbar(result.message, result.success ? "success" : "warning");
};
@@ -184,7 +323,8 @@ export const useQueue = (adminType = "loket") => {
diLoketPatients,
terlambatPatients,
pendingPatients,
waitingPatients,
anjunganPatients,
menungguPatients,
nextPatient,
totalPasien,
quotaUsed,
+193
View File
@@ -0,0 +1,193 @@
// composables/useQueueAPI.ts
// Composable untuk API calls terkait antrian pasien
export interface Patient {
no: number;
jamPanggil: string;
barcode: string;
noAntrian: string;
shift: string;
klinik: string;
fastTrack: string;
pembayaran: string;
status: 'anjungan' | 'pending' | 'di-loket' | 'di-klinik' | 'selesai' | 'terlambat';
processStage: 'loket' | 'klinik' | 'penunjang';
createdAt: string;
registrationType?: 'online' | 'onsite';
visitType?: string;
visitDate?: string;
}
export interface QueueAPIResponse<T = any> {
success: boolean;
data?: T;
message?: string;
error?: string;
}
export const useQueueAPI = () => {
const config = useRuntimeConfig();
const baseURL = config.public.apiBaseUrl || '/api/queue';
/**
* Fetch all patients from database
*/
const fetchAllPatients = async (): Promise<Patient[]> => {
try {
const response = await $fetch<QueueAPIResponse<Patient[]>>(`${baseURL}/patients`, {
method: 'GET',
});
if (response.success && response.data) {
return response.data;
}
throw new Error(response.message || 'Failed to fetch patients');
} catch (error: any) {
console.error('❌ Error fetching patients:', error);
throw error;
}
};
/**
* Fetch single patient by ID or barcode
*/
const fetchPatient = async (idOrBarcode: string): Promise<Patient | null> => {
try {
const response = await $fetch<QueueAPIResponse<Patient>>(`${baseURL}/patients/${idOrBarcode}`, {
method: 'GET',
});
if (response.success && response.data) {
return response.data;
}
return null;
} catch (error: any) {
console.error('❌ Error fetching patient:', error);
return null;
}
};
/**
* Create new patient (register from Anjungan)
*/
const createPatient = async (patientData: Partial<Patient>): Promise<Patient> => {
try {
const response = await $fetch<QueueAPIResponse<Patient>>(`${baseURL}/patients`, {
method: 'POST',
body: patientData,
});
if (response.success && response.data) {
return response.data;
}
throw new Error(response.message || 'Failed to create patient');
} catch (error: any) {
console.error('❌ Error creating patient:', error);
throw error;
}
};
/**
* Update patient status (check-in, process, etc)
*/
const updatePatient = async (
idOrBarcode: string,
updates: Partial<Patient>
): Promise<Patient> => {
try {
const response = await $fetch<QueueAPIResponse<Patient>>(
`${baseURL}/patients/${idOrBarcode}`,
{
method: 'PATCH',
body: updates,
}
);
if (response.success && response.data) {
return response.data;
}
throw new Error(response.message || 'Failed to update patient');
} catch (error: any) {
console.error('❌ Error updating patient:', error);
throw error;
}
};
/**
* Check-in patient (update status to di-loket)
*/
const checkInPatient = async (idOrBarcode: string): Promise<Patient> => {
return updatePatient(idOrBarcode, { status: 'di-loket' });
};
/**
* Process patient at loket (update status and processStage)
*/
const processPatientAtLoket = async (
idOrBarcode: string,
updates: { status?: string; processStage?: string }
): Promise<Patient> => {
return updatePatient(idOrBarcode, updates as Partial<Patient>);
};
/**
* Sync local state with database
*/
const syncWithDatabase = async (localPatients: Patient[]): Promise<Patient[]> => {
try {
// Fetch latest from database
const dbPatients = await fetchAllPatients();
// Merge strategy: prefer database data, but keep local if newer
const merged = new Map<string, Patient>();
// Add database patients
dbPatients.forEach(patient => {
merged.set(patient.barcode, patient);
});
// Add local patients that don't exist in DB or are newer
localPatients.forEach(localPatient => {
const existing = merged.get(localPatient.barcode);
if (!existing || new Date(localPatient.createdAt) > new Date(existing.createdAt)) {
merged.set(localPatient.barcode, localPatient);
}
});
return Array.from(merged.values());
} catch (error: any) {
console.error('❌ Error syncing with database:', error);
// Return local patients as fallback
return localPatients;
}
};
/**
* Batch sync: save multiple patients to database
*/
const batchSyncPatients = async (patients: Patient[]): Promise<boolean> => {
try {
const response = await $fetch<QueueAPIResponse>(`${baseURL}/patients/batch`, {
method: 'POST',
body: { patients },
});
return response.success || false;
} catch (error: any) {
console.error('❌ Error batch syncing patients:', error);
return false;
}
};
return {
fetchAllPatients,
fetchPatient,
createPatient,
updatePatient,
checkInPatient,
processPatientAtLoket,
syncWithDatabase,
batchSyncPatients,
};
};
+629
View File
@@ -0,0 +1,629 @@
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"
let ruangInfo = '';
if (data.klinik && data.nomorRuang) {
// Konversi nomor ruang ke abjad (1 = A, 2 = B, 3 = C, dst)
const ruangNumber = parseInt(data.nomorRuang) || 1;
const ruangLetter = String.fromCharCode(64 + ruangNumber); // 64 = '@', 65 = 'A', 66 = 'B', dst
ruangInfo = `${data.klinik} - Ruang ${ruangLetter}`;
} else if (data.klinik && data.ruang) {
// Fallback jika hanya ada nama ruang
ruangInfo = `${data.klinik} - ${data.ruang}`;
} else if (data.klinik) {
// Hanya klinik jika tidak ada info ruang
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 proxy path to avoid CORS issues
const statsURL = '/stats-api/visit/stats';
/**
* Fetch visit statistics
*/
const fetchStats = async (filters: { from?: string; to?: string; service_code?: string; payment_type?: string } = {}): Promise<VisitStats | null> => {
try {
const queryParams = new URLSearchParams();
if (filters.from) queryParams.append('from', filters.from);
if (filters.to) queryParams.append('to', filters.to);
if (filters.service_code) queryParams.append('service_code', filters.service_code);
if (filters.payment_type) queryParams.append('payment_type', filters.payment_type);
const url = `${statsURL}${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
const response = await $fetch<{ message: string; data: VisitStats }>(url, {
method: 'GET',
});
if (response && response.data) {
return response.data;
}
return null;
} catch (error: any) {
console.error('❌ Error fetching visit stats:', error);
return null;
}
};
return {
fetchStats,
};
};
+205
View File
@@ -0,0 +1,205 @@
// composables/useWebSocket.ts
import { ref, computed, onUnmounted, getCurrentInstance, toValue } from 'vue'
export interface WebSocketConfig {
url: string | any
clientId: string | any
onMessage?: (data: any) => void
onOpen?: () => void
onClose?: () => void
onError?: (error: Event) => void
reconnectInterval?: number
maxReconnectAttempts?: number
fallbackPostUrl?: string // Optional: use a specific URL for the POST fallback (e.g., a proxy)
}
export interface WebSocketMessage {
to_client: string
data: any
}
export const useWebSocket = (config: WebSocketConfig) => {
const ws = ref<WebSocket | null>(null)
const isConnected = ref(false)
const reconnectAttempts = ref(0)
const reconnectTimer = ref<NodeJS.Timeout | null>(null)
const currentUrl = computed(() => toValue(config.url))
const currentClientId = computed(() => toValue(config.clientId))
const wsUrl = computed(() => {
let baseUrl = currentUrl.value
// Automatically upgrade to secure protocols if caller uses HTTPS
if (typeof window !== 'undefined' && window.location.protocol === 'https:') {
if (baseUrl.startsWith('ws://')) {
baseUrl = baseUrl.replace('ws://', 'wss://')
} else if (baseUrl.startsWith('http://')) {
baseUrl = baseUrl.replace('http://', 'https://')
}
}
const url = new URL(baseUrl)
url.searchParams.set('client_id', currentClientId.value)
return url.toString()
})
const clearHandlers = (wsInstance: WebSocket | null) => {
if (wsInstance) {
wsInstance.onopen = null
wsInstance.onmessage = null
wsInstance.onclose = null
wsInstance.onerror = null
}
}
const connect = () => {
try {
// Clear any pending reconnect timer first
if (reconnectTimer.value) {
clearTimeout(reconnectTimer.value)
reconnectTimer.value = null
}
// Close existing connection if any, and CLEAN HANDLERS to prevent recursion
if (ws.value) {
if (ws.value.readyState !== WebSocket.CLOSED) {
console.log('🔌 Closing existing WebSocket before new connection...')
clearHandlers(ws.value)
ws.value.close()
}
ws.value = null
}
const connectionUrl = wsUrl.value
ws.value = new WebSocket(connectionUrl)
ws.value.onopen = () => {
console.log('✅ WebSocket connected:', currentClientId.value)
isConnected.value = true
reconnectAttempts.value = 0
config.onOpen?.()
}
ws.value.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
config.onMessage?.(data)
} catch (error) {
console.error('Error parsing WebSocket message:', error)
}
}
ws.value.onclose = () => {
console.log('❌ WebSocket closed:', currentClientId.value)
isConnected.value = false
config.onClose?.()
// Attempt to reconnect only if we didn't just reach max attempts
// The reconnectTimer is cleared in disconnect() and at start of connect()
if (reconnectAttempts.value < (config.maxReconnectAttempts || 5)) {
reconnectAttempts.value++
const interval = config.reconnectInterval || 3000
console.log(`⏳ Reconnecting in ${interval}ms... Attempt ${reconnectAttempts.value}`)
reconnectTimer.value = setTimeout(() => {
connect()
}, interval)
} else {
console.error('Max reconnection attempts reached')
}
}
ws.value.onerror = (error) => {
console.error('⚠️ WebSocket error:', error)
config.onError?.(error)
}
} catch (error) {
console.error('❌ Error creating WebSocket:', error)
config.onError?.(error as Event)
}
}
const disconnect = () => {
if (reconnectTimer.value) {
clearTimeout(reconnectTimer.value)
reconnectTimer.value = null
}
if (ws.value) {
console.log('🔌 Manual disconnect: Cleaning handlers and closing...')
clearHandlers(ws.value)
ws.value.close()
ws.value = null
}
isConnected.value = false
reconnectAttempts.value = 0
}
const sendMessage = (message: any) => {
if (ws.value && ws.value.readyState === WebSocket.OPEN) {
ws.value.send(JSON.stringify(message))
return true
}
console.warn('WebSocket is not connected')
return false
}
// Send data via POST to WebSocket server
const sendViaPost = async (message: WebSocketMessage) => {
try {
let postUrl = ''
if (config.fallbackPostUrl) {
// Use the explicitly provided fallback URL (likely a relative proxy path)
postUrl = config.fallbackPostUrl
} else {
// Derive post URL from WebSocket URL (legacy behavior)
let baseUrl = currentUrl.value
if (baseUrl.startsWith('ws://')) {
baseUrl = baseUrl.replace('ws://', 'http://')
} else if (baseUrl.startsWith('wss://')) {
baseUrl = baseUrl.replace('wss://', 'https://')
}
const urlObj = new URL(baseUrl)
postUrl = `${urlObj.protocol}//${urlObj.host}${urlObj.pathname}`
}
const response = await fetch(postUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(message),
})
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error')
console.error('❌ POST Error response:', errorText)
throw new Error(`HTTP error! status: ${response.status}, message: ${errorText}`)
}
const result = await response.json().catch(() => ({ success: true }))
return result
} catch (error) {
console.error('❌ Error sending message via POST:', error)
throw error
}
}
if (getCurrentInstance()) {
onUnmounted(() => {
disconnect()
})
}
return {
ws: computed(() => ws.value),
isConnected: computed(() => isConnected.value),
connect,
disconnect,
sendMessage,
sendViaPost,
reconnectAttempts: computed(() => reconnectAttempts.value),
}
}
+5
View File
@@ -0,0 +1,5 @@
// Default avatar image path
// Simpan gambar avatar default di: assets/images/default-avatar.png
import defaultAvatarImage from '@/assets/images/default-avatar.png'
export const DEFAULT_AVATAR = defaultAvatarImage
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from "cypress";
export default defineConfig({
e2e: {
baseUrl: "http://10.10.150.175:3000",
supportFile: false,
specPattern: "cypress/e2e/**/*.cy.{js,jsx,ts,tsx}",
},
component: {
devServer: {
framework: "vue",
bundler: "vite",
},
},
});
+7
View File
@@ -0,0 +1,7 @@
describe('Login Page', () => {
it('should load the login page', () => {
cy.visit('/')
// Adjust this to match a selector on your home or login page
cy.contains('h1', /login/i).should('be.visible')
})
})
+5
View File
@@ -0,0 +1,5 @@
{
"name": "Using fixtures to represent data",
"email": "[email protected]",
"body": "Fixtures are a great way to mock data for responses to routes"
}
+37
View File
@@ -0,0 +1,37 @@
/// <reference types="cypress" />
// ***********************************************
// This example commands.ts shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add('login', (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
//
// declare global {
// namespace Cypress {
// interface Chainable {
// login(email: string, password: string): Chainable<void>
// drag(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
// dismiss(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
// visit(originalFn: CommandOriginalFn, url: string, options: Partial<VisitOptions>): Chainable<Element>
// }
// }
// }
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Components App</title>
</head>
<body>
<div data-cy-root></div>
</body>
</html>
+36
View File
@@ -0,0 +1,36 @@
// ***********************************************************
// This example support/component.ts is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import './commands'
import { mount } from 'cypress/vue'
// Augment the Cypress namespace to include type definitions for
// your custom command.
// Alternatively, can be defined in cypress/support/component.d.ts
// with a <reference path="./component" /> at the top of your spec.
declare global {
namespace Cypress {
interface Chainable {
mount: typeof mount
}
}
}
Cypress.Commands.add('mount', mount)
// Example use:
// cy.mount(MyComponent)
+1
View File
@@ -0,0 +1 @@
{"message":"Data visit berhasil diambil","data":[{"id":446,"visit_code":26021300007,"registration_datetime":"2026-02-13T03:40:42Z","active":true,"healthcare_services":[{"id":528,"ticket":"RN007","check_in":true,"check_in_datetime":"2026-02-13T03:41:04Z","datetime_start":"2026-02-13T03:40:42Z","healtcare_service_code":"RN","active":true,"fk_ms_healthcare_service_id":11,"healthcare_service_name":"ANAK","shift_number":1,"available_start_time":"07:00:00","fk_ref_payment_type_id":2,"payment_type_name":"JKN","fk_ms_registration_counter_id":14,"registration_counter_name":"LOKET 14 REG","visit_statuses":[{"id":2174,"fk_ref_visit_status_id":3,"datetime":"2026-02-13T03:40:49.674718Z","visit_status_name":"PG ANJUNGAN","visit_status_description":"Panggilan loket ke Anjungan"},{"id":2182,"fk_ref_visit_status_id":28,"datetime":"2026-02-13T03:47:18.423472Z","visit_status_name":"PE PENDAFTARAN","visit_status_description":"Pending Pendaftaran"},{"id":2177,"fk_ref_visit_status_id":6,"datetime":"2026-02-13T03:41:04.275703Z","visit_status_name":"RT PENDAFTARAN","visit_status_description":"Tunggu Panggilan Loket"},{"id":2175,"fk_ref_visit_status_id":4,"datetime":"2026-02-13T03:40:49.676542Z","visit_status_name":"RT CHECK-IN","visit_status_description":"Tunggu Pasien Check-In"},{"id":2173,"fk_ref_visit_status_id":2,"datetime":"2026-02-13T03:40:42.946501Z","visit_status_name":"RT ANJUNGAN","visit_status_description":"Ruang Tunggu Anjungan"},{"id":2172,"fk_ref_visit_status_id":1,"datetime":"2026-02-13T03:40:42.944133Z","visit_status_name":"CT ANJUNGAN","visit_status_description":"Cetak Tiket Antrian"},{"id":2184,"fk_ref_visit_status_id":8,"datetime":"2026-02-13T03:47:20.080008Z","visit_status_name":"PO PENDAFTARAN","visit_status_description":"Proses Pendaftaran"},{"id":2183,"fk_ref_visit_status_id":28,"datetime":"2026-02-13T03:47:18.425723Z","visit_status_name":"PE PENDAFTARAN","visit_status_description":"Pending Pendaftaran"},{"id":2176,"fk_ref_visit_status_id":5,"datetime":"2026-02-13T03:41:04.274554Z","visit_status_name":"PS CHECK-IN","visit_status_description":"Pasien Sudah Check-In"},{"id":2181,"fk_ref_visit_status_id":8,"datetime":"2026-02-13T03:47:14.538472Z","visit_status_name":"PO PENDAFTARAN","visit_status_description":"Proses Pendaftaran"}]}]}],"meta":{"limit":10,"offset":0,"total":2139,"total_pages":214,"current_page":1,"has_next":true,"has_prev":false}}
+47
View File
@@ -0,0 +1,47 @@
services:
web-antrean:
build:
context: .
dockerfile: Dockerfile
container_name: web-antrean
ports:
- "3000:3000"
env_file:
- .env
environment:
- NODE_ENV=production
- HOST=0.0.0.0
- PORT=3000
- NUXT_AUTH_SECRET=${NUXT_AUTH_SECRET}
- NUXT_KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID}
- NUXT_KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET}
- NUXT_KEYCLOAK_ISSUER=${KEYCLOAK_ISSUER}
- NUXT_KEYCLOAK_LOGOUT_URI=${KEYCLOAK_LOGOUT_URI}
- NUXT_POST_LOGOUT_REDIRECT_URI=${POST_LOGOUT_REDIRECT_URI}
- NUXT_PUBLIC_AUTH_URL=${AUTH_ORIGIN}
- NUXT_EXTERNAL_API_BASE_URL=${EXTERNAL_API_BASE_URL}
- NUXT_PUBLIC_VERIFICATION_API_BASE_URL=${VERIFICATION_API_BASE_URL}
- NUXT_PUBLIC_ANTRIAN_API_URL=${ANTRIAN_API_URL}
- NUXT_PUBLIC_VISIT_API_URL=${VISIT_API_URL}
- NUXT_PUBLIC_WS_API_URL=${WS_API_URL}
- NUXT_SESSION_DURATION_HOURS=${SESSION_DURATION_HOURS}
- NUXT_OAUTH_STATE_DURATION_MINUTES=${OAUTH_STATE_DURATION_MINUTES}
volumes:
# Mount local data directory to persist the sqlite users database
- ./data:/app/data
restart: unless-stopped
networks:
- antrean-network
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/', (r) => {process.exit(r.statusCode < 500 ? 0 : 1)})"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
antrean-network:
driver: bridge
volumes:
data:
File diff suppressed because it is too large Load diff
-36
View File
@@ -1,36 +0,0 @@
# # .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
# 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"
KEYCLOAK_CLIENT_ID="akbar-test"
KEYCLOAK_CLIENT_SECRET="FDyv3UYMgJOYPnvzXVVv6diRtcgEevKg"
KEYCLOAK_ISSUER="https://auth.rssa.top/realms/sandbox"
# AUTH_ORIGIN="http://10.10.150.175:3001"
AUTH_ORIGIN="http://localhost:3001"
+25 -220
View File
@@ -1,16 +1,14 @@
<template>
<v-app id="inspire">
<SideBar
:items="filteredNavItems"
v-model:drawer="drawer"
:rail="rail"
@toggle-rail="rail = !rail"
/>
<SideBar
:items="filteredNavItems"
v-model:drawer="drawer"
:rail="rail"
@toggle-rail="rail = !rail"
/>
<v-main app>
<slot />
</v-main>
</v-app>
<v-main app>
<slot />
</v-main>
</template>
<script setup lang="ts">
@@ -22,7 +20,7 @@ import { useNavItemsStore } from '~/stores/navItems1';
import { useAuth } from "~/composables/useAuth";
definePageMeta({
middleware: ['auth', 'permissions']
middleware: ['auth', 'checkPageAccess']
})
// State for controlling the sidebar
@@ -32,219 +30,26 @@ const rail = ref(true);
const navItemsStore = useNavItemsStore();
const { user, checkAuth } = useAuth();
interface NavItem {
id: number;
name: string;
path: string;
icon: string;
children?: NavItem[];
}
// Navigation will be filtered via navItemsStore using the new hakAkses system
interface BackendPermission {
id: number;
create: boolean;
read: boolean;
update: boolean;
disable: boolean;
delete: boolean;
active: boolean;
pagename: string;
pagesID: number;
level?: number;
sort?: number;
parent?: number;
}
interface PermissionResponse {
message?: string;
data?: BackendPermission[];
meta?: {
count: number;
total: number;
};
error?: string;
}
// Cache for API permissions
const apiPermissions = ref<BackendPermission[]>([]);
const currentUserRoles = ref<string[]>([]);
const currentUserGroups = ref<string[]>([]);
// Get current user data with roles and groups
const fetchCurrentUserData = async () => {
try {
const userData = await $fetch('/api/users/current');
currentUserRoles.value = [
...(userData.realmRoles || []),
...(userData.roles || []),
];
// Extract groups from paths (e.g., "/Instalasi STIM/Devops/Superadmin" -> "STIM")
const groups: string[] = [];
(userData.groups || []).forEach((g: string) => {
const parts = g.split('/').filter(Boolean);
if (parts.length > 1) {
groups.push(parts[1]); // Get second part as group name
} else if (parts.length === 1) {
groups.push(parts[0]);
}
});
currentUserGroups.value = groups;
return { roles: currentUserRoles.value, groups: currentUserGroups.value };
} catch (error) {
console.error('Error fetching current user data:', error);
return { roles: [], groups: [] };
}
};
// Fetch permissions from backend API (only for nav filtering, not for saving)
// Saving is now handled by permissions middleware
const fetchPermissionsFromAPI = async () => {
const { roles, groups } = await fetchCurrentUserData();
if (roles.length === 0 || groups.length === 0) {
console.warn('No roles or groups found for current user');
return;
}
// Use first role and first group (or combine as needed)
const primaryRole = roles[0] || '';
const primaryGroup = groups[0] || '';
if (!primaryRole || !primaryGroup) {
return;
}
try {
const response = await $fetch<PermissionResponse>('/api/permission', {
query: {
roles: primaryRole,
groups: primaryGroup,
},
});
if (response && response.data && Array.isArray(response.data)) {
apiPermissions.value = response.data;
// Note: Auto-save to allHakAksesData is now handled by permissions middleware
}
} catch (error) {
console.error('Error fetching permissions from API:', error);
// Fallback to local storage if API fails
apiPermissions.value = [];
}
};
const filteredNavItems = computed(() => {
// If no API permissions, check local storage as fallback
if (apiPermissions.value.length === 0) {
const hakAksesData = useLocalStorage<any[]>('allHakAksesData', []);
const roleCandidates = [
...(user.value?.roles || []),
...(user.value?.realm_access?.roles || []),
].map((role) => role.toLowerCase());
const localPermission = hakAksesData.value.find((item) =>
roleCandidates.includes(item.role?.toLowerCase() || item.namaTipeUser?.toLowerCase())
);
if (localPermission) {
const permissionMap = new Map(
localPermission.hakAksesMenu.map((menu: any) => [menu.name.toLowerCase(), menu])
);
const applyFilter = (items: NavItem[]): NavItem[] => {
return items
.map((item) => {
const menuPerm = permissionMap.get(item.name.toLowerCase());
const filteredChildren = item.children ? applyFilter(item.children) : [];
const allowThis = menuPerm ? menuPerm.canAccess : false;
const hasChildren = filteredChildren.length > 0;
if (!allowThis && !hasChildren) return null;
return {
...item,
...(hasChildren ? { children: filteredChildren } : {}),
};
})
.filter(Boolean);
};
return applyFilter(navItemsStore.navItems);
}
// If no permissions found, show all items
return navItemsStore.navItems;
}
// Use API permissions to filter
const permissionMap = new Map(
apiPermissions.value.map((perm) => [perm.pagename.toLowerCase(), perm])
);
// Mapping untuk pagename dari API ke nama menu di sidebar
const pagenameToMenuMapping: Record<string, string[]> = {
'halaman utama': ['dashboard', 'halaman utama'],
'pengaturan': ['master data'],
'halaman': ['master data'],
'dashboard': ['dashboard'],
};
const applyFilter = (items: NavItem[]): NavItem[] => {
return items
.map((item) => {
// Try to match by pagename or menu name
let perm = permissionMap.get(item.name.toLowerCase());
// If no direct match, try fuzzy matching
if (!perm) {
perm = Array.from(permissionMap.values()).find(p => {
const pagenameLower = p.pagename?.toLowerCase() || '';
const menuNameLower = item.name.toLowerCase();
// Direct match
if (pagenameLower === menuNameLower) return true;
// Contains match
if (pagenameLower.includes(menuNameLower) || menuNameLower.includes(pagenameLower)) {
return true;
}
// Check mapping
const mappedMenus = pagenameToMenuMapping[pagenameLower];
if (mappedMenus && mappedMenus.some(m => m === menuNameLower)) {
return true;
}
return false;
});
}
const filteredChildren = item.children ? applyFilter(item.children) : [];
const allowThis = perm ? (perm.active || perm.read) : false;
const hasChildren = filteredChildren.length > 0;
// If permission allows and has children, show item with filtered children
// If permission allows but no children, show item
// If no permission but has allowed children, show item with children
if (!allowThis && !hasChildren) return null;
return {
...item,
...(hasChildren ? { children: filteredChildren } : {}),
};
})
.filter(Boolean);
};
return applyFilter(navItemsStore.navItems);
});
const filteredNavItems = computed(() => navItemsStore.filteredNavItems);
onMounted(async () => {
await checkAuth();
await fetchPermissionsFromAPI();
if (user.value) {
await navItemsStore.refreshNavItems();
}
});
// Watch for user changes to refresh navItems
watch(() => user.value, async (newUser) => {
if (newUser) {
await navItemsStore.refreshNavItems();
} else {
// Optionally reset navItems when logged out
navItemsStore.filteredNavItems = [];
}
}, { deep: true });
</script>
<style scoped>
+3 -5
View File
@@ -1,9 +1,7 @@
<template>
<v-app>
<v-main>
<slot />
</v-main>
</v-app>
<v-main>
<slot />
</v-main>
</template>
<style>
+42 -11
View File
@@ -10,9 +10,8 @@ export default defineNuxtRouteMiddleware(async (to: RouteLocationNormalized) =>
const bypassFlag = sessionStorage.getItem('bypassRootRedirect');
if (bypassFlag === 'true' && to.path === '/') {
console.log('🔑 Bypass flag detected - allowing root access');
// Clear the flag immediately to prevent future bypasses
sessionStorage.removeItem('bypassRootRedirect');
return; // Allow access without any redirect
return;
}
}
@@ -28,29 +27,61 @@ export default defineNuxtRouteMiddleware(async (to: RouteLocationNormalized) =>
return;
}
// Skip auth check if it's the development server side render pass.
if (process.server && process.env.NODE_ENV === 'development') {
console.log('⏭️ Skipping intensive check on server-side during development');
useAuth();
// On server-side, skip auth check - let client handle it
if (process.server) {
console.log('⏭️ Server-side: Skipping auth check (will verify on client)');
return;
}
// CLIENT-SIDE ONLY from here
// Check for the authentication signal from a successful login redirect
const isAuthRedirect: boolean = to.query.authenticated === 'true';
const isAuthRedirect = to.query.authenticated === 'true';
if (isAuthRedirect) {
console.log('⏳ Client-side is processing a new login session, allowing the route to load...');
return navigateTo({ path: to.path, query: {} }, { replace: true });
console.log('⏳ Processing new login session...');
// Give browser time to process the cookie from redirect
await new Promise(resolve => setTimeout(resolve, 100));
try {
const { checkAuth } = useAuth();
console.log('🔍 Checking authentication after redirect...');
let user = await checkAuth();
// Retry up to 3 times if cookie not available yet
let retries = 0;
while (!user && retries < 3) {
console.log(`⚠️ Cookie not available yet, retry ${retries + 1}/3...`);
await new Promise(resolve => setTimeout(resolve, 200));
user = await checkAuth();
retries++;
}
if (user) {
console.log('✅ User authenticated after redirect:', user.name || user.email);
// Remove query parameter and allow access
return navigateTo({ path: to.path, query: {} }, { replace: true });
} else {
console.log('❌ No session after retries, redirecting to login');
return navigateTo('/LoginPage');
}
} catch (authError) {
console.error('❌ Auth check failed after redirect:', authError);
return navigateTo('/LoginPage');
}
}
// Normal auth check for protected routes
try {
const { checkAuth } = useAuth();
console.log('🔍 Checking authentication status using useAuth...');
console.log('🔍 Checking authentication status...');
const user = await checkAuth();
if (user) {
console.log('✅ User is authenticated:', user.name || user.preferred_username || user.email);
console.log('✅ User is authenticated:', user.name || user.email);
return;
} else {
console.log('❌ No valid session found, redirecting to login');
+70
View File
@@ -0,0 +1,70 @@
// middleware/checkPageAccess.ts
// Middleware to check if user has access to the page based on hakAkses
export default defineNuxtRouteMiddleware(async (to, from) => {
// Skip check for public pages
const publicPaths = ['/LoginPage', '/auth/login', '/index-legacy'];
// index.vue is the debug dashboard, let's keep it accessible for now as requested
if (to.path === '/' || publicPaths.includes(to.path)) {
return;
}
// On server-side, skip access check - let client handle it
// This matches auth.ts behavior and prevents SSR failures when cookie context is missing
if (process.server) {
console.log('⏭️ Server-side: Skipping page access check (will verify on client)');
return;
}
// Import useAuth and useHakAkses
const { user, checkAuth } = useAuth();
const { getAllowedPages } = useHakAkses();
// If user not loaded, try to load
if (!user.value) {
await checkAuth();
}
// If still not authenticated, redirect to login
if (!user.value) {
return navigateTo('/LoginPage');
}
try {
const allowedPages = await getAllowedPages();
const targetPath = to.path.endsWith('/') && to.path.length > 1 ? to.path.slice(0, -1) : to.path;
const targetPathLower = targetPath.toLowerCase();
const toPathLower = to.path.toLowerCase();
// Check if user has access to this page
// We also check against the raw path just in case, case-insensitive
const isAllowed = allowedPages.some(path => {
const normalizedAllowed = path.endsWith('/') && path.length > 1 ? path.slice(0, -1) : path;
const normalizedAllowedLower = normalizedAllowed.toLowerCase();
const pathLower = path.toLowerCase();
return normalizedAllowedLower === targetPathLower || pathLower === toPathLower;
});
if (!isAllowed) {
console.warn(`Access denied to ${to.path}. User allowed pages:`, allowedPages);
// Redirect to first allowed page if available, else stay/error
if (allowedPages.length > 0) {
// If dashboard is allowed, go there, else go to the first allowed one
const dashboardPath = allowedPages.find(p => p === '/' || p === '/dashboard');
return navigateTo(dashboardPath || allowedPages[0]);
} else {
// No access to any page - technically this shouldn't happen if user has roles
console.error('User has roles but no allowed pages found in configuration.');
// For now, allow root as fallback since index.vue is kept
if (to.path === '/') return;
}
}
} catch (error) {
console.error('Error checking page access:', error);
// On error, we might want to allow or block. Let's allow but log.
return;
}
});
+2 -1
View File
@@ -27,8 +27,9 @@ export default defineNuxtRouteMiddleware(async (to) => {
try {
console.log('🔍 Checking if user is already authenticated...');
const headers = import.meta.server ? useRequestHeaders(['cookie']) as Record<string, string> : {}
// The $fetch will automatically send the new user_session cookie
const session = await $fetch<{ user: any } | null>('/api/auth/session').catch(() => null);
const session = await $fetch<{ user: any } | null>('/api/auth/session', { headers }).catch(() => null);
if (session && session.user) {
console.log('✅ User already authenticated, redirecting to dashboard');
+5 -3
View File
@@ -231,9 +231,11 @@ export default defineNuxtRouteMiddleware(async (to) => {
return;
}
// DISABLED: Auto-fetch permissions is now disabled
// Permissions should only be fetched manually via button click in HakAkses page
// Run async permission sync (non-blocking)
// This will only run once per session due to sessionStorage check
fetchAndSavePermissions().catch(err => {
console.error('❌ [Permissions Middleware] Failed to sync permissions:', err);
});
// fetchAndSavePermissions().catch(err => {
// console.error('❌ [Permissions Middleware] Failed to sync permissions:', err);
// });
});
+69 -45
View File
@@ -3,12 +3,11 @@ import vuetify, { transformAssetUrls } from "vite-plugin-vuetify";
export default defineNuxtConfig({
compatibilityDate: "2025-05-15",
devtools: {
enabled: true,
enabled: process.env.ENABLE_DEVTOOLS === 'true',
timeline: {
enabled: true,
enabled: false,
},
},
app: {
head: {
meta: [
@@ -16,47 +15,38 @@ export default defineNuxtConfig({
{ name: 'mobile-web-app-capable', content: 'yes' },
{ name: 'apple-mobile-web-app-capable', content: 'yes' },
{ name: 'apple-mobile-web-app-status-bar-style', content: 'black-translucent' }
],
link: [
{ rel: "icon", type: "image/x-icon", href: "/love%20logo%20biru%20small.ico" },
]
}
},
},
modules: [
// "@nuxt/content",
"@nuxt/eslint",
"@nuxt/fonts",
"@nuxt/icon",
"@nuxt/image",
"@nuxt/scripts",
"@nuxt/test-utils",
"@nuxt/ui",
"@pinia/nuxt",
"@vesp/nuxt-fontawesome",
"@nuxtjs/google-fonts",
async (_options, nuxt) => {
nuxt.hooks.hook("vite:extendConfig", async (config) => {
modules: [// "@nuxt/content",
"@nuxt/eslint", "@nuxt/fonts", "@nuxt/icon", "@nuxt/image", "@nuxt/scripts", "@nuxt/test-utils", "@nuxt/ui", "@pinia/nuxt", "@vesp/nuxt-fontawesome", "@nuxtjs/google-fonts", async (_options, nuxt) => {
nuxt.hooks.hook("vite:extendConfig", async (config) => {
// @ts-expect-error
config.plugins.push(vuetify({ autoImport: true }));
// Add HTTPS plugin
try {
// @ts-ignore
const { default: basicSsl } = await import('@vitejs/plugin-basic-ssl');
// @ts-expect-error
config.plugins.push(vuetify({ autoImport: true }));
// Add HTTPS plugin
try {
// @ts-ignore
const { default: basicSsl } = await import('@vitejs/plugin-basic-ssl');
// @ts-expect-error
config.plugins.push(basicSsl());
// @ts-expect-error
config.server = config.server || {};
// @ts-expect-error
config.server.https = true;
// @ts-expect-error
config.server.host = '10.10.150.175';
// @ts-expect-error
config.server.port = 3001;
} catch (e) {
console.warn('Failed to load HTTPS plugin:', e);
}
});
},
],
config.plugins.push(basicSsl());
// @ts-expect-error
config.server = config.server || {};
// @ts-expect-error
config.server.https = true;
// @ts-expect-error
config.server.host = '10.10.150.114';
// @ts-expect-error
config.server.port = 3000;
} catch (e) {
console.warn('Failed to load HTTPS plugin:', e);
}
});
}, "nuxt-charts"],
fontawesome: {
icons: {
@@ -77,10 +67,24 @@ export default defineNuxtConfig({
keycloakClientId: process.env.KEYCLOAK_CLIENT_ID,
keycloakClientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
keycloakIssuer: process.env.KEYCLOAK_ISSUER,
keycloakLogoutUri: process.env.KEYCLOAK_LOGOUT_URI, // Optional: custom logout URI
postLogoutRedirectUri: process.env.POST_LOGOUT_REDIRECT_URI, // Optional: custom post-logout redirect URI
// Session and Oauth durations
sessionDurationHours: parseInt(process.env.SESSION_DURATION_HOURS || '1', 10),
oauthStateDurationMinutes: parseInt(process.env.OAUTH_STATE_DURATION_MINUTES || '10', 10),
// External API
externalApiBaseUrl: process.env.EXTERNAL_API_BASE_URL || 'http://10.10.123.135:8084',
externalApiTimeout: parseInt(process.env.EXTERNAL_API_TIMEOUT || '10000', 10),
public: {
authUrl: process.env.AUTH_ORIGIN,
// authUrl: process.env.AUTH_ORIGIN || "http://10.10.150.175:3001",
// authUrl: process.env.AUTH_ORIGIN || "http://localhost:3001",
wsBaseUrl: process.env.WS_API_URL || process.env.WS_BASE_URL || 'ws://10.10.123.135:8084/api/v1/ws',
verificationApiBaseUrl: process.env.ANTRIAN_API_URL || process.env.VERIFICATION_API_BASE_URL || 'http://10.10.123.140:8089/api/v1',
externalApiBaseUrl: process.env.VISIT_API_URL || (process.env.EXTERNAL_API_BASE_URL ? `${process.env.EXTERNAL_API_BASE_URL}/api/v1` : 'http://10.10.123.135:8084/api/v1'),
},
},
@@ -93,11 +97,31 @@ export default defineNuxtConfig({
"@mdi/font/css/materialdesignicons.min.css",
"~/assets/scss/main.scss",
],
devServer: (() => {
const hostEnv = process.env.HOST || 'localhost';
// Parse HOST if it's a full URL (e.g., "http://10.10.150.175:3000")
let host = hostEnv;
let port = 3000;
try {
const url = new URL(hostEnv);
host = url.hostname;
port = url.port ? parseInt(url.port, 10) : 3000;
} catch {
// If HOST is not a URL, use it as-is (e.g., "10.10.150.175" or "localhost")
host = hostEnv;
}
return {
port: port,
host: host,
https: false, // Enable HTTPS
allowedHosts: ["localhost", "localhost.dev", "10.10.150.175","antrean.dev.rssa.id"]
};
})(),
devServer: {
port: 3001,
host: '10.10.150.175'
},
vite: {
css: {
@@ -116,4 +140,4 @@ export default defineNuxtConfig({
noExternal: ["vuetify"],
},
},
});
});
View File
+6939 -2300
View File
File diff suppressed because it is too large Load diff
+18 -7
View File
@@ -4,14 +4,17 @@
"type": "module",
"scripts": {
"build": "nuxt build",
"_command_dev": "nuxt dev -o --host --port 3001",
"_command_dev2": "nuxt dev -o --port 3001",
"_command_dev3": "nuxt dev -o --host 10.10.150.175 --port 3001",
"_command_dev": "nuxt dev -o --host --port 3000",
"_command_dev2": "nuxt dev -o --port 3000",
"dev": "nuxt dev -o",
"dev:https": "nuxt dev -o --host localhost --port 3001",
"dev:https": "nuxt dev -o --host 0.0.0.0 --port 3000",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
"postinstall": "nuxt prepare",
"test": "vitest",
"test:ui": "vitest --ui",
"cypress:open": "cypress open",
"cypress:run": "cypress run"
},
"dependencies": {
"@fortawesome/free-brands-svg-icons": "^7.1.0",
@@ -33,23 +36,31 @@
"eslint": "^9.32.0",
"html5-qrcode": "^2.3.8",
"nuxt": "^3.17.7",
"nuxt-charts": "^2.0.0",
"nuxt-qrcode": "^0.4.8",
"pinia": "^3.0.3",
"pinia-plugin-persistedstate": "^4.7.1",
"qrcode": "^1.5.4",
"qrcode.vue": "^3.6.0",
"typescript": "^5.8.3",
"vue": "^3.5.18",
"vue-chartjs": "^5.3.2",
"vue-draggable-next": "^2.3.0",
"vue-router": "^4.5.1"
"vue-router": "^4.5.1",
"vue3-carousel": "^0.17.0"
},
"devDependencies": {
"@nuxtjs/google-fonts": "^3.2.0",
"@types/node": "^24.7.2",
"@vitejs/plugin-basic-ssl": "^2.1.0",
"@vitejs/plugin-basic-ssl": "^2.1.4",
"@vitest/ui": "^3.2.4",
"@vue/test-utils": "^2.4.6",
"cypress": "^15.9.0",
"happy-dom": "^20.5.0",
"sass": "^1.93.3",
"sass-embedded": "^1.89.2",
"vite-plugin-vuetify": "^2.1.2",
"vitest": "^3.2.4",
"vuetify": "^3.9.3"
}
}
+316 -89
View File
@@ -1,16 +1,17 @@
<template>
<div class="loket-container">
<div>
<!-- Compact Header -->
<PageHeader
icon="mdi-hospital-building"
title="Admin Klinik"
:subtitle="currentDate"
:show-add-button="false"
theme="secondary"
theme="primary"
/>
<!-- Main Content Grid -->
<v-row class="content-grid" dense>
<div class="loket-container">
<!-- Main Content Grid -->
<v-row class="content-grid" dense>
<!-- Left Column: Current Patient & Queue Actions -->
<v-col cols="12" md="5">
<div class="sticky-wrapper">
@@ -23,6 +24,7 @@
@action="handlePatientAction"
@change-klinik="showChangeKlinikDialog = true"
@process-next="handleProcessNext"
@call="handleCallPatient"
/>
<!-- Queue Actions Card -->
@@ -30,6 +32,7 @@
class="mt-3"
:total-quota="150"
:used-quota="quotaUsed"
:menunggu-count="menungguCount"
:has-next="!!nextPatient"
@call="handleCall"
/>
@@ -41,12 +44,12 @@
<v-btn
block
class="py-6"
color="primary-600"
color="warning"
:disabled="!currentProcessingPatient"
@click="showKlinikDialog = true"
@click="openKlinikRuangDialog()"
>
<v-icon start>mdi-hospital-building</v-icon>
Buat Antrean Klinik
<v-icon start>mdi-door</v-icon>
Buat Antrean Ruang
</v-btn>
</v-col>
@@ -73,41 +76,9 @@
<v-card-text class="pa-4">
<!-- Header with Filters -->
<div class="data-header mb-4">
<div class="section-label">DATA PASIEN</div>
<div class="section-label">CARI PASIEN</div>
<div class="filters">
<!-- Status Filter -->
<v-chip-group v-model="selectedStatus" mandatory class="status-filter">
<v-chip value="all" :class="{ 'active-chip': selectedStatus === 'all' }">
Semua ({{ allPatients.length }})
</v-chip>
<v-chip value="diloket" :class="{ 'active-chip': selectedStatus === 'diloket' }">
Di Klinik ({{ (diLoketPatients || []).length }})
</v-chip>
<v-chip value="terlambat" :class="{ 'active-chip': selectedStatus === 'terlambat' }">
Terlambat ({{ (terlambatPatients || []).length }})
</v-chip>
<v-chip value="pending" :class="{ 'active-chip': selectedStatus === 'pending' }">
Pending ({{ (pendingPatients || []).length }})
</v-chip>
</v-chip-group>
<!-- Fast Track Filter -->
<v-select
v-model="selectedFastTrack"
:items="fastTrackOptions"
label="Filter Fast Track"
density="compact"
hide-details
clearable
class="fast-track-filter"
variant="outlined"
>
<template #prepend-inner>
<v-icon size="20">mdi-flash</v-icon>
</template>
</v-select>
<!-- Search Field -->
<v-text-field
v-model="searchQuery"
@@ -120,18 +91,21 @@
</div>
</div>
<PatientDataTable
:items="allPatients"
v-model:selected-status="selectedStatus"
v-model:search-query="searchQuery"
v-model:selected-fast-track="selectedFastTrack"
:di-loket-count="(diLoketPatients || []).length"
:terlambat-count="(terlambatPatients || []).length"
:pending-count="(pendingPatients || []).length"
:status-labels="statusLabels"
:show-diproses="false"
@action="handleTableAction"
/>
<ClientOnly>
<PatientDataTable
:items="allPatients"
v-model:selected-status="selectedStatus"
v-model:search-query="searchQuery"
v-model:selected-fast-track="selectedFastTrack"
:di-loket-count="(diLoketPatients || []).length"
:terlambat-count="(terlambatPatients || []).length"
:pending-count="(pendingPatients || []).length"
:status-labels="statusLabels"
:show-diproses="false"
:fast-track-options="fastTrackOptions"
@action="handleTableAction"
/>
</ClientOnly>
</v-card-text>
</v-card>
</v-col>
@@ -167,18 +141,105 @@
@select="changeKlinik"
/>
<!-- Dialog Klinik Ruang -->
<v-dialog v-model="showKlinikRuangDialog" max-width="900px" persistent scrollable>
<v-card class="dialog-card">
<v-card-title class="dialog-header dialog-header-warning">
<span class="headline-4">Pilih Klinik Ruang</span>
<v-btn icon variant="text" size="small" class="btn-close" @click="closeKlinikRuangDialog">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-divider/>
<v-card-text class="dialog-content">
<div v-if="currentProcessingPatient" class="patient-card mb-4">
<h4 class="headline-5 mb-3">Informasi Pasien</h4>
<v-row dense>
<v-col cols="6">
<div class="detail-item">
<span class="caption-2 text-muted">Barcode</span>
<span class="body-2 text-semibold">{{ currentProcessingPatient.barcode }}</span>
</div>
</v-col>
<v-col cols="6">
<div class="detail-item">
<span class="caption-2 text-muted">No. Antrian</span>
<span class="body-2 text-semibold">{{ currentProcessingPatient.noAntrian }}</span>
</div>
</v-col>
</v-row>
</div>
<v-text-field
v-model="klinikRuangSearch"
placeholder="Cari Klinik Ruang..."
prepend-inner-icon="mdi-magnify"
variant="outlined"
density="compact"
hide-details
class="mb-4"
/>
<v-expansion-panels class="expansion-panels">
<v-expansion-panel
v-for="klinikRuang in filteredKlinikRuang"
:key="klinikRuang.id"
class="expansion-panel"
>
<v-expansion-panel-title class="expansion-title">
<div class="expansion-header">
<v-chip size="small" color="warning" class="mr-2">
{{ klinikRuang.kodeKlinik }}
</v-chip>
<span class="body-2 text-semibold">{{ klinikRuang.namaKlinik }}</span>
</div>
<template #actions>
<v-chip size="x-small" variant="outlined">
{{ klinikRuang.ruangList.length }} Ruang
</v-chip>
</template>
</v-expansion-panel-title>
<v-expansion-panel-text>
<div class="ruang-list">
<div
v-for="ruang in klinikRuang.ruangList"
:key="ruang.nomorRuang"
class="ruang-item"
@click="buatAntreanKlinikRuang(klinikRuang, ruang)"
>
<v-icon color="warning" size="20">mdi-door</v-icon>
<div class="ruang-info">
<span class="body-3 text-semibold">Ruang {{ ruang.nomorRuang }} - {{ ruang.namaRuang }}</span>
<span class="caption-2 text-muted">Screen: {{ ruang.nomorScreen }}</span>
</div>
<v-icon size="20" color="grey">mdi-chevron-right</v-icon>
</div>
</div>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-card-text>
</v-card>
</v-dialog>
<!-- Snackbar -->
<AppSnackbar
v-model="snackbar"
:message="snackbarText"
:color="snackbarColor"
/>
</div>
</div>
</template>
<script setup>
import { ref, computed } from "vue";
import { useQueue } from "@/composables/useQueue";
import { useQueueStore } from "@/stores/queueStore";
import { useMasterStore } from "@/stores/masterStore";
import PageHeader from "@/components/common/PageHeader.vue";
import CurrentPatientCard from "@/components/features/queue/CurrentPatientCard.vue";
import QueueActionsCard from "@/components/features/queue/QueueActionsCard.vue";
@@ -186,6 +247,9 @@ import PatientDataTable from "@/components/features/queue/TabelPatientData.vue";
import SelectionDialog from "@/components/common/SelectionDialog.vue";
import AppSnackbar from "@/components/common/AppSnackbar.vue";
const masterStore = useMasterStore();
const queueStore = useQueueStore();
const {
snackbar,
snackbarText,
@@ -201,6 +265,7 @@ const {
terlambatPatients,
pendingPatients,
nextPatient,
menungguCount,
quotaUsed,
filteredKliniks,
filteredPenunjangs,
@@ -228,6 +293,24 @@ const selectedStatus = ref("all");
const searchQuery = ref("");
const selectedFastTrack = ref(null);
// Dialog Klinik Ruang
const showKlinikRuangDialog = ref(false);
const klinikRuangSearch = ref("");
// Get klinik ruang data from masterStore
const klinikRuangList = computed(() => {
return masterStore.ruangData || [];
});
const filteredKlinikRuang = computed(() => {
if (!klinikRuangSearch.value) return klinikRuangList.value;
return klinikRuangList.value.filter(
(k) =>
k.namaKlinik.toLowerCase().includes(klinikRuangSearch.value.toLowerCase()) ||
k.kodeKlinik.toLowerCase().includes(klinikRuangSearch.value.toLowerCase())
);
});
// Fast Track options from all patients
const fastTrackOptions = computed(() => {
const normalizedValues = allPatients.value
@@ -297,6 +380,41 @@ const handleTableAction = (item, action) => {
const handleProcessNext = () => {
processNextQueue();
};
const handleCallPatient = () => {
// TODO: Integrate text-to-speech library here
// Example: speak(`Nomor antrian ${currentProcessingPatient.value?.noAntrian.split(" |")[0]}, silakan menuju ke loket`)
if (currentProcessingPatient.value) {
console.log('Calling patient:', currentProcessingPatient.value);
// Placeholder for text-to-speech integration
}
};
const openKlinikRuangDialog = () => {
showKlinikRuangDialog.value = true;
};
const closeKlinikRuangDialog = () => {
showKlinikRuangDialog.value = false;
klinikRuangSearch.value = "";
};
const buatAntreanKlinikRuang = (klinikRuang, ruang) => {
if (!currentProcessingPatient.value) return;
const result = queueStore.createAntreanKlinikRuang(
klinikRuang,
ruang,
currentProcessingPatient.value,
"klinik"
);
snackbarText.value = result.message;
snackbarColor.value = result.success ? "success" : "error";
snackbar.value = true;
closeKlinikRuangDialog();
};
</script>
<style scoped lang="scss">
@@ -304,6 +422,12 @@ const handleProcessNext = () => {
background: var(--color-neutral-300);
min-height: 100vh;
padding: 16px;
border-radius: 0 !important;
}
/* Remove rounded edges from PageHeader component */
:deep(.page-header) {
border-radius: 0 !important;
}
.content-grid {
@@ -335,36 +459,6 @@ const handleProcessNext = () => {
flex-wrap: wrap;
}
.status-filter {
flex: 1;
min-width: 300px;
}
.status-filter .v-chip {
font-size: 12px;
font-weight: 600;
height: 32px;
border: 1px solid var(--color-neutral-500);
background: var(--color-neutral-100);
color: var(--color-neutral-600);
transition: all 0.2s ease;
}
.status-filter .v-chip:hover {
background: var(--color-neutral-300);
}
.status-filter .v-chip.active-chip {
background: var(--color-secondary-600);
color: var(--color-neutral-100);
border-color: var(--color-secondary-600);
}
.fast-track-filter {
min-width: 180px;
max-width: 220px;
}
.search-field {
max-width: 300px;
min-width: 250px;
@@ -390,6 +484,144 @@ const handleProcessNext = () => {
background: var(--color-neutral-100);
}
/* Dialog Klinik Ruang Styles */
.dialog-card {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.dialog-header {
background: linear-gradient(135deg, var(--color-warning-600) 0%, var(--color-primary-700) 100%);
color: var(--color-neutral-100);
padding: 20px 24px;
display: flex;
justify-content: space-between;
align-items: center;
}
.dialog-header-warning {
background: linear-gradient(135deg, var(--color-warning-600) 0%, var(--color-primary-700) 100%);
}
.headline-4 {
font-size: 20px;
line-height: 28px;
font-weight: 600;
margin: 0;
}
.headline-5 {
font-size: 18px;
line-height: 24px;
font-weight: 600;
margin: 0;
color: var(--color-neutral-900);
}
.btn-close {
color: var(--color-neutral-100) !important;
}
.dialog-content {
padding: 24px !important;
background: var(--color-neutral-300);
}
.patient-card {
background: var(--color-neutral-100);
border-radius: 12px;
padding: 20px;
border: 1px solid var(--color-neutral-400);
}
.detail-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.caption-2 {
font-size: 12px;
line-height: 16px;
font-weight: 400;
}
.body-2 {
font-size: 16px;
line-height: 24px;
font-weight: 400;
}
.body-3 {
font-size: 14px;
line-height: 20px;
font-weight: 400;
}
.text-semibold {
font-weight: 600 !important;
}
.text-muted {
color: var(--color-neutral-700);
}
.expansion-panels {
background: transparent;
}
.expansion-panel {
background: var(--color-neutral-100);
border: 1px solid var(--color-neutral-400);
border-radius: 12px !important;
margin-bottom: 12px;
overflow: hidden;
}
.expansion-panel::before {
box-shadow: none !important;
}
.expansion-title {
background: var(--color-neutral-100);
min-height: 60px;
}
.expansion-header {
display: flex;
align-items: center;
}
.ruang-list {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px 0;
}
.ruang-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: var(--color-neutral-300);
border: 1px solid var(--color-neutral-400);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
}
.ruang-item:hover {
border-color: var(--color-warning-600);
background: rgba(255, 185, 95, 0.15);
}
.ruang-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
@media (max-width: 960px) {
.loket-container {
padding: 12px;
@@ -406,12 +638,7 @@ const handleProcessNext = () => {
align-items: stretch;
}
.status-filter {
min-width: 100%;
}
.search-field,
.fast-track-filter {
.search-field {
max-width: 100%;
min-width: 100%;
}
File diff suppressed because it is too large Load diff
+308
View File
@@ -0,0 +1,308 @@
<template>
<div>
<PageHeader
icon="mdi-door-open"
title="Admin Klinik Ruang"
subtitle="Pilih Klinik untuk mengelola antrian ruang"
:show-add-button="false"
theme="primary"
/>
<div class="admin-klinik-ruang-index">
<div v-if="klinikRuangList.length > 0" class="klinik-grid">
<div
v-for="klinikRuang in klinikRuangList"
:key="`${klinikRuang.kodeKlinik}-${klinikRuang.jenisLayanan}`"
class="klinik-card"
@click="navigateToKlinik(klinikRuang.kodeKlinik, klinikRuang.jenisLayanan)"
>
<div class="klinik-card-header">
<div class="klinik-info">
<h3 class="klinik-name">{{ klinikRuang.namaKlinik }}</h3>
<div class="klinik-details">
<v-chip
size="x-small"
:class="klinikRuang.jenisLayanan === 'Reguler' ? 'chip-reguler' : 'chip-eksekutif'"
>
{{ klinikRuang.jenisLayanan }}
</v-chip>
<span class="kode-chip">{{ klinikRuang.kodeKlinik }}</span>
</div>
</div>
</div>
<div class="klinik-preview">
<div class="klinik-room-count">
<v-icon size="18" color="primary-600">mdi-door</v-icon>
<span>{{ klinikRuang.ruangList.length }} Ruang</span>
</div>
<div class="klinik-tags">
<v-chip
v-for="(ruang, idx) in klinikRuang.ruangList.slice(0, 3)"
:key="idx"
size="small"
class="ma-1 chip-preview"
>
{{ ruang.namaRuang }}
</v-chip>
<v-chip
v-if="klinikRuang.ruangList.length > 3"
size="small"
class="ma-1 chip-more"
>
+{{ klinikRuang.ruangList.length - 3 }}
</v-chip>
</div>
</div>
<!-- <div class="klinik-card-footer">
<v-btn
color="primary-600"
variant="flat"
size="large"
block
class="btn-view text-white"
>
<v-icon left>mdi-clipboard-list</v-icon>
Kelola Antrian
</v-btn>
</div> -->
</div>
</div>
<div v-else class="empty-state">
<v-icon size="64" color="grey-lighten-1">mdi-hospital-building-outline</v-icon>
<h3>Tidak Ada Klinik Ruang Tersedia</h3>
<p>Silakan tambah klinik ruang terlebih dahulu di halaman master</p>
<v-btn
color="primary-600"
variant="flat"
class="mt-4"
@click="navigateToSettings"
>
<v-icon left>mdi-cog</v-icon>
Ke Halaman Master
</v-btn>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useMasterStore } from '@/stores/masterStore';
import { useClinicStore } from '@/stores/clinicStore';
import { useRuangStore } from '@/stores/ruangStore';
import PageHeader from '@/components/common/PageHeader.vue';
const router = useRouter();
const masterStore = useMasterStore();
const clinicStore = useClinicStore();
const ruangStore = useRuangStore();
const loading = ref(false);
const klinikRuangList = computed(() => {
const list = masterStore.ruangData || [];
return [...list].sort((a, b) => a.namaKlinik.localeCompare(b.namaKlinik));
});
const navigateToKlinik = (kodeKlinik, jenisLayanan) => {
// Include jenisLayanan as query parameter to differentiate same clinic code with different service types
router.push({
path: `/adminklinikruang/${kodeKlinik}`,
query: { jenisLayanan }
});
};
const navigateToSettings = () => {
router.push('/setting/masterklinikruang');
};
// Load data on mount
onMounted(async () => {
console.log('🚀 AdminKlinikRuang/index mounted, loading data...');
loading.value = true;
try {
// 1. Fetch clinics from API (uses cache if available)
await clinicStore.fetchRegulerClinics();
// 2. Sync room definitions from API (centralized logic)
// This will update ruangStore.ruangData which klinikRuangList depends on
await ruangStore.fetchRuangFromAPI();
console.log('✅ Data loading and sync completed in AdminKlinikRuang/index');
} catch (error) {
console.error('❌ Error loading data:', error);
} finally {
loading.value = false;
}
});
</script>
<style scoped lang="scss">
.admin-klinik-ruang-index {
background: var(--color-neutral-300);
min-height: calc(100vh - 80px);
padding: 16px;
font-family: 'Inter', 'Roboto', sans-serif;
}
/* Remove rounded edges from PageHeader component */
:deep(.page-header) {
border-radius: 0 !important;
}
.klinik-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 20px;
width: 100%;
margin: 0;
}
.klinik-card {
background: var(--color-neutral-100);
border-radius: 16px;
padding: 24px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
border: 2px solid var(--color-neutral-400);
cursor: pointer;
transition: all 0.3s ease;
display: flex;
flex-direction: column;
gap: 16px;
&:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(33, 150, 243, 0.2);
border-color: var(--color-primary-600);
}
}
.klinik-card-header {
display: flex;
align-items: center;
gap: 16px;
padding-bottom: 16px;
border-bottom: 2px solid var(--color-neutral-400);
}
.klinik-info {
flex: 1;
}
.klinik-name {
font-size: 20px;
font-weight: 700;
margin: 0 0 8px 0;
color: var(--color-neutral-900);
line-height: 1.2;
}
.klinik-details {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
}
.chip-reguler {
background-color: var(--color-primary-600) !important;
color: var(--color-neutral-100) !important;
font-weight: 600;
font-size: 11px;
}
.chip-eksekutif {
background-color: var(--color-secondary-600) !important;
color: var(--color-neutral-100) !important;
font-weight: 600;
font-size: 11px;
}
.kode-chip {
font-size: 12px;
font-weight: 600;
color: var(--color-neutral-700);
background: var(--color-neutral-400);
padding: 2px 8px;
border-radius: 4px;
}
.klinik-preview {
flex: 1;
}
.klinik-room-count {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 600;
color: var(--color-primary-600);
margin-bottom: 12px;
}
.klinik-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.chip-preview {
background-color: var(--color-primary-600) !important;
color: var(--color-neutral-100) !important;
font-weight: 500;
font-size: 12px;
}
.chip-more {
background-color: var(--color-neutral-600) !important;
color: var(--color-neutral-100) !important;
font-weight: 600;
font-size: 12px;
}
.klinik-card-footer {
margin-top: auto;
}
.btn-view {
font-weight: 600;
text-transform: none;
letter-spacing: 0.5px;
}
.empty-state {
text-align: center;
padding: 80px 20px;
color: var(--color-neutral-700);
h3 {
font-size: 24px;
font-weight: 700;
margin: 24px 0 8px 0;
color: var(--color-neutral-800);
}
p {
font-size: 16px;
color: var(--color-neutral-600);
}
}
@media (max-width: 768px) {
.admin-klinik-ruang-index {
padding: 12px;
}
.klinik-grid {
grid-template-columns: 1fr;
gap: 16px;
}
}
</style>
-393
View File
@@ -1,393 +0,0 @@
<template>
<div class="loket-container">
<!-- Compact Header -->
<PageHeader
icon="mdi-view-dashboard"
title="Admin Loket"
:subtitle="currentDate"
:show-add-button="false"
theme="primary"
/>
<!-- Main Content Grid -->
<v-row class="content-grid" dense>
<!-- Left Column: Current Patient & Queue Actions -->
<v-col cols="12" md="5">
<div class="sticky-wrapper">
<!-- Current Patient Card -->
<CurrentPatientCard
:patient="currentProcessingPatient"
:has-next-queue="(diLoketPatients || []).length > 0"
:next-queue-info="nextQueueInfo"
@action="handlePatientAction"
@change-klinik="showChangeKlinikDialog = true"
@process-next="handleProcessNext"
/>
<!-- Queue Actions Card -->
<QueueActionsCard
class="mt-3"
:total-quota="150"
:used-quota="quotaUsed"
:has-next="!!nextPatient"
@call="handleCall"
/>
<!-- Create Queue Buttons -->
<div class="create-buttons mt-3">
<v-row no-gutters>
<v-col cols="6" class="pr-2">
<v-btn
block
class="py-6"
color="primary-600"
:disabled="!currentProcessingPatient"
@click="showKlinikDialog = true"
>
<v-icon start>mdi-hospital-building</v-icon>
Buat Antrean Klinik
</v-btn>
</v-col>
<v-col cols="6" class="pl-2">
<v-btn
block
class="py-6 text-white"
color="secondary-600"
:disabled="!currentProcessingPatient"
@click="openPenunjangDialog()"
>
<v-icon start>mdi-clipboard-pulse</v-icon>
Buat Antrean Penunjang
</v-btn>
</v-col>
</v-row>
</div>
</div>
</v-col>
<!-- Right Column: Patient Table -->
<v-col cols="12" md="7">
<v-card class="patient-data-container" elevation="0">
<v-card-text class="pa-4">
<!-- Header with Filters -->
<div class="data-header mb-4">
<div class="section-label">DATA PASIEN</div>
<div class="filters">
<!-- Search Field -->
<v-text-field
v-model="searchQuery"
placeholder="Cari barcode, nomor antrian..."
density="compact"
hide-details
class="search-field"
prepend-inner-icon="mdi-magnify"
/>
</div>
</div>
<PatientDataTable
:items="allPatientsForStage"
v-model:selected-status="selectedStatus"
v-model:search-query="searchQuery"
v-model:selected-fast-track="selectedFastTrack"
:di-loket-count="diLoketCount"
:diproses-count="currentProcessingPatient ? 1 : 0"
:terlambat-count="(terlambatPatients || []).length"
:pending-count="(pendingPatients || []).length"
:show-diproses="false"
:fast-track-options="fastTrackOptions"
@action="handleTableAction"
/>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- Klinik Dialog -->
<SelectionDialog
v-model="showKlinikDialog"
title="Pilih Klinik"
:items="filteredKliniks"
v-model:search-query="klinikSearch"
search-placeholder="Cari klinik..."
@select="selectKlinik"
/>
<!-- Penunjang Dialog -->
<SelectionDialog
v-model="showPenunjangDialog"
title="Pilih Penunjang"
:items="filteredPenunjangs"
v-model:search-query="penunjangSearch"
search-placeholder="Cari penunjang..."
@select="selectPenunjang"
/>
<!-- Change Klinik Dialog -->
<SelectionDialog
v-model="showChangeKlinikDialog"
title="Ubah Klinik"
:items="filteredChangeKliniks"
v-model:search-query="changeKlinikSearch"
search-placeholder="Cari klinik..."
@select="changeKlinik"
/>
<!-- Snackbar -->
<AppSnackbar
v-model="snackbar"
:message="snackbarText"
:color="snackbarColor"
/>
</div>
</template>
<script setup>
import { ref, computed } from "vue";
import { useQueue } from "@/composables/useQueue";
import PageHeader from "@/components/common/PageHeader.vue";
import CurrentPatientCard from "@/components/features/queue/CurrentPatientCard.vue";
import QueueActionsCard from "@/components/features/queue/QueueActionsCard.vue";
import PatientDataTable from "@/components/features/queue/TabelPatientData.vue";
import SelectionDialog from "@/components/common/SelectionDialog.vue";
import AppSnackbar from "@/components/common/AppSnackbar.vue";
const {
snackbar,
snackbarText,
snackbarColor,
showKlinikDialog,
showPenunjangDialog,
showChangeKlinikDialog,
klinikSearch,
penunjangSearch,
changeKlinikSearch,
currentProcessingPatient,
diLoketPatients,
terlambatPatients,
pendingPatients,
nextPatient,
quotaUsed,
filteredKliniks,
filteredPenunjangs,
filteredChangeKliniks,
callNext,
callMultiplePatients,
processPatient,
selectKlinik,
selectPenunjang,
openPenunjangDialog,
changeKlinik,
processNextQueue,
} = useQueue("loket");
const currentDate = ref(
new Date().toLocaleDateString("id-ID", {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
})
);
const selectedStatus = ref("all");
const searchQuery = ref("");
const selectedFastTrack = ref(null);
// Fast Track options from all patients
const fastTrackOptions = computed(() => {
const normalizedValues = allPatientsForStage.value
.map((p) => (p.fastTrack ?? "").toString().trim().toUpperCase())
.filter((v) => v.length > 0);
const uniqueTracks = [...new Set(normalizedValues)];
return uniqueTracks.sort();
});
// Combine all patients with status - PRESERVE ALL PROPERTIES
const allPatientsForStage = computed(() => {
const currentPatientNo = currentProcessingPatient.value?.no;
const diLoket = (diLoketPatients.value || []).map((p) => ({
...p, // Spread all properties first
// If this patient is currently being processed, mark as "diproses", otherwise "diloket"
status: p.no === currentPatientNo ? "diproses" : "diloket",
}));
const terlambat = (terlambatPatients.value || []).map((p) => ({
...p,
status: "terlambat",
}));
const pending = (pendingPatients.value || []).map((p) => ({
...p,
status: "pending",
}));
// Do not show waiting patients in the table until called
const combined = [...diLoket, ...terlambat, ...pending];
// Debug: check if fastTrack property exists
console.log("📊 AdminLoket - allPatientsForStage:", combined.length);
if (combined.length > 0) {
console.log("📊 First patient:", combined[0]);
console.log("📊 First patient has fastTrack?", "fastTrack" in combined[0]);
console.log("📊 First patient fastTrack value:", combined[0].fastTrack);
console.log(
"📊 All fastTrack values:",
combined.map((p) => p.fastTrack)
);
}
return combined;
});
// Count diLoket patients (excluding currently processing)
const diLoketCount = computed(() => {
const currentPatientNo = currentProcessingPatient.value?.no;
return (diLoketPatients.value || []).filter(p => p.no !== currentPatientNo).length;
});
// Next queue info for CurrentPatientCard
const nextQueueInfo = computed(() => {
const currentPatientNo = currentProcessingPatient.value?.no;
const nextPatient = (diLoketPatients.value || []).find(p => p.no !== currentPatientNo) || (diLoketPatients.value || [])[0];
if (nextPatient) {
return `Antrian berikutnya: ${nextPatient.noAntrian.split(" |")[0]}`;
}
return null;
});
const handlePatientAction = (action) => {
if (currentProcessingPatient.value) {
processPatient(currentProcessingPatient.value, action);
}
};
const handleCall = (count) => {
if (count === 1) {
callNext();
} else {
callMultiplePatients(count);
}
};
const handleTableAction = (item, action) => {
processPatient(item, action);
};
const handleProcessNext = () => {
processNextQueue();
};
</script>
<style scoped lang="scss">
.loket-container {
background: var(--color-neutral-300);
min-height: 100vh;
padding: 16px;
}
.content-grid {
margin: 0 -8px;
}
.content-grid > .v-col {
padding: 8px;
}
.create-buttons .v-btn {
text-transform: none;
font-weight: 600;
}
.sticky-wrapper {
position: sticky;
top: 16px;
align-self: flex-start;
max-height: calc(100vh - 32px);
overflow-y: auto;
}
.filters {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.status-filter {
flex: 1;
min-width: 300px;
}
.status-filter .v-chip {
font-size: 12px;
font-weight: 600;
height: 32px;
border: 1px solid var(--color-neutral-500);
background: var(--color-neutral-100);
color: var(--color-neutral-600);
transition: all 0.2s ease;
}
.status-filter .v-chip:hover {
background: var(--color-neutral-300);
}
.status-filter .v-chip.active-chip {
background: var(--color-primary-600);
color: var(--color-neutral-100);
border-color: var(--color-primary-600);
}
.search-field {
max-width: 300px;
min-width: 250px;
}
.section-label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.5px;
color: var(--color-neutral-600);
text-transform: uppercase;
}
.data-header {
display: flex;
flex-direction: column;
gap: 12px;
}
.patient-data-container {
border-radius: 12px;
border: 1px solid var(--color-neutral-500);
background: var(--color-neutral-100);
}
@media (max-width: 960px) {
.loket-container {
padding: 12px;
}
.sticky-wrapper {
position: relative;
top: 0;
max-height: none;
}
.filters {
flex-direction: column;
align-items: stretch;
}
.status-filter {
min-width: 100%;
}
.search-field {
max-width: 100%;
min-width: 100%;
}
}
</style>
File diff suppressed because it is too large Load diff
+327
View File
@@ -0,0 +1,327 @@
<template>
<div>
<PageHeader
icon="mdi-counter"
title="Admin Loket"
subtitle="Pilih Loket untuk mengelola antrian"
:show-add-button="false"
theme="primary"
/>
<div class="admin-loket-index">
<div v-if="loketList.length > 0" class="loket-grid">
<div
v-for="loket in loketList"
:key="loket.id"
:class="['loket-card', { 'loket-card-inactive': !loket.loketAktif }]"
@click="loket.loketAktif ? navigateToLoket(loket.id) : null"
>
<div class="loket-card-header">
<div class="loket-info">
<h3 class="loket-name">{{ loket.namaLoket }}</h3>
<div class="loket-details">
<!-- Tipe Loket -->
<v-chip
size="x-small"
:class="['JKN', 'Reguler', 'REGULER'].includes(loket.tipeLoket || loket.tipeloket) ? 'chip-tipe-reguler' : 'chip-tipe-eksekutif'"
>
{{ loket.tipeLoket || loket.tipeloket || 'REGULER' }}
</v-chip>
<!-- Pembayaran (Multiple Chips) -->
<v-chip
v-for="(payment, idx) in (Array.isArray(loket.pembayaran) ? loket.pembayaran.slice(0, 2) : [loket.pembayaran])"
:key="idx"
size="x-small"
class="chip-pembayaran"
>
{{ payment }}
</v-chip>
<v-chip
v-if="Array.isArray(loket.pembayaran) && loket.pembayaran.length > 2"
size="x-small"
class="chip-more-payment"
>
+{{ loket.pembayaran.length - 2 }}
</v-chip>
<!-- Status Tidak Aktif (only show when inactive) -->
<v-chip
v-if="!loket.loketAktif"
size="x-small"
color="error"
variant="flat"
class="ml-2"
>
Tidak Aktif
</v-chip>
</div>
</div>
</div>
<div class="loket-preview">
<div class="pelayanan-count">
<v-icon size="18" color="primary-600">mdi-hospital-building</v-icon>
<span>{{ loket.pelayanan?.length || 0 }} Pelayanan</span>
</div>
<div class="pelayanan-tags">
<v-chip
v-for="(serviceId, idx) in (loket.pelayanan || []).slice(0, 3)"
:key="idx"
size="small"
class="ma-1 chip-preview"
>
{{ getServiceName(loket, serviceId) }}
</v-chip>
<v-chip
v-if="loket.pelayanan?.length > 3"
size="small"
class="ma-1 chip-more"
>
+{{ loket.pelayanan.length - 3 }}
</v-chip>
</div>
</div>
</div>
</div>
<div v-else class="empty-state">
<v-icon size="64" color="grey-lighten-1">mdi-desktop-mac-dashboard</v-icon>
<h3>Tidak Ada Data Loket Tersedia</h3>
<p>Silakan tambah data loket terlebih dahulu di halaman master</p>
<v-btn
color="primary-600"
variant="flat"
class="mt-4"
@click="router.push('/setting/masterloket')"
>
<v-icon left>mdi-cog</v-icon>
Ke Halaman Master
</v-btn>
</div>
</div>
</div>
</template>
<script setup>
import { computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useLoketStore } from '@/stores/loketStore';
import { useMasterStore } from '@/stores/masterStore';
import PageHeader from '@/components/common/PageHeader.vue';
const router = useRouter();
const loketStore = useLoketStore();
const masterStore = useMasterStore();
// Ensure data is loaded
onMounted(() => {
if (loketStore.fetchLoketFromAPI) {
loketStore.fetchLoketFromAPI();
}
});
const loketList = computed(() => {
return loketStore.loketData || [];
});
// Helper untuk mendapatkan nama layanan/klinik
const getServiceName = (item, serviceId) => {
const idStr = String(serviceId);
if (item._spesialisDetail && Array.isArray(item._spesialisDetail)) {
const detail = item._spesialisDetail.find(s => String(s.idklinik) === idStr);
if (detail && detail.namaklinik) return detail.namaklinik;
}
const byId = masterStore.getKlinikById ? masterStore.getKlinikById(serviceId) : null;
if (byId && byId.nama) return byId.nama;
return masterStore.getKlinikNameByKode ? masterStore.getKlinikNameByKode(serviceId) : serviceId;
};
const navigateToLoket = (id) => {
router.push(`/adminloket/${id}`);
};
</script>
<style scoped lang="scss">
.admin-loket-index {
background: var(--color-neutral-300);
min-height: calc(100vh - 80px);
padding: 16px;
font-family: 'Inter', 'Roboto', sans-serif;
}
/* Remove rounded edges from PageHeader component */
:deep(.page-header) {
border-radius: 0 !important;
}
.loket-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 20px;
width: 100%;
margin: 0;
}
.loket-card {
background: var(--color-neutral-100);
border-radius: 16px;
padding: 24px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
border: 2px solid var(--color-neutral-400);
cursor: pointer;
transition: all 0.3s ease;
display: flex;
flex-direction: column;
gap: 16px;
&:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(33, 150, 243, 0.2);
border-color: var(--color-primary-600);
}
}
.loket-card-inactive {
background: var(--color-neutral-600) !important;
cursor: not-allowed !important;
opacity: 0.7;
&:hover {
transform: none !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08) !important;
border-color: var(--color-neutral-400) !important;
}
.loket-name {
color: var(--color-neutral-100) !important;
}
.pelayanan-count {
color: var(--color-neutral-300) !important;
}
}
.loket-card-header {
display: flex;
align-items: center;
gap: 16px;
padding-bottom: 16px;
border-bottom: 2px solid var(--color-neutral-400);
}
.loket-info {
flex: 1;
}
.loket-name {
font-size: 20px;
font-weight: 700;
margin: 0 0 8px 0;
color: var(--color-neutral-900);
line-height: 1.2;
}
.loket-details {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
}
// Chip Tipe Loket
.chip-tipe-reguler {
background-color: #009262 !important;
color: var(--color-neutral-100) !important;
font-weight: 600;
font-size: 11px;
}
.chip-tipe-eksekutif {
background-color: #E67E22 !important;
color: var(--color-neutral-100) !important;
font-weight: 600;
font-size: 11px;
}
// Chip Pembayaran
.chip-pembayaran {
background-color: var(--color-primary-600) !important;
color: var(--color-neutral-100) !important;
font-weight: 600;
font-size: 11px;
}
.chip-more-payment {
background-color: var(--color-neutral-600) !important;
color: var(--color-neutral-100) !important;
font-weight: 600;
font-size: 11px;
}
.loket-preview {
flex: 1;
}
.pelayanan-count {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 600;
color: var(--color-primary-600);
margin-bottom: 12px;
}
.pelayanan-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.chip-preview {
background-color: var(--color-primary-600) !important;
color: var(--color-neutral-100) !important;
font-weight: 500;
font-size: 11px;
}
.chip-more {
background-color: var(--color-neutral-600) !important;
color: var(--color-neutral-100) !important;
font-weight: 600;
font-size: 11px;
}
.empty-state {
text-align: center;
padding: 80px 20px;
color: var(--color-neutral-700);
h3 {
font-size: 24px;
font-weight: 700;
margin: 24px 0 8px 0;
color: var(--color-neutral-800);
}
p {
font-size: 16px;
color: var(--color-neutral-600);
}
}
@media (max-width: 768px) {
.admin-loket-index {
padding: 12px;
}
.loket-grid {
grid-template-columns: 1fr;
gap: 16px;
}
}
</style>
+13
View File
@@ -450,6 +450,19 @@ const getStatusLabel = (status) => {
text-transform: uppercase;
}
.waiting-badge {
display: inline-flex;
align-items: center;
gap: 6px;
background: #fef3c7;
color: #b45309;
padding: 6px 12px;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
border: 1px solid #fcd34d;
}
.patient-details {
background: linear-gradient(135deg, #f3e8ff 0%, #e9d5ff 100%);
border-radius: 8px;
+31 -10
View File
@@ -1,10 +1,17 @@
<template>
<v-container fluid class="bg-grey-lighten-4 pa-4">
<div>
<div class="page-header">
<div class="header-content">
<div class="header-left">
<div class="header-icon">
<v-icon size="32" color="white">mdi-view-dashboard</v-icon>
<img
src="/RSSA logo 1.png"
alt="RSUD Logo"
class="header-logo"
width="28"
height="28"
style="width: 28px; height: 28px; object-fit: contain;"
/>
</div>
<div class="header-text">
<h1 class="page-title">Admin Anjungan</h1>
@@ -13,7 +20,9 @@
</div>
</div>
</div>
<v-card flat>
<v-container fluid class="bg-grey-lighten-4 pa-4">
<v-card flat>
<v-card-text>
<v-row align="center">
<v-col cols="12" md="4">
@@ -78,12 +87,13 @@
</template>
</TabelData>
</v-card>
</v-container>
</v-container>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import TabelData from "../components/TabelData.vue"; // Pastikan path-nya benar
import TabelData from "@/components/features/monitoring/TabelData.vue";
// Ini adalah data yang akan menjadi "single source of truth"
// untuk tabel Anda. Data ini dikirim sebagai props ke komponen anak.
@@ -195,10 +205,10 @@ const items = ref([
<style scoped>
.page-header {
background: linear-gradient(135deg, #1976d2 0%, #1565c0 100%);
border-radius: 16px;
margin-bottom: 24px;
box-shadow: 0 8px 32px rgba(25, 118, 210, 0.3);
background: linear-gradient(135deg, #3A61C9 0%, #3556AE 100%);
border-radius: 0 !important;
margin-bottom: 0;
box-shadow: 0 8px 32px rgba(58, 97, 201, 0.3);
}
.header-content {
@@ -215,11 +225,22 @@ const items = ref([
}
.header-icon {
background: rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.95);
border-radius: 16px;
padding: 16px;
margin-right: 20px;
backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.header-logo {
width: 28px;
height: 28px;
object-fit: contain;
padding: 4px;
}
.page-title {
File diff suppressed because it is too large Load diff
+73 -38
View File
@@ -1,9 +1,16 @@
<!-- pages/Anjungan/Anjungan/index.vue -->
<template>
<div class="anjungan-selection-container">
<div>
<div class="selection-header">
<div class="header-icon">
<v-icon size="64" color="white">mdi-monitor-dashboard</v-icon>
<img
src="/RSSA logo 1.png"
alt="RSUD Logo"
class="header-logo"
width="40"
height="40"
style="width: 40px; height: 40px; object-fit: contain;"
/>
</div>
<div class="header-content">
<h1 class="main-title">Pilih Anjungan</h1>
@@ -11,7 +18,8 @@
</div>
</div>
<div class="anjungan-grid">
<div class="anjungan-selection-container">
<div class="anjungan-grid">
<div
v-for="anj in paginatedAnjungan"
:key="anj.id"
@@ -19,7 +27,7 @@
@click="navigateToAnjungan(anj.id)"
>
<div class="anjungan-card-header">
<v-icon size="32" color="primary">mdi-monitor</v-icon>
<v-icon size="28" color="primary">mdi-monitor</v-icon>
<div class="anjungan-info">
<h3 class="anjungan-name">{{ anj.namaAnjungan }}</h3>
<p class="anjungan-type">{{ anj.jenisPasien }}</p>
@@ -28,7 +36,7 @@
<div class="anjungan-klinik-preview">
<div class="klinik-count">
<v-icon size="18" color="secondary">mdi-hospital-box</v-icon>
<v-icon size="16" color="primary-600">mdi-hospital-box</v-icon>
<span>{{ anj.klinik.length }} Klinik</span>
</div>
<div class="klinik-tags">
@@ -58,7 +66,7 @@
block
class="btn-view"
>
<v-icon left>mdi-eye</v-icon>
<v-icon left size="20">mdi-eye</v-icon>
Tampilkan
</v-btn>
</div>
@@ -66,7 +74,7 @@
</div>
<div v-if="anjunganList.length === 0" class="empty-state">
<v-icon size="64" color="grey-lighten-1">mdi-monitor-off</v-icon>
<v-icon size="48" color="grey-lighten-1">mdi-monitor-off</v-icon>
<h3>Tidak Ada Anjungan Tersedia</h3>
<p>Silakan tambah anjungan terlebih dahulu di halaman master</p>
<v-btn
@@ -75,15 +83,16 @@
@click="navigateToSettings"
class="mt-4"
>
<v-icon left>mdi-cog</v-icon>
<v-icon left size="20">mdi-cog</v-icon>
Ke Halaman Master Anjungan
</v-btn>
</div>
<div v-else-if="totalPages > 1" class="pagination">
<v-btn variant="outlined" @click="goPrev" :disabled="page <= 1">Prev</v-btn>
<span class="page-info">Page {{ page }} / {{ totalPages }}</span>
<v-btn variant="outlined" @click="goNext" :disabled="page >= totalPages">Next</v-btn>
<div v-else-if="totalPages > 1" class="pagination">
<v-btn variant="outlined" @click="goPrev" :disabled="page <= 1">Prev</v-btn>
<span class="page-info">Page {{ page }} / {{ totalPages }}</span>
<v-btn variant="outlined" @click="goNext" :disabled="page >= totalPages">Next</v-btn>
</div>
</div>
</div>
</template>
@@ -148,30 +157,40 @@ const navigateToSettings = () => {
<style scoped lang="scss">
.anjungan-selection-container {
background: var(--color-neutral-300);
min-height: 100vh;
padding: 40px;
min-height: calc(100vh - 80px);
padding: 16px;
font-family: 'Inter', 'Roboto', sans-serif;
}
.selection-header {
background: linear-gradient(135deg, var(--color-primary-600) 0%, var(--color-primary-700) 100%);
border-radius: 20px;
padding: 40px;
margin-bottom: 40px;
border-radius: 0 !important;
padding: 16px 28px;
margin-bottom: 0;
display: flex;
align-items: center;
gap: 32px;
box-shadow: 0 8px 24px rgba(255, 155, 27, 0.3);
gap: 16px;
height: 80px;
box-shadow: 0 4px 16px rgba(33, 150, 243, 0.2);
}
.header-icon {
background: rgba(255, 255, 255, 0.15);
border-radius: 20px;
padding: 24px;
background: rgba(255, 255, 255, 0.9);
border-radius: 50%;
padding: 6px;
display: flex;
align-items: center;
justify-content: center;
width: 54px;
height: 54px;
backdrop-filter: blur(10px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.header-logo {
width: 40px;
height: 40px;
object-fit: contain;
}
.header-content {
@@ -180,19 +199,21 @@ const navigateToSettings = () => {
}
.main-title {
font-size: 40px;
font-weight: 800;
font-size: 32px;
font-weight: 600;
margin: 0;
color: var(--color-neutral-100);
line-height: 1.2;
line-height: 40px;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.subtitle {
font-size: 18px;
font-weight: 600;
margin: 8px 0 0 0;
font-size: 15px;
font-weight: 400;
margin: 2px 0 0 0;
color: var(--color-neutral-100);
opacity: 0.95;
opacity: 0.9;
line-height: 22px;
}
.anjungan-grid {
@@ -259,7 +280,7 @@ const navigateToSettings = () => {
gap: 6px;
font-size: 14px;
font-weight: 600;
color: var(--color-secondary-600);
color: var(--color-primary-600);
margin-bottom: 12px;
}
@@ -270,8 +291,8 @@ const navigateToSettings = () => {
}
.chip-preview {
background-color: var(--color-secondary-300) !important;
color: var(--color-secondary-700) !important;
background-color: var(--color-primary-600) !important;
color: var(--color-neutral-100) !important;
font-weight: 500;
font-size: 12px;
}
@@ -326,21 +347,35 @@ const navigateToSettings = () => {
@media (max-width: 768px) {
.anjungan-selection-container {
padding: 20px;
padding: 12px;
}
.selection-header {
flex-direction: column;
text-align: center;
padding: 32px 24px;
flex-direction: row;
padding: 12px 16px;
height: auto;
min-height: 64px;
}
.header-icon {
padding: 6px;
width: 40px;
height: 40px;
}
.header-logo {
width: 24px;
height: 24px;
}
.main-title {
font-size: 32px;
font-size: 20px;
line-height: 28px;
}
.subtitle {
font-size: 16px;
font-size: 13px;
line-height: 18px;
}
.anjungan-grid {
File diff suppressed because it is too large Load diff
+387
View File
@@ -0,0 +1,387 @@
<!-- pages/Anjungan/Anjungan/index.vue -->
<template>
<div>
<div class="selection-header">
<div class="header-icon">
<img
src="/RSSA logo 1.png"
alt="RSUD Logo"
class="header-logo"
width="40"
height="40"
style="width: 40px; height: 40px; object-fit: contain;"
/>
</div>
<div class="header-content">
<h1 class="main-title">Pilih Anjungan</h1>
<p class="subtitle">RSUD dr. Saiful Anwar Provinsi Jawa Timur</p>
</div>
</div>
<div class="anjungan-selection-container">
<div class="anjungan-grid">
<div
v-for="anj in paginatedAnjungan"
:key="anj.id"
class="anjungan-card"
@click="navigateToAnjungan(anj.id)"
>
<div class="anjungan-card-header">
<v-icon size="28" color="primary">mdi-monitor</v-icon>
<div class="anjungan-info">
<h3 class="anjungan-name">{{ anj.namaAnjungan }}</h3>
<p class="anjungan-type">{{ anj.jenisPasien }}</p>
</div>
</div>
<div class="anjungan-klinik-preview">
<div class="klinik-count">
<v-icon size="16" color="primary-600">mdi-hospital-box</v-icon>
<span>{{ anj.klinik.length }} Klinik</span>
</div>
<div class="klinik-tags">
<v-chip
v-for="(kode, idx) in anj.klinik.slice(0, 4)"
:key="idx"
size="small"
class="ma-1 chip-preview"
>
{{ masterStore.getKlinikNameByKode(kode) }}
</v-chip>
<v-chip
v-if="anj.klinik.length > 4"
size="small"
class="ma-1 chip-more"
>
+{{ anj.klinik.length - 4 }}
</v-chip>
</div>
</div>
<div class="anjungan-card-footer">
<v-btn
color="primary"
variant="flat"
size="large"
block
class="btn-view"
>
<v-icon left size="20">mdi-eye</v-icon>
Tampilkan
</v-btn>
</div>
</div>
</div>
<div v-if="anjunganList.length === 0" class="empty-state">
<v-icon size="48" color="grey-lighten-1">mdi-monitor-off</v-icon>
<h3>Tidak Ada Anjungan Tersedia</h3>
<p>Silakan tambah anjungan terlebih dahulu di halaman master</p>
<v-btn
color="primary"
variant="flat"
@click="navigateToSettings"
class="mt-4"
>
<v-icon left size="20">mdi-cog</v-icon>
Ke Halaman Master Anjungan
</v-btn>
</div>
<div v-else-if="totalPages > 1" class="pagination">
<v-btn variant="outlined" @click="goPrev" :disabled="page <= 1">Prev</v-btn>
<span class="page-info">Page {{ page }} / {{ totalPages }}</span>
<v-btn variant="outlined" @click="goNext" :disabled="page >= totalPages">Next</v-btn>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { useAnjunganStore } from '@/stores/anjunganStore';
import { useMasterStore } from '@/stores/masterStore';
import { useRoute } from '#app';
definePageMeta({
layout: false,
});
const anjunganStore = useAnjunganStore();
const masterStore = useMasterStore();
const route = useRoute();
// Safeguard supaya tidak undefined saat store belum terisi
const anjunganList = computed(() => {
const fromGetter = anjunganStore.getAllAnjungan?.value;
const fromState = anjunganStore.anjunganItems?.value || anjunganStore.anjunganItems || [];
return Array.isArray(fromGetter) ? fromGetter : Array.isArray(fromState) ? fromState : [];
});
// Pagination
const itemsPerPage = 10;
const page = computed({
get: () => Number(route.query.page || 1),
set: (val) => navigateTo({ query: { ...route.query, page: val } }),
});
const totalPages = computed(() => Math.max(1, Math.ceil(anjunganList.value.length / itemsPerPage)));
const paginatedAnjungan = computed(() => {
const start = (page.value - 1) * itemsPerPage;
return anjunganList.value.slice(start, start + itemsPerPage);
});
const goPrev = () => {
if (page.value > 1) page.value = page.value - 1;
};
const goNext = () => {
if (page.value < totalPages.value) page.value = page.value + 1;
};
const navigateToAnjungan = (anjunganId) => {
const id = typeof anjunganId === 'number' ? anjunganId : parseInt(anjunganId, 10);
if (isNaN(id)) {
console.error('Invalid anjungan ID:', anjunganId);
return;
}
navigateTo(`/anjungan/anjungancopy/${id}`);
};
const navigateToSettings = () => {
navigateTo('/setting/masteranjungan');
};
</script>
<style scoped lang="scss">
.anjungan-selection-container {
background: var(--color-neutral-300);
min-height: calc(100vh - 80px);
padding: 16px;
font-family: 'Inter', 'Roboto', sans-serif;
}
.selection-header {
background: linear-gradient(135deg, var(--color-primary-600) 0%, var(--color-primary-700) 100%);
border-radius: 0 !important;
padding: 16px 28px;
margin-bottom: 0;
display: flex;
align-items: center;
gap: 16px;
height: 80px;
box-shadow: 0 4px 16px rgba(33, 150, 243, 0.2);
}
.header-icon {
background: rgba(255, 255, 255, 0.9);
border-radius: 50%;
padding: 6px;
display: flex;
align-items: center;
justify-content: center;
width: 54px;
height: 54px;
backdrop-filter: blur(10px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.header-logo {
width: 40px;
height: 40px;
object-fit: contain;
}
.header-content {
flex: 1;
color: var(--color-neutral-100);
}
.main-title {
font-size: 32px;
font-weight: 600;
margin: 0;
color: var(--color-neutral-100);
line-height: 40px;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.subtitle {
font-size: 15px;
font-weight: 400;
margin: 2px 0 0 0;
color: var(--color-neutral-100);
opacity: 0.9;
line-height: 22px;
}
.anjungan-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 24px;
max-width: 1400px;
margin: 0 auto;
}
.anjungan-card {
background: var(--color-neutral-100);
border-radius: 16px;
padding: 24px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
border: 2px solid var(--color-neutral-400);
cursor: pointer;
transition: all 0.3s ease;
display: flex;
flex-direction: column;
gap: 16px;
&:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(6, 113, 224, 0.2);
border-color: var(--color-secondary-600);
}
}
.anjungan-card-header {
display: flex;
align-items: center;
gap: 16px;
padding-bottom: 16px;
border-bottom: 2px solid var(--color-neutral-400);
}
.anjungan-info {
flex: 1;
}
.anjungan-name {
font-size: 20px;
font-weight: 700;
margin: 0;
color: var(--color-neutral-900);
line-height: 1.2;
}
.anjungan-type {
font-size: 14px;
color: var(--color-neutral-600);
margin: 4px 0 0 0;
font-weight: 500;
}
.anjungan-klinik-preview {
flex: 1;
}
.klinik-count {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 600;
color: var(--color-primary-600);
margin-bottom: 12px;
}
.klinik-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.chip-preview {
background-color: var(--color-primary-600) !important;
color: var(--color-neutral-100) !important;
font-weight: 500;
font-size: 12px;
}
.chip-more {
background-color: var(--color-neutral-600) !important;
color: var(--color-neutral-100) !important;
font-weight: 600;
font-size: 12px;
}
.anjungan-card-footer {
margin-top: auto;
}
.btn-view {
font-weight: 600;
text-transform: none;
letter-spacing: 0.5px;
}
.empty-state {
text-align: center;
padding: 80px 20px;
color: var(--color-neutral-700);
h3 {
font-size: 24px;
font-weight: 700;
margin: 24px 0 8px 0;
color: var(--color-neutral-800);
}
p {
font-size: 16px;
color: var(--color-neutral-600);
}
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-top: 24px;
}
.page-info {
font-weight: 600;
color: var(--color-neutral-800);
}
@media (max-width: 768px) {
.anjungan-selection-container {
padding: 12px;
}
.selection-header {
flex-direction: row;
padding: 12px 16px;
height: auto;
min-height: 64px;
}
.header-icon {
padding: 6px;
width: 40px;
height: 40px;
}
.header-logo {
width: 24px;
height: 24px;
}
.main-title {
font-size: 20px;
line-height: 28px;
}
.subtitle {
font-size: 13px;
line-height: 18px;
}
.anjungan-grid {
grid-template-columns: 1fr;
gap: 16px;
}
}
</style>
File diff suppressed because it is too large Load diff
+380
View File
@@ -0,0 +1,380 @@
<!-- pages/Anjungan/AntreanMasuk/index.vue -->
<template>
<div>
<div class="selection-header">
<div class="header-icon">
<img
src="/RSSA logo 1.png"
alt="RSUD Logo"
class="header-logo"
width="40"
height="40"
style="width: 40px; height: 40px; object-fit: contain;"
/>
</div>
<div class="header-content">
<h1 class="main-title">Pilih Layar Antrean Masuk</h1>
<p class="subtitle">RSUD dr. Saiful Anwar Provinsi Jawa Timur</p>
</div>
</div>
<div class="screen-selection-container">
<div v-if="paginatedScreens.length > 0" class="screens-grid">
<div
v-for="screen in paginatedScreens"
:key="screen.id"
class="screen-card"
@click="navigateToScreen(screen.id)"
>
<div class="screen-card-header">
<v-icon size="28" color="primary">mdi-monitor</v-icon>
<div class="screen-info">
<h3 class="screen-name">{{ screen.namaScreen }}</h3>
<p class="screen-details">{{ screen.nomorScreen }}</p>
</div>
</div>
<div class="screen-preview">
<div class="screen-loket-count">
<v-icon size="16" color="primary">mdi-view-dashboard</v-icon>
<span>{{ screen.loket?.length || 0 }} Loket</span>
</div>
<div class="screen-tags">
<v-chip
v-for="(loketId, idx) in (screen.loket || []).slice(0, 3)"
:key="idx"
size="small"
class="ma-1 chip-preview"
>
{{ getLoketNameById(loketId) }}
</v-chip>
<v-chip
v-if="(screen.loket || []).length > 3"
size="small"
class="ma-1 chip-more"
>
+{{ (screen.loket || []).length - 3 }}
</v-chip>
</div>
</div>
<div class="screen-card-footer">
<v-btn
color="primary-600"
variant="flat"
size="large"
block
class="btn-view text-white"
>
<v-icon left size="20">mdi-eye</v-icon>
Tampilkan
</v-btn>
</div>
</div>
</div>
<div v-else class="empty-state">
<v-icon size="48" color="grey-lighten-1">mdi-monitor-off</v-icon>
<h3>Tidak Ada Layar Tersedia</h3>
<p>Silakan tambah layar terlebih dahulu di halaman master</p>
<v-btn
color="primary-600"
variant="flat"
class="mt-4"
@click="navigateToSettings"
>
<v-icon left size="20">mdi-cog</v-icon>
Ke Halaman Master
</v-btn>
</div>
<div v-if="paginatedScreens.length > 0 && totalPages > 1" class="pagination">
<v-btn variant="outlined" :disabled="page <= 1" @click="goPrev">Prev</v-btn>
<span class="page-info">Page {{ page }} / {{ totalPages }}</span>
<v-btn variant="outlined" :disabled="page >= totalPages" @click="goNext">Next</v-btn>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { useAntreanMasukScreenStore } from '@/stores/antreanMasukScreenStore';
import { useLoketStore } from '@/stores/loketStore';
import { useRoute } from '#app';
definePageMeta({
layout: false,
});
const antreanMasukScreenStore = useAntreanMasukScreenStore();
const loketStore = useLoketStore();
const route = useRoute();
// Helper to get loket name by ID
const getLoketNameById = (loketId) => {
const loket = loketStore.getLoketById(loketId);
return loket ? loket.namaLoket : `Loket ${loketId}`;
};
// Get all screens from store
const allScreens = computed(() => {
return antreanMasukScreenStore.antreanMasukScreenItems || [];
});
// Pagination
const itemsPerPage = 10;
const page = computed({
get: () => Number(route.query.page || 1),
set: (val) => navigateTo({ query: { ...route.query, page: val } }),
});
const totalPages = computed(() => Math.max(1, Math.ceil(allScreens.value.length / itemsPerPage)));
const paginatedScreens = computed(() => {
const start = (page.value - 1) * itemsPerPage;
return allScreens.value.slice(start, start + itemsPerPage);
});
const goPrev = () => {
if (page.value > 1) page.value = page.value - 1;
};
const goNext = () => {
if (page.value < totalPages.value) page.value = page.value + 1;
};
const navigateToScreen = (screenId) => {
navigateTo(`/anjungan/antreanmasuk/${screenId}`);
};
const navigateToSettings = () => {
navigateTo('/setting/screenantreanmasuk');
};
</script>
<style scoped lang="scss">
.screen-selection-container {
background: var(--color-neutral-300);
min-height: calc(100vh - 80px);
padding: 16px;
font-family: 'Inter', 'Roboto', sans-serif;
}
.selection-header {
background: linear-gradient(135deg, var(--color-primary-600) 0%, var(--color-primary-700) 100%);
border-radius: 0 !important;
padding: 16px 28px;
margin-bottom: 0;
display: flex;
align-items: center;
gap: 16px;
height: 80px;
box-shadow: 0 4px 16px rgba(33, 150, 243, 0.2);
}
.header-icon {
background: rgba(255, 255, 255, 0.9);
border-radius: 50%;
padding: 6px;
display: flex;
align-items: center;
justify-content: center;
width: 54px;
height: 54px;
backdrop-filter: blur(10px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.header-logo {
width: 40px;
height: 40px;
object-fit: contain;
}
.header-content {
flex: 1;
color: var(--color-neutral-100);
}
.main-title {
font-size: 32px;
font-weight: 600;
margin: 0;
color: var(--color-neutral-100);
line-height: 40px;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.subtitle {
font-size: 15px;
font-weight: 400;
margin: 2px 0 0 0;
color: var(--color-neutral-100);
opacity: 0.9;
line-height: 22px;
}
.screens-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 20px;
width: 100%;
margin: 0;
}
.screen-card {
background: var(--color-neutral-100);
border-radius: 16px;
padding: 24px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
border: 2px solid var(--color-neutral-400);
cursor: pointer;
transition: all 0.3s ease;
display: flex;
flex-direction: column;
gap: 16px;
&:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(25, 118, 210, 0.2);
border-color: var(--color-primary-600);
}
}
.screen-card-header {
display: flex;
align-items: center;
gap: 16px;
padding-bottom: 16px;
border-bottom: 2px solid var(--color-neutral-400);
}
.screen-info {
flex: 1;
}
.screen-name {
font-size: 20px;
font-weight: 700;
margin: 0;
color: var(--color-neutral-900);
line-height: 1.2;
}
.screen-details {
font-size: 14px;
color: var(--color-neutral-600);
margin: 4px 0 0 0;
font-weight: 500;
}
.screen-preview {
flex: 1;
}
.screen-loket-count {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 600;
color: var(--color-primary-600);
margin-bottom: 12px;
}
.screen-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.chip-preview {
background-color: var(--color-primary-300) !important;
color: var(--color-primary-700) !important;
font-weight: 500;
font-size: 12px;
}
.chip-more {
background-color: var(--color-neutral-600) !important;
color: var(--color-neutral-100) !important;
font-weight: 600;
font-size: 12px;
}
.screen-card-footer {
margin-top: auto;
}
.btn-view {
font-weight: 600;
text-transform: none;
letter-spacing: 0.5px;
}
.empty-state {
text-align: center;
padding: 80px 20px;
color: var(--color-neutral-700);
h3 {
font-size: 24px;
font-weight: 700;
margin: 24px 0 8px 0;
color: var(--color-neutral-800);
}
p {
font-size: 16px;
color: var(--color-neutral-600);
}
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-top: 24px;
}
.page-info {
font-weight: 600;
color: var(--color-neutral-800);
}
@media (max-width: 768px) {
.screen-selection-container {
padding: 12px;
}
.selection-header {
flex-direction: row;
padding: 12px 16px;
height: auto;
min-height: 64px;
}
.header-icon {
padding: 6px;
width: 36px;
height: 36px;
}
.main-title {
font-size: 20px;
line-height: 28px;
}
.subtitle {
font-size: 13px;
line-height: 18px;
}
.screens-grid {
grid-template-columns: 1fr;
gap: 16px;
}
}
</style>
+195 -18
View File
@@ -5,7 +5,14 @@
<div class="tv-header">
<div class="tv-header-left">
<div class="hospital-logo">
<v-icon size="56" color="white">mdi-hospital-box</v-icon>
<img
src="/RSSA logo 1.png"
alt="RSUD Logo"
class="header-logo"
width="64"
height="64"
style="width: 64px; height: 64px; object-fit: contain;"
/>
</div>
<div class="header-text">
<h1 class="display-title">ANTRIAN KLINIK</h1>
@@ -20,11 +27,11 @@
</div>
<!-- Hero Section - Sedang Dipanggil -->
<div v-if="currentCalledQueue" class="hero-queue">
<div v-if="currentCalledQueue" class="hero-queue" :class="{ 'hero-pending': currentCalledQueue.status === 'pending' }">
<div class="hero-content">
<div class="hero-label">
<v-icon size="32" color="white">mdi-bell-ring</v-icon>
SEDANG DIPANGGIL
{{ currentCalledQueue.status === 'pending' ? 'PENDING' : 'SEDANG DIPANGGIL' }}
</div>
<div class="hero-number">
{{ currentCalledQueue.noAntrian.split(" |")[0] }}
@@ -56,8 +63,10 @@
<div class="queue-display">
<!-- Current Queue -->
<div v-if="clinic.currentQueue" class="current-queue-section">
<div class="queue-status-label">SEKARANG</div>
<div class="queue-number-large">
<div class="queue-status-label" :class="{ 'status-pending': clinic.currentQueue.status === 'pending' }">
{{ clinic.currentQueue.status === 'pending' ? 'PENDING' : 'SEKARANG' }}
</div>
<div class="queue-number-large" :class="{ 'queue-pending': clinic.currentQueue.status === 'pending' }">
{{ clinic.currentQueue.noAntrian.split(" |")[0] }}
</div>
</div>
@@ -70,6 +79,7 @@
v-for="queue in clinic.nextQueues.slice(0, 3)"
:key="queue.no"
class="next-number-badge"
:class="{ 'badge-pending': queue.status === 'pending' }"
>
{{ queue.noAntrian.split(" |")[0] }}
</div>
@@ -84,6 +94,20 @@
<v-icon size="40" color="grey-lighten-3">mdi-minus-circle-outline</v-icon>
<div class="empty-text">Tidak Ada Antrian</div>
</div>
<!-- Pending Queues (jika ada yang pending tapi tidak di current atau next) -->
<div v-if="clinic.pendingQueues && clinic.pendingQueues.length > 0" class="pending-queues-section">
<div class="pending-label">PENDING</div>
<div class="pending-numbers-grid">
<div
v-for="queue in clinic.pendingQueues"
:key="queue.no"
class="pending-number-badge"
>
{{ queue.noAntrian.split(" |")[0] }}
</div>
</div>
</div>
</div>
</div>
</div>
@@ -176,7 +200,24 @@ let timeInterval = null;
let refreshInterval = null;
const klinikPatients = computed(() => {
return queueStore.getPatientsByStage("klinik").value.all;
// Ambil pasien dengan processStage "klinik" (sudah check-in di loket)
const klinikStagePatients = queueStore.getPatientsByStage("klinik").value.all;
// Juga ambil pasien dengan status "pending" yang processStage-nya masih "loket"
// Ini adalah pasien yang di-pending di AdminLoket dan harus kembali ke kolom klinik
const pendingFromLoket = queueStore.getPatientsByStage("loket").value.all
.filter(p => p.status === "pending");
// Gabungkan dan pastikan tidak ada duplikat
const allPatients = [...klinikStagePatients];
pendingFromLoket.forEach(pendingPatient => {
const exists = allPatients.find(p => p.no === pendingPatient.no);
if (!exists) {
allPatients.push(pendingPatient);
}
});
return allPatients;
});
// Get kliniks based on screen configuration
@@ -217,21 +258,33 @@ const displayedClinics = computed(() => {
return timeA[0] * 60 + timeA[1] - (timeB[0] * 60 + timeB[1]);
});
// Current queue: prioritaskan di-loket, lalu waiting, lalu pending (dengan card merah)
const currentQueue =
queues.find((q) => q.status === "di-loket") ||
queues.find((q) => q.status === "waiting") ||
queues.find((q) => q.status === "anjungan") ||
queues.find((q) => q.status === "pending") ||
null;
// Next queues: tampilkan semua yang bukan current queue, termasuk pending
const nextQueues = queues.filter(
(q) =>
q.no !== currentQueue?.no &&
(q.status === "waiting" || q.status === "di-loket")
(q.status === "anjungan" || q.status === "di-loket" || q.status === "pending")
);
// Pending queues: pasien dengan status pending yang tidak di current atau next
const pendingQueues = queues.filter(
(q) =>
q.status === "pending" &&
q.no !== currentQueue?.no &&
!nextQueues.find(nq => nq.no === q.no)
);
return {
name: klinik.name,
currentQueue: currentQueue,
nextQueues: nextQueues,
pendingQueues: pendingQueues,
totalQueues: queues.length,
};
});
@@ -241,9 +294,19 @@ const currentCalledQueue = computed(() => {
if (!screenData.value) return null;
const screenKlinikNames = screenKliniks.value.map(k => k.name);
// Prioritaskan pasien yang sedang dipanggil (di-loket), lalu pending
const calledQueues = klinikPatients.value
.filter((p) => p.status === "di-loket" && screenKlinikNames.includes(p.klinik))
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
.filter((p) =>
(p.status === "di-loket" || p.status === "pending") &&
screenKlinikNames.includes(p.klinik)
)
.sort((a, b) => {
// Prioritaskan di-loket dulu, lalu pending
if (a.status === "di-loket" && b.status !== "di-loket") return -1;
if (a.status !== "di-loket" && b.status === "di-loket") return 1;
// Jika sama, sort by createdAt
return new Date(b.createdAt) - new Date(a.createdAt);
});
return calledQueues[0] || null;
});
@@ -258,18 +321,20 @@ const statistics = computed(() => {
return {
total: all.length,
waiting: all.filter((p) => p.status === "waiting").length,
anjungan: all.filter((p) => p.status === "anjungan").length,
active: all.filter((p) => p.status === "di-loket").length,
};
});
const updateTime = () => {
const now = new Date();
currentTime.value = now.toLocaleTimeString("id-ID", {
// Format jam dengan titik dua (HH:MM:SS)
const timeString = now.toLocaleTimeString("id-ID", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
currentTime.value = timeString.replace(/\./g, ':');
currentDate.value = now.toLocaleDateString("id-ID", {
weekday: "long",
year: "numeric",
@@ -288,12 +353,23 @@ onMounted(() => {
updateTime();
timeInterval = setInterval(updateTime, 1000);
refreshInterval = setInterval(() => {}, 5000);
});
onUnmounted(() => {
if (timeInterval) clearInterval(timeInterval);
if (refreshInterval) clearInterval(refreshInterval);
// Initialize and connect WebSocket (Centralized)
const anjunganClientId = `anjungan-klinik-${screenId.value}`;
queueStore.initWebSocket(anjunganClientId);
// Register interest in each clinic on this screen for scoped WebSocket refreshes
screenKliniks.value.forEach(k => {
queueStore.registerClinicInterest(k.kode);
});
onUnmounted(() => {
if (timeInterval) clearInterval(timeInterval);
// Unregister clinic interests
screenKliniks.value.forEach(k => {
queueStore.unregisterClinicInterest(k.kode);
});
});
});
</script>
@@ -332,12 +408,20 @@ onUnmounted(() => {
}
.hospital-logo {
background: rgba(255, 255, 255, 0.15);
background: rgba(255, 255, 255, 0.95);
border-radius: 16px;
padding: 12px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.header-logo {
width: 64px;
height: 64px;
object-fit: contain;
padding: 4px;
}
.display-title {
@@ -377,6 +461,7 @@ onUnmounted(() => {
color: var(--color-neutral-100);
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
letter-spacing: 2px;
font-variant-numeric: tabular-nums; /* Prevent digit shifting */
}
.current-date {
@@ -396,6 +481,21 @@ onUnmounted(() => {
box-shadow: 0 12px 32px rgba(224, 21, 7, 0.35);
}
.hero-queue.hero-pending {
background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%);
box-shadow: 0 12px 32px rgba(220, 38, 38, 0.5);
animation: pending-pulse 2s ease-in-out infinite;
}
@keyframes pending-pulse {
0%, 100% {
box-shadow: 0 12px 32px rgba(220, 38, 38, 0.5);
}
50% {
box-shadow: 0 12px 48px rgba(220, 38, 38, 0.7);
}
}
.hero-content {
text-align: center;
color: var(--color-neutral-100);
@@ -420,6 +520,7 @@ onUnmounted(() => {
margin: 16px 0;
text-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
letter-spacing: 8px;
font-variant-numeric: tabular-nums; /* Stable digits */
}
.hero-clinic {
@@ -511,6 +612,20 @@ onUnmounted(() => {
letter-spacing: 1px;
}
.queue-status-label.status-pending {
color: var(--color-danger-600);
animation: pending-blink 1.5s ease-in-out infinite;
}
@keyframes pending-blink {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
.queue-number-large {
font-size: 80px;
font-weight: 900;
@@ -518,6 +633,12 @@ onUnmounted(() => {
line-height: 1;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
letter-spacing: 4px;
font-variant-numeric: tabular-nums; /* Stable digits */
}
.queue-number-large.queue-pending {
color: var(--color-danger-600);
text-shadow: 0 2px 8px rgba(220, 38, 38, 0.4);
}
.next-queues-section {
@@ -551,6 +672,13 @@ onUnmounted(() => {
box-shadow: 0 2px 8px rgba(6, 99, 199, 0.25);
min-width: 60px;
text-align: center;
font-variant-numeric: tabular-nums; /* Stable digits */
}
.next-number-badge.badge-pending {
background: var(--color-danger-600);
box-shadow: 0 2px 8px rgba(220, 38, 38, 0.4);
border: 2px solid var(--color-danger-700);
}
.empty-queue {
@@ -565,6 +693,54 @@ onUnmounted(() => {
font-weight: 600;
}
/* ========== PENDING QUEUES SECTION ========== */
.pending-queues-section {
text-align: center;
margin-top: 12px;
padding-top: 12px;
border-top: 2px solid var(--color-danger-200);
}
.pending-label {
font-size: 13px;
font-weight: 700;
color: var(--color-danger-600);
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.pending-numbers-grid {
display: flex;
gap: 8px;
justify-content: center;
flex-wrap: wrap;
}
.pending-number-badge {
background: var(--color-danger-600);
color: var(--color-neutral-100);
padding: 8px 16px;
border-radius: 12px;
font-size: 22px;
font-weight: 700;
box-shadow: 0 2px 8px rgba(220, 38, 38, 0.4);
border: 2px solid var(--color-danger-700);
min-width: 60px;
text-align: center;
animation: pending-badge-pulse 2s ease-in-out infinite;
font-variant-numeric: tabular-nums; /* Stable digits */
}
@keyframes pending-badge-pulse {
0%, 100% {
box-shadow: 0 2px 8px rgba(220, 38, 38, 0.4);
}
50% {
box-shadow: 0 2px 12px rgba(220, 38, 38, 0.6);
}
}
/* ========== FOOTER ========== */
.tv-footer {
background: linear-gradient(135deg, var(--color-secondary-600) 0%, var(--color-secondary-700) 100%);
@@ -602,6 +778,7 @@ onUnmounted(() => {
line-height: 1;
color: var(--color-neutral-100);
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
font-variant-numeric: tabular-nums; /* Stable digits */
}
.stat-label {
+46 -31
View File
@@ -1,6 +1,6 @@
<!-- pages/Anjungan/AntrianKlinik/index.vue -->
<template>
<div class="screen-selection-container">
<div>
<div class="selection-header">
<div class="header-icon">
<v-icon size="64" color="white">mdi-monitor-multiple</v-icon>
@@ -11,7 +11,8 @@
</div>
</div>
<div class="screens-grid">
<div class="screen-selection-container">
<div class="screens-grid">
<div
v-for="screen in paginatedScreens"
:key="screen.id"
@@ -80,10 +81,11 @@
</v-btn>
</div>
<div v-else-if="totalPages > 1" class="pagination">
<v-btn variant="outlined" @click="goPrev" :disabled="page <= 1">Prev</v-btn>
<span class="page-info">Page {{ page }} / {{ totalPages }}</span>
<v-btn variant="outlined" @click="goNext" :disabled="page >= totalPages">Next</v-btn>
<div v-else-if="totalPages > 1" class="pagination">
<v-btn variant="outlined" @click="goPrev" :disabled="page <= 1">Prev</v-btn>
<span class="page-info">Page {{ page }} / {{ totalPages }}</span>
<v-btn variant="outlined" @click="goNext" :disabled="page >= totalPages">Next</v-btn>
</div>
</div>
</div>
</template>
@@ -150,30 +152,34 @@ const navigateToSettings = () => {
<style scoped lang="scss">
.screen-selection-container {
background: var(--color-neutral-300);
min-height: 100vh;
padding: 40px;
min-height: calc(100vh - 80px);
padding: 16px;
font-family: 'Inter', 'Roboto', sans-serif;
}
.selection-header {
background: linear-gradient(135deg, var(--color-primary-600) 0%, var(--color-primary-700) 100%);
border-radius: 20px;
padding: 40px;
margin-bottom: 40px;
border-radius: 0 !important;
padding: 16px 28px;
margin-bottom: 0;
display: flex;
align-items: center;
gap: 32px;
box-shadow: 0 8px 24px rgba(255, 155, 27, 0.3);
gap: 16px;
height: 80px;
box-shadow: 0 4px 16px rgba(33, 150, 243, 0.2);
}
.header-icon {
background: rgba(255, 255, 255, 0.15);
border-radius: 20px;
padding: 24px;
background: rgba(255, 255, 255, 0.9);
border-radius: 50%;
padding: 6px;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
backdrop-filter: blur(10px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.header-content {
@@ -182,21 +188,21 @@ const navigateToSettings = () => {
}
.main-title {
font-size: 48px;
font-weight: 800;
font-size: 32px;
font-weight: 600;
margin: 0;
letter-spacing: 2px;
color: var(--color-neutral-100);
line-height: 1.2;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
line-height: 40px;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.subtitle {
font-size: 20px;
font-weight: 600;
margin: 8px 0 0 0;
font-size: 15px;
font-weight: 400;
margin: 2px 0 0 0;
color: var(--color-neutral-100);
opacity: 0.95;
opacity: 0.9;
line-height: 22px;
}
.screens-grid {
@@ -330,21 +336,30 @@ const navigateToSettings = () => {
@media (max-width: 768px) {
.screen-selection-container {
padding: 20px;
padding: 12px;
}
.selection-header {
flex-direction: column;
text-align: center;
padding: 32px 24px;
flex-direction: row;
padding: 12px 16px;
height: auto;
min-height: 64px;
}
.header-icon {
padding: 4px;
width: 36px;
height: 36px;
}
.main-title {
font-size: 32px;
font-size: 20px;
line-height: 28px;
}
.subtitle {
font-size: 16px;
font-size: 13px;
line-height: 18px;
}
.screens-grid {
File diff suppressed because it is too large Load diff
+235 -174
View File
@@ -1,9 +1,16 @@
<!-- pages/Anjungan/AntrianKlinikRuang/index.vue -->
<template>
<div class="klinik-selection-container">
<div>
<div class="selection-header">
<div class="header-icon">
<v-icon size="64" color="white">mdi-door-open</v-icon>
<img
src="/RSSA logo 1.png"
alt="RSUD Logo"
class="header-logo"
width="40"
height="40"
style="width: 40px; height: 40px; object-fit: contain;"
/>
</div>
<div class="header-content">
<h1 class="main-title">Pilih Klinik Ruang</h1>
@@ -11,86 +18,103 @@
</div>
</div>
<div class="kliniks-grid" v-if="paginatedKliniks.length > 0">
<div
v-for="klinik in paginatedKliniks"
:key="klinik.kodeKlinik"
class="klinik-card"
@click="navigateToKlinik(klinik.kodeKlinik)"
>
<div class="klinik-card-header">
<v-icon size="32" color="success">mdi-hospital-building</v-icon>
<div class="klinik-info">
<h3 class="klinik-name">{{ klinik.namaKlinik }}</h3>
<p class="klinik-code">{{ klinik.kodeKlinik }}</p>
<ClientOnly>
<div class="klinik-selection-container">
<div v-if="kliniksWithRuang.length > 0" class="kliniks-grid">
<div
v-for="klinik in kliniksWithRuang"
:key="klinik.id"
class="klinik-card"
@click="navigateToKlinik(klinik)"
>
<div class="klinik-card-header">
<v-icon size="32" color="primary">mdi-hospital-building</v-icon>
<div class="klinik-info">
<h3 class="klinik-name">{{ klinik.namaKlinik || 'Unknown Clinic' }}</h3>
<div class="klinik-details">
<v-chip
size="x-small"
:class="klinik.jenisLayanan === 'Reguler' ? 'chip-reguler' : 'chip-eksekutif'"
>
{{ klinik.jenisLayanan || 'Reguler' }}
</v-chip>
<span class="kode-chip">{{ klinik.kodeKlinik }}</span>
</div>
</div>
</div>
<div class="klinik-ruang-preview">
<div class="ruang-count">
<v-icon size="18" color="primary">mdi-door</v-icon>
<span>{{ (klinik.ruangList || []).length }} Ruang</span>
</div>
<div class="ruang-tags">
<template v-if="klinik.ruangList && klinik.ruangList.length > 0">
<v-chip
v-for="(ruang, idx) in klinik.ruangList.slice(0, 3)"
:key="`${klinik.id}-ruang-${idx}`"
size="small"
class="ma-1 chip-preview"
>
{{ ruang.namaRuang }}
</v-chip>
<v-chip
v-if="klinik.ruangList.length > 3"
size="small"
class="ma-1 chip-more"
>
+{{ klinik.ruangList.length - 3 }}
</v-chip>
</template>
<div v-else class="text-caption text-grey">Tidak ada ruang terdaftar</div>
</div>
</div>
<div class="klinik-card-footer">
<v-btn
color="primary"
variant="flat"
size="large"
block
class="btn-view"
>
<v-icon left>mdi-eye</v-icon>
Tampilkan
</v-btn>
</div>
</div>
<div class="klinik-ruang-preview">
<div class="ruang-count">
<v-icon size="18" color="success">mdi-door</v-icon>
<span>{{ klinik.ruangList.length }} Ruang</span>
</div>
<div class="ruang-tags">
<v-chip
v-for="(ruang, idx) in klinik.ruangList.slice(0, 3)"
:key="idx"
size="small"
class="ma-1 chip-preview"
>
{{ ruang.namaRuang }}
</v-chip>
<v-chip
v-if="klinik.ruangList.length > 3"
size="small"
class="ma-1 chip-more"
>
+{{ klinik.ruangList.length - 3 }}
</v-chip>
</div>
</div>
</div>
<div class="klinik-card-footer">
<div v-else class="empty-state">
<v-icon size="64" color="grey-lighten-1">mdi-hospital-building-off</v-icon>
<h3>Tidak Ada Klinik dengan Ruang Tersedia</h3>
<p>Silakan tambah ruang klinik terlebih dahulu di halaman master</p>
<v-btn
color="success"
color="primary"
variant="flat"
size="large"
block
class="btn-view"
@click="navigateToSettings"
class="mt-4"
>
<v-icon left>mdi-eye</v-icon>
Tampilkan
<v-icon left>mdi-cog</v-icon>
Ke Halaman Master
</v-btn>
</div>
</div>
</div>
<div v-else class="empty-state">
<v-icon size="64" color="grey-lighten-1">mdi-hospital-building-off</v-icon>
<h3>Tidak Ada Klinik dengan Ruang Tersedia</h3>
<p>Silakan tambah ruang klinik terlebih dahulu di halaman master</p>
<v-btn
color="success"
variant="flat"
@click="navigateToSettings"
class="mt-4"
>
<v-icon left>mdi-cog</v-icon>
Ke Halaman Master
</v-btn>
</div>
<div v-if="paginatedKliniks.length > 0 && totalPages > 1" class="pagination">
<v-btn variant="outlined" @click="goPrev" :disabled="page <= 1">Prev</v-btn>
<span class="page-info">Page {{ page }} / {{ totalPages }}</span>
<v-btn variant="outlined" @click="goNext" :disabled="page >= totalPages">Next</v-btn>
</div>
<template #fallback>
<div class="d-flex justify-center align-center" style="height: 400px;">
<v-progress-circular indeterminate color="primary" size="64"></v-progress-circular>
</div>
</template>
</ClientOnly>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { ref, computed, onMounted } from 'vue';
import { useMasterStore } from '@/stores/masterStore';
import { useClinicStore } from '@/stores/clinicStore';
import { useRuangStore } from '@/stores/ruangStore';
import { useRoute } from '#app';
definePageMeta({
@@ -98,107 +122,105 @@ definePageMeta({
});
const masterStore = useMasterStore();
const clinicStore = useClinicStore();
const ruangStore = useRuangStore();
const route = useRoute();
// Group klinik berdasarkan data flatten getAllRuangList agar nama ruang baru langsung muncul
// Simplified computed to use masterStore.ruangData (which is already grouped/processed by ruangStore)
const kliniksWithRuang = computed(() => {
const fromGetter = masterStore.getAllRuangList?.value;
const fromState = masterStore.ruangData?.value || masterStore.ruangData || [];
let allRuang = Array.isArray(fromGetter) ? fromGetter : [];
// Jika getter kosong, flatten dari ruangData (grouped)
if (allRuang.length === 0 && Array.isArray(fromState)) {
fromState.forEach((r) => {
(r?.ruangList || []).forEach((ru) => {
allRuang.push({
kodeKlinik: r.kodeKlinik,
namaKlinik: r.namaKlinik,
nomorRuang: ru.nomorRuang,
namaRuang: ru.namaRuang,
nomorScreen: ru.nomorScreen,
});
});
});
}
const klinikMap = {};
allRuang.forEach((ruang) => {
if (!klinikMap[ruang.kodeKlinik]) {
klinikMap[ruang.kodeKlinik] = {
kodeKlinik: ruang.kodeKlinik,
namaKlinik: ruang.namaKlinik,
ruangList: [],
};
}
klinikMap[ruang.kodeKlinik].ruangList.push({
namaRuang: ruang.namaRuang,
nomorRuang: ruang.nomorRuang,
nomorScreen: ruang.nomorScreen,
});
});
return Object.values(klinikMap);
const list = masterStore.ruangData || [];
return [...list].sort((a, b) => a.namaKlinik.localeCompare(b.namaKlinik));
});
// Pagination
const itemsPerPage = 10;
const page = computed({
get: () => Number(route.query.page || 1),
set: (val) => navigateTo({ query: { ...route.query, page: val } }),
});
// Deprecated: Pagination logic removed
const totalPages = computed(() => Math.max(1, Math.ceil(kliniksWithRuang.value.length / itemsPerPage)));
const paginatedKliniks = computed(() => {
const start = (page.value - 1) * itemsPerPage;
return kliniksWithRuang.value.slice(start, start + itemsPerPage);
});
const goPrev = () => {
if (page.value > 1) page.value = page.value - 1;
};
const goNext = () => {
if (page.value < totalPages.value) page.value = page.value + 1;
};
const navigateToKlinik = (kodeKlinik) => {
navigateTo(`/anjungan/antrianklinikruang/${kodeKlinik}`);
const navigateToKlinik = (klinik) => {
// Build URL with jenisLayanan query parameter to differentiate same clinic codes
const url = `/anjungan/antrianklinikruang/${klinik.kodeKlinik}`;
const query = klinik.jenisLayanan ? { jenisLayanan: klinik.jenisLayanan } : {};
navigateTo({ path: url, query });
};
const navigateToSettings = () => {
navigateTo('/setting/masterklinikruang');
};
// Load data on mount
onMounted(async () => {
console.log('🚀 Anjungan/AntrianKlinikRuang/index mounted, loading data...');
try {
// 1. Fetch clinics from API (uses cache if available)
await clinicStore.fetchRegulerClinics();
// 2. Sync room definitions from API (centralized logic)
await ruangStore.fetchRuangFromAPI();
console.log('✅ Anjungan/index data sync completed');
} catch (error) {
console.error('❌ Error loading data:', error);
}
});
</script>
<style scoped lang="scss">
.klinik-selection-container {
background: var(--color-neutral-300);
min-height: 100vh;
padding: 40px;
min-height: calc(100vh - 80px);
padding: 16px;
font-family: 'Inter', 'Roboto', sans-serif;
}
.kliniks-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 20px;
width: 100%;
margin: 0;
}
@media (max-width: 1280px) {
.kliniks-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 960px) {
.kliniks-grid {
grid-template-columns: repeat(2, 1fr);
}
}
.selection-header {
background: linear-gradient(135deg, var(--color-success-600) 0%, var(--color-success-700) 100%);
border-radius: 20px;
padding: 40px;
margin-bottom: 40px;
background: linear-gradient(135deg, var(--color-primary-600) 0%, var(--color-primary-700) 100%);
border-radius: 0 !important;
padding: 16px 28px;
margin-bottom: 0;
display: flex;
align-items: center;
gap: 32px;
box-shadow: 0 8px 24px rgba(0, 146, 98, 0.3);
gap: 16px;
height: 80px;
box-shadow: 0 4px 16px rgba(33, 150, 243, 0.2);
}
.header-icon {
background: rgba(255, 255, 255, 0.15);
border-radius: 20px;
padding: 24px;
background: rgba(255, 255, 255, 0.9);
border-radius: 50%;
padding: 6px;
display: flex;
align-items: center;
justify-content: center;
width: 54px;
height: 54px;
backdrop-filter: blur(10px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.header-logo {
width: 40px;
height: 40px;
object-fit: contain;
}
.header-content {
@@ -207,47 +229,46 @@ const navigateToSettings = () => {
}
.main-title {
font-size: 48px;
font-weight: 800;
font-size: 32px;
font-weight: 600;
margin: 0;
letter-spacing: 2px;
color: var(--color-neutral-100);
line-height: 1.2;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
line-height: 40px;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.subtitle {
font-size: 20px;
font-weight: 600;
margin: 8px 0 0 0;
font-size: 15px;
font-weight: 400;
margin: 2px 0 0 0;
color: var(--color-neutral-100);
opacity: 0.95;
opacity: 0.9;
line-height: 22px;
}
.kliniks-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 24px;
max-width: 1400px;
margin: 0 auto;
@media (max-width: 1440px) {
/* Legacy grid styles removed */
}
.klinik-card {
background: var(--color-neutral-100);
border-radius: 16px;
padding: 24px;
border-radius: 20px;
padding: clamp(16px, 2vw, 24px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
border: 2px solid var(--color-neutral-400);
cursor: pointer;
transition: all 0.3s ease;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
flex-direction: column;
gap: 16px;
min-height: 280px; /* Ensure enough height for content */
height: 100%;
&:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 146, 98, 0.2);
border-color: var(--color-success-600);
transform: translateY(-8px);
box-shadow: 0 12px 24px rgba(33, 150, 243, 0.2);
border-color: var(--color-primary-600);
z-index: 2;
}
}
@@ -255,8 +276,9 @@ const navigateToSettings = () => {
display: flex;
align-items: center;
gap: 16px;
padding-bottom: 16px;
padding-bottom: clamp(12px, 1.5vw, 16px);
border-bottom: 2px solid var(--color-neutral-400);
flex-shrink: 0;
}
.klinik-info {
@@ -271,6 +293,36 @@ const navigateToSettings = () => {
line-height: 1.2;
}
.klinik-details {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
}
.chip-reguler {
background-color: var(--color-primary-600) !important;
color: var(--color-neutral-100) !important;
font-weight: 600;
font-size: 11px;
}
.chip-eksekutif {
background-color: var(--color-secondary-600) !important;
color: var(--color-neutral-100) !important;
font-weight: 600;
font-size: 11px;
}
.kode-chip {
font-size: 12px;
font-weight: 600;
color: var(--color-neutral-700);
background: var(--color-neutral-400);
padding: 2px 8px;
border-radius: 4px;
}
.klinik-code {
font-size: 14px;
color: var(--color-neutral-600);
@@ -288,7 +340,7 @@ const navigateToSettings = () => {
gap: 6px;
font-size: 14px;
font-weight: 600;
color: var(--color-success-600);
color: var(--color-primary-600);
margin-bottom: 12px;
}
@@ -299,8 +351,8 @@ const navigateToSettings = () => {
}
.chip-preview {
background-color: var(--color-success-300) !important;
color: var(--color-success-700) !important;
background-color: var(--color-primary-600) !important;
color: var(--color-neutral-100) !important;
font-weight: 500;
font-size: 12px;
}
@@ -355,26 +407,35 @@ const navigateToSettings = () => {
@media (max-width: 768px) {
.klinik-selection-container {
padding: 20px;
padding: 12px;
}
.selection-header {
flex-direction: column;
text-align: center;
padding: 32px 24px;
flex-direction: row;
padding: 12px 16px;
height: auto;
min-height: 64px;
}
.header-icon {
padding: 4px;
width: 36px;
height: 36px;
}
.header-logo {
width: 24px;
height: 24px;
}
.main-title {
font-size: 32px;
font-size: 20px;
line-height: 28px;
}
.subtitle {
font-size: 16px;
}
.kliniks-grid {
grid-template-columns: 1fr;
gap: 16px;
font-size: 13px;
line-height: 18px;
}
}
</style>
File diff suppressed because it is too large Load diff
+427
View File
@@ -0,0 +1,427 @@
<!-- pages/Anjungan/AntrianLoket/index.vue -->
<template>
<div>
<div class="selection-header">
<div class="header-icon">
<img
src="/RSSA logo 1.png"
alt="RSUD Logo"
class="header-logo"
width="40"
height="40"
style="width: 40px; height: 40px; object-fit: contain;"
/>
</div>
<div class="header-content">
<h1 class="main-title">Pilih Layar Antrian Loket</h1>
<p class="subtitle">RSUD dr. Saiful Anwar Provinsi Jawa Timur</p>
</div>
</div>
<ClientOnly>
<div class="loket-selection-container">
<div v-if="visibleLokets.length > 0" class="lokets-grid">
<div
v-for="loket in visibleLokets"
:key="loket.id"
class="loket-card"
@click="navigateToLoket(loket.id)"
>
<div class="loket-card-header">
<div class="loket-info">
<h3 class="loket-name">{{ loket.namaLoket }}</h3>
</div>
</div>
<div class="loket-preview">
<div class="loket-service-count">
<v-icon size="18" color="primary-600">mdi-hospital-building</v-icon>
<span>{{ (loket.pelayanan || []).length }} Pelayanan</span>
</div>
<div class="loket-tags">
<template v-if="loket.pelayanan && loket.pelayanan.length > 0">
<v-chip
v-for="(pelayananKode, idx) in (loket.pelayanan || []).slice(0, 3)"
:key="idx"
size="small"
class="ma-1 chip-preview"
>
{{ getKlinikName(pelayananKode, loket) }}
</v-chip>
<v-chip
v-if="(loket.pelayanan || []).length > 3"
size="small"
class="ma-1 chip-more"
>
+{{ (loket.pelayanan || []).length - 3 }}
</v-chip>
</template>
<div v-else class="text-caption text-grey">Tidak ada pelayanan terdaftar</div>
</div>
</div>
<div class="loket-card-footer">
<v-btn
color="primary-600"
variant="flat"
size="large"
block
class="btn-view text-white"
>
<v-icon left>mdi-eye</v-icon>
Tampilkan
</v-btn>
</div>
</div>
<!-- Sentinel for Infinite Scroll (placed inside grid or after it, depending on layout structure) -->
<!-- Since grid is flex/grid, we might need it to be full width or just present -->
<div ref="targetRef" style="height: 20px; width: 100%; grid-column: 1 / -1;"></div>
</div>
<div v-else class="empty-state">
<v-icon size="64" color="grey-lighten-1">mdi-view-dashboard-off</v-icon>
<h3>Tidak Ada Loket Tersedia</h3>
<p>Silakan tambah loket terlebih dahulu di halaman master</p>
<v-btn
color="primary-600"
variant="flat"
class="mt-4"
@click="navigateToSettings"
>
<v-icon left>mdi-cog</v-icon>
Ke Halaman Master
</v-btn>
</div>
</div>
<template #fallback>
<div class="d-flex justify-center align-center" style="height: 400px;">
<v-progress-circular indeterminate color="primary" size="64"></v-progress-circular>
</div>
</template>
</ClientOnly>
</div>
</template>
<script setup>
import { computed, onMounted } from 'vue';
import { useLoketStore } from '@/stores/loketStore';
import { useMasterStore } from '@/stores/masterStore';
import { useInfiniteScroll } from '@/composables/useInfiniteScroll';
import { useRoute } from '#app';
definePageMeta({
layout: false,
});
const loketStore = useLoketStore();
const masterStore = useMasterStore();
const route = useRoute();
// Auto-fetch API loket saat component mount
onMounted(() => {
loketStore.fetchLoketFromAPI(true).then(result => {
if (result.success) {
console.log('✅ [AntrianLoket Index] Loket API data loaded:', result.message);
} else {
console.warn('⚠️ [AntrianLoket Index] Failed to fetch loket from API:', result.message);
}
}).catch(err => {
console.error('❌ [AntrianLoket Index] Error fetching loket from API:', err);
});
});
// Helper function: Convert nomor loket ke huruf (1 -> A, 2 -> B, dst)
const numberToLetter = (num) => {
if (num < 1) return 'A';
const charCode = 64 + num;
return String.fromCharCode(Math.min(charCode, 90));
};
// Get all lokets from loketStore (master loket)
const allLokets = computed(() => {
// Akses langsung dari loketStore.loketData (ref)
const lokets = loketStore.loketData
if (!lokets) return []
// Jika lokets adalah ref, gunakan .value, jika tidak langsung return
return Array.isArray(lokets) ? lokets : (lokets.value || [])
});
// Infinite Scroll (Replaces Pagination)
const pageSize = 20;
const { visibleItems: visibleLokets, targetRef, hasMore } = useInfiniteScroll(allLokets, pageSize);
// Deprecated: Pagination logic removed
const navigateToLoket = (loketId) => {
navigateTo(`/anjungan/antrianloket/${loketId}`);
};
const navigateToSettings = () => {
navigateTo('/setting/masterloket');
};
// Helper function untuk mendapatkan nama klinik dari kode
const getKlinikName = (kode, loket = null) => {
// 1. Coba cari di _spesialisDetail (data dari API)
if (loket && loket._spesialisDetail) {
const detail = loket._spesialisDetail.find(s => String(s.idklinik) === String(kode));
if (detail && detail.namaklinik) {
return detail.namaklinik;
}
}
// 2. Coba getKlinikById untuk local data (numeric ID seperti 1000, 1001)
// Local data EKSEKUTIF menggunakan ID numeric, bukan kode
const byId = masterStore.getKlinikById ? masterStore.getKlinikById(kode) : null;
if (byId && byId.nama) return byId.nama;
// 3. Fallback ke masterStore.getKlinikNameByKode untuk data yang pakai kode
return masterStore.getKlinikNameByKode ? masterStore.getKlinikNameByKode(kode) : kode;
};
</script>
<style scoped lang="scss">
.loket-selection-container {
background: var(--color-neutral-300);
min-height: calc(100vh - 80px);
padding: 16px;
font-family: 'Inter', 'Roboto', sans-serif;
}
.lokets-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 20px;
width: 100%;
margin: 0;
}
@media (max-width: 1280px) {
.lokets-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 960px) {
.lokets-grid {
grid-template-columns: repeat(2, 1fr);
}
}
.selection-header {
background: linear-gradient(135deg, var(--color-primary-600) 0%, var(--color-primary-700) 100%);
border-radius: 0 !important;
padding: 16px 28px;
margin-bottom: 0;
display: flex;
align-items: center;
gap: 16px;
height: 80px;
box-shadow: 0 4px 16px rgba(33, 150, 243, 0.2);
}
.header-icon {
background: rgba(255, 255, 255, 0.9);
border-radius: 50%;
padding: 6px;
display: flex;
align-items: center;
justify-content: center;
width: 54px;
height: 54px;
backdrop-filter: blur(10px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.header-logo {
width: 40px;
height: 40px;
object-fit: contain;
}
.header-content {
flex: 1;
color: var(--color-neutral-100);
}
.main-title {
font-size: 32px;
font-weight: 600;
margin: 0;
color: var(--color-neutral-100);
line-height: 40px;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.subtitle {
font-size: 15px;
font-weight: 400;
margin: 2px 0 0 0;
color: var(--color-neutral-100);
opacity: 0.9;
line-height: 22px;
}
.loket-card {
background: var(--color-neutral-100);
border-radius: 16px;
padding: 24px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
border: 2px solid var(--color-neutral-400);
cursor: pointer;
transition: all 0.3s ease;
display: flex;
flex-direction: column;
gap: 16px;
&:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(33, 150, 243, 0.2);
border-color: var(--color-primary-600);
}
}
.loket-card-header {
display: flex;
align-items: center;
gap: 16px;
padding-bottom: 16px;
border-bottom: 2px solid var(--color-neutral-400);
}
.loket-info {
flex: 1;
}
.loket-name {
font-size: 20px;
font-weight: 700;
margin: 0;
color: var(--color-neutral-900);
line-height: 1.2;
}
.loket-details {
font-size: 14px;
color: var(--color-neutral-600);
margin: 4px 0 0 0;
font-weight: 500;
}
.loket-preview {
flex: 1;
}
.loket-service-count {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 600;
color: var(--color-primary-600);
margin-bottom: 12px;
}
.loket-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.chip-preview {
background-color: var(--color-primary-600) !important;
color: var(--color-neutral-100) !important;
font-weight: 500;
font-size: 12px;
}
.chip-more {
background-color: var(--color-neutral-600) !important;
color: var(--color-neutral-100) !important;
font-weight: 600;
font-size: 12px;
}
.loket-card-footer {
margin-top: auto;
}
.btn-view {
font-weight: 600;
text-transform: none;
letter-spacing: 0.5px;
}
.empty-state {
text-align: center;
padding: 80px 20px;
color: var(--color-neutral-700);
h3 {
font-size: 24px;
font-weight: 700;
margin: 24px 0 8px 0;
color: var(--color-neutral-800);
}
p {
font-size: 16px;
color: var(--color-neutral-600);
}
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-top: 24px;
}
.page-info {
font-weight: 600;
color: var(--color-neutral-800);
}
@media (max-width: 768px) {
.loket-selection-container {
padding: 12px;
}
.selection-header {
flex-direction: row;
padding: 12px 16px;
height: auto;
min-height: 64px;
}
.header-icon {
padding: 4px;
width: 36px;
height: 36px;
}
.header-logo {
width: 24px;
height: 24px;
}
.main-title {
font-size: 20px;
line-height: 28px;
}
.subtitle {
font-size: 13px;
line-height: 18px;
}
.lokets-grid {
grid-template-columns: 1fr;
gap: 16px;
}
}
</style>
+32 -8
View File
@@ -5,7 +5,14 @@
<div class="display-header">
<div class="header-left">
<div class="logo-circle">
<v-icon size="48" color="white">mdi-clipboard-pulse</v-icon>
<img
src="/RSSA logo 1.png"
alt="RSUD Logo"
class="header-logo"
width="64"
height="64"
style="width: 64px; height: 64px; object-fit: contain;"
/>
</div>
<div class="header-text">
<h1 class="hospital-name">RUMAH SAKIT</h1>
@@ -103,7 +110,7 @@
<v-icon size="32" color="#9C27B0">mdi-clock-alert-outline</v-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ statistics.waiting }}</div>
<div class="stat-value">{{ statistics.anjungan }}</div>
<div class="stat-label">Menunggu</div>
</div>
</div>
@@ -154,7 +161,7 @@ const displayedPenunjangs = computed(() => {
.sort((a, b) => {
const statusPriority = {
'di-loket': 1,
'waiting': 2,
'anjungan': 2,
'terlambat': 3,
'pending': 4
}
@@ -168,11 +175,11 @@ const displayedPenunjangs = computed(() => {
})
const currentQueue = queues.find(q => q.status === 'di-loket') ||
(queues.find(q => q.status === 'waiting') || null)
(queues.find(q => q.status === 'anjungan') || null)
const nextQueues = queues.filter(q =>
q.no !== currentQueue?.no &&
(q.status === 'waiting' || q.status === 'di-loket')
(q.status === 'anjungan' || q.status === 'di-loket')
)
return {
@@ -196,18 +203,20 @@ const statistics = computed(() => {
const all = penunjangPatients.value
return {
total: all.length,
waiting: all.filter(p => p.status === 'waiting').length,
anjungan: all.filter(p => p.status === 'anjungan').length,
active: all.filter(p => p.status === 'di-loket').length
}
})
const updateTime = () => {
const now = new Date()
currentTime.value = now.toLocaleTimeString('id-ID', {
// Format jam dengan titik dua (HH:MM:SS)
const timeString = now.toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
currentTime.value = timeString.replace(/\./g, ':')
currentDate.value = now.toLocaleDateString('id-ID', {
weekday: 'long',
day: 'numeric',
@@ -252,7 +261,7 @@ onUnmounted(() => {
}
.logo-circle {
background: rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.95);
width: 80px;
height: 80px;
border-radius: 50%;
@@ -260,6 +269,16 @@ onUnmounted(() => {
align-items: center;
justify-content: center;
backdrop-filter: blur(10px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.header-logo {
width: 64px;
height: 64px;
object-fit: contain;
padding: 4px;
justify-content: center;
backdrop-filter: blur(10px);
}
.hospital-name {
@@ -285,6 +304,7 @@ onUnmounted(() => {
color: #FFFFFF;
line-height: 1;
letter-spacing: 2px;
font-variant-numeric: tabular-nums; /* Prevent digit shifting */
}
.date-small {
@@ -324,6 +344,7 @@ onUnmounted(() => {
margin: 16px 0;
letter-spacing: 4px;
text-shadow: 3px 3px 6px rgba(0, 0, 0, 0.2);
font-variant-numeric: tabular-nums; /* Stable digits */
}
.call-clinic {
@@ -417,6 +438,7 @@ onUnmounted(() => {
color: #212121;
line-height: 1;
letter-spacing: 2px;
font-variant-numeric: tabular-nums; /* Stable digits */
}
.next-section {
@@ -447,6 +469,7 @@ onUnmounted(() => {
font-weight: 700;
box-shadow: 0 4px 12px rgba(156, 39, 176, 0.2);
letter-spacing: 1px;
font-variant-numeric: tabular-nums; /* Stable digits */
}
.empty-state {
@@ -492,6 +515,7 @@ onUnmounted(() => {
font-weight: 900;
color: #212121;
line-height: 1;
font-variant-numeric: tabular-nums; /* Stable digits */
}
.stat-label {
+38 -36
View File
@@ -1,27 +1,28 @@
<template>
<v-container>
<v-card>
<!-- Header -->
<div class="page-header">
<div class="header-content">
<div class="header-left">
<div class="header-icon">
<v-icon size="32" color="white">mdi-clipboard-plus</v-icon>
</div>
<div class="header-text">
<h2 class="page-title">Buat Antrean</h2>
<p class="page-subtitle">{{ currentDate }} - Manajemen Pembuatan Antrean</p>
</div>
<div>
<!-- Header -->
<div class="page-header">
<div class="header-content">
<div class="header-left">
<div class="header-icon">
<v-icon size="32" color="white">mdi-clipboard-plus</v-icon>
</div>
<div class="header-text">
<h2 class="page-title">Buat Antrean</h2>
<p class="page-subtitle">{{ currentDate }} - Manajemen Pembuatan Antrean</p>
</div>
<v-chip color="white" variant="flat" class="stat-chip">
<v-icon start size="20" color="primary">mdi-account-multiple</v-icon>
<span class="text-semibold">{{ totalPasien }} Pasien</span>
</v-chip>
</div>
<v-chip color="white" variant="flat" class="stat-chip">
<v-icon start size="20" color="primary">mdi-account-multiple</v-icon>
<span class="text-semibold">{{ totalPasien }} Pasien</span>
</v-chip>
</div>
</div>
<!-- Filters & Search -->
<v-card-text class="pa-4">
<v-container>
<v-card>
<!-- Filters & Search -->
<v-card-text class="pa-4">
<v-row dense class="mb-4">
<v-col cols="12" md="8">
<v-text-field
@@ -327,14 +328,15 @@
</v-card>
</v-dialog>
<!-- Snackbar -->
<v-snackbar v-model="snackbar" :color="snackbarColor" :timeout="3000" location="top right">
<span class="body-3">{{ snackbarText }}</span>
<template #actions>
<v-btn variant="text" size="small" @click="snackbar = false">Tutup</v-btn>
</template>
</v-snackbar>
</v-container>
<!-- Snackbar -->
<v-snackbar v-model="snackbar" :color="snackbarColor" :timeout="3000" location="top right">
<span class="body-3">{{ snackbarText }}</span>
<template #actions>
<v-btn variant="text" size="small" @click="snackbar = false">Tutup</v-btn>
</template>
</v-snackbar>
</v-container>
</div>
</template>
<script setup>
@@ -547,11 +549,11 @@ $neutral-400: #E5F7FA;
$neutral-300: #F5F7FA;
$neutral-100: #FFFFFF;
$primary-700: #FF9B1B;
$primary-600: #FFA532;
$primary-700: #3556AE;
$primary-600: #3A61C9;
$secondary-700: #0053AD;
$secondary-600: #0671E0;
$secondary-700: #E65A0D;
$secondary-600: #F16F29;
$warning-600: #FFB95F;
@@ -574,8 +576,8 @@ $font-weight-bold: 700;
// ============================================
.page-header {
background: linear-gradient(135deg, $primary-600 0%, $primary-700 100%);
border-radius: 16px 16px 0 0;
box-shadow: 0 4px 16px rgba(255, 165, 50, 0.2);
border-radius: 0 !important;
box-shadow: 0 4px 16px rgba(58, 97, 201, 0.2);
}
.header-content {
@@ -674,7 +676,7 @@ $font-weight-bold: 700;
}
.dialog-header {
background: linear-gradient(135deg, $secondary-600 0%, $secondary-700 100%);
background: linear-gradient(135deg, $primary-600 0%, $primary-700 100%);
color: $neutral-100;
padding: 20px 24px;
display: flex;
@@ -683,11 +685,11 @@ $font-weight-bold: 700;
}
.dialog-header-warning {
background: linear-gradient(135deg, $warning-600 0%, $primary-700 100%);
background: linear-gradient(135deg, $primary-600 0%, $primary-700 100%);
}
.dialog-header-secondary {
background: linear-gradient(135deg, $secondary-600 0%, $secondary-700 100%);
background: linear-gradient(135deg, $primary-600 0%, $primary-700 100%);
}
.headline-4 {
File diff suppressed because it is too large Load diff
@@ -1,717 +0,0 @@
<template>
<v-app class="bg-modern">
<v-main class="no-overflow">
<v-container fluid class="no-scroll-container pa-2 pa-md-4">
<v-row align="start" justify="center" class="fill-height">
<v-col cols="12" sm="11" md="10" lg="8" xl="7" class="d-flex flex-column">
<!-- Main Card dengan Glassmorphism -->
<v-card class="main-card" elevation="0">
<!-- Header Minimalis -->
<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
v-model="tab"
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>
<v-card-text class="content-modern pa-4 pa-md-6">
<v-window v-model="tab">
<!-- Tab Scan QR -->
<v-window-item value="scan">
<QRScanTab
:is-scanning="isScanning"
:has-camera="hasCamera"
:camera-checking="cameraChecking"
:camera-ready="cameraReady"
:primary-color="primaryColor"
@start-scanning="startScanning"
@stop-scanning="stopScanning"
@test-camera="testCamera"
@open-history="openHistoryDialog"
@open-qr-history="openQRHistoryDialog"
/>
</v-window-item>
<!-- Tab Manual -->
<v-window-item value="manual">
<ManualInputTab
v-model="manualInput"
:primary-color="primaryColor"
:secondary-color="secondaryColor"
@submit="checkInManual"
@open-history="openHistoryDialog"
ref="manualInputTabRef"
/>
</v-window-item>
<!-- Tab Generate QR -->
<v-window-item value="generate">
<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="generateQuickQR(generateRandomPatientId(), '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="generateQuickQR(generateRandomPatientId(), '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="generateQRCode">
<v-text-field
v-model="generatePatientId"
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 v-slot:append-inner>
<v-btn
icon
size="small"
variant="text"
@click="generateRandomId()"
title="Generate Random ID"
>
<v-icon size="20">mdi-refresh</v-icon>
</v-btn>
</template>
</v-text-field>
<v-select
v-model="generateStatus"
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="!generatePatientId"
>
<v-icon start size="20">mdi-qrcode-plus</v-icon>
Generate QR Code
</v-btn>
</div>
</v-form>
<!-- QR Code Display -->
<div v-if="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 id="qrcode" class="qr-code-container mb-4"></div>
<v-chip :color="generateStatus === 'ALLOWED' ? 'success' : 'warning'" class="mb-3">
<v-icon start>{{ generateStatus === 'ALLOWED' ? 'mdi-check' : 'mdi-clock-alert' }}</v-icon>
{{ generateStatus === 'ALLOWED' ? 'Diizinkan Check-in' : 'Belum Diizinkan' }}
</v-chip>
<p class="text-body-2 text-grey mb-4">
Data: {{ generatedQRData }}
</p>
<!-- Action Buttons -->
<v-row dense>
<v-col cols="4">
<v-btn
variant="outlined"
:color="primaryColor"
block
size="small"
@click="downloadQR"
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="copyQRToClipboard"
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="shareQR"
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 v-slot: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>
</v-window-item>
</v-window>
</v-card-text>
</v-card>
<!-- Stats Footer Minimalis -->
<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>
</v-col>
</v-row>
</v-container>
</v-main>
<!-- Check-in Result Dialog -->
<CheckInDialog
v-model="infoDialog"
:last-check-in-result="lastCheckInResult"
:info-message="infoMessage"
:info-action="infoAction"
:scanned-data="scannedData"
@close="handleInfoAction"
/>
<!-- Enhanced Snackbar -->
<v-snackbar
v-model="snackbarShow"
:color="snackbar.color"
:timeout="snackbar.timeout"
location="bottom right"
rounded="pill"
elevation="24"
class="custom-snackbar"
>
<div class="d-flex align-center">
<v-avatar :color="snackbar.color" size="32" class="mr-3">
<v-icon size="20" color="white">{{ snackbar.icon }}</v-icon>
</v-avatar>
<div>
<div class="font-weight-bold">{{ snackbar.title }}</div>
<div class="text-body-2">{{ snackbar.message }}</div>
</div>
</div>
</v-snackbar>
<!-- History Dialog -->
<HistoryDialog
v-model="historyDialog"
:search="historySearch"
:date-filter="historyDateFilter"
:status-filter="historyStatusFilter"
:filtered-history="filteredHistory"
:history="checkInHistory"
:status-options="historyStatusOptions"
:primary-color="primaryColor"
:get-status-color="getStatusColor"
:get-status-icon="getStatusIcon"
:get-status-text="getStatusText"
:get-status-class="getStatusClass"
:format-date-time="formatDateTime"
:format-date="formatDate"
@update:search="historySearch = $event"
@update:date-filter="historyDateFilter = $event"
@update:status-filter="historyStatusFilter = $event"
@delete-item="handleDeleteHistoryItem"
@clear="handleClearHistory"
/>
<!-- QR History Dialog -->
<QRHistoryDialog
v-model="qrHistoryDialog"
:search="historySearch"
:date-filter="historyDateFilter"
:filtered-history="filteredQRHistory"
:history="scannedQRHistory"
:primary-color="primaryColor"
@update:search="historySearch = $event"
@update:date-filter="historyDateFilter = $event"
@use-data="useQRData"
@delete-item="handleDeleteQRHistoryItem"
@clear="handleClearQRHistory"
/>
</v-app>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import type { TabValue, InfoAction, CheckInHistoryItem } from '~/types/checkin';
import { PRIMARY_COLOR, SECONDARY_COLOR, QR_STATUS_OPTIONS } from '~/constants/checkin';
import { useSnackbar } from '~/composables/useSnackbar';
import { useCheckInHistory } from '~/composables/useCheckInHistory';
import { useQRScanner } from '~/composables/useQRScanner';
import { useCheckIn } from '~/composables/useCheckIn';
import { useQRGenerator } from '~/composables/useQRGenerator';
import CheckInDialog from '~/components/checkin/CheckInDialog.vue';
import HistoryDialog from '~/components/checkin/HistoryDialog.vue';
import QRHistoryDialog from '~/components/checkin/QRHistoryDialog.vue';
import ManualInputTab from '~/components/checkin/ManualInputTab.vue';
import QRScanTab from '~/components/checkin/QRScanTab.vue';
definePageMeta({
middleware:['auth'],
layout: false,
})
// TypeScript declaration for QRCode
declare global {
interface Window {
QRCode: typeof import('qrcode').default;
}
}
// --- DESAIN & TEMA ---
const primaryColor = ref(PRIMARY_COLOR);
const secondaryColor = ref(SECONDARY_COLOR);
// --- LOGIKA ---
const tab = ref<TabValue>('scan');
const infoDialog = ref(false);
const infoMessage = ref('');
const infoAction = ref<InfoAction>('kembali');
const scannedData = ref<string | null>(null);
const manualInput = ref('');
const manualInputTabRef = ref<InstanceType<typeof ManualInputTab> | null>(null);
// lastCheckInResult sudah dipindahkan ke useCheckIn composable
// Timer untuk auto-close dialog
let dialogAutoCloseTimer: ReturnType<typeof setTimeout> | null = null;
// Fungsi untuk auto-close dialog setelah 15 detik
const autoCloseDialog = () => {
// Clear timer sebelumnya jika ada
if (dialogAutoCloseTimer) {
clearTimeout(dialogAutoCloseTimer);
}
// Set timer untuk menutup dialog setelah 15 detik
dialogAutoCloseTimer = setTimeout(() => {
if (infoDialog.value) {
infoDialog.value = false;
dialogAutoCloseTimer = null;
}
}, 15000); // 15 detik
};
// Snackbar composable (harus didefinisikan sebelum useQRScanner)
const { snackbar, snackbarShow, showSnackbar } = useSnackbar();
// History composable
const {
checkInHistory,
scannedQRHistory,
historySearch,
historyDateFilter,
historyStatusFilter,
filteredHistory,
filteredQRHistory,
loadHistory,
saveToHistory,
deleteHistoryItem,
clearHistory,
loadScannedQRHistory,
saveScannedQRData,
clearQRHistory,
deleteQRHistoryItem,
getStatusColor,
getStatusIcon,
getStatusText,
getStatusClass,
formatDateTime,
formatDate,
historyStatusOptions,
} = useCheckInHistory();
// Computed untuk stats footer
const todayCount = computed(() => {
const today = new Date().toDateString()
return checkInHistory.value.filter((item: CheckInHistoryItem) => {
const itemDate = new Date(item.checkInDate).toDateString()
return itemDate === today
}).length
})
const completedCount = computed(() => {
return checkInHistory.value.filter((item: CheckInHistoryItem) =>
item.status === 'success' || item.status === 'ALLOWED'
).length
})
const pendingCount = computed(() => {
return checkInHistory.value.filter((item: CheckInHistoryItem) =>
item.status === 'pending' || item.status === 'NOT_ALLOWED'
).length
})
// Check-in composable (didefinisikan dulu, saveSuccessfulScan akan di-set nanti)
let saveSuccessfulScanRef: ((qrData: string) => void) | null = null
const {
lastCheckInResult,
processQRCode,
checkInManual: checkInManualComposable,
} = useCheckIn({
showSnackbar,
saveToHistory,
saveSuccessfulScan: (qrData: string) => {
// Delegate ke saveSuccessfulScan dari useQRScanner
if (saveSuccessfulScanRef) {
saveSuccessfulScanRef(qrData)
}
},
onCheckInSuccess: (result) => {
infoMessage.value = result.message
infoAction.value = result.action
infoDialog.value = true
},
autoCloseDialog,
})
// QR Scanner composable (setelah useCheckIn agar bisa akses processQRCode)
const {
isScanning,
hasCamera,
cameraChecking,
cameraReady,
checkCameraAvailability,
testCamera,
startScanning,
stopScanning,
saveSuccessfulScan,
} = useQRScanner(
(decodedText: string) => {
// Callback ketika QR berhasil di-scan
scannedData.value = decodedText
processQRCode(decodedText)
// Simpan ke history scanned QR
saveScannedQRData(decodedText)
},
showSnackbar
)
// Set saveSuccessfulScanRef setelah useQRScanner didefinisikan
saveSuccessfulScanRef = saveSuccessfulScan
// History Dialog
const historyDialog = ref(false);
const qrHistoryDialog = ref(false);
// Generate random patient ID function (untuk digunakan di composable dan quick test buttons)
const generateRandomPatientId = () => {
// Generate random 6-digit number
const randomNum = Math.floor(100000 + Math.random() * 900000);
return `P-${randomNum}`;
};
// QR Generator composable
const {
generatePatientId,
generateStatus,
generatedQRData,
generateRandomId,
generateQuickQR,
generateQRCode,
downloadQR,
copyQRToClipboard,
shareQR,
} = useQRGenerator({
showSnackbar,
generateRandomPatientId,
});
const statusOptions = [...QR_STATUS_OPTIONS] as Array<{ title: string; value: string }>;
// Watch tab changes untuk stop scanner saat pindah tab
watch(tab, (newTab: TabValue) => {
if (newTab !== 'scan' && isScanning.value) {
stopScanning();
}
// Check camera when switching to scan tab
if (newTab === 'scan' && !hasCamera.value && !cameraChecking.value) {
checkCameraAvailability();
}
});
// Check camera on mount
onMounted(() => {
if (typeof window !== 'undefined' && typeof navigator !== 'undefined') {
if (navigator.mediaDevices && typeof navigator.mediaDevices.getUserMedia === 'function') {
checkCameraAvailability();
} else {
console.warn('MediaDevices API not available. Make sure you are using HTTPS or localhost.');
hasCamera.value = false;
cameraChecking.value = false;
showSnackbar('Warning', 'MediaDevices API tidak tersedia. Pastikan menggunakan HTTPS atau localhost.', 'warning', 'mdi-alert');
}
} else {
hasCamera.value = false;
cameraChecking.value = false;
}
});
// Cleanup saat component unmount
onUnmounted(() => {
if (isScanning.value) {
stopScanning();
}
// Clear dialog auto-close timer
if (dialogAutoCloseTimer) {
clearTimeout(dialogAutoCloseTimer);
dialogAutoCloseTimer = null;
}
});
// onDetect sudah dipindahkan ke useCheckIn composable sebagai processQRCode
const handleInfoAction = async () => {
// Clear timer jika dialog ditutup secara manual
if (dialogAutoCloseTimer) {
clearTimeout(dialogAutoCloseTimer);
dialogAutoCloseTimer = null;
}
infoDialog.value = false;
// Scanner tetap berjalan setelah dialog ditutup
// Ini memungkinkan user untuk segera memproses QR code berikutnya
// tanpa perlu memulai scanner lagi
};
// performCheckIn sudah dipindahkan ke useCheckIn composable
const checkInManual = async () => {
try {
if (!manualInput.value || !manualInput.value.trim()) {
showSnackbar('Error', 'Mohon isi nomor antrean atau ID pasien', 'error', 'mdi-alert');
return;
}
const patientId = manualInput.value.trim();
// Validate form if available
if (manualInputTabRef.value?.form) {
try {
const isValid = manualInputTabRef.value.form.validate();
if (!isValid) {
return;
}
} catch (e) {
// If validate fails, continue anyway
console.warn('Form validation error:', e);
}
}
await checkInManualComposable(
patientId,
() => {
// onSuccess callback
try {
manualInput.value = '';
if (manualInputTabRef.value?.form) {
try {
manualInputTabRef.value.form.reset();
} catch (e) {
// Ignore reset errors
console.warn('Form reset error:', e);
}
}
} catch (e) {
console.warn('Error in success callback:', e);
}
},
() => {
// onError callback - tidak perlu melakukan apa-apa karena error sudah ditangani di composable
}
);
} catch (error) {
console.error('Error in checkInManual:', error);
showSnackbar('Error', 'Terjadi kesalahan saat melakukan check-in. Silakan coba lagi.', 'error', 'mdi-alert');
}
};
// generateQRCode, generateQuickQR, downloadQR, copyQRToClipboard, shareQR sudah dipindahkan ke useQRGenerator composable
// History Functions - semua sudah dipindahkan ke useCheckInHistory composable
const openHistoryDialog = () => {
loadHistory();
historyDialog.value = true;
};
const openQRHistoryDialog = () => {
loadScannedQRHistory();
qrHistoryDialog.value = true;
};
const handleDeleteHistoryItem = (index: number) => {
deleteHistoryItem(index);
showSnackbar('Berhasil', 'Riwayat berhasil dihapus', 'success', 'mdi-check');
};
const handleClearHistory = () => {
clearHistory();
showSnackbar('Berhasil', 'Semua riwayat berhasil dihapus', 'success', 'mdi-check');
};
const handleDeleteQRHistoryItem = (index: number) => {
deleteQRHistoryItem(index);
showSnackbar('Berhasil', 'Riwayat QR scan berhasil dihapus', 'success', 'mdi-check');
};
const handleClearQRHistory = () => {
clearQRHistory();
showSnackbar('Berhasil', 'Semua riwayat QR scan berhasil dihapus', 'success', 'mdi-check');
};
const useQRData = (qrData: string) => {
qrHistoryDialog.value = false;
scannedData.value = qrData;
processQRCode(qrData);
};
// filteredHistory, filteredQRHistory, dan helper functions sudah dipindahkan ke useCheckInHistory composable
// loadSuccessfulScans sudah dipanggil di dalam useQRScanner composable saat initialization
</script>
<style scoped lang="scss">
@import '~/assets/scss/checkin/variables';
@import '~/assets/scss/checkin/components';
@import '~/assets/scss/checkin/dialogs';
</style>
+2136 -593
View File
File diff suppressed because it is too large Load diff
File diff suppressed because it is too large Load diff
+1 -1
View File
@@ -112,7 +112,7 @@
import { ref, computed, watch } from "vue";
definePageMeta({
middleware:['auth']
middleware: ['auth', 'check-page-access']
})
// === Data Dummy untuk Tabel ===
+13 -7
View File
@@ -1,5 +1,5 @@
<template>
<v-container fluid fill-height class="login-background">
<v-container fluid class="login-background fill-height">
<div class="floating-medical-icon icon-1">
<v-icon size="144">mdi-heart-pulse</v-icon>
@@ -20,7 +20,7 @@
<v-icon size="114">mdi-bandage</v-icon>
</div>
<v-row class="fill-height align-center justify-center">
<v-row class="fill-height align-center justify-center ma-0">
<v-col cols="12" class="d-flex justify-center align-center">
<v-card class="main-card white-card rounded-xl pa-0" max-width="450" width="100%">
<v-row class="ma-0">
@@ -31,19 +31,25 @@
<p class="login-instruction-dark">MASUK UNTUK MELANJUTKAN</p>
</div>
<div class="d-flex flex-column align-center text-center mb-6">
<span class="text-h5 font-weight-bold app-title-dark">Antrean RSSA</span>
<div class="d-flex flex-column align-center text-center mb-10 pt-10 pb-10">
<img
src="/AntreanLogobg.png"
src="/love logo biru.png"
alt="Antrean Logo"
class="mt-3 hospital-logo"
height="180"
style="height: 180px; width: auto; object-fit: contain;"
/>
<!-- src="/x logo biru.png" -->
<!-- src="/logo spiral.png" -->
<!-- src="/logo biru.png" -->
<!-- src="/love logo biru.png" -->
</div>
<v-alert v-if="errorMessage" type="error" class="mb-4" dismissible @click:close="errorMessage = ''">
{{ errorMessage }}
</v-alert>
<v-alert v-if="successMessage" type="success" class="mb-4">
{{ successMessage }}
</v-alert>
@@ -280,7 +286,7 @@ import type { LoginResponse } from '~/types/auth'
// Use guest middleware to redirect if already authenticated
definePageMeta({
layout: 'empty',
middleware:['auth','guest']
middleware: ['guest']
})
// Reactive state
@@ -375,4 +381,4 @@ Jam Operasional: Senin - Jumat, 07:00 - 15:00
}
</script>
<style scoped src="~/assets/styles/loginpage/login.css"></style>
<style scoped src="~/assets/styles/loginpage/login.scss"></style>
Loaded 100 of 172 files, more files were not shown because too many files have changed in this diff. Show more