update API master LOKET dan antrian LOKET
This commit is contained in:
@@ -0,0 +1,541 @@
|
||||
<template>
|
||||
<div class="loket-preview-container">
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading" class="loading-overlay">
|
||||
<v-progress-circular indeterminate color="primary" size="60"></v-progress-circular>
|
||||
<p class="mt-4">Memuat data loket...</p>
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<div v-else class="preview-header">
|
||||
<div class="header-left">
|
||||
<div class="logo-circle">
|
||||
<v-icon size="40" color="primary">mdi-hospital-box</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="loket-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 Klinik -->
|
||||
<div v-if="!isLoading" class="kliniks-grid">
|
||||
<div
|
||||
v-for="klinik in displayedClinics"
|
||||
:key="klinik.name"
|
||||
class="klinik-box"
|
||||
>
|
||||
<!-- Klinik Header -->
|
||||
<div class="klinik-header-bar">
|
||||
<span class="klinik-title">{{ klinik.name }}</span>
|
||||
<v-chip size="small" class="klinik-count">
|
||||
<v-icon size="16" color="white" class="mr-1">mdi-account-multiple</v-icon>
|
||||
<span class="count-text">{{ klinik.totalQueues }}</span>
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<!-- Queue Content -->
|
||||
<div class="klinik-content">
|
||||
<!-- Current Serving Queue (Large) -->
|
||||
<div class="current-serving-section">
|
||||
<div class="current-label">SEDANG DILAYANI</div>
|
||||
<div
|
||||
v-if="klinik.currentQueue"
|
||||
class="current-number-large"
|
||||
>
|
||||
{{ klinik.currentQueue.noAntrian.split(' |')[0] }}
|
||||
</div>
|
||||
<div v-else class="current-waiting-text">
|
||||
MENUNGGU PANGGILAN
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- All Queues Grid (Small) -->
|
||||
<div
|
||||
v-if="klinik.allQueues && klinik.allQueues.length > 0"
|
||||
class="all-queues-grid"
|
||||
:style="getGridStyle(klinik.allQueues.length)"
|
||||
>
|
||||
<div
|
||||
v-for="queue in klinik.allQueues"
|
||||
:key="`queue-${queue.no}`"
|
||||
class="queue-grid-item"
|
||||
:class="{
|
||||
'is-current': queue.no === klinik.currentQueue?.no,
|
||||
}"
|
||||
>
|
||||
{{ queue.noAntrian.split(' |')[0] }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="!klinik.currentQueue && (!klinik.allQueues || klinik.allQueues.length === 0)" 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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useLoketStore } from '@/stores/loketStore'
|
||||
|
||||
const props = defineProps({
|
||||
loketId: {
|
||||
type: Number,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const loketStore = useLoketStore()
|
||||
|
||||
const currentTime = ref('')
|
||||
const currentDate = ref('')
|
||||
const isLoading = ref(true)
|
||||
let timeInterval = null
|
||||
|
||||
// Get loket data
|
||||
const loketData = computed(() => {
|
||||
return loketStore.getLoketById(props.loketId) || null
|
||||
})
|
||||
|
||||
// Mock queue data untuk preview
|
||||
const mockQueues = ref([
|
||||
{ no: 1, noAntrian: 'RT001', klinik: 'R. TINDAKAN', status: 'current' },
|
||||
{ no: 2, noAntrian: 'RT002', klinik: 'R. TINDAKAN', status: 'waiting' },
|
||||
{ no: 3, noAntrian: 'RD001', klinik: 'RADIOTERAPI', status: 'waiting' },
|
||||
{ no: 4, noAntrian: 'RM001', klinik: 'REHAB MEDIK', status: 'waiting' },
|
||||
])
|
||||
|
||||
// Simulasi klinik dengan queue
|
||||
const displayedClinics = computed(() => {
|
||||
// Gunakan pelayanan dari loketData jika available, atau gunakan contoh default
|
||||
const pelayanan = loketData.value?.pelayanan || ['RT', 'RD', 'RM']
|
||||
|
||||
// Map pelayanan ke nama klinik untuk preview
|
||||
const klinikNames = {
|
||||
'RT': 'R. TINDAKAN',
|
||||
'RD': 'RADIOTERAPI',
|
||||
'RM': 'REHAB MEDIK',
|
||||
'RA': 'RADIOTERAPI',
|
||||
'IP': 'RAWAT INAP',
|
||||
'RJ': 'RAWAT JALAN',
|
||||
}
|
||||
|
||||
const clinicsMap = new Map()
|
||||
|
||||
// Create mock clinic entries berdasarkan pelayanan
|
||||
pelayanan.forEach(kode => {
|
||||
const klinikName = klinikNames[kode] || kode
|
||||
if (!clinicsMap.has(klinikName)) {
|
||||
clinicsMap.set(klinikName, {
|
||||
name: klinikName,
|
||||
code: kode,
|
||||
allQueues: [],
|
||||
currentQueue: null,
|
||||
totalQueues: 0
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Distribute mock queues
|
||||
mockQueues.value.forEach(queue => {
|
||||
const klinikEntry = clinicsMap.get(queue.klinik)
|
||||
if (klinikEntry) {
|
||||
klinikEntry.allQueues.push(queue)
|
||||
if (queue.status === 'current') {
|
||||
klinikEntry.currentQueue = queue
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Calculate totals
|
||||
clinicsMap.forEach(clinic => {
|
||||
clinic.totalQueues = clinic.allQueues.length
|
||||
})
|
||||
|
||||
return Array.from(clinicsMap.values())
|
||||
})
|
||||
|
||||
const getGridStyle = (queueCount) => {
|
||||
if (queueCount <= 3) return { gridTemplateColumns: `repeat(${queueCount}, 1fr)` }
|
||||
if (queueCount <= 6) return { gridTemplateColumns: 'repeat(3, 1fr)' }
|
||||
return { gridTemplateColumns: 'repeat(4, 1fr)' }
|
||||
}
|
||||
|
||||
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(async () => {
|
||||
try {
|
||||
// Fetch loket data dari API terlebih dahulu
|
||||
console.log('📊 [LoketPreview] Fetching loket data for ID:', props.loketId);
|
||||
const result = await loketStore.fetchLoketFromAPI(true)
|
||||
|
||||
if (result.success) {
|
||||
console.log('✅ [LoketPreview] Loket data fetched successfully:', result.message);
|
||||
} else {
|
||||
console.warn('⚠️ [LoketPreview] Failed to fetch loket data:', result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ [LoketPreview] Error fetching loket data:', error);
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
// Start timer setelah data di-fetch
|
||||
updateTime()
|
||||
timeInterval = setInterval(updateTime, 1000)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
// Color variables
|
||||
$primary-600: #3A61C9;
|
||||
$primary-700: #3556AE;
|
||||
$primary-50: #F0F4FF;
|
||||
$primary-200: #9AB2F1;
|
||||
$neutral-100: #FFFFFF;
|
||||
$neutral-300: #F5F7FA;
|
||||
$neutral-600: #89939E;
|
||||
$neutral-700: #717171;
|
||||
$neutral-900: #212121;
|
||||
$warning-600: #FF9800;
|
||||
$warning-700: #F57C00;
|
||||
$warning-100: #FFF3E0;
|
||||
|
||||
.loket-preview-container {
|
||||
background: $neutral-300;
|
||||
padding: 24px;
|
||||
font-family: 'Inter', 'Roboto', sans-serif;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
min-height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ========== LOADING STATE ========== */
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
border-radius: 8px;
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
.loading-overlay p {
|
||||
color: $neutral-700;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ========== HEADER ========== */
|
||||
.preview-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: linear-gradient(135deg, $primary-600 0%, $primary-700 100%);
|
||||
border-radius: 16px;
|
||||
padding: 24px 40px;
|
||||
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.95);
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.hospital-name {
|
||||
font-size: 48px;
|
||||
font-weight: 800;
|
||||
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: $neutral-100;
|
||||
margin: 6px 0 0 0;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.loket-info {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: $neutral-100;
|
||||
margin: 4px 0 0 0;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.datetime-display {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.time-large {
|
||||
font-size: 56px;
|
||||
font-weight: 900;
|
||||
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: $neutral-100;
|
||||
margin-top: 8px;
|
||||
font-weight: 500;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
/* ========== KLINIKS GRID ========== */
|
||||
.kliniks-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 20px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.klinik-box {
|
||||
background: $neutral-100;
|
||||
border: 2px solid $primary-200;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 12px rgba(33, 150, 243, 0.12);
|
||||
min-height: 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.klinik-header-bar {
|
||||
background: linear-gradient(135deg, $primary-600 0%, $primary-700 100%);
|
||||
padding: 18px 22px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.klinik-title {
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
color: $neutral-100;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.2;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.klinik-count {
|
||||
background: rgba(255, 255, 255, 0.2) !important;
|
||||
color: $neutral-100 !important;
|
||||
font-weight: 700;
|
||||
backdrop-filter: blur(10px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px !important;
|
||||
}
|
||||
|
||||
.count-text {
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.klinik-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 22px;
|
||||
background: $primary-50;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Current Serving Section */
|
||||
.current-serving-section {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.current-label {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: $primary-600;
|
||||
margin-bottom: 12px;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.current-number-large {
|
||||
font-size: 80px;
|
||||
font-weight: 900;
|
||||
color: $neutral-900;
|
||||
letter-spacing: 4px;
|
||||
line-height: 1;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.current-waiting-text {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: $neutral-600;
|
||||
letter-spacing: 1px;
|
||||
padding: 40px 0;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* All Queues Grid */
|
||||
.all-queues-grid {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.queue-grid-item {
|
||||
background: $neutral-100;
|
||||
border: 2px solid $neutral-600;
|
||||
border-radius: 8px;
|
||||
padding: 10px 8px;
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: $neutral-700;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
|
||||
&.is-current {
|
||||
background: $warning-100;
|
||||
border: 3px solid $warning-600;
|
||||
color: $warning-700;
|
||||
font-weight: 800;
|
||||
box-shadow: 0 2px 6px rgba(255, 152, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 32px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 16px;
|
||||
color: $neutral-600;
|
||||
margin-top: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1366px) {
|
||||
.hospital-name {
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
.time-large {
|
||||
font-size: 48px;
|
||||
}
|
||||
|
||||
.klinik-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.current-number-large {
|
||||
font-size: 72px;
|
||||
}
|
||||
|
||||
.current-waiting-text {
|
||||
font-size: 24px;
|
||||
padding: 30px 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.loket-preview-container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.kliniks-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.hospital-name {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.time-large {
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
.current-number-large {
|
||||
font-size: 60px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -256,12 +256,23 @@ const getKlinikNameFromPatient = (patient) => {
|
||||
// Prioritas 1: Gunakan kodeKlinik dari data pasien (dari nomor antrian yang dibuat sebelumnya)
|
||||
// Ini adalah sumber kebenaran karena kodeKlinik disimpan saat nomor antrian dibuat
|
||||
if (patient.kodeKlinik) {
|
||||
// Cari di clinicStore terlebih dahulu
|
||||
// 1a. Coba cari di loketData._spesialisDetail (data dari API loket) terlebih dahulu
|
||||
if (loketData.value && loketData.value._spesialisDetail) {
|
||||
const spesialis = loketData.value._spesialisDetail.find(s =>
|
||||
String(s.idklinik) === String(patient.kodeKlinik)
|
||||
)
|
||||
if (spesialis && spesialis.namaklinik) {
|
||||
return spesialis.namaklinik.trim()
|
||||
}
|
||||
}
|
||||
|
||||
// 1b. Cari di clinicStore
|
||||
const clinic = clinicStore.getClinicByKode ? clinicStore.getClinicByKode(patient.kodeKlinik) : null
|
||||
if (clinic && clinic.name) {
|
||||
return clinic.name.trim()
|
||||
}
|
||||
// Fallback ke masterStore
|
||||
|
||||
// 1c. Fallback ke masterStore
|
||||
const klinikData = masterStore.getKlinikByKode ? masterStore.getKlinikByKode(patient.kodeKlinik) : null
|
||||
if (klinikData && klinikData.nama) {
|
||||
return klinikData.nama.trim()
|
||||
@@ -399,13 +410,51 @@ const displayedClinics = computed(() => {
|
||||
clinicsMap.get(klinikName).push(patient)
|
||||
})
|
||||
|
||||
// Jika ada filter pelayanan, pastikan semua pelayanan loket ditampilkan (meskipun belum ada antrian)
|
||||
if (shouldFilterByPelayanan) {
|
||||
// PENTING: Selalu tampilkan card untuk semua pelayanan loket,
|
||||
// bahkan jika tidak ada antrian
|
||||
if (targetLoketId && loketData.value) {
|
||||
allowedPelayananCodes.forEach(kode => {
|
||||
const klinikData = masterStore.getKlinikByKode ? masterStore.getKlinikByKode(kode) : null
|
||||
if (klinikData && !clinicsMap.has(klinikData.nama)) {
|
||||
// Tambahkan klinik kosong jika belum ada antrian
|
||||
clinicsMap.set(klinikData.nama, [])
|
||||
// Prioritas 1: Cari nama klinik dari _spesialisDetail (data API)
|
||||
let klinikName = null
|
||||
|
||||
if (loketData.value._spesialisDetail) {
|
||||
const spesialis = loketData.value._spesialisDetail.find(s =>
|
||||
String(s.idklinik) === String(kode)
|
||||
)
|
||||
if (spesialis && spesialis.namaklinik) {
|
||||
klinikName = spesialis.namaklinik
|
||||
}
|
||||
}
|
||||
|
||||
// Prioritas 2: Coba getKlinikById untuk local data (numeric ID seperti 1000, 1001)
|
||||
// Local data EKSEKUTIF menggunakan ID numeric, bukan kode
|
||||
if (!klinikName) {
|
||||
const klinikData = masterStore.getKlinikById ? masterStore.getKlinikById(kode) : null
|
||||
if (klinikData && klinikData.nama) {
|
||||
klinikName = klinikData.nama
|
||||
}
|
||||
}
|
||||
|
||||
// Prioritas 3: Fallback ke masterStore.getKlinikByKode untuk data yang pakai kode
|
||||
if (!klinikName) {
|
||||
const klinikData = masterStore.getKlinikByKode ? masterStore.getKlinikByKode(kode) : null
|
||||
if (klinikData && klinikData.nama) {
|
||||
klinikName = klinikData.nama
|
||||
}
|
||||
}
|
||||
|
||||
// Prioritas 4: Fallback ke clinicStore
|
||||
if (!klinikName) {
|
||||
const clinic = clinicStore.getClinicByKode ? clinicStore.getClinicByKode(kode) : null
|
||||
if (clinic && clinic.name) {
|
||||
klinikName = clinic.name
|
||||
}
|
||||
}
|
||||
|
||||
// Jika berhasil mendapatkan nama klinik, tambahkan ke map
|
||||
if (klinikName && !clinicsMap.has(klinikName)) {
|
||||
// Tambahkan klinik kosong (akan ditampilkan sebagai "Tidak Ada Antrian")
|
||||
clinicsMap.set(klinikName, [])
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -864,12 +913,18 @@ const updateTime = () => {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Redirect to index if loket not found
|
||||
if (!loketData.value) {
|
||||
navigateTo('/anjungan/antrianloket')
|
||||
return
|
||||
}
|
||||
// Fetch loket data dari API di background
|
||||
loketStore.fetchLoketFromAPI(true).then(result => {
|
||||
if (result.success) {
|
||||
console.log('✅ [AntrianLoket] Loket API data loaded:', result.message);
|
||||
} else {
|
||||
console.warn('⚠️ [AntrianLoket] Failed to fetch loket from API:', result.message);
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error('❌ [AntrianLoket] Error fetching loket from API:', err);
|
||||
});
|
||||
|
||||
// Langsung start timer tanpa menunggu fetch
|
||||
updateTime()
|
||||
timeInterval = setInterval(updateTime, 1000)
|
||||
})
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
size="small"
|
||||
class="ma-1 chip-preview"
|
||||
>
|
||||
{{ getKlinikName(pelayananKode) }}
|
||||
{{ getKlinikName(pelayananKode, loket) }}
|
||||
</v-chip>
|
||||
<v-chip
|
||||
v-if="(loket.pelayanan || []).length > 3"
|
||||
@@ -93,7 +93,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { useLoketStore } from '@/stores/loketStore';
|
||||
import { useMasterStore } from '@/stores/masterStore';
|
||||
import { useRoute } from '#app';
|
||||
@@ -106,6 +106,19 @@ const loketStore = useLoketStore();
|
||||
const masterStore = useMasterStore();
|
||||
const route = useRoute();
|
||||
|
||||
// Auto-fetch API loket saat component mount
|
||||
onMounted(() => {
|
||||
loketStore.fetchLoketFromAPI(true).then(result => {
|
||||
if (result.success) {
|
||||
console.log('✅ [AntrianLoket Index] Loket API data loaded:', result.message);
|
||||
} else {
|
||||
console.warn('⚠️ [AntrianLoket Index] Failed to fetch loket from API:', result.message);
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error('❌ [AntrianLoket Index] Error fetching loket from API:', err);
|
||||
});
|
||||
});
|
||||
|
||||
// Helper function: Convert nomor loket ke huruf (1 -> A, 2 -> B, dst)
|
||||
const numberToLetter = (num) => {
|
||||
if (num < 1) return 'A';
|
||||
@@ -123,7 +136,7 @@ const allLokets = computed(() => {
|
||||
});
|
||||
|
||||
// Pagination
|
||||
const itemsPerPage = 10;
|
||||
const itemsPerPage = 20;
|
||||
const page = computed({
|
||||
get: () => Number(route.query.page || 1),
|
||||
set: (val) => navigateTo({ query: { ...route.query, page: val } }),
|
||||
@@ -153,7 +166,21 @@ const navigateToSettings = () => {
|
||||
};
|
||||
|
||||
// Helper function untuk mendapatkan nama klinik dari kode
|
||||
const getKlinikName = (kode) => {
|
||||
const getKlinikName = (kode, loket = null) => {
|
||||
// 1. Coba cari di _spesialisDetail (data dari API)
|
||||
if (loket && loket._spesialisDetail) {
|
||||
const detail = loket._spesialisDetail.find(s => String(s.idklinik) === String(kode));
|
||||
if (detail && detail.namaklinik) {
|
||||
return detail.namaklinik;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Coba getKlinikById untuk local data (numeric ID seperti 1000, 1001)
|
||||
// Local data EKSEKUTIF menggunakan ID numeric, bukan kode
|
||||
const byId = masterStore.getKlinikById ? masterStore.getKlinikById(kode) : null;
|
||||
if (byId && byId.nama) return byId.nama;
|
||||
|
||||
// 3. Fallback ke masterStore.getKlinikNameByKode untuk data yang pakai kode
|
||||
return masterStore.getKlinikNameByKode ? masterStore.getKlinikNameByKode(kode) : kode;
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
<template #item.jenisLayanan="{ item }">
|
||||
<v-chip
|
||||
size="small"
|
||||
:class="item.jenisLayanan === 'Reguler' ? 'chip-reguler' : 'chip-eksekutif'"
|
||||
:class="['Reguler', 'REGULER', 'JKN'].includes(item.jenisLayanan) ? 'chip-reguler' : 'chip-eksekutif'"
|
||||
>
|
||||
{{ item.jenisLayanan }}
|
||||
</v-chip>
|
||||
@@ -808,15 +808,15 @@ $font-weight-bold: 700;
|
||||
}
|
||||
|
||||
.chip-reguler {
|
||||
background-color: $success-600 !important;
|
||||
color: $neutral-100 !important;
|
||||
font-weight: $font-weight-medium;
|
||||
background-color: #009262 !important;
|
||||
color: #FFFFFF !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.chip-eksekutif {
|
||||
background-color: $secondary-600 !important;
|
||||
color: $neutral-100 !important;
|
||||
font-weight: $font-weight-medium;
|
||||
background-color: #E67E22 !important;
|
||||
color: #FFFFFF !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-edit {
|
||||
|
||||
+232
-16
@@ -36,7 +36,7 @@
|
||||
<v-card-text>
|
||||
<v-data-table
|
||||
:headers="loketHeaders"
|
||||
:items="masterStore.loketData"
|
||||
:items="loketStore.loketData"
|
||||
:items-per-page="10"
|
||||
class="elevation-0 data-table"
|
||||
>
|
||||
@@ -47,7 +47,7 @@
|
||||
size="small"
|
||||
class="mr-1 mb-1 chip-primary-outline"
|
||||
>
|
||||
{{ masterStore.getKlinikNameByKode(serviceKode) }}
|
||||
{{ getServiceName(item, serviceKode) }}
|
||||
</v-chip>
|
||||
<v-chip
|
||||
v-if="item.pelayanan.length > 2"
|
||||
@@ -58,7 +58,37 @@
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<template #item.pembayaran="{ item }">
|
||||
<v-chip
|
||||
size="small"
|
||||
:class="['JKN', 'Reguler', 'REGULER'].includes(item.pembayaran) ? 'chip-reguler' : 'chip-eksekutif'"
|
||||
>
|
||||
{{ item.pembayaran }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<template #item.loketAktif="{ item }">
|
||||
<v-chip
|
||||
size="small"
|
||||
:color="item.loketAktif ? 'success' : 'error'"
|
||||
variant="flat"
|
||||
class="text-capitalize"
|
||||
>
|
||||
{{ item.loketAktif ? 'Aktif' : 'Tidak Aktif' }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<template #item.aksi="{ item }">
|
||||
<v-btn
|
||||
size="small"
|
||||
@click="openPreview(item)"
|
||||
variant="flat"
|
||||
class="btn-preview mr-2"
|
||||
>
|
||||
<v-icon size="16" left>mdi-eye</v-icon>
|
||||
Preview
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="flat"
|
||||
@@ -146,7 +176,7 @@
|
||||
<v-select
|
||||
v-model="formData.pembayaran"
|
||||
label="Pembayaran"
|
||||
:items="['JKN', 'UMUM', 'SPM', 'JKMM', 'JAMPERSAL', 'T4', 'KARYAWAN']"
|
||||
:items="['JKN', 'UMUM', 'EKSEKUTIF', 'SPM', 'JKMM', 'JAMPERSAL', 'T4', 'KARYAWAN']"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
:rules="[v => !!v || 'Pembayaran harus dipilih']"
|
||||
@@ -155,15 +185,13 @@
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="6">
|
||||
<v-select
|
||||
v-model="formData.keterangan"
|
||||
label="Keterangan"
|
||||
:items="['ONLINE', 'OFFLINE']"
|
||||
variant="outlined"
|
||||
<v-switch
|
||||
v-model="formData.loketAktif"
|
||||
:label="formData.loketAktif ? 'Loket Aktif' : 'Loket Tidak Aktif'"
|
||||
color="success"
|
||||
hide-details
|
||||
density="compact"
|
||||
:rules="[v => !!v || 'Keterangan harus dipilih']"
|
||||
hide-details="auto"
|
||||
class="input-field"
|
||||
class="mt-1"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
@@ -246,6 +274,34 @@
|
||||
</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-view-dashboard</v-icon>
|
||||
<span class="headline-4">Preview Loket: {{ previewItem?.namaLoket || '' }}</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 pa-0">
|
||||
<iframe
|
||||
v-if="previewItem"
|
||||
:src="previewUrl"
|
||||
class="preview-iframe"
|
||||
frameborder="0"
|
||||
></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>
|
||||
@@ -258,11 +314,15 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useMasterStore } from '@/stores/masterStore';
|
||||
import { useLoketStore } from '@/stores/loketStore';
|
||||
|
||||
const masterStore = useMasterStore();
|
||||
const loketStore = useLoketStore();
|
||||
const dialog = ref(false);
|
||||
const previewDialog = ref(false);
|
||||
const previewItem = ref(null);
|
||||
const isEdit = ref(false);
|
||||
const formRef = ref(null);
|
||||
|
||||
@@ -272,13 +332,73 @@ const snackbar = ref({
|
||||
color: 'success'
|
||||
});
|
||||
|
||||
// Helper untuk mendapatkan nama layanan/klinik
|
||||
const getServiceName = (item, serviceId) => {
|
||||
// 1. Coba cari di _spesialisDetail (data dari API)
|
||||
// Convert ID ke string untuk perbandingan aman
|
||||
const idStr = String(serviceId);
|
||||
|
||||
if (item._spesialisDetail && Array.isArray(item._spesialisDetail)) {
|
||||
const detail = item._spesialisDetail.find(s => String(s.idklinik) === idStr);
|
||||
if (detail && detail.namaklinik) {
|
||||
return detail.namaklinik;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fallback ke masterStore.getKlinikById untuk local data (numeric ID seperti 1000, 1001)
|
||||
// Local data EKSEKUTIF menggunakan ID numeric, bukan kode
|
||||
const byId = masterStore.getKlinikById(serviceId);
|
||||
if (byId && byId.nama) return byId.nama;
|
||||
|
||||
// 3. Fallback ke masterStore.getKlinikNameByKode untuk data yang pakai kode
|
||||
return masterStore.getKlinikNameByKode(serviceId);
|
||||
};
|
||||
|
||||
// Auto-fetch loket dari API saat component mount
|
||||
onMounted(async () => {
|
||||
console.log('🚀 MasterLoket mounted, fetching loket dari API...');
|
||||
|
||||
try {
|
||||
const result = await loketStore.fetchLoketFromAPI(true); // force = true untuk fresh data
|
||||
|
||||
console.log('📊 Fetch result:', result);
|
||||
console.log('📊 loketStore.loketData length:', loketStore.loketData.value?.length);
|
||||
console.log('📊 loketStore.loketData:', loketStore.loketData.value);
|
||||
|
||||
if (result.success) {
|
||||
console.log('✅ Fetch successful:', result.message);
|
||||
|
||||
snackbar.value = {
|
||||
show: true,
|
||||
message: result.message,
|
||||
color: 'success'
|
||||
};
|
||||
} else {
|
||||
console.error('❌ Fetch failed:', result.message);
|
||||
snackbar.value = {
|
||||
show: true,
|
||||
message: result.message,
|
||||
color: 'warning'
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error in onMounted:', error);
|
||||
snackbar.value = {
|
||||
show: true,
|
||||
message: `Error: ${error.message}`,
|
||||
color: 'error'
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const loketHeaders = ref([
|
||||
{ title: "No", value: "no" },
|
||||
{ title: "ID", value: "id" },
|
||||
{ title: "Nama Loket", value: "namaLoket" },
|
||||
{ title: "Kuota", value: "kuota" },
|
||||
{ title: "Pelayanan", value: "pelayanan" },
|
||||
{ title: "Pembayaran", value: "pembayaran" },
|
||||
{ title: "Keterangan", value: "keterangan" },
|
||||
{ title: "Status Loket Aktif", value: "loketAktif" },
|
||||
{ title: "Aksi", value: "aksi", sortable: false },
|
||||
]);
|
||||
|
||||
@@ -288,8 +408,8 @@ const formData = ref({
|
||||
kuota: null,
|
||||
statusPelayanan: '',
|
||||
pembayaran: '',
|
||||
keterangan: '',
|
||||
pelayanan: [],
|
||||
loketAktif: true,
|
||||
});
|
||||
|
||||
const openTambahDialog = () => {
|
||||
@@ -352,6 +472,22 @@ const submitForm = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Computed property untuk preview URL
|
||||
const previewUrl = computed(() => {
|
||||
if (!previewItem.value) return '';
|
||||
return `/anjungan/antrianloket/${previewItem.value.id}`;
|
||||
});
|
||||
|
||||
const openPreview = (item) => {
|
||||
previewItem.value = item;
|
||||
previewDialog.value = true;
|
||||
};
|
||||
|
||||
const closePreviewDialog = () => {
|
||||
previewDialog.value = false;
|
||||
previewItem.value = null;
|
||||
};
|
||||
|
||||
const handleDelete = (item) => {
|
||||
if (confirm(`Hapus loket ${item.namaLoket}?`)) {
|
||||
const result = masterStore.deleteLoket(item.id);
|
||||
@@ -365,7 +501,7 @@ const handleDelete = (item) => {
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
// Colors from Design System
|
||||
/* Colors from Design System */
|
||||
$neutral-900: #212121;
|
||||
$neutral-800: #4D4D4D;
|
||||
$neutral-700: #717171;
|
||||
@@ -478,11 +614,20 @@ $font-weight-semibold: 600;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// DATA TABLE
|
||||
// ============================================
|
||||
.data-table {
|
||||
font-family: $font-family-base;
|
||||
|
||||
// Vertically center-align table headers
|
||||
:deep(th) {
|
||||
vertical-align: middle !important;
|
||||
}
|
||||
|
||||
// Vertically center-align table cells
|
||||
:deep(td) {
|
||||
vertical-align: middle !important;
|
||||
}
|
||||
}
|
||||
|
||||
.chip-primary-outline {
|
||||
@@ -502,6 +647,18 @@ $font-weight-semibold: 600;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.chip-reguler {
|
||||
background-color: #009262 !important;
|
||||
color: #FFFFFF !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.chip-eksekutif {
|
||||
background-color: #E67E22 !important;
|
||||
color: #FFFFFF !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-edit {
|
||||
background-color: $primary-600 !important;
|
||||
color: $neutral-100 !important;
|
||||
@@ -511,6 +668,15 @@ $font-weight-semibold: 600;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.btn-preview {
|
||||
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;
|
||||
@@ -664,4 +830,54 @@ $font-weight-semibold: 600;
|
||||
line-height: 24px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// PREVIEW DIALOG
|
||||
// ============================================
|
||||
.preview-dialog-card {
|
||||
font-family: $font-family-base;
|
||||
height: 95vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.preview-dialog-header {
|
||||
background: linear-gradient(135deg, $primary-600 0%, $primary-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: auto;
|
||||
}
|
||||
|
||||
.preview-iframe {
|
||||
width: 100%;
|
||||
height: 75vh;
|
||||
min-height: 600px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.preview-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: $neutral-100;
|
||||
padding: 40px;
|
||||
}
|
||||
</style>
|
||||
+237
-171
@@ -69,145 +69,71 @@ export const useLoketStore = defineStore('loket', () => {
|
||||
const clinicStore = useClinicStore();
|
||||
const anjunganStore = useAnjunganStore();
|
||||
|
||||
// Get pelayanan contoh dari master anjungan secara dinamis
|
||||
// Menggunakan computed untuk mendapatkan data terbaru dari anjunganStore
|
||||
const getPelayananContoh = () => {
|
||||
// getAllAnjungan adalah computed yang mengembalikan anjunganItems.value
|
||||
// Jadi langsung akses tanpa .value karena computed sudah handle itu
|
||||
try {
|
||||
const anjunganItems = anjunganStore.getAllAnjungan || [];
|
||||
return getPelayananContohDariAnjungan(anjunganItems);
|
||||
} catch {
|
||||
// Fallback jika store belum terinisialisasi
|
||||
console.warn('AnjunganStore belum terinisialisasi, menggunakan data default');
|
||||
return getPelayananContohDariAnjungan([]);
|
||||
}
|
||||
};
|
||||
|
||||
// Inisialisasi data loket dengan pelayanan dari master anjungan
|
||||
// Data akan diupdate saat store diinisialisasi
|
||||
const initLoketData = () => {
|
||||
const pelayananContoh = getPelayananContoh();
|
||||
|
||||
// Base loket (A-J) dengan pelayanan dari contoh
|
||||
const baseLoket = [
|
||||
{
|
||||
id: 1,
|
||||
no: 1,
|
||||
namaLoket: "Loket A",
|
||||
kuota: 500,
|
||||
pelayanan: pelayananContoh.loket1, // Dari master anjungan
|
||||
pembayaran: "JKN",
|
||||
keterangan: "ONLINE",
|
||||
statusPelayanan: "RAWAT JALAN",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
no: 2,
|
||||
namaLoket: "Loket B",
|
||||
kuota: 666,
|
||||
pelayanan: pelayananContoh.loket2, // Dari master anjungan
|
||||
pembayaran: "JKN",
|
||||
keterangan: "ONLINE",
|
||||
statusPelayanan: "RAWAT JALAN",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
no: 3,
|
||||
namaLoket: "Loket C",
|
||||
kuota: 666,
|
||||
pelayanan: pelayananContoh.loket3, // Dari master anjungan
|
||||
pembayaran: "JKN",
|
||||
keterangan: "ONLINE",
|
||||
statusPelayanan: "RAWAT JALAN",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
no: 4,
|
||||
namaLoket: "Loket D",
|
||||
kuota: 3676,
|
||||
pelayanan: pelayananContoh.loket4, // Dari master anjungan
|
||||
pembayaran: "JKN",
|
||||
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 initialData = initLoketData();
|
||||
// Initialize with default data, will be overridden by persisted data if exists
|
||||
const loketData = ref(initialData);
|
||||
// ============================================
|
||||
// STATE - SEPARATED DATA SOURCES
|
||||
// ============================================
|
||||
|
||||
// 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;
|
||||
}
|
||||
// API Data (JKN/REGULER) - ID 1-14 dari backend
|
||||
// TIDAK di-persist ke localStorage, selalu fetch fresh dari API
|
||||
const apiLoketData = ref([])
|
||||
|
||||
// Local Data (EKSEKUTIF) - ID 1000+ manual input
|
||||
// DI-persist ke localStorage. Default 14 loket hardcoded.
|
||||
const localLoketData = ref(Array.from({ length: 14 }, (_, i) => {
|
||||
// Distribute services securely
|
||||
// Loket 1-5: Single service
|
||||
// Loket 6-10: Dual services
|
||||
// Loket 11+: Single service
|
||||
let services = [];
|
||||
if (i < 5) {
|
||||
services = [1000 + i];
|
||||
} else if (i < 10) {
|
||||
services = [1000 + i, 1005 + i];
|
||||
} else {
|
||||
services = [1000 + i];
|
||||
}
|
||||
|
||||
return {
|
||||
id: 1000 + i,
|
||||
no: i + 1,
|
||||
namaLoket: `LOKET ${i + 1} EKS`,
|
||||
kuota: 500,
|
||||
pelayanan: services,
|
||||
pembayaran: 'EKSEKUTIF',
|
||||
keterangan: 'ONLINE',
|
||||
statusPelayanan: 'RAWAT JALAN',
|
||||
source: 'local',
|
||||
loketAktif: true
|
||||
};
|
||||
}))
|
||||
|
||||
// Merged data (computed) - Gabungan API + Local
|
||||
const loketData = computed(() => {
|
||||
// Debug log
|
||||
if (apiLoketData.value.length > 0) {
|
||||
console.log('📊 [loketData computed] API data found:', apiLoketData.value.length, 'items');
|
||||
}
|
||||
if (localLoketData.value.length > 0) {
|
||||
console.log('📊 [loketData computed] Local data found:', localLoketData.value.length, 'items');
|
||||
}
|
||||
|
||||
// Create new objects to prevent state mutation in computed
|
||||
const merged = [
|
||||
...apiLoketData.value.map(i => ({...i})),
|
||||
...localLoketData.value.map(i => ({...i}))
|
||||
]
|
||||
|
||||
console.log('📊 [loketData computed] Merged total:', merged.length, 'items');
|
||||
|
||||
// Sort by ID
|
||||
merged.sort((a, b) => a.id - b.id)
|
||||
|
||||
// Recalculate sequential No
|
||||
return merged.map((item, index) => ({
|
||||
...item,
|
||||
no: index + 1
|
||||
}))
|
||||
})
|
||||
|
||||
// Computed - Available services (reference dari clinicStore)
|
||||
// Mengambil data dari clinicStore untuk dropdown pelayanan
|
||||
@@ -258,48 +184,60 @@ export const useLoketStore = defineStore('loket', () => {
|
||||
});
|
||||
|
||||
// Actions - CRUD Operations
|
||||
// ADD: Hanya untuk EKSEKUTIF (ID 1000+)
|
||||
const addLoket = (loketPayload) => {
|
||||
const newId = Math.max(...loketData.value.map(l => l.id), 0) + 1;
|
||||
// Generate ID starting from 1000 for EKSEKUTIF
|
||||
const maxLocalId = localLoketData.value.length > 0
|
||||
? Math.max(...localLoketData.value.map(l => l.id))
|
||||
: 999;
|
||||
|
||||
const newId = maxLocalId + 1;
|
||||
const newNo = loketData.value.length + 1;
|
||||
|
||||
// Jika namaLoket tidak disediakan atau masih menggunakan angka, ubah ke huruf
|
||||
let namaLoket = loketPayload.namaLoket;
|
||||
if (!namaLoket || namaLoket.match(/Loket \d+/)) {
|
||||
const letter = numberToLetter(newNo);
|
||||
namaLoket = `Loket ${letter}`;
|
||||
}
|
||||
|
||||
const newLoket = {
|
||||
id: newId,
|
||||
no: newNo,
|
||||
...loketPayload,
|
||||
namaLoket: namaLoket,
|
||||
pembayaran: 'EKSEKUTIF', // Force EKSEKUTIF for local data
|
||||
source: 'local', // Mark as local data
|
||||
loketAktif: loketPayload.loketAktif ?? true, // Default aktif
|
||||
};
|
||||
|
||||
loketData.value.push(newLoket);
|
||||
localLoketData.value.push(newLoket);
|
||||
return { success: true, message: `Loket ${newLoket.namaLoket} berhasil ditambahkan`, data: newLoket };
|
||||
};
|
||||
|
||||
// UPDATE: Hanya untuk EKSEKUTIF (source='local')
|
||||
const updateLoket = (loketPayload) => {
|
||||
const index = loketData.value.findIndex(l => l.id === loketPayload.id);
|
||||
// Check if trying to edit API data
|
||||
if (loketPayload.source === 'api' || (loketPayload.id >= 1 && loketPayload.id <= 100)) {
|
||||
return { success: false, message: 'Data JKN dari API tidak bisa diedit. Hanya data EKSEKUTIF yang bisa diubah.' };
|
||||
}
|
||||
|
||||
const index = localLoketData.value.findIndex(l => l.id === loketPayload.id);
|
||||
if (index !== -1) {
|
||||
loketData.value[index] = {
|
||||
...loketData.value[index],
|
||||
localLoketData.value[index] = {
|
||||
...localLoketData.value[index],
|
||||
...loketPayload,
|
||||
source: 'local', // Ensure source stays local
|
||||
pembayaran: 'EKSEKUTIF', // Ensure pembayaran stays EKSEKUTIF
|
||||
};
|
||||
return { success: true, message: `Loket ${loketPayload.namaLoket} berhasil diupdate` };
|
||||
}
|
||||
return { success: false, message: 'Loket tidak ditemukan' };
|
||||
};
|
||||
|
||||
// DELETE: Hanya untuk EKSEKUTIF (source='local')
|
||||
const deleteLoket = (loketId) => {
|
||||
const index = loketData.value.findIndex(l => l.id === loketId);
|
||||
// Check if trying to delete API data
|
||||
if (loketId >= 1 && loketId <= 100) {
|
||||
return { success: false, message: 'Data JKN dari API tidak bisa dihapus. Hanya data EKSEKUTIF yang bisa dihapus.' };
|
||||
}
|
||||
|
||||
const index = localLoketData.value.findIndex(l => l.id === loketId);
|
||||
if (index !== -1) {
|
||||
const loketName = loketData.value[index].namaLoket;
|
||||
loketData.value.splice(index, 1);
|
||||
loketData.value.forEach((l, idx) => {
|
||||
l.no = idx + 1;
|
||||
});
|
||||
const loketName = localLoketData.value[index].namaLoket;
|
||||
localLoketData.value.splice(index, 1);
|
||||
return { success: true, message: `Loket ${loketName} berhasil dihapus` };
|
||||
}
|
||||
return { success: false, message: 'Loket tidak ditemukan' };
|
||||
@@ -312,10 +250,148 @@ export const useLoketStore = defineStore('loket', () => {
|
||||
// Helper function untuk convert nomor ke huruf (export untuk digunakan di komponen)
|
||||
const getLoketLetter = (num) => numberToLetter(num);
|
||||
|
||||
// ============================================
|
||||
// API INTEGRATION
|
||||
// ============================================
|
||||
|
||||
// State untuk API management
|
||||
const isLoadingAPI = ref(false);
|
||||
const apiError = ref(null);
|
||||
const lastSyncTimestamp = ref(null);
|
||||
let activeFetchPromise = null;
|
||||
|
||||
/**
|
||||
* Fetch loket data dari API backend
|
||||
* Mengikuti pattern dari clinicStore.fetchRegulerClinics()
|
||||
* Filter hanya loket dengan pembayaran JKN (REGULER)
|
||||
* Sort berdasarkan nama loket (urut dari 1)
|
||||
*/
|
||||
const fetchLoketFromAPI = async (force = false, retryCount = 0) => {
|
||||
// Guard: singleton pattern - prevent duplicate fetch
|
||||
if (activeFetchPromise && retryCount === 0) {
|
||||
console.log('⏳ Loket fetch in progress, reusing existing request...');
|
||||
return activeFetchPromise;
|
||||
}
|
||||
|
||||
// Guard: Skip if data already exists and not forced
|
||||
const hasData = loketData.value && loketData.value.length > 0;
|
||||
if (hasData && !force && retryCount === 0) {
|
||||
return { success: true, message: 'Data sudah tersedia' };
|
||||
}
|
||||
|
||||
// Define the actual fetch operation
|
||||
const performFetch = async () => {
|
||||
isLoadingAPI.value = true;
|
||||
apiError.value = null;
|
||||
|
||||
try {
|
||||
console.log(`🔄 Fetching loket from API (Try ${retryCount + 1})...`);
|
||||
const response = await fetch('http://10.10.150.131:8089/api/v1/klinik/loket');
|
||||
|
||||
if (!response.ok) {
|
||||
// Handle Rate Limiting with exponential backoff
|
||||
if (response.status === 429 && retryCount < 3) {
|
||||
const delay = (retryCount + 1) * 1500;
|
||||
console.warn(`⚠️ Rate limit hit (429). Retrying in ${delay}ms...`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
activeFetchPromise = null;
|
||||
return fetchLoketFromAPI(force, retryCount + 1);
|
||||
}
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const rawData = await response.json();
|
||||
console.log('📦 Raw API Response received');
|
||||
console.log('📦 Raw data:', rawData);
|
||||
|
||||
// Extract data array
|
||||
const data = rawData.data || rawData;
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error('Data format tidak valid. Expected array.');
|
||||
}
|
||||
|
||||
console.log('📊 Total items from API:', data.length);
|
||||
|
||||
// Filter hanya JKN (REGULER) dan transform ke format internal
|
||||
const lokets = data
|
||||
.filter(item => {
|
||||
const hasJKN = item.pembayaran?.some(p => p.pembayaran === 'JKN');
|
||||
if (!hasJKN) {
|
||||
console.warn('⚠️ Loket skipped (no JKN):', item.namaloket, 'pembayaran:', item.pembayaran);
|
||||
}
|
||||
return hasJKN;
|
||||
})
|
||||
.map((item) => ({
|
||||
id: parseInt(item.idloket),
|
||||
no: 0, // Will be set after sort
|
||||
namaLoket: item.namaloket,
|
||||
kodeLoket: item.kodeloket,
|
||||
kuota: parseInt(item.kuotaloket),
|
||||
pelayanan: item.spesialis?.map(s => s.idklinik) || [],
|
||||
pembayaran: 'JKN',
|
||||
keterangan: item.tipevisit?.map(t => t.tipevisit).join(', ') || 'ONLINE',
|
||||
statusPelayanan: item.tipeloket || 'REGULER',
|
||||
loketAktif: item.loketaktif,
|
||||
jenisLoket: item.jenisloket,
|
||||
|
||||
// Simpan detail lengkap spesialis untuk display nama klinik
|
||||
_spesialisDetail: item.spesialis || [],
|
||||
}));
|
||||
|
||||
// Sort by nama loket (extract number and sort: LOKET 1, 2, 3,... 14)
|
||||
lokets.sort((a, b) => {
|
||||
const numA = parseInt(a.namaLoket.match(/\d+/)?.[0] || 0);
|
||||
const numB = parseInt(b.namaLoket.match(/\d+/)?.[0] || 0);
|
||||
return numA - numB;
|
||||
});
|
||||
|
||||
// Update `no` field to sequential after sort
|
||||
lokets.forEach((loket, idx) => {
|
||||
loket.no = idx + 1;
|
||||
loket.source = 'api'; // Mark as API data
|
||||
});
|
||||
|
||||
console.log(`✅ ${lokets.length} loket JKN berhasil di-filter dan dimuat dari API`);
|
||||
console.log('✅ Loket data:', lokets.map(l => ({ id: l.id, namaLoket: l.namaLoket, pembayaran: l.pembayaran })));
|
||||
|
||||
// Update API store - TIDAK di-persist
|
||||
apiLoketData.value = lokets;
|
||||
lastSyncTimestamp.value = new Date().toISOString();
|
||||
|
||||
console.log(`✅ ${lokets.length} loket JKN berhasil dimuat dari API`);
|
||||
return {
|
||||
success: true,
|
||||
message: `${lokets.length} loket berhasil dimuat`,
|
||||
data: lokets
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error fetching loket:', error);
|
||||
apiError.value = error.message;
|
||||
return {
|
||||
success: false,
|
||||
message: `Gagal memuat data: ${error.message}`
|
||||
};
|
||||
} finally {
|
||||
isLoadingAPI.value = false;
|
||||
activeFetchPromise = null;
|
||||
console.log('✅ API fetch flow completed');
|
||||
}
|
||||
};
|
||||
|
||||
activeFetchPromise = performFetch();
|
||||
return activeFetchPromise;
|
||||
};
|
||||
|
||||
return {
|
||||
// State
|
||||
loketData,
|
||||
availableServices,
|
||||
|
||||
// API State
|
||||
isLoadingAPI,
|
||||
apiError,
|
||||
lastSyncTimestamp,
|
||||
|
||||
// Actions
|
||||
addLoket,
|
||||
@@ -323,29 +399,19 @@ export const useLoketStore = defineStore('loket', () => {
|
||||
deleteLoket,
|
||||
getLoketById,
|
||||
getLoketLetter,
|
||||
|
||||
// API Actions
|
||||
fetchLoketFromAPI,
|
||||
};
|
||||
}, {
|
||||
persist: {
|
||||
key: 'loket-store-state',
|
||||
storage: typeof window !== 'undefined' ? localStorage : undefined,
|
||||
paths: ['loketData'],
|
||||
paths: ['localLoketData'], // ONLY persist EKSEKUTIF data
|
||||
serializer: {
|
||||
deserialize: JSON.parse,
|
||||
serialize: JSON.stringify,
|
||||
},
|
||||
restore: (value) => {
|
||||
// Ensure loketData is always an array
|
||||
if (value && value.loketData && !Array.isArray(value.loketData)) {
|
||||
value.loketData = [];
|
||||
}
|
||||
// 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;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user