Merge branch 'Antrean-Code' of https://git.rssa.top/arie.bagus.2905/Web-Antrean into Antrean-Code

This commit is contained in:
bagus-arie05
2026-01-08 12:53:44 +07:00
18 changed files with 3257 additions and 73 deletions

No files matched your search

+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
@@ -24,7 +24,7 @@
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>
@@ -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;
}
+68 -9
View File
@@ -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 = () => {
+3 -1
View File
@@ -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();
});
</script>
+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);
// });
});
+1 -1
View File
@@ -96,7 +96,7 @@ export default defineNuxtConfig({
devServer: {
port: 3000,
host: '0.0.0.0'
host: 'localhost'
},
vite: {
+37 -2
View File
@@ -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"
);
+909
View File
@@ -0,0 +1,909 @@
<!-- pages/Anjungan/AntreanMasuk/[id].vue -->
<template>
<div class="antrian-display-container">
<!-- Header -->
<div class="display-header">
<div class="header-left">
<div class="logo-circle">
<v-icon size="48" color="white">mdi-door-open</v-icon>
</div>
<div class="header-text">
<h1 class="hospital-name">ANTREAN MASUK</h1>
<p class="display-subtitle">RSUD dr. Saiful Anwar Provinsi Jawa Timur</p>
<p v-if="loketData" class="klinik-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 All Called Queues -->
<div class="queue-grid-container">
<!-- Loket Header -->
<div class="loket-header">
<h2 class="loket-title">{{ loketData?.namaLoket || 'LOKET 1' }}</h2>
</div>
<!-- Patient Cards Grid (like AdminLoket) -->
<div v-if="allCalledQueues.length > 0" class="patient-cards-grid">
<v-card
v-for="queue in allCalledQueues"
:key="queue.no"
class="patient-card-display"
elevation="2"
>
<v-card-text class="card-text-content">
<!-- Queue Number - Large -->
<div class="card-content">
<div class="queue-number-large">
{{ queue.noAntrian.split(" |")[0] }}
</div>
<!-- Fast Track Icon -->
<div v-if="queue.fastTrack === 'YA'" class="fast-track-badge">
<v-icon color="warning" size="36" class="fast-track-icon">
mdi-flash
</v-icon>
</div>
<!-- Klinik Info -->
<div class="klinik-info">
<v-chip size="default" variant="outlined" class="klinik-chip-large">
{{ queue.klinik }}
</v-chip>
</div>
</div>
</v-card-text>
</v-card>
</div>
<!-- Empty State -->
<div v-else class="empty-state">
<v-icon size="64" color="grey-lighten-3">mdi-clock-outline</v-icon>
<p class="empty-text">Tidak Ada Antrian yang Dipanggil</p>
</div>
</div>
<!-- Footer Stats -->
<div class="footer-stats-bar">
<div class="stat-item">
<div class="stat-icon stat-icon-total">
<v-icon size="32" color="primary-600">mdi-format-list-numbered</v-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ statistics?.total ?? 0 }}</div>
<div class="stat-label">Total Antrian</div>
</div>
</div>
<div class="stat-divider"></div>
<div class="stat-item">
<div class="stat-icon stat-icon-waiting">
<v-icon size="32" color="primary-600">mdi-clock-alert-outline</v-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ statistics?.waiting ?? 0 }}</div>
<div class="stat-label">Menunggu</div>
</div>
</div>
<div class="stat-divider"></div>
<div class="stat-item">
<div class="stat-icon stat-icon-active">
<v-icon size="32" color="primary-600">mdi-account-check</v-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ statistics?.active ?? 0 }}</div>
<div class="stat-label">Dipanggil untuk Check-in</div>
</div>
</div>
<div class="footer-message">
<v-icon size="24" color="primary-600" class="mr-2">mdi-information</v-icon>
<span>Harap perhatikan nomor tiket Anda yang dipanggil untuk check-in</span>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { useQueueStore } from '@/stores/queueStore'
import { useMasterStore } from '@/stores/masterStore'
import { useRoute } from '#app'
definePageMeta({
layout: false,
})
const route = useRoute()
const queueStore = useQueueStore()
const masterStore = useMasterStore()
const loketId = computed(() => {
const id = route.params.id
// Handle both string and array cases
const idValue = Array.isArray(id) ? id[0] : id
const parsed = parseInt(idValue)
return isNaN(parsed) ? null : parsed
})
const loketData = computed(() => {
const id = loketId.value
if (!id) return null
return masterStore.getLoketById(id) || null
})
const currentTime = ref('')
const currentDate = ref('')
let timeInterval = null
// Get all patients with processStage 'loket'
const loketPatients = computed(() => {
try {
const result = queueStore.getPatientsByStage('loket')
if (result && result.value && Array.isArray(result.value.all)) {
return result.value.all
}
return []
} catch {
return []
}
})
// Filter: Only show tickets from anjungan that are called for check-in
// These are tickets printed from anjungan that have been called by admin loket
// Status berubah dari 'menunggu' menjadi 'waiting' ketika dipanggil oleh admin loket
// Status 'waiting' = sudah dipanggil tapi belum check-in
// Setiap loket menampilkan data pasien yang berbeda berdasarkan loketId
const calledForCheckIn = computed(() => {
const currentLoketId = loketId.value
// Untuk saat ini, hanya loket 1 yang aktif
// Nanti setiap loket akan menampilkan data yang berbeda
if (!currentLoketId || currentLoketId !== 1) {
// Jika bukan loket 1, return empty array (untuk sementara)
return []
}
return loketPatients.value
.filter(p => {
// Must be called (waiting status = sudah dipanggil dari status menunggu)
// and not yet checked in (processStage still 'loket')
const isCalled = p.status === 'waiting' && p.processStage === 'loket'
// Must be from anjungan (onsite registration)
const isFromAnjungan = p.registrationType === 'onsite' ||
(p.noAntrian && p.noAntrian.includes('Onsite'))
// Filter berdasarkan loketId (untuk saat ini hanya loket 1)
// Nanti bisa ditambahkan field loketId di data pasien untuk filter yang lebih spesifik
const isForThisLoket = !p.loketId || p.loketId === currentLoketId
return isCalled && isFromAnjungan && isForThisLoket
})
// Tidak perlu sorting - tampilkan semua sesuai urutan dari store
})
// Removed displayedQueues - tidak diperlukan lagi karena menggunakan allCalledQueues
// Get all called queues (tidak perlu urut, tampilkan semua)
// Maksimal 20 orang yang dipanggil
const allCalledQueues = computed(() => {
try {
// Ensure calledForCheckIn is an array
const queues = Array.isArray(calledForCheckIn.value) ? calledForCheckIn.value : []
// Tampilkan maksimal 20 yang sudah dipanggil
// Tidak perlu sorting, tampilkan sesuai urutan dari store
return queues.slice(0, 20)
} catch {
return []
}
})
const statistics = computed(() => {
// Ensure we always return default values even if data is not ready
try {
const currentLoketId = loketId.value
// Ensure loketPatients is an array
const patients = Array.isArray(loketPatients.value) ? loketPatients.value : []
// Filter tickets from anjungan untuk loket ini
// Untuk saat ini, hanya loket 1 yang aktif
const allAnjunganTickets = patients.filter(p => {
if (!p) return false
const isFromAnjungan = p.registrationType === 'onsite' ||
(p.noAntrian && p.noAntrian.includes('Onsite'))
// Filter berdasarkan loketId (untuk saat ini hanya loket 1)
const isForThisLoket = !p.loketId || p.loketId === currentLoketId
return isFromAnjungan && isForThisLoket
})
// Menunggu = belum dipanggil (status 'menunggu')
const menungguCount = allAnjunganTickets.filter(p => p && p.status === 'menunggu').length
// Dipanggil untuk check-in = sudah dipanggil (status 'waiting')
const calledForCheckInArray = Array.isArray(calledForCheckIn.value) ? calledForCheckIn.value : []
const activeCount = calledForCheckInArray.length
return {
total: allAnjunganTickets.length || 0,
waiting: menungguCount || 0, // Jumlah yang masih menunggu untuk dipanggil
active: activeCount || 0 // Jumlah yang sudah dipanggil dan ditampilkan di layar
}
} catch {
// Return default values if there's any error
return {
total: 0,
waiting: 0,
active: 0
}
}
})
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(() => {
// Wait for stores to be hydrated from persisted state
// Use nextTick to ensure stores are ready
nextTick(() => {
// Redirect to index if loket not found
if (!loketData.value) {
navigateTo('/anjungan/antreanmasuk')
return
}
// Untuk saat ini, hanya loket 1 yang aktif
// Jika mengakses loket selain 1, redirect ke loket 1
if (loketId.value !== 1) {
navigateTo('/anjungan/antreanmasuk/1')
return
}
updateTime()
timeInterval = setInterval(updateTime, 1000)
})
})
onUnmounted(() => {
if (timeInterval) clearInterval(timeInterval)
})
</script>
<style scoped lang="scss">
.antrian-display-container {
background: #FFFFFF;
width: 100vw;
height: 100vh;
min-height: 100vh;
padding: 24px;
font-family: 'Roboto', sans-serif;
overflow: auto;
display: flex;
flex-direction: column;
margin: 0;
box-sizing: border-box;
position: relative;
}
/* Ensure body/html don't cause layout issues */
:deep(body),
:deep(html) {
margin: 0;
padding: 0;
overflow: hidden;
width: 100%;
height: 100%;
}
:deep(#__nuxt) {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
}
/* ========== HEADER ========== */
.display-header {
display: flex;
justify-content: space-between;
align-items: center;
background: linear-gradient(135deg, var(--color-primary-600) 0%, var(--color-primary-700) 100%);
border-radius: 16px;
padding: 24px 40px;
margin-bottom: 20px;
box-shadow: 0 8px 24px rgba(25, 118, 210, 0.3);
flex-shrink: 0;
box-sizing: border-box;
}
.header-left {
display: flex;
align-items: center;
gap: 24px;
}
.logo-circle {
background: rgba(255, 255, 255, 0.15);
width: 80px;
height: 80px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(10px);
}
.header-text {
flex: 1;
}
.hospital-name {
font-size: 48px;
font-weight: 800;
color: var(--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: var(--color-neutral-100);
margin: 6px 0 0 0;
opacity: 0.95;
}
.klinik-info {
font-size: 16px;
font-weight: 500;
color: var(--color-neutral-100);
margin: 4px 0 0 0;
opacity: 0.85;
}
.loket-info {
font-size: 16px;
font-weight: 500;
color: var(--color-neutral-100);
margin: 4px 0 0 0;
opacity: 0.85;
}
.header-right {
display: flex;
align-items: center;
}
.datetime-display {
text-align: right;
}
.time-large {
font-size: 56px;
font-weight: 900;
color: var(--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: var(--color-neutral-100);
margin-top: 8px;
font-weight: 500;
opacity: 0.95;
}
/* ========== QUEUE GRID CONTAINER ========== */
.queue-grid-container {
flex: 1 1 auto;
display: flex;
flex-direction: column;
margin-bottom: 24px;
background: #FFFFFF;
border-radius: 20px;
border: 2px solid var(--color-primary-200);
overflow: auto;
padding: 24px;
min-height: 0;
max-height: calc(100vh - 280px);
box-sizing: border-box;
box-shadow: 0 4px 16px rgba(25, 118, 210, 0.08);
}
.loket-header {
background: linear-gradient(135deg, var(--color-primary-600) 0%, var(--color-primary-700) 100%);
padding: 16px 20px;
text-align: center;
border-radius: 12px;
margin-bottom: 20px;
flex-shrink: 0;
height: 70px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
}
.loket-header .loket-title {
font-size: 36px;
font-weight: 800;
color: #FFFFFF;
margin: 0;
letter-spacing: 0.5px;
text-transform: uppercase;
}
/* Patient Cards Grid - Fixed for 1920x1080, 5 columns */
.patient-cards-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 20px;
flex: 1;
overflow: hidden;
padding: 0;
width: 100%;
box-sizing: border-box;
align-content: start;
height: 100%;
}
.patient-card-display {
position: relative;
border-radius: 16px;
border: 2px solid var(--color-primary-200);
background: #FFFFFF;
transition: all 0.3s ease;
height: 100%;
min-height: 280px;
display: flex;
flex-direction: column;
box-shadow: 0 4px 16px rgba(25, 118, 210, 0.08);
width: 100%;
box-sizing: border-box;
}
.patient-card-display:hover {
box-shadow: 0 8px 24px rgba(25, 118, 210, 0.15);
transform: translateY(-2px);
border-color: var(--color-primary-600);
}
.card-text-content {
padding: 20px !important;
height: 100%;
display: flex;
box-sizing: border-box;
}
.card-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
gap: 12px;
text-align: center;
width: 100%;
}
.queue-number-large {
font-size: 72px;
font-weight: 900;
color: #212121;
letter-spacing: 2px;
line-height: 1;
}
.fast-track-badge {
display: flex;
align-items: center;
justify-content: center;
}
.fast-track-icon {
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.7;
transform: scale(1.1);
}
}
.klinik-info {
margin-top: 8px;
}
.klinik-chip-large {
font-size: 14px;
font-weight: 700;
padding: 6px 14px;
border: 2px solid var(--color-primary-600);
color: var(--color-primary-600);
height: auto;
white-space: nowrap;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
background: rgba(25, 118, 210, 0.05);
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
}
.empty-text {
font-size: 18px;
color: #89939E;
margin-top: 12px;
font-weight: 600;
}
/* ========== FOOTER STATS ========== */
.footer-stats-bar {
background: #FFFFFF;
border: 2px solid var(--color-primary-200);
border-radius: 20px;
padding: 24px 40px;
display: flex !important;
align-items: center;
justify-content: space-between;
gap: 32px;
box-shadow: 0 4px 16px rgba(25, 118, 210, 0.08);
flex: 0 0 100px;
flex-shrink: 0;
flex-grow: 0;
height: 100px;
order: 999;
min-height: 100px;
max-height: 100px;
width: 100%;
max-width: 100%;
box-sizing: border-box;
position: relative;
overflow: visible;
visibility: visible !important;
opacity: 1 !important;
z-index: 10;
margin-top: auto;
}
.stat-item {
display: flex;
align-items: center;
gap: 16px;
flex-shrink: 0;
min-width: fit-content;
width: auto;
height: 64px;
}
.stat-icon {
width: 64px;
height: 64px;
min-width: 64px;
min-height: 64px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.stat-icon-total,
.stat-icon-waiting {
background: var(--color-primary-200);
}
.stat-icon-active {
background: var(--color-primary-200);
}
.stat-info {
text-align: left;
flex-shrink: 0;
min-width: fit-content;
width: auto;
height: 64px;
display: flex;
flex-direction: column;
justify-content: center;
}
.stat-value {
font-size: 42px;
font-weight: 900;
color: #212121;
line-height: 1;
white-space: nowrap;
height: 42px;
display: flex;
align-items: center;
}
.stat-label {
font-size: 15px;
color: #717171;
font-weight: 600;
margin-top: 4px;
height: 18px;
line-height: 18px;
}
.stat-divider {
width: 2px;
height: 64px;
min-width: 2px;
min-height: 64px;
background: var(--color-primary-200);
flex-shrink: 0;
}
.footer-message {
flex: 1;
text-align: right;
font-size: 20px;
font-weight: 600;
color: #4D4D4D;
display: flex;
align-items: center;
justify-content: flex-end;
min-width: 0;
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
height: 64px;
padding-left: 32px;
}
/* ========== RESPONSIVE ========== */
/* Fullscreen layout - no fixed dimensions to prevent breaking on refresh */
@media (max-width: 1366px) {
.antrian-display-container {
padding: 16px;
}
.display-header {
padding: 16px 24px;
}
.logo-circle {
width: 64px;
height: 64px;
}
.logo-circle .v-icon {
font-size: 40px !important;
}
.hospital-name {
font-size: 36px;
}
.display-subtitle {
font-size: 16px;
}
.time-large {
font-size: 48px;
}
.date-small {
font-size: 14px;
}
.hero-call-section {
padding: 24px;
}
.call-label {
font-size: 24px;
}
.call-number {
font-size: 100px;
}
.call-loket {
font-size: 28px;
}
.loket-header-bar {
padding: 20px 24px;
}
.loket-title {
font-size: 28px;
}
.loket-content {
padding: 32px;
}
.current-number {
font-size: 96px;
}
.next-item {
font-size: 24px;
padding: 10px 20px;
}
.footer-stats-bar {
padding: 16px 32px;
}
.stat-icon {
width: 56px;
height: 56px;
}
.stat-icon .v-icon {
font-size: 28px !important;
}
.stat-value {
font-size: 36px;
}
.stat-label {
font-size: 13px;
}
.stat-divider {
height: 50px;
}
.footer-message {
font-size: 18px;
}
}
@media (max-width: 1024px) {
.display-header {
flex-direction: column;
gap: 16px;
text-align: center;
}
.header-left {
flex-direction: column;
gap: 12px;
}
.datetime-display {
text-align: center;
}
.footer-stats-bar {
flex-direction: column;
gap: 16px;
}
.stat-divider {
display: none;
}
.footer-message {
text-align: center;
justify-content: center;
}
}
@media (max-width: 960px) {
.patient-cards-grid {
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 16px;
}
.queue-number-large {
font-size: 64px;
}
.patient-card-display {
min-height: 240px;
}
}
@media (min-width: 961px) and (max-width: 1400px) {
.patient-cards-grid {
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
}
.queue-number-large {
font-size: 80px;
}
}
@media (min-width: 1401px) and (max-width: 1919px) {
.patient-cards-grid {
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
}
.queue-number-large {
font-size: 96px;
}
}
/* Fixed layout for 1920x1080 */
@media (min-width: 1920px) {
.patient-cards-grid {
grid-template-columns: repeat(5, 1fr);
gap: 20px;
}
.queue-number-large {
font-size: 72px;
}
.patient-card-display {
min-height: 280px;
}
}
@media (max-width: 768px) {
.hospital-name {
font-size: 28px;
}
.time-large {
font-size: 36px;
}
.loket-header .loket-title {
font-size: 28px;
}
.queue-grid-container {
padding: 16px;
}
}
/* Prevent text selection */
* {
user-select: none;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
</style>
+348
View File
@@ -0,0 +1,348 @@
<!-- pages/Anjungan/AntreanMasuk/index.vue -->
<template>
<div class="loket-selection-container">
<div class="selection-header">
<div class="header-icon">
<v-icon size="64" color="white">mdi-ticket-account</v-icon>
</div>
<div class="header-content">
<h1 class="main-title">Pilih Loket Antrean</h1>
<p class="subtitle">RSUD dr. Saiful Anwar Provinsi Jawa Timur</p>
</div>
</div>
<div v-if="paginatedLokets.length > 0" class="lokets-grid">
<div
v-for="loket in paginatedLokets"
:key="loket.id"
class="loket-card"
@click="navigateToLoket(loket.id)"
>
<div class="loket-card-header">
<v-icon size="32" color="primary">mdi-counter</v-icon>
<div class="loket-info">
<h3 class="loket-name">{{ loket.namaLoket }}</h3>
<p class="loket-details">No. {{ loket.no }}</p>
</div>
</div>
<div class="loket-preview">
<div class="loket-service-count">
<v-icon size="18" color="primary">mdi-hospital-building</v-icon>
<span>{{ loket.pelayanan?.length || 0 }} Pelayanan</span>
</div>
<div class="loket-tags">
<v-chip
v-for="(pelayanan, idx) in (loket.pelayanan || []).slice(0, 3)"
:key="idx"
size="small"
class="ma-1 chip-preview"
>
{{ pelayanan }}
</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 class="loket-card-footer">
<v-btn
color="primary-600"
variant="flat"
size="large"
block
class="btn-view"
>
<v-icon left>mdi-eye</v-icon>
Tampilkan
</v-btn>
</div>
</div>
</div>
<div v-else class="empty-state">
<v-icon size="64" color="grey-lighten-1">mdi-counter-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 v-if="paginatedLokets.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>
</template>
<script setup>
import { computed } from 'vue';
import { useMasterStore } from '@/stores/masterStore';
import { useRoute } from '#app';
definePageMeta({
layout: false,
});
const masterStore = useMasterStore();
const route = useRoute();
// Get all lokets
// Untuk saat ini, hanya menampilkan loket 1
// Nanti setiap loket akan menampilkan data yang berbeda
const allLokets = computed(() => {
const allLoketsData = masterStore.loketData?.value || masterStore.loketData || [];
// Filter hanya loket 1 untuk saat ini
return allLoketsData.filter(loket => loket.id === 1 || loket.no === 1);
});
// 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(allLokets.value.length / itemsPerPage)));
const paginatedLokets = computed(() => {
const start = (page.value - 1) * itemsPerPage;
return allLokets.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 navigateToLoket = (loketId) => {
navigateTo(`/anjungan/antreanmasuk/${loketId}`);
};
const navigateToSettings = () => {
navigateTo('/setting/masterloket');
};
</script>
<style scoped lang="scss">
.loket-selection-container {
background: var(--color-neutral-300);
min-height: 100vh;
padding: 40px;
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;
display: flex;
align-items: center;
gap: 32px;
box-shadow: 0 8px 24px rgba(25, 118, 210, 0.3);
}
.header-icon {
background: rgba(255, 255, 255, 0.15);
border-radius: 20px;
padding: 24px;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(10px);
}
.header-content {
flex: 1;
color: var(--color-neutral-100);
}
.main-title {
font-size: 48px;
font-weight: 800;
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);
}
.subtitle {
font-size: 20px;
font-weight: 600;
margin: 8px 0 0 0;
color: var(--color-neutral-100);
opacity: 0.95;
}
.lokets-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 24px;
max-width: 1400px;
margin: 0 auto;
}
.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(25, 118, 210, 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-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;
}
.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: 20px;
}
.selection-header {
flex-direction: column;
text-align: center;
padding: 32px 24px;
}
.main-title {
font-size: 32px;
}
.subtitle {
font-size: 16px;
}
.lokets-grid {
grid-template-columns: 1fr;
gap: 16px;
}
}
</style>
+876
View File
@@ -0,0 +1,876 @@
<!-- pages/Anjungan/AntrianLoket/index.vue -->
<template>
<div class="antrian-display-container">
<!-- Header -->
<div class="display-header">
<div class="header-left">
<div class="logo-circle">
<v-icon size="48" color="white">mdi-view-dashboard</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="klinik-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>
<!-- Hero Call Section - Highlight card untuk nomor antrian yang dipanggil -->
<div v-if="currentCalledQueue" class="hero-call-section">
<div class="call-label">
<v-icon size="32" color="white" class="mr-2">mdi-bullhorn</v-icon>
NOMOR ANTRIAN YANG DIPANGGIL
</div>
<div class="call-number">{{ currentCalledQueue.noAntrian.split(' |')[0] }}</div>
<div class="call-loket">{{ currentCalledQueue.loket || 'Loket' }}</div>
</div>
<!-- Main Grid - Display Loket -->
<div class="lokets-grid">
<div
v-for="loket in displayedLokets"
:key="loket.id"
class="loket-box"
>
<!-- Loket Header -->
<div class="loket-header-bar">
<span class="loket-title">{{ loket.namaLoket }}</span>
<v-chip size="small" class="loket-count">
<v-icon size="16" color="white" class="mr-1">mdi-account-multiple</v-icon>
<span class="count-text">{{ loket.totalQueues }}</span>
</v-chip>
</div>
<!-- Queue Content -->
<div class="loket-content">
<!-- Current Queue -->
<div v-if="loket.currentQueue" class="current-section">
<div class="current-label">SEKARANG</div>
<div
class="current-number"
:class="{ 'highlight-called': isCalled(loket.currentQueue) }"
>
{{ loket.currentQueue.noAntrian.split(' |')[0] }}
</div>
</div>
<!-- Empty State -->
<div v-if="!loket.currentQueue" 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>
<!-- Footer Stats -->
<div class="footer-stats-bar">
<div class="stat-item">
<div class="stat-icon stat-icon-total">
<v-icon size="32" color="success-600">mdi-format-list-numbered</v-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ statistics.total }}</div>
<div class="stat-label">Total Antrian</div>
</div>
</div>
<div class="stat-divider"></div>
<div class="stat-item">
<div class="stat-icon stat-icon-active">
<v-icon size="32" color="success-600">mdi-account-check</v-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ statistics.active }}</div>
<div class="stat-label">Sedang Dilayani</div>
</div>
</div>
<div class="footer-message">
<v-icon size="24" color="success-600" class="mr-2">mdi-information</v-icon>
<span>Harap perhatikan nomor antrian Anda di layar</span>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useQueueStore } from '@/stores/queueStore'
import { useLoketStore } from '@/stores/loketStore'
import { useRoute } from '#app'
definePageMeta({
layout: false,
})
const route = useRoute()
const queueStore = useQueueStore()
const loketStore = useLoketStore()
const currentTime = ref('')
const currentDate = ref('')
let timeInterval = null
// Get loket ID from route
const loketId = computed(() => {
const id = route.params.id
const idValue = Array.isArray(id) ? id[0] : id
const parsed = parseInt(idValue)
return isNaN(parsed) ? null : parsed
})
// Get loket data for this ID
const loketData = computed(() => {
const id = loketId.value
if (!id) return null
return loketStore.getLoketById(id) || null
})
// Get current processing patient dari AdminLoket
const currentProcessingPatient = computed(() => {
return queueStore.currentProcessingPatient?.loket || null
})
// Get all patients with processStage "loket" and filter status "di-loket" dan "pending"
const loketPatients = computed(() => {
const allPatients = queueStore.getPatientsByStage('loket').value.all
// Tampilkan antrian dengan status "di-loket" dan "pending"
return allPatients.filter(p => p.status === 'di-loket' || p.status === 'pending')
})
// Get all lokets from loketStore
const allLokets = computed(() => {
return loketStore.loketData?.value || loketStore.loketData || []
})
// Helper function untuk mendapatkan loket default
const getDefaultLoket = () => {
const allLoketsData = loketStore.loketData?.value || loketStore.loketData || []
const loket1 = allLoketsData.find(l => l.id === 1 || l.no === 1)
return loket1 ? loket1.namaLoket : 'Loket 1'
}
// Display lokets with their queues
const displayedLokets = computed(() => {
if (allLokets.value.length === 0) {
return []
}
// Include currentProcessingPatient dalam list jika ada dan status "di-loket" atau "pending"
let allPatientsForDistribution = [...loketPatients.value]
// Jika ada antrian yang sedang diproses, tambahkan ke list distribusi
if (currentProcessingPatient.value && (currentProcessingPatient.value.status === 'di-loket' || currentProcessingPatient.value.status === 'pending')) {
// Pastikan currentProcessingPatient ada di list (jika belum ada)
const existsInList = allPatientsForDistribution.find(p => p.no === currentProcessingPatient.value.no)
if (!existsInList) {
allPatientsForDistribution.push(currentProcessingPatient.value)
}
}
// Sort all di-loket patients by jamPanggil (terlama dulu untuk prioritas distribusi)
const sortedPatients = allPatientsForDistribution.sort((a, b) => {
const timeA = (a.jamPanggil || '00:00').split(':').map(Number)
const timeB = (b.jamPanggil || '00:00').split(':').map(Number)
return timeA[0] * 60 + timeA[1] - (timeB[0] * 60 + timeB[1])
})
// Inisialisasi loket dengan currentQueue null
const loketAssignments = allLokets.value
.sort((a, b) => a.no - b.no) // Urutkan berdasarkan nomor loket
.map(loket => ({
id: loket.id,
namaLoket: loket.namaLoket,
no: loket.no,
currentQueue: null
}))
// Prioritas 1: Assign antrian yang sedang diproses (currentProcessingPatient) ke loket yang sesuai
// Antrian yang sedang diproses HARUS muncul di loket yang dituju
if (currentProcessingPatient.value && (currentProcessingPatient.value.status === 'di-loket' || currentProcessingPatient.value.status === 'pending')) {
const processingPatient = currentProcessingPatient.value
const targetLoketId = processingPatient.loketId || 1
const targetLoketName = processingPatient.loket || getDefaultLoket()
// Cari loket target berdasarkan loketId atau loket name
const targetLoket = loketAssignments.find(l =>
l.id === targetLoketId ||
l.no === targetLoketId ||
l.namaLoket === targetLoketName ||
l.namaLoket.toLowerCase() === targetLoketName.toLowerCase()
)
if (targetLoket) {
// Assign antrian yang sedang diproses sebagai prioritas tertinggi
// Ini akan menggantikan antrian lain yang ada di loket ini
targetLoket.currentQueue = processingPatient
}
}
// Distribusi antrian ke loket: setiap antrian hanya di-assign ke satu loket
// Prioritas: loket yang kosong dulu, lalu round-robin jika semua sudah terisi
const usedPatients = new Set()
// Jika ada antrian yang sedang diproses, mark sebagai used
if (currentProcessingPatient.value) {
usedPatients.add(currentProcessingPatient.value.no)
}
sortedPatients.forEach(patient => {
// Skip jika patient sudah di-assign atau sedang diproses
if (usedPatients.has(patient.no)) {
return
}
// Jika patient sudah punya loket assignment, gunakan itu
if (patient.loket || patient.loketId) {
const loketName = patient.loket || patient.loketId
const targetLoketId = patient.loketId || 1
const assignedLoket = loketAssignments.find(l =>
l.id === targetLoketId ||
l.no === targetLoketId ||
l.namaLoket === loketName ||
`Loket ${l.no}` === loketName ||
l.namaLoket.toLowerCase() === loketName.toLowerCase()
)
// Jika loket sudah punya antrian lain (bukan yang sedang diproses), skip
// Jika loket kosong, assign antrian ke loket tersebut
if (assignedLoket && !assignedLoket.currentQueue) {
assignedLoket.currentQueue = patient
usedPatients.add(patient.no)
}
} else {
// Cari loket yang kosong (belum ada currentQueue)
let emptyLoket = loketAssignments.find(l => !l.currentQueue)
if (emptyLoket) {
// Assign antrian ke loket yang kosong
emptyLoket.currentQueue = patient
usedPatients.add(patient.no)
}
// Jika semua loket sudah terisi, antrian ini tidak akan ditampilkan
}
})
// Map ke format yang diharapkan
return loketAssignments.map(loket => ({
id: loket.id,
namaLoket: loket.namaLoket,
currentQueue: loket.currentQueue,
totalQueues: loket.currentQueue ? 1 : 0
}))
})
// Current called queue - antrian yang sedang diproses atau paling baru dipanggil
const currentCalledQueue = computed(() => {
// Prioritas 1: Antrian yang sedang diproses di AdminLoket (currentProcessingPatient)
if (currentProcessingPatient.value && (currentProcessingPatient.value.status === 'di-loket' || currentProcessingPatient.value.status === 'pending')) {
// Cari loket yang dituju dari assignment antrian
const loketInfo = displayedLokets.value.find(l =>
l.currentQueue && l.currentQueue.no === currentProcessingPatient.value.no
) || allLokets.value.find(l =>
(l.id === currentProcessingPatient.value.loketId) ||
(l.namaLoket === currentProcessingPatient.value.loket)
)
return {
...currentProcessingPatient.value,
loket: loketInfo ? loketInfo.namaLoket : (currentProcessingPatient.value.loket || 'Loket')
}
}
// Prioritas 2: Antrian yang paling baru dipanggil dari displayedLokets
const allCurrentQueues = displayedLokets.value
.filter(loket => loket.currentQueue)
.map(loket => ({
...loket.currentQueue,
loketNama: loket.namaLoket
}))
.sort((a, b) => {
// Sort berdasarkan createdAt atau jamPanggil terbaru
const dateA = new Date(a.createdAt || a.jamPanggil || 0)
const dateB = new Date(b.createdAt || b.jamPanggil || 0)
return dateB - dateA
})
if (allCurrentQueues.length > 0) {
const patient = allCurrentQueues[0]
return {
...patient,
loket: patient.loketNama || patient.loket || 'Loket'
}
}
// Jika tidak ada antrian dipanggil/diproses, return null (hero section dikosongkan)
return null
})
// Check if a queue is currently called (highlighted)
const isCalled = (queue) => {
if (!currentCalledQueue.value || !queue) return false
return currentCalledQueue.value.no === queue.no
}
// Statistics
const statistics = computed(() => {
const all = loketPatients.value
return {
total: all.length,
active: all.length // Semua sudah di-loket berarti sedang dilayani
}
})
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(() => {
// Redirect to index if loket not found
if (!loketData.value) {
navigateTo('/anjungan/antrianloket')
return
}
updateTime()
timeInterval = setInterval(updateTime, 1000)
})
onUnmounted(() => {
if (timeInterval) clearInterval(timeInterval)
})
</script>
<style scoped lang="scss">
.antrian-display-container {
background: var(--color-neutral-300);
min-height: 100vh;
max-width: 1920px;
max-height: 1080px;
width: 100vw;
height: 100vh;
padding: 24px;
font-family: 'Inter', 'Roboto', sans-serif;
overflow: hidden;
display: flex;
flex-direction: column;
margin: 0 auto;
}
/* ========== HEADER ========== */
.display-header {
display: flex;
justify-content: space-between;
align-items: center;
background: linear-gradient(135deg, var(--color-primary-600) 0%, var(--color-primary-700) 100%);
border-radius: 16px;
padding: 24px 40px;
margin-bottom: 20px;
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.15);
width: 80px;
height: 80px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(10px);
}
.hospital-name {
font-size: 48px;
font-weight: 800;
color: var(--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: var(--color-neutral-100);
margin: 6px 0 0 0;
opacity: 0.95;
}
.klinik-info {
font-size: 16px;
font-weight: 500;
color: var(--color-neutral-100);
margin: 4px 0 0 0;
opacity: 0.85;
}
.datetime-display {
text-align: right;
}
.time-large {
font-size: 56px;
font-weight: 900;
color: var(--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: var(--color-neutral-100);
margin-top: 8px;
font-weight: 500;
opacity: 0.95;
}
/* ========== HERO CALL SECTION ========== */
.hero-call-section {
background: linear-gradient(135deg, var(--color-danger-600) 0%, var(--color-danger-700) 100%);
border-radius: 20px;
padding: 32px;
margin-bottom: 20px;
text-align: center;
box-shadow: 0 12px 32px rgba(224, 21, 7, 0.35);
animation: pulse-highlight 2s ease-in-out infinite;
}
@keyframes pulse-highlight {
0%, 100% {
box-shadow: 0 12px 32px rgba(224, 21, 7, 0.35);
}
50% {
box-shadow: 0 12px 48px rgba(224, 21, 7, 0.55);
}
}
.call-label {
font-size: 28px;
font-weight: 700;
color: var(--color-neutral-100);
letter-spacing: 2px;
margin-bottom: 12px;
display: flex;
align-items: center;
justify-content: center;
text-transform: uppercase;
}
.call-number {
font-size: 140px;
font-weight: 900;
color: var(--color-neutral-100);
margin: 16px 0;
letter-spacing: 8px;
line-height: 1;
text-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
animation: number-pulse 1.5s ease-in-out infinite;
}
@keyframes number-pulse {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
}
.call-loket {
font-size: 36px;
font-weight: 700;
color: var(--color-neutral-100);
letter-spacing: 1px;
}
/* ========== LOKETS GRID ========== */
.lokets-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-bottom: 20px;
flex: 1;
}
.loket-box {
background: var(--color-neutral-100);
border: 2px solid var(--color-primary-200);
border-radius: 16px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(33, 150, 243, 0.12);
min-height: 240px;
display: flex;
flex-direction: column;
}
.loket-header-bar {
background: linear-gradient(135deg, var(--color-primary-600) 0%, var(--color-primary-700) 100%);
padding: 16px 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.loket-title {
font-size: 22px;
font-weight: 800;
color: var(--color-neutral-100);
letter-spacing: 0.5px;
text-transform: uppercase;
line-height: 1.2;
flex: 1;
}
.loket-count {
background: rgba(255, 255, 255, 0.2) !important;
color: var(--color-neutral-100) !important;
font-weight: 700;
backdrop-filter: blur(10px);
display: flex;
align-items: center;
gap: 4px;
}
.count-text {
font-size: 18px;
font-weight: 800;
}
.loket-content {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
padding: 20px;
background: var(--color-primary-100);
}
.current-section {
text-align: center;
margin-bottom: 12px;
}
.current-label {
font-size: 16px;
font-weight: 700;
color: var(--color-primary-600);
margin-bottom: 8px;
letter-spacing: 1px;
text-transform: uppercase;
}
.current-number {
font-size: 80px;
font-weight: 900;
color: var(--color-neutral-900);
letter-spacing: 4px;
line-height: 1;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
&.highlight-called {
color: var(--color-danger-600);
animation: highlight-flash 1s ease-in-out infinite;
transform: scale(1.1);
}
}
@keyframes highlight-flash {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
.empty-state {
text-align: center;
padding: 32px 0;
}
.empty-text {
font-size: 16px;
color: var(--color-neutral-600);
margin-top: 12px;
font-weight: 600;
}
/* ========== FOOTER STATS ========== */
.footer-stats-bar {
background: var(--color-neutral-100);
border: 2px solid var(--color-primary-200);
border-radius: 16px;
padding: 20px 40px;
display: flex;
align-items: center;
gap: 32px;
box-shadow: 0 4px 12px rgba(33, 150, 243, 0.12);
}
.stat-item {
display: flex;
align-items: center;
gap: 16px;
}
.stat-icon {
width: 64px;
height: 64px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
}
.stat-icon-total,
.stat-icon-active {
background: var(--color-primary-200);
}
.stat-info {
text-align: left;
}
.stat-value {
font-size: 40px;
font-weight: 900;
color: var(--color-neutral-900);
line-height: 1;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.stat-label {
font-size: 14px;
color: var(--color-neutral-700);
font-weight: 600;
margin-top: 4px;
opacity: 0.95;
}
.stat-divider {
width: 2px;
height: 60px;
background: var(--color-primary-200);
}
.footer-message {
flex: 1;
text-align: right;
font-size: 20px;
font-weight: 600;
color: var(--color-neutral-800);
display: flex;
align-items: center;
justify-content: flex-end;
}
/* ========== RESPONSIVE ========== */
@media (min-width: 1920px) and (min-height: 1080px) {
.antrian-display-container {
width: 1920px;
height: 1080px;
padding: 24px;
}
}
@media (max-width: 1366px) {
.antrian-display-container {
padding: 16px;
}
.display-header {
padding: 16px 24px;
}
.logo-circle {
width: 64px;
height: 64px;
}
.logo-circle .v-icon {
font-size: 40px !important;
}
.hospital-name {
font-size: 36px;
}
.display-subtitle {
font-size: 16px;
}
.time-large {
font-size: 48px;
}
.date-small {
font-size: 14px;
}
.hero-call-section {
padding: 24px;
}
.call-label {
font-size: 24px;
}
.call-number {
font-size: 100px;
}
.call-loket {
font-size: 28px;
}
.lokets-grid {
gap: 12px;
}
.loket-box {
min-height: 220px;
padding: 0;
}
.loket-header-bar {
padding: 14px 16px;
}
.loket-title {
font-size: 20px;
}
.loket-content {
padding: 16px;
}
.current-number {
font-size: 72px;
}
.footer-stats-bar {
padding: 16px 32px;
}
.stat-icon {
width: 56px;
height: 56px;
}
.stat-icon .v-icon {
font-size: 28px !important;
}
.stat-value {
font-size: 36px;
}
.stat-label {
font-size: 13px;
}
.stat-divider {
height: 50px;
}
.footer-message {
font-size: 18px;
}
}
@media (max-width: 1024px) {
.display-header {
flex-direction: column;
gap: 16px;
text-align: center;
}
.header-left {
flex-direction: column;
gap: 12px;
}
.datetime-display {
text-align: center;
}
.lokets-grid {
grid-template-columns: repeat(2, 1fr);
}
.footer-stats-bar {
flex-direction: column;
gap: 16px;
}
.stat-divider {
display: none;
}
.footer-message {
text-align: center;
justify-content: center;
}
}
@media (max-width: 768px) {
.lokets-grid {
grid-template-columns: 1fr;
}
.hospital-name {
font-size: 28px;
}
.time-large {
font-size: 36px;
}
.call-number {
font-size: 80px;
}
.current-number {
font-size: 60px;
}
}
/* Prevent text selection */
* {
user-select: none;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
</style>
+344
View File
@@ -0,0 +1,344 @@
<!-- pages/Anjungan/AntrianLoket/index.vue -->
<template>
<div class="loket-selection-container">
<div class="selection-header">
<div class="header-icon">
<v-icon size="64" color="white">mdi-view-dashboard</v-icon>
</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>
<div v-if="paginatedLokets.length > 0" class="lokets-grid">
<div
v-for="loket in paginatedLokets"
:key="loket.id"
class="loket-card"
@click="navigateToLoket(loket.id)"
>
<div class="loket-card-header">
<v-icon size="32" color="primary-600">mdi-view-dashboard</v-icon>
<div class="loket-info">
<h3 class="loket-name">{{ loket.namaLoket }}</h3>
<p class="loket-details">No. {{ loket.no }}</p>
</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 || 0 }} Pelayanan</span>
</div>
<div class="loket-tags">
<v-chip
v-for="(pelayanan, idx) in (loket.pelayanan || []).slice(0, 3)"
:key="idx"
size="small"
class="ma-1 chip-preview"
>
{{ pelayanan }}
</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 class="loket-card-footer">
<v-btn
color="primary-600"
variant="flat"
size="large"
block
class="btn-view"
>
<v-icon left>mdi-eye</v-icon>
Tampilkan
</v-btn>
</div>
</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 v-if="paginatedLokets.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>
</template>
<script setup>
import { computed } from 'vue';
import { useLoketStore } from '@/stores/loketStore';
import { useRoute } from '#app';
definePageMeta({
layout: false,
});
const loketStore = useLoketStore();
const route = useRoute();
// Get all lokets
const allLokets = computed(() => {
return loketStore.loketData?.value || loketStore.loketData || [];
});
// 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(allLokets.value.length / itemsPerPage)));
const paginatedLokets = computed(() => {
const start = (page.value - 1) * itemsPerPage;
return allLokets.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 navigateToLoket = (loketId) => {
navigateTo(`/anjungan/antrianloket/${loketId}`);
};
const navigateToSettings = () => {
navigateTo('/setting/masterloket');
};
</script>
<style scoped lang="scss">
.loket-selection-container {
background: var(--color-neutral-300);
min-height: 100vh;
padding: 40px;
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;
display: flex;
align-items: center;
gap: 32px;
box-shadow: 0 8px 24px rgba(33, 150, 243, 0.3);
}
.header-icon {
background: rgba(255, 255, 255, 0.15);
border-radius: 20px;
padding: 24px;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(10px);
}
.header-content {
flex: 1;
color: var(--color-neutral-100);
}
.main-title {
font-size: 48px;
font-weight: 800;
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);
}
.subtitle {
font-size: 20px;
font-weight: 600;
margin: 8px 0 0 0;
color: var(--color-neutral-100);
opacity: 0.95;
}
.lokets-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 24px;
max-width: 1400px;
margin: 0 auto;
}
.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-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;
}
.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: 20px;
}
.selection-header {
flex-direction: column;
text-align: center;
padding: 32px 24px;
}
.main-title {
font-size: 32px;
}
.subtitle {
font-size: 16px;
}
.lokets-grid {
grid-template-columns: 1fr;
gap: 16px;
}
}
</style>
+208 -32
View File
@@ -315,11 +315,12 @@
<v-card-text class="pa-3">
<div class="d-flex justify-space-between align-start">
<div class="flex-grow-1">
<div class="d-flex align-center mb-1">
<!-- Status & Method Chips -->
<div class="d-flex align-center flex-wrap gap-1 mb-2">
<v-chip
:color="getStatusColor(item.status)"
size="x-small"
class="mr-2"
density="compact"
>
<v-icon start size="12">{{ getStatusIcon(item.status) }}</v-icon>
{{ getStatusText(item.status) }}
@@ -328,22 +329,51 @@
color="grey-lighten-1"
size="x-small"
variant="text"
density="compact"
>
{{ item.method }}
</v-chip>
</div>
<p class="text-body-2 font-weight-medium mb-1">
<v-icon size="14" class="mr-1" :color="primaryColor">mdi-account-circle</v-icon>
{{ item.patientId }}
</p>
<!-- Patient ID -->
<div class="mb-2">
<p class="text-body-2 font-weight-medium mb-0">
<v-icon size="14" class="mr-1" :color="primaryColor">mdi-account-circle</v-icon>
{{ item.patientId }}
</p>
</div>
<!-- Antrean & Pembayaran Chips -->
<div class="d-flex align-center flex-wrap gap-1 mb-2">
<v-chip
v-if="item.klinikQueueNumber"
size="x-small"
:color="secondaryColor"
variant="tonal"
density="compact"
>
<v-icon start size="12">mdi-ticket</v-icon>
{{ item.klinikQueueNumber }}
</v-chip>
<v-chip
v-if="item.pembayaran"
size="x-small"
:color="primaryColor"
variant="tonal"
density="compact"
>
<v-icon start size="12">mdi-credit-card</v-icon>
{{ item.pembayaran }}
</v-chip>
</div>
<!-- Time -->
<div class="d-flex align-center">
<v-chip
size="x-small"
variant="text"
color="grey-darken-1"
class="text-caption"
density="compact"
>
<v-icon start size="12">mdi-clock-outline</v-icon>
{{ formatTime(item.checkInTime) }}
@@ -857,15 +887,36 @@
</v-chip>
</div>
<div class="mb-2">
<p class="text-body-1 font-weight-bold mb-1">
<!-- Patient Info -->
<div class="mb-3">
<p class="text-body-1 font-weight-bold mb-2">
<v-icon size="18" class="mr-1" :color="primaryColor">mdi-account-circle</v-icon>
ID Pasien: {{ item.patientId }}
</p>
<p v-if="item.queueNumber" class="text-body-2 text-grey mb-1">
<v-icon size="16" class="mr-1">mdi-ticket</v-icon>
Nomor Antrean: {{ item.queueNumber }}
{{ item.patientId }}
</p>
<!-- Antrean & Pembayaran Info Grid -->
<div class="d-flex flex-wrap gap-2 mb-2">
<v-chip
v-if="item.klinikQueueNumber"
size="small"
:color="secondaryColor"
variant="tonal"
density="comfortable"
>
<v-icon start size="16">mdi-ticket</v-icon>
{{ item.klinikQueueNumber }}
</v-chip>
<v-chip
v-if="item.pembayaran"
size="small"
:color="primaryColor"
variant="tonal"
density="comfortable"
>
<v-icon start size="16">mdi-credit-card</v-icon>
{{ item.pembayaran }}
</v-chip>
</div>
</div>
<div class="d-flex flex-wrap gap-2">
@@ -1132,6 +1183,8 @@ const historyStatusFilter = ref('');
const checkInHistory = ref<Array<{
patientId: string;
queueNumber?: string;
klinikQueueNumber?: string;
pembayaran?: string;
status: string;
checkInTime: string;
checkInDate: string;
@@ -1959,6 +2012,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(),
@@ -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) {
+12 -15
View File
@@ -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);
}
+5
View File
@@ -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)
+11
View File
@@ -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;
},
},
});
+1 -1
View File
@@ -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,
+6 -4
View File
@@ -35,10 +35,12 @@ const defaultNavItems: NavItem[] = [
icon: "mdi-monitor-multiple",
path: "",
children: [
{ id: 11, name: "Anjungan", path: "/anjungan/anjungan", icon: "mdi-circle-small" },
{ id: 12, name: "Klinik", path: "/anjungan/AntrianKlinik", icon: "mdi-circle-small" },
{ id: 13, name: "Klinik Ruang", path: "/anjungan/AntrianKlinikRuang", icon: "mdi-circle-small"},
{ id: 14, name: "Penunjang", path: "/anjungan/AntrianPenunjang", icon: "mdi-circle-small"},
{ id: 10, name: "Anjungan", path: "/anjungan/anjungan", icon: "mdi-circle-small" },
{ id: 11, name: "Klinik", path: "/anjungan/AntrianKlinik", icon: "mdi-circle-small" },
{ id: 12, name: "Klinik Ruang", path: "/anjungan/AntrianKlinikRuang", icon: "mdi-circle-small"},
{ id: 13, name: "Penunjang", path: "/anjungan/AntrianPenunjang", icon: "mdi-circle-small"},
{id: 14, name: "Loket", path: "/anjungan/AntrianLoket", icon: "mdi-circle-small"},
{id: 15, name: "Antrean Masuk", path: "/anjungan/AntreanMasuk", icon: "mdi-circle-small"},
],
},
+50 -3
View File
@@ -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 };
@@ -1000,5 +1036,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;
},
},
});