layar baru antrean klinik dan masuk
This commit is contained in:
No files matched your search
+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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user