Merge branch 'Antrean-Code' of https://git.rssa.top/arie.bagus.2905/Web-Antrean into Antrean-Code
This commit is contained in:
No files matched your search
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -96,7 +96,7 @@ export default defineNuxtConfig({
|
||||
|
||||
devServer: {
|
||||
port: 3000,
|
||||
host: '0.0.0.0'
|
||||
host: 'localhost'
|
||||
},
|
||||
|
||||
vite: {
|
||||
|
||||
+37
-2
@@ -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"
|
||||
);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user