update API master LOKET dan antrian LOKET

This commit is contained in:
Fanrouver
2026-01-23 08:24:14 +07:00
parent 8de89cb579
commit 75a96389fe
6 changed files with 1116 additions and 211 deletions
+541
View File
@@ -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>
+68 -13
View File
@@ -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)
})
+31 -4
View File
@@ -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>
+7 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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;
},
},
});