PUSH UP WOKE UPDATE Api ANTREAN MASUK DAN MINOR CHANGE

This commit is contained in:
Fanrouver
2026-01-30 13:40:43 +07:00
parent 7c3fc4bd50
commit 507f415710
13 changed files with 808 additions and 217 deletions

No files matched your search

+2 -2
View File
@@ -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"]
};
})(),
+40 -23
View File
@@ -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)
})
+62 -36
View File
@@ -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 {
+8 -7
View File
@@ -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
View File
@@ -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"
+74 -1
View File
@@ -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' },
+74 -2
View File
@@ -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
View File
@@ -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;
+75 -2
View File
@@ -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'
+77
View File
@@ -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: {
+91
View File
@@ -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: {
+4 -5
View File
@@ -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
View File
@@ -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,