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

This commit is contained in:
bagus-arie05
2026-01-15 08:01:52 +07:00
7 changed files with 1549 additions and 326 deletions
+512 -263
View File
@@ -9,12 +9,11 @@
src="/Rumah_Sakit_Umum_Daerah_Dr._Saiful_Anwar.webp"
alt="RSUD Logo"
class="header-logo"
/>
>
</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>
<h1 class="hospital-name">RSUD dr. Saiful Anwar Provinsi Jawa Timur</h1>
<!-- <p class="display-subtitle">RSUD dr. Saiful Anwar Provinsi Jawa Timur</p> -->
</div>
</div>
<div class="header-right">
@@ -25,50 +24,57 @@
</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 A' }}</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"
<!-- Main Grid - Display Loket Cards -->
<div class="loket-grid-container">
<!-- Loket Grid -->
<div class="lokets-grid" :style="gridStyle">
<div
v-for="loketData in displayedLokets"
:key="loketData.id"
class="loket-card"
>
<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>
<!-- Loket Header -->
<div class="loket-header-bar">
<div class="loket-name">{{ loketData.nama }}</div>
<div class="queue-count">
<v-icon size="18" color="white">mdi-account-multiple</v-icon>
<span class="count-number">{{ loketData.totalQueues }}</span>
</div>
</div>
<!-- Queue Display - Vertical Scrollable -->
<div
:ref="el => setQueueDisplayRef(loketData.id, el)"
class="queue-display"
:class="{ 'has-scroll': loketData.queues.length > 20 }"
>
<!-- Ticket Cards List -->
<div v-if="loketData.queues.length > 0" class="ticket-cards-list" :style="ticketGridStyle">
<div
v-for="(queue, index) in loketData.queues"
:key="`${loketData.id}-${queue.no}-${queue.barcode || index}`"
class="ticket-card"
:class="{ 'ticket-card-fast-track': queue.fastTrack === 'YA' }"
>
<!-- Fast Track Badge - Pojok Kanan Atas -->
<div v-if="queue.fastTrack === 'YA'" class="ticket-fast-track">
<v-icon color="white" size="14">mdi-flash</v-icon>
</div>
<div class="ticket-number">{{ queue.noAntrian?.split(" |")[0] || queue.no || '' }}</div>
<!-- <div v-if="queue.klinik" class="ticket-klinik">{{ queue.klinik }}</div> -->
</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>
<!-- Empty State -->
<div
v-else
class="empty-queue"
>
<v-icon size="40" color="grey-lighten-3">mdi-minus-circle-outline</v-icon>
<div class="empty-text">Tidak Ada Antrian</div>
</div>
</div>
</div>
</div>
</div>
@@ -84,7 +90,7 @@
</div>
</div>
<div class="stat-divider"></div>
<div class="stat-divider" />
<div class="stat-item">
<div class="stat-icon stat-icon-waiting">
@@ -96,7 +102,7 @@
</div>
</div>
<div class="stat-divider"></div>
<div class="stat-divider" />
<div class="stat-item">
<div class="stat-icon stat-icon-active">
@@ -117,9 +123,10 @@
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { useQueueStore } from '@/stores/queueStore'
import { useMasterStore } from '@/stores/masterStore'
import { useAntreanMasukScreenStore } from '@/stores/antreanMasukScreenStore'
import { useRoute } from '#app'
definePageMeta({
@@ -129,8 +136,10 @@ definePageMeta({
const route = useRoute()
const queueStore = useQueueStore()
const masterStore = useMasterStore()
const antreanMasukScreenStore = useAntreanMasukScreenStore()
const loketId = computed(() => {
// Get screen ID from route (not loket ID)
const screenId = computed(() => {
const id = route.params.id
// Handle both string and array cases
const idValue = Array.isArray(id) ? id[0] : id
@@ -138,26 +147,51 @@ const loketId = computed(() => {
return isNaN(parsed) ? null : parsed
})
const loketData = computed(() => {
const id = loketId.value
// Get screen configuration
const screenData = computed(() => {
const id = screenId.value
if (!id) return null
return masterStore.getLoketById(id) || null
const screen = antreanMasukScreenStore.antreanMasukScreenItems.find(s => s.id === id)
return screen || null
})
// Get all loket IDs configured for this screen
const configuredLoketIds = computed(() => {
return screenData.value?.loket || []
})
const currentTime = ref('')
const currentDate = ref('')
let timeInterval = null
// Refs for queue display containers (for auto-scroll)
const queueDisplayRefs = ref({})
const setQueueDisplayRef = (loketId, el) => {
if (el) {
queueDisplayRefs.value[loketId] = el
}
}
// Get all patients with processStage 'loket'
// Force reactivity by accessing the computed result directly
const loketPatients = computed(() => {
try {
const result = queueStore.getPatientsByStage('loket')
if (result && result.value && Array.isArray(result.value.all)) {
return result.value.all
// Get the computed result from store
const stageResult = queueStore.getPatientsByStage('loket')
// Access .value to get the actual computed value
// This ensures reactivity after store hydration
if (stageResult && stageResult.value) {
const patients = stageResult.value.all || []
// Ensure it's an array and filter out any null/undefined
return Array.isArray(patients) ? patients.filter(p => p != null) : []
}
return []
} catch {
} catch (error) {
console.error('Error getting loket patients:', error)
return []
}
})
@@ -166,14 +200,12 @@ const loketPatients = computed(() => {
// 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
// Filter berdasarkan loket yang dikonfigurasi di screen
const calledForCheckIn = computed(() => {
const currentLoketId = loketId.value
const loketIds = configuredLoketIds.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)
// Jika tidak ada loket yang dikonfigurasi, return empty array
if (!loketIds || loketIds.length === 0) {
return []
}
@@ -183,51 +215,127 @@ const calledForCheckIn = computed(() => {
// and not yet checked in (processStage still 'loket')
const isCalled = p.status === 'waiting' && p.processStage === 'loket'
// Tampilkan semua pasien dengan status 'waiting', tidak hanya dari anjungan
// Semua pasien yang sudah dipanggil oleh admin loket harus ditampilkan
// Filter berdasarkan loketId yang dikonfigurasi di screen
const patientLoketId = p.loketId || 1 // Default ke loket 1 jika tidak ada
const isForConfiguredLoket = loketIds.includes(patientLoketId)
// 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 && isForThisLoket
return isCalled && isForConfiguredLoket
})
// 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 {
// Group queues by loketId and create loket cards
const displayedLokets = computed(() => {
const loketIds = configuredLoketIds.value
if (!loketIds || loketIds.length === 0) {
return []
}
// Ensure calledForCheckIn is an array
const queues = Array.isArray(calledForCheckIn.value) ? calledForCheckIn.value : []
// Group queues by loketId
const queuesByLoket = new Map()
loketIds.forEach(loketId => {
const loket = masterStore.getLoketById(loketId)
const loketQueues = queues
.filter(q => {
const patientLoketId = q.loketId || 1
return patientLoketId === loketId
})
.sort((a, b) => {
// Sort by createdAt or no for consistent ordering
if (a.createdAt && b.createdAt) {
return new Date(a.createdAt) - new Date(b.createdAt)
}
return (a.no || 0) - (b.no || 0)
})
queuesByLoket.set(loketId, {
id: loketId,
nama: loket ? loket.namaLoket : `Loket ${loketId}`,
queues: loketQueues,
totalQueues: loketQueues.length
})
})
// Filter: hanya tampilkan loket yang memiliki antrian
return Array.from(queuesByLoket.values()).filter(loket => loket.queues.length > 0)
})
// Calculate grid columns based on number of displayed lokets
const gridColumns = computed(() => {
const count = displayedLokets.value.length
// Jika hanya 1-2 loket, tampilkan 1 kolom (full width)
if (count <= 2) return 1
// Jika 3-4 loket, tampilkan 2 kolom
if (count <= 4) return 2
// Jika 5-6 loket, tampilkan 3 kolom
if (count <= 6) return 3
// Jika 7-9 loket, tampilkan 4 kolom
if (count <= 9) return 4
// Jika 10-12 loket, tampilkan 5 kolom
if (count <= 12) return 5
// Jika 13-14 loket, tampilkan 6 kolom
if (count <= 14) return 6
// Default: 7 kolom untuk 15+ loket
return 7
})
const gridStyle = computed(() => {
return {
gridTemplateColumns: `repeat(${gridColumns.value}, 1fr)`
}
})
// Calculate ticket grid columns based on loket grid columns
// When loket is wider (fewer columns), tickets can form more columns
const ticketGridColumns = computed(() => {
const loketCols = gridColumns.value
// Jika hanya 1 kolom loket (full width), tiket bisa 3 kolom
if (loketCols === 1) return 3
// Jika 2 kolom loket, tiket bisa 2 kolom
if (loketCols === 2) return 2
// Jika 3 kolom loket, tiket bisa 2 kolom
if (loketCols === 3) return 2
// Jika 4+ kolom loket, tiket 1 kolom (vertikal)
return 1
})
const ticketGridStyle = computed(() => {
return {
gridTemplateColumns: `repeat(${ticketGridColumns.value}, 1fr)`
}
})
const statistics = computed(() => {
// Ensure we always return default values even if data is not ready
try {
const currentLoketId = loketId.value
const loketIds = configuredLoketIds.value
// Jika tidak ada loket yang dikonfigurasi, return 0
if (!loketIds || loketIds.length === 0) {
return {
total: 0,
waiting: 0,
active: 0
}
}
// Ensure loketPatients is an array
const patients = Array.isArray(loketPatients.value) ? loketPatients.value : []
// Filter semua pasien untuk loket ini (tidak hanya dari anjungan)
// Untuk saat ini, hanya loket 1 yang aktif
// Filter semua pasien untuk loket-loket yang dikonfigurasi di screen
const allLoketTickets = patients.filter(p => {
if (!p) return false
// Filter berdasarkan loketId (untuk saat ini hanya loket 1)
const isForThisLoket = !p.loketId || p.loketId === currentLoketId
return isForThisLoket
// Filter berdasarkan loketId yang dikonfigurasi
const patientLoketId = p.loketId || 1 // Default ke loket 1 jika tidak ada
return loketIds.includes(patientLoketId)
})
// Menunggu = belum dipanggil (status 'menunggu')
@@ -269,30 +377,142 @@ const updateTime = () => {
})
}
// Auto-scroll setiap 2 detik dengan loop (atas -> bawah -> atas)
let scrollIntervals = {}
const startAutoScroll = () => {
// Clear existing intervals
Object.values(scrollIntervals).forEach(interval => {
if (interval) clearInterval(interval)
})
scrollIntervals = {}
nextTick(() => {
displayedLokets.value.forEach(loketData => {
const container = queueDisplayRefs.value[loketData.id]
if (container && loketData.queues.length > 0) {
let isScrollingDown = true
let currentScrollTop = 0
scrollIntervals[loketData.id] = setInterval(() => {
const maxScroll = container.scrollHeight - container.clientHeight
// Jika konten tidak cukup untuk di-scroll, skip
if (maxScroll <= 0) {
return
}
// Hitung ukuran scroll berdasarkan tinggi card
// Ambil tinggi card pertama jika ada, atau gunakan default 80px (min-height ticket-card)
let scrollAmount = 80 // Default: min-height ticket-card
const firstCard = container.querySelector('.ticket-card')
if (firstCard) {
const cardRect = firstCard.getBoundingClientRect()
const cardHeight = cardRect.height
// Tambahkan gap (10px dari .ticket-cards-list gap)
scrollAmount = cardHeight + 10
}
if (isScrollingDown) {
// Scroll ke bawah dengan ukuran card
currentScrollTop += scrollAmount
if (currentScrollTop >= maxScroll) {
// Sudah sampai bawah, kembali ke atas dan lanjut scroll ke bawah
currentScrollTop = 0
// Tetap isScrollingDown = true agar langsung scroll ke bawah lagi
}
} else {
// Scroll ke atas (tidak digunakan karena langsung reset ke atas)
currentScrollTop -= scrollAmount
if (currentScrollTop <= 0) {
currentScrollTop = 0
isScrollingDown = true
}
}
container.scrollTop = currentScrollTop
}, 2000) // Setiap 2 detik
}
})
})
}
// Watch loketPatients to ensure reactivity after refresh
watch(loketPatients, (newPatients) => {
// Force update by accessing the computed
// This ensures reactivity after store hydration
if (newPatients && newPatients.length > 0) {
console.log('Loket patients updated:', newPatients.length)
}
}, { immediate: true, deep: true })
// Watch store's getPatientsByStage to ensure reactivity after refresh
watch(() => {
try {
const result = queueStore.getPatientsByStage('loket')
return result?.value?.all || []
} catch {
return []
}
}, (newPatients) => {
// Force reactivity update
if (newPatients && newPatients.length > 0) {
console.log('Store loket patients updated:', newPatients.length)
}
}, { immediate: true, deep: true })
// Watch displayedLokets untuk restart auto-scroll
watch(displayedLokets, () => {
startAutoScroll()
}, { deep: true })
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) {
// Redirect to index if screen not found
if (!screenData.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
}
// Force reactivity by accessing store data after delays
// This ensures store is fully hydrated after refresh
setTimeout(() => {
// Access loketPatients to trigger computed
const _ = loketPatients.value
// Access calledForCheckIn to trigger computed
const __ = calledForCheckIn.value
// Access displayedLokets to trigger computed
const ___ = displayedLokets.value
}, 100)
// Additional delay to ensure store hydration is complete
setTimeout(() => {
// Force re-computation by accessing again
const _ = loketPatients.value
const __ = calledForCheckIn.value
const ___ = displayedLokets.value
}, 300)
updateTime()
timeInterval = setInterval(updateTime, 1000)
// Start auto-scroll setelah delay singkat
setTimeout(() => {
startAutoScroll()
}, 500)
})
})
onUnmounted(() => {
if (timeInterval) clearInterval(timeInterval)
// Clear all scroll intervals
Object.values(scrollIntervals).forEach(interval => {
if (interval) clearInterval(interval)
})
scrollIntervals = {}
})
</script>
@@ -432,160 +652,208 @@ onUnmounted(() => {
opacity: 0.95;
}
/* ========== QUEUE GRID CONTAINER ========== */
.queue-grid-container {
/* ========== LOKET GRID CONTAINER ========== */
.loket-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;
.lokets-grid {
display: grid;
gap: 16px;
width: 100%;
box-sizing: border-box;
/* grid-template-columns is set dynamically via :style binding */
}
.loket-card {
background: #FFFFFF;
border-radius: 8px;
border: 1px solid #E0E0E0;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
flex-direction: column;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
min-height: 400px;
/* Calculate: viewport - header (~120px) - footer (100px) - container padding (48px) - margins (44px) */
max-height: calc(100vh - 312px);
}
.loket-header .loket-title {
font-size: 36px;
font-weight: 800;
color: #FFFFFF;
margin: 0;
/* Loket Header */
.loket-header-bar {
background: #FFFFFF;
padding: 16px 20px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 3px solid #FF9B1B;
flex-shrink: 0;
}
.loket-name {
font-size: 20px;
font-weight: 700;
color: #212121;
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 {
.queue-count {
display: flex;
align-items: center;
gap: 6px;
background: var(--color-primary-600);
padding: 6px 12px;
border-radius: 20px;
min-width: 50px;
justify-content: center;
}
.fast-track-icon {
animation: pulse 2s ease-in-out infinite;
.queue-count .v-icon {
color: white !important;
}
@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;
.count-number {
font-size: 16px;
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);
color: #FFFFFF;
}
.empty-state {
/* Queue Display - Vertical Scrollable */
.queue-display {
flex: 1;
padding: 20px;
padding-bottom: 120px; /* Extra padding to prevent footer overlap */
overflow-y: auto;
overflow-x: hidden;
background: #FFFFFF;
min-height: 0;
scroll-behavior: smooth;
box-sizing: border-box;
}
.queue-display.has-scroll {
max-height: calc(100vh - 400px);
}
/* Hide scrollbar */
.queue-display {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
}
.queue-display::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
.ticket-cards-list {
display: grid;
gap: 10px;
width: 100%;
padding-bottom: 20px; /* Additional bottom padding for last card */
/* grid-template-columns is set dynamically via :style binding */
}
.ticket-card {
background: #F5F7FA ;
border: 1px solid #E0E0E0;
border-radius: 8px;
padding: 12px 16px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
transition: all 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
min-height: 80px;
position: relative; /* Untuk absolute positioning fast track icon */
}
.ticket-card-fast-track {
background: rgba(255, 132, 65, 0.4) !important; /* #FF8441 dengan 40% opacity (66 hex = 102/255) */
}
.ticket-card:hover {
box-shadow: 0 2px 6px rgba(25, 118, 210, 0.2);
border-color: var(--color-primary-600);
transform: translateY(-1px);
}
.ticket-number {
font-size: 28px;
font-weight: 800;
color: #3556AE;
letter-spacing: 1px;
line-height: 1.2;
}
.ticket-klinik {
font-size: 11px;
font-weight: 500;
color: #717171;
text-align: center;
margin-top: 4px;
}
.ticket-fast-track {
position: absolute;
top: 8px;
right: 8px;
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(239, 83, 80, 0.15); /* Red background dengan opacity */
border: 2px solid rgba(229, 57, 53, 0.4); /* Red border */
transition: all 0.2s ease;
z-index: 10;
box-shadow: 0 2px 8px rgba(229, 57, 53, 0.2);
}
.ticket-fast-track:hover {
background: #E53935; /* Solid red on hover */
border-color: #E53935;
transform: scale(1.1);
box-shadow: 0 4px 12px rgba(229, 57, 53, 0.4);
}
.ticket-fast-track .v-icon {
color: #E53935 !important;
transition: all 0.2s ease;
}
.ticket-fast-track:hover .v-icon {
color: white !important;
}
.empty-queue {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
min-height: 300px;
}
.empty-queue .v-icon {
color: #BDBDBD !important;
margin-bottom: 12px;
}
.empty-text {
font-size: 18px;
font-size: 14px;
color: #89939E;
margin-top: 12px;
font-weight: 600;
margin-top: 8px;
font-weight: 500;
}
/* ========== FOOTER STATS ========== */
@@ -759,27 +1027,19 @@ onUnmounted(() => {
font-size: 28px;
}
.loket-header-bar {
padding: 20px 24px;
.lokets-grid {
gap: 12px;
/* grid-template-columns is set dynamically via :style binding */
}
.loket-title {
.loket-name {
font-size: 20px;
}
.ticket-number {
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;
}
@@ -842,53 +1102,41 @@ onUnmounted(() => {
}
@media (max-width: 960px) {
.patient-cards-grid {
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 16px;
.lokets-grid {
gap: 12px;
/* grid-template-columns is set dynamically via :style binding */
}
.queue-number-large {
font-size: 64px;
.ticket-number {
font-size: 20px;
color: #3556AE;
}
.patient-card-display {
min-height: 240px;
.ticket-card {
min-height: 70px;
padding: 10px;
}
}
@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;
.lokets-grid {
gap: 12px;
/* grid-template-columns is set dynamically via :style binding */
}
}
@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;
.lokets-grid {
gap: 14px;
/* grid-template-columns is set dynamically via :style binding */
}
}
/* Fixed layout for 1920x1080 */
/* Fixed layout for 1920x1080 and above */
@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;
.lokets-grid {
gap: 16px;
/* grid-template-columns is set dynamically via :style binding */
}
}
@@ -901,12 +1149,13 @@ onUnmounted(() => {
font-size: 36px;
}
.loket-header .loket-title {
font-size: 28px;
.loket-name {
font-size: 18px;
}
.queue-grid-container {
padding: 16px;
.ticket-number {
font-size: 20px;
color: #3556AE;
}
}
+56 -59
View File
@@ -1,56 +1,56 @@
<!-- pages/Anjungan/AntreanMasuk/index.vue -->
<template>
<div class="loket-selection-container">
<div class="screen-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>
<h1 class="main-title">Pilih Layar Antrean Masuk</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-if="paginatedScreens.length > 0" class="screens-grid">
<div
v-for="loket in paginatedLokets"
:key="loket.id"
class="loket-card"
@click="navigateToLoket(loket.id)"
v-for="screen in paginatedScreens"
:key="screen.id"
class="screen-card"
@click="navigateToScreen(screen.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. {{ numberToLetter(loket.no) }}</p>
<div class="screen-card-header">
<v-icon size="32" color="primary">mdi-monitor</v-icon>
<div class="screen-info">
<h3 class="screen-name">{{ screen.namaScreen }}</h3>
<p class="screen-details">{{ screen.nomorScreen }}</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 class="screen-preview">
<div class="screen-loket-count">
<v-icon size="18" color="primary">mdi-view-dashboard</v-icon>
<span>{{ screen.loket?.length || 0 }} Loket</span>
</div>
<div class="loket-tags">
<div class="screen-tags">
<v-chip
v-for="(pelayanan, idx) in (loket.pelayanan || []).slice(0, 3)"
v-for="(loketId, idx) in (screen.loket || []).slice(0, 3)"
:key="idx"
size="small"
class="ma-1 chip-preview"
>
{{ pelayanan }}
{{ getLoketNameById(loketId) }}
</v-chip>
<v-chip
v-if="(loket.pelayanan || []).length > 3"
v-if="(screen.loket || []).length > 3"
size="small"
class="ma-1 chip-more"
>
+{{ (loket.pelayanan || []).length - 3 }}
+{{ (screen.loket || []).length - 3 }}
</v-chip>
</div>
</div>
<div class="loket-card-footer">
<div class="screen-card-footer">
<v-btn
color="primary-600"
variant="flat"
@@ -66,9 +66,9 @@
</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-icon size="64" color="grey-lighten-1">mdi-monitor-off</v-icon>
<h3>Tidak Ada Layar Tersedia</h3>
<p>Silakan tambah layar terlebih dahulu di halaman master</p>
<v-btn
color="primary-600"
variant="flat"
@@ -80,7 +80,7 @@
</v-btn>
</div>
<div v-if="paginatedLokets.length > 0 && totalPages > 1" class="pagination">
<div v-if="paginatedScreens.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>
@@ -90,30 +90,27 @@
<script setup>
import { computed } from 'vue';
import { useMasterStore } from '@/stores/masterStore';
import { useAntreanMasukScreenStore } from '@/stores/antreanMasukScreenStore';
import { useLoketStore } from '@/stores/loketStore';
import { useRoute } from '#app';
definePageMeta({
layout: false,
});
const masterStore = useMasterStore();
const antreanMasukScreenStore = useAntreanMasukScreenStore();
const loketStore = useLoketStore();
const route = useRoute();
// Helper function: Convert nomor loket ke huruf (1 -> A, 2 -> B, dst)
const numberToLetter = (num) => {
if (num < 1) return 'A';
const charCode = 64 + num;
return String.fromCharCode(Math.min(charCode, 90));
// Helper to get loket name by ID
const getLoketNameById = (loketId) => {
const loket = loketStore.getLoketById(loketId);
return loket ? loket.namaLoket : `Loket ${loketId}`;
};
// 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);
// Get all screens from store
const allScreens = computed(() => {
return antreanMasukScreenStore.antreanMasukScreenItems || [];
});
// Pagination
@@ -123,11 +120,11 @@ const page = computed({
set: (val) => navigateTo({ query: { ...route.query, page: val } }),
});
const totalPages = computed(() => Math.max(1, Math.ceil(allLokets.value.length / itemsPerPage)));
const totalPages = computed(() => Math.max(1, Math.ceil(allScreens.value.length / itemsPerPage)));
const paginatedLokets = computed(() => {
const paginatedScreens = computed(() => {
const start = (page.value - 1) * itemsPerPage;
return allLokets.value.slice(start, start + itemsPerPage);
return allScreens.value.slice(start, start + itemsPerPage);
});
const goPrev = () => {
@@ -138,17 +135,17 @@ const goNext = () => {
if (page.value < totalPages.value) page.value = page.value + 1;
};
const navigateToLoket = (loketId) => {
navigateTo(`/anjungan/antreanmasuk/${loketId}`);
const navigateToScreen = (screenId) => {
navigateTo(`/anjungan/antreanmasuk/${screenId}`);
};
const navigateToSettings = () => {
navigateTo('/setting/masterloket');
navigateTo('/setting/screenantreanmasuk');
};
</script>
<style scoped lang="scss">
.loket-selection-container {
.screen-selection-container {
background: var(--color-neutral-300);
min-height: 100vh;
padding: 40px;
@@ -199,7 +196,7 @@ const navigateToSettings = () => {
opacity: 0.95;
}
.lokets-grid {
.screens-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 24px;
@@ -207,7 +204,7 @@ const navigateToSettings = () => {
margin: 0 auto;
}
.loket-card {
.screen-card {
background: var(--color-neutral-100);
border-radius: 16px;
padding: 24px;
@@ -226,7 +223,7 @@ const navigateToSettings = () => {
}
}
.loket-card-header {
.screen-card-header {
display: flex;
align-items: center;
gap: 16px;
@@ -234,11 +231,11 @@ const navigateToSettings = () => {
border-bottom: 2px solid var(--color-neutral-400);
}
.loket-info {
.screen-info {
flex: 1;
}
.loket-name {
.screen-name {
font-size: 20px;
font-weight: 700;
margin: 0;
@@ -246,18 +243,18 @@ const navigateToSettings = () => {
line-height: 1.2;
}
.loket-details {
.screen-details {
font-size: 14px;
color: var(--color-neutral-600);
margin: 4px 0 0 0;
font-weight: 500;
}
.loket-preview {
.screen-preview {
flex: 1;
}
.loket-service-count {
.screen-loket-count {
display: flex;
align-items: center;
gap: 6px;
@@ -267,7 +264,7 @@ const navigateToSettings = () => {
margin-bottom: 12px;
}
.loket-tags {
.screen-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
@@ -287,7 +284,7 @@ const navigateToSettings = () => {
font-size: 12px;
}
.loket-card-footer {
.screen-card-footer {
margin-top: auto;
}
@@ -329,7 +326,7 @@ const navigateToSettings = () => {
}
@media (max-width: 768px) {
.loket-selection-container {
.screen-selection-container {
padding: 20px;
}
@@ -347,7 +344,7 @@ const navigateToSettings = () => {
font-size: 16px;
}
.lokets-grid {
.screens-grid {
grid-template-columns: 1fr;
gap: 16px;
}
+1 -1
View File
@@ -915,6 +915,7 @@ onUnmounted(() => {
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(10px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
@@ -923,7 +924,6 @@ onUnmounted(() => {
height: 64px;
object-fit: contain;
padding: 4px;
backdrop-filter: blur(10px);
}
.hospital-name {
+789
View File
@@ -0,0 +1,789 @@
<template>
<v-container>
<v-card>
<!-- Header -->
<div class="page-header">
<div class="header-content">
<div class="header-left">
<div class="header-icon">
<v-icon size="32" color="white">mdi-ticket-account</v-icon>
</div>
<div class="header-text">
<h2 class="page-title">Screen Antrean Masuk</h2>
<p class="page-subtitle">Rabu, 13 Agustus 2025 - Konfigurasi Layar Antrean Masuk</p>
</div>
</div>
<v-btn
color="white"
@click="openTambahDialog"
elevation="0"
class="add-btn"
>
<v-icon left size="20">mdi-plus-circle</v-icon>
Tambah Screen
</v-btn>
</div>
</div>
<!-- Table -->
<v-card-text>
<v-data-table
:headers="headers"
:items="antreanMasukScreenItems"
:items-per-page="10"
class="elevation-0 data-table"
>
<template v-slot:item.no="{ index }">
{{ index + 1 }}
</template>
<template v-slot:item.loket="{ item }">
<div class="loket-tags">
<v-chip
v-for="(loketId, idx) in item.loket.slice(0, 3)"
:key="idx"
size="small"
class="ma-1 chip-secondary-outline"
>
{{ getLoketNameById(loketId) }}
</v-chip>
<v-chip
v-if="item.loket.length > 3"
size="small"
class="ma-1 chip-neutral"
>
+{{ item.loket.length - 3 }}
</v-chip>
</div>
</template>
<template v-slot:item.layarInformasi="{ item }">
<v-btn
size="small"
@click="openPreviewDialog(item)"
variant="flat"
class="btn-preview mr-2"
>
<v-icon size="16" left>mdi-eye</v-icon>
Preview
</v-btn>
</template>
<template v-slot:item.actions="{ item }">
<v-btn
size="small"
@click="openEditDialog(item)"
variant="flat"
class="btn-edit mr-2"
>
<v-icon size="16" left>mdi-cog</v-icon>
Konfigurasi
</v-btn>
<v-btn
size="small"
@click="handleDelete(item)"
variant="outlined"
class="btn-delete"
>
<v-icon size="16" left>mdi-delete</v-icon>
Delete
</v-btn>
</template>
</v-data-table>
</v-card-text>
</v-card>
<!-- Dialog Tambah/Edit -->
<v-dialog v-model="dialog" max-width="700px" persistent scrollable>
<v-card class="dialog-card">
<v-card-title class="dialog-header">
<span class="headline-4">{{ isEdit ? 'Konfigurasi Screen' : 'Tambah Screen Baru' }}</span>
<v-btn icon variant="text" @click="closeDialog" size="small" class="btn-close">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-divider></v-divider>
<v-card-text class="dialog-content">
<v-form ref="formRef">
<!-- Informasi Screen -->
<div class="field-group">
<div class="group-label">Informasi Screen</div>
<v-text-field
label="Nama Screen"
v-model="formData.namaScreen"
variant="outlined"
density="compact"
:rules="[v => !!v || 'Nama screen harus diisi']"
hide-details="auto"
class="mb-3 input-field"
placeholder="Layar Antrean Masuk 1"
></v-text-field>
<v-text-field
label="Nomor Screen"
v-model="formData.nomorScreen"
variant="outlined"
density="compact"
hide-details="auto"
class="mb-3 input-field"
placeholder="AM-001"
></v-text-field>
<small class="caption-2">Nomor identifikasi unik untuk screen</small>
</div>
<v-divider class="my-4 divider-section"></v-divider>
<!-- Loket Selection -->
<div class="field-group">
<div class="group-label">
<v-icon size="18" class="icon-label">mdi-view-dashboard</v-icon>
<span>Loket yang Ditampilkan</span>
</div>
<v-select
label="Pilih Loket"
:items="loketOptions"
v-model="formData.loket"
item-title="nama"
item-value="id"
variant="outlined"
density="compact"
multiple
chips
closable-chips
:rules="[v => v.length > 0 || 'Pilih minimal 1 loket']"
hide-details="auto"
class="input-field"
>
<template v-slot:chip="{ props, item }">
<v-chip
v-bind="props"
closable
size="small"
class="chip-secondary"
>
{{ item.raw.nama }}
</v-chip>
</template>
<template v-slot:item="{ props, item }">
<v-list-item v-bind="props">
<template v-slot:prepend>
<v-chip size="x-small" class="chip-secondary-small">{{ item.raw.nama }}</v-chip>
</template>
<v-list-item-title class="body-3">{{ item.raw.nama }}</v-list-item-title>
</v-list-item>
</template>
</v-select>
<!-- Selected Preview -->
<div v-if="formData.loket.length > 0" class="selected-preview">
<v-icon size="14" class="icon-success">mdi-check-circle</v-icon>
<small class="caption-2">{{ formData.loket.length }} loket dipilih</small>
</div>
<!-- Grid Preview -->
<div v-if="formData.loket.length > 0" class="loket-grid mt-4">
<div class="grid-label">Preview Tampilan:</div>
<div class="loket-preview-grid">
<v-chip
v-for="loketId in formData.loket"
:key="loketId"
size="small"
class="ma-1 chip-secondary-outline"
>
{{ getLoketNameById(loketId) }}
</v-chip>
</div>
</div>
</div>
</v-form>
</v-card-text>
<v-divider></v-divider>
<v-card-actions class="dialog-actions">
<v-spacer></v-spacer>
<v-btn
variant="outlined"
@click="closeDialog"
class="btn-cancel"
>
<v-icon left size="18">mdi-close</v-icon>
Batal
</v-btn>
<v-btn
variant="flat"
@click="submitForm"
class="btn-submit"
>
<v-icon left size="18">mdi-content-save</v-icon>
Simpan
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Preview Dialog -->
<v-dialog v-model="previewDialog" max-width="95vw" max-height="95vh" persistent>
<v-card class="preview-dialog-card">
<v-card-title class="preview-dialog-header">
<div class="preview-header-content">
<v-icon size="24" class="mr-2">mdi-ticket-account</v-icon>
<span class="headline-4">Preview Screen: {{ previewItem?.namaScreen || '' }}</span>
</div>
<v-btn icon variant="text" size="small" class="btn-close" @click="closePreviewDialog">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-divider></v-divider>
<v-card-text class="preview-content">
<iframe
v-if="previewUrl"
:src="previewUrl"
class="preview-iframe"
frameborder="0"
allowfullscreen
></iframe>
<div v-else class="preview-loading">
<v-progress-circular indeterminate color="primary"></v-progress-circular>
<p class="mt-4">Memuat preview...</p>
</div>
</v-card-text>
</v-card>
</v-dialog>
<!-- Snackbar -->
<v-snackbar v-model="snackbar.show" :color="snackbar.color" :timeout="3000">
<span class="body-3">{{ snackbar.message }}</span>
<template v-slot:actions>
<v-btn variant="text" @click="snackbar.show = false" size="small">Tutup</v-btn>
</template>
</v-snackbar>
</v-container>
</template>
<script setup>
import { ref, computed } from 'vue';
import { useAntreanMasukScreenStore } from '@/stores/antreanMasukScreenStore';
import { useLoketStore } from '@/stores/loketStore';
const antreanMasukScreenStore = useAntreanMasukScreenStore();
const loketStore = useLoketStore();
const dialog = ref(false);
const isEdit = ref(false);
const formRef = ref(null);
const previewDialog = ref(false);
const previewItem = ref(null);
const previewUrl = ref('');
const snackbar = ref({
show: false,
message: '',
color: 'success'
});
const headers = ref([
{ title: "No", value: "no", sortable: false, width: "80px" },
{ title: "Nama Screen", value: "namaScreen", sortable: true },
{ title: "Nomor Screen", value: "nomorScreen", sortable: true },
{ title: "Loket Ditampilkan", value: "loket", sortable: false },
{ title: "Layar Informasi", value: "layarInformasi", sortable: false, width: "150px" },
{ title: "Actions", value: "actions", sortable: false, width: "auto" },
]);
const antreanMasukScreenItems = computed(() => antreanMasukScreenStore.antreanMasukScreenItems);
// Get loket options for select dropdown
const loketOptions = computed(() => {
// Directly access loketStore.loketData.value since it's a ref
// This is the most reliable way to get all lokets
const loketsArray = loketStore.loketData?.value || loketStore.loketData || [];
// Ensure it's an array
if (!Array.isArray(loketsArray)) {
console.warn('loketData is not an array:', loketsArray);
return [];
}
// Debug: log jumlah loket yang ditemukan
if (loketsArray.length > 0) {
console.log(`[ScreenAntreanMasuk] Found ${loketsArray.length} lokets:`, loketsArray.map(l => l.namaLoket || l.nama));
} else {
console.warn('[ScreenAntreanMasuk] No lokets found in loketStore');
}
// Map ALL lokets to options format - return everything, no filtering or limiting
return loketsArray.map(loket => ({
id: loket.id,
nama: loket.namaLoket || `Loket ${loket.no || loket.id}`
}));
});
// Helper to get loket name by ID
const getLoketNameById = (loketId) => {
// Get loket directly from loketStore
const loketsArray = loketStore.loketData?.value || loketStore.loketData || [];
const loket = loketsArray.find(l => l.id === loketId);
return loket ? loket.namaLoket : `Loket ${loketId}`;
};
const formData = ref({
id: null,
namaScreen: '',
nomorScreen: '',
loket: [],
});
const openTambahDialog = () => {
isEdit.value = false;
resetForm();
dialog.value = true;
};
const openEditDialog = (item) => {
isEdit.value = true;
formData.value = {
id: item.id,
namaScreen: item.namaScreen,
nomorScreen: item.nomorScreen,
loket: [...item.loket],
};
dialog.value = true;
};
const closeDialog = () => {
dialog.value = false;
resetForm();
};
const resetForm = () => {
formData.value = {
id: null,
namaScreen: '',
nomorScreen: '',
loket: [],
};
if (formRef.value) {
formRef.value.reset();
}
};
const submitForm = async () => {
const { valid } = await formRef.value.validate();
if (!valid) return;
if (isEdit.value) {
// Update screen
const result = antreanMasukScreenStore.updateAntreanMasukScreen(formData.value);
snackbar.value = {
show: true,
message: result.message,
color: result.success ? 'success' : 'error'
};
} else {
// Add new screen
const result = antreanMasukScreenStore.addAntreanMasukScreen(formData.value);
snackbar.value = {
show: true,
message: result.message,
color: result.success ? 'success' : 'error'
};
}
closeDialog();
};
const handleDelete = (item) => {
if (confirm(`Hapus screen ${item.namaScreen}?`)) {
const result = antreanMasukScreenStore.deleteAntreanMasukScreen(item.id);
snackbar.value = {
show: true,
message: result.message,
color: result.success ? 'success' : 'error'
};
}
};
const openPreviewDialog = (item) => {
previewItem.value = item;
// Generate preview URL untuk screen dengan ID
// Note: Preview akan menampilkan halaman antrean masuk dengan ID screen
previewUrl.value = `/anjungan/antreanmasuk/${item.id}`;
previewDialog.value = true;
};
const closePreviewDialog = () => {
previewDialog.value = false;
previewItem.value = null;
previewUrl.value = '';
};
</script>
<style scoped lang="scss">
/* Colors from Design System */
$neutral-900: #212121;
$neutral-800: #4D4D4D;
$neutral-700: #717171;
$neutral-600: #89939E;
$neutral-500: #ABBED1;
$neutral-400: #E5F7FA;
$neutral-300: #F5F7FA;
$neutral-100: #FFFFFF;
$secondary-700: #0053AD;
$secondary-600: #0671E0;
$secondary-300: #DBEDFF;
$success-600: #009262;
$success-300: #84DFC1;
$success-200: #F1FBF8;
$danger-600: #E02B1D;
// Font Family & Weights
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-weight-regular: 400;
$font-weight-medium: 500;
$font-weight-semibold: 600;
// Apply font family
* {
font-family: $font-family-base;
}
// ============================================
// PAGE HEADER
// ============================================
.page-header {
background: linear-gradient(135deg, $secondary-600 0%, $secondary-700 100%);
border-radius: 16px 16px 0 0;
box-shadow: 0 4px 16px rgba(6, 113, 224, 0.2);
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32px;
color: $neutral-100;
}
.header-left {
display: flex;
align-items: center;
}
.header-icon {
background: rgba(255, 255, 255, 0.2);
border-radius: 16px;
padding: 16px;
margin-right: 20px;
backdrop-filter: blur(10px);
}
.page-title {
font-size: 36px;
line-height: 44px;
font-weight: $font-weight-semibold;
margin: 0;
color: $neutral-100;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.page-subtitle {
margin: 4px 0 0 0;
opacity: 0.9;
font-size: 16px;
line-height: 24px;
font-weight: $font-weight-regular;
color: $neutral-100;
}
.add-btn {
font-weight: $font-weight-semibold;
text-transform: none;
letter-spacing: 0.5px;
font-size: 16px;
line-height: 24px;
color: $secondary-600 !important;
}
// ============================================
// DATA TABLE
// ============================================
.data-table {
font-family: $font-family-base;
}
.loket-tags {
display: flex;
flex-wrap: wrap;
max-width: 600px;
}
.chip-secondary-outline {
border: 1px solid $secondary-600;
background-color: transparent !important;
color: $secondary-600 !important;
font-weight: $font-weight-medium;
font-size: 12px;
line-height: 16px;
}
.chip-neutral {
background-color: $neutral-600 !important;
color: $neutral-100 !important;
font-weight: $font-weight-medium;
font-size: 12px;
line-height: 16px;
}
.btn-edit {
background-color: $secondary-600 !important;
color: $neutral-100 !important;
text-transform: none;
font-weight: $font-weight-semibold;
font-size: 14px;
line-height: 20px;
}
.btn-delete {
border-color: $danger-600 !important;
color: $danger-600 !important;
text-transform: none;
font-weight: $font-weight-semibold;
font-size: 14px;
line-height: 20px;
}
// ============================================
// DIALOG
// ============================================
.dialog-card {
font-family: $font-family-base;
}
.dialog-header {
background: linear-gradient(135deg, $secondary-600 0%, $secondary-700 100%);
color: $neutral-100;
padding: 20px 24px;
display: flex;
justify-content: space-between;
align-items: center;
}
.headline-4 {
font-size: 20px;
line-height: 28px;
font-weight: $font-weight-semibold;
margin: 0;
}
.btn-close {
color: $neutral-100 !important;
}
.dialog-content {
padding: 24px !important;
background: $neutral-300;
}
.dialog-actions {
padding: 16px 24px;
background: $neutral-300;
}
// ============================================
// FORM ELEMENTS
// ============================================
.field-group {
background: $neutral-100;
padding: 20px;
border-radius: 12px;
margin-bottom: 0;
border: 1px solid $neutral-400;
}
.group-label {
font-size: 14px;
line-height: 20px;
font-weight: $font-weight-semibold;
color: $secondary-600;
margin-bottom: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
display: flex;
align-items: center;
gap: 6px;
}
.icon-label {
color: $secondary-600 !important;
}
.caption-2 {
font-size: 12px;
line-height: 16px;
font-weight: $font-weight-regular;
color: $neutral-700;
}
.body-3 {
font-size: 14px;
line-height: 20px;
font-weight: $font-weight-regular;
}
.input-field {
font-size: 14px;
line-height: 20px;
}
.divider-section {
border-color: $neutral-400 !important;
}
// ============================================
// CHIPS
// ============================================
.chip-secondary {
background-color: $secondary-600 !important;
color: $neutral-100 !important;
font-weight: $font-weight-medium;
}
.chip-secondary-small {
background-color: $secondary-600 !important;
color: $neutral-100 !important;
font-weight: $font-weight-semibold;
font-size: 12px;
line-height: 16px;
}
// ============================================
// SELECTED PREVIEW
// ============================================
.selected-preview {
margin-top: 8px;
padding: 8px 12px;
background: $success-200;
border-radius: 6px;
display: flex;
align-items: center;
gap: 4px;
border: 1px solid $success-300;
}
.icon-success {
color: $success-600 !important;
}
.loket-grid {
background: $neutral-300;
padding: 16px;
border-radius: 8px;
border: 1px solid $neutral-400;
}
.grid-label {
font-size: 12px;
line-height: 16px;
font-weight: $font-weight-semibold;
color: $neutral-700;
margin-bottom: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.loket-preview-grid {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
// ============================================
// BUTTONS
// ============================================
.btn-cancel {
border-color: $neutral-600 !important;
color: $neutral-800 !important;
text-transform: none;
font-weight: $font-weight-semibold;
font-size: 16px;
line-height: 24px;
min-width: 100px;
}
.btn-submit {
background-color: $secondary-600 !important;
color: $neutral-100 !important;
text-transform: none;
font-weight: $font-weight-semibold;
font-size: 16px;
line-height: 24px;
min-width: 100px;
}
// ============================================
// PREVIEW DIALOG
// ============================================
.btn-preview {
background-color: $success-600 !important;
color: $neutral-100 !important;
text-transform: none;
font-weight: $font-weight-semibold;
font-size: 14px;
line-height: 20px;
}
.preview-dialog-card {
font-family: $font-family-base;
height: 95vh;
display: flex;
flex-direction: column;
}
.preview-dialog-header {
background: linear-gradient(135deg, $secondary-600 0%, $secondary-700 100%);
color: $neutral-100;
padding: 16px 24px;
display: flex;
justify-content: space-between;
align-items: center;
}
.preview-header-content {
display: flex;
align-items: center;
}
.preview-content {
padding: 0 !important;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: $neutral-800;
overflow: hidden;
}
.preview-iframe {
width: 100%;
height: 100%;
min-height: 80vh;
border: none;
}
.preview-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: $neutral-100;
padding: 40px;
}
</style>
+89
View File
@@ -0,0 +1,89 @@
// stores/antreanMasukScreenStore.js
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useAntreanMasukScreenStore = defineStore('antreanMasukScreen', () => {
// Initial antrean masuk screen items data
const antreanMasukScreenItems = ref([
{
id: 1,
namaScreen: "Layar Antrean Masuk 1",
nomorScreen: "AM-001",
loket: [1, 2], // Array of loket IDs
},
{
id: 2,
namaScreen: "Layar Antrean Masuk 2",
nomorScreen: "AM-002",
loket: [3, 4],
},
]);
// Computed
const getAntreanMasukScreenById = (id) => {
return computed(() => {
const targetId = Number(id);
return antreanMasukScreenItems.value.find((s) => Number(s.id) === targetId);
});
};
const getAllAntreanMasukScreens = computed(() => antreanMasukScreenItems.value);
// Actions
const addAntreanMasukScreen = (screenPayload) => {
// Ensure we get a valid ID even if antreanMasukScreenItems is empty
const maxId = antreanMasukScreenItems.value.length > 0
? Math.max(...antreanMasukScreenItems.value.map(s => s.id), 0)
: 0;
const newId = maxId + 1;
// Pastikan id baru tidak tertimpa payload (payload.id bisa null)
const newScreen = {
...screenPayload,
id: newId,
};
antreanMasukScreenItems.value.push(newScreen);
return { success: true, message: `Screen ${newScreen.namaScreen} berhasil ditambahkan`, data: newScreen };
};
const updateAntreanMasukScreen = (screenPayload) => {
const index = antreanMasukScreenItems.value.findIndex(s => s.id === screenPayload.id);
if (index !== -1) {
antreanMasukScreenItems.value[index] = {
...antreanMasukScreenItems.value[index],
...screenPayload,
};
return { success: true, message: `Konfigurasi ${screenPayload.namaScreen} berhasil disimpan` };
}
return { success: false, message: 'Screen tidak ditemukan' };
};
const deleteAntreanMasukScreen = (screenId) => {
const index = antreanMasukScreenItems.value.findIndex(s => s.id === screenId);
if (index !== -1) {
const screenName = antreanMasukScreenItems.value[index].namaScreen;
antreanMasukScreenItems.value.splice(index, 1);
return { success: true, message: `Screen ${screenName} berhasil dihapus` };
}
return { success: false, message: 'Screen tidak ditemukan' };
};
return {
// State
antreanMasukScreenItems,
// Computed
getAllAntreanMasukScreens,
getAntreanMasukScreenById,
// Actions
addAntreanMasukScreen,
updateAntreanMasukScreen,
deleteAntreanMasukScreen,
};
}, {
persist: {
key: 'antrean-masuk-screen-store-state',
storage: typeof window !== 'undefined' ? localStorage : undefined,
paths: ['antreanMasukScreenItems'],
},
});
+101 -3
View File
@@ -43,9 +43,28 @@ const getPelayananContohDariAnjungan = (anjunganItems) => {
loket4: klinikAnjunganReguler.filter(k => ['KK', 'PR'].includes(k)).length > 0
? klinikAnjunganReguler.filter(k => ['KK', 'PR'].includes(k))
: klinikAnjunganReguler.slice(7, 9), // Ambil 2 berikutnya
loket5: klinikAnjunganReguler.filter(k => ['BD', 'GI'].includes(k)).length > 0
? klinikAnjunganReguler.filter(k => ['BD', 'GI'].includes(k))
: klinikAnjunganReguler.slice(9, 11), // Ambil 2 berikutnya
loket6: klinikAnjunganReguler.filter(k => ['GR', 'GZ'].includes(k)).length > 0
? klinikAnjunganReguler.filter(k => ['GR', 'GZ'].includes(k))
: klinikAnjunganReguler.slice(11, 13), // Ambil 2 berikutnya
loket7: klinikAnjunganReguler.filter(k => ['IP', 'MT'].includes(k)).length > 0
? klinikAnjunganReguler.filter(k => ['IP', 'MT'].includes(k))
: klinikAnjunganReguler.slice(13, 15), // Ambil 2 berikutnya
loket8: klinikAnjunganReguler.filter(k => ['OB', 'HO'].includes(k)).length > 0
? klinikAnjunganReguler.filter(k => ['OB', 'HO'].includes(k))
: klinikAnjunganReguler.slice(15, 17), // Ambil 2 berikutnya
loket9: klinikAnjunganReguler.filter(k => ['AN', 'BD'].includes(k)).length > 0
? klinikAnjunganReguler.filter(k => ['AN', 'BD'].includes(k))
: klinikAnjunganReguler.slice(0, 2), // Ambil 2 pertama jika tidak ada match
loket10: klinikAnjunganReguler.filter(k => ['GI', 'GZ'].includes(k)).length > 0
? klinikAnjunganReguler.filter(k => ['GI', 'GZ'].includes(k))
: klinikAnjunganReguler.slice(2, 4), // Ambil 2 berikutnya
};
};
export const useLoketStore = defineStore('loket', () => {
const clinicStore = useClinicStore();
const anjunganStore = useAnjunganStore();
@@ -58,7 +77,7 @@ export const useLoketStore = defineStore('loket', () => {
try {
const anjunganItems = anjunganStore.getAllAnjungan || [];
return getPelayananContohDariAnjungan(anjunganItems);
} catch (error) {
} catch {
// Fallback jika store belum terinisialisasi
console.warn('AnjunganStore belum terinisialisasi, menggunakan data default');
return getPelayananContohDariAnjungan([]);
@@ -69,7 +88,9 @@ export const useLoketStore = defineStore('loket', () => {
// Data akan diupdate saat store diinisialisasi
const initLoketData = () => {
const pelayananContoh = getPelayananContoh();
return [
// Base loket (A-J) dengan pelayanan dari contoh
const baseLoket = [
{
id: 1,
no: 1,
@@ -110,12 +131,83 @@ export const useLoketStore = defineStore('loket', () => {
keterangan: "ONLINE",
statusPelayanan: "RAWAT JALAN",
},
{
id: 5,
no: 5,
namaLoket: "Loket E",
kuota: 500,
pelayanan: pelayananContoh.loket5, // Dari master anjungan
pembayaran: "JKN",
keterangan: "ONLINE",
statusPelayanan: "RAWAT JALAN",
},
{
id: 6,
no: 6,
namaLoket: "Loket F",
kuota: 500,
pelayanan: pelayananContoh.loket6, // Dari master anjungan
pembayaran: "JKN",
keterangan: "ONLINE",
statusPelayanan: "RAWAT JALAN",
},
{
id: 7,
no: 7,
namaLoket: "Loket G",
kuota: 500,
pelayanan: pelayananContoh.loket7, // Dari master anjungan
pembayaran: "JKN",
keterangan: "ONLINE",
statusPelayanan: "RAWAT JALAN",
},
{
id: 8,
no: 8,
namaLoket: "Loket H",
kuota: 500,
pelayanan: pelayananContoh.loket8, // Dari master anjungan
pembayaran: "JKN",
keterangan: "ONLINE",
statusPelayanan: "RAWAT JALAN",
},
{
id: 9,
no: 9,
namaLoket: "Loket I",
kuota: 500,
pelayanan: pelayananContoh.loket9, // Dari master anjungan
pembayaran: "JKN",
keterangan: "ONLINE",
statusPelayanan: "RAWAT JALAN",
},
{
id: 10,
no: 10,
namaLoket: "Loket J",
kuota: 500,
pelayanan: pelayananContoh.loket10, // Dari master anjungan
pembayaran: "JKN",
keterangan: "ONLINE",
statusPelayanan: "RAWAT JALAN",
},
];
return baseLoket;
};
// State - Loket Data
// Data pelayanan diambil dari contoh master anjungan
const loketData = ref(initLoketData());
const initialData = initLoketData();
// Initialize with default data, will be overridden by persisted data if exists
const loketData = ref(initialData);
// After store is created, check if data needs migration
// This runs after Pinia restores from localStorage
// If loketData has less than 10 items, reinitialize with default data (A-J)
if (loketData.value && loketData.value.length < 10) {
loketData.value = initialData;
}
// Computed - Available services (reference dari clinicStore)
// Mengambil data dari clinicStore untuk dropdown pelayanan
@@ -246,6 +338,12 @@ export const useLoketStore = defineStore('loket', () => {
if (value && value.loketData && !Array.isArray(value.loketData)) {
value.loketData = [];
}
// If restored data has less than 10 lokets, reinitialize with default data (A-J)
if (value && value.loketData && Array.isArray(value.loketData) && value.loketData.length < 10) {
// Reinitialize with default data (A-J) - this will happen on next store initialization
// For now, we'll let the store initialization handle it
value.loketData = null; // Force reinitialization
}
return value;
},
},
+1
View File
@@ -57,6 +57,7 @@ const defaultNavItems: NavItem[] = [
{ id: 21, name: "Master Klinik Ruang", path: "/setting/masterklinikruang", icon: "mdi-circle-small" },
{ id: 22, name: "Master Penunjang", path: "/setting/masterpenunjang", icon: "mdi-circle-small" },
{ id: 23, name: "Screen", path: "/setting/screen", icon: "mdi-circle-small" },
{ id: 24, name: "Screen Antrean Masuk", path: "/setting/screenantreanmasuk", icon: "mdi-circle-small" },
],
},
];