From 2d3d589fe64edecbd13e2609e2abf28561a263c8 Mon Sep 17 00:00:00 2001 From: Fanrouver Date: Thu, 8 Jan 2026 12:44:40 +0700 Subject: [PATCH] layar baru antrean klinik dan masuk --- DOMAIN_CHANGE_CHECKLIST.md | 371 +++++++ .../features/queue/QueueActionsCard.vue | 4 +- composables/useQueue.js | 77 +- layouts/default.vue | 4 +- middleware/permissions.ts | 8 +- nuxt.config.ts | 2 +- pages/AdminLoket.vue | 39 +- pages/Anjungan/AntreanMasuk/[id].vue | 909 ++++++++++++++++++ pages/Anjungan/AntreanMasuk/index.vue | 348 +++++++ pages/Anjungan/AntrianLoket/[id].vue | 876 +++++++++++++++++ pages/Anjungan/AntrianLoket/index.vue | 344 +++++++ pages/CheckInPasien/checkIn.vue | 240 ++++- pages/Setting/HakAkses.vue | 27 +- server/api/auth/keycloak-login.ts | 5 + stores/loketStore.js | 11 + stores/masterStore.js | 2 +- stores/navItems1.ts | 2 + stores/queueStore.js | 53 +- 18 files changed, 3253 insertions(+), 69 deletions(-) create mode 100644 DOMAIN_CHANGE_CHECKLIST.md create mode 100644 pages/Anjungan/AntreanMasuk/[id].vue create mode 100644 pages/Anjungan/AntreanMasuk/index.vue create mode 100644 pages/Anjungan/AntrianLoket/[id].vue create mode 100644 pages/Anjungan/AntrianLoket/index.vue diff --git a/DOMAIN_CHANGE_CHECKLIST.md b/DOMAIN_CHANGE_CHECKLIST.md new file mode 100644 index 0000000..0027c5a --- /dev/null +++ b/DOMAIN_CHANGE_CHECKLIST.md @@ -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 +``` + +### 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 +``` + +### 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 +``` + +### 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 + +# Method 2: Delete pods (akan auto-recreate) +kubectl delete pods -l app=antrean -n + +# Method 3: Scale down then up +kubectl scale deployment antrean-app --replicas=0 -n +kubectl scale deployment antrean-app --replicas=2 -n +``` + +### 4.7. Verifikasi di Kubernetes + +```bash +# Cek ConfigMap +kubectl get configmap antrean-config -n -o yaml + +# Cek Secret (values akan di-encode base64) +kubectl get secret antrean-secrets -n -o yaml + +# Cek Ingress +kubectl get ingress antrean-ingress -n + +# Cek pods environment +kubectl exec -it -n -- env | grep AUTH_ORIGIN + +# Cek logs +kubectl logs -f deployment/antrean-app -n +``` + +--- + +## 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 + diff --git a/components/features/queue/QueueActionsCard.vue b/components/features/queue/QueueActionsCard.vue index 441a0a7..7482220 100644 --- a/components/features/queue/QueueActionsCard.vue +++ b/components/features/queue/QueueActionsCard.vue @@ -24,7 +24,7 @@ rounded class="mt-1" /> - Terpakai: {{ usedQuota }} + Selesai: {{ usedQuota }} Bisa dipanggil: {{ callableQuota }} @@ -168,7 +168,7 @@ defineEmits(['call']); .quota-callable { display: block; font-size: 11px; - color: var(--color-primary-600); + color: var(--color-neutral-900); margin-top: 4px; font-weight: 600; } diff --git a/composables/useQueue.js b/composables/useQueue.js index 0be6bbe..b8030ae 100644 --- a/composables/useQueue.js +++ b/composables/useQueue.js @@ -124,16 +124,68 @@ export const useQueue = (adminType = "loket") => { showSnackbar(result.message, color); }; + // Helper function untuk mendapatkan pasien yang sedang diproses dari store + const getCurrentProcessingPatientFromStore = () => { + try { + const processingPatient = queueStore.currentProcessingPatient?.[adminType]; + 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"); @@ -147,15 +199,22 @@ 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; } + + const result = queueStore.changeKlinik( + patient, + klinik, + adminType + ); + showSnackbar(result.message, result.success ? "success" : "error"); + showChangeKlinikDialog.value = false; }; const processNextQueue = () => { diff --git a/layouts/default.vue b/layouts/default.vue index c7f456e..9d27ab0 100644 --- a/layouts/default.vue +++ b/layouts/default.vue @@ -243,7 +243,9 @@ const filteredNavItems = computed(() => { onMounted(async () => { await checkAuth(); - await fetchPermissionsFromAPI(); + // DISABLED: Auto-fetch permissions is now disabled + // Permissions should only be fetched manually via button click in HakAkses page + // await fetchPermissionsFromAPI(); }); diff --git a/middleware/permissions.ts b/middleware/permissions.ts index 2749513..50daf57 100644 --- a/middleware/permissions.ts +++ b/middleware/permissions.ts @@ -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); + // }); }); diff --git a/nuxt.config.ts b/nuxt.config.ts index c4bbda4..6047e62 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -96,7 +96,7 @@ export default defineNuxtConfig({ devServer: { port: 3000, - host: '0.0.0.0' + host: 'localhost' }, vite: { diff --git a/pages/AdminLoket.vue b/pages/AdminLoket.vue index cd7b6d4..e05abd4 100644 --- a/pages/AdminLoket.vue +++ b/pages/AdminLoket.vue @@ -447,12 +447,47 @@ const closeKlinikRuangDialog = () => { }; const buatAntreanKlinikRuang = (klinikRuang, ruang) => { - if (!currentProcessingPatient.value) return; + // Pastikan currentProcessingPatient valid, jika tidak, coba dapatkan dari store + let patient = currentProcessingPatient.value; + if (!patient) { + try { + // Coba dapatkan pasien yang sedang diproses dari store + const processingPatient = queueStore.currentProcessingPatient?.loket; + if (processingPatient) { + // 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 + let allPatients = []; + if (queueStore.allPatients) { + // Jika allPatients adalah ref (punya .value) + allPatients = Array.isArray(queueStore.allPatients) + ? queueStore.allPatients + : (queueStore.allPatients.value || []); + } + + const latestPatient = allPatients.find( + p => (p && p.no === processingPatient.no) || + (p && p.barcode && p.barcode === processingPatient.barcode) || + (p && p.noAntrian && p.noAntrian === processingPatient.noAntrian) + ); + patient = latestPatient || processingPatient; + } + } catch (error) { + console.error('Error getting patient from store:', error); + } + } + + if (!patient) { + snackbarText.value = "Tidak ada pasien yang sedang diproses"; + snackbarColor.value = "error"; + snackbar.value = true; + closeKlinikRuangDialog(); + return; + } const result = queueStore.createAntreanKlinikRuang( klinikRuang, ruang, - currentProcessingPatient.value, + patient, "loket" ); diff --git a/pages/Anjungan/AntreanMasuk/[id].vue b/pages/Anjungan/AntreanMasuk/[id].vue new file mode 100644 index 0000000..6da9a20 --- /dev/null +++ b/pages/Anjungan/AntreanMasuk/[id].vue @@ -0,0 +1,909 @@ + + + + + + + diff --git a/pages/Anjungan/AntreanMasuk/index.vue b/pages/Anjungan/AntreanMasuk/index.vue new file mode 100644 index 0000000..e0523d4 --- /dev/null +++ b/pages/Anjungan/AntreanMasuk/index.vue @@ -0,0 +1,348 @@ + + + + + + diff --git a/pages/Anjungan/AntrianLoket/[id].vue b/pages/Anjungan/AntrianLoket/[id].vue new file mode 100644 index 0000000..4174401 --- /dev/null +++ b/pages/Anjungan/AntrianLoket/[id].vue @@ -0,0 +1,876 @@ + + + + + + + diff --git a/pages/Anjungan/AntrianLoket/index.vue b/pages/Anjungan/AntrianLoket/index.vue new file mode 100644 index 0000000..ee2965e --- /dev/null +++ b/pages/Anjungan/AntrianLoket/index.vue @@ -0,0 +1,344 @@ + + + + + + diff --git a/pages/CheckInPasien/checkIn.vue b/pages/CheckInPasien/checkIn.vue index 0bc5945..4cb0871 100644 --- a/pages/CheckInPasien/checkIn.vue +++ b/pages/CheckInPasien/checkIn.vue @@ -315,11 +315,12 @@
-
+ +
{{ getStatusIcon(item.status) }} {{ getStatusText(item.status) }} @@ -328,22 +329,51 @@ color="grey-lighten-1" size="x-small" variant="text" + density="compact" > {{ item.method }}
-

- mdi-account-circle - {{ item.patientId }} -

+ +
+

+ mdi-account-circle + {{ item.patientId }} +

+
+ +
+ + mdi-ticket + {{ item.klinikQueueNumber }} + + + mdi-credit-card + {{ item.pembayaran }} + +
+ +
mdi-clock-outline {{ formatTime(item.checkInTime) }} @@ -857,15 +887,36 @@
-
-

+ +

+

mdi-account-circle - ID Pasien: {{ item.patientId }} -

-

- mdi-ticket - Nomor Antrean: {{ item.queueNumber }} + {{ item.patientId }}

+ + +
+ + mdi-ticket + {{ item.klinikQueueNumber }} + + + mdi-credit-card + {{ item.pembayaran }} + +
@@ -1132,6 +1183,8 @@ const historyStatusFilter = ref(''); const checkInHistory = ref { saveToHistory({ patientId: checkInResult.patient.barcode, queueNumber: checkInResult.patient.noAntrian, + klinikQueueNumber: checkInResult.patient.noAntrian?.split(" |")[0] || checkInResult.patient.noAntrian, + pembayaran: checkInResult.patient.pembayaran || 'N/A', status: 'success', checkInTime: new Date().toISOString(), checkInDate: new Date().toISOString(), @@ -1971,12 +2026,67 @@ const onDetect = async (decodedText: string) => { infoAction.value = 'checkin'; infoDialog.value = true; } else { + // Simpan ke history dengan status failed jika check-in gagal + const cleanInput = String(patientId).trim().toUpperCase(); + const foundPatient = queueStore.allPatients.find(p => { + if (p.barcode === cleanInput || p.barcode === patientId) return true; + const parsedNo = parseInt(cleanInput.replace(/[^0-9]/g, '')) || parseInt(patientId); + if (!isNaN(parsedNo) && p.no === parsedNo) return true; + const noAntrianUpper = (p.noAntrian || '').toUpperCase(); + if (noAntrianUpper.includes(cleanInput) || noAntrianUpper.includes(patientId)) return true; + return false; + }); + + saveToHistory({ + patientId: patientId, + queueNumber: foundPatient?.noAntrian || null, + klinikQueueNumber: foundPatient?.noAntrian?.split(" |")[0] || null, + pembayaran: foundPatient?.pembayaran || 'N/A', + status: 'failed', + checkInTime: new Date().toISOString(), + checkInDate: new Date().toISOString(), + method: 'QR Scan' + }); + infoMessage.value = `❌ Check-in Gagal!\n\n${checkInResult.message}`; infoAction.value = 'checkin'; infoDialog.value = true; } } else { - // Jika belum diperbolehkan, tampilkan pesan + // Jika belum diperbolehkan, cari data pasien untuk disimpan ke history + // Cari pasien dari queueStore berdasarkan patientId/barcode + const cleanInput = String(patientId).trim().toUpperCase(); + const foundPatient = queueStore.allPatients.find(p => { + // Exact barcode match + if (p.barcode === cleanInput || p.barcode === patientId) { + return true; + } + // Try parsing as number + const parsedNo = parseInt(cleanInput.replace(/[^0-9]/g, '')) || parseInt(patientId); + if (!isNaN(parsedNo) && p.no === parsedNo) { + return true; + } + // Check if noAntrian includes the input + const noAntrianUpper = (p.noAntrian || '').toUpperCase(); + if (noAntrianUpper.includes(cleanInput) || noAntrianUpper.includes(patientId)) { + return true; + } + return false; + }); + + // Simpan ke history dengan status NOT_ALLOWED + saveToHistory({ + patientId: patientId, + queueNumber: foundPatient?.noAntrian || null, + klinikQueueNumber: foundPatient?.noAntrian?.split(" |")[0] || null, + pembayaran: foundPatient?.pembayaran || 'N/A', + status: 'NOT_ALLOWED', + checkInTime: new Date().toISOString(), + checkInDate: new Date().toISOString(), + method: 'QR Scan' + }); + + // Tampilkan pesan lastCheckInResult.value = { success: false, patientId: patientId, @@ -2001,6 +2111,8 @@ const onDetect = async (decodedText: string) => { saveToHistory({ patientId: checkInResult.patient.barcode, queueNumber: checkInResult.patient.noAntrian, + klinikQueueNumber: checkInResult.patient.noAntrian?.split(" |")[0] || checkInResult.patient.noAntrian, + pembayaran: checkInResult.patient.pembayaran || 'N/A', status: 'success', checkInTime: new Date().toISOString(), checkInDate: new Date().toISOString(), @@ -2011,6 +2123,28 @@ const onDetect = async (decodedText: string) => { infoAction.value = 'checkin'; infoDialog.value = true; } else { + // Simpan ke history dengan status failed jika check-in gagal + const cleanInput = String(decodedText).trim().toUpperCase(); + const foundPatient = queueStore.allPatients.find(p => { + if (p.barcode === cleanInput || p.barcode === decodedText) return true; + const parsedNo = parseInt(cleanInput.replace(/[^0-9]/g, '')) || parseInt(decodedText); + if (!isNaN(parsedNo) && p.no === parsedNo) return true; + const noAntrianUpper = (p.noAntrian || '').toUpperCase(); + if (noAntrianUpper.includes(cleanInput) || noAntrianUpper.includes(decodedText)) return true; + return false; + }); + + saveToHistory({ + patientId: decodedText, + queueNumber: foundPatient?.noAntrian || null, + klinikQueueNumber: foundPatient?.noAntrian?.split(" |")[0] || null, + pembayaran: foundPatient?.pembayaran || 'N/A', + status: 'failed', + checkInTime: new Date().toISOString(), + checkInDate: new Date().toISOString(), + method: 'QR Scan' + }); + infoMessage.value = `❌ Check-in Gagal!\n\n${checkInResult.message}`; infoAction.value = 'checkin'; infoDialog.value = true; @@ -2071,6 +2205,8 @@ const checkInManual = async () => { saveToHistory({ patientId: checkInResult.patient.barcode, queueNumber: checkInResult.patient.noAntrian, + klinikQueueNumber: checkInResult.patient.noAntrian?.split(" |")[0] || checkInResult.patient.noAntrian, + pembayaran: checkInResult.patient.pembayaran || 'N/A', status: 'success', checkInTime: new Date().toISOString(), checkInDate: new Date().toISOString(), @@ -2083,6 +2219,28 @@ const checkInManual = async () => { (manualForm.value as any).reset(); } } else { + // Simpan ke history dengan status failed jika check-in gagal + const cleanInput = String(manualInput.value.trim()).toUpperCase(); + const foundPatient = queueStore.allPatients.find(p => { + if (p.barcode === cleanInput || p.barcode === manualInput.value.trim()) return true; + const parsedNo = parseInt(cleanInput.replace(/[^0-9]/g, '')) || parseInt(manualInput.value.trim()); + if (!isNaN(parsedNo) && p.no === parsedNo) return true; + const noAntrianUpper = (p.noAntrian || '').toUpperCase(); + if (noAntrianUpper.includes(cleanInput) || noAntrianUpper.includes(manualInput.value.trim())) return true; + return false; + }); + + saveToHistory({ + patientId: manualInput.value.trim(), + queueNumber: foundPatient?.noAntrian || null, + klinikQueueNumber: foundPatient?.noAntrian?.split(" |")[0] || null, + pembayaran: foundPatient?.pembayaran || 'N/A', + status: 'failed', + checkInTime: new Date().toISOString(), + checkInDate: new Date().toISOString(), + method: 'Manual' + }); + showSnackbar('Gagal!', checkInResult.message || 'Check-in manual gagal dilakukan. Silakan coba lagi!', 'error', 'mdi-close-circle'); } }; @@ -2283,6 +2441,8 @@ const loadHistory = () => { const saveToHistory = (item: { patientId: string; queueNumber?: string; + klinikQueueNumber?: string; + pembayaran?: string; status: string; checkInTime: string; checkInDate: string; @@ -2313,6 +2473,8 @@ const saveToHistory = (item: { (history: { patientId: string; queueNumber?: string; + klinikQueueNumber?: string; + pembayaran?: string; status: string; checkInTime: string; checkInDate: string; @@ -2464,25 +2626,29 @@ const recentHistory = computed(() => { const getStatusColor = (status: string) => { if (status === 'ALLOWED' || status === 'success') return 'success'; - if (status === 'NOT_ALLOWED' || status === 'failed') return 'error'; + if (status === 'NOT_ALLOWED') return 'warning'; + if (status === 'failed') return 'error'; return 'warning'; }; const getStatusIcon = (status: string) => { if (status === 'ALLOWED' || status === 'success') return 'mdi-check-circle'; - if (status === 'NOT_ALLOWED' || status === 'failed') return 'mdi-close-circle'; + if (status === 'NOT_ALLOWED') return 'mdi-clock-alert'; + if (status === 'failed') return 'mdi-close-circle'; return 'mdi-clock-alert'; }; const getStatusText = (status: string) => { if (status === 'ALLOWED' || status === 'success') return 'Berhasil'; - if (status === 'NOT_ALLOWED' || status === 'failed') return 'Gagal'; + if (status === 'NOT_ALLOWED') return 'Menunggu'; + if (status === 'failed') return 'Gagal'; return 'Pending'; }; const getStatusClass = (status: string) => { if (status === 'ALLOWED' || status === 'success') return 'history-success'; - if (status === 'NOT_ALLOWED' || status === 'failed') return 'history-failed'; + if (status === 'NOT_ALLOWED') return 'history-pending'; + if (status === 'failed') return 'history-failed'; return 'history-pending'; }; @@ -2553,17 +2719,14 @@ const statsCompleted = computed(() => { }); const statsWaiting = computed(() => { - const todayAfterReset = getTodayAfterReset(); + // Ambil data menunggu dari queueStore untuk stage 'loket' + // Menghitung pasien dengan status 'menunggu' (belum dipanggil) atau 'waiting' (sudah dipanggil tapi belum check-in) + const loketPatients = queueStore.getPatientsByStage('loket'); + const menungguPatients = loketPatients.value.menunggu || []; + const waitingPatients = loketPatients.value.waiting || []; - return checkInHistory.value.filter(item => { - const itemDate = new Date(item.checkInDate || item.checkInTime); - const itemDateStr = itemDate.toISOString().split('T')[0]; - - // Only count items from today (after reset time consideration) - if (itemDateStr !== todayAfterReset) return false; - - return item.status === 'NOT_ALLOWED' || item.status === 'pending' || item.status === 'failed'; - }).length; + // Total pasien yang masih menunggu check-in (belum dipanggil + sudah dipanggil tapi belum check-in) + return menungguPatients.length + waitingPatients.length; }); // Load history on mount @@ -2836,6 +2999,7 @@ if (typeof window !== 'undefined') { .history-item-compact { transition: all 0.2s ease; + border-left: 3px solid transparent; } .history-item-compact:hover { @@ -2844,15 +3008,27 @@ if (typeof window !== 'undefined') { } .history-item-compact.history-success { - border-left: 3px solid #4caf50; + border-left-color: #4caf50; + background: rgba(76, 175, 80, 0.03); } .history-item-compact.history-failed { - border-left: 3px solid #f44336; + border-left-color: #f44336; + background: rgba(244, 67, 54, 0.03); } .history-item-compact.history-pending { - border-left: 3px solid #ff9800; + border-left-color: #ff9800; + background: rgba(255, 152, 0, 0.03); +} + +/* Gap utility untuk flex-wrap */ +.gap-1 { + gap: 4px; +} + +.gap-2 { + gap: 8px; } @media (min-width: 960px) { diff --git a/pages/Setting/HakAkses.vue b/pages/Setting/HakAkses.vue index 93288d1..e28341b 100644 --- a/pages/Setting/HakAkses.vue +++ b/pages/Setting/HakAkses.vue @@ -1464,27 +1464,24 @@ const saveItem = async () => { return; } - // If hakAksesMenu is empty or not fetched yet, fetch from backend - const needsFetch = !editedItem.value.hakAksesMenu || - editedItem.value.hakAksesMenu.length === 0 || - !editedItem.value.hakAksesMenu.some(m => m.canAccess || m.canView || m.canAdd || m.canEdit || m.canDelete); + // Check if permissions have been fetched from backend + // If not, show warning and ask user to fetch first + const hasFetchedPermissions = fetchedBackendData.value.length > 0 || + (editedItem.value.hakAksesMenu && + editedItem.value.hakAksesMenu.length > 0 && + editedItem.value.hakAksesMenu.some(m => m.canAccess || m.canView || m.canAdd || m.canEdit || m.canDelete)); - if (needsFetch) { + if (!hasFetchedPermissions) { snackbar.value = { show: true, - message: 'Mengambil permissions dari backend...', - color: 'info', - timeout: 2000, + message: 'Silakan klik tombol "Ambil Data dari Backend API" terlebih dahulu untuk mengambil permissions!', + color: 'warning', + timeout: 5000, }; - - // Fetch permissions from backend before saving - await fetchPermissionsFromBackend(); - - // Wait a bit for the fetch to complete - await new Promise(resolve => setTimeout(resolve, 500)); + return; } - // Ensure hakAksesMenu exists + // Ensure hakAksesMenu exists (fallback to empty template if somehow missing) if (!editedItem.value.hakAksesMenu || editedItem.value.hakAksesMenu.length === 0) { editedItem.value.hakAksesMenu = buildMenuTemplate(navItemsStore.navItems); } diff --git a/server/api/auth/keycloak-login.ts b/server/api/auth/keycloak-login.ts index c2ebf43..3eeb494 100644 --- a/server/api/auth/keycloak-login.ts +++ b/server/api/auth/keycloak-login.ts @@ -37,6 +37,11 @@ export default defineEventHandler(async (event) => { // Build Keycloak authorization URL const redirectUri = `${config.public.authUrl}/api/auth/keycloak-callback` + + // Debug: Log the redirect URI being used + console.log('🔧 AUTH_ORIGIN from config:', config.public.authUrl) + console.log('🔗 Redirect URI being sent to Keycloak:', redirectUri) + const authUrl = new URL(`${config.keycloakIssuer}/protocol/openid-connect/auth`) authUrl.searchParams.set('client_id', config.keycloakClientId) diff --git a/stores/loketStore.js b/stores/loketStore.js index 754b5bb..4ed22b9 100644 --- a/stores/loketStore.js +++ b/stores/loketStore.js @@ -120,6 +120,17 @@ export const useLoketStore = defineStore('loket', () => { key: 'loket-store-state', storage: typeof window !== 'undefined' ? localStorage : undefined, paths: ['loketData'], + serializer: { + deserialize: JSON.parse, + serialize: JSON.stringify, + }, + restore: (value) => { + // Ensure loketData is always an array + if (value && value.loketData && !Array.isArray(value.loketData)) { + value.loketData = []; + } + return value; + }, }, }); diff --git a/stores/masterStore.js b/stores/masterStore.js index 22100cc..e8dc2a3 100644 --- a/stores/masterStore.js +++ b/stores/masterStore.js @@ -103,7 +103,7 @@ export const useMasterStore = defineStore('master', () => { const getKlinikByKode = (kode) => { // Menggunakan getter dari clinicStore - const clinic = clinicStore.getClinicConfigByKode(kode); + const clinic = clinicStore.getClinicByKode(kode); if (clinic) { return { id: clinic.id, diff --git a/stores/navItems1.ts b/stores/navItems1.ts index 68e6bfb..aea8267 100644 --- a/stores/navItems1.ts +++ b/stores/navItems1.ts @@ -38,6 +38,8 @@ const defaultNavItems: NavItem[] = [ { id: 11, name: "Klinik", path: "/anjungan/AntrianKlinik", icon: "mdi-circle-small" }, { id: 12, name: "Klinik Ruang", path: "/anjungan/AntrianKlinikRuang", icon: "mdi-circle-small"}, { id: 13, name: "Penunjang", path: "/anjungan/AntrianPenunjang", icon: "mdi-circle-small"}, + {id: 14, name: "Loket", path: "/anjungan/AntrianLoket", icon: "mdi-circle-small"}, + {id: 14, name: "Antrean Masuk", path: "/anjungan/AntreanMasuk", icon: "mdi-circle-small"}, ], }, diff --git a/stores/queueStore.js b/stores/queueStore.js index e00652b..702e173 100644 --- a/stores/queueStore.js +++ b/stores/queueStore.js @@ -3,10 +3,19 @@ import { defineStore } from 'pinia'; import { ref, computed } from 'vue'; import { useClinicStore } from './clinicStore'; import { usePenunjangStore } from './penunjangStore'; +import { useLoketStore } from './loketStore'; export const useQueueStore = defineStore('queue', () => { const clinicStore = useClinicStore(); const penunjangStore = usePenunjangStore(); + const loketStore = useLoketStore(); + + // Helper function untuk mendapatkan loket default (Loket 1) + const getDefaultLoket = () => { + const allLokets = loketStore.loketData?.value || loketStore.loketData || []; + const loket1 = allLokets.find(l => l.id === 1 || l.no === 1); + return loket1 ? loket1.namaLoket : 'Loket 1'; + }; // Seed data for easy reset during dev const seedPatients = [ { @@ -418,7 +427,7 @@ export const useQueueStore = defineStore('queue', () => { message = `Pasien ${patientCode} di-pending`; break; - case "aktifkan": + case "aktifkan": { const currentStatus = allPatients.value[patientIndex].status; if (currentStatus === "terlambat" || currentStatus === "pending") { // PERBAIKAN: Update dengan cara yang Vue reactive @@ -431,12 +440,39 @@ export const useQueueStore = defineStore('queue', () => { message = `Pasien ${patientCode} tidak dapat diaktifkan (status: ${currentStatus})`; } break; + } - case "proses": + case "proses": { // Ambil data terbaru dari array - currentProcessingPatient.value[adminType] = allPatients.value[patientIndex]; + const patient = allPatients.value[patientIndex]; + + // Jika adminType adalah 'loket', pastikan ada loket assignment + if (adminType === 'loket') { + // Pastikan antrian yang diproses memiliki loket assignment + const currentLoket = patient.loket || getDefaultLoket(); + const currentLoketId = patient.loketId || 1; + + // Update patient dengan loket assignment (jika belum ada) + if (!patient.loket || !patient.loketId) { + const updatedPatient = { + ...patient, + loket: currentLoket, + loketId: currentLoketId + }; + allPatients.value[patientIndex] = updatedPatient; + // Set currentProcessingPatient dengan loket assignment + currentProcessingPatient.value[adminType] = updatedPatient; + } else { + // Jika sudah ada loket assignment, langsung set currentProcessingPatient + currentProcessingPatient.value[adminType] = patient; + } + } else { + // Untuk adminType selain loket, langsung set currentProcessingPatient + currentProcessingPatient.value[adminType] = patient; + } message = `Memproses pasien ${patientCode}`; break; + } } return { success: true, message }; @@ -779,5 +815,16 @@ export const useQueueStore = defineStore('queue', () => { key: 'queue-store-state', storage: typeof window !== 'undefined' ? localStorage : undefined, paths: ['allPatients', 'quotaUsed', 'currentProcessingPatient'], + serializer: { + deserialize: JSON.parse, + serialize: JSON.stringify, + }, + restore: (value) => { + // Ensure allPatients is always an array + if (value && value.allPatients && !Array.isArray(value.allPatients)) { + value.allPatients = []; + } + return value; + }, }, }); \ No newline at end of file