layar baru antrean klinik dan masuk

This commit is contained in:
Fanrouver
2026-01-08 12:44:40 +07:00
parent 96b10b9e75
commit 2d3d589fe6
18 changed files with 3253 additions and 69 deletions

No files matched your search

+37 -2
View File
@@ -447,12 +447,47 @@ const closeKlinikRuangDialog = () => {
};
const buatAntreanKlinikRuang = (klinikRuang, ruang) => {
if (!currentProcessingPatient.value) return;
// Pastikan currentProcessingPatient valid, jika tidak, coba dapatkan dari store
let patient = currentProcessingPatient.value;
if (!patient) {
try {
// Coba dapatkan pasien yang sedang diproses dari store
const processingPatient = queueStore.currentProcessingPatient?.loket;
if (processingPatient) {
// Dapatkan data terbaru dari allPatients langsung (bukan dari getPatientsByStage yang sudah difilter)
// allPatients adalah ref yang di-export dari Pinia store, bisa diakses langsung atau dengan .value
let allPatients = [];
if (queueStore.allPatients) {
// Jika allPatients adalah ref (punya .value)
allPatients = Array.isArray(queueStore.allPatients)
? queueStore.allPatients
: (queueStore.allPatients.value || []);
}
const latestPatient = allPatients.find(
p => (p && p.no === processingPatient.no) ||
(p && p.barcode && p.barcode === processingPatient.barcode) ||
(p && p.noAntrian && p.noAntrian === processingPatient.noAntrian)
);
patient = latestPatient || processingPatient;
}
} catch (error) {
console.error('Error getting patient from store:', error);
}
}
if (!patient) {
snackbarText.value = "Tidak ada pasien yang sedang diproses";
snackbarColor.value = "error";
snackbar.value = true;
closeKlinikRuangDialog();
return;
}
const result = queueStore.createAntreanKlinikRuang(
klinikRuang,
ruang,
currentProcessingPatient.value,
patient,
"loket"
);
+909
View File
@@ -0,0 +1,909 @@
<!-- pages/Anjungan/AntreanMasuk/[id].vue -->
<template>
<div class="antrian-display-container">
<!-- Header -->
<div class="display-header">
<div class="header-left">
<div class="logo-circle">
<v-icon size="48" color="white">mdi-door-open</v-icon>
</div>
<div class="header-text">
<h1 class="hospital-name">ANTREAN MASUK</h1>
<p class="display-subtitle">RSUD dr. Saiful Anwar Provinsi Jawa Timur</p>
<p v-if="loketData" class="klinik-info">{{ loketData.namaLoket }}</p>
</div>
</div>
<div class="header-right">
<div class="datetime-display">
<div class="time-large">{{ currentTime }}</div>
<div class="date-small">{{ currentDate }}</div>
</div>
</div>
</div>
<!-- Main Grid - Display All Called Queues -->
<div class="queue-grid-container">
<!-- Loket Header -->
<div class="loket-header">
<h2 class="loket-title">{{ loketData?.namaLoket || 'LOKET 1' }}</h2>
</div>
<!-- Patient Cards Grid (like AdminLoket) -->
<div v-if="allCalledQueues.length > 0" class="patient-cards-grid">
<v-card
v-for="queue in allCalledQueues"
:key="queue.no"
class="patient-card-display"
elevation="2"
>
<v-card-text class="card-text-content">
<!-- Queue Number - Large -->
<div class="card-content">
<div class="queue-number-large">
{{ queue.noAntrian.split(" |")[0] }}
</div>
<!-- Fast Track Icon -->
<div v-if="queue.fastTrack === 'YA'" class="fast-track-badge">
<v-icon color="warning" size="36" class="fast-track-icon">
mdi-flash
</v-icon>
</div>
<!-- Klinik Info -->
<div class="klinik-info">
<v-chip size="default" variant="outlined" class="klinik-chip-large">
{{ queue.klinik }}
</v-chip>
</div>
</div>
</v-card-text>
</v-card>
</div>
<!-- Empty State -->
<div v-else class="empty-state">
<v-icon size="64" color="grey-lighten-3">mdi-clock-outline</v-icon>
<p class="empty-text">Tidak Ada Antrian yang Dipanggil</p>
</div>
</div>
<!-- Footer Stats -->
<div class="footer-stats-bar">
<div class="stat-item">
<div class="stat-icon stat-icon-total">
<v-icon size="32" color="primary-600">mdi-format-list-numbered</v-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ statistics?.total ?? 0 }}</div>
<div class="stat-label">Total Antrian</div>
</div>
</div>
<div class="stat-divider"></div>
<div class="stat-item">
<div class="stat-icon stat-icon-waiting">
<v-icon size="32" color="primary-600">mdi-clock-alert-outline</v-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ statistics?.waiting ?? 0 }}</div>
<div class="stat-label">Menunggu</div>
</div>
</div>
<div class="stat-divider"></div>
<div class="stat-item">
<div class="stat-icon stat-icon-active">
<v-icon size="32" color="primary-600">mdi-account-check</v-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ statistics?.active ?? 0 }}</div>
<div class="stat-label">Dipanggil untuk Check-in</div>
</div>
</div>
<div class="footer-message">
<v-icon size="24" color="primary-600" class="mr-2">mdi-information</v-icon>
<span>Harap perhatikan nomor tiket Anda yang dipanggil untuk check-in</span>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { useQueueStore } from '@/stores/queueStore'
import { useMasterStore } from '@/stores/masterStore'
import { useRoute } from '#app'
definePageMeta({
layout: false,
})
const route = useRoute()
const queueStore = useQueueStore()
const masterStore = useMasterStore()
const loketId = computed(() => {
const id = route.params.id
// Handle both string and array cases
const idValue = Array.isArray(id) ? id[0] : id
const parsed = parseInt(idValue)
return isNaN(parsed) ? null : parsed
})
const loketData = computed(() => {
const id = loketId.value
if (!id) return null
return masterStore.getLoketById(id) || null
})
const currentTime = ref('')
const currentDate = ref('')
let timeInterval = null
// Get all patients with processStage 'loket'
const loketPatients = computed(() => {
try {
const result = queueStore.getPatientsByStage('loket')
if (result && result.value && Array.isArray(result.value.all)) {
return result.value.all
}
return []
} catch {
return []
}
})
// Filter: Only show tickets from anjungan that are called for check-in
// These are tickets printed from anjungan that have been called by admin loket
// Status berubah dari 'menunggu' menjadi 'waiting' ketika dipanggil oleh admin loket
// Status 'waiting' = sudah dipanggil tapi belum check-in
// Setiap loket menampilkan data pasien yang berbeda berdasarkan loketId
const calledForCheckIn = computed(() => {
const currentLoketId = loketId.value
// Untuk saat ini, hanya loket 1 yang aktif
// Nanti setiap loket akan menampilkan data yang berbeda
if (!currentLoketId || currentLoketId !== 1) {
// Jika bukan loket 1, return empty array (untuk sementara)
return []
}
return loketPatients.value
.filter(p => {
// Must be called (waiting status = sudah dipanggil dari status menunggu)
// and not yet checked in (processStage still 'loket')
const isCalled = p.status === 'waiting' && p.processStage === 'loket'
// Must be from anjungan (onsite registration)
const isFromAnjungan = p.registrationType === 'onsite' ||
(p.noAntrian && p.noAntrian.includes('Onsite'))
// Filter berdasarkan loketId (untuk saat ini hanya loket 1)
// Nanti bisa ditambahkan field loketId di data pasien untuk filter yang lebih spesifik
const isForThisLoket = !p.loketId || p.loketId === currentLoketId
return isCalled && isFromAnjungan && isForThisLoket
})
// Tidak perlu sorting - tampilkan semua sesuai urutan dari store
})
// Removed displayedQueues - tidak diperlukan lagi karena menggunakan allCalledQueues
// Get all called queues (tidak perlu urut, tampilkan semua)
// Maksimal 20 orang yang dipanggil
const allCalledQueues = computed(() => {
try {
// Ensure calledForCheckIn is an array
const queues = Array.isArray(calledForCheckIn.value) ? calledForCheckIn.value : []
// Tampilkan maksimal 20 yang sudah dipanggil
// Tidak perlu sorting, tampilkan sesuai urutan dari store
return queues.slice(0, 20)
} catch {
return []
}
})
const statistics = computed(() => {
// Ensure we always return default values even if data is not ready
try {
const currentLoketId = loketId.value
// Ensure loketPatients is an array
const patients = Array.isArray(loketPatients.value) ? loketPatients.value : []
// Filter tickets from anjungan untuk loket ini
// Untuk saat ini, hanya loket 1 yang aktif
const allAnjunganTickets = patients.filter(p => {
if (!p) return false
const isFromAnjungan = p.registrationType === 'onsite' ||
(p.noAntrian && p.noAntrian.includes('Onsite'))
// Filter berdasarkan loketId (untuk saat ini hanya loket 1)
const isForThisLoket = !p.loketId || p.loketId === currentLoketId
return isFromAnjungan && isForThisLoket
})
// Menunggu = belum dipanggil (status 'menunggu')
const menungguCount = allAnjunganTickets.filter(p => p && p.status === 'menunggu').length
// Dipanggil untuk check-in = sudah dipanggil (status 'waiting')
const calledForCheckInArray = Array.isArray(calledForCheckIn.value) ? calledForCheckIn.value : []
const activeCount = calledForCheckInArray.length
return {
total: allAnjunganTickets.length || 0,
waiting: menungguCount || 0, // Jumlah yang masih menunggu untuk dipanggil
active: activeCount || 0 // Jumlah yang sudah dipanggil dan ditampilkan di layar
}
} catch {
// Return default values if there's any error
return {
total: 0,
waiting: 0,
active: 0
}
}
})
const updateTime = () => {
const now = new Date()
currentTime.value = now.toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
currentDate.value = now.toLocaleDateString('id-ID', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric'
})
}
onMounted(() => {
// Wait for stores to be hydrated from persisted state
// Use nextTick to ensure stores are ready
nextTick(() => {
// Redirect to index if loket not found
if (!loketData.value) {
navigateTo('/anjungan/antreanmasuk')
return
}
// Untuk saat ini, hanya loket 1 yang aktif
// Jika mengakses loket selain 1, redirect ke loket 1
if (loketId.value !== 1) {
navigateTo('/anjungan/antreanmasuk/1')
return
}
updateTime()
timeInterval = setInterval(updateTime, 1000)
})
})
onUnmounted(() => {
if (timeInterval) clearInterval(timeInterval)
})
</script>
<style scoped lang="scss">
.antrian-display-container {
background: #FFFFFF;
width: 100vw;
height: 100vh;
min-height: 100vh;
padding: 24px;
font-family: 'Roboto', sans-serif;
overflow: auto;
display: flex;
flex-direction: column;
margin: 0;
box-sizing: border-box;
position: relative;
}
/* Ensure body/html don't cause layout issues */
:deep(body),
:deep(html) {
margin: 0;
padding: 0;
overflow: hidden;
width: 100%;
height: 100%;
}
:deep(#__nuxt) {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
}
/* ========== HEADER ========== */
.display-header {
display: flex;
justify-content: space-between;
align-items: center;
background: linear-gradient(135deg, var(--color-primary-600) 0%, var(--color-primary-700) 100%);
border-radius: 16px;
padding: 24px 40px;
margin-bottom: 20px;
box-shadow: 0 8px 24px rgba(25, 118, 210, 0.3);
flex-shrink: 0;
box-sizing: border-box;
}
.header-left {
display: flex;
align-items: center;
gap: 24px;
}
.logo-circle {
background: rgba(255, 255, 255, 0.15);
width: 80px;
height: 80px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(10px);
}
.header-text {
flex: 1;
}
.hospital-name {
font-size: 48px;
font-weight: 800;
color: var(--color-neutral-100);
margin: 0;
letter-spacing: 2px;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
line-height: 1;
}
.display-subtitle {
font-size: 20px;
font-weight: 600;
color: var(--color-neutral-100);
margin: 6px 0 0 0;
opacity: 0.95;
}
.klinik-info {
font-size: 16px;
font-weight: 500;
color: var(--color-neutral-100);
margin: 4px 0 0 0;
opacity: 0.85;
}
.loket-info {
font-size: 16px;
font-weight: 500;
color: var(--color-neutral-100);
margin: 4px 0 0 0;
opacity: 0.85;
}
.header-right {
display: flex;
align-items: center;
}
.datetime-display {
text-align: right;
}
.time-large {
font-size: 56px;
font-weight: 900;
color: var(--color-neutral-100);
letter-spacing: 2px;
line-height: 1;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.date-small {
font-size: 18px;
color: var(--color-neutral-100);
margin-top: 8px;
font-weight: 500;
opacity: 0.95;
}
/* ========== QUEUE GRID CONTAINER ========== */
.queue-grid-container {
flex: 1 1 auto;
display: flex;
flex-direction: column;
margin-bottom: 24px;
background: #FFFFFF;
border-radius: 20px;
border: 2px solid var(--color-primary-200);
overflow: auto;
padding: 24px;
min-height: 0;
max-height: calc(100vh - 280px);
box-sizing: border-box;
box-shadow: 0 4px 16px rgba(25, 118, 210, 0.08);
}
.loket-header {
background: linear-gradient(135deg, var(--color-primary-600) 0%, var(--color-primary-700) 100%);
padding: 16px 20px;
text-align: center;
border-radius: 12px;
margin-bottom: 20px;
flex-shrink: 0;
height: 70px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
}
.loket-header .loket-title {
font-size: 36px;
font-weight: 800;
color: #FFFFFF;
margin: 0;
letter-spacing: 0.5px;
text-transform: uppercase;
}
/* Patient Cards Grid - Fixed for 1920x1080, 5 columns */
.patient-cards-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 20px;
flex: 1;
overflow: hidden;
padding: 0;
width: 100%;
box-sizing: border-box;
align-content: start;
height: 100%;
}
.patient-card-display {
position: relative;
border-radius: 16px;
border: 2px solid var(--color-primary-200);
background: #FFFFFF;
transition: all 0.3s ease;
height: 100%;
min-height: 280px;
display: flex;
flex-direction: column;
box-shadow: 0 4px 16px rgba(25, 118, 210, 0.08);
width: 100%;
box-sizing: border-box;
}
.patient-card-display:hover {
box-shadow: 0 8px 24px rgba(25, 118, 210, 0.15);
transform: translateY(-2px);
border-color: var(--color-primary-600);
}
.card-text-content {
padding: 20px !important;
height: 100%;
display: flex;
box-sizing: border-box;
}
.card-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
gap: 12px;
text-align: center;
width: 100%;
}
.queue-number-large {
font-size: 72px;
font-weight: 900;
color: #212121;
letter-spacing: 2px;
line-height: 1;
}
.fast-track-badge {
display: flex;
align-items: center;
justify-content: center;
}
.fast-track-icon {
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.7;
transform: scale(1.1);
}
}
.klinik-info {
margin-top: 8px;
}
.klinik-chip-large {
font-size: 14px;
font-weight: 700;
padding: 6px 14px;
border: 2px solid var(--color-primary-600);
color: var(--color-primary-600);
height: auto;
white-space: nowrap;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
background: rgba(25, 118, 210, 0.05);
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
}
.empty-text {
font-size: 18px;
color: #89939E;
margin-top: 12px;
font-weight: 600;
}
/* ========== FOOTER STATS ========== */
.footer-stats-bar {
background: #FFFFFF;
border: 2px solid var(--color-primary-200);
border-radius: 20px;
padding: 24px 40px;
display: flex !important;
align-items: center;
justify-content: space-between;
gap: 32px;
box-shadow: 0 4px 16px rgba(25, 118, 210, 0.08);
flex: 0 0 100px;
flex-shrink: 0;
flex-grow: 0;
height: 100px;
order: 999;
min-height: 100px;
max-height: 100px;
width: 100%;
max-width: 100%;
box-sizing: border-box;
position: relative;
overflow: visible;
visibility: visible !important;
opacity: 1 !important;
z-index: 10;
margin-top: auto;
}
.stat-item {
display: flex;
align-items: center;
gap: 16px;
flex-shrink: 0;
min-width: fit-content;
width: auto;
height: 64px;
}
.stat-icon {
width: 64px;
height: 64px;
min-width: 64px;
min-height: 64px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.stat-icon-total,
.stat-icon-waiting {
background: var(--color-primary-200);
}
.stat-icon-active {
background: var(--color-primary-200);
}
.stat-info {
text-align: left;
flex-shrink: 0;
min-width: fit-content;
width: auto;
height: 64px;
display: flex;
flex-direction: column;
justify-content: center;
}
.stat-value {
font-size: 42px;
font-weight: 900;
color: #212121;
line-height: 1;
white-space: nowrap;
height: 42px;
display: flex;
align-items: center;
}
.stat-label {
font-size: 15px;
color: #717171;
font-weight: 600;
margin-top: 4px;
height: 18px;
line-height: 18px;
}
.stat-divider {
width: 2px;
height: 64px;
min-width: 2px;
min-height: 64px;
background: var(--color-primary-200);
flex-shrink: 0;
}
.footer-message {
flex: 1;
text-align: right;
font-size: 20px;
font-weight: 600;
color: #4D4D4D;
display: flex;
align-items: center;
justify-content: flex-end;
min-width: 0;
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
height: 64px;
padding-left: 32px;
}
/* ========== RESPONSIVE ========== */
/* Fullscreen layout - no fixed dimensions to prevent breaking on refresh */
@media (max-width: 1366px) {
.antrian-display-container {
padding: 16px;
}
.display-header {
padding: 16px 24px;
}
.logo-circle {
width: 64px;
height: 64px;
}
.logo-circle .v-icon {
font-size: 40px !important;
}
.hospital-name {
font-size: 36px;
}
.display-subtitle {
font-size: 16px;
}
.time-large {
font-size: 48px;
}
.date-small {
font-size: 14px;
}
.hero-call-section {
padding: 24px;
}
.call-label {
font-size: 24px;
}
.call-number {
font-size: 100px;
}
.call-loket {
font-size: 28px;
}
.loket-header-bar {
padding: 20px 24px;
}
.loket-title {
font-size: 28px;
}
.loket-content {
padding: 32px;
}
.current-number {
font-size: 96px;
}
.next-item {
font-size: 24px;
padding: 10px 20px;
}
.footer-stats-bar {
padding: 16px 32px;
}
.stat-icon {
width: 56px;
height: 56px;
}
.stat-icon .v-icon {
font-size: 28px !important;
}
.stat-value {
font-size: 36px;
}
.stat-label {
font-size: 13px;
}
.stat-divider {
height: 50px;
}
.footer-message {
font-size: 18px;
}
}
@media (max-width: 1024px) {
.display-header {
flex-direction: column;
gap: 16px;
text-align: center;
}
.header-left {
flex-direction: column;
gap: 12px;
}
.datetime-display {
text-align: center;
}
.footer-stats-bar {
flex-direction: column;
gap: 16px;
}
.stat-divider {
display: none;
}
.footer-message {
text-align: center;
justify-content: center;
}
}
@media (max-width: 960px) {
.patient-cards-grid {
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 16px;
}
.queue-number-large {
font-size: 64px;
}
.patient-card-display {
min-height: 240px;
}
}
@media (min-width: 961px) and (max-width: 1400px) {
.patient-cards-grid {
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
}
.queue-number-large {
font-size: 80px;
}
}
@media (min-width: 1401px) and (max-width: 1919px) {
.patient-cards-grid {
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
}
.queue-number-large {
font-size: 96px;
}
}
/* Fixed layout for 1920x1080 */
@media (min-width: 1920px) {
.patient-cards-grid {
grid-template-columns: repeat(5, 1fr);
gap: 20px;
}
.queue-number-large {
font-size: 72px;
}
.patient-card-display {
min-height: 280px;
}
}
@media (max-width: 768px) {
.hospital-name {
font-size: 28px;
}
.time-large {
font-size: 36px;
}
.loket-header .loket-title {
font-size: 28px;
}
.queue-grid-container {
padding: 16px;
}
}
/* Prevent text selection */
* {
user-select: none;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
</style>
+348
View File
@@ -0,0 +1,348 @@
<!-- pages/Anjungan/AntreanMasuk/index.vue -->
<template>
<div class="loket-selection-container">
<div class="selection-header">
<div class="header-icon">
<v-icon size="64" color="white">mdi-ticket-account</v-icon>
</div>
<div class="header-content">
<h1 class="main-title">Pilih Loket Antrean</h1>
<p class="subtitle">RSUD dr. Saiful Anwar Provinsi Jawa Timur</p>
</div>
</div>
<div v-if="paginatedLokets.length > 0" class="lokets-grid">
<div
v-for="loket in paginatedLokets"
:key="loket.id"
class="loket-card"
@click="navigateToLoket(loket.id)"
>
<div class="loket-card-header">
<v-icon size="32" color="primary">mdi-counter</v-icon>
<div class="loket-info">
<h3 class="loket-name">{{ loket.namaLoket }}</h3>
<p class="loket-details">No. {{ loket.no }}</p>
</div>
</div>
<div class="loket-preview">
<div class="loket-service-count">
<v-icon size="18" color="primary">mdi-hospital-building</v-icon>
<span>{{ loket.pelayanan?.length || 0 }} Pelayanan</span>
</div>
<div class="loket-tags">
<v-chip
v-for="(pelayanan, idx) in (loket.pelayanan || []).slice(0, 3)"
:key="idx"
size="small"
class="ma-1 chip-preview"
>
{{ pelayanan }}
</v-chip>
<v-chip
v-if="(loket.pelayanan || []).length > 3"
size="small"
class="ma-1 chip-more"
>
+{{ (loket.pelayanan || []).length - 3 }}
</v-chip>
</div>
</div>
<div class="loket-card-footer">
<v-btn
color="primary-600"
variant="flat"
size="large"
block
class="btn-view"
>
<v-icon left>mdi-eye</v-icon>
Tampilkan
</v-btn>
</div>
</div>
</div>
<div v-else class="empty-state">
<v-icon size="64" color="grey-lighten-1">mdi-counter-off</v-icon>
<h3>Tidak Ada Loket Tersedia</h3>
<p>Silakan tambah loket terlebih dahulu di halaman master</p>
<v-btn
color="primary-600"
variant="flat"
class="mt-4"
@click="navigateToSettings"
>
<v-icon left>mdi-cog</v-icon>
Ke Halaman Master
</v-btn>
</div>
<div v-if="paginatedLokets.length > 0 && totalPages > 1" class="pagination">
<v-btn variant="outlined" :disabled="page <= 1" @click="goPrev">Prev</v-btn>
<span class="page-info">Page {{ page }} / {{ totalPages }}</span>
<v-btn variant="outlined" :disabled="page >= totalPages" @click="goNext">Next</v-btn>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { useMasterStore } from '@/stores/masterStore';
import { useRoute } from '#app';
definePageMeta({
layout: false,
});
const masterStore = useMasterStore();
const route = useRoute();
// Get all lokets
// Untuk saat ini, hanya menampilkan loket 1
// Nanti setiap loket akan menampilkan data yang berbeda
const allLokets = computed(() => {
const allLoketsData = masterStore.loketData?.value || masterStore.loketData || [];
// Filter hanya loket 1 untuk saat ini
return allLoketsData.filter(loket => loket.id === 1 || loket.no === 1);
});
// Pagination
const itemsPerPage = 10;
const page = computed({
get: () => Number(route.query.page || 1),
set: (val) => navigateTo({ query: { ...route.query, page: val } }),
});
const totalPages = computed(() => Math.max(1, Math.ceil(allLokets.value.length / itemsPerPage)));
const paginatedLokets = computed(() => {
const start = (page.value - 1) * itemsPerPage;
return allLokets.value.slice(start, start + itemsPerPage);
});
const goPrev = () => {
if (page.value > 1) page.value = page.value - 1;
};
const goNext = () => {
if (page.value < totalPages.value) page.value = page.value + 1;
};
const navigateToLoket = (loketId) => {
navigateTo(`/anjungan/antreanmasuk/${loketId}`);
};
const navigateToSettings = () => {
navigateTo('/setting/masterloket');
};
</script>
<style scoped lang="scss">
.loket-selection-container {
background: var(--color-neutral-300);
min-height: 100vh;
padding: 40px;
font-family: 'Inter', 'Roboto', sans-serif;
}
.selection-header {
background: linear-gradient(135deg, var(--color-primary-600) 0%, var(--color-primary-700) 100%);
border-radius: 20px;
padding: 40px;
margin-bottom: 40px;
display: flex;
align-items: center;
gap: 32px;
box-shadow: 0 8px 24px rgba(25, 118, 210, 0.3);
}
.header-icon {
background: rgba(255, 255, 255, 0.15);
border-radius: 20px;
padding: 24px;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(10px);
}
.header-content {
flex: 1;
color: var(--color-neutral-100);
}
.main-title {
font-size: 48px;
font-weight: 800;
margin: 0;
letter-spacing: 2px;
color: var(--color-neutral-100);
line-height: 1.2;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.subtitle {
font-size: 20px;
font-weight: 600;
margin: 8px 0 0 0;
color: var(--color-neutral-100);
opacity: 0.95;
}
.lokets-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 24px;
max-width: 1400px;
margin: 0 auto;
}
.loket-card {
background: var(--color-neutral-100);
border-radius: 16px;
padding: 24px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
border: 2px solid var(--color-neutral-400);
cursor: pointer;
transition: all 0.3s ease;
display: flex;
flex-direction: column;
gap: 16px;
&:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(25, 118, 210, 0.2);
border-color: var(--color-primary-600);
}
}
.loket-card-header {
display: flex;
align-items: center;
gap: 16px;
padding-bottom: 16px;
border-bottom: 2px solid var(--color-neutral-400);
}
.loket-info {
flex: 1;
}
.loket-name {
font-size: 20px;
font-weight: 700;
margin: 0;
color: var(--color-neutral-900);
line-height: 1.2;
}
.loket-details {
font-size: 14px;
color: var(--color-neutral-600);
margin: 4px 0 0 0;
font-weight: 500;
}
.loket-preview {
flex: 1;
}
.loket-service-count {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 600;
color: var(--color-primary-600);
margin-bottom: 12px;
}
.loket-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.chip-preview {
background-color: var(--color-primary-300) !important;
color: var(--color-primary-700) !important;
font-weight: 500;
font-size: 12px;
}
.chip-more {
background-color: var(--color-neutral-600) !important;
color: var(--color-neutral-100) !important;
font-weight: 600;
font-size: 12px;
}
.loket-card-footer {
margin-top: auto;
}
.btn-view {
font-weight: 600;
text-transform: none;
letter-spacing: 0.5px;
}
.empty-state {
text-align: center;
padding: 80px 20px;
color: var(--color-neutral-700);
h3 {
font-size: 24px;
font-weight: 700;
margin: 24px 0 8px 0;
color: var(--color-neutral-800);
}
p {
font-size: 16px;
color: var(--color-neutral-600);
}
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-top: 24px;
}
.page-info {
font-weight: 600;
color: var(--color-neutral-800);
}
@media (max-width: 768px) {
.loket-selection-container {
padding: 20px;
}
.selection-header {
flex-direction: column;
text-align: center;
padding: 32px 24px;
}
.main-title {
font-size: 32px;
}
.subtitle {
font-size: 16px;
}
.lokets-grid {
grid-template-columns: 1fr;
gap: 16px;
}
}
</style>
+876
View File
@@ -0,0 +1,876 @@
<!-- pages/Anjungan/AntrianLoket/index.vue -->
<template>
<div class="antrian-display-container">
<!-- Header -->
<div class="display-header">
<div class="header-left">
<div class="logo-circle">
<v-icon size="48" color="white">mdi-view-dashboard</v-icon>
</div>
<div class="header-text">
<h1 class="hospital-name">ANTRIAN LOKET</h1>
<p class="display-subtitle">RSUD dr. Saiful Anwar Provinsi Jawa Timur</p>
<p v-if="loketData" class="klinik-info">{{ loketData.namaLoket }}</p>
</div>
</div>
<div class="header-right">
<div class="datetime-display">
<div class="time-large">{{ currentTime }}</div>
<div class="date-small">{{ currentDate }}</div>
</div>
</div>
</div>
<!-- Hero Call Section - Highlight card untuk nomor antrian yang dipanggil -->
<div v-if="currentCalledQueue" class="hero-call-section">
<div class="call-label">
<v-icon size="32" color="white" class="mr-2">mdi-bullhorn</v-icon>
NOMOR ANTRIAN YANG DIPANGGIL
</div>
<div class="call-number">{{ currentCalledQueue.noAntrian.split(' |')[0] }}</div>
<div class="call-loket">{{ currentCalledQueue.loket || 'Loket' }}</div>
</div>
<!-- Main Grid - Display Loket -->
<div class="lokets-grid">
<div
v-for="loket in displayedLokets"
:key="loket.id"
class="loket-box"
>
<!-- Loket Header -->
<div class="loket-header-bar">
<span class="loket-title">{{ loket.namaLoket }}</span>
<v-chip size="small" class="loket-count">
<v-icon size="16" color="white" class="mr-1">mdi-account-multiple</v-icon>
<span class="count-text">{{ loket.totalQueues }}</span>
</v-chip>
</div>
<!-- Queue Content -->
<div class="loket-content">
<!-- Current Queue -->
<div v-if="loket.currentQueue" class="current-section">
<div class="current-label">SEKARANG</div>
<div
class="current-number"
:class="{ 'highlight-called': isCalled(loket.currentQueue) }"
>
{{ loket.currentQueue.noAntrian.split(' |')[0] }}
</div>
</div>
<!-- Empty State -->
<div v-if="!loket.currentQueue" class="empty-state">
<v-icon size="48" color="grey-lighten-3">mdi-clock-outline</v-icon>
<p class="empty-text">Tidak Ada Antrian</p>
</div>
</div>
</div>
</div>
<!-- Footer Stats -->
<div class="footer-stats-bar">
<div class="stat-item">
<div class="stat-icon stat-icon-total">
<v-icon size="32" color="success-600">mdi-format-list-numbered</v-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ statistics.total }}</div>
<div class="stat-label">Total Antrian</div>
</div>
</div>
<div class="stat-divider"></div>
<div class="stat-item">
<div class="stat-icon stat-icon-active">
<v-icon size="32" color="success-600">mdi-account-check</v-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ statistics.active }}</div>
<div class="stat-label">Sedang Dilayani</div>
</div>
</div>
<div class="footer-message">
<v-icon size="24" color="success-600" class="mr-2">mdi-information</v-icon>
<span>Harap perhatikan nomor antrian Anda di layar</span>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useQueueStore } from '@/stores/queueStore'
import { useLoketStore } from '@/stores/loketStore'
import { useRoute } from '#app'
definePageMeta({
layout: false,
})
const route = useRoute()
const queueStore = useQueueStore()
const loketStore = useLoketStore()
const currentTime = ref('')
const currentDate = ref('')
let timeInterval = null
// Get loket ID from route
const loketId = computed(() => {
const id = route.params.id
const idValue = Array.isArray(id) ? id[0] : id
const parsed = parseInt(idValue)
return isNaN(parsed) ? null : parsed
})
// Get loket data for this ID
const loketData = computed(() => {
const id = loketId.value
if (!id) return null
return loketStore.getLoketById(id) || null
})
// Get current processing patient dari AdminLoket
const currentProcessingPatient = computed(() => {
return queueStore.currentProcessingPatient?.loket || null
})
// Get all patients with processStage "loket" and filter status "di-loket" dan "pending"
const loketPatients = computed(() => {
const allPatients = queueStore.getPatientsByStage('loket').value.all
// Tampilkan antrian dengan status "di-loket" dan "pending"
return allPatients.filter(p => p.status === 'di-loket' || p.status === 'pending')
})
// Get all lokets from loketStore
const allLokets = computed(() => {
return loketStore.loketData?.value || loketStore.loketData || []
})
// Helper function untuk mendapatkan loket default
const getDefaultLoket = () => {
const allLoketsData = loketStore.loketData?.value || loketStore.loketData || []
const loket1 = allLoketsData.find(l => l.id === 1 || l.no === 1)
return loket1 ? loket1.namaLoket : 'Loket 1'
}
// Display lokets with their queues
const displayedLokets = computed(() => {
if (allLokets.value.length === 0) {
return []
}
// Include currentProcessingPatient dalam list jika ada dan status "di-loket" atau "pending"
let allPatientsForDistribution = [...loketPatients.value]
// Jika ada antrian yang sedang diproses, tambahkan ke list distribusi
if (currentProcessingPatient.value && (currentProcessingPatient.value.status === 'di-loket' || currentProcessingPatient.value.status === 'pending')) {
// Pastikan currentProcessingPatient ada di list (jika belum ada)
const existsInList = allPatientsForDistribution.find(p => p.no === currentProcessingPatient.value.no)
if (!existsInList) {
allPatientsForDistribution.push(currentProcessingPatient.value)
}
}
// Sort all di-loket patients by jamPanggil (terlama dulu untuk prioritas distribusi)
const sortedPatients = allPatientsForDistribution.sort((a, b) => {
const timeA = (a.jamPanggil || '00:00').split(':').map(Number)
const timeB = (b.jamPanggil || '00:00').split(':').map(Number)
return timeA[0] * 60 + timeA[1] - (timeB[0] * 60 + timeB[1])
})
// Inisialisasi loket dengan currentQueue null
const loketAssignments = allLokets.value
.sort((a, b) => a.no - b.no) // Urutkan berdasarkan nomor loket
.map(loket => ({
id: loket.id,
namaLoket: loket.namaLoket,
no: loket.no,
currentQueue: null
}))
// Prioritas 1: Assign antrian yang sedang diproses (currentProcessingPatient) ke loket yang sesuai
// Antrian yang sedang diproses HARUS muncul di loket yang dituju
if (currentProcessingPatient.value && (currentProcessingPatient.value.status === 'di-loket' || currentProcessingPatient.value.status === 'pending')) {
const processingPatient = currentProcessingPatient.value
const targetLoketId = processingPatient.loketId || 1
const targetLoketName = processingPatient.loket || getDefaultLoket()
// Cari loket target berdasarkan loketId atau loket name
const targetLoket = loketAssignments.find(l =>
l.id === targetLoketId ||
l.no === targetLoketId ||
l.namaLoket === targetLoketName ||
l.namaLoket.toLowerCase() === targetLoketName.toLowerCase()
)
if (targetLoket) {
// Assign antrian yang sedang diproses sebagai prioritas tertinggi
// Ini akan menggantikan antrian lain yang ada di loket ini
targetLoket.currentQueue = processingPatient
}
}
// Distribusi antrian ke loket: setiap antrian hanya di-assign ke satu loket
// Prioritas: loket yang kosong dulu, lalu round-robin jika semua sudah terisi
const usedPatients = new Set()
// Jika ada antrian yang sedang diproses, mark sebagai used
if (currentProcessingPatient.value) {
usedPatients.add(currentProcessingPatient.value.no)
}
sortedPatients.forEach(patient => {
// Skip jika patient sudah di-assign atau sedang diproses
if (usedPatients.has(patient.no)) {
return
}
// Jika patient sudah punya loket assignment, gunakan itu
if (patient.loket || patient.loketId) {
const loketName = patient.loket || patient.loketId
const targetLoketId = patient.loketId || 1
const assignedLoket = loketAssignments.find(l =>
l.id === targetLoketId ||
l.no === targetLoketId ||
l.namaLoket === loketName ||
`Loket ${l.no}` === loketName ||
l.namaLoket.toLowerCase() === loketName.toLowerCase()
)
// Jika loket sudah punya antrian lain (bukan yang sedang diproses), skip
// Jika loket kosong, assign antrian ke loket tersebut
if (assignedLoket && !assignedLoket.currentQueue) {
assignedLoket.currentQueue = patient
usedPatients.add(patient.no)
}
} else {
// Cari loket yang kosong (belum ada currentQueue)
let emptyLoket = loketAssignments.find(l => !l.currentQueue)
if (emptyLoket) {
// Assign antrian ke loket yang kosong
emptyLoket.currentQueue = patient
usedPatients.add(patient.no)
}
// Jika semua loket sudah terisi, antrian ini tidak akan ditampilkan
}
})
// Map ke format yang diharapkan
return loketAssignments.map(loket => ({
id: loket.id,
namaLoket: loket.namaLoket,
currentQueue: loket.currentQueue,
totalQueues: loket.currentQueue ? 1 : 0
}))
})
// Current called queue - antrian yang sedang diproses atau paling baru dipanggil
const currentCalledQueue = computed(() => {
// Prioritas 1: Antrian yang sedang diproses di AdminLoket (currentProcessingPatient)
if (currentProcessingPatient.value && (currentProcessingPatient.value.status === 'di-loket' || currentProcessingPatient.value.status === 'pending')) {
// Cari loket yang dituju dari assignment antrian
const loketInfo = displayedLokets.value.find(l =>
l.currentQueue && l.currentQueue.no === currentProcessingPatient.value.no
) || allLokets.value.find(l =>
(l.id === currentProcessingPatient.value.loketId) ||
(l.namaLoket === currentProcessingPatient.value.loket)
)
return {
...currentProcessingPatient.value,
loket: loketInfo ? loketInfo.namaLoket : (currentProcessingPatient.value.loket || 'Loket')
}
}
// Prioritas 2: Antrian yang paling baru dipanggil dari displayedLokets
const allCurrentQueues = displayedLokets.value
.filter(loket => loket.currentQueue)
.map(loket => ({
...loket.currentQueue,
loketNama: loket.namaLoket
}))
.sort((a, b) => {
// Sort berdasarkan createdAt atau jamPanggil terbaru
const dateA = new Date(a.createdAt || a.jamPanggil || 0)
const dateB = new Date(b.createdAt || b.jamPanggil || 0)
return dateB - dateA
})
if (allCurrentQueues.length > 0) {
const patient = allCurrentQueues[0]
return {
...patient,
loket: patient.loketNama || patient.loket || 'Loket'
}
}
// Jika tidak ada antrian dipanggil/diproses, return null (hero section dikosongkan)
return null
})
// Check if a queue is currently called (highlighted)
const isCalled = (queue) => {
if (!currentCalledQueue.value || !queue) return false
return currentCalledQueue.value.no === queue.no
}
// Statistics
const statistics = computed(() => {
const all = loketPatients.value
return {
total: all.length,
active: all.length // Semua sudah di-loket berarti sedang dilayani
}
})
const updateTime = () => {
const now = new Date()
currentTime.value = now.toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
currentDate.value = now.toLocaleDateString('id-ID', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric'
})
}
onMounted(() => {
// Redirect to index if loket not found
if (!loketData.value) {
navigateTo('/anjungan/antrianloket')
return
}
updateTime()
timeInterval = setInterval(updateTime, 1000)
})
onUnmounted(() => {
if (timeInterval) clearInterval(timeInterval)
})
</script>
<style scoped lang="scss">
.antrian-display-container {
background: var(--color-neutral-300);
min-height: 100vh;
max-width: 1920px;
max-height: 1080px;
width: 100vw;
height: 100vh;
padding: 24px;
font-family: 'Inter', 'Roboto', sans-serif;
overflow: hidden;
display: flex;
flex-direction: column;
margin: 0 auto;
}
/* ========== HEADER ========== */
.display-header {
display: flex;
justify-content: space-between;
align-items: center;
background: linear-gradient(135deg, var(--color-primary-600) 0%, var(--color-primary-700) 100%);
border-radius: 16px;
padding: 24px 40px;
margin-bottom: 20px;
box-shadow: 0 8px 24px rgba(33, 150, 243, 0.3);
}
.header-left {
display: flex;
align-items: center;
gap: 24px;
}
.logo-circle {
background: rgba(255, 255, 255, 0.15);
width: 80px;
height: 80px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(10px);
}
.hospital-name {
font-size: 48px;
font-weight: 800;
color: var(--color-neutral-100);
margin: 0;
letter-spacing: 2px;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
line-height: 1;
}
.display-subtitle {
font-size: 20px;
font-weight: 600;
color: var(--color-neutral-100);
margin: 6px 0 0 0;
opacity: 0.95;
}
.klinik-info {
font-size: 16px;
font-weight: 500;
color: var(--color-neutral-100);
margin: 4px 0 0 0;
opacity: 0.85;
}
.datetime-display {
text-align: right;
}
.time-large {
font-size: 56px;
font-weight: 900;
color: var(--color-neutral-100);
letter-spacing: 2px;
line-height: 1;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.date-small {
font-size: 18px;
color: var(--color-neutral-100);
margin-top: 8px;
font-weight: 500;
opacity: 0.95;
}
/* ========== HERO CALL SECTION ========== */
.hero-call-section {
background: linear-gradient(135deg, var(--color-danger-600) 0%, var(--color-danger-700) 100%);
border-radius: 20px;
padding: 32px;
margin-bottom: 20px;
text-align: center;
box-shadow: 0 12px 32px rgba(224, 21, 7, 0.35);
animation: pulse-highlight 2s ease-in-out infinite;
}
@keyframes pulse-highlight {
0%, 100% {
box-shadow: 0 12px 32px rgba(224, 21, 7, 0.35);
}
50% {
box-shadow: 0 12px 48px rgba(224, 21, 7, 0.55);
}
}
.call-label {
font-size: 28px;
font-weight: 700;
color: var(--color-neutral-100);
letter-spacing: 2px;
margin-bottom: 12px;
display: flex;
align-items: center;
justify-content: center;
text-transform: uppercase;
}
.call-number {
font-size: 140px;
font-weight: 900;
color: var(--color-neutral-100);
margin: 16px 0;
letter-spacing: 8px;
line-height: 1;
text-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
animation: number-pulse 1.5s ease-in-out infinite;
}
@keyframes number-pulse {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
}
.call-loket {
font-size: 36px;
font-weight: 700;
color: var(--color-neutral-100);
letter-spacing: 1px;
}
/* ========== LOKETS GRID ========== */
.lokets-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-bottom: 20px;
flex: 1;
}
.loket-box {
background: var(--color-neutral-100);
border: 2px solid var(--color-primary-200);
border-radius: 16px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(33, 150, 243, 0.12);
min-height: 240px;
display: flex;
flex-direction: column;
}
.loket-header-bar {
background: linear-gradient(135deg, var(--color-primary-600) 0%, var(--color-primary-700) 100%);
padding: 16px 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.loket-title {
font-size: 22px;
font-weight: 800;
color: var(--color-neutral-100);
letter-spacing: 0.5px;
text-transform: uppercase;
line-height: 1.2;
flex: 1;
}
.loket-count {
background: rgba(255, 255, 255, 0.2) !important;
color: var(--color-neutral-100) !important;
font-weight: 700;
backdrop-filter: blur(10px);
display: flex;
align-items: center;
gap: 4px;
}
.count-text {
font-size: 18px;
font-weight: 800;
}
.loket-content {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
padding: 20px;
background: var(--color-primary-100);
}
.current-section {
text-align: center;
margin-bottom: 12px;
}
.current-label {
font-size: 16px;
font-weight: 700;
color: var(--color-primary-600);
margin-bottom: 8px;
letter-spacing: 1px;
text-transform: uppercase;
}
.current-number {
font-size: 80px;
font-weight: 900;
color: var(--color-neutral-900);
letter-spacing: 4px;
line-height: 1;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
&.highlight-called {
color: var(--color-danger-600);
animation: highlight-flash 1s ease-in-out infinite;
transform: scale(1.1);
}
}
@keyframes highlight-flash {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
.empty-state {
text-align: center;
padding: 32px 0;
}
.empty-text {
font-size: 16px;
color: var(--color-neutral-600);
margin-top: 12px;
font-weight: 600;
}
/* ========== FOOTER STATS ========== */
.footer-stats-bar {
background: var(--color-neutral-100);
border: 2px solid var(--color-primary-200);
border-radius: 16px;
padding: 20px 40px;
display: flex;
align-items: center;
gap: 32px;
box-shadow: 0 4px 12px rgba(33, 150, 243, 0.12);
}
.stat-item {
display: flex;
align-items: center;
gap: 16px;
}
.stat-icon {
width: 64px;
height: 64px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
}
.stat-icon-total,
.stat-icon-active {
background: var(--color-primary-200);
}
.stat-info {
text-align: left;
}
.stat-value {
font-size: 40px;
font-weight: 900;
color: var(--color-neutral-900);
line-height: 1;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.stat-label {
font-size: 14px;
color: var(--color-neutral-700);
font-weight: 600;
margin-top: 4px;
opacity: 0.95;
}
.stat-divider {
width: 2px;
height: 60px;
background: var(--color-primary-200);
}
.footer-message {
flex: 1;
text-align: right;
font-size: 20px;
font-weight: 600;
color: var(--color-neutral-800);
display: flex;
align-items: center;
justify-content: flex-end;
}
/* ========== RESPONSIVE ========== */
@media (min-width: 1920px) and (min-height: 1080px) {
.antrian-display-container {
width: 1920px;
height: 1080px;
padding: 24px;
}
}
@media (max-width: 1366px) {
.antrian-display-container {
padding: 16px;
}
.display-header {
padding: 16px 24px;
}
.logo-circle {
width: 64px;
height: 64px;
}
.logo-circle .v-icon {
font-size: 40px !important;
}
.hospital-name {
font-size: 36px;
}
.display-subtitle {
font-size: 16px;
}
.time-large {
font-size: 48px;
}
.date-small {
font-size: 14px;
}
.hero-call-section {
padding: 24px;
}
.call-label {
font-size: 24px;
}
.call-number {
font-size: 100px;
}
.call-loket {
font-size: 28px;
}
.lokets-grid {
gap: 12px;
}
.loket-box {
min-height: 220px;
padding: 0;
}
.loket-header-bar {
padding: 14px 16px;
}
.loket-title {
font-size: 20px;
}
.loket-content {
padding: 16px;
}
.current-number {
font-size: 72px;
}
.footer-stats-bar {
padding: 16px 32px;
}
.stat-icon {
width: 56px;
height: 56px;
}
.stat-icon .v-icon {
font-size: 28px !important;
}
.stat-value {
font-size: 36px;
}
.stat-label {
font-size: 13px;
}
.stat-divider {
height: 50px;
}
.footer-message {
font-size: 18px;
}
}
@media (max-width: 1024px) {
.display-header {
flex-direction: column;
gap: 16px;
text-align: center;
}
.header-left {
flex-direction: column;
gap: 12px;
}
.datetime-display {
text-align: center;
}
.lokets-grid {
grid-template-columns: repeat(2, 1fr);
}
.footer-stats-bar {
flex-direction: column;
gap: 16px;
}
.stat-divider {
display: none;
}
.footer-message {
text-align: center;
justify-content: center;
}
}
@media (max-width: 768px) {
.lokets-grid {
grid-template-columns: 1fr;
}
.hospital-name {
font-size: 28px;
}
.time-large {
font-size: 36px;
}
.call-number {
font-size: 80px;
}
.current-number {
font-size: 60px;
}
}
/* Prevent text selection */
* {
user-select: none;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
</style>
+344
View File
@@ -0,0 +1,344 @@
<!-- pages/Anjungan/AntrianLoket/index.vue -->
<template>
<div class="loket-selection-container">
<div class="selection-header">
<div class="header-icon">
<v-icon size="64" color="white">mdi-view-dashboard</v-icon>
</div>
<div class="header-content">
<h1 class="main-title">Pilih Layar Antrian Loket</h1>
<p class="subtitle">RSUD dr. Saiful Anwar Provinsi Jawa Timur</p>
</div>
</div>
<div v-if="paginatedLokets.length > 0" class="lokets-grid">
<div
v-for="loket in paginatedLokets"
:key="loket.id"
class="loket-card"
@click="navigateToLoket(loket.id)"
>
<div class="loket-card-header">
<v-icon size="32" color="primary-600">mdi-view-dashboard</v-icon>
<div class="loket-info">
<h3 class="loket-name">{{ loket.namaLoket }}</h3>
<p class="loket-details">No. {{ loket.no }}</p>
</div>
</div>
<div class="loket-preview">
<div class="loket-service-count">
<v-icon size="18" color="primary-600">mdi-hospital-building</v-icon>
<span>{{ loket.pelayanan?.length || 0 }} Pelayanan</span>
</div>
<div class="loket-tags">
<v-chip
v-for="(pelayanan, idx) in (loket.pelayanan || []).slice(0, 3)"
:key="idx"
size="small"
class="ma-1 chip-preview"
>
{{ pelayanan }}
</v-chip>
<v-chip
v-if="(loket.pelayanan || []).length > 3"
size="small"
class="ma-1 chip-more"
>
+{{ (loket.pelayanan || []).length - 3 }}
</v-chip>
</div>
</div>
<div class="loket-card-footer">
<v-btn
color="primary-600"
variant="flat"
size="large"
block
class="btn-view"
>
<v-icon left>mdi-eye</v-icon>
Tampilkan
</v-btn>
</div>
</div>
</div>
<div v-else class="empty-state">
<v-icon size="64" color="grey-lighten-1">mdi-view-dashboard-off</v-icon>
<h3>Tidak Ada Loket Tersedia</h3>
<p>Silakan tambah loket terlebih dahulu di halaman master</p>
<v-btn
color="primary-600"
variant="flat"
class="mt-4"
@click="navigateToSettings"
>
<v-icon left>mdi-cog</v-icon>
Ke Halaman Master
</v-btn>
</div>
<div v-if="paginatedLokets.length > 0 && totalPages > 1" class="pagination">
<v-btn variant="outlined" :disabled="page <= 1" @click="goPrev">Prev</v-btn>
<span class="page-info">Page {{ page }} / {{ totalPages }}</span>
<v-btn variant="outlined" :disabled="page >= totalPages" @click="goNext">Next</v-btn>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { useLoketStore } from '@/stores/loketStore';
import { useRoute } from '#app';
definePageMeta({
layout: false,
});
const loketStore = useLoketStore();
const route = useRoute();
// Get all lokets
const allLokets = computed(() => {
return loketStore.loketData?.value || loketStore.loketData || [];
});
// Pagination
const itemsPerPage = 10;
const page = computed({
get: () => Number(route.query.page || 1),
set: (val) => navigateTo({ query: { ...route.query, page: val } }),
});
const totalPages = computed(() => Math.max(1, Math.ceil(allLokets.value.length / itemsPerPage)));
const paginatedLokets = computed(() => {
const start = (page.value - 1) * itemsPerPage;
return allLokets.value.slice(start, start + itemsPerPage);
});
const goPrev = () => {
if (page.value > 1) page.value = page.value - 1;
};
const goNext = () => {
if (page.value < totalPages.value) page.value = page.value + 1;
};
const navigateToLoket = (loketId) => {
navigateTo(`/anjungan/antrianloket/${loketId}`);
};
const navigateToSettings = () => {
navigateTo('/setting/masterloket');
};
</script>
<style scoped lang="scss">
.loket-selection-container {
background: var(--color-neutral-300);
min-height: 100vh;
padding: 40px;
font-family: 'Inter', 'Roboto', sans-serif;
}
.selection-header {
background: linear-gradient(135deg, var(--color-primary-600) 0%, var(--color-primary-700) 100%);
border-radius: 20px;
padding: 40px;
margin-bottom: 40px;
display: flex;
align-items: center;
gap: 32px;
box-shadow: 0 8px 24px rgba(33, 150, 243, 0.3);
}
.header-icon {
background: rgba(255, 255, 255, 0.15);
border-radius: 20px;
padding: 24px;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(10px);
}
.header-content {
flex: 1;
color: var(--color-neutral-100);
}
.main-title {
font-size: 48px;
font-weight: 800;
margin: 0;
letter-spacing: 2px;
color: var(--color-neutral-100);
line-height: 1.2;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.subtitle {
font-size: 20px;
font-weight: 600;
margin: 8px 0 0 0;
color: var(--color-neutral-100);
opacity: 0.95;
}
.lokets-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 24px;
max-width: 1400px;
margin: 0 auto;
}
.loket-card {
background: var(--color-neutral-100);
border-radius: 16px;
padding: 24px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
border: 2px solid var(--color-neutral-400);
cursor: pointer;
transition: all 0.3s ease;
display: flex;
flex-direction: column;
gap: 16px;
&:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(33, 150, 243, 0.2);
border-color: var(--color-primary-600);
}
}
.loket-card-header {
display: flex;
align-items: center;
gap: 16px;
padding-bottom: 16px;
border-bottom: 2px solid var(--color-neutral-400);
}
.loket-info {
flex: 1;
}
.loket-name {
font-size: 20px;
font-weight: 700;
margin: 0;
color: var(--color-neutral-900);
line-height: 1.2;
}
.loket-details {
font-size: 14px;
color: var(--color-neutral-600);
margin: 4px 0 0 0;
font-weight: 500;
}
.loket-preview {
flex: 1;
}
.loket-service-count {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 600;
color: var(--color-primary-600);
margin-bottom: 12px;
}
.loket-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.chip-preview {
background-color: var(--color-primary-300) !important;
color: var(--color-primary-700) !important;
font-weight: 500;
font-size: 12px;
}
.chip-more {
background-color: var(--color-neutral-600) !important;
color: var(--color-neutral-100) !important;
font-weight: 600;
font-size: 12px;
}
.loket-card-footer {
margin-top: auto;
}
.btn-view {
font-weight: 600;
text-transform: none;
letter-spacing: 0.5px;
}
.empty-state {
text-align: center;
padding: 80px 20px;
color: var(--color-neutral-700);
h3 {
font-size: 24px;
font-weight: 700;
margin: 24px 0 8px 0;
color: var(--color-neutral-800);
}
p {
font-size: 16px;
color: var(--color-neutral-600);
}
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-top: 24px;
}
.page-info {
font-weight: 600;
color: var(--color-neutral-800);
}
@media (max-width: 768px) {
.loket-selection-container {
padding: 20px;
}
.selection-header {
flex-direction: column;
text-align: center;
padding: 32px 24px;
}
.main-title {
font-size: 32px;
}
.subtitle {
font-size: 16px;
}
.lokets-grid {
grid-template-columns: 1fr;
gap: 16px;
}
}
</style>
+208 -32
View File
@@ -315,11 +315,12 @@
<v-card-text class="pa-3">
<div class="d-flex justify-space-between align-start">
<div class="flex-grow-1">
<div class="d-flex align-center mb-1">
<!-- Status & Method Chips -->
<div class="d-flex align-center flex-wrap gap-1 mb-2">
<v-chip
:color="getStatusColor(item.status)"
size="x-small"
class="mr-2"
density="compact"
>
<v-icon start size="12">{{ getStatusIcon(item.status) }}</v-icon>
{{ getStatusText(item.status) }}
@@ -328,22 +329,51 @@
color="grey-lighten-1"
size="x-small"
variant="text"
density="compact"
>
{{ item.method }}
</v-chip>
</div>
<p class="text-body-2 font-weight-medium mb-1">
<v-icon size="14" class="mr-1" :color="primaryColor">mdi-account-circle</v-icon>
{{ item.patientId }}
</p>
<!-- Patient ID -->
<div class="mb-2">
<p class="text-body-2 font-weight-medium mb-0">
<v-icon size="14" class="mr-1" :color="primaryColor">mdi-account-circle</v-icon>
{{ item.patientId }}
</p>
</div>
<!-- Antrean & Pembayaran Chips -->
<div class="d-flex align-center flex-wrap gap-1 mb-2">
<v-chip
v-if="item.klinikQueueNumber"
size="x-small"
:color="secondaryColor"
variant="tonal"
density="compact"
>
<v-icon start size="12">mdi-ticket</v-icon>
{{ item.klinikQueueNumber }}
</v-chip>
<v-chip
v-if="item.pembayaran"
size="x-small"
:color="primaryColor"
variant="tonal"
density="compact"
>
<v-icon start size="12">mdi-credit-card</v-icon>
{{ item.pembayaran }}
</v-chip>
</div>
<!-- Time -->
<div class="d-flex align-center">
<v-chip
size="x-small"
variant="text"
color="grey-darken-1"
class="text-caption"
density="compact"
>
<v-icon start size="12">mdi-clock-outline</v-icon>
{{ formatTime(item.checkInTime) }}
@@ -857,15 +887,36 @@
</v-chip>
</div>
<div class="mb-2">
<p class="text-body-1 font-weight-bold mb-1">
<!-- Patient Info -->
<div class="mb-3">
<p class="text-body-1 font-weight-bold mb-2">
<v-icon size="18" class="mr-1" :color="primaryColor">mdi-account-circle</v-icon>
ID Pasien: {{ item.patientId }}
</p>
<p v-if="item.queueNumber" class="text-body-2 text-grey mb-1">
<v-icon size="16" class="mr-1">mdi-ticket</v-icon>
Nomor Antrean: {{ item.queueNumber }}
{{ item.patientId }}
</p>
<!-- Antrean & Pembayaran Info Grid -->
<div class="d-flex flex-wrap gap-2 mb-2">
<v-chip
v-if="item.klinikQueueNumber"
size="small"
:color="secondaryColor"
variant="tonal"
density="comfortable"
>
<v-icon start size="16">mdi-ticket</v-icon>
{{ item.klinikQueueNumber }}
</v-chip>
<v-chip
v-if="item.pembayaran"
size="small"
:color="primaryColor"
variant="tonal"
density="comfortable"
>
<v-icon start size="16">mdi-credit-card</v-icon>
{{ item.pembayaran }}
</v-chip>
</div>
</div>
<div class="d-flex flex-wrap gap-2">
@@ -1132,6 +1183,8 @@ const historyStatusFilter = ref('');
const checkInHistory = ref<Array<{
patientId: string;
queueNumber?: string;
klinikQueueNumber?: string;
pembayaran?: string;
status: string;
checkInTime: string;
checkInDate: string;
@@ -1959,6 +2012,8 @@ const onDetect = async (decodedText: string) => {
saveToHistory({
patientId: checkInResult.patient.barcode,
queueNumber: checkInResult.patient.noAntrian,
klinikQueueNumber: checkInResult.patient.noAntrian?.split(" |")[0] || checkInResult.patient.noAntrian,
pembayaran: checkInResult.patient.pembayaran || 'N/A',
status: 'success',
checkInTime: new Date().toISOString(),
checkInDate: new Date().toISOString(),
@@ -1971,12 +2026,67 @@ const onDetect = async (decodedText: string) => {
infoAction.value = 'checkin';
infoDialog.value = true;
} else {
// Simpan ke history dengan status failed jika check-in gagal
const cleanInput = String(patientId).trim().toUpperCase();
const foundPatient = queueStore.allPatients.find(p => {
if (p.barcode === cleanInput || p.barcode === patientId) return true;
const parsedNo = parseInt(cleanInput.replace(/[^0-9]/g, '')) || parseInt(patientId);
if (!isNaN(parsedNo) && p.no === parsedNo) return true;
const noAntrianUpper = (p.noAntrian || '').toUpperCase();
if (noAntrianUpper.includes(cleanInput) || noAntrianUpper.includes(patientId)) return true;
return false;
});
saveToHistory({
patientId: patientId,
queueNumber: foundPatient?.noAntrian || null,
klinikQueueNumber: foundPatient?.noAntrian?.split(" |")[0] || null,
pembayaran: foundPatient?.pembayaran || 'N/A',
status: 'failed',
checkInTime: new Date().toISOString(),
checkInDate: new Date().toISOString(),
method: 'QR Scan'
});
infoMessage.value = `❌ Check-in Gagal!\n\n${checkInResult.message}`;
infoAction.value = 'checkin';
infoDialog.value = true;
}
} else {
// Jika belum diperbolehkan, tampilkan pesan
// Jika belum diperbolehkan, cari data pasien untuk disimpan ke history
// Cari pasien dari queueStore berdasarkan patientId/barcode
const cleanInput = String(patientId).trim().toUpperCase();
const foundPatient = queueStore.allPatients.find(p => {
// Exact barcode match
if (p.barcode === cleanInput || p.barcode === patientId) {
return true;
}
// Try parsing as number
const parsedNo = parseInt(cleanInput.replace(/[^0-9]/g, '')) || parseInt(patientId);
if (!isNaN(parsedNo) && p.no === parsedNo) {
return true;
}
// Check if noAntrian includes the input
const noAntrianUpper = (p.noAntrian || '').toUpperCase();
if (noAntrianUpper.includes(cleanInput) || noAntrianUpper.includes(patientId)) {
return true;
}
return false;
});
// Simpan ke history dengan status NOT_ALLOWED
saveToHistory({
patientId: patientId,
queueNumber: foundPatient?.noAntrian || null,
klinikQueueNumber: foundPatient?.noAntrian?.split(" |")[0] || null,
pembayaran: foundPatient?.pembayaran || 'N/A',
status: 'NOT_ALLOWED',
checkInTime: new Date().toISOString(),
checkInDate: new Date().toISOString(),
method: 'QR Scan'
});
// Tampilkan pesan
lastCheckInResult.value = {
success: false,
patientId: patientId,
@@ -2001,6 +2111,8 @@ const onDetect = async (decodedText: string) => {
saveToHistory({
patientId: checkInResult.patient.barcode,
queueNumber: checkInResult.patient.noAntrian,
klinikQueueNumber: checkInResult.patient.noAntrian?.split(" |")[0] || checkInResult.patient.noAntrian,
pembayaran: checkInResult.patient.pembayaran || 'N/A',
status: 'success',
checkInTime: new Date().toISOString(),
checkInDate: new Date().toISOString(),
@@ -2011,6 +2123,28 @@ const onDetect = async (decodedText: string) => {
infoAction.value = 'checkin';
infoDialog.value = true;
} else {
// Simpan ke history dengan status failed jika check-in gagal
const cleanInput = String(decodedText).trim().toUpperCase();
const foundPatient = queueStore.allPatients.find(p => {
if (p.barcode === cleanInput || p.barcode === decodedText) return true;
const parsedNo = parseInt(cleanInput.replace(/[^0-9]/g, '')) || parseInt(decodedText);
if (!isNaN(parsedNo) && p.no === parsedNo) return true;
const noAntrianUpper = (p.noAntrian || '').toUpperCase();
if (noAntrianUpper.includes(cleanInput) || noAntrianUpper.includes(decodedText)) return true;
return false;
});
saveToHistory({
patientId: decodedText,
queueNumber: foundPatient?.noAntrian || null,
klinikQueueNumber: foundPatient?.noAntrian?.split(" |")[0] || null,
pembayaran: foundPatient?.pembayaran || 'N/A',
status: 'failed',
checkInTime: new Date().toISOString(),
checkInDate: new Date().toISOString(),
method: 'QR Scan'
});
infoMessage.value = `❌ Check-in Gagal!\n\n${checkInResult.message}`;
infoAction.value = 'checkin';
infoDialog.value = true;
@@ -2071,6 +2205,8 @@ const checkInManual = async () => {
saveToHistory({
patientId: checkInResult.patient.barcode,
queueNumber: checkInResult.patient.noAntrian,
klinikQueueNumber: checkInResult.patient.noAntrian?.split(" |")[0] || checkInResult.patient.noAntrian,
pembayaran: checkInResult.patient.pembayaran || 'N/A',
status: 'success',
checkInTime: new Date().toISOString(),
checkInDate: new Date().toISOString(),
@@ -2083,6 +2219,28 @@ const checkInManual = async () => {
(manualForm.value as any).reset();
}
} else {
// Simpan ke history dengan status failed jika check-in gagal
const cleanInput = String(manualInput.value.trim()).toUpperCase();
const foundPatient = queueStore.allPatients.find(p => {
if (p.barcode === cleanInput || p.barcode === manualInput.value.trim()) return true;
const parsedNo = parseInt(cleanInput.replace(/[^0-9]/g, '')) || parseInt(manualInput.value.trim());
if (!isNaN(parsedNo) && p.no === parsedNo) return true;
const noAntrianUpper = (p.noAntrian || '').toUpperCase();
if (noAntrianUpper.includes(cleanInput) || noAntrianUpper.includes(manualInput.value.trim())) return true;
return false;
});
saveToHistory({
patientId: manualInput.value.trim(),
queueNumber: foundPatient?.noAntrian || null,
klinikQueueNumber: foundPatient?.noAntrian?.split(" |")[0] || null,
pembayaran: foundPatient?.pembayaran || 'N/A',
status: 'failed',
checkInTime: new Date().toISOString(),
checkInDate: new Date().toISOString(),
method: 'Manual'
});
showSnackbar('Gagal!', checkInResult.message || 'Check-in manual gagal dilakukan. Silakan coba lagi!', 'error', 'mdi-close-circle');
}
};
@@ -2283,6 +2441,8 @@ const loadHistory = () => {
const saveToHistory = (item: {
patientId: string;
queueNumber?: string;
klinikQueueNumber?: string;
pembayaran?: string;
status: string;
checkInTime: string;
checkInDate: string;
@@ -2313,6 +2473,8 @@ const saveToHistory = (item: {
(history: {
patientId: string;
queueNumber?: string;
klinikQueueNumber?: string;
pembayaran?: string;
status: string;
checkInTime: string;
checkInDate: string;
@@ -2464,25 +2626,29 @@ const recentHistory = computed(() => {
const getStatusColor = (status: string) => {
if (status === 'ALLOWED' || status === 'success') return 'success';
if (status === 'NOT_ALLOWED' || status === 'failed') return 'error';
if (status === 'NOT_ALLOWED') return 'warning';
if (status === 'failed') return 'error';
return 'warning';
};
const getStatusIcon = (status: string) => {
if (status === 'ALLOWED' || status === 'success') return 'mdi-check-circle';
if (status === 'NOT_ALLOWED' || status === 'failed') return 'mdi-close-circle';
if (status === 'NOT_ALLOWED') return 'mdi-clock-alert';
if (status === 'failed') return 'mdi-close-circle';
return 'mdi-clock-alert';
};
const getStatusText = (status: string) => {
if (status === 'ALLOWED' || status === 'success') return 'Berhasil';
if (status === 'NOT_ALLOWED' || status === 'failed') return 'Gagal';
if (status === 'NOT_ALLOWED') return 'Menunggu';
if (status === 'failed') return 'Gagal';
return 'Pending';
};
const getStatusClass = (status: string) => {
if (status === 'ALLOWED' || status === 'success') return 'history-success';
if (status === 'NOT_ALLOWED' || status === 'failed') return 'history-failed';
if (status === 'NOT_ALLOWED') return 'history-pending';
if (status === 'failed') return 'history-failed';
return 'history-pending';
};
@@ -2553,17 +2719,14 @@ const statsCompleted = computed(() => {
});
const statsWaiting = computed(() => {
const todayAfterReset = getTodayAfterReset();
// Ambil data menunggu dari queueStore untuk stage 'loket'
// Menghitung pasien dengan status 'menunggu' (belum dipanggil) atau 'waiting' (sudah dipanggil tapi belum check-in)
const loketPatients = queueStore.getPatientsByStage('loket');
const menungguPatients = loketPatients.value.menunggu || [];
const waitingPatients = loketPatients.value.waiting || [];
return checkInHistory.value.filter(item => {
const itemDate = new Date(item.checkInDate || item.checkInTime);
const itemDateStr = itemDate.toISOString().split('T')[0];
// Only count items from today (after reset time consideration)
if (itemDateStr !== todayAfterReset) return false;
return item.status === 'NOT_ALLOWED' || item.status === 'pending' || item.status === 'failed';
}).length;
// Total pasien yang masih menunggu check-in (belum dipanggil + sudah dipanggil tapi belum check-in)
return menungguPatients.length + waitingPatients.length;
});
// Load history on mount
@@ -2836,6 +2999,7 @@ if (typeof window !== 'undefined') {
.history-item-compact {
transition: all 0.2s ease;
border-left: 3px solid transparent;
}
.history-item-compact:hover {
@@ -2844,15 +3008,27 @@ if (typeof window !== 'undefined') {
}
.history-item-compact.history-success {
border-left: 3px solid #4caf50;
border-left-color: #4caf50;
background: rgba(76, 175, 80, 0.03);
}
.history-item-compact.history-failed {
border-left: 3px solid #f44336;
border-left-color: #f44336;
background: rgba(244, 67, 54, 0.03);
}
.history-item-compact.history-pending {
border-left: 3px solid #ff9800;
border-left-color: #ff9800;
background: rgba(255, 152, 0, 0.03);
}
/* Gap utility untuk flex-wrap */
.gap-1 {
gap: 4px;
}
.gap-2 {
gap: 8px;
}
@media (min-width: 960px) {
+12 -15
View File
@@ -1464,27 +1464,24 @@ const saveItem = async () => {
return;
}
// If hakAksesMenu is empty or not fetched yet, fetch from backend
const needsFetch = !editedItem.value.hakAksesMenu ||
editedItem.value.hakAksesMenu.length === 0 ||
!editedItem.value.hakAksesMenu.some(m => m.canAccess || m.canView || m.canAdd || m.canEdit || m.canDelete);
// Check if permissions have been fetched from backend
// If not, show warning and ask user to fetch first
const hasFetchedPermissions = fetchedBackendData.value.length > 0 ||
(editedItem.value.hakAksesMenu &&
editedItem.value.hakAksesMenu.length > 0 &&
editedItem.value.hakAksesMenu.some(m => m.canAccess || m.canView || m.canAdd || m.canEdit || m.canDelete));
if (needsFetch) {
if (!hasFetchedPermissions) {
snackbar.value = {
show: true,
message: 'Mengambil permissions dari backend...',
color: 'info',
timeout: 2000,
message: 'Silakan klik tombol "Ambil Data dari Backend API" terlebih dahulu untuk mengambil permissions!',
color: 'warning',
timeout: 5000,
};
// Fetch permissions from backend before saving
await fetchPermissionsFromBackend();
// Wait a bit for the fetch to complete
await new Promise(resolve => setTimeout(resolve, 500));
return;
}
// Ensure hakAksesMenu exists
// Ensure hakAksesMenu exists (fallback to empty template if somehow missing)
if (!editedItem.value.hakAksesMenu || editedItem.value.hakAksesMenu.length === 0) {
editedItem.value.hakAksesMenu = buildMenuTemplate(navItemsStore.navItems);
}