PUSH UP WOKE UPDATE Api ANTREAN MASUK DAN MINOR CHANGE
This commit is contained in:
No files matched your search
+2
-2
@@ -84,7 +84,6 @@ export default defineNuxtConfig({
|
||||
"@mdi/font/css/materialdesignicons.min.css",
|
||||
"~/assets/scss/main.scss",
|
||||
],
|
||||
|
||||
devServer: (() => {
|
||||
const hostEnv = process.env.HOST || 'localhost';
|
||||
|
||||
@@ -103,7 +102,8 @@ export default defineNuxtConfig({
|
||||
|
||||
return {
|
||||
port: port,
|
||||
host: host
|
||||
host: host,
|
||||
allowedHosts: ["localhost", "localhost.dev", "10.10.150.175","antrean.dev.rssa.id"]
|
||||
};
|
||||
})(),
|
||||
|
||||
|
||||
@@ -306,6 +306,9 @@ const calledForCheckIn = computed(() => {
|
||||
return []
|
||||
}
|
||||
|
||||
// Normalize loketIds to strings for robust comparison
|
||||
const normalizedConfiguredIds = loketIds.map(id => String(id))
|
||||
|
||||
return loketPatients.value
|
||||
.filter(p => {
|
||||
// Must be called (waiting status = sudah dipanggil dari status menunggu)
|
||||
@@ -313,8 +316,9 @@ const calledForCheckIn = computed(() => {
|
||||
const isCalled = p.status === 'waiting' && p.processStage === 'loket'
|
||||
|
||||
// Filter berdasarkan loketId yang dikonfigurasi di screen
|
||||
const patientLoketId = p.loketId || 1 // Default ke loket 1 jika tidak ada
|
||||
const isForConfiguredLoket = loketIds.includes(patientLoketId)
|
||||
// Handle potential undefined or number types
|
||||
const patientLoketId = String(p.loketId || 1)
|
||||
const isForConfiguredLoket = normalizedConfiguredIds.includes(patientLoketId)
|
||||
|
||||
return isCalled && isForConfiguredLoket
|
||||
})
|
||||
@@ -336,13 +340,14 @@ const displayedLokets = computed(() => {
|
||||
|
||||
// Group queues by loketId
|
||||
const queuesByLoket = new Map()
|
||||
const normalizedQueues = Array.isArray(calledForCheckIn.value) ? calledForCheckIn.value : []
|
||||
|
||||
loketIds.forEach(loketId => {
|
||||
const loket = masterStore.getLoketById(loketId)
|
||||
const loketQueues = queues
|
||||
const loketQueues = normalizedQueues
|
||||
.filter(q => {
|
||||
const patientLoketId = q.loketId || 1
|
||||
return patientLoketId === loketId
|
||||
// Robust string comparison
|
||||
return String(q.loketId || 1) === String(loketId)
|
||||
})
|
||||
.sort((a, b) => {
|
||||
// Sort by createdAt or no for consistent ordering
|
||||
@@ -516,26 +521,38 @@ onMounted(() => {
|
||||
navigateTo('/anjungan/antreanmasuk')
|
||||
return
|
||||
}
|
||||
|
||||
// Proactive Fetching: Ensure this screen fetches data even if others didn't
|
||||
const fetchAllData = async () => {
|
||||
console.log('🔄 Anjungan polling: Fetching data for configured lokets...');
|
||||
try {
|
||||
// 1. Fetch data for each configured loket
|
||||
const loketIds = configuredLoketIds.value;
|
||||
if (loketIds && loketIds.length > 0) {
|
||||
for (const id of loketIds) {
|
||||
await queueStore.fetchPatientsForLoket(id);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Ensure initial data (seeds etc)
|
||||
queueStore.ensureInitialData();
|
||||
|
||||
console.log('✅ Anjungan polling: Success');
|
||||
} catch (err) {
|
||||
console.error('❌ Anjungan polling error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial fetch
|
||||
fetchAllData();
|
||||
|
||||
// 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)
|
||||
// Polling every 10 seconds to keep data synchronized
|
||||
const pollingInterval = setInterval(fetchAllData, 10000);
|
||||
|
||||
onUnmounted(() => {
|
||||
clearInterval(pollingInterval);
|
||||
});
|
||||
|
||||
updateTime()
|
||||
timeInterval = setInterval(updateTime, 1000)
|
||||
})
|
||||
|
||||
@@ -106,9 +106,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Queues already Checked-In (DI LOKET) -->
|
||||
<!-- Queues already Checked-In -->
|
||||
<div v-if="klinik.diLoketQueues && klinik.diLoketQueues.length > 0" class="mt-4">
|
||||
<div class="current-label mb-2">DI LOKET (SIAP DIPANGGIL)</div>
|
||||
<div class="current-label mb-2">SIAP DIPANGGIL</div>
|
||||
<div
|
||||
class="all-queues-grid"
|
||||
:style="getGridStyle(klinik.diLoketQueues.length)"
|
||||
@@ -127,9 +127,29 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Queues PENDING (TERTUNDA) -->
|
||||
<div v-if="klinik.pendingQueues && klinik.pendingQueues.length > 0" class="mt-4">
|
||||
<div class="current-label mb-2 text-warning">TERTUNDA</div>
|
||||
<div
|
||||
class="all-queues-grid"
|
||||
:style="getGridStyle(klinik.pendingQueues.length)"
|
||||
>
|
||||
<div
|
||||
v-for="queue in klinik.pendingQueues"
|
||||
:key="`pending-${queue.no}`"
|
||||
class="queue-grid-item is-pending"
|
||||
:class="{
|
||||
'highlight-called': isCalled(queue),
|
||||
}"
|
||||
>
|
||||
{{ queue.noAntrian.split(' |')[0] }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="!klinik.currentQueue && klinik.diLoketQueues.length === 0" class="empty-state">
|
||||
<div v-if="!klinik.currentQueue && (!klinik.diLoketQueues || klinik.diLoketQueues.length === 0) && (!klinik.pendingQueues || klinik.pendingQueues.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>
|
||||
@@ -233,10 +253,10 @@ const loketPatients = computed(() => {
|
||||
const allPatients = queueStore.allPatients?.value || queueStore.allPatients || [];
|
||||
|
||||
// Filter for this stage and relevant statuses
|
||||
// Include 'di-loket' (serving/called) and 'waiting' (next in line)
|
||||
// Include 'di-loket' (serving/called), 'waiting' (next in line) and 'pending' (on hold)
|
||||
return allPatients.filter(p =>
|
||||
p.processStage === 'loket' &&
|
||||
(p.status === 'di-loket' || p.status === 'waiting')
|
||||
(p.status === 'di-loket' || p.status === 'waiting' || p.status === 'pending')
|
||||
);
|
||||
});
|
||||
|
||||
@@ -559,9 +579,11 @@ const displayedClinics = computed(() => {
|
||||
let currentQueue = null
|
||||
const normalizedKlinikName = klinikName.trim()
|
||||
|
||||
// Get all checked-in patients for this clinic
|
||||
// Get all checked-in or pending patients for this clinic
|
||||
const servingQueues = sortedQueues.filter(q =>
|
||||
q.status === 'di-loket' && q.processStage === 'loket' && getKlinikNameFromPatient(q) === normalizedKlinikName
|
||||
(q.status === 'di-loket' || q.status === 'pending') &&
|
||||
q.processStage === 'loket' &&
|
||||
getKlinikNameFromPatient(q) === normalizedKlinikName
|
||||
)
|
||||
|
||||
// Prioritas 1: If Hero section is calling someone for THIS clinic, show them as serving
|
||||
@@ -596,14 +618,16 @@ const displayedClinics = computed(() => {
|
||||
return q.no !== currentQueue?.no
|
||||
})
|
||||
|
||||
// Separate di-loket grid - excluding currentQueue
|
||||
const diLoketQueuesInGrid = servingQueues.filter(q => q.no !== currentQueue?.no);
|
||||
// Separate di-loket and pending grids
|
||||
const diLoketQueuesInGrid = servingQueues.filter(q => q.no !== currentQueue?.no && q.status !== 'pending');
|
||||
const pendingQueues = servingQueues.filter(q => q.status === 'pending');
|
||||
|
||||
return {
|
||||
name: klinikName,
|
||||
currentQueue: currentQueue,
|
||||
multipleCalls: multipleCalls,
|
||||
diLoketQueues: diLoketQueuesInGrid,
|
||||
pendingQueues: pendingQueues, // Aded to display pending patients
|
||||
waitingQueues: [], // Removed as per request
|
||||
allQueues: diLoketQueuesInGrid,
|
||||
totalQueues: servingQueues.length
|
||||
@@ -613,13 +637,12 @@ const displayedClinics = computed(() => {
|
||||
return clinics.sort((a, b) => a.name.localeCompare(b.name))
|
||||
})
|
||||
|
||||
// Dynamic grid logic: divide total by 2 to balance across rows, cap at 7
|
||||
// Dynamic grid logic: divide total by 2 to balance across rows, cap at 5 for larger cards
|
||||
const gridColumns = computed(() => {
|
||||
const total = displayedClinics.value.length
|
||||
if (total <= 1) return 1
|
||||
if (total <= 7) return total // Show in one row if 7 or less looks better?
|
||||
// User asked for balanced rows, so for 8 clinics it will be 4 col, 2 rows.
|
||||
return Math.min(7, Math.ceil(total / 2))
|
||||
if (total <= 5) return total
|
||||
return Math.min(5, Math.ceil(total / 2))
|
||||
})
|
||||
|
||||
const gridStyle = computed(() => ({
|
||||
@@ -897,10 +920,10 @@ onUnmounted(() => {
|
||||
|
||||
<style scoped lang="scss">
|
||||
.antrian-display-container {
|
||||
background: var(--color-neutral-300);
|
||||
background: #FFFFFF;
|
||||
width: 100%;
|
||||
height: 100dvh;
|
||||
padding: clamp(10px, 1.5vw, 20px);
|
||||
padding: 16px;
|
||||
font-family: 'Inter', 'Roboto', sans-serif;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
@@ -942,8 +965,8 @@ onUnmounted(() => {
|
||||
align-items: center;
|
||||
background: linear-gradient(135deg, var(--color-primary-600) 0%, var(--color-primary-700) 100%);
|
||||
border-radius: 12px;
|
||||
padding: clamp(8px, 1vh, 16px) clamp(16px, 2vw, 32px);
|
||||
margin-bottom: clamp(10px, 1.2vh, 16px);
|
||||
padding: clamp(12px, 1.2vw, 18px) clamp(16px, 2vw, 24px);
|
||||
margin-bottom: 12px;
|
||||
box-shadow: 0 8px 24px rgba(33, 150, 243, 0.3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -974,7 +997,7 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.hospital-name {
|
||||
font-size: clamp(22px, 2.5vw, 34px);
|
||||
font-size: clamp(28px, 3vw, 42px);
|
||||
font-weight: 800;
|
||||
color: var(--color-neutral-100);
|
||||
margin: 0;
|
||||
@@ -1253,9 +1276,8 @@ onUnmounted(() => {
|
||||
.kliniks-grid {
|
||||
display: grid;
|
||||
/* grid-template-columns is now handled by :style="gridStyle" */
|
||||
gap: clamp(10px, 1.2vw, 20px);
|
||||
margin-bottom: clamp(10px, 1.2vh, 20px);
|
||||
padding: 0 1vw;
|
||||
gap: clamp(8px, 1vw, 16px);
|
||||
margin-bottom: 12px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
@@ -1267,7 +1289,7 @@ onUnmounted(() => {
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 12px rgba(33, 150, 243, 0.12);
|
||||
min-height: clamp(160px, 20vh, 260px);
|
||||
min-height: clamp(200px, 25vh, 320px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -1282,7 +1304,7 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.klinik-title {
|
||||
font-size: clamp(14px, 1vw, 22px);
|
||||
font-size: clamp(18px, 1.2vw, 26px);
|
||||
font-weight: 800;
|
||||
color: var(--color-neutral-100);
|
||||
letter-spacing: 0.5px;
|
||||
@@ -1315,9 +1337,9 @@ onUnmounted(() => {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: clamp(8px, 1.2vh, 16px);
|
||||
padding: clamp(12px, 1.5vh, 24px);
|
||||
background: var(--color-primary-50);
|
||||
gap: clamp(4px, 0.8vh, 12px);
|
||||
gap: clamp(8px, 1vh, 16px);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@@ -1331,19 +1353,19 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.current-label {
|
||||
font-size: 14px;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--color-primary-600);
|
||||
margin-bottom: 10px;
|
||||
margin-bottom: 12px;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.current-number-large {
|
||||
font-size: clamp(32px, 3.5vw, 56px);
|
||||
font-size: clamp(48px, 5vw, 84px);
|
||||
font-weight: 900;
|
||||
color: var(--color-neutral-900);
|
||||
letter-spacing: 1.5px;
|
||||
letter-spacing: 2px;
|
||||
line-height: 1;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
@@ -1356,7 +1378,7 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.current-waiting-text {
|
||||
font-size: clamp(14px, 1.1vw, 24px);
|
||||
font-size: clamp(16px, 1.2vw, 28px);
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-500);
|
||||
letter-spacing: 0.5px;
|
||||
@@ -1540,15 +1562,19 @@ onUnmounted(() => {
|
||||
|
||||
/* ========== FOOTER STATS ========== */
|
||||
.footer-stats-bar {
|
||||
background: var(--color-neutral-100);
|
||||
background: #FFFFFF;
|
||||
border: 2px solid var(--color-primary-200);
|
||||
border-radius: 16px;
|
||||
padding: clamp(10px, 1.2vh, 18px) clamp(20px, 2.5vw, 40px);
|
||||
display: flex;
|
||||
border-radius: 14px;
|
||||
padding: 20px 40px;
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
gap: clamp(16px, 2vw, 32px);
|
||||
box-shadow: 0 4px 12px rgba(33, 150, 243, 0.12);
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
box-shadow: 0 4px 16px rgba(25, 118, 210, 0.08);
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
|
||||
@@ -1369,7 +1369,7 @@ const LAST_RESET_TIME_KEY = 'checkin_last_reset_time';
|
||||
const RESET_HOUR = 2; // Jam 2 pagi
|
||||
const HISTORY_STORAGE_KEY = 'checkin_history';
|
||||
|
||||
// Check and reset daily at 10 PM (22:00)
|
||||
// Check and reset daily at 2 AM (02:00)
|
||||
const checkAndResetDaily = () => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
@@ -3474,18 +3474,19 @@ const statsToday = computed(() => {
|
||||
}).length;
|
||||
});
|
||||
|
||||
// Get today's date after reset (after 10 PM, consider next day)
|
||||
// Get today's date after reset (after 2 AM)
|
||||
const getTodayAfterReset = (): string => {
|
||||
const now = new Date();
|
||||
const currentHour = now.getHours();
|
||||
|
||||
// If it's 10 PM or later, consider it as next day for stats
|
||||
if (currentHour >= RESET_HOUR) {
|
||||
const tomorrow = new Date(now);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
return tomorrow.toISOString().split('T')[0];
|
||||
// If it's before 2 AM, it's still technically "yesterday" operationally
|
||||
if (currentHour < RESET_HOUR) {
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
return yesterday.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
// If it's 2 AM or later, it's "today"
|
||||
return now.toISOString().split('T')[0];
|
||||
};
|
||||
|
||||
|
||||
+5
-1
@@ -77,7 +77,7 @@
|
||||
<div class="action-divider"></div>
|
||||
|
||||
<!-- Export Button -->
|
||||
<v-menu offset-y>
|
||||
<v-menu v-if="false" offset-y>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
@@ -203,6 +203,7 @@
|
||||
</div>
|
||||
<div class="chart-controls">
|
||||
<v-btn
|
||||
v-if="false"
|
||||
icon="mdi-download"
|
||||
variant="text"
|
||||
size="small"
|
||||
@@ -270,6 +271,7 @@
|
||||
</div>
|
||||
<div class="chart-controls">
|
||||
<v-btn
|
||||
v-if="false"
|
||||
icon="mdi-download"
|
||||
variant="text"
|
||||
size="small"
|
||||
@@ -314,6 +316,7 @@
|
||||
</div>
|
||||
<div class="chart-controls">
|
||||
<v-btn
|
||||
v-if="false"
|
||||
icon="mdi-download"
|
||||
variant="text"
|
||||
size="small"
|
||||
@@ -358,6 +361,7 @@
|
||||
</div>
|
||||
<div class="chart-controls">
|
||||
<v-btn
|
||||
v-if="false"
|
||||
icon="mdi-download"
|
||||
variant="text"
|
||||
size="small"
|
||||
|
||||
@@ -35,11 +35,47 @@
|
||||
<v-card>
|
||||
<!-- Table -->
|
||||
<v-card-text>
|
||||
<!-- Filter Row -->
|
||||
<div class="d-flex flex-wrap align-center justify-space-between mb-4">
|
||||
<div class="d-flex align-center">
|
||||
<span class="mr-2 body-3">Show</span>
|
||||
<v-select
|
||||
v-model="itemsPerPage"
|
||||
:items="[10, 25, 50, 100]"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
style="max-width: 80px;"
|
||||
rounded
|
||||
class="mr-2"
|
||||
></v-select>
|
||||
<span class="body-3">entries</span>
|
||||
</div>
|
||||
<div class="d-flex align-center">
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
label="Search"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
rounded
|
||||
clearable
|
||||
style="min-width: 250px;"
|
||||
></v-text-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-data-table
|
||||
v-model:page="page"
|
||||
:headers="headers"
|
||||
:items="masterStore.klinikData"
|
||||
:items-per-page="10"
|
||||
:items-per-page="itemsPerPage"
|
||||
:search="search"
|
||||
item-value="id"
|
||||
class="elevation-0 data-table"
|
||||
hover
|
||||
@update:itemsLength="filteredTotal = $event"
|
||||
>
|
||||
<template #item.jenisLayanan="{ item }">
|
||||
<v-chip
|
||||
@@ -82,6 +118,23 @@
|
||||
Delete
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<template #bottom>
|
||||
<v-row class="ma-2" align="center">
|
||||
<v-col cols="12" sm="6" class="d-flex align-center justify-start body-3 text-grey">
|
||||
{{ showingEntriesText }}
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" class="d-flex align-center justify-end">
|
||||
<v-pagination
|
||||
v-model="page"
|
||||
:length="pageCount"
|
||||
total-visible="5"
|
||||
rounded="circle"
|
||||
size="small"
|
||||
></v-pagination>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
@@ -398,6 +451,15 @@ import { useClinicStore } from '@/stores/clinicStore';
|
||||
|
||||
const masterStore = useMasterStore();
|
||||
const clinicStore = useClinicStore();
|
||||
const page = ref(1);
|
||||
const itemsPerPage = ref(10);
|
||||
const search = ref('');
|
||||
const filteredTotal = ref(masterStore.klinikData.length);
|
||||
|
||||
import { watch } from 'vue';
|
||||
watch(() => masterStore.klinikData.length, (newLen) => {
|
||||
if (!search.value) filteredTotal.value = newLen;
|
||||
}, { immediate: true });
|
||||
const dialog = ref(false);
|
||||
const isEdit = ref(false);
|
||||
const formRef = ref(null);
|
||||
@@ -418,6 +480,17 @@ const headers = ref([
|
||||
{ title: 'Aksi', value: 'aksi', sortable: false },
|
||||
]);
|
||||
|
||||
const pageCount = computed(() => {
|
||||
return Math.ceil(filteredTotal.value / itemsPerPage.value) || 1;
|
||||
});
|
||||
|
||||
const showingEntriesText = computed(() => {
|
||||
if (filteredTotal.value === 0) return 'Showing 0 to 0 of 0 entries';
|
||||
const start = (page.value - 1) * itemsPerPage.value + 1;
|
||||
const end = Math.min(page.value * itemsPerPage.value, filteredTotal.value);
|
||||
return `Showing ${start} to ${end} of ${filteredTotal.value} entries${search.value ? ' (filtered)' : ''}`;
|
||||
});
|
||||
|
||||
const hariList = ref([
|
||||
{ no: 1, hari: 'Senin' },
|
||||
{ no: 2, hari: 'Selasa' },
|
||||
|
||||
@@ -41,13 +41,48 @@
|
||||
</div>
|
||||
|
||||
<v-card>
|
||||
<!-- Table -->
|
||||
<v-card-text>
|
||||
<!-- Filter Row -->
|
||||
<div class="d-flex flex-wrap align-center justify-space-between mb-4">
|
||||
<div class="d-flex align-center">
|
||||
<span class="mr-2 body-3">Show</span>
|
||||
<v-select
|
||||
v-model="itemsPerPage"
|
||||
:items="[10, 25, 50, 100]"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
style="max-width: 80px;"
|
||||
rounded
|
||||
class="mr-2"
|
||||
></v-select>
|
||||
<span class="body-3">entries</span>
|
||||
</div>
|
||||
<div class="d-flex align-center">
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
label="Search"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
rounded
|
||||
clearable
|
||||
style="min-width: 250px;"
|
||||
></v-text-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-data-table
|
||||
v-model:page="page"
|
||||
:headers="headers"
|
||||
:items="masterStore.ruangData"
|
||||
:items-per-page="10"
|
||||
:items-per-page="itemsPerPage"
|
||||
:search="search"
|
||||
item-value="id"
|
||||
class="elevation-0 data-table"
|
||||
hover
|
||||
@update:itemsLength="filteredTotal = $event"
|
||||
>
|
||||
<template v-slot:item.namaKlinik="{ item }">
|
||||
<v-chip size="small" class="chip-success-outline">
|
||||
@@ -98,6 +133,23 @@
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<template #bottom>
|
||||
<v-row class="ma-2" align="center">
|
||||
<v-col cols="12" sm="6" class="d-flex align-center justify-start body-3 text-grey">
|
||||
{{ showingEntriesText }}
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" class="d-flex align-center justify-end">
|
||||
<v-pagination
|
||||
v-model="page"
|
||||
:length="pageCount"
|
||||
total-visible="5"
|
||||
rounded="circle"
|
||||
size="small"
|
||||
></v-pagination>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.aksi="{ item }">
|
||||
<v-btn
|
||||
size="small"
|
||||
@@ -322,6 +374,15 @@ import { useRuangStore } from '@/stores/ruangStore';
|
||||
const masterStore = useMasterStore();
|
||||
const clinicStore = useClinicStore();
|
||||
const ruangStore = useRuangStore();
|
||||
const page = ref(1);
|
||||
const itemsPerPage = ref(10);
|
||||
const search = ref('');
|
||||
const filteredTotal = ref(masterStore.ruangData.length);
|
||||
|
||||
import { watch } from 'vue';
|
||||
watch(() => masterStore.ruangData.length, (newLen) => {
|
||||
if (!search.value) filteredTotal.value = newLen;
|
||||
}, { immediate: true });
|
||||
const dialog = ref(false);
|
||||
const isEdit = ref(false);
|
||||
const formRef = ref(null);
|
||||
@@ -345,6 +406,17 @@ const headers = ref([
|
||||
{ title: 'Aksi', value: 'aksi', sortable: false },
|
||||
]);
|
||||
|
||||
const pageCount = computed(() => {
|
||||
return Math.ceil(filteredTotal.value / itemsPerPage.value) || 1;
|
||||
});
|
||||
|
||||
const showingEntriesText = computed(() => {
|
||||
if (filteredTotal.value === 0) return 'Showing 0 to 0 of 0 entries';
|
||||
const start = (page.value - 1) * itemsPerPage.value + 1;
|
||||
const end = Math.min(page.value * itemsPerPage.value, filteredTotal.value);
|
||||
return `Showing ${start} to ${end} of ${filteredTotal.value} entries${search.value ? ' (filtered)' : ''}`;
|
||||
});
|
||||
|
||||
const formData = ref({
|
||||
id: null,
|
||||
kodeKlinik: '',
|
||||
|
||||
+139
-31
@@ -35,11 +35,47 @@
|
||||
<v-card>
|
||||
<!-- Table -->
|
||||
<v-card-text>
|
||||
<!-- Filter Row -->
|
||||
<div class="d-flex flex-wrap align-center justify-space-between mb-4">
|
||||
<div class="d-flex align-center">
|
||||
<span class="mr-2 body-3">Show</span>
|
||||
<v-select
|
||||
v-model="itemsPerPage"
|
||||
:items="[10, 25, 50, 100]"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
style="max-width: 80px;"
|
||||
rounded
|
||||
class="mr-2"
|
||||
></v-select>
|
||||
<span class="body-3">entries</span>
|
||||
</div>
|
||||
<div class="d-flex align-center">
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
label="Search"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
rounded
|
||||
clearable
|
||||
style="min-width: 250px;"
|
||||
></v-text-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-data-table
|
||||
v-model:page="page"
|
||||
:headers="loketHeaders"
|
||||
:items="loketStore.loketData"
|
||||
:items-per-page="10"
|
||||
:items-per-page="itemsPerPage"
|
||||
:search="search"
|
||||
item-value="id"
|
||||
class="elevation-0 data-table"
|
||||
hover
|
||||
@update:itemsLength="filteredTotal = $event"
|
||||
>
|
||||
<template #item.pelayanan="{ item }">
|
||||
<v-chip
|
||||
@@ -111,6 +147,17 @@
|
||||
</template>
|
||||
|
||||
<template #item.aksi="{ item }">
|
||||
<v-btn
|
||||
v-if="item.source === 'api'"
|
||||
size="small"
|
||||
variant="flat"
|
||||
color="info-600"
|
||||
class="btn-refresh mr-2"
|
||||
icon="mdi-refresh"
|
||||
@click="handleRefreshSingle(item)"
|
||||
:loading="loketStore.isLoadingAPI && refreshingId === item.id"
|
||||
>
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
size="small"
|
||||
@@ -133,6 +180,23 @@
|
||||
Delete
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<template #bottom>
|
||||
<v-row class="ma-2" align="center">
|
||||
<v-col cols="12" sm="6" class="d-flex align-center justify-start body-3 text-grey">
|
||||
{{ showingEntriesText }}
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" class="d-flex align-center justify-end">
|
||||
<v-pagination
|
||||
v-model="page"
|
||||
:length="pageCount"
|
||||
total-visible="5"
|
||||
rounded="circle"
|
||||
size="small"
|
||||
></v-pagination>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
@@ -345,6 +409,15 @@ import { useLoketStore } from '@/stores/loketStore';
|
||||
|
||||
const masterStore = useMasterStore();
|
||||
const loketStore = useLoketStore();
|
||||
const page = ref(1);
|
||||
const itemsPerPage = ref(10);
|
||||
const search = ref('');
|
||||
const filteredTotal = ref((loketStore.loketData || []).length);
|
||||
|
||||
import { watch } from 'vue';
|
||||
watch(() => (loketStore.loketData || []).length, (newLen) => {
|
||||
if (!search.value) filteredTotal.value = newLen;
|
||||
}, { immediate: true });
|
||||
const dialog = ref(false);
|
||||
const previewDialog = ref(false);
|
||||
const previewItem = ref(null);
|
||||
@@ -428,6 +501,17 @@ const loketHeaders = ref([
|
||||
{ title: "Aksi", value: "aksi", sortable: false },
|
||||
]);
|
||||
|
||||
const pageCount = computed(() => {
|
||||
return Math.ceil(filteredTotal.value / itemsPerPage.value) || 1;
|
||||
});
|
||||
|
||||
const showingEntriesText = computed(() => {
|
||||
if (filteredTotal.value === 0) return 'Showing 0 to 0 of 0 entries';
|
||||
const start = (page.value - 1) * itemsPerPage.value + 1;
|
||||
const end = Math.min(page.value * itemsPerPage.value, filteredTotal.value);
|
||||
return `Showing ${start} to ${end} of ${filteredTotal.value} entries${search.value ? ' (filtered)' : ''}`;
|
||||
});
|
||||
|
||||
const formData = ref({
|
||||
id: null,
|
||||
namaLoket: '',
|
||||
@@ -524,6 +608,30 @@ const handleDelete = (item) => {
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const refreshingId = ref(null);
|
||||
|
||||
const handleRefreshSingle = async (item) => {
|
||||
if (!item.id) return;
|
||||
|
||||
refreshingId.value = item.id;
|
||||
try {
|
||||
const result = await loketStore.fetchSingleLoketFromAPI(item.id);
|
||||
snackbar.value = {
|
||||
show: true,
|
||||
message: result.message,
|
||||
color: result.success ? 'success' : 'warning'
|
||||
};
|
||||
} catch (error) {
|
||||
snackbar.value = {
|
||||
show: true,
|
||||
message: `Error: ${error.message}`,
|
||||
color: 'error'
|
||||
};
|
||||
} finally {
|
||||
refreshingId.value = null;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -549,20 +657,20 @@ $success-200: #F1FBF8;
|
||||
|
||||
$danger-600: #E02B1D;
|
||||
|
||||
// Font Family & Weights
|
||||
/* 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
|
||||
/* Apply font family */
|
||||
* {
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// PAGE HEADER
|
||||
// ============================================
|
||||
/* ============================================ */
|
||||
/* PAGE HEADER */
|
||||
/* ============================================ */
|
||||
.page-header {
|
||||
background: linear-gradient(135deg, $primary-600 0%, $primary-700 100%);
|
||||
border-radius: 0 !important;
|
||||
@@ -618,9 +726,9 @@ $font-weight-semibold: 600;
|
||||
color: $primary-600 !important;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ACTION BAR
|
||||
// ============================================
|
||||
/* ============================================ */
|
||||
/* ACTION BAR */
|
||||
/* ============================================ */
|
||||
.action-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -640,17 +748,17 @@ $font-weight-semibold: 600;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
// DATA TABLE
|
||||
// ============================================
|
||||
/* DATA TABLE */
|
||||
/* ============================================ */
|
||||
.data-table {
|
||||
font-family: $font-family-base;
|
||||
|
||||
// Vertically center-align table headers
|
||||
/* Vertically center-align table headers */
|
||||
:deep(th) {
|
||||
vertical-align: middle !important;
|
||||
}
|
||||
|
||||
// Vertically center-align table cells
|
||||
/* Vertically center-align table cells */
|
||||
:deep(td) {
|
||||
vertical-align: middle !important;
|
||||
}
|
||||
@@ -720,9 +828,9 @@ $font-weight-semibold: 600;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// DIALOG
|
||||
// ============================================
|
||||
/* ============================================ */
|
||||
/* DIALOG */
|
||||
/* ============================================ */
|
||||
.dialog-card {
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
@@ -757,9 +865,9 @@ $font-weight-semibold: 600;
|
||||
background: $neutral-300;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// FORM ELEMENTS
|
||||
// ============================================
|
||||
/* ============================================ */
|
||||
/* FORM ELEMENTS */
|
||||
/* ============================================ */
|
||||
.field-group {
|
||||
background: $neutral-100;
|
||||
padding: 20px;
|
||||
@@ -807,9 +915,9 @@ $font-weight-semibold: 600;
|
||||
border-color: $neutral-400 !important;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CHIPS
|
||||
// ============================================
|
||||
/* ============================================ */
|
||||
/* CHIPS */
|
||||
/* ============================================ */
|
||||
.chip-primary {
|
||||
background-color: $primary-600 !important;
|
||||
color: $neutral-100 !important;
|
||||
@@ -824,9 +932,9 @@ $font-weight-semibold: 600;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// SELECTED PREVIEW
|
||||
// ============================================
|
||||
/* ============================================ */
|
||||
/* SELECTED PREVIEW */
|
||||
/* ============================================ */
|
||||
.selected-preview {
|
||||
margin-top: 8px;
|
||||
padding: 8px 12px;
|
||||
@@ -842,9 +950,9 @@ $font-weight-semibold: 600;
|
||||
color: $success-600 !important;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// BUTTONS
|
||||
// ============================================
|
||||
/* ============================================ */
|
||||
/* BUTTONS */
|
||||
/* ============================================ */
|
||||
.btn-cancel {
|
||||
border-color: $neutral-600 !important;
|
||||
color: $neutral-800 !important;
|
||||
@@ -865,9 +973,9 @@ $font-weight-semibold: 600;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// PREVIEW DIALOG
|
||||
// ============================================
|
||||
/* ============================================ */
|
||||
/* PREVIEW DIALOG */
|
||||
/* ============================================ */
|
||||
.preview-dialog-card {
|
||||
font-family: $font-family-base;
|
||||
height: 95vh;
|
||||
|
||||
@@ -30,11 +30,47 @@
|
||||
<v-card>
|
||||
<!-- Table -->
|
||||
<v-card-text>
|
||||
<!-- Filter Row -->
|
||||
<div class="d-flex flex-wrap align-center justify-space-between mb-4">
|
||||
<div class="d-flex align-center">
|
||||
<span class="mr-2 body-3">Show</span>
|
||||
<v-select
|
||||
v-model="itemsPerPage"
|
||||
:items="[10, 25, 50, 100]"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
style="max-width: 80px;"
|
||||
rounded
|
||||
class="mr-2"
|
||||
></v-select>
|
||||
<span class="body-3">entries</span>
|
||||
</div>
|
||||
<div class="d-flex align-center">
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
label="Search"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
rounded
|
||||
clearable
|
||||
style="min-width: 250px;"
|
||||
></v-text-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-data-table
|
||||
v-model:page="page"
|
||||
:headers="headers"
|
||||
:items="masterStore.penunjangData"
|
||||
:items-per-page="10"
|
||||
:items-per-page="itemsPerPage"
|
||||
:search="search"
|
||||
item-value="id"
|
||||
class="elevation-0 data-table"
|
||||
hover
|
||||
@update:itemsLength="filteredTotal = $event"
|
||||
>
|
||||
<template #item.jenis="{ item }">
|
||||
<v-chip
|
||||
@@ -86,6 +122,23 @@
|
||||
Delete
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<template #bottom>
|
||||
<v-row class="ma-2" align="center">
|
||||
<v-col cols="12" sm="6" class="d-flex align-center justify-start body-3 text-grey">
|
||||
{{ showingEntriesText }}
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" class="d-flex align-center justify-end">
|
||||
<v-pagination
|
||||
v-model="page"
|
||||
:length="pageCount"
|
||||
total-visible="5"
|
||||
rounded="circle"
|
||||
size="small"
|
||||
></v-pagination>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
@@ -350,10 +403,19 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { ref, computed } from 'vue';
|
||||
import { useMasterStore } from '@/stores/masterStore';
|
||||
|
||||
const masterStore = useMasterStore();
|
||||
const page = ref(1);
|
||||
const itemsPerPage = ref(10);
|
||||
const search = ref('');
|
||||
const filteredTotal = ref(masterStore.penunjangData.length);
|
||||
|
||||
import { watch } from 'vue';
|
||||
watch(() => masterStore.penunjangData.length, (newLen) => {
|
||||
if (!search.value) filteredTotal.value = newLen;
|
||||
}, { immediate: true });
|
||||
const dialog = ref(false);
|
||||
const isEdit = ref(false);
|
||||
const formRef = ref(null);
|
||||
@@ -375,6 +437,17 @@ const headers = ref([
|
||||
{ title: 'Aksi', value: 'aksi', sortable: false },
|
||||
]);
|
||||
|
||||
const pageCount = computed(() => {
|
||||
return Math.ceil(filteredTotal.value / itemsPerPage.value) || 1;
|
||||
});
|
||||
|
||||
const showingEntriesText = computed(() => {
|
||||
if (filteredTotal.value === 0) return 'Showing 0 to 0 of 0 entries';
|
||||
const start = (page.value - 1) * itemsPerPage.value + 1;
|
||||
const end = Math.min(page.value * itemsPerPage.value, filteredTotal.value);
|
||||
return `Showing ${start} to ${end} of ${filteredTotal.value} entries${search.value ? ' (filtered)' : ''}`;
|
||||
});
|
||||
|
||||
const jenisPenunjangList = ref([
|
||||
'Medis',
|
||||
'Non-Medis'
|
||||
|
||||
@@ -923,6 +923,80 @@ export const useClinicStore = defineStore('clinic', () => {
|
||||
}));
|
||||
};
|
||||
|
||||
const addClinic = (clinicPayload) => {
|
||||
try {
|
||||
const newId = clinics.value.length > 0
|
||||
? Math.max(...clinics.value.map(c => c.id || 0)) + 1
|
||||
: 1000;
|
||||
|
||||
const newClinic = {
|
||||
id: newId,
|
||||
kode: clinicPayload.kode || 'XX',
|
||||
name: clinicPayload.name || 'Unknown',
|
||||
subtitle: clinicPayload.subtitle || "",
|
||||
icon: clinicPayload.icon || "mdi-hospital-box",
|
||||
shift: clinicPayload.shift || "1 SHIFT",
|
||||
schedule: clinicPayload.schedule || "Mulai Pukul 07:00",
|
||||
available: clinicPayload.available !== undefined ? clinicPayload.available : true,
|
||||
doctors: clinicPayload.doctors || [],
|
||||
shifts: clinicPayload.shifts || [],
|
||||
totalQuota: clinicPayload.totalQuota || 0,
|
||||
jamShiftPerHari: clinicPayload.jamShiftPerHari || {},
|
||||
jamShiftList: clinicPayload.jamShiftList || [],
|
||||
autoShift: clinicPayload.autoShift || false,
|
||||
jadwalKlinik: clinicPayload.jadwalKlinik || [],
|
||||
tanggalTutup: clinicPayload.tanggalTutup || [],
|
||||
jenisLayanan: clinicPayload.jenisLayanan || "Eksekutif",
|
||||
};
|
||||
|
||||
clinics.value.push(newClinic);
|
||||
console.log('✅ Clinic added:', newClinic);
|
||||
return { success: true, message: 'Klinik berhasil ditambahkan', clinic: newClinic };
|
||||
} catch (error) {
|
||||
console.error('❌ Error adding clinic:', error);
|
||||
return { success: false, message: `Gagal menambah klinik: ${error.message}` };
|
||||
}
|
||||
};
|
||||
|
||||
const updateClinic = (id, updates) => {
|
||||
try {
|
||||
const index = clinics.value.findIndex(c => c.id === id);
|
||||
if (index === -1) {
|
||||
return { success: false, message: 'Klinik tidak ditemukan' };
|
||||
}
|
||||
|
||||
clinics.value[index] = {
|
||||
...clinics.value[index],
|
||||
...updates,
|
||||
name: updates.name || updates.nama || clinics.value[index].name
|
||||
};
|
||||
|
||||
console.log('✅ Clinic updated:', clinics.value[index]);
|
||||
return { success: true, message: 'Klinik berhasil diperbarui', clinic: clinics.value[index] };
|
||||
} catch (error) {
|
||||
console.error('❌ Error updating clinic:', error);
|
||||
return { success: false, message: `Gagal memperbarui klinik: ${error.message}` };
|
||||
}
|
||||
};
|
||||
|
||||
const deleteClinic = (id) => {
|
||||
try {
|
||||
const index = clinics.value.findIndex(c => c.id === id);
|
||||
if (index === -1) {
|
||||
return { success: false, message: 'Klinik tidak ditemukan' };
|
||||
}
|
||||
|
||||
const deletedClinic = clinics.value[index];
|
||||
clinics.value.splice(index, 1);
|
||||
|
||||
console.log('✅ Clinic deleted:', id);
|
||||
return { success: true, message: `Klinik ${deletedClinic.name} berhasil dihapus` };
|
||||
} catch (error) {
|
||||
console.error('❌ Error deleting clinic:', error);
|
||||
return { success: false, message: `Gagal menghapus klinik: ${error.message}` };
|
||||
}
|
||||
};
|
||||
|
||||
// Actions untuk update master config (sync dengan masterStore operations)
|
||||
const updateClinicMasterConfig = (kode, masterConfig) => {
|
||||
const clinic = clinics.value.find(c => c.kode === kode);
|
||||
@@ -950,6 +1024,9 @@ export const useClinicStore = defineStore('clinic', () => {
|
||||
getClinicsForDropdown,
|
||||
updateClinicMasterConfig,
|
||||
fetchRegulerClinics,
|
||||
addClinic,
|
||||
updateClinic,
|
||||
deleteClinic,
|
||||
};
|
||||
}, {
|
||||
persist: {
|
||||
|
||||
@@ -423,6 +423,96 @@ export const useLoketStore = defineStore('loket', () => {
|
||||
return activeFetchPromise;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch detail data for a SPECIFIC loket
|
||||
* Endpoint: /api/v1/loket/:loketid
|
||||
*/
|
||||
const fetchSingleLoketFromAPI = async (loketId) => {
|
||||
if (!loketId) return { success: false, message: 'ID Loket diperlukan' };
|
||||
|
||||
isLoadingAPI.value = true;
|
||||
apiError.value = null;
|
||||
|
||||
try {
|
||||
console.log(`🔄 [loketStore] Fetching detail for loket ${loketId}...`);
|
||||
const response = await fetch(`http://10.10.150.131:8089/api/v1/loket/${loketId}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const rawData = await response.json();
|
||||
|
||||
if (rawData.metadata && rawData.metadata.code !== 200) {
|
||||
throw new Error(rawData.message || 'API returned error status');
|
||||
}
|
||||
|
||||
// Check if data is array or single object
|
||||
const data = rawData.data;
|
||||
if (!data) throw new Error('No data returned from API');
|
||||
|
||||
// The API /api/v1/loket/:id seems to return structure similar to /api/v1/klinik/loket
|
||||
// but filtered for one loket. Let's handle both array and object.
|
||||
const l = Array.isArray(data) ? data[0] : data;
|
||||
|
||||
if (!l) throw new Error('Loket data empty');
|
||||
|
||||
// Map to store format
|
||||
const id = parseInt(l.idloket || l.id);
|
||||
|
||||
const spesialisDetail = (l.spesialis || []).map(s => {
|
||||
const clinic = clinicStore.clinics.find(c =>
|
||||
String(c.id) === String(s.idklinik) ||
|
||||
c.name === s.namaklinik
|
||||
);
|
||||
return {
|
||||
idklinik: s.idklinik,
|
||||
namaklinik: s.namaklinik,
|
||||
code: clinic ? clinic.kode : (s.kode || s.idklinik)
|
||||
};
|
||||
});
|
||||
|
||||
const pelayananCodes = [...new Set(spesialisDetail.map(s => s.code))];
|
||||
const pembayaranArray = (l.pembayaran || []).map(p => p.pembayaran).filter(Boolean);
|
||||
const pembayaran = pembayaranArray.length > 0 ? pembayaranArray : ['JKN'];
|
||||
|
||||
const mapped = {
|
||||
id: id,
|
||||
namaLoket: l.namaloket,
|
||||
kodeLoket: l.kodeloket,
|
||||
kuota: parseInt(l.kuotaloket) || 100, // Kuota is part of the detail but we keep it
|
||||
pelayanan: pelayananCodes,
|
||||
_spesialisDetail: spesialisDetail,
|
||||
pembayaran: pembayaran,
|
||||
tipeLoket: l.tipeloket || 'REGULER',
|
||||
source: 'api',
|
||||
loketAktif: l.loketaktif ?? true,
|
||||
jenisloket: l.jenisloket,
|
||||
tipeloket: l.tipeloket
|
||||
};
|
||||
|
||||
// Sync to apiLoketData
|
||||
const index = apiLoketData.value.findIndex(item => item.id === id);
|
||||
if (index !== -1) {
|
||||
// PRESERVE sequential 'no' if exists
|
||||
mapped.no = apiLoketData.value[index].no;
|
||||
apiLoketData.value[index] = mapped;
|
||||
} else {
|
||||
mapped.no = apiLoketData.value.length + 1;
|
||||
apiLoketData.value.push(mapped);
|
||||
apiLoketData.value.sort((a, b) => a.id - b.id);
|
||||
}
|
||||
|
||||
return { success: true, message: `Detail loket ${mapped.namaLoket} diperbarui`, data: mapped };
|
||||
} catch (error) {
|
||||
console.error(`❌ [loketStore] Error fetching detail for loket ${loketId}:`, error);
|
||||
apiError.value = error.message;
|
||||
return { success: false, message: `Gagal memuat detail loket: ${error.message}` };
|
||||
} finally {
|
||||
isLoadingAPI.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
// State - Base refs (for persist)
|
||||
apiLoketData, // Raw API data array (persisted)
|
||||
@@ -447,6 +537,7 @@ export const useLoketStore = defineStore('loket', () => {
|
||||
|
||||
// API Actions
|
||||
fetchLoketFromAPI,
|
||||
fetchSingleLoketFromAPI,
|
||||
};
|
||||
}, {
|
||||
persist: {
|
||||
|
||||
@@ -85,12 +85,12 @@ export const useMasterStore = defineStore('master', () => {
|
||||
// Actions - Klinik (delegate to clinicStore)
|
||||
const addKlinik = (klinikPayload) => {
|
||||
// Map masterStore format ke clinicStore format
|
||||
const clinicPayload = {
|
||||
const mappedClinic = {
|
||||
...klinikPayload,
|
||||
name: klinikPayload.nama, // Convert 'nama' to 'name'
|
||||
shift: `Shift ${klinikPayload.shift}`, // Convert number to string format
|
||||
shift: `Shift ${klinikPayload.shift || 1}`, // Convert number to string format
|
||||
};
|
||||
return clinicStore.addClinic(clinicPayload);
|
||||
return clinicStore.addClinic(mappedClinic);
|
||||
};
|
||||
|
||||
const updateKlinik = (klinikPayload) => {
|
||||
@@ -104,8 +104,7 @@ export const useMasterStore = defineStore('master', () => {
|
||||
};
|
||||
|
||||
const deleteKlinik = (klinikId) => {
|
||||
// Deletion should ideally happen directly in clinicStore
|
||||
return { success: false, message: 'Penghapusan klinik harus dilakukan melalui Clinic Store.' };
|
||||
return clinicStore.deleteClinic(klinikId);
|
||||
};
|
||||
|
||||
const getKlinikById = (id) => {
|
||||
|
||||
+157
-107
@@ -1,6 +1,6 @@
|
||||
// stores/queueStore.js
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useClinicStore } from './clinicStore';
|
||||
import { usePenunjangStore } from './penunjangStore';
|
||||
import { useLoketStore } from './loketStore';
|
||||
@@ -21,6 +21,10 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
|
||||
// Throttle mechanism: track last fetch time per loket
|
||||
const lastFetchTime = ref({});
|
||||
|
||||
// Synchronization Guard: track last update time to break loops across tabs
|
||||
const lastUpdated = ref(Date.now());
|
||||
// synchronization guard (moved lower)
|
||||
|
||||
/**
|
||||
* Sync patient status to apiPatientsPerLoket for reactivity
|
||||
@@ -563,90 +567,90 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
// SEED DATA: ONLY EKSEKUTIF PATIENTS
|
||||
// All REGULER patients will come from API
|
||||
const seedPatients = [
|
||||
{
|
||||
no: 1,
|
||||
jamPanggil: "11:20",
|
||||
barcode: seedBarcodeE1,
|
||||
noAntrian: `EA001 | Online - ${seedBarcodeE1}`,
|
||||
shift: "Shift 1",
|
||||
klinik: "KANDUNGAN",
|
||||
kodeKlinik: "KD",
|
||||
fastTrack: "TIDAK",
|
||||
pembayaran: "Eksekutif",
|
||||
status: "waiting",
|
||||
processStage: "loket",
|
||||
createdAt: new Date().toISOString(),
|
||||
registrationType: 'online',
|
||||
visitType: 'SEKARANG',
|
||||
visitDate: new Date().toISOString().substring(0, 10),
|
||||
namaDokter: "Dr. Ahmad Wijaya, Sp.OG",
|
||||
noRM: "RM-E001",
|
||||
penanggungJawab: null,
|
||||
alasanFastTrack: null,
|
||||
},
|
||||
{
|
||||
no: 2,
|
||||
jamPanggil: "13:45",
|
||||
barcode: seedBarcodeE2,
|
||||
noAntrian: `EA002 | Online - ${seedBarcodeE2}`,
|
||||
shift: "Shift 1",
|
||||
klinik: "IPD",
|
||||
kodeKlinik: "IP",
|
||||
fastTrack: "TIDAK",
|
||||
pembayaran: "Eksekutif",
|
||||
status: "waiting",
|
||||
processStage: "loket",
|
||||
createdAt: new Date().toISOString(),
|
||||
registrationType: 'online',
|
||||
visitType: 'SEKARANG',
|
||||
visitDate: new Date().toISOString().substring(0, 10),
|
||||
namaDokter: "Dr. Budi Santoso, Sp.PD",
|
||||
noRM: "RM-E002",
|
||||
penanggungJawab: null,
|
||||
alasanFastTrack: null,
|
||||
},
|
||||
{
|
||||
no: 3,
|
||||
jamPanggil: "15:10",
|
||||
barcode: seedBarcodeE3,
|
||||
noAntrian: `F-EA003 | Online - ${seedBarcodeE3}`,
|
||||
shift: "Shift 2",
|
||||
klinik: "SARAF",
|
||||
kodeKlinik: "SR",
|
||||
fastTrack: "YA",
|
||||
pembayaran: "Eksekutif",
|
||||
status: "waiting",
|
||||
processStage: "loket",
|
||||
createdAt: new Date().toISOString(),
|
||||
registrationType: 'online',
|
||||
visitType: 'SEKARANG',
|
||||
visitDate: new Date().toISOString().substring(0, 10),
|
||||
namaDokter: "Dr. Citra Dewi, Sp.S",
|
||||
noRM: "RM-E003",
|
||||
penanggungJawab: "Dr. Citra Dewi",
|
||||
alasanFastTrack: "Pasien Eksekutif prioritas",
|
||||
},
|
||||
{
|
||||
no: 4,
|
||||
jamPanggil: "16:30",
|
||||
barcode: seedBarcodeE4,
|
||||
noAntrian: `EA004 | Online - ${seedBarcodeE4}`,
|
||||
shift: "Shift 2",
|
||||
klinik: "THT",
|
||||
kodeKlinik: "TH",
|
||||
fastTrack: "TIDAK",
|
||||
pembayaran: "VIP",
|
||||
status: "waiting",
|
||||
processStage: "loket",
|
||||
createdAt: new Date().toISOString(),
|
||||
registrationType: 'online',
|
||||
visitType: 'SEKARANG',
|
||||
visitDate: new Date().toISOString().substring(0, 10),
|
||||
namaDokter: "Dr. Eka Putri, Sp.THT",
|
||||
noRM: "RM-E004",
|
||||
penanggungJawab: null,
|
||||
alasanFastTrack: null,
|
||||
},
|
||||
// {
|
||||
// no: 1,
|
||||
// jamPanggil: "11:20",
|
||||
// barcode: seedBarcodeE1,
|
||||
// noAntrian: `EA001 | Online - ${seedBarcodeE1}`,
|
||||
// shift: "Shift 1",
|
||||
// klinik: "KANDUNGAN",
|
||||
// kodeKlinik: "KD",
|
||||
// fastTrack: "TIDAK",
|
||||
// pembayaran: "Eksekutif",
|
||||
// status: "waiting",
|
||||
// processStage: "loket",
|
||||
// createdAt: new Date().toISOString(),
|
||||
// registrationType: 'online',
|
||||
// visitType: 'SEKARANG',
|
||||
// visitDate: new Date().toISOString().substring(0, 10),
|
||||
// namaDokter: "Dr. Ahmad Wijaya, Sp.OG",
|
||||
// noRM: "RM-E001",
|
||||
// penanggungJawab: null,
|
||||
// alasanFastTrack: null,
|
||||
// },
|
||||
// {
|
||||
// no: 2,
|
||||
// jamPanggil: "13:45",
|
||||
// barcode: seedBarcodeE2,
|
||||
// noAntrian: `EA002 | Online - ${seedBarcodeE2}`,
|
||||
// shift: "Shift 1",
|
||||
// klinik: "IPD",
|
||||
// kodeKlinik: "IP",
|
||||
// fastTrack: "TIDAK",
|
||||
// pembayaran: "Eksekutif",
|
||||
// status: "waiting",
|
||||
// processStage: "loket",
|
||||
// createdAt: new Date().toISOString(),
|
||||
// registrationType: 'online',
|
||||
// visitType: 'SEKARANG',
|
||||
// visitDate: new Date().toISOString().substring(0, 10),
|
||||
// namaDokter: "Dr. Budi Santoso, Sp.PD",
|
||||
// noRM: "RM-E002",
|
||||
// penanggungJawab: null,
|
||||
// alasanFastTrack: null,
|
||||
// },
|
||||
// {
|
||||
// no: 3,
|
||||
// jamPanggil: "15:10",
|
||||
// barcode: seedBarcodeE3,
|
||||
// noAntrian: `F-EA003 | Online - ${seedBarcodeE3}`,
|
||||
// shift: "Shift 2",
|
||||
// klinik: "SARAF",
|
||||
// kodeKlinik: "SR",
|
||||
// fastTrack: "YA",
|
||||
// pembayaran: "Eksekutif",
|
||||
// status: "waiting",
|
||||
// processStage: "loket",
|
||||
// createdAt: new Date().toISOString(),
|
||||
// registrationType: 'online',
|
||||
// visitType: 'SEKARANG',
|
||||
// visitDate: new Date().toISOString().substring(0, 10),
|
||||
// namaDokter: "Dr. Citra Dewi, Sp.S",
|
||||
// noRM: "RM-E003",
|
||||
// penanggungJawab: "Dr. Citra Dewi",
|
||||
// alasanFastTrack: "Pasien Eksekutif prioritas",
|
||||
// },
|
||||
// {
|
||||
// no: 4,
|
||||
// jamPanggil: "16:30",
|
||||
// barcode: seedBarcodeE4,
|
||||
// noAntrian: `EA004 | Online - ${seedBarcodeE4}`,
|
||||
// shift: "Shift 2",
|
||||
// klinik: "THT",
|
||||
// kodeKlinik: "TH",
|
||||
// fastTrack: "TIDAK",
|
||||
// pembayaran: "VIP",
|
||||
// status: "waiting",
|
||||
// processStage: "loket",
|
||||
// createdAt: new Date().toISOString(),
|
||||
// registrationType: 'online',
|
||||
// visitType: 'SEKARANG',
|
||||
// visitDate: new Date().toISOString().substring(0, 10),
|
||||
// namaDokter: "Dr. Eka Putri, Sp.THT",
|
||||
// noRM: "RM-E004",
|
||||
// penanggungJawab: null,
|
||||
// alasanFastTrack: null,
|
||||
// },
|
||||
];
|
||||
|
||||
|
||||
@@ -771,21 +775,26 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
const isProcessing = Object.values(currentProcessingPatient.value).some(p => p && p.no === patient.no);
|
||||
if (isProcessing) return true;
|
||||
|
||||
const createdAt = patient.createdAt ? new Date(patient.createdAt) : null;
|
||||
const visitDateStr = patient.visitDate; // YYYY-MM-DD
|
||||
|
||||
const threshold = getResetThreshold();
|
||||
|
||||
// Check by createdAt first
|
||||
if (createdAt && createdAt < threshold) {
|
||||
return false;
|
||||
// Get YYYY-MM-DD for the logical "today" (based on 2 AM threshold)
|
||||
const year = threshold.getFullYear();
|
||||
const month = String(threshold.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(threshold.getDate()).padStart(2, '0');
|
||||
const logicalTodayStr = `${year}-${month}-${day}`;
|
||||
|
||||
// 1. If visitDate (YYYY-MM-DD) matches logical today's date, it's a "today" patient
|
||||
// This correctly includes API patients with midnight timestamps (00:00:00)
|
||||
if (patient.visitDate) {
|
||||
if (patient.visitDate === logicalTodayStr) return true;
|
||||
if (patient.visitDate < logicalTodayStr) return false;
|
||||
}
|
||||
|
||||
// Check by visitDate if available
|
||||
if (visitDateStr) {
|
||||
const vDate = new Date(visitDateStr);
|
||||
vDate.setHours(23, 59, 59, 999); // End of visit date
|
||||
if (vDate < threshold) return false;
|
||||
// 2. Fallback: check createdAt timestamp (critical for seed/manual tickets)
|
||||
// Tickets created before today's 2 AM are stale
|
||||
const createdAt = patient.createdAt ? new Date(patient.createdAt) : null;
|
||||
if (createdAt && createdAt < threshold) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -807,37 +816,77 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
syncCountersWithState();
|
||||
};
|
||||
|
||||
// Synchronization Guard: track if we are currently hydrating from another tab
|
||||
let isSyncing = false;
|
||||
|
||||
// CROSS-TAB SYNC: Listen for storage events to update store across tabs
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('storage', (event) => {
|
||||
if (event.key === 'queue-store-state') {
|
||||
console.log('🔄 queue-store-state changed in another tab, re-hydrating...');
|
||||
if (!event.newValue) return;
|
||||
|
||||
try {
|
||||
const newState = JSON.parse(event.newValue);
|
||||
|
||||
// BREAK THE LOOP: Only re-hydrate if the incoming data is strictly newer than ours
|
||||
// Defensive check: Handle cases where lastUpdated might be undefined or missing
|
||||
const incomingVersion = Number(newState?.lastUpdated || 0);
|
||||
const currentVersion = Number(lastUpdated.value || 0);
|
||||
|
||||
if (incomingVersion === 0 || incomingVersion <= currentVersion) {
|
||||
// Already up to date, older, or corrupted data. Skip hydration.
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`🔄 Syncing state to version: ${incomingVersion}`);
|
||||
|
||||
if (newState) {
|
||||
if (newState.allPatients) allPatients.value = newState.allPatients;
|
||||
if (newState.quotaUsed !== undefined) quotaUsed.value = newState.quotaUsed;
|
||||
if (newState.currentProcessingPatient) currentProcessingPatient.value = newState.currentProcessingPatient;
|
||||
isSyncing = true; // Block local watch from updating timestamp
|
||||
|
||||
// FIX: Hydrate apiPatientsPerLoket as well since it is persisted.
|
||||
// Failure to hydrate this causes "ping-pong" writes between tabs if they have different local values.
|
||||
if (newState.apiPatientsPerLoket) apiPatientsPerLoket.value = newState.apiPatientsPerLoket;
|
||||
try {
|
||||
if (newState.allPatients) allPatients.value = newState.allPatients;
|
||||
if (newState.quotaUsed !== undefined) quotaUsed.value = newState.quotaUsed;
|
||||
if (newState.currentProcessingPatient) currentProcessingPatient.value = newState.currentProcessingPatient;
|
||||
if (newState.apiPatientsPerLoket) apiPatientsPerLoket.value = newState.apiPatientsPerLoket;
|
||||
|
||||
// Set local timestamp to match the source
|
||||
lastUpdated.value = incomingVersion;
|
||||
|
||||
syncCountersWithState();
|
||||
} finally {
|
||||
// Ensure we release the lock
|
||||
setTimeout(() => { isSyncing = false; }, 50);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error hydrating from storage event:', e);
|
||||
isSyncing = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Automatic timestamp update for local changes
|
||||
// This ensures lastUpdated changes whenever state changes LOCALLY
|
||||
// but skips updates coming from isSyncing = true
|
||||
watch([allPatients, quotaUsed, currentProcessingPatient, apiPatientsPerLoket], () => {
|
||||
if (!isSyncing) {
|
||||
lastUpdated.value = Date.now();
|
||||
// console.log('✨ Local state updated, version:', lastUpdated.value);
|
||||
}
|
||||
}, { deep: true });
|
||||
|
||||
// Computed - Filter berdasarkan process stage dan status
|
||||
const getPatientsByStage = (stage) => {
|
||||
return computed(() => {
|
||||
// Filter by stage AND date (today only)
|
||||
const patients = allPatients.value.filter(p => p.processStage === stage && isTodayPatient(p));
|
||||
// optimization: filter all once, then sub-filter
|
||||
const patients = allPatients.value.filter(p => p && p.processStage === stage && isTodayPatient(p));
|
||||
|
||||
// Debug log
|
||||
console.log(`getPatientsByStage(${stage}):`, patients.length, 'patients (Today)');
|
||||
// Debug log (limited to avoid spam)
|
||||
if (patients.length > 0) {
|
||||
// console.log(`getPatientsByStage(${stage}):`, patients.length, 'patients (Today)');
|
||||
}
|
||||
|
||||
return {
|
||||
all: patients,
|
||||
@@ -936,6 +985,7 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
...allPatients.value[index],
|
||||
status: "waiting",
|
||||
// Assign to this specific loket if it was unassigned
|
||||
loketId: (adminType === 'loket' && targetId) ? parseInt(targetId) : allPatients.value[index].loketId,
|
||||
lastCalledAt: callTimestamp
|
||||
};
|
||||
|
||||
@@ -2227,7 +2277,7 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
persist: {
|
||||
key: 'queue-store-state',
|
||||
storage: typeof window !== 'undefined' ? localStorage : undefined,
|
||||
paths: ['allPatients', 'quotaUsed', 'currentProcessingPatient', 'apiPatientsPerLoket'],
|
||||
paths: ['allPatients', 'quotaUsed', 'currentProcessingPatient', 'apiPatientsPerLoket', 'lastUpdated'],
|
||||
serializer: {
|
||||
deserialize: JSON.parse,
|
||||
serialize: JSON.stringify,
|
||||
|
||||
Reference in New Issue
Block a user