Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e18ed186e1 | ||
|
|
400bfcdcaf | ||
|
|
8b9c4725de | ||
|
|
f81dd57a16 | ||
|
|
0928e78bec | ||
|
|
047b39064d | ||
|
|
ffdb88d13c |
No files matched your search
@@ -1,5 +1,70 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
const nuxtApp = useNuxtApp();
|
||||
const loading = ref(false);
|
||||
|
||||
nuxtApp.hook('page:start', () => {
|
||||
loading.value = true;
|
||||
});
|
||||
nuxtApp.hook('page:finish', () => {
|
||||
loading.value = false;
|
||||
});
|
||||
|
||||
// Naikkan versi ini setiap kali ada perubahan struktur state (schema migration).
|
||||
// Saat versi tidak cocok, semua cache Pinia akan dibersihkan otomatis.
|
||||
const STORE_SCHEMA_VERSION = 3;
|
||||
const VERSION_KEY = 'app-store-version';
|
||||
|
||||
// Daftar semua localStorage key yang dikelola oleh Pinia stores
|
||||
const PINIA_STORE_KEYS = [
|
||||
'queue-store-state',
|
||||
'clinic-store-state',
|
||||
'doctor-store',
|
||||
'loket-store-state',
|
||||
'ruang-store-state',
|
||||
'klinikruang-store-state',
|
||||
'antrean-masuk-screen-store',
|
||||
'anjungan-store',
|
||||
'screen-store',
|
||||
'penunjang-store',
|
||||
];
|
||||
|
||||
onMounted(() => {
|
||||
try {
|
||||
if (typeof window === 'undefined' || !window.localStorage) return;
|
||||
|
||||
const savedVersion = parseInt(localStorage.getItem(VERSION_KEY) || '0', 10);
|
||||
|
||||
if (savedVersion < STORE_SCHEMA_VERSION) {
|
||||
// Versi lama terdeteksi → hapus semua cache Pinia sekaligus
|
||||
console.log(`[Migration] Store schema v${savedVersion} → v${STORE_SCHEMA_VERSION}. Clearing all caches...`);
|
||||
PINIA_STORE_KEYS.forEach(key => localStorage.removeItem(key));
|
||||
localStorage.setItem(VERSION_KEY, String(STORE_SCHEMA_VERSION));
|
||||
console.log('[Migration] Done. App will fetch fresh data from API.');
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore storage errors (e.g., private browsing mode)
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-app>
|
||||
<v-overlay
|
||||
:model-value="loading"
|
||||
class="align-center justify-center"
|
||||
persistent
|
||||
z-index="9999"
|
||||
>
|
||||
<v-progress-circular
|
||||
color="primary"
|
||||
indeterminate
|
||||
size="64"
|
||||
width="6"
|
||||
></v-progress-circular>
|
||||
</v-overlay>
|
||||
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { defineEventHandler, proxyRequest } from 'h3'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const config = useRuntimeConfig(event)
|
||||
const targetBase = config.public.verificationApiBaseUrl || 'http://10.10.123.140:8089/api/v1'
|
||||
|
||||
// Extract path suffix after /klinik-api
|
||||
@@ -16,12 +16,13 @@ export default defineEventHandler(async (event) => {
|
||||
const urlObj = new URL(targetBase)
|
||||
headers.host = urlObj.host
|
||||
} catch (e) {
|
||||
headers.host = '10.10.123.140:8089'
|
||||
headers.host = config.proxyTargetHostKlinikFallback || '10.10.123.140:8089'
|
||||
}
|
||||
|
||||
// Spoof the origin and referer to match the server's trusted/whitelisted client origin
|
||||
headers.origin = 'http://10.10.150.175:3000'
|
||||
headers.referer = 'http://10.10.150.175:3000/'
|
||||
const proxyOrigin = config.proxyClientOrigin as string
|
||||
headers.origin = proxyOrigin
|
||||
headers.referer = proxyOrigin.endsWith('/') ? proxyOrigin : `${proxyOrigin}/`
|
||||
|
||||
console.log(`[Proxy Klinik-API] Forwarding ${event.node.req.method} to: ${targetUrl}`)
|
||||
console.log(`[Proxy Klinik-API] Headers - Host: ${headers.host}, Origin: ${headers.origin}`)
|
||||
@@ -1,7 +1,7 @@
|
||||
import { defineEventHandler, proxyRequest } from 'h3'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const config = useRuntimeConfig(event)
|
||||
const targetBase = config.public.externalApiBaseUrl || 'http://10.10.123.135:8084/api/v1'
|
||||
|
||||
// Extract path suffix after /stats-api
|
||||
@@ -16,12 +16,13 @@ export default defineEventHandler(async (event) => {
|
||||
const urlObj = new URL(targetBase)
|
||||
headers.host = urlObj.host
|
||||
} catch (e) {
|
||||
headers.host = '10.10.123.135:8084'
|
||||
headers.host = config.proxyTargetHostFallback || '10.10.123.135:8084'
|
||||
}
|
||||
|
||||
// Spoof the origin and referer to match the server's trusted/whitelisted client origin
|
||||
headers.origin = 'http://10.10.150.175:3000'
|
||||
headers.referer = 'http://10.10.150.175:3000/'
|
||||
const proxyOrigin = config.proxyClientOrigin as string
|
||||
headers.origin = proxyOrigin
|
||||
headers.referer = proxyOrigin.endsWith('/') ? proxyOrigin : `${proxyOrigin}/`
|
||||
|
||||
console.log(`[Proxy Stats-API] Forwarding ${event.node.req.method} to: ${targetUrl}`)
|
||||
console.log(`[Proxy Stats-API] Headers - Host: ${headers.host}, Origin: ${headers.origin}`)
|
||||
@@ -1,7 +1,7 @@
|
||||
import { defineEventHandler, proxyRequest } from 'h3'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const config = useRuntimeConfig(event)
|
||||
const targetBase = config.public.externalApiBaseUrl || 'http://10.10.123.135:8084/api/v1'
|
||||
|
||||
// Extract path suffix after /visit-api
|
||||
@@ -16,12 +16,13 @@ export default defineEventHandler(async (event) => {
|
||||
const urlObj = new URL(targetBase)
|
||||
headers.host = urlObj.host
|
||||
} catch (e) {
|
||||
headers.host = '10.10.123.135:8084'
|
||||
headers.host = config.proxyTargetHostFallback || '10.10.123.135:8084'
|
||||
}
|
||||
|
||||
// Spoof the origin and referer to match the server's trusted/whitelisted client origin
|
||||
headers.origin = 'http://10.10.150.175:3000'
|
||||
headers.referer = 'http://10.10.150.175:3000/'
|
||||
const proxyOrigin = config.proxyClientOrigin as string
|
||||
headers.origin = proxyOrigin
|
||||
headers.referer = proxyOrigin.endsWith('/') ? proxyOrigin : `${proxyOrigin}/`
|
||||
|
||||
console.log(`[Proxy Visit-API] Forwarding ${event.node.req.method} to: ${targetUrl}`)
|
||||
console.log(`[Proxy Visit-API] Headers - Host: ${headers.host}, Origin: ${headers.origin}`)
|
||||
@@ -0,0 +1,156 @@
|
||||
<template>
|
||||
<v-dialog v-model="internalModel" max-width="500px">
|
||||
<v-card class="dialog-card overflow-hidden rounded-xl" elevation="0">
|
||||
<!-- Solid Blue Header -->
|
||||
<v-card-title class="bg-primary-600 text-white py-4 px-6 d-flex justify-space-between align-center">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon start icon="mdi-alert-circle-outline" class="mr-2"></v-icon>
|
||||
<span class="text-h6 font-weight-bold">Konfirmasi Selesai</span>
|
||||
</div>
|
||||
<v-btn icon="mdi-close" variant="text" size="small" @click="internalModel = false" class="text-white"></v-btn>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="dialog-content-premium pa-6 text-center">
|
||||
<div class="text-center mb-6 text-uppercase font-weight-bold text-caption text-neutral-600" style="letter-spacing: 1px;">
|
||||
Antrean Belum Dibuat
|
||||
</div>
|
||||
|
||||
<p class="text-body-1 mb-6">
|
||||
Tiket antrean ruang atau penunjang <strong>belum dibuat</strong> untuk pasien ini.
|
||||
</p>
|
||||
|
||||
<div class="comparison-container mb-6 mx-auto" style="max-width: 220px; justify-content: center;">
|
||||
<div class="comparison-item current">
|
||||
<div class="item-label text-center">PASIEN AKTIF</div>
|
||||
<div class="item-card">
|
||||
<div class="item-number text-danger-700">{{ patient?.noAntrian?.split(' |')[0] || '-' }}</div>
|
||||
<div class="item-status">Sedang Diproses</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-body-2 text-neutral-600">
|
||||
Apakah Anda yakin ingin menyelesaikan pelayanan untuk pasien ini?
|
||||
</p>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-actions class="pa-4 bg-light justify-center">
|
||||
<v-btn
|
||||
class="px-8 font-weight-bold rounded-lg text-none btn-batal"
|
||||
variant="outlined"
|
||||
@click="internalModel = false"
|
||||
size="large"
|
||||
>
|
||||
Batal
|
||||
</v-btn>
|
||||
<v-btn
|
||||
class="px-8 font-weight-bold ml-4 rounded-lg text-none btn-confirm"
|
||||
variant="flat"
|
||||
@click="confirm"
|
||||
size="large"
|
||||
elevation="2"
|
||||
>
|
||||
Ya, Selesaikan
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
patient: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'confirm']);
|
||||
|
||||
const internalModel = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val)
|
||||
});
|
||||
|
||||
const confirm = () => {
|
||||
emit('confirm');
|
||||
internalModel.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dialog-card {
|
||||
/* Remove border to prevent white outline around blue header */
|
||||
}
|
||||
.bg-light {
|
||||
background-color: var(--color-neutral-50) !important;
|
||||
}
|
||||
|
||||
.comparison-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: white;
|
||||
padding: 16px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--color-neutral-300);
|
||||
}
|
||||
|
||||
.comparison-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.item-label {
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
color: var(--color-neutral-600);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.item-card {
|
||||
background: white;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--color-neutral-400);
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.item-number {
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.item-status {
|
||||
font-size: 11px;
|
||||
color: var(--color-neutral-600);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.text-danger-700 {
|
||||
color: var(--color-danger-700) !important;
|
||||
}
|
||||
|
||||
.btn-batal {
|
||||
border-color: var(--color-neutral-300) !important;
|
||||
color: var(--color-neutral-700) !important;
|
||||
}
|
||||
|
||||
.btn-confirm {
|
||||
background-color: var(--color-primary-600) !important;
|
||||
color: white !important;
|
||||
}
|
||||
</style>
|
||||
@@ -34,15 +34,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right d-flex align-center" style="gap: 8px;">
|
||||
<v-chip
|
||||
v-if="patient.ruang"
|
||||
color="primary-600"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
class="subspesialis-chip text-caption font-weight-bold"
|
||||
>
|
||||
{{ patient.ruang }}
|
||||
</v-chip>
|
||||
<v-chip
|
||||
:color="getStatusColor(patient.status)"
|
||||
size="small"
|
||||
@@ -63,6 +54,17 @@
|
||||
>
|
||||
{{ patient.klinik }}
|
||||
</v-chip>
|
||||
<!-- Sub Spesialis -->
|
||||
<v-chip
|
||||
v-if="patient.ruang"
|
||||
color="primary-600"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
class="subspesialis-chip font-weight-bold"
|
||||
style="height: 24px; font-size: 11px;"
|
||||
>
|
||||
{{ patient.ruang }}
|
||||
</v-chip>
|
||||
<!-- Tipe Layanan atau Jenis Pasien -->
|
||||
<v-chip
|
||||
v-if="displayJenisPasien"
|
||||
|
||||
@@ -487,7 +487,11 @@ const handleAction = (patient, action) => {
|
||||
padding: 8px 12px;
|
||||
background: var(--color-neutral-200);
|
||||
border-radius: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid var(--color-neutral-400);
|
||||
border-left: 3px solid var(--color-primary-600);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.results-text {
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
<template>
|
||||
<v-dialog v-model="internalModel" max-width="500px">
|
||||
<v-card class="dialog-card overflow-hidden rounded-xl" elevation="0">
|
||||
<!-- Solid Blue Header -->
|
||||
<v-card-title class="bg-primary-600 text-white py-4 px-6 d-flex justify-space-between align-center">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon start icon="mdi-alert-circle-outline" class="mr-2"></v-icon>
|
||||
<span class="text-h6 font-weight-bold">Konfirmasi Selesai</span>
|
||||
</div>
|
||||
<v-btn icon="mdi-close" variant="text" size="small" @click="internalModel = false" class="text-white"></v-btn>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="dialog-content-premium pa-6 text-center">
|
||||
<div class="text-center mb-6 text-uppercase font-weight-bold text-caption text-neutral-600" style="letter-spacing: 1px;">
|
||||
Tindakan Diperlukan
|
||||
</div>
|
||||
|
||||
<p class="text-body-1 mb-6">
|
||||
Pasien ini <strong>sudah dibuatkan</strong> tiket antrean ruang atau penunjang, namun statusnya belum diselesaikan di Loket.
|
||||
</p>
|
||||
|
||||
<div class="comparison-container mb-6 mx-auto" style="max-width: 220px; justify-content: center;">
|
||||
<div class="comparison-item current">
|
||||
<div class="item-label text-center">PASIEN AKTIF</div>
|
||||
<div class="item-card">
|
||||
<div class="item-number text-danger-700">{{ patient?.noAntrian?.split(' |')[0] || '-' }}</div>
|
||||
<div class="item-status">Menunggu Selesai</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-body-2 text-neutral-600">
|
||||
Apakah Anda ingin menyelesaikan pasien ini sekarang dan melanjutkan ke antrean berikutnya?
|
||||
</p>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-actions class="pa-4 bg-light justify-center">
|
||||
<v-btn
|
||||
class="px-8 font-weight-bold rounded-lg text-none btn-batal"
|
||||
variant="outlined"
|
||||
@click="internalModel = false"
|
||||
size="large"
|
||||
>
|
||||
Batal
|
||||
</v-btn>
|
||||
<v-btn
|
||||
class="px-6 font-weight-bold ml-4 rounded-lg text-none btn-confirm"
|
||||
variant="flat"
|
||||
@click="confirm"
|
||||
size="large"
|
||||
elevation="2"
|
||||
>
|
||||
Ya, Selesaikan
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
patient: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'confirm']);
|
||||
|
||||
const internalModel = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val)
|
||||
});
|
||||
|
||||
const confirm = () => {
|
||||
emit('confirm');
|
||||
internalModel.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dialog-card {
|
||||
/* No border to prevent white outline */
|
||||
}
|
||||
.bg-light {
|
||||
background-color: var(--color-neutral-50) !important;
|
||||
}
|
||||
|
||||
|
||||
.comparison-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: white;
|
||||
padding: 16px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--color-neutral-300);
|
||||
}
|
||||
|
||||
.comparison-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.item-label {
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
color: var(--color-neutral-600);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.item-card {
|
||||
background: white;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--color-neutral-400);
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.item-number {
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.item-status {
|
||||
font-size: 11px;
|
||||
color: var(--color-neutral-600);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.text-danger-700 {
|
||||
color: var(--color-danger-700) !important;
|
||||
}
|
||||
|
||||
.btn-batal {
|
||||
border-color: var(--color-neutral-300) !important;
|
||||
color: var(--color-neutral-700) !important;
|
||||
}
|
||||
|
||||
.btn-confirm {
|
||||
background-color: var(--color-primary-600) !important;
|
||||
color: white !important;
|
||||
}
|
||||
</style>
|
||||
@@ -186,14 +186,15 @@ export const useQueue = (adminType = "loket", specificId = null) => {
|
||||
showSnackbar(result.message, result.success ? "success" : "warning");
|
||||
};
|
||||
|
||||
const processPatient = (patient, action) => {
|
||||
const result = queueStore.processPatient(patient, action, adminType, idValue.value);
|
||||
const processPatient = async (patient, action) => {
|
||||
const result = await queueStore.processPatient(patient, action, adminType, idValue.value);
|
||||
|
||||
let color = "success";
|
||||
if (action === "terlambat") color = "warning";
|
||||
else if (action === "pending") color = "info";
|
||||
|
||||
showSnackbar(result.message, color);
|
||||
return result;
|
||||
};
|
||||
|
||||
// Helper function untuk mendapatkan pasien yang sedang diproses dari store
|
||||
@@ -293,9 +294,10 @@ export const useQueue = (adminType = "loket", specificId = null) => {
|
||||
};
|
||||
|
||||
|
||||
const processNextQueue = () => {
|
||||
const result = queueStore.processNextQueue(adminType, idValue.value);
|
||||
const processNextQueue = async () => {
|
||||
const result = await queueStore.processNextQueue(adminType, idValue.value);
|
||||
showSnackbar(result.message, result.success ? "success" : "warning");
|
||||
return result;
|
||||
};
|
||||
|
||||
const getRowClass = (item) => {
|
||||
|
||||
@@ -126,18 +126,25 @@ export const useThermalPrint = () => {
|
||||
// Format nomor antrian (hilangkan bagian "| Onsite - barcode")
|
||||
const noAntrianDisplay = data.noAntrian.split(' |')[0];
|
||||
|
||||
// Format informasi ruang: "Poli Anak - Ruang A"
|
||||
// Format informasi ruang: "Poli Anak - Ruang A" atau "Poli Anak - Gastro-Hepatologi"
|
||||
let ruangInfo = '';
|
||||
if (data.klinik && data.nomorRuang) {
|
||||
// Konversi nomor ruang ke abjad (1 = A, 2 = B, 3 = C, dst)
|
||||
const ruangNumber = parseInt(data.nomorRuang) || 1;
|
||||
const ruangLetter = String.fromCharCode(64 + ruangNumber); // 64 = '@', 65 = 'A', 66 = 'B', dst
|
||||
ruangInfo = `${data.klinik} - Ruang ${ruangLetter}`;
|
||||
} else if (data.klinik && data.ruang) {
|
||||
// Fallback jika hanya ada nama ruang
|
||||
|
||||
// 1. Jika ada data.ruang dan bukan sekadar kata "Ruang X" (berarti ini subspesialis)
|
||||
if (data.ruang && !data.ruang.toLowerCase().startsWith('ruang')) {
|
||||
ruangInfo = `${data.klinik} - ${data.ruang}`;
|
||||
} else if (data.klinik) {
|
||||
// Hanya klinik jika tidak ada info ruang
|
||||
}
|
||||
// 2. Fallback: gunakan nomorRuang untuk dikonversi ke abjad (Ruang A, Ruang B, dll)
|
||||
else if (data.klinik && data.nomorRuang) {
|
||||
const ruangNumber = parseInt(data.nomorRuang) || 1;
|
||||
const ruangLetter = String.fromCharCode(64 + ruangNumber); // 64 = '@', 65 = 'A'
|
||||
ruangInfo = `${data.klinik} - Ruang ${ruangLetter}`;
|
||||
}
|
||||
// 3. Fallback: gunakan data.ruang yang ada
|
||||
else if (data.klinik && data.ruang) {
|
||||
ruangInfo = `${data.klinik} - ${data.ruang}`;
|
||||
}
|
||||
// 4. Default: hanya nama klinik
|
||||
else if (data.klinik) {
|
||||
ruangInfo = data.klinik;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,8 +18,8 @@ export interface VisitStats {
|
||||
|
||||
export const useVisitAPI = () => {
|
||||
const config = useRuntimeConfig();
|
||||
// We use the configured proxy path to avoid CORS issues
|
||||
const statsURL = '/stats-api/visit/stats';
|
||||
// We use the configured external API URL directly
|
||||
const statsURL = `${config.public.externalApiBaseUrl}/visit/stats`;
|
||||
|
||||
/**
|
||||
* Fetch visit statistics
|
||||
|
||||
@@ -26,6 +26,9 @@ services:
|
||||
- NUXT_PUBLIC_WS_API_URL=${WS_API_URL}
|
||||
- NUXT_SESSION_DURATION_HOURS=${SESSION_DURATION_HOURS}
|
||||
- NUXT_OAUTH_STATE_DURATION_MINUTES=${OAUTH_STATE_DURATION_MINUTES}
|
||||
- NUXT_PROXY_CLIENT_ORIGIN=${PROXY_CLIENT_ORIGIN}
|
||||
- NUXT_PROXY_TARGET_HOST_FALLBACK=${PROXY_TARGET_HOST_FALLBACK}
|
||||
- NUXT_PROXY_TARGET_HOST_KLINIK_FALLBACK=${PROXY_TARGET_HOST_KLINIK_FALLBACK}
|
||||
volumes:
|
||||
# Mount local data directory to persist the sqlite users database
|
||||
- ./data:/app/data
|
||||
|
||||
+630
@@ -0,0 +1,630 @@
|
||||
# 📓 DEVLOG — Development Log
|
||||
|
||||
**Project:** Web Antrean — Sistem Manajemen Antrean Rawat Jalan RSSA
|
||||
**Author:** Akbar
|
||||
**Stack:** JavaScript · Vue 3 · TypeScript · Nuxt 3 · Vuetify 3 · Pinia
|
||||
|
||||
---
|
||||
|
||||
## 🧠 AI Prompt — Cara Menggunakan Dokumen Ini
|
||||
|
||||
> Salin prompt berikut ke Claude untuk membantu mengisi DEVLOG harian:
|
||||
|
||||
```
|
||||
Kamu adalah technical writer. Bantu aku menulis DEVLOG untuk hari ini.
|
||||
|
||||
Context:
|
||||
- Project: Web Antrean — Sistem Manajemen Antrean Rawat Jalan RSSA
|
||||
- Stack: Nuxt 3, Vue 3, TypeScript, Vuetify 3, Pinia, WebSocket, Keycloak
|
||||
- Yang dikerjakan hari ini: [ceritakan bebas]
|
||||
- Masalah yang ditemui: [ceritakan]
|
||||
- Solusi yang diterapkan: [ceritakan]
|
||||
|
||||
Format output:
|
||||
## [Tanggal] — [Judul Singkat]
|
||||
**Yang dikerjakan:** ...
|
||||
**Keputusan teknis:** ...
|
||||
**Masalah & solusi:** ...
|
||||
**Besok:** ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Format Entry
|
||||
|
||||
```markdown
|
||||
## [YYYY-MM-DD] — [Judul Singkat Pekerjaan]
|
||||
|
||||
**Sprint/Phase:** [nama sprint atau fase]
|
||||
**Durasi:** [X jam]
|
||||
**Status:** ✅ Done | 🔄 In Progress | ⏸ Blocked
|
||||
|
||||
### Yang Dikerjakan
|
||||
- ...
|
||||
|
||||
### Keputusan Teknis
|
||||
> Jelaskan keputusan arsitektur/implementasi penting dan alasannya
|
||||
|
||||
### Masalah & Solusi
|
||||
| Masalah | Solusi | Referensi |
|
||||
|---------|--------|-----------|
|
||||
| | | |
|
||||
|
||||
### Besok
|
||||
- [ ] ...
|
||||
|
||||
### Referensi
|
||||
- [link]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Log Entries
|
||||
|
||||
<!-- Tambahkan entry baru di bawah baris ini, urutan terbaru di atas -->
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-19 — Fitur Detail Akun Verifikasi
|
||||
|
||||
**Sprint/Phase:** Phase 6 — Verifikasi Akun
|
||||
**Durasi:** ~4 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Penambahan halaman `DetailAkun.vue` (`400bfcd`)
|
||||
- Update `VerifikasiAkun.vue` untuk integrasi dengan detail akun
|
||||
- Modifikasi konfigurasi Vuetify di `plugins/vuetify.ts`
|
||||
|
||||
### Keputusan Teknis
|
||||
> Pembuatan halaman detail akun terpisah untuk memberikan view lengkap informasi akun yang sedang diverifikasi, memperbaiki user experience admin saat memvalidasi data user.
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-08–09 — Voice Over Antrean & Manajemen Loket/Klinik
|
||||
|
||||
**Sprint/Phase:** Phase 5 — Stabilisasi & Kiosk Features
|
||||
**Durasi:** ~12 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Implementasi sistem notifikasi suara (voice over) untuk antrean loket dan klinik (`8b9c472`)
|
||||
- Update UI `AdminLoket/[id].vue` dan `Anjungan/AntrianLoket/[id].vue` untuk support voice announcement
|
||||
- Modifikasi `queueStore.js` untuk integrasi status antrean
|
||||
- Resolusi isu WebSocket dan CORS policy pada endpoint api antrean
|
||||
|
||||
### Keputusan Teknis
|
||||
> Menggunakan voice synthesis/announcement otomatis untuk pendaftaran antrean agar pasien dengan keterbatasan visual dapat dipanggil secara otomatis dan jelas di area loket maupun klinik.
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-02–05 — Proxy Routes & Fitur Pindah Klinik
|
||||
|
||||
**Sprint/Phase:** Phase 5 — Integrasi API & Queue System
|
||||
**Durasi:** ~16 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Implementasi proxy routes untuk `klinik-api`, `stats-api`, `visit-api` menghindari CORS issues (`0928e78`)
|
||||
- Fitur pindah klinik pada dashboard admin (`f81dd57`)
|
||||
- Penambahan dialog konfirmasi (CheckIn, Unfinished Patient) di queue features
|
||||
- Store lokalisasi menggunakan Pinia untuk loket, klinik, dan queue management (`047b390`)
|
||||
- Pembaruan DEVLOG, DEVPLAN, PRD, QMD, dan EVLOG
|
||||
|
||||
### Keputusan Teknis
|
||||
> Menggunakan proxy routes di sisi server (Nitro) untuk mem-bypass CORS ketika memanggil backend API. Hal ini memperkuat keamanan dan menstabilkan fetch data dari aplikasi Vue frontend ke service backend.
|
||||
|
||||
## 2026-05-25 — Perbaikan & Dokumentasi Project
|
||||
|
||||
**Sprint/Phase:** Phase 5 — Stabilisasi & Dokumentasi
|
||||
**Durasi:** ~4 jam
|
||||
**Status:** 🔄 In Progress
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Push perbaikan berbagai bug (`ffdb88d`)
|
||||
- Pembuatan dokumentasi project: PRD, QMD, DEVLOG
|
||||
- Stabilisasi WebSocket sync dengan deterministic client ID
|
||||
- Penambahan polling fallback 30 detik di semua admin interface
|
||||
|
||||
### Keputusan Teknis
|
||||
> Membuat dokumentasi lengkap (PRD, QMD, DEVLOG) untuk meningkatkan maintainability project ke depan. Semua dokumen diisi berdasarkan kondisi aktual project, bukan template kosong.
|
||||
|
||||
### Masalah & Solusi
|
||||
| Masalah | Solusi | Referensi |
|
||||
|---------|--------|-----------|
|
||||
| Dokumentasi project belum ada | Buat PRD, QMD, DEVLOG di folder `docs/` | — |
|
||||
|
||||
### Besok
|
||||
- [ ] Review dan finalisasi dokumen
|
||||
- [ ] Lanjutkan stabilisasi fitur penunjang
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-22 — Stabilisasi WebSocket & Tooltips Dashboard
|
||||
|
||||
**Sprint/Phase:** Phase 5 — Stabilisasi
|
||||
**Durasi:** ~8 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Standardisasi header konfigurasi (origin & referer) di `stats-api`, `visit-api`, `klinik-api` ke `http://10.10.150.175:3000`
|
||||
- Penambahan tooltips di komponen Dashboard
|
||||
- Standardisasi WebSocket client ID menjadi deterministic untuk reliable cross-device messaging
|
||||
- Implementasi polling fallback 30 detik di semua admin interface
|
||||
- Audit semua halaman yang bergantung WebSocket
|
||||
|
||||
### Keputusan Teknis
|
||||
> WebSocket client ID diubah dari random ke deterministic agar server bisa menargetkan pesan ke device tertentu. Polling 30 detik ditambahkan sebagai safety net jika WebSocket disconnect — ini menghindari data drift antar display.
|
||||
|
||||
### Masalah & Solusi
|
||||
| Masalah | Solusi | Referensi |
|
||||
|---------|--------|-----------|
|
||||
| Header origin/referer tidak konsisten antar proxy route | Standardisasi ke `http://10.10.150.175:3000` di semua route handler | `server/routes/` |
|
||||
| Data antrean tidak sinkron antar display saat WS drop | Polling fallback 30 detik + deterministic WS client ID | `useWebSocket.ts` |
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-21 — Perbaikan Layar Info & Isolasi Loket
|
||||
|
||||
**Sprint/Phase:** Phase 5 — Stabilisasi
|
||||
**Durasi:** ~6 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Fix: info klinik ruang tidak muncul di layar informasi (`ec9dac0`)
|
||||
- Isolasi state `currentProcessingPatient` menggunakan unique storage key per loket
|
||||
- Perbaikan `processNextQueue` dan `callNext` — strict filter berdasarkan `loketId`
|
||||
- Validasi WebSocket event handler agar tidak ada update lintas loket yang tidak sah
|
||||
- Memastikan `allPatients` tetap single source of truth
|
||||
|
||||
### Keputusan Teknis
|
||||
> Masalah kritis: loket A memproses pasien loket B karena `currentProcessingPatient` menggunakan key yang sama. Solusi: setiap loket mendapat unique persisted state key (`currentPatient_loket_{id}`).
|
||||
|
||||
### Masalah & Solusi
|
||||
| Masalah | Solusi | Referensi |
|
||||
|---------|--------|-----------|
|
||||
| Cross-loket interference pada `currentProcessingPatient` | Unique storage key per loket di persisted state | `queueStore.js` |
|
||||
| `processNextQueue` memproses pasien dari loket lain | Strict filter berdasarkan `loketId` dan service mapping | `useQueue.js` |
|
||||
| WebSocket event mengupdate loket yang salah | Guard check di event handler | `useWebSocket.ts` |
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-20 — Subspesialis & Perbaikan Bug Loket
|
||||
|
||||
**Sprint/Phase:** Phase 4 — Fitur Eksekutif
|
||||
**Durasi:** ~7 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Penambahan fitur subspesialis di PatientCard (`1446e87`)
|
||||
- Display subspesialis sebagai pill/chip di samping status badge
|
||||
- Perbaikan bug tampilan loket (`f622052`)
|
||||
- UI enhancement: PatientCard menampilkan info ruang/subspesialis secara kondisional
|
||||
|
||||
### Keputusan Teknis
|
||||
> Subspesialis ditampilkan sebagai pill/chip di PatientCard agar admin klinik bisa langsung melihat spesialisasi pasien tanpa perlu buka detail. Conditional rendering — tampilkan ruang ATAU subspesialis, tergantung data yang tersedia.
|
||||
|
||||
### Masalah & Solusi
|
||||
| Masalah | Solusi | Referensi |
|
||||
|---------|--------|-----------|
|
||||
| Info subspesialis tidak tampil di card pasien | Tambah conditional pill/chip di `PatientCard.vue` | `components/features/queue/PatientCard.vue` |
|
||||
| Bug tampilan loket | Fix layout dan data binding | `pages/AdminLoket.vue` |
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-19 — Docker & Anjungan Eksekutif
|
||||
|
||||
**Sprint/Phase:** Phase 4 — Fitur Eksekutif & Deployment
|
||||
**Durasi:** ~8 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Penambahan Dockerfile dan docker-compose untuk deployment (`bc74b98`)
|
||||
- Refinement UI anjungan eksekutif — pilihan subspesialis
|
||||
- Peningkatan kontras dan visibilitas button seleksi (border tebal, shadow, hover state)
|
||||
- Implementasi handler function untuk selection state yang robust
|
||||
|
||||
### Keputusan Teknis
|
||||
> Docker ditambahkan untuk standardisasi deployment. Anjungan eksekutif mendapat UI khusus dengan radio-style button yang lebih intuitif — target user adalah pasien dengan tech level rendah, jadi kontras dan ukuran harus maksimal.
|
||||
|
||||
### Masalah & Solusi
|
||||
| Masalah | Solusi | Referensi |
|
||||
|---------|--------|-----------|
|
||||
| Deployment manual dan tidak konsisten | Dockerfile + docker-compose | `Dockerfile`, `docker-compose.yml` |
|
||||
| Button seleksi subspesialis kurang kontras | Thicker border, deeper shadow, distinct hover/selected state | Anjungan pages |
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-18 — Optimasi Performa & Memory
|
||||
|
||||
**Sprint/Phase:** Phase 5 — Stabilisasi
|
||||
**Durasi:** ~6 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Perbaikan memory usage dan load performance (`cb3310b`)
|
||||
- Verifikasi status kunjungan operasi
|
||||
- Pengecekan semua API endpoint yang sudah terintegrasi
|
||||
- Pembersihan `console.log` verbose di high-frequency path
|
||||
|
||||
### Keputusan Teknis
|
||||
> Mengurangi memory footprint dengan: (1) membersihkan verbose logging di WebSocket handler dan QR scanner, (2) implementasi blacklist untuk endpoint yang terus-menerus gagal agar tidak spam request.
|
||||
|
||||
### Masalah & Solusi
|
||||
| Masalah | Solusi | Referensi |
|
||||
|---------|--------|-----------|
|
||||
| `doctorStore.js` terus retry endpoint yang 500 | Implementasi blacklist endpoint gagal | `stores/doctorStore.js` |
|
||||
| `console.log` verbose di `useWebSocket.ts` | Cleanup logging di high-frequency path | `composables/useWebSocket.ts` |
|
||||
| Memory usage terus naik | Reduce excessive API retry + cleanup logging | — |
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-20 — Update Hak Akses & Perbaikan Tiket
|
||||
|
||||
**Sprint/Phase:** Phase 3 — Hak Akses & Permission
|
||||
**Durasi:** ~6 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Update sistem hak akses berbasis Keycloak role & group (`2bf00ef`)
|
||||
- Perbaikan API pembuatan tiket antrean
|
||||
- Standardisasi user role slugs dari Keycloak groups
|
||||
- Implementasi slugification utility untuk role normalization
|
||||
|
||||
### Keputusan Teknis
|
||||
> Role dari Keycloak group path (`/Instalasi STIM/Devops/Superadmin`) di-slugify menjadi URL-friendly (`instalasi-stim-devops-superadmin`) untuk konsistensi di permission API dan internal user data.
|
||||
|
||||
### Masalah & Solusi
|
||||
| Masalah | Solusi | Referensi |
|
||||
|---------|--------|-----------|
|
||||
| Role dari Keycloak tidak URL-friendly | Implementasi slugification utility | `composables/useHakAkses.ts` |
|
||||
| Tiket antrean gagal digenerate | Perbaikan API endpoint pembuatan tiket | `server/api/` |
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-15 — Update Favicon
|
||||
|
||||
**Sprint/Phase:** Phase 3 — Polish
|
||||
**Durasi:** ~1 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Ganti favicon aplikasi web-antrean
|
||||
- Update konfigurasi `app.head` di `nuxt.config.ts` untuk mengarah ke file icon baru
|
||||
|
||||
### Keputusan Teknis
|
||||
> Favicon diletakkan di folder `public/` dan direferensi via `nuxt.config.ts` → `app.head.link`.
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-13 — Integrasi Keycloak Role Access
|
||||
|
||||
**Sprint/Phase:** Phase 3 — Hak Akses & Permission
|
||||
**Durasi:** ~8 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Verifikasi dan standardisasi akses Keycloak role
|
||||
- Implementasi middleware `auth.ts`, `guest.ts`, `permissions.ts`, `checkPageAccess.ts`
|
||||
- Setup permission store (`permissionStore.ts`)
|
||||
- Integrasi permission API endpoint (`server/api/permission.get.ts`)
|
||||
|
||||
### Keputusan Teknis
|
||||
> Menggunakan 4 layer middleware: (1) `auth.ts` — cek login, (2) `guest.ts` — redirect jika sudah login, (3) `permissions.ts` — cek hak akses halaman, (4) `checkPageAccess.ts` — validasi granular per page. Model ini memastikan defense-in-depth untuk access control.
|
||||
|
||||
### Masalah & Solusi
|
||||
| Masalah | Solusi | Referensi |
|
||||
|---------|--------|-----------|
|
||||
| Role dari Keycloak format path, bukan slug | Konversi ke slug untuk matching di permission API | `middleware/permissions.ts` |
|
||||
|
||||
---
|
||||
|
||||
## 2026-02-24 — Perbaikan Ambil Tiket
|
||||
|
||||
**Sprint/Phase:** Phase 2 — Fitur Inti
|
||||
**Durasi:** ~4 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Fix bug pengambilan tiket antrean di anjungan (`94ff9f5`)
|
||||
- Perbaikan flow generate nomor antrean
|
||||
|
||||
### Masalah & Solusi
|
||||
| Masalah | Solusi | Referensi |
|
||||
|---------|--------|-----------|
|
||||
| Tiket gagal digenerate pada kondisi tertentu | Fix logic generate nomor antrean | `composables/useQRGenerator.ts` |
|
||||
|
||||
---
|
||||
|
||||
## 2026-02-19 — Implementasi Anjungan (Kiosk) & Queue Store
|
||||
|
||||
**Sprint/Phase:** Phase 2 — Fitur Inti
|
||||
**Durasi:** ~10 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Implementasi modul Anjungan (Kiosk) lengkap (`0c4019c`)
|
||||
- Halaman pilih klinik, klinik ruang, penunjang
|
||||
- Halaman antrian per klinik, klinik ruang, penunjang
|
||||
- Check-in pasien via QR
|
||||
- Admin anjungan
|
||||
- Implementasi Pinia store baru untuk queue management (`4342cdc`)
|
||||
- Integrasi dengan Visit API dan Antrian API
|
||||
- WebSocket integration untuk real-time update
|
||||
- Halaman admin dan kiosk display
|
||||
|
||||
### Keputusan Teknis
|
||||
> `queueStore.js` dibuat sebagai central store untuk semua operasi antrean — ini menjadi single source of truth untuk `allPatients`. Keputusan ini mempermudah sinkronisasi real-time tapi membuat file menjadi sangat besar (saat ini 141KB). Perlu dipecah di masa depan.
|
||||
|
||||
---
|
||||
|
||||
## 2026-02-18 — Core Anjungan Display & Verification
|
||||
|
||||
**Sprint/Phase:** Phase 2 — Fitur Inti
|
||||
**Durasi:** ~8 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Implementasi core display system anjungan (`7d07a4f`)
|
||||
- Display antrean klinik ruang
|
||||
- Display antrean masuk
|
||||
- Display antrean loket
|
||||
- Tambah verification store dan konfigurasi endpoint baru (`f78bbea`)
|
||||
- `verificationApiBaseUrl` → `http://10.10.123.140:8089/api/v1`
|
||||
- `ekstrakExpertiseUrl` endpoint
|
||||
- Fix wsBaseUrl fallback di Nuxt config (`d03fa63`)
|
||||
|
||||
### Keputusan Teknis
|
||||
> Dual backend API architecture: Visit API (port 8084) untuk data kunjungan utama, Antrian API (port 8089) untuk verifikasi dan data klinik/dokter. Proxy layer Nitro digunakan untuk bypass CORS dan menyembunyikan IP backend dari client.
|
||||
|
||||
---
|
||||
|
||||
## 2026-02-13 — Admin Loket, Klinik Ruang & Queue Management
|
||||
|
||||
**Sprint/Phase:** Phase 2 — Fitur Inti
|
||||
**Durasi:** ~10 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Implementasi halaman Admin Loket detail (`8c6b3fd`)
|
||||
- Manajemen antrian pasien per loket
|
||||
- Aksi pasien: panggil, skip, recall, selesai
|
||||
- Dialog seleksi (pilih loket, pilih layanan)
|
||||
- Implementasi `AdminKlinikRuang` page (`67a5514`)
|
||||
- Manajemen antrian per klinik ruang
|
||||
- Processing, calling, filtering, global search
|
||||
- Implementasi komprehensif patient queue management (`dbc9054`)
|
||||
- Pinia store untuk queue
|
||||
- API integration
|
||||
- Admin counter page
|
||||
- Tambah komponen `PatientCard`, `CurrentPatientCard` (`95ff830`)
|
||||
- Fitur queue management untuk klinik ruang dan counter (`4df13cb`)
|
||||
|
||||
### Keputusan Teknis
|
||||
> Memisahkan queue components menjadi 4 komponen: `PatientCard` (card per pasien), `CurrentPatientCard` (pasien sedang dilayani), `QueueActionsCard` (tombol aksi), `TabelPatientData` (tabel lengkap). Ini memastikan reusability di halaman admin loket, klinik, dan penunjang.
|
||||
|
||||
---
|
||||
|
||||
## 2026-02-12 — Anjungan Pages, WebSocket & Queue API
|
||||
|
||||
**Sprint/Phase:** Phase 2 — Fitur Inti
|
||||
**Durasi:** ~10 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Introduce halaman Anjungan untuk antrian klinik, klinik ruang, dan counter (`a5ab338`)
|
||||
- Tambah halaman check-in pasien
|
||||
- Implementasi core queue management system dengan WebSocket (`0e3ee37`)
|
||||
- Pinia store untuk queue
|
||||
- WebSocket integration
|
||||
- Halaman check-in dan kiosk display
|
||||
- Update post API klinik dan loket di queueStore (`4985aef`)
|
||||
- Fix status pasien selesai di klinik ruang (`c08941a`)
|
||||
- Various merge dan bugfix
|
||||
|
||||
### Keputusan Teknis
|
||||
> WebSocket dipilih sebagai primary communication channel untuk real-time sync karena latensi < 2 detik krusial di rumah sakit — pasien harus langsung melihat nomor panggil berubah di layar display.
|
||||
|
||||
---
|
||||
|
||||
## 2026-02-10–11 — WebSocket Bug Fixing
|
||||
|
||||
**Sprint/Phase:** Phase 2 — Stabilisasi WebSocket
|
||||
**Durasi:** ~12 jam (2 hari)
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Fix bug WebSocket di check-in loket (`b6dc252`)
|
||||
- Fix socket check-in flow (`e686dda`)
|
||||
- Update socket data dan token handling (`fb70237`)
|
||||
- Fix duplikasi data dan ticket menghilang di admin klinik loket (`c02905e`)
|
||||
- Update WS untuk admin loket dan check-in (`a7a654b`)
|
||||
- Fix missing function error (`23164bc`)
|
||||
- Perbaikan sorting pasien di next process (`6af3f58`)
|
||||
|
||||
### Masalah & Solusi
|
||||
| Masalah | Solusi | Referensi |
|
||||
|---------|--------|-----------|
|
||||
| Duplikasi tiket di admin klinik | Fix logic deduplication di WebSocket handler | `useWebSocket.ts` |
|
||||
| Tiket menghilang setelah check-in | Fix state update flow — pastikan re-render setelah WS event | `queueStore.js` |
|
||||
| Socket check-in tidak trigger update | Fix event listener binding | `composables/useCheckIn.ts` |
|
||||
|
||||
---
|
||||
|
||||
## 2026-02-09 — Integrasi WebSocket
|
||||
|
||||
**Sprint/Phase:** Phase 2 — Real-time
|
||||
**Durasi:** ~6 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Setup dan implementasi WebSocket client (`101dc38`)
|
||||
- Koneksi ke `ws://10.10.123.135:8084/api/v1/ws`
|
||||
- Implementasi `useWebSocket.ts` composable
|
||||
|
||||
### Keputusan Teknis
|
||||
> Menggunakan native browser WebSocket API dibungkus composable (`useWebSocket.ts`) daripada library seperti `socket.io` — mengurangi dependency dan lebih ringan untuk environment LAN internal.
|
||||
|
||||
---
|
||||
|
||||
## 2026-02-02–06 — API Integration & Display Screen
|
||||
|
||||
**Sprint/Phase:** Phase 2 — Integrasi API
|
||||
**Durasi:** ~20 jam (5 hari)
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Update tampilan layar screen display (`cf54ded`)
|
||||
- Penambahan API klinik ruang (`a29838e`, `b327997`, `c2d9023`)
|
||||
- Update logika pemanggilan admin loket by payment (`efeb42e`)
|
||||
- Fix HTTPS implementation dan check-in (`c899a71`)
|
||||
- Update session dan WebSocket (`0428017`)
|
||||
- Update status klinik ruang (`6696881`)
|
||||
- Update tampilan anjungan (`329ac9c`)
|
||||
- Fixing admin loket dan blokade loket yang tidak tersedia (`5d139c8`)
|
||||
- Update verifikasi dan lainnya (`2838016`)
|
||||
- Update post API status dan data pasien ruang (`e2a5d43`)
|
||||
|
||||
### Keputusan Teknis
|
||||
> Logika pemanggilan admin loket diubah menjadi payment-based — pasien yang sudah bayar diprioritaskan. Loket yang tidak tersedia di-blokade otomatis agar petugas tidak melihat loket yang bukan tanggung jawabnya.
|
||||
|
||||
---
|
||||
|
||||
## 2026-01-28–30 — Master Data & API Queue Store
|
||||
|
||||
**Sprint/Phase:** Phase 1 — Fondasi
|
||||
**Durasi:** ~12 jam (3 hari)
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Update master data berdasarkan jenis layanan (`9f0f6a7`)
|
||||
- Update API antrean masuk dan minor changes (`507f415`)
|
||||
- Fix session dan tampilan screen (`8dd94ed`)
|
||||
- Fix loop dan duplikasi data (`0bd5311`)
|
||||
- Update fetch API (`cccefb0`)
|
||||
- Update layout semua halaman (`ae8b06d`)
|
||||
- Update queueStore dari API (`19633af`)
|
||||
- Minor update anjungan (`59e42f3`)
|
||||
|
||||
---
|
||||
|
||||
## 2026-01-22–27 — Admin Loket & API Integrasi
|
||||
|
||||
**Sprint/Phase:** Phase 1 — Fondasi
|
||||
**Durasi:** ~16 jam (4 hari)
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Update logika penarikan API dan tampilan anjungan (`e900c5a`)
|
||||
- Update No RM dan desain (`2ccb378`)
|
||||
- Update API master loket dan antrian loket (`75a9638`)
|
||||
- Update anjungan (`8de89cb`)
|
||||
- Update master klinik ruang (`6c08352`)
|
||||
- Update sidebar check-in dan antrian loket (`525322b`)
|
||||
- Update API loket admin (`083fe3e`)
|
||||
- Push update admin loket (`f606045`)
|
||||
|
||||
---
|
||||
|
||||
## 2026-01-19–21 — Layout, Sidebar, Dashboard & Monitoring
|
||||
|
||||
**Sprint/Phase:** Phase 1 — Fondasi
|
||||
**Durasi:** ~20 jam (3 hari)
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Update flow klinik ruang dan design consistency (`cbd8f44`)
|
||||
- Update sidebar, profile, dan loket (`f2efd83`)
|
||||
- Update loket dan monitoring (`81b877b`, `c00c18e`)
|
||||
- Update page header komponen (`00bb954`)
|
||||
- Sidebar change (`3db912a`)
|
||||
- Update header, card, dialog (`fde7111`, `531ca10`, `f7a3b20`)
|
||||
- Perubahan update sidebar, dashboard, monitoring, antrean loket, verifikasi (`bb71955`)
|
||||
- Update check-in tampilan dan function (`482294b`)
|
||||
|
||||
### Keputusan Teknis
|
||||
> Layout system menggunakan Vuetify navigation drawer (`SideBar.vue`) + custom `PageHeader.vue`. Design consistency diterapkan via SCSS variables (`_variables.scss`, `_colors.scss`) untuk warna, spacing, dan typography.
|
||||
|
||||
---
|
||||
|
||||
## 2026-01-15 — Login Page, Check-in & Antrean Masuk
|
||||
|
||||
**Sprint/Phase:** Phase 1 — Fondasi
|
||||
**Durasi:** ~8 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Implementasi login page dan check-in (`e99a0ab`)
|
||||
- Update color palette dan konsultasi pasien (`a8a55b9`)
|
||||
- Tampilan antrean masuk (`ed57e2d`)
|
||||
- Edit manual check-in (`928be7c`)
|
||||
- Update logika pemanggilan, sticky konten, tampilan card (`96c6376`)
|
||||
- Perbaikan generate dan scan tiket QR (`8aa5ab2`)
|
||||
|
||||
### Keputusan Teknis
|
||||
> QR code digunakan untuk tiket antrean — pasien mendapat QR saat ambil antrean, lalu scan QR saat check-in. Library `html5-qrcode` dipilih untuk scanner dan `qrcode.vue` untuk generator.
|
||||
|
||||
---
|
||||
|
||||
## 2026-01-12–14 — Loket, Klinik Ruang & Fast Track
|
||||
|
||||
**Sprint/Phase:** Phase 1 — Fondasi
|
||||
**Durasi:** ~16 jam (3 hari)
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Update layout klinik dan WebSocket layar klinik (`6e7160c`)
|
||||
- Update klinik ruang dan status fast track (`27209de`)
|
||||
- Perubahan tiket dan alur antrean (`5c14227`)
|
||||
- Push loket baru (`42fe62c`)
|
||||
- Loket perbaikan "sedang dilayani" (`48e4aab`)
|
||||
- Anjungan fast track dan tampilan ruang (`3817b2d`)
|
||||
- Perbaikan loket dan total antrean (`a000ba0`, `0a8e11a`, `9ccbaba`)
|
||||
- Check-in tampilan (`0c7b33a`)
|
||||
- Fixing layar loket dan status pasien (`e80032f`)
|
||||
- QRcode update (`6c80c08`)
|
||||
- Update warna dan klinik ruang (`f3e90ad`)
|
||||
- Perubahan antrean masuk (`c5b623f`)
|
||||
- Update seed data dan fungsi pindah/konsul klinik (`89c3549`)
|
||||
|
||||
---
|
||||
|
||||
## 2026-01-08–09 — Klinik Ruang, QR & Print
|
||||
|
||||
**Sprint/Phase:** Phase 1 — Fondasi
|
||||
**Durasi:** ~12 jam (2 hari)
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Layar baru antrean klinik dan antrean masuk (`2d3d589`)
|
||||
- Update klinik ruang (`ca5913c`)
|
||||
- Nav items (`8a4bb44`)
|
||||
- Update print admin loket, logika klinik ruang, numbering antrian baru (`676bdc0`)
|
||||
- Update QR function (`22d7205`)
|
||||
- Perubahan format No Antrean (`6d5d565`)
|
||||
- Check-in footers detail (`1b7a142`)
|
||||
- Klinik ruang dan update admin loket (`9ea8300`)
|
||||
|
||||
### Keputusan Teknis
|
||||
> Format nomor antrean: `[KodeKlinik]-[NomorUrut]` (contoh: `PDL-001`). Thermal print diimplementasi via `useThermalPrint.ts` composable untuk cetak tiket langsung dari browser ke printer thermal via Web API.
|
||||
|
||||
---
|
||||
|
||||
<!-- Tambahkan entry baru di atas baris ini, urutan terbaru di atas -->
|
||||
|
||||
---
|
||||
|
||||
## 📊 Summary Stats
|
||||
|
||||
| Metrik | Value |
|
||||
|--------|-------|
|
||||
| Total hari dev | ~50+ hari (Jan 2026 — Mei 2026) |
|
||||
| Fase project | 5 fase (Setup → Fondasi → Hak Akses → Eksekutif → Stabilisasi) |
|
||||
| Fitur selesai | 8 modul utama (Anjungan, Check-in, Loket, Klinik, Penunjang, Dashboard, Setting, Hak Akses) |
|
||||
| Total commits | 80+ commits |
|
||||
| Bug ditemukan | 15+ (WebSocket, loket interference, memory, display sync) |
|
||||
| Bug diselesaikan | 15+ |
|
||||
| Halaman dibuat | 28 halaman |
|
||||
| Composables | 16 composable |
|
||||
| Pinia Stores | 13 stores |
|
||||
| Komponen | 20+ komponen |
|
||||
+243
@@ -0,0 +1,243 @@
|
||||
# 🗺️ DEVPLAN — Development Plan
|
||||
|
||||
**Project:** Web Antrean — Sistem Manajemen Antrean Rawat Jalan RSSA
|
||||
**Version:** 1.0.0
|
||||
**Author:** Akbar
|
||||
**Stack:** JavaScript · Vue 3 · TypeScript · Nuxt 3 · Vuetify 3 · Pinia
|
||||
**Last Updated:** 2026-05-25
|
||||
|
||||
---
|
||||
|
||||
## 🧠 AI Prompt — Cara Menggunakan Dokumen Ini
|
||||
|
||||
> Salin prompt berikut ke Claude untuk membantu membuat dev plan:
|
||||
|
||||
```
|
||||
Kamu adalah tech lead senior fullstack (Nuxt 3, Vue 3, TypeScript).
|
||||
|
||||
Project: Web Antrean — Sistem Manajemen Antrean Rawat Jalan RSSA
|
||||
Deskripsi: Digitalisasi alur antrean pasien rawat jalan di rumah sakit
|
||||
Deadline: Mei 2026 (Go-Live)
|
||||
Tim: 1 Fullstack Engineer (Akbar), PO Tim RSSA
|
||||
|
||||
Fitur yang harus dibangun:
|
||||
1. Anjungan mandiri (kiosk registrasi pasien, pilih klinik/subspesialis)
|
||||
2. Check-in pasien via QR code
|
||||
3. Manajemen antrean loket & display screen
|
||||
4. Manajemen antrean klinik & ruang
|
||||
5. Manajemen antrean penunjang
|
||||
6. Real-time sync via WebSocket
|
||||
7. Dashboard monitoring & statistik
|
||||
8. Manajemen user, master data, dan hak akses (Keycloak)
|
||||
|
||||
Buatkan:
|
||||
1. Breakdown fase development (Phase 0 s/d launch)
|
||||
2. Task list per fase dengan estimasi
|
||||
3. Urutan prioritas fitur (MoSCoW)
|
||||
4. Technical dependencies antar task
|
||||
5. Milestone dan checkpoint
|
||||
|
||||
Format dalam tabel Markdown yang terstruktur.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Project Timeline
|
||||
|
||||
```
|
||||
[Start]──Phase 0──Phase 1──Phase 2──Phase 3──Phase 4──Phase 5──Phase 6──[Launch]
|
||||
Setup Fondasi Inti API Hak Akses Eksekutif Stabilisasi Verifikasi
|
||||
```
|
||||
|
||||
| Fase | Nama | Durasi | Target | Status |
|
||||
|------|------|--------|--------|--------|
|
||||
| Phase 0 | Setup & Arsitektur | 1 Minggu | Awal Jan 2026 | ✅ Done |
|
||||
| Phase 1 | Fondasi (Loket, Klinik, QR) | 3 Minggu | Jan 2026 | ✅ Done |
|
||||
| Phase 2 | Fitur Inti (Anjungan, WS, API) | 3 Minggu | Feb 2026 | ✅ Done |
|
||||
| Phase 3 | Hak Akses & Keycloak | 2 Minggu | Apr 2026 | ✅ Done |
|
||||
| Phase 4 | Fitur Eksekutif & Docker | 1 Minggu | Pertengahan Mei | ✅ Done |
|
||||
| Phase 5 | Stabilisasi & Dokumentasi | 2 Minggu | Akhir Mei 2026 | ✅ Done |
|
||||
| Phase 6 | Verifikasi Akun & Kiosk | 2 Minggu | Juni 2026 | 🔄 In Progress |
|
||||
|
||||
---
|
||||
|
||||
## 2. Feature Prioritization (MoSCoW)
|
||||
|
||||
| Priority | Fitur | Estimasi | Phase |
|
||||
|----------|-------|----------|-------|
|
||||
| 🔴 Must Have | Modul Anjungan (Pilih klinik, ambil tiket, cetak) | 40 jam | 2 |
|
||||
| 🔴 Must Have | Modul Loket (Panggil, skip, recall, next) | 30 jam | 1 & 2 |
|
||||
| 🔴 Must Have | Check-in via QR Code | 20 jam | 1 |
|
||||
| 🔴 Must Have | Sinkronisasi Real-time via WebSocket | 30 jam | 2 |
|
||||
| 🔴 Must Have | Integrasi API Eksternal (Visit API, Antrian API) | 40 jam | 2 |
|
||||
| 🔴 Must Have | Manajemen Hak Akses Keycloak | 25 jam | 3 |
|
||||
| 🟡 Should Have | Antrean Klinik & Penunjang | 30 jam | 1 & 4 |
|
||||
| 🟡 Should Have | Dashboard Monitoring & Statistik | 15 jam | 1 |
|
||||
| 🟡 Should Have | Pilihan Subspesialis (Klinik Eksekutif) | 10 jam | 4 |
|
||||
| 🟢 Could Have | Notifikasi Suara Panggilan (Text-to-Speech) | 15 jam | Future |
|
||||
| ⬜ Won't Have | Aplikasi Mobile Native | — | Out of scope |
|
||||
| ⬜ Won't Have | Integrasi Billing / Pembayaran Langsung | — | Out of scope |
|
||||
|
||||
---
|
||||
|
||||
## 3. Task Breakdown
|
||||
|
||||
### Phase 0 — Setup & Arsitektur
|
||||
| Task | Estimasi | Dependencies | Status |
|
||||
|------|----------|--------------|--------|
|
||||
| Init Nuxt 3 + TypeScript | 2 jam | — | ✅ Done |
|
||||
| Setup Vuetify 3 + SCSS variables | 4 jam | Task 1 | ✅ Done |
|
||||
| Setup Pinia state management | 2 jam | Task 1 | ✅ Done |
|
||||
| Setup Vue Router (Nuxt pages/layout) | 3 jam | Task 1 | ✅ Done |
|
||||
| Konfigurasi Proxy API di `nuxt.config.ts` | 3 jam | Task 1 | ✅ Done |
|
||||
|
||||
### Phase 1 — Fondasi (Loket, Klinik, QR)
|
||||
| Task | Estimasi | Dependencies | Status |
|
||||
|------|----------|--------------|--------|
|
||||
| UI Layout, Sidebar, PageHeader | 8 jam | Phase 0 | ✅ Done |
|
||||
| Komponen PatientCard & Tabel | 8 jam | Phase 0 | ✅ Done |
|
||||
| UI Admin Loket & Admin Klinik | 12 jam | Task 2 | ✅ Done |
|
||||
| Check-in Pasien UI & QR Scanner (`html5-qrcode`) | 12 jam | Phase 0 | ✅ Done |
|
||||
| Generator Tiket QR (`qrcode.vue`) | 6 jam | Phase 0 | ✅ Done |
|
||||
| Cetak Tiket Thermal (`useThermalPrint.ts`) | 8 jam | Phase 0 | ✅ Done |
|
||||
|
||||
### Phase 2 — Fitur Inti (Anjungan, WS, API)
|
||||
| Task | Estimasi | Dependencies | Status |
|
||||
|------|----------|--------------|--------|
|
||||
| Integrasi Visit API & Antrian API | 16 jam | Phase 0 | ✅ Done |
|
||||
| Setup WebSocket client (`useWebSocket.ts`) | 8 jam | Task 1 | ✅ Done |
|
||||
| Layar Display Antrean (Klinik & Loket) | 12 jam | Task 2 | ✅ Done |
|
||||
| Modul Anjungan Mandiri (UI & Logic) | 16 jam | Task 1 | ✅ Done |
|
||||
| Centralized `queueStore` | 12 jam | Task 1, 2 | ✅ Done |
|
||||
| Bugfix duplikasi data & WS handling | 12 jam | Task 2, 5 | ✅ Done |
|
||||
|
||||
### Phase 3 — Hak Akses & Keycloak
|
||||
| Task | Estimasi | Dependencies | Status |
|
||||
|------|----------|--------------|--------|
|
||||
| Konfigurasi Keycloak SSO di frontend | 8 jam | — | ✅ Done |
|
||||
| Middleware Auth & Guest | 4 jam | Task 1 | ✅ Done |
|
||||
| CRUD Master Data (Klinik, Loket, Screen) | 12 jam | Phase 1 | ✅ Done |
|
||||
| Manajemen Hak Akses User/Group (UI & API) | 12 jam | Task 1 | ✅ Done |
|
||||
| Middleware Permission & Page Access Guard | 8 jam | Task 4 | ✅ Done |
|
||||
|
||||
### Phase 4 — Fitur Eksekutif & Docker
|
||||
| Task | Estimasi | Dependencies | Status |
|
||||
|------|----------|--------------|--------|
|
||||
| UI Pemilihan Subspesialis di Anjungan | 8 jam | Phase 2 | ✅ Done |
|
||||
| Conditional display spesialis di PatientCard | 4 jam | Task 1 | ✅ Done |
|
||||
| Setup Dockerfile & docker-compose | 4 jam | — | ✅ Done |
|
||||
| Update konfigurasi environment untuk container | 4 jam | Task 3 | ✅ Done |
|
||||
|
||||
### Phase 5 — Stabilisasi & QA
|
||||
| Task | Estimasi | Dependencies | Status |
|
||||
|------|----------|--------------|--------|
|
||||
| Standardisasi WS Client ID (Deterministic) | 4 jam | Phase 2 | ✅ Done |
|
||||
| Polling fallback 30s untuk layar display | 6 jam | Phase 2 | ✅ Done |
|
||||
| Isolasi state loket (`currentProcessingPatient`) | 6 jam | Phase 2 | ✅ Done |
|
||||
| Blacklist handler untuk endpoint gagal 500 | 4 jam | Phase 2 | ✅ Done |
|
||||
| Dokumentasi PRD, QMD, DEVLOG, EVLOG | 8 jam | — | ✅ Done |
|
||||
| Setup Unit Testing & E2E framework | 4 jam | — | ✅ Done |
|
||||
|
||||
### Phase 6 — Verifikasi Akun & Kiosk
|
||||
| Task | Estimasi | Dependencies | Status |
|
||||
|------|----------|--------------|--------|
|
||||
| Sistem notifikasi suara (voice over) loket & klinik | 12 jam | Phase 2 | ✅ Done |
|
||||
| Fitur pindah klinik pada dashboard admin | 8 jam | Phase 2 | ✅ Done |
|
||||
| Halaman Detail Akun Verifikasi | 4 jam | Phase 3 | ✅ Done |
|
||||
| Implementasi Proxy Routes (CORS) | 8 jam | Phase 2 | ✅ Done |
|
||||
|
||||
---
|
||||
|
||||
## 4. Technical Dependencies
|
||||
|
||||
```text
|
||||
Nuxt 3 + TypeScript
|
||||
├── Pinia (State Management)
|
||||
│ └── pinia-plugin-persistedstate (localStorage)
|
||||
├── Vuetify 3 (UI Framework)
|
||||
│ └── Material Design Icons
|
||||
├── Nuxt Nitro (Server Engine)
|
||||
│ └── Server Routes (CORS Proxy API)
|
||||
├── Auth Layer
|
||||
│ └── Keycloak SSO (OIDC/OAuth 2.0)
|
||||
├── API Layer
|
||||
│ ├── Visit API (10.10.123.135:8084)
|
||||
│ └── Antrian API (10.10.123.140:8089)
|
||||
└── Real-time Layer
|
||||
└── Native Browser WebSocket
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Environment Setup
|
||||
|
||||
### Local Development
|
||||
```bash
|
||||
# Clone repo
|
||||
git clone https://git.rssa.top/arie.bagus.2905/web-antrean
|
||||
cd web-antrean
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Copy env
|
||||
cp .env.example .env
|
||||
|
||||
# Jalankan dev server dengan host spesifik
|
||||
npm run dev
|
||||
# ATAU
|
||||
npm run _command_dev
|
||||
```
|
||||
|
||||
### Environment Variables (.env)
|
||||
```env
|
||||
AUTH_ORIGIN="http://10.10.150.175:3000"
|
||||
KEYCLOAK_CLIENT_ID="akbar-test"
|
||||
KEYCLOAK_ISSUER="https://auth.rssa.top/realms/sandbox"
|
||||
ANTRIAN_API_URL="http://10.10.123.140:8089/api/v1"
|
||||
VISIT_API_URL="http://10.10.123.135:8084/api/v1"
|
||||
WS_API_URL="ws://10.10.123.135:8084/api/v1/ws"
|
||||
PROXY_CLIENT_ORIGIN="http://10.10.150.175:3000"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Git Strategy
|
||||
|
||||
### Branch Naming
|
||||
```
|
||||
main → production (stable)
|
||||
Antrean-Code → development / staging
|
||||
feature/xxx → fitur baru (contoh: feature/anjungan-eksekutif)
|
||||
fix/xxx → bug fix (contoh: fix/ws-duplicate)
|
||||
```
|
||||
|
||||
### Commit Convention
|
||||
Saat ini commit message banyak menggunakan free-text (contoh: `push perbaikan`, `update bug ws`). Ke depannya, disarankan menggunakan **Conventional Commits**:
|
||||
```
|
||||
feat: tambah halaman dashboard
|
||||
fix: perbaiki duplikasi antrean di loket
|
||||
chore: update docker config
|
||||
docs: update DEVPLAN
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Milestones & Checkpoints
|
||||
|
||||
| Milestone | Kriteria | Target Tanggal | Status |
|
||||
|-----------|----------|----------------|--------|
|
||||
| 🏁 Project Kickoff | Setup Nuxt, Layout, Styling | Jan 2026 | ✅ Done |
|
||||
| 🔌 API & WS Connected | Data mengalir dari backend, WS sync jalan | Feb 2026 | ✅ Done |
|
||||
| 🔐 Auth & Roles | Keycloak jalan, routing terlindungi middleware | Apr 2026 | ✅ Done |
|
||||
| 🏥 Anjungan Ready | Pasien bisa check-in & ambil tiket lancar | Pertengahan Mei | ✅ Done |
|
||||
| 🛡️ Stability Pass | Tidak ada leak, isolasi loket aman, no WS drift | Akhir Mei 2026 | ✅ Done |
|
||||
| 🚀 Production Go-Live | Deployment docker di server production | Q2/Q3 2026 | 🔄 Pending |
|
||||
|
||||
---
|
||||
|
||||
## 8. Changelog
|
||||
|
||||
| Versi | Tanggal | Author | Perubahan |
|
||||
|-------|---------|--------|-----------|
|
||||
| 1.0.0 | 2026-05-25 | Akbar | Initial plan — direkonstruksi berdasarkan timeline aktual |
|
||||
+544
@@ -0,0 +1,544 @@
|
||||
# 🚨 EVLOG — Event & Error Log
|
||||
|
||||
**Project:** Web Antrean — Sistem Manajemen Antrean Rawat Jalan RSSA
|
||||
**Author:** Akbar
|
||||
**Stack:** JavaScript · Vue 3 · TypeScript · Nuxt 3 · Vuetify 3 · Pinia
|
||||
|
||||
---
|
||||
|
||||
## 🧠 AI Prompt — Cara Menggunakan Dokumen Ini
|
||||
|
||||
> Salin prompt berikut ke Claude untuk menganalisis dan mendokumentasikan error:
|
||||
|
||||
```
|
||||
Kamu adalah senior engineer Nuxt 3 + TypeScript. Aku menemukan error berikut:
|
||||
|
||||
Error message: [paste error]
|
||||
Stack trace: [paste stack trace]
|
||||
Context: [dimana terjadi, langkah reproduksi]
|
||||
Tech: Nuxt 3, Vue 3, TypeScript, Vuetify 3, Pinia, WebSocket, Keycloak
|
||||
|
||||
Bantu aku:
|
||||
1. Analisis root cause error ini
|
||||
2. Berikan solusi step-by-step
|
||||
3. Sarankan cara mencegah error serupa
|
||||
4. Format hasilnya untuk EVLOG dalam Markdown
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Severity Legend
|
||||
|
||||
| Level | Icon | Deskripsi | SLA |
|
||||
|-------|------|-----------|-----|
|
||||
| Critical | 🔴 | App down / data loss / security | < 4 jam |
|
||||
| High | 🟠 | Fitur utama break | < 1 hari |
|
||||
| Medium | 🟡 | Fitur minor terganggu | < 3 hari |
|
||||
| Low | 🟢 | UI/kosmetik | Backlog |
|
||||
|
||||
---
|
||||
|
||||
## Format Entry
|
||||
|
||||
```markdown
|
||||
### [EV-XXX] — [Judul Singkat Error/Event]
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **ID** | EV-XXX |
|
||||
| **Tanggal** | YYYY-MM-DD |
|
||||
| **Severity** | 🔴 Critical / 🟠 High / 🟡 Medium / 🟢 Low |
|
||||
| **Environment** | Development / Staging / Production |
|
||||
| **Status** | 🔍 Investigating / 🔧 In Fix / ✅ Resolved / ⏭ Wontfix |
|
||||
| **Reporter** | Akbar |
|
||||
|
||||
**Error Message:**
|
||||
> [paste error message]
|
||||
|
||||
**Langkah Reproduksi:**
|
||||
1. ...
|
||||
|
||||
**Root Cause:**
|
||||
> ...
|
||||
|
||||
**Solusi / Fix:**
|
||||
> ...
|
||||
|
||||
**Prevention:**
|
||||
> ...
|
||||
|
||||
**Related:** [commit / file]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Event Log
|
||||
|
||||
<!-- Tambahkan entry baru di bawah baris ini, urutan terbaru di atas -->
|
||||
|
||||
---
|
||||
|
||||
### [EV-012] — Info Klinik Ruang Tidak Muncul di Layar Display
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **ID** | EV-012 |
|
||||
| **Tanggal** | 2026-05-21 |
|
||||
| **Severity** | 🟠 High |
|
||||
| **Environment** | Development |
|
||||
| **Status** | ✅ Resolved |
|
||||
| **Reporter** | Akbar |
|
||||
|
||||
**Error Message:**
|
||||
> Layar informasi klinik ruang menampilkan data kosong — tidak ada info klinik ruang yang muncul.
|
||||
|
||||
**Langkah Reproduksi:**
|
||||
1. Buka halaman display screen klinik ruang
|
||||
2. Data klinik ruang seharusnya muncul
|
||||
3. Layar kosong — tidak ada informasi tampil
|
||||
|
||||
**Root Cause:**
|
||||
> Data binding untuk info klinik ruang tidak ter-update setelah fetch dari API. Kemungkinan reactive state tidak di-watch dengan benar sehingga UI tidak re-render saat data berubah.
|
||||
|
||||
**Solusi / Fix:**
|
||||
> Perbaikan data binding dan reactive state untuk informasi klinik ruang pada layar display.
|
||||
|
||||
**Related:** Commit `ec9dac0` — "push perbaikan layar informasi info klinik ruang tidak muncul"
|
||||
|
||||
---
|
||||
|
||||
### [EV-011] — Data Antrean Tidak Sinkron Antar Display Screen
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **ID** | EV-011 |
|
||||
| **Tanggal** | 2026-05-22 |
|
||||
| **Severity** | 🔴 Critical |
|
||||
| **Environment** | Development |
|
||||
| **Status** | ✅ Resolved |
|
||||
| **Reporter** | Akbar |
|
||||
|
||||
**Error Message:**
|
||||
> Display screen di lokasi berbeda menampilkan nomor panggil yang berbeda. Admin memanggil pasien tapi layar display tidak update, atau update terlambat > 10 detik.
|
||||
|
||||
**Langkah Reproduksi:**
|
||||
1. Buka admin loket di PC A
|
||||
2. Buka display screen di TV/monitor B
|
||||
3. Panggil pasien berikutnya di PC A
|
||||
4. Display di monitor B tidak menampilkan nomor panggil terbaru
|
||||
|
||||
**Root Cause:**
|
||||
> WebSocket client ID menggunakan random ID, sehingga server tidak bisa menargetkan pesan ke device tertentu secara reliable. Saat koneksi putus lalu reconnect, client ID berubah dan device kehilangan subscription.
|
||||
|
||||
**Solusi / Fix:**
|
||||
```typescript
|
||||
// ❌ Before — random client ID
|
||||
const clientId = `client_${Math.random().toString(36).substr(2, 9)}`
|
||||
|
||||
// ✅ After — deterministic client ID berdasarkan page + device
|
||||
const clientId = `${pageType}_${loketId || 'global'}_${deviceFingerprint}`
|
||||
```
|
||||
Ditambahkan juga polling fallback setiap 30 detik sebagai safety net.
|
||||
|
||||
**Prevention:**
|
||||
> Selalu gunakan deterministic identifier untuk WebSocket client. Implementasi polling fallback untuk semua halaman yang bergantung pada real-time data.
|
||||
|
||||
**Related:** Conversation `6fe234cf` — "Stabilizing WebSocket Queue Synchronization"
|
||||
|
||||
---
|
||||
|
||||
### [EV-010] — Cross-Loket Interference pada Patient Processing
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **ID** | EV-010 |
|
||||
| **Tanggal** | 2026-05-21 |
|
||||
| **Severity** | 🔴 Critical |
|
||||
| **Environment** | Development |
|
||||
| **Status** | ✅ Resolved |
|
||||
| **Reporter** | Akbar |
|
||||
|
||||
**Error Message:**
|
||||
> Loket A memproses pasien yang seharusnya milik Loket B. `currentProcessingPatient` menampilkan pasien dari loket yang salah.
|
||||
|
||||
**Langkah Reproduksi:**
|
||||
1. Login sebagai admin loket A di browser tab 1
|
||||
2. Login sebagai admin loket B di browser tab 2
|
||||
3. Panggil pasien di loket A
|
||||
4. Loket B menampilkan pasien yang sama sebagai "sedang diproses"
|
||||
|
||||
**Root Cause:**
|
||||
> `currentProcessingPatient` menggunakan key persisted state yang sama untuk semua loket. Karena `pinia-plugin-persistedstate` menyimpan ke `localStorage` dengan key yang sama, operasi di satu loket menimpa state loket lain.
|
||||
|
||||
**Solusi / Fix:**
|
||||
```javascript
|
||||
// ❌ Before — shared key
|
||||
persist: { key: 'currentProcessingPatient' }
|
||||
|
||||
// ✅ After — unique key per loket
|
||||
persist: { key: `currentPatient_loket_${loketId}` }
|
||||
```
|
||||
Ditambahkan juga strict filter di `processNextQueue` dan `callNext` berdasarkan `loketId`, serta guard check di WebSocket event handler.
|
||||
|
||||
**Prevention:**
|
||||
> Setiap state yang bersifat per-instance (per loket, per klinik) HARUS menggunakan unique key. Jangan pernah share persisted state key antar instance.
|
||||
|
||||
**Related:** Conversation `7a37e693` — "Isolating Loket Queue Operations"
|
||||
|
||||
---
|
||||
|
||||
### [EV-009] — Bug Tampilan Loket
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **ID** | EV-009 |
|
||||
| **Tanggal** | 2026-05-20 |
|
||||
| **Severity** | 🟡 Medium |
|
||||
| **Environment** | Development |
|
||||
| **Status** | ✅ Resolved |
|
||||
| **Reporter** | Akbar |
|
||||
|
||||
**Error Message:**
|
||||
> Layout admin loket tidak render dengan benar — elemen UI tumpang tindih atau alignment salah.
|
||||
|
||||
**Langkah Reproduksi:**
|
||||
1. Login sebagai admin loket
|
||||
2. Buka halaman `/admin-loket`
|
||||
3. Layout tampilan loket tidak sesuai desain
|
||||
|
||||
**Root Cause:**
|
||||
> CSS layout issue dan data binding yang tidak sinkron dengan state loket.
|
||||
|
||||
**Solusi / Fix:**
|
||||
> Perbaikan layout dan data binding di halaman admin loket.
|
||||
|
||||
**Related:** Commit `f622052` — "perbaikan bug tampilan loket"
|
||||
|
||||
---
|
||||
|
||||
### [EV-008] — Memory Leak & Request Spam dari doctorStore
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **ID** | EV-008 |
|
||||
| **Tanggal** | 2026-05-18 |
|
||||
| **Severity** | 🟠 High |
|
||||
| **Environment** | Development |
|
||||
| **Status** | ✅ Resolved |
|
||||
| **Reporter** | Akbar |
|
||||
|
||||
**Error Message:**
|
||||
```
|
||||
Internal Server Error (500) — /klinik-api/doctors/...
|
||||
```
|
||||
Error terjadi berulang-ulang tanpa henti, menyebabkan request spam ke backend.
|
||||
|
||||
**Langkah Reproduksi:**
|
||||
1. Buka halaman yang memuat `doctorStore.js`
|
||||
2. Backend endpoint `/doctors` return 500
|
||||
3. Store terus retry tanpa batas
|
||||
4. Network tab penuh dengan request gagal, memory usage naik terus
|
||||
|
||||
**Root Cause:**
|
||||
> `doctorStore.js` tidak memiliki mekanisme backoff atau blacklist untuk endpoint yang gagal. Setiap kali fetch gagal, store langsung retry tanpa delay, menyebabkan infinite loop request ke server.
|
||||
|
||||
**Solusi / Fix:**
|
||||
```javascript
|
||||
// ✅ Implementasi blacklist endpoint gagal
|
||||
const blacklistedEndpoints = new Set()
|
||||
|
||||
async function fetchDoctors(endpoint) {
|
||||
if (blacklistedEndpoints.has(endpoint)) {
|
||||
return [] // skip, sudah di-blacklist
|
||||
}
|
||||
try {
|
||||
return await $fetch(endpoint)
|
||||
} catch (error) {
|
||||
if (error.status === 500) {
|
||||
blacklistedEndpoints.add(endpoint)
|
||||
console.warn(`Endpoint blacklisted: ${endpoint}`)
|
||||
}
|
||||
return []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Prevention:**
|
||||
> Semua API fetch HARUS memiliki: (1) error handling, (2) retry limit atau backoff, (3) blacklist mechanism untuk persistent failures. Jangan pernah retry tanpa batas.
|
||||
|
||||
**Related:** Conversation `c7502197` — "Optimizing Web Antrean Memory Usage", commit `cb3310b`
|
||||
|
||||
---
|
||||
|
||||
### [EV-007] — Console.log Verbose Memperlambat Performa
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **ID** | EV-007 |
|
||||
| **Tanggal** | 2026-05-18 |
|
||||
| **Severity** | 🟢 Low |
|
||||
| **Environment** | Development |
|
||||
| **Status** | ✅ Resolved |
|
||||
| **Reporter** | Akbar |
|
||||
|
||||
**Error Message:**
|
||||
> Tidak ada error message, tapi browser DevTools penuh dengan log output, menyebabkan performa menurun terutama di device yang lebih lambat.
|
||||
|
||||
**Langkah Reproduksi:**
|
||||
1. Buka halaman yang menggunakan WebSocket atau QR scanner
|
||||
2. Buka browser DevTools → Console
|
||||
3. Log membanjir setiap detik dari `useWebSocket.ts` dan QR scanner di `checkIn.vue`
|
||||
|
||||
**Root Cause:**
|
||||
> `console.log` debugging statements di high-frequency code paths (WebSocket message handler yang dipanggil per-detik, QR scanner frame processing) tidak dihapus setelah debugging selesai.
|
||||
|
||||
**Solusi / Fix:**
|
||||
> Cleanup semua `console.log` di:
|
||||
> - `composables/useWebSocket.ts` — WebSocket message handler
|
||||
> - `pages/CheckInPasien/checkIn.vue` — QR scanner frame processing
|
||||
> Hanya sisakan `console.warn` dan `console.error` untuk kondisi yang benar-benar perlu.
|
||||
|
||||
**Prevention:**
|
||||
> Gunakan convention: `console.log` hanya untuk debugging sementara, `console.warn`/`console.error` untuk production logging. Tambahkan lint rule atau pre-commit hook untuk mendeteksi `console.log` yang tersisa.
|
||||
|
||||
**Related:** Conversation `c7502197` — "Optimizing Web Antrean Memory Usage"
|
||||
|
||||
---
|
||||
|
||||
### [EV-006] — Header Origin/Referer Tidak Konsisten di Proxy Routes
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **ID** | EV-006 |
|
||||
| **Tanggal** | 2026-05-22 |
|
||||
| **Severity** | 🟡 Medium |
|
||||
| **Environment** | Development |
|
||||
| **Status** | ✅ Resolved |
|
||||
| **Reporter** | Akbar |
|
||||
|
||||
**Error Message:**
|
||||
> CORS error atau origin validation failure saat memanggil backend API melalui proxy routes. Beberapa request berhasil, beberapa gagal secara intermittent.
|
||||
|
||||
**Langkah Reproduksi:**
|
||||
1. Panggil API via proxy route `stats-api`, `visit-api`, atau `klinik-api`
|
||||
2. Beberapa request gagal dengan CORS error
|
||||
3. Request yang sama kadang berhasil, kadang gagal
|
||||
|
||||
**Root Cause:**
|
||||
> Setiap proxy route handler (`server/routes/stats-api/`, `visit-api/`, `klinik-api/`) menggunakan header origin dan referer yang berbeda-beda. Backend melakukan validasi origin, dan header yang tidak konsisten menyebabkan intermittent failure.
|
||||
|
||||
**Solusi / Fix:**
|
||||
> Standardisasi semua proxy route handler agar menggunakan origin dan referer yang sama:
|
||||
```typescript
|
||||
// ✅ Semua proxy routes menggunakan header yang sama
|
||||
headers: {
|
||||
'Origin': 'http://10.10.150.175:3000',
|
||||
'Referer': 'http://10.10.150.175:3000',
|
||||
}
|
||||
```
|
||||
|
||||
**Prevention:**
|
||||
> Buat shared utility function untuk proxy header configuration agar semua route handler menggunakan config yang sama. Hindari copy-paste header config per file.
|
||||
|
||||
**Related:** Conversation `97867794` — "Standardizing Header Configurations Across APIs"
|
||||
|
||||
---
|
||||
|
||||
### [EV-005] — Duplikasi Tiket di Admin Klinik Loket
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **ID** | EV-005 |
|
||||
| **Tanggal** | 2026-02-11 |
|
||||
| **Severity** | 🟠 High |
|
||||
| **Environment** | Development |
|
||||
| **Status** | ✅ Resolved |
|
||||
| **Reporter** | Akbar |
|
||||
|
||||
**Error Message:**
|
||||
> Daftar pasien di admin klinik menampilkan tiket yang sama dua kali. Setelah check-in, tiket pasien menghilang dari daftar.
|
||||
|
||||
**Langkah Reproduksi:**
|
||||
1. Pasien ambil antrean di anjungan
|
||||
2. Buka admin klinik loket
|
||||
3. Pasien yang sama muncul 2x di daftar
|
||||
4. Setelah check-in, tiket menghilang dari daftar
|
||||
|
||||
**Root Cause:**
|
||||
> WebSocket event handler menambahkan pasien ke `allPatients` tanpa deduplication check. Saat event masuk bersamaan dari multiple sources (initial load + WebSocket), data terduplikasi. Saat check-in, state update menghapus entry yang salah.
|
||||
|
||||
**Solusi / Fix:**
|
||||
```javascript
|
||||
// ✅ Deduplication check sebelum add ke allPatients
|
||||
function addPatient(patient) {
|
||||
const exists = allPatients.value.find(p => p.nomorAntrean === patient.nomorAntrean)
|
||||
if (!exists) {
|
||||
allPatients.value.push(patient)
|
||||
} else {
|
||||
// Update existing instead of duplicate
|
||||
Object.assign(exists, patient)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Prevention:**
|
||||
> Semua operasi mutasi pada `allPatients` harus melalui function yang melakukan deduplication check. Jangan pernah langsung `push` tanpa cek duplikat.
|
||||
|
||||
**Related:** Commit `c02905e` — "fix duplication and ticket disappear in adminklinik loket"
|
||||
|
||||
---
|
||||
|
||||
### [EV-004] — WebSocket Check-in Tidak Trigger UI Update
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **ID** | EV-004 |
|
||||
| **Tanggal** | 2026-02-10 |
|
||||
| **Severity** | 🟠 High |
|
||||
| **Environment** | Development |
|
||||
| **Status** | ✅ Resolved |
|
||||
| **Reporter** | Akbar |
|
||||
|
||||
**Error Message:**
|
||||
> Pasien check-in via QR berhasil (API return success), tapi status di admin loket tidak berubah. UI tetap menampilkan pasien sebagai "belum hadir".
|
||||
|
||||
**Langkah Reproduksi:**
|
||||
1. Pasien scan QR di halaman check-in
|
||||
2. API return success — pasien tercatat hadir
|
||||
3. Buka admin loket — status pasien masih "belum hadir"
|
||||
4. Perlu manual refresh untuk melihat update
|
||||
|
||||
**Root Cause:**
|
||||
> WebSocket event listener untuk check-in event tidak ter-bind dengan benar. Event `checkin_success` diterima oleh WebSocket, tapi handler tidak melakukan state update ke Pinia store.
|
||||
|
||||
**Solusi / Fix:**
|
||||
> Fix event listener binding di `useWebSocket.ts` — pastikan `checkin_success` event memicu update pada `queueStore.allPatients`.
|
||||
|
||||
**Prevention:**
|
||||
> Setiap WebSocket event type harus memiliki dedicated handler yang ter-test. Buat mapping event → handler yang eksplisit.
|
||||
|
||||
**Related:** Commits `e686dda`, `b6dc252` — "fix socket checkin", "update bug ws checkin loket"
|
||||
|
||||
---
|
||||
|
||||
### [EV-003] — HTTPS Implementation Gagal di Check-in
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **ID** | EV-003 |
|
||||
| **Tanggal** | 2026-02-02 |
|
||||
| **Severity** | 🟡 Medium |
|
||||
| **Environment** | Development |
|
||||
| **Status** | ✅ Resolved |
|
||||
| **Reporter** | Akbar |
|
||||
|
||||
**Error Message:**
|
||||
> Check-in via QR tidak berfungsi saat HTTPS aktif. Kamera QR scanner tidak bisa diakses.
|
||||
|
||||
**Langkah Reproduksi:**
|
||||
1. Aktifkan HTTPS di dev server
|
||||
2. Buka halaman check-in
|
||||
3. QR scanner gagal — kamera tidak muncul
|
||||
|
||||
**Root Cause:**
|
||||
> Browser memerlukan HTTPS untuk akses kamera (MediaDevices API), tapi mixed content policy memblokir request ke backend HTTP. Konfigurasi HTTPS tidak lengkap — SSL certificate self-signed tidak dipercaya browser.
|
||||
|
||||
**Solusi / Fix:**
|
||||
> Fix implementasi HTTPS menggunakan `@vitejs/plugin-basic-ssl` di `nuxt.config.ts`. Konfigurasi dev server untuk HTTPS dengan self-signed cert yang di-trust browser.
|
||||
|
||||
**Prevention:**
|
||||
> Saat menggunakan Web API yang memerlukan secure context (kamera, geolocation), pastikan HTTPS sudah configured end-to-end termasuk backend.
|
||||
|
||||
**Related:** Commit `c899a71` — "fix https implementation and checkin"
|
||||
|
||||
---
|
||||
|
||||
### [EV-002] — Loop & Duplikasi Data di Fetch API
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **ID** | EV-002 |
|
||||
| **Tanggal** | 2026-01-29 |
|
||||
| **Severity** | 🟠 High |
|
||||
| **Environment** | Development |
|
||||
| **Status** | ✅ Resolved |
|
||||
| **Reporter** | Akbar |
|
||||
|
||||
**Error Message:**
|
||||
> Data pasien terduplikasi di daftar antrean. Fetch API dipanggil berulang-ulang dalam loop.
|
||||
|
||||
**Langkah Reproduksi:**
|
||||
1. Buka halaman yang memuat data antrean
|
||||
2. Data pasien muncul berkali-kali
|
||||
3. Network tab menunjukkan API dipanggil berulang-ulang
|
||||
|
||||
**Root Cause:**
|
||||
> `watch` atau `computed` yang bergantung pada reactive state memicu re-fetch setiap kali state berubah, dan hasil fetch mengubah state lagi — menyebabkan infinite loop.
|
||||
|
||||
**Solusi / Fix:**
|
||||
> Fix logic watch/computed agar tidak circular. Tambahkan guard condition untuk mencegah re-fetch saat data sudah di-load.
|
||||
|
||||
**Prevention:**
|
||||
> Hindari circular dependency antara watcher dan state mutation. Gunakan flag `isLoading` untuk mencegah concurrent fetch.
|
||||
|
||||
**Related:** Commit `0bd5311` — "fix loop dan duplicate data"
|
||||
|
||||
---
|
||||
|
||||
### [EV-001] — Session & Tampilan Screen Tidak Sinkron
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **ID** | EV-001 |
|
||||
| **Tanggal** | 2026-01-30 |
|
||||
| **Severity** | 🟡 Medium |
|
||||
| **Environment** | Development |
|
||||
| **Status** | ✅ Resolved |
|
||||
| **Reporter** | Akbar |
|
||||
|
||||
**Error Message:**
|
||||
> Display screen menampilkan data lama setelah session expire. Setelah re-login, data tidak refresh otomatis.
|
||||
|
||||
**Langkah Reproduksi:**
|
||||
1. Login dan buka display screen
|
||||
2. Tunggu session expire (1 jam)
|
||||
3. Re-login
|
||||
4. Display masih menampilkan data dari session sebelumnya
|
||||
|
||||
**Root Cause:**
|
||||
> Pinia persisted state menyimpan data lama di `localStorage`. Saat session expire dan user re-login, store tidak di-reset — data lama masih tampil.
|
||||
|
||||
**Solusi / Fix:**
|
||||
> Reset persisted state saat login baru. Pastikan display screen melakukan fresh fetch setelah session recovery.
|
||||
|
||||
**Prevention:**
|
||||
> Implementasi session lifecycle hooks: on session expire → clear stale state, on re-login → fresh fetch semua data.
|
||||
|
||||
**Related:** Commit `8dd94ed` — "update fix session dan tampilan screen"
|
||||
|
||||
---
|
||||
|
||||
## 📊 Error Statistics
|
||||
|
||||
| Bulan | Critical | High | Medium | Low | Total |
|
||||
|-------|---------|------|--------|-----|-------|
|
||||
| Jan 2026 | 0 | 1 | 1 | 0 | 2 |
|
||||
| Feb 2026 | 0 | 3 | 1 | 0 | 4 |
|
||||
| Mar 2026 | 0 | 0 | 0 | 0 | 0 |
|
||||
| Apr 2026 | 0 | 0 | 0 | 0 | 0 |
|
||||
| Mei 2026 | 2 | 2 | 1 | 1 | 6 |
|
||||
| **Total** | **2** | **6** | **3** | **1** | **12** |
|
||||
|
||||
---
|
||||
|
||||
## 🔁 Recurring Issues
|
||||
|
||||
> Daftar error yang muncul lebih dari sekali — kandidat untuk refactor/improvement permanen.
|
||||
|
||||
| Error Pattern | Frekuensi | Action |
|
||||
|---------------|-----------|--------|
|
||||
| WebSocket disconnect → data tidak sinkron | 3x (EV-004, EV-011, EV-012) | ✅ Implemented: polling fallback 30 detik + auto-reconnect + deterministic client ID |
|
||||
| Duplikasi data pasien di daftar | 2x (EV-002, EV-005) | ✅ Implemented: deduplication check di `allPatients` mutation |
|
||||
| Session/state stale setelah reconnect | 2x (EV-001, EV-011) | ✅ Implemented: fresh fetch on reconnect + state reset on re-login |
|
||||
| Request spam ke endpoint yang gagal | 1x (EV-008) | ✅ Implemented: blacklist + retry limit. **Monitor untuk recurring** |
|
||||
| Cross-instance state conflict (loket) | 1x (EV-010) | ✅ Implemented: unique persisted state key per instance. **Audit untuk klinik/penunjang** |
|
||||
+408
@@ -0,0 +1,408 @@
|
||||
# 📋 PRD — Product Requirements Document
|
||||
|
||||
**Project:** Web Antrean — Sistem Manajemen Antrean Rawat Jalan RSSA
|
||||
**Version:** 1.0.0
|
||||
**Status:** `In Review`
|
||||
**Author:** Akbar
|
||||
**Stack:** JavaScript · Vue 3 · TypeScript · Nuxt 3 · Vuetify 3 · Pinia
|
||||
**Last Updated:** 2026-05-25
|
||||
|
||||
---
|
||||
|
||||
## 🧠 AI Prompt — Cara Menggunakan Dokumen Ini
|
||||
|
||||
> Salin prompt berikut ke Claude / ChatGPT untuk membantu melanjutkan PRD:
|
||||
|
||||
```
|
||||
Kamu adalah product manager senior. Bantu aku mengembangkan PRD untuk project berikut:
|
||||
- Nama project: Web Antrean — Sistem Manajemen Antrean Rawat Jalan RSSA
|
||||
- Tujuan utama: Digitalisasi alur antrean pasien rawat jalan di rumah sakit
|
||||
- Target pengguna: Pasien, Admin Loket, Admin Klinik, Admin Penunjang, Superadmin
|
||||
- Tech stack: Nuxt 3, Vue 3, TypeScript, Vuetify 3, Pinia, WebSocket, Keycloak SSO
|
||||
|
||||
Buatkan:
|
||||
1. Problem statement yang tajam
|
||||
2. User stories tambahan dengan acceptance criteria
|
||||
3. Risiko teknis dan mitigasinya
|
||||
|
||||
Format output dalam Markdown.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
### 1.1 Executive Summary
|
||||
Web Antrean adalah aplikasi manajemen antrean rawat jalan berbasis web yang dibangun untuk Rumah Sakit RSSA. Sistem ini mendigitalisasi seluruh alur antrean pasien mulai dari pendaftaran via anjungan mandiri, pemanggilan pasien di loket dan klinik, hingga monitoring real-time oleh administrator. Aplikasi ini kritis karena menggantikan sistem antrean manual yang tidak efisien dan rawan error.
|
||||
|
||||
### 1.2 Problem Statement
|
||||
Rumah Sakit RSSA menghadapi masalah antrean rawat jalan yang tidak terstruktur: pasien tidak mengetahui posisi antrean mereka, petugas loket kesulitan mengelola urutan panggil, dan tidak ada visibilitas real-time lintas unit (loket, klinik, penunjang). Kondisi ini menyebabkan penumpukan pasien, waktu tunggu tidak terprediksi, dan pengalaman buruk bagi pasien.
|
||||
|
||||
### 1.3 Goals & Success Metrics
|
||||
|
||||
| # | Goal | Metrik | Target |
|
||||
|---|------|--------|--------|
|
||||
| 1 | Mengurangi waktu tunggu pasien | Rata-rata waktu tunggu per sesi | < 30 menit |
|
||||
| 2 | Digitalisasi antrean loket & klinik | % proses antrean via sistem | 100% |
|
||||
| 3 | Real-time sync antar perangkat | Latensi update antrean via WebSocket | < 2 detik |
|
||||
| 4 | Kemandirian pasien dalam ambil antrean | % pasien gunakan anjungan mandiri | > 80% |
|
||||
| 5 | Stabilitas sistem | Uptime saat jam operasional | > 99.5% |
|
||||
|
||||
### 1.4 Non-Goals (Out of Scope)
|
||||
- [ ] Integrasi dengan sistem pembayaran / billing
|
||||
- [ ] Manajemen rekam medis (EMR)
|
||||
- [ ] Aplikasi mobile native (iOS/Android)
|
||||
- [ ] Antrean rawat inap (ranap) — fitur ranap admin tersedia tapi belum final
|
||||
- [ ] Sistem appointment/penjadwalan janji temu online
|
||||
|
||||
---
|
||||
|
||||
## 2. Stakeholders
|
||||
|
||||
| Role | Nama | Tanggung Jawab |
|
||||
|------|------|----------------|
|
||||
| Product Owner | Tim RSSA | Prioritas fitur & validasi kebutuhan |
|
||||
| Fullstack Engineer | Akbar | Arsitektur, implementasi, & deployment |
|
||||
| UI/UX Designer | — | Desain antarmuka (dikerjakan oleh engineer) |
|
||||
| QA Engineer | — | Pengujian via Cypress & Vitest |
|
||||
| System Admin | Tim IT RSSA | Infrastruktur, server, & jaringan internal |
|
||||
|
||||
---
|
||||
|
||||
## 3. User Personas
|
||||
|
||||
### 🧑 Persona 1: Pasien Rawat Jalan
|
||||
- **Goals:** Mendapatkan nomor antrean dengan mudah, mengetahui posisi antrean, dan dipanggil tepat waktu
|
||||
- **Pain Points:** Tidak tahu urutan antrean, harus menunggu tanpa informasi, bingung harus ke mana
|
||||
- **Tech Level:** Rendah — menggunakan anjungan layar sentuh di rumah sakit
|
||||
|
||||
### 🧑 Persona 2: Petugas Loket
|
||||
- **Goals:** Memanggil pasien secara urut, memproses lebih dari satu loket secara paralel, melihat daftar antrean real-time
|
||||
- **Pain Points:** Antrean manual rawan konflik antar loket, sulit pantau siapa yang sudah dipanggil
|
||||
- **Tech Level:** Menengah — menggunakan PC/tablet di meja loket
|
||||
|
||||
### 🧑 Persona 3: Admin Klinik / Dokter
|
||||
- **Goals:** Melihat daftar pasien yang akan dilayani di kliniknya, memanggil pasien sesuai urutan, mencatat status kunjungan
|
||||
- **Pain Points:** Tidak tahu berapa pasien tersisa, data pasien tidak sinkron antar perangkat
|
||||
- **Tech Level:** Menengah
|
||||
|
||||
### 🧑 Persona 4: Superadmin / Admin IT
|
||||
- **Goals:** Mengelola master data (klinik, loket, penunjang, screen), mengatur hak akses user, memonitor seluruh antrean
|
||||
- **Pain Points:** Tidak ada dashboard terpusat, perubahan konfigurasi butuh restart sistem
|
||||
- **Tech Level:** Tinggi
|
||||
|
||||
---
|
||||
|
||||
## 4. User Stories
|
||||
|
||||
| ID | Epic | Story | Priority | Acceptance Criteria | Status |
|
||||
|----|------|-------|----------|---------------------|--------|
|
||||
| US-001 | Anjungan | Sebagai pasien, saya ingin mengambil nomor antrean via anjungan mandiri agar tidak perlu antri ke loket | 🔴 High | - [ ] Pasien dapat memilih klinik tujuan<br>- [ ] Sistem generate nomor antrean unik<br>- [ ] Tiket dicetak atau ditampilkan di layar | `Done` |
|
||||
| US-002 | Anjungan | Sebagai pasien eksekutif, saya ingin memilih sub-spesialis di anjungan agar antrean sesuai dokter yang dituju | 🔴 High | - [ ] Daftar subspesialis tampil berdasarkan klinik<br>- [ ] Pilihan tersimpan ke nomor antrean | `Done` |
|
||||
| US-003 | Check-in | Sebagai pasien, saya ingin check-in via scan QR agar kedatangan saya tercatat di sistem | 🔴 High | - [ ] QR scanner aktif via kamera<br>- [ ] Status pasien berubah menjadi "hadir"<br>- [ ] Notifikasi berhasil muncul | `Done` |
|
||||
| US-004 | Loket | Sebagai petugas loket, saya ingin memanggil pasien berikutnya agar antrean berjalan terurut | 🔴 High | - [ ] Tombol "Panggil Berikutnya" tersedia<br>- [ ] Nomor antrean tampil di display screen<br>- [ ] Sync real-time via WebSocket | `Done` |
|
||||
| US-005 | Klinik | Sebagai admin klinik, saya ingin melihat daftar pasien yang akan dilayani agar saya bisa mempersiapkan pelayanan | 🔴 High | - [ ] Daftar pasien tampil dengan status terkini<br>- [ ] Update otomatis tanpa refresh manual | `Done` |
|
||||
| US-006 | Monitoring | Sebagai superadmin, saya ingin melihat dashboard antrean seluruh unit agar dapat memantau kondisi operasional | 🟡 Medium | - [ ] Statistik antrean per unit tersedia<br>- [ ] Data diperbarui real-time | `Done` |
|
||||
| US-007 | Setting | Sebagai superadmin, saya ingin mengatur hak akses per user/group agar keamanan data terjaga | 🟡 Medium | - [ ] CRUD hak akses per role & group Keycloak<br>- [ ] Perubahan langsung efektif tanpa restart | `Done` |
|
||||
| US-008 | Penunjang | Sebagai admin penunjang, saya ingin mengelola antrean unit penunjang (lab, radiologi) agar terpisah dari antrean klinik | 🟡 Medium | - [ ] Antrean penunjang terpisah per unit<br>- [ ] Admin hanya melihat unit penunjangnya | `In Progress` |
|
||||
|
||||
---
|
||||
|
||||
## 5. Functional Requirements
|
||||
|
||||
### 5.1 Modul: Anjungan Mandiri
|
||||
- **FR-01:** Pasien dapat memilih klinik tujuan dari daftar klinik aktif
|
||||
- **FR-02:** Sistem mengenerate nomor antrean berformat `[KodeKlinik]-[Nomor]` (contoh: `PDL-001`)
|
||||
- **FR-03:** Anjungan mendukung pilihan subspesialis untuk klinik eksekutif
|
||||
- **FR-04:** Tiket antrean dapat dicetak via thermal printer
|
||||
- **FR-05:** Anjungan dapat dikonfigurasi per tipe (klinik, penunjang, klinik ruang)
|
||||
|
||||
### 5.2 Modul: Check-in Pasien
|
||||
- **FR-06:** Pasien dapat check-in mandiri via scan QR code
|
||||
- **FR-07:** Sistem memvalidasi QR dan mengupdate status kunjungan ke "hadir"
|
||||
- **FR-08:** Riwayat check-in pasien dapat dilihat oleh petugas
|
||||
|
||||
### 5.3 Modul: Loket
|
||||
- **FR-09:** Petugas loket dapat memanggil pasien berikutnya sesuai urutan antrean
|
||||
- **FR-10:** Setiap loket memiliki state antrean yang terisolasi (tidak interferensi antar loket)
|
||||
- **FR-11:** Petugas dapat skip, recall, atau selesaikan pasien
|
||||
- **FR-12:** Display nomor panggil sinkron via WebSocket ke layar display
|
||||
- **FR-12b:** Pemanggilan antrean dilengkapi dengan notifikasi suara (voice over) otomatis
|
||||
|
||||
### 5.4 Modul: Klinik / Dokter
|
||||
- **FR-13:** Admin klinik melihat daftar pasien berdasarkan klinik yang diampu
|
||||
- **FR-14:** Status pasien (menunggu, dipanggil, selesai) dapat diupdate
|
||||
- **FR-15:** Pemanggilan pasien di klinik sync ke display screen ruangan
|
||||
|
||||
### 5.5 Modul: Dashboard & Monitoring
|
||||
- **FR-16:** Dashboard menampilkan statistik antrean real-time (total, menunggu, selesai)
|
||||
- **FR-17:** Superadmin dapat melihat data seluruh unit sekaligus
|
||||
- **FR-18:** Data diperbarui via WebSocket + polling fallback 30 detik
|
||||
|
||||
### 5.6 Modul: Setting & Master Data
|
||||
- **FR-19:** CRUD Master Klinik, Loket, Penunjang, Klinik Ruang, Screen
|
||||
- **FR-20:** Manajemen hak akses berbasis Keycloak role & group
|
||||
- **FR-21:** Konfigurasi screen display untuk setiap loket/klinik
|
||||
- **FR-22:** Manajemen user login dan sesi
|
||||
|
||||
### 5.7 Modul: Penunjang
|
||||
- **FR-23:** Antrean unit penunjang (lab, radiologi, dll) dikelola terpisah
|
||||
- **FR-24:** Admin penunjang hanya mengakses unit yang menjadi tanggung jawabnya
|
||||
|
||||
---
|
||||
|
||||
## 6. Non-Functional Requirements
|
||||
|
||||
| Kategori | Requirement | Target |
|
||||
|----------|-------------|--------|
|
||||
| Performance | WebSocket latency update antrean | < 2 detik |
|
||||
| Performance | Waktu load halaman utama | < 3 detik |
|
||||
| Performance | Polling fallback interval | 30 detik |
|
||||
| Security | Auth method | Keycloak SSO (OAuth 2.0 / OIDC) |
|
||||
| Security | Session duration | 1 jam (configurable) |
|
||||
| Security | Hak akses berbasis role & group | Role-Based + Group-Based |
|
||||
| Availability | Uptime jam operasional (06.00–21.00) | > 99.5% |
|
||||
| Accessibility | Anjungan — touch target size | > 44px (mudah dioperasikan pasien) |
|
||||
| Scalability | Jumlah koneksi WebSocket simultan | > 50 device sekaligus |
|
||||
| Compatibility | Browser support | Chrome, Edge (terbaru) |
|
||||
| Network | Operasi di jaringan LAN internal RS | ✅ Fully LAN-based |
|
||||
|
||||
---
|
||||
|
||||
## 7. Technical Specifications
|
||||
|
||||
### 7.1 Tech Stack
|
||||
|
||||
| Layer | Teknologi |
|
||||
|-------|-----------|
|
||||
| Frontend Framework | Nuxt 3 (SSR — server-side rendering) |
|
||||
| UI Framework | Vue 3 · Vuetify 3 |
|
||||
| Language | TypeScript · JavaScript |
|
||||
| State Management | Pinia + pinia-plugin-persistedstate |
|
||||
| Styling | SCSS (Vuetify override) + Material Design Icons |
|
||||
| Real-time | WebSocket (native browser API via `useWebSocket.ts`) |
|
||||
| Auth | Keycloak SSO (OAuth 2.0 / OIDC) |
|
||||
| Charts | Chart.js · vue-chartjs · nuxt-charts |
|
||||
| QR | html5-qrcode (scanner) · qrcode.vue (generator) |
|
||||
| Print | Thermal printer via `useThermalPrint.ts` |
|
||||
| Date | Day.js |
|
||||
| Icons | FontAwesome · Material Design Icons |
|
||||
| Testing | Vitest · Cypress |
|
||||
| Fonts | Inter (Google Fonts) |
|
||||
|
||||
### 7.2 Backend APIs (Eksternal)
|
||||
|
||||
| Service | Base URL | Keterangan |
|
||||
|---------|----------|------------|
|
||||
| Visit API | `http://10.10.123.135:8084/api/v1` | Data kunjungan & antrean utama |
|
||||
| Antrian API (Klinik) | `http://10.10.123.140:8089/api/v1` | Verifikasi & data klinik/dokter |
|
||||
| WebSocket | `ws://10.10.123.135:8084/api/v1/ws` | Real-time queue update |
|
||||
|
||||
### 7.3 Arsitektur Sistem
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Nuxt 3 Frontend (SSR/CSR) │
|
||||
│ Pages · Components · Stores (Pinia) │
|
||||
│ Composables: useWebSocket · useQueue · │
|
||||
│ useCheckIn · useThermalPrint · useQRScanner │
|
||||
└──────────┬──────────────────┬────────────────┘
|
||||
│ $fetch / useFetch │ WebSocket
|
||||
┌──────────▼──────────┐ ┌────▼───────────────┐
|
||||
│ Nuxt Server (Nitro) │ │ WebSocket Server │
|
||||
│ server/api/ │ │ ws://10.10.123. │
|
||||
│ server/routes/ │ │ 135:8084/api/v1/ws│
|
||||
│ (Proxy + API layer) │ └────────────────────┘
|
||||
└──────────┬──────────┘
|
||||
│ HTTP Proxy (CORS bypass)
|
||||
┌──────────▼──────────────────────────────────┐
|
||||
│ Backend Services (Eksternal) │
|
||||
│ Visit API (8084) · Antrian API (8089) │
|
||||
│ Keycloak SSO (auth.rssa.top) │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 7.4 Proxy Routes (Nuxt Server)
|
||||
|
||||
| Proxy Path | Target Backend | Keterangan |
|
||||
|------------|---------------|------------|
|
||||
| `/visit-api/**` | `http://10.10.123.135:8084/api/v1/**` | Data kunjungan pasien |
|
||||
| `/klinik-api/**` | `http://10.10.123.140:8089/api/v1/**` | Data klinik & dokter |
|
||||
| `/stats-api/**` | `http://10.10.123.135:8084/api/v1/**` | Statistik dashboard |
|
||||
|
||||
### 7.5 Struktur Folder
|
||||
|
||||
```
|
||||
web-antrean/
|
||||
├── assets/scss/ ← Global styles & variables
|
||||
├── components/ ← Reusable UI components
|
||||
├── composables/ ← useWebSocket, useQueue, useCheckIn,
|
||||
│ useThermalPrint, useQRScanner, useAPI
|
||||
├── layouts/ ← Layout default & admin
|
||||
├── middleware/ ← Auth & permission guards
|
||||
├── pages/ ← Semua halaman (lihat seksi 8.1)
|
||||
├── server/
|
||||
│ ├── api/ ← Internal API (auth, hak-akses, queue, users)
|
||||
│ └── routes/ ← Proxy routes (visit-api, klinik-api, stats-api)
|
||||
├── stores/ ← Pinia stores (queue, clinic, loket, dll)
|
||||
├── types/ ← TypeScript interfaces & types
|
||||
├── public/ ← Static assets (favicon, logo)
|
||||
├── nuxt.config.ts ← Konfigurasi utama Nuxt
|
||||
└── .env ← Environment variables (tidak di-commit)
|
||||
```
|
||||
|
||||
### 7.6 Internal API Endpoints (Nuxt Nitro)
|
||||
|
||||
| Method | Endpoint | Deskripsi | Auth |
|
||||
|--------|----------|-----------|------|
|
||||
| GET | `/api/permission` | Ambil permissions berdasarkan role & group | ✅ |
|
||||
| GET | `/api/hak-akses` | Daftar semua hak akses | ✅ |
|
||||
| POST | `/api/hak-akses` | Buat hak akses baru | ✅ |
|
||||
| PATCH | `/api/hak-akses/:id` | Update hak akses | ✅ |
|
||||
| DELETE | `/api/hak-akses/:id` | Hapus hak akses | ✅ |
|
||||
| GET | `/api/users/list` | Daftar user dari Keycloak | ✅ |
|
||||
| POST | `/api/auth/login` | Login via Keycloak SSO | ❌ |
|
||||
| POST | `/api/auth/logout` | Logout & invalidate session | ✅ |
|
||||
| POST | `/api/external/validate-token` | Validasi JWT token eksternal | ✅ |
|
||||
| GET | `/api/config/...` | Konfigurasi aplikasi | ✅ |
|
||||
|
||||
### 7.7 TypeScript Types Utama
|
||||
|
||||
```typescript
|
||||
// types/user.ts
|
||||
export interface User {
|
||||
id: string
|
||||
namaLengkap: string
|
||||
namaUser: string
|
||||
email: string
|
||||
tipeUser: string
|
||||
roles: string[]
|
||||
groups: string[]
|
||||
lastLogin: number
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
// types/queue.ts
|
||||
export interface QueuePatient {
|
||||
nomorAntrean: string
|
||||
namaPassien: string
|
||||
noRM: string
|
||||
klinik: string
|
||||
status: 'menunggu' | 'dipanggil' | 'selesai' | 'skip'
|
||||
loketId?: string
|
||||
subspesialis?: string
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
// types/permission.ts
|
||||
export interface Permission {
|
||||
id: number
|
||||
pagename: string
|
||||
read: boolean
|
||||
create: boolean
|
||||
update: boolean
|
||||
delete: boolean
|
||||
disable: boolean
|
||||
active: boolean
|
||||
level: number
|
||||
parent: number | null
|
||||
}
|
||||
|
||||
// types/api.ts
|
||||
export interface ApiResponse<T> {
|
||||
data: T
|
||||
message: string
|
||||
success: boolean
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. UI/UX
|
||||
|
||||
### 8.1 Halaman & Routes
|
||||
|
||||
| Halaman | Route | Auth | Role |
|
||||
|---------|-------|------|------|
|
||||
| Landing / Home | `/` | ❌ | Public |
|
||||
| Login | `/login-page` | ❌ | Public |
|
||||
| Dashboard | `/dashboard` | ✅ | Superadmin |
|
||||
| Admin Klinik | `/admin-klinik` | ✅ | Admin Klinik |
|
||||
| Admin Loket | `/admin-loket` | ✅ | Admin Loket |
|
||||
| Admin Penunjang | `/admin-penunjang` | ✅ | Admin Penunjang |
|
||||
| Buat Antrean | `/buat-antrean` | ✅ | Loket |
|
||||
| Klinik Ruang Admin | `/klinik-ruang-admin` | ✅ | Admin Klinik Ruang |
|
||||
| Ranap Admin | `/ranap-admin` | ✅ | Admin Ranap |
|
||||
| Anjungan Utama | `/anjungan` | ❌ | Kiosk |
|
||||
| Admin Anjungan | `/anjungan/admin-anjungan` | ✅ | Superadmin |
|
||||
| Antrian Klinik | `/anjungan/antrian-klinik` | ❌ | Kiosk |
|
||||
| Antrian Klinik Ruang | `/anjungan/antrian-klinik-ruang` | ❌ | Kiosk |
|
||||
| Antrian Penunjang | `/anjungan/antrian-penunjang` | ❌ | Kiosk |
|
||||
| Antrean Masuk Screen | `/anjungan/antrean-masuk` | ❌ | Display |
|
||||
| Check In Pasien | `/check-in-pasien/check-in` | ❌ | Kiosk |
|
||||
| Data Pasien | `/data-pasien` | ✅ | Admin |
|
||||
| Edit Data Pasien | `/data-pasien/edit/:id` | ✅ | Admin |
|
||||
| Monitoring Pasien | `/monitoring-pasien/monitoring-pasien` | ✅ | Admin |
|
||||
| Detail Pasien | `/monitoring-pasien/pasien/:id` | ✅ | Admin |
|
||||
| Profil | `/profile/profil` | ✅ | Semua |
|
||||
| User Login | `/setting/user-login` | ✅ | Superadmin |
|
||||
| Hak Akses | `/setting/hak-akses` | ✅ | Superadmin |
|
||||
| Master Klinik | `/setting/master-klinik` | ✅ | Superadmin |
|
||||
| Master Klinik Ruang | `/setting/master-klinik-ruang` | ✅ | Superadmin |
|
||||
| Master Loket | `/setting/master-loket` | ✅ | Superadmin |
|
||||
| Master Penunjang | `/setting/master-penunjang` | ✅ | Superadmin |
|
||||
| Screen Settings | `/setting/screen` | ✅ | Superadmin |
|
||||
| Verifikasi Akun | `/verifikasi-akun/verifikasi-akun` | ❌ | Public |
|
||||
| Detail Akun Verifikasi | `/verifikasi-akun/detail-akun` | ✅ | Admin |
|
||||
|
||||
### 8.2 Breakpoints
|
||||
|
||||
| Nama | Size | Keterangan |
|
||||
|------|------|------------|
|
||||
| Mobile | < 640px | Tidak diprioritaskan (akses via PC/tablet) |
|
||||
| Tablet | 640–1024px | Anjungan & loket (tablet) |
|
||||
| Desktop | > 1024px | Admin & monitoring |
|
||||
|
||||
### 8.3 Design System
|
||||
- **Framework UI:** Vuetify 3 (Material Design 3)
|
||||
- **Font:** Inter (400, 500, 600, 700)
|
||||
- **Icons:** Material Design Icons (`@mdi/font`) + FontAwesome
|
||||
- **Color scheme:** Mengikuti theme Vuetify (light/dark configurable)
|
||||
- **SCSS Variables:** Didefinisikan di `assets/scss/_variables.scss` & `_colors.scss`
|
||||
|
||||
---
|
||||
|
||||
## 9. Risks
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigasi |
|
||||
|------|-----------|--------|----------|
|
||||
| WebSocket disconnect saat jaringan LAN tidak stabil | Tinggi | Tinggi | Polling fallback 30 detik + auto-reconnect logic |
|
||||
| Backend API eksternal down (Visit API / Antrian API) | Sedang | Tinggi | Error handling graceful, retry logic, blacklist endpoint gagal |
|
||||
| Konflik antrean antar loket (race condition) | Sedang | Tinggi | State isolasi per `loketId`, strict filtering di `processNextQueue` |
|
||||
| Sesi Keycloak expire saat jam operasional | Sedang | Sedang | Session duration dikonfigurasi 1 jam, refresh token otomatis |
|
||||
| Thermal printer tidak kompatibel di semua device | Rendah | Sedang | Uji di device target sebelum go-live, fallback ke tampilan layar |
|
||||
| Data antrean tidak sinkron antar display screen | Sedang | Tinggi | WebSocket deterministic client ID + polling fallback |
|
||||
| Kapasitas WebSocket server saat pasien peak | Rendah | Tinggi | Monitor jumlah koneksi, koordinasi dengan tim backend |
|
||||
|
||||
---
|
||||
|
||||
## 10. Deployment
|
||||
|
||||
| Item | Detail |
|
||||
|------|--------|
|
||||
| Server | VPS / Server internal RSSA |
|
||||
| Domain dev | `http://10.10.150.175:3000` |
|
||||
| Domain staging | `https://antrean.dev.rssa.id` |
|
||||
| Domain prod | `https://antrean.rssa.id` |
|
||||
| Containerisasi | Docker + docker-compose |
|
||||
| Auth Server | Keycloak (`https://auth.rssa.top/realms/sandbox`) |
|
||||
| Build command | `nuxt build` |
|
||||
| Start command | `node .output/server/index.mjs` |
|
||||
|
||||
---
|
||||
|
||||
## 11. Changelog
|
||||
|
||||
| Versi | Tanggal | Author | Perubahan |
|
||||
|-------|---------|--------|-----------|
|
||||
| 1.0.0 | 2026-05-25 | Akbar | Initial PRD — dibuat berdasarkan kondisi project aktual |
|
||||
+394
@@ -0,0 +1,394 @@
|
||||
# ✅ QMD — Quality Management Document
|
||||
|
||||
**Project:** Web Antrean — Sistem Manajemen Antrean Rawat Jalan RSSA
|
||||
**Version:** 1.0.0
|
||||
**Author:** Akbar
|
||||
**Stack:** JavaScript · Vue 3 · TypeScript · Nuxt 3 · Vuetify 3 · Pinia
|
||||
**Last Updated:** 2026-05-25
|
||||
|
||||
---
|
||||
|
||||
## 🧠 AI Prompt — Cara Menggunakan Dokumen Ini
|
||||
|
||||
> Salin prompt berikut ke Claude untuk membantu QA planning:
|
||||
|
||||
```
|
||||
Kamu adalah QA engineer senior untuk project Nuxt 3 + Vue 3 + TypeScript.
|
||||
Project: Web Antrean — Sistem manajemen antrean rawat jalan RSSA.
|
||||
|
||||
Fitur utama:
|
||||
- Anjungan mandiri (kiosk registrasi pasien, pilih klinik/subspesialis)
|
||||
- Check-in pasien via QR code
|
||||
- Manajemen antrean loket (panggil, skip, recall, selesai)
|
||||
- Manajemen antrean klinik & penunjang
|
||||
- Real-time sync via WebSocket + polling fallback
|
||||
- Dashboard monitoring & statistik
|
||||
- Setting: master data (klinik, loket, penunjang, screen), hak akses (Keycloak role/group)
|
||||
- Cetak tiket via thermal printer
|
||||
|
||||
Bantu aku membuat:
|
||||
1. Test plan lengkap (unit, integration, E2E)
|
||||
2. Test cases untuk fitur di atas
|
||||
3. Definition of Done (DoD) per story
|
||||
4. Checklist code review untuk Vue 3 + TypeScript
|
||||
5. Standar kualitas kode (naming, linting, typing)
|
||||
|
||||
Format dalam tabel Markdown. Tool: Vitest, Cypress, Vue Test Utils, happy-dom.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Quality Objectives
|
||||
|
||||
| Objektif | Target | Cara Ukur |
|
||||
|----------|--------|-----------|
|
||||
| Test Coverage (Unit) | > 60% | Vitest coverage report (`npx vitest run --coverage`) |
|
||||
| Bug Rate (prod) | < 5 bug/sprint | Manual tracking / issue log |
|
||||
| Code Review | 100% PR di-review | Git workflow |
|
||||
| TypeScript strict | Minimal `any` type | ESLint + `tsc --noEmit` |
|
||||
| WebSocket Reliability | > 99% message delivery | Monitoring log + polling fallback |
|
||||
| Page Load | < 3 detik | Lighthouse / manual timing di jaringan LAN |
|
||||
|
||||
---
|
||||
|
||||
## 2. Testing Strategy
|
||||
|
||||
### 2.1 Piramida Testing
|
||||
```
|
||||
▲
|
||||
/E2E\ ← Cypress (browser real)
|
||||
/──────\
|
||||
/ Integ \ ← Vitest + Nuxt Test Utils
|
||||
/──────────\
|
||||
/ Unit Test \ ← Vitest + Vue Test Utils + happy-dom
|
||||
/______________\
|
||||
```
|
||||
|
||||
### 2.2 Test Toolchain
|
||||
|
||||
| Tipe | Tool | Config File | Status |
|
||||
|------|------|-------------|--------|
|
||||
| Unit | Vitest + happy-dom | `vitest.config.ts` | ✅ Terkonfigurasi |
|
||||
| Component | Vue Test Utils (`@vue/test-utils`) | — | ✅ Terinstal |
|
||||
| E2E | Cypress | `cypress.config.ts` | ✅ Terkonfigurasi |
|
||||
| Component (Cypress) | Cypress Component Testing | `cypress.config.ts` → `component` | ✅ Terkonfigurasi |
|
||||
| Linting | ESLint (Nuxt preset) | `eslint.config.mjs` | ✅ Terkonfigurasi |
|
||||
| Type Check | TypeScript (via Nuxt) | `tsconfig.json` → extends `.nuxt/tsconfig.json` | ✅ |
|
||||
| Test Environment | happy-dom | `vitest.config.ts` → `environment: 'happy-dom'` | ✅ |
|
||||
|
||||
### 2.3 Test Commands
|
||||
|
||||
```bash
|
||||
# Unit & Component Tests
|
||||
npm run test # vitest (watch mode)
|
||||
npm run test:ui # vitest --ui (browser UI)
|
||||
|
||||
# E2E Tests
|
||||
npm run cypress:open # Cypress interactive
|
||||
npm run cypress:run # Cypress headless
|
||||
|
||||
# Linting
|
||||
npx eslint .
|
||||
|
||||
# Type Check
|
||||
npx nuxi typecheck
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Test Cases
|
||||
|
||||
### 3.1 Unit Tests — Composables
|
||||
|
||||
| ID | Modul | Test Case | Expected | Status |
|
||||
|----|-------|-----------|----------|--------|
|
||||
| UT-001 | `useAuth.ts` | Login — mengembalikan user data setelah Keycloak auth | `user.value` tidak null, memiliki `id`, `roles`, `groups` | `Todo` |
|
||||
| UT-002 | `useAuth.ts` | Logout — session di-clear dan redirect ke login page | `user.value` menjadi null, navigasi ke `/LoginPage` | `Todo` |
|
||||
| UT-003 | `useQueue.js` | `processNextQueue()` — mengambil pasien berikutnya sesuai loketId | Return pasien dengan status `menunggu` dan `loketId` yang cocok | `Todo` |
|
||||
| UT-004 | `useQueue.js` | `processNextQueue()` — skip pasien dari loket lain | Pasien dari loket lain tidak terproses | `Todo` |
|
||||
| UT-005 | `useWebSocket.ts` | Koneksi sukses — state connected | `isConnected.value === true` setelah open event | `Todo` |
|
||||
| UT-006 | `useWebSocket.ts` | Auto-reconnect setelah disconnect | Reconnect attempt dalam < 5 detik | `Todo` |
|
||||
| UT-007 | `useCheckIn.ts` | Check-in valid QR — update status pasien | Status pasien berubah ke `hadir`, return success | `Todo` |
|
||||
| UT-008 | `useCheckIn.ts` | Check-in invalid QR — error handling | Return error message, status tidak berubah | `Todo` |
|
||||
| UT-009 | `useQRScanner.ts` | Inisialisasi scanner — kamera aktif | Scanner instance terbuat tanpa error | `Todo` |
|
||||
| UT-010 | `useHakAkses.ts` | Fetch permissions — mapping role ke menu | Menu permissions sesuai dengan role & group user | `Todo` |
|
||||
| UT-011 | `useThermalPrint.ts` | Generate tiket — format nomor antrean benar | Output mengandung kode klinik + nomor urut | `Todo` |
|
||||
| UT-012 | `useClinicAPI.ts` | Fetch daftar klinik — return data klinik aktif | Array klinik tidak kosong, setiap item punya `id` & `nama` | `Todo` |
|
||||
|
||||
### 3.2 Unit Tests — Stores (Pinia)
|
||||
|
||||
| ID | Modul | Test Case | Expected | Status |
|
||||
|----|-------|-----------|----------|--------|
|
||||
| UT-013 | `queueStore.js` | `allPatients` — menyimpan & mengembalikan daftar pasien | State `allPatients` terisi array setelah fetch | `Todo` |
|
||||
| UT-014 | `queueStore.js` | `currentProcessingPatient` — isolasi per loket | Setiap loket punya key unik di persisted state | `Todo` |
|
||||
| UT-015 | `clinicStore.js` | Fetch daftar klinik dari API | `clinics` terisi data dari klinik-api | `Todo` |
|
||||
| UT-016 | `doctorStore.js` | Blacklist endpoint gagal | Endpoint yang 500 di-blacklist, tidak di-retry spam | `Todo` |
|
||||
| UT-017 | `loketStore.js` | State loket terisolasi antar loket | Operasi di loket A tidak mempengaruhi loket B | `Todo` |
|
||||
| UT-018 | `masterStore.js` | CRUD master klinik | Create, read, update, delete berjalan tanpa error | `Todo` |
|
||||
| UT-019 | `permissionStore.ts` | Load permission sesuai role | Permission loaded dan accessible via getter | `Todo` |
|
||||
|
||||
### 3.3 Unit Tests — Middleware
|
||||
|
||||
| ID | Modul | Test Case | Expected | Status |
|
||||
|----|-------|-----------|----------|--------|
|
||||
| UT-020 | `auth.ts` | User belum login → redirect ke `/LoginPage` | `navigateTo('/LoginPage')` dipanggil | `Todo` |
|
||||
| UT-021 | `auth.ts` | User sudah login → lanjut ke halaman tujuan | Tidak ada redirect | `Todo` |
|
||||
| UT-022 | `guest.ts` | User sudah login akses `/LoginPage` → redirect ke `/dashboard` | `navigateTo('/dashboard')` dipanggil | `Todo` |
|
||||
| UT-023 | `permissions.ts` | User tanpa akses ke halaman → redirect/block | Akses ditolak, redirect ke halaman authorized | `Todo` |
|
||||
| UT-024 | `checkPageAccess.ts` | Validasi hak akses per halaman berdasarkan group | Halaman hanya bisa diakses sesuai permission | `Todo` |
|
||||
|
||||
### 3.4 Component Tests
|
||||
|
||||
| ID | Komponen | Skenario | Expected | Status |
|
||||
|----|----------|----------|----------|--------|
|
||||
| CT-001 | `PatientCard.vue` | Render data pasien lengkap | Nama, noRM, nomor antrean, status, subspesialis tampil | `Todo` |
|
||||
| CT-002 | `PatientCard.vue` | Status badge warna sesuai status | `menunggu` = kuning, `dipanggil` = biru, `selesai` = hijau | `Todo` |
|
||||
| CT-003 | `CurrentPatientCard.vue` | Tampilkan pasien yang sedang diproses | Data pasien aktif tampil dengan aksi (selesai, skip) | `Todo` |
|
||||
| CT-004 | `QueueActionsCard.vue` | Tombol aksi antrean (panggil, skip, recall) | Semua tombol render dan emit event yang benar | `Todo` |
|
||||
| CT-005 | `TabelPatientData.vue` | Render tabel daftar pasien | Kolom: nama, noRM, antrean, status, aksi tampil benar | `Todo` |
|
||||
| CT-006 | `SideBar.vue` | Menu render sesuai hak akses user | Menu yang tidak diizinkan tidak tampil | `Todo` |
|
||||
| CT-007 | `PageHeader.vue` | Render judul halaman dan breadcrumb | Judul dan navigasi sesuai route aktif | `Todo` |
|
||||
| CT-008 | `AppSnackbar.vue` | Notifikasi muncul dan auto-dismiss | Snackbar tampil 3 detik lalu hilang | `Todo` |
|
||||
| CT-009 | `SelectionDialog.vue` | Dialog pilihan dengan konfirmasi | Pilihan terseleksi, emit event saat konfirmasi | `Todo` |
|
||||
| CT-010 | `ProfileMenu.vue` | Tampil info user dan tombol logout | Nama user tampil, klik logout memanggil `useAuth().logout()` | `Todo` |
|
||||
|
||||
### 3.5 E2E Tests (Cypress)
|
||||
|
||||
| ID | Flow | Steps | Expected | Status |
|
||||
|----|------|-------|----------|--------|
|
||||
| E2E-001 | Login | 1. Buka `/` 2. Redirect ke `/LoginPage` 3. Klik login Keycloak 4. Isi credentials | Redirect ke `/dashboard`, user session aktif | `Skeleton` |
|
||||
| E2E-002 | Anjungan — Ambil Antrean | 1. Buka `/anjungan` 2. Pilih klinik 3. Konfirmasi | Nomor antrean di-generate, tiket tampil | `Todo` |
|
||||
| E2E-003 | Anjungan Eksekutif — Pilih Subspesialis | 1. Buka `/anjungan` 2. Pilih klinik eksekutif 3. Pilih subspesialis 4. Konfirmasi | Antrean tercipta dengan subspesialis terpilih | `Todo` |
|
||||
| E2E-004 | Check-in QR | 1. Buka `/check-in-pasien/check-in` 2. Scan QR valid | Status pasien update ke "hadir", notifikasi sukses | `Todo` |
|
||||
| E2E-005 | Loket — Panggil Pasien | 1. Login sebagai admin loket 2. Buka `/admin-loket` 3. Klik "Panggil Berikutnya" | Pasien berikutnya tampil di current patient card | `Todo` |
|
||||
| E2E-006 | Loket — Skip & Recall | 1. Panggil pasien 2. Klik skip 3. Klik recall | Pasien di-skip lalu bisa di-recall kembali | `Todo` |
|
||||
| E2E-007 | Klinik — Lihat Daftar Pasien | 1. Login sebagai admin klinik 2. Buka `/admin-klinik` | Daftar pasien klinik tampil sesuai klinik user | `Todo` |
|
||||
| E2E-008 | Dashboard — Statistik | 1. Login sebagai superadmin 2. Buka `/dashboard` | Chart statistik dan data antrean tampil | `Todo` |
|
||||
| E2E-009 | Setting — CRUD Master Klinik | 1. Buka `/setting/master-klinik` 2. Tambah klinik 3. Edit 4. Hapus | Data klinik berhasil CRUD tanpa error | `Todo` |
|
||||
| E2E-010 | Setting — Hak Akses | 1. Buka `/setting/hak-akses` 2. Pilih role & group 3. Set permission 4. Simpan | Hak akses tersimpan dan efektif | `Todo` |
|
||||
| E2E-011 | WebSocket Sync | 1. Buka admin loket di tab A 2. Buka display screen di tab B 3. Panggil pasien di tab A | Tab B menampilkan nomor panggil dalam < 2 detik | `Todo` |
|
||||
| E2E-012 | Auth Guard | 1. Tanpa login, akses `/dashboard` | Redirect ke `/LoginPage` | `Todo` |
|
||||
|
||||
---
|
||||
|
||||
## 4. Definition of Done (DoD)
|
||||
|
||||
Sebuah task/story dianggap **Done** jika:
|
||||
|
||||
- [ ] Code sudah diimplementasi dan berjalan di dev server
|
||||
- [ ] Unit test ditulis untuk logic kritis (composable, store)
|
||||
- [ ] TypeScript: tidak ada error pada `npx nuxi typecheck`
|
||||
- [ ] ESLint: tidak ada error (`npx eslint .`)
|
||||
- [ ] Code review dilakukan minimal 1 orang
|
||||
- [ ] Tested manual di browser Chrome (target utama)
|
||||
- [ ] WebSocket sync diverifikasi antar device (jika fitur terkait real-time)
|
||||
- [ ] Responsive layout dicek untuk tablet (anjungan) dan desktop (admin)
|
||||
- [ ] Tidak ada `console.log` debugging yang tertinggal di production path
|
||||
- [ ] State antrean terisolasi per loket (jika fitur terkait loket)
|
||||
- [ ] Polling fallback 30 detik berfungsi sebagai safety net
|
||||
|
||||
---
|
||||
|
||||
## 5. Code Review Checklist
|
||||
|
||||
### General
|
||||
- [ ] Logic mudah dibaca dan dipahami
|
||||
- [ ] Tidak ada dead code atau file `.txt` backup yang tersisa
|
||||
- [ ] Error handling ada di semua `$fetch` / `useFetch` call
|
||||
- [ ] Tidak ada hardcoded IP/URL (gunakan `.env` + `runtimeConfig`)
|
||||
- [ ] Tidak ada `console.log` di high-frequency path (WebSocket handler, polling loop)
|
||||
|
||||
### Vue 3 + TypeScript
|
||||
- [ ] `<script setup>` digunakan (Composition API)
|
||||
- [ ] Props & emits memiliki type yang jelas
|
||||
- [ ] Minimal penggunaan `any` type (justified jika ada)
|
||||
- [ ] Composables dipakai untuk logic reusable (`composables/use*.ts`)
|
||||
- [ ] Reactive data menggunakan `ref()` / `reactive()` dengan benar
|
||||
- [ ] `watch` / `computed` digunakan daripada manual mutation
|
||||
|
||||
### Nuxt 3 Specific
|
||||
- [ ] Data fetching pakai `$fetch` / `useFetch` / `useAsyncData`
|
||||
- [ ] Middleware auth terpasang di route yang memerlukan login
|
||||
- [ ] Server routes (proxy) mengikuti konvensi `server/routes/[prefix]/[...].ts`
|
||||
- [ ] Internal API mengikuti konvensi `server/api/[resource].[method].ts`
|
||||
- [ ] Environment variables diakses via `useRuntimeConfig()`
|
||||
|
||||
### Pinia Store
|
||||
- [ ] Store menggunakan `defineStore()` dengan naming `use[Name]Store`
|
||||
- [ ] State yang perlu persisten menggunakan `pinia-plugin-persistedstate`
|
||||
- [ ] Key persisted state unik per loket/klinik (hindari konflik antar instance)
|
||||
- [ ] `allPatients` tetap single source of truth (tidak duplikasi state)
|
||||
|
||||
### WebSocket
|
||||
- [ ] Client ID deterministic (bukan random) untuk targetable messaging
|
||||
- [ ] Auto-reconnect logic aktif
|
||||
- [ ] Polling fallback 30 detik sebagai safety net
|
||||
- [ ] Event handler tidak melakukan full re-render (surgical update)
|
||||
|
||||
---
|
||||
|
||||
## 6. Standar Kode
|
||||
|
||||
### Naming Convention
|
||||
|
||||
| Tipe | Konvensi | Contoh (Aktual) |
|
||||
|------|----------|-----------------|
|
||||
| Page (Vue) | PascalCase | `Dashboard.vue`, `AdminLoket.vue` |
|
||||
| Component | PascalCase | `PatientCard.vue`, `QueueActionsCard.vue` |
|
||||
| Composable | camelCase + `use` prefix | `useWebSocket.ts`, `useQueue.js` |
|
||||
| Store (Pinia) | camelCase + `Store` suffix | `queueStore.js`, `clinicStore.js` |
|
||||
| Middleware | camelCase | `auth.ts`, `permissions.ts` |
|
||||
| Type/Interface | PascalCase | `User`, `Permission`, `ApiResponse<T>` |
|
||||
| Constants | SCREAMING_SNAKE | `API_BASE_URL`, `WS_API_URL` |
|
||||
| CSS Class | kebab-case | `.patient-card`, `.queue-actions` |
|
||||
| Server API | `[resource].[method].ts` | `permission.get.ts`, `validate-token.post.ts` |
|
||||
| Server Proxy Route | `[...].ts` di folder prefix | `server/routes/visit-api/[...].ts` |
|
||||
|
||||
### File Organization Rules
|
||||
|
||||
```
|
||||
# ✅ Good — composable terpisah untuk concern berbeda
|
||||
composables/
|
||||
useAuth.ts ← Authentication logic
|
||||
useWebSocket.ts ← WebSocket connection management
|
||||
useQueue.js ← Queue business logic
|
||||
useCheckIn.ts ← Check-in flow
|
||||
useThermalPrint.ts ← Thermal printer integration
|
||||
|
||||
# ✅ Good — komponen terorganisir per fitur
|
||||
components/
|
||||
common/ ← Reusable (AppSnackbar, Avatar, PageHeader)
|
||||
layout/ ← Layout (SideBar, ProfileMenu)
|
||||
features/
|
||||
queue/ ← PatientCard, CurrentPatientCard, QueueActionsCard
|
||||
antrean/ ← Komponen anjungan
|
||||
master/ ← Komponen setting master data
|
||||
monitoring/ ← Komponen monitoring pasien
|
||||
|
||||
# ❌ Bad — logic besar langsung di <script setup> page
|
||||
pages/AdminLoket.vue ← Jangan taruh >100 baris logic di sini, extract ke composable
|
||||
```
|
||||
|
||||
### Code Pattern — Composable
|
||||
|
||||
```typescript
|
||||
// ✅ Good — typed composable dengan error handling
|
||||
export function useClinicAPI() {
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
async function fetchClinics(): Promise<Clinic[]> {
|
||||
try {
|
||||
const data = await $fetch('/klinik-api/clinics')
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch clinics:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
return { fetchClinics }
|
||||
}
|
||||
|
||||
// ❌ Bad — untyped, no error handling
|
||||
export function useClinicAPI() {
|
||||
async function fetchClinics() {
|
||||
const data = await $fetch('/klinik-api/clinics') // bisa crash
|
||||
return data
|
||||
}
|
||||
return { fetchClinics }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. CI/CD Quality Gates
|
||||
|
||||
> **Catatan:** CI/CD belum diimplementasi. Berikut rencana pipeline saat siap.
|
||||
|
||||
```yaml
|
||||
# GitHub Actions — quality checks (planned)
|
||||
name: Quality Gate
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Type Check
|
||||
run: npx nuxi typecheck
|
||||
|
||||
- name: Lint
|
||||
run: npx eslint .
|
||||
|
||||
- name: Unit Tests
|
||||
run: npx vitest run --coverage
|
||||
|
||||
- name: Build
|
||||
run: npx nuxt build
|
||||
```
|
||||
|
||||
### Manual Quality Gate (Saat Ini)
|
||||
|
||||
Sebelum merge / deploy, lakukan checklist manual berikut:
|
||||
|
||||
- [ ] `npx nuxi typecheck` — tidak ada error
|
||||
- [ ] `npx eslint .` — tidak ada error
|
||||
- [ ] `npm run test` — semua test pass
|
||||
- [ ] `npm run build` — build sukses tanpa error
|
||||
- [ ] Test manual di browser Chrome di jaringan LAN (`http://10.10.150.175:3000`)
|
||||
- [ ] Verifikasi WebSocket sync antara admin & display screen
|
||||
- [ ] Pastikan Docker build berhasil (`docker compose build`)
|
||||
|
||||
---
|
||||
|
||||
## 8. Bug Severity Matrix
|
||||
|
||||
| Level | Deskripsi | Contoh di Web Antrean | SLA Fix |
|
||||
|-------|-----------|----------------------|---------|
|
||||
| 🔴 Critical | App crash / data loss / security breach | WebSocket down total → antrean tidak sync, Keycloak auth bypass, pasien kehilangan antrean | < 4 jam |
|
||||
| 🟠 High | Fitur utama tidak bisa dipakai | Tombol panggil pasien tidak berfungsi, anjungan tidak bisa generate antrean, QR scanner error | < 1 hari |
|
||||
| 🟡 Medium | Fitur minor terganggu, ada workaround | Thermal print gagal (pasien masih bisa lihat di layar), statistik dashboard delayed | < 3 hari |
|
||||
| 🟢 Low | UI/kosmetik, tidak mengganggu fungsi | Alignment card tidak rapi, warna badge sedikit off, tooltip tidak muncul | Backlog |
|
||||
|
||||
### Known Issues & Mitigasi
|
||||
|
||||
| Issue | Severity | Mitigasi Saat Ini |
|
||||
|-------|----------|-------------------|
|
||||
| WebSocket disconnect saat jaringan LAN tidak stabil | 🔴 | Auto-reconnect + polling fallback 30 detik |
|
||||
| Endpoint 500 menyebabkan request spam | 🟠 | Blacklist endpoint gagal di `doctorStore.js` |
|
||||
| `console.log` verbose di WebSocket handler | 🟢 | Dibersihkan di high-frequency path |
|
||||
| Race condition antar loket | 🟠 | State isolasi per `loketId` + unique storage key |
|
||||
| Mixed JS/TS — beberapa store masih `.js` | 🟢 | Migrasi bertahap ke `.ts` |
|
||||
|
||||
---
|
||||
|
||||
## 9. Technical Debt Tracker
|
||||
|
||||
| Item | Impact | Priority | Plan |
|
||||
|------|--------|----------|------|
|
||||
| Store files masih `.js` (queueStore, clinicStore, dll) | Type safety rendah | 🟡 Medium | Migrasi bertahap ke TypeScript |
|
||||
| `queueStore.js` terlalu besar (141KB) | Sulit maintain & test | 🟠 High | Pecah menjadi sub-stores per concern |
|
||||
| Tidak ada test coverage saat ini | Regresi tidak terdeteksi | 🟠 High | Mulai dari composable kritis |
|
||||
| CI/CD belum ada | Manual quality gate | 🟡 Medium | Setup GitHub Actions |
|
||||
| Beberapa file backup (`.txt`, `old_*.vue`) masih ada | Noise di codebase | 🟢 Low | Cleanup & gitignore |
|
||||
|
||||
---
|
||||
|
||||
## 10. Changelog
|
||||
|
||||
| Versi | Tanggal | Author | Perubahan |
|
||||
|-------|---------|--------|-----------|
|
||||
| 1.0.0 | 2026-05-25 | Akbar | Initial QMD — dibuat berdasarkan kondisi project aktual |
|
||||
@@ -77,6 +77,9 @@ export default defineNuxtConfig({
|
||||
// External API
|
||||
externalApiBaseUrl: process.env.EXTERNAL_API_BASE_URL || 'http://10.10.123.135:8084',
|
||||
externalApiTimeout: parseInt(process.env.EXTERNAL_API_TIMEOUT || '10000', 10),
|
||||
proxyClientOrigin: process.env.PROXY_CLIENT_ORIGIN || 'http://10.10.150.175:3000',
|
||||
proxyTargetHostFallback: process.env.PROXY_TARGET_HOST_FALLBACK || '10.10.123.135:8084',
|
||||
proxyTargetHostKlinikFallback: process.env.PROXY_TARGET_HOST_KLINIK_FALLBACK || '10.10.123.140:8089',
|
||||
|
||||
public: {
|
||||
authUrl: process.env.AUTH_ORIGIN,
|
||||
|
||||
+68
-14
@@ -236,7 +236,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
import { ref, computed, onMounted, onUnmounted } from "vue";
|
||||
import { useQueue } from "@/composables/useQueue";
|
||||
import { useQueueStore } from "@/stores/queueStore";
|
||||
import { useMasterStore } from "@/stores/masterStore";
|
||||
@@ -289,6 +289,16 @@ const currentDate = ref(
|
||||
})
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
queueStore.ensureInitialData();
|
||||
queueStore.initWebSocket('admin-klinik');
|
||||
queueStore.registerGlobalInterest();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
queueStore.unregisterGlobalInterest();
|
||||
});
|
||||
|
||||
const selectedStatus = ref("all");
|
||||
const searchQuery = ref("");
|
||||
const selectedFastTrack = ref(null);
|
||||
@@ -359,34 +369,74 @@ const nextQueueInfo = computed(() => {
|
||||
return null;
|
||||
});
|
||||
|
||||
const handlePatientAction = (action) => {
|
||||
if (currentProcessingPatient.value) {
|
||||
processPatient(currentProcessingPatient.value, action);
|
||||
const broadcastUpdate = async (callData = null) => {
|
||||
try {
|
||||
const displayClientIds = [];
|
||||
displayClientIds.push('admin-klinik'); // Broadcast to other AdminKlinik instances
|
||||
|
||||
// Broadcast to a few likely screen IDs to ensure all Anjungan Klinik screens get the trigger
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
displayClientIds.push(`anjungan-klinik-${i}`);
|
||||
}
|
||||
|
||||
console.log('📡 [AdminKlinik] Broadcasting update trigger to:', displayClientIds);
|
||||
|
||||
const payload = {
|
||||
triggerRefresh: true
|
||||
};
|
||||
|
||||
if (currentProcessingPatient.value && currentProcessingPatient.value.kodeKlinik) {
|
||||
payload.klinikId = currentProcessingPatient.value.kodeKlinik;
|
||||
}
|
||||
|
||||
if (callData) {
|
||||
payload.callEvent = {
|
||||
...callData,
|
||||
calledAt: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
await Promise.all(displayClientIds.map(clientId =>
|
||||
queueStore.sendViaPost({
|
||||
to_client: clientId,
|
||||
data: payload
|
||||
})
|
||||
));
|
||||
} catch (error) {
|
||||
console.error('❌ [AdminKlinik] Error broadcasting update:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCall = (count) => {
|
||||
const handlePatientAction = async (action) => {
|
||||
if (currentProcessingPatient.value) {
|
||||
await processPatient(currentProcessingPatient.value, action);
|
||||
broadcastUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCall = async (count) => {
|
||||
if (count === 1) {
|
||||
callNext();
|
||||
} else {
|
||||
callMultiplePatients(count);
|
||||
}
|
||||
broadcastUpdate();
|
||||
};
|
||||
|
||||
const handleTableAction = (item, action) => {
|
||||
processPatient(item, action);
|
||||
const handleTableAction = async (item, action) => {
|
||||
await processPatient(item, action);
|
||||
broadcastUpdate();
|
||||
};
|
||||
|
||||
const handleProcessNext = () => {
|
||||
processNextQueue();
|
||||
const handleProcessNext = async () => {
|
||||
await processNextQueue();
|
||||
broadcastUpdate();
|
||||
};
|
||||
|
||||
const handleCallPatient = () => {
|
||||
// TODO: Integrate text-to-speech library here
|
||||
// Example: speak(`Nomor antrian ${currentProcessingPatient.value?.noAntrian.split(" |")[0]}, silakan menuju ke loket`)
|
||||
if (currentProcessingPatient.value) {
|
||||
console.log('Calling patient:', currentProcessingPatient.value);
|
||||
// Placeholder for text-to-speech integration
|
||||
broadcastUpdate(JSON.parse(JSON.stringify(currentProcessingPatient.value)));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -399,10 +449,10 @@ const closeKlinikRuangDialog = () => {
|
||||
klinikRuangSearch.value = "";
|
||||
};
|
||||
|
||||
const buatAntreanKlinikRuang = (klinikRuang, ruang) => {
|
||||
const buatAntreanKlinikRuang = async (klinikRuang, ruang) => {
|
||||
if (!currentProcessingPatient.value) return;
|
||||
|
||||
const result = queueStore.createAntreanKlinikRuang(
|
||||
const result = await queueStore.createAntreanKlinikRuang(
|
||||
klinikRuang,
|
||||
ruang,
|
||||
currentProcessingPatient.value,
|
||||
@@ -413,6 +463,10 @@ const buatAntreanKlinikRuang = (klinikRuang, ruang) => {
|
||||
snackbarColor.value = result.success ? "success" : "error";
|
||||
snackbar.value = true;
|
||||
|
||||
if (result.success) {
|
||||
broadcastUpdate();
|
||||
}
|
||||
|
||||
closeKlinikRuangDialog();
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -54,9 +54,6 @@
|
||||
<div class="room-title">
|
||||
<v-icon color="warning-600" class="mr-2">mdi-door</v-icon>
|
||||
<span>{{ ruang.namaRuang }}</span>
|
||||
<v-chip size="small" class="ml-2" color="warning-600">
|
||||
Kamar : R.{{ ruang.nomorRuang }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</v-card-title>
|
||||
|
||||
@@ -809,6 +806,7 @@ const queueStore = useQueueStore();
|
||||
const masterStore = useMasterStore();
|
||||
const clinicStore = useClinicStore();
|
||||
const ruangStore = useRuangStore();
|
||||
const config = useRuntimeConfig();
|
||||
|
||||
const kodeKlinik = computed(() => {
|
||||
const kode = route.params.kodeKlinik;
|
||||
@@ -931,14 +929,11 @@ const filterOptionsList = {
|
||||
};
|
||||
|
||||
// WebSocket configuration
|
||||
const config = useRuntimeConfig();
|
||||
|
||||
// Generate a unique session suffix (random ID)
|
||||
const uniqueSessionSuffix = ref(process.client ? Math.random().toString(36).substring(2, 8) : '')
|
||||
|
||||
// WebSocket client ID for admin
|
||||
// Use a DETERMINISTIC client ID so that other pages can broadcast directly to this admin.
|
||||
// Format: admin-klinik-ruang-{kodeKlinik} — stable and targetable from any device.
|
||||
const adminClientId = computed(() => {
|
||||
return `admin-klinik-ruang-${kodeKlinik.value}-${uniqueSessionSuffix.value}`
|
||||
return `admin-klinik-ruang-${kodeKlinik.value}`
|
||||
})
|
||||
|
||||
const isConnected = computed(() => queueStore.isWsConnected);
|
||||
@@ -946,10 +941,10 @@ const sendViaPost = (data) => queueStore.sendViaPost(data);
|
||||
|
||||
const fetchAllData = async () => {
|
||||
if (!kodeKlinik.value) return;
|
||||
// console.log('🔄 AdminKlinikRuang refresh: Syncing data...');
|
||||
try {
|
||||
await queueStore.fetchPatientsForClinic(kodeKlinik.value);
|
||||
queueStore.ensureInitialData();
|
||||
queueStore.registerClinicInterest(kodeKlinik.value);
|
||||
// console.log('✅ AdminKlinikRuang refresh: Success');
|
||||
} catch (err) {
|
||||
console.error('❌ AdminKlinikRuang refresh error:', err);
|
||||
@@ -1624,6 +1619,9 @@ const broadcastUpdate = async () => {
|
||||
// Base broadcast ID
|
||||
anjunganClientIds.push(`anjungan-klinik-ruang-${klinikData.value.kodeKlinik}`);
|
||||
|
||||
// Broadcast to other AdminKlinikRuang instances for same clinic
|
||||
anjunganClientIds.push(`admin-klinik-ruang-${klinikData.value.kodeKlinik}`);
|
||||
|
||||
// Screen-specific IDs
|
||||
ruangList.value.forEach(r => {
|
||||
if (r.nomorScreen) {
|
||||
@@ -1716,7 +1714,7 @@ const handleCallPatientByTipe = async (ruang, tipeLayanan) => {
|
||||
visit_code: patient.barcode || patient.visitCode,
|
||||
visit_status_id: [visitStatusId]
|
||||
};
|
||||
const visitApiBase = '/visit-api';
|
||||
const visitApiBase = config.public.externalApiBaseUrl || 'http://10.10.123.135:8084/api/v1';
|
||||
const apiResponse = await fetch(`${visitApiBase}/visit/status/finish`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -1735,6 +1733,7 @@ const handleCallPatientByTipe = async (ruang, tipeLayanan) => {
|
||||
try {
|
||||
const anjunganClientIds = [];
|
||||
anjunganClientIds.push(`anjungan-klinik-ruang-${klinikData.value.kodeKlinik}`);
|
||||
anjunganClientIds.push(`admin-klinik-ruang-${klinikData.value.kodeKlinik}`);
|
||||
|
||||
if (ruang.nomorScreen) {
|
||||
const specificScreenId = `anjungan-klinik-ruang-${klinikData.value.kodeKlinik}-screen-${ruang.nomorScreen}`;
|
||||
@@ -1759,7 +1758,17 @@ const handleCallPatientByTipe = async (ruang, tipeLayanan) => {
|
||||
to_client: clientId,
|
||||
data: {
|
||||
noantrian: nomorAntrian,
|
||||
tipeLayanan: tipeLayanan
|
||||
klinikId: kodeKlinik.value,
|
||||
tipeLayanan: tipeLayanan,
|
||||
triggerRefresh: true,
|
||||
callKlinikEvent: {
|
||||
noantrian: nomorAntrian,
|
||||
barcode: updateData.barcode,
|
||||
kodeKlinik: kodeKlinik.value,
|
||||
tipeLayanan: tipeLayanan,
|
||||
lastCalledAt: updateData.lastCalledAt,
|
||||
nomorRuang: String(ruang.nomorRuang)
|
||||
}
|
||||
},
|
||||
};
|
||||
await sendViaPost(message);
|
||||
@@ -1941,23 +1950,25 @@ onMounted(async () => {
|
||||
await fetchAllData();
|
||||
|
||||
// 3. Centralized WebSocket & interest registration
|
||||
// initWebSocket is already called via the watcher on adminClientId
|
||||
// Explicitly init WS with deterministic ID so other pages can target this admin directly.
|
||||
if (kodeKlinik.value) {
|
||||
queueStore.registerClinicInterest(kodeKlinik.value);
|
||||
queueStore.initWebSocket(adminClientId.value);
|
||||
queueStore.registerClinicInterest(kodeKlinik.value);
|
||||
}
|
||||
});
|
||||
|
||||
// 4. Lifecycle cleanup (Keep outside async block to preserve context)
|
||||
let pollInterval;
|
||||
onMounted(() => {
|
||||
// Check for daily reset and poll data every minute
|
||||
// Safety-net polling: re-fetch every 30 seconds to catch any missed WS events.
|
||||
// This guarantees data is never more than 30 seconds stale.
|
||||
pollInterval = setInterval(() => {
|
||||
const didReset = queueStore.checkAndResetDaily();
|
||||
if (didReset) {
|
||||
console.log("🕒 [AdminKlinikRuang] 2 AM threshold reached. Data reset performed.");
|
||||
}
|
||||
fetchAllData();
|
||||
}, 60000);
|
||||
}, 30000); // 30 seconds
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
|
||||
+176
-35
@@ -304,6 +304,20 @@
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Check-In Confirmation Dialog -->
|
||||
<CheckInConfirmationDialog
|
||||
v-model="showCheckInConfirmDialog"
|
||||
:patient="currentProcessingPatient"
|
||||
@confirm="confirmCheckIn"
|
||||
/>
|
||||
|
||||
<!-- Unfinished Patient Dialog -->
|
||||
<UnfinishedPatientDialog
|
||||
v-model="showUnfinishedPatientDialog"
|
||||
:patient="currentProcessingPatient"
|
||||
@confirm="confirmUnfinishedAndProcess"
|
||||
/>
|
||||
|
||||
<!-- Snackbar -->
|
||||
<AppSnackbar
|
||||
v-model="snackbar"
|
||||
@@ -327,6 +341,8 @@ import PageHeader from "@/components/common/PageHeader.vue";
|
||||
import CurrentPatientCard from "@/components/features/queue/CurrentPatientCard.vue";
|
||||
import QueueActionsCard from "@/components/features/queue/QueueActionsCard.vue";
|
||||
import PatientDataTable from "@/components/features/queue/TabelPatientData.vue";
|
||||
import CheckInConfirmationDialog from "@/components/features/queue/CheckInConfirmationDialog.vue";
|
||||
import UnfinishedPatientDialog from "@/components/features/queue/UnfinishedPatientDialog.vue";
|
||||
import SelectionDialog from "@/components/common/SelectionDialog.vue";
|
||||
import AppSnackbar from "@/components/common/AppSnackbar.vue";
|
||||
import { useThermalPrint } from "@/composables/useThermalPrint";
|
||||
@@ -339,6 +355,13 @@ const loketStore = useLoketStore();
|
||||
const clinicStore = useClinicStore();
|
||||
const ruangStore = useRuangStore();
|
||||
const { printTicketFromPatient } = useThermalPrint();
|
||||
const config = useRuntimeConfig();
|
||||
|
||||
useHead({
|
||||
script: [
|
||||
{ src: "https://code.responsivevoice.org/responsivevoice.js?key=ZeMK8Joo" }
|
||||
]
|
||||
});
|
||||
|
||||
// Broadcast Channel
|
||||
let broadcastChannel = null;
|
||||
@@ -386,18 +409,20 @@ const {
|
||||
const changeKlinik = async (klinik) => {
|
||||
const result = await originalChangeKlinik(klinik);
|
||||
if (result.success) {
|
||||
// Global broadcast to update all displays and other admins
|
||||
broadcastUpdate();
|
||||
// If patient was moved to a different loket, include destination loket in broadcast
|
||||
const broadcastData = result.moved && result.patient?.loketId
|
||||
? { loketId: result.patient.loketId } // Destination loket will get notified
|
||||
: null;
|
||||
broadcastUpdate(broadcastData);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Generate a unique session suffix (random ID)
|
||||
const uniqueSessionSuffix = ref(process.client ? Math.random().toString(36).substring(2, 8) : '')
|
||||
|
||||
// Use a DETERMINISTIC client ID so that Anjungan can broadcast directly to this admin.
|
||||
// Format: admin-loket-{loketId} — stable and targetable from any device.
|
||||
const anjunganClientId = computed(() => {
|
||||
if (!loketId.value) return ''
|
||||
return `admin-loket-${loketId.value}-${uniqueSessionSuffix.value}`
|
||||
return `admin-loket-${loketId.value}`
|
||||
})
|
||||
|
||||
const fetchAllData = async () => {
|
||||
@@ -450,6 +475,9 @@ watch(anjunganClientId, (newClientId, oldClientId) => {
|
||||
})
|
||||
|
||||
// PERSISTENCE FIX: Ensure data exists on mount
|
||||
// Periodic polling interval ref (safety-net fallback)
|
||||
let periodicRefreshInterval = null;
|
||||
|
||||
onMounted(async () => {
|
||||
console.log("🚀 AdminLoket Component mounted");
|
||||
|
||||
@@ -470,6 +498,12 @@ onMounted(async () => {
|
||||
}
|
||||
}, 60000); // Check every minute
|
||||
|
||||
// Safety-net: Poll every 30 seconds to catch patients registered from Anjungan
|
||||
// even if a WebSocket notification was missed or not received.
|
||||
periodicRefreshInterval = setInterval(() => {
|
||||
fetchPatientsForCurrentLoket();
|
||||
}, 30000);
|
||||
|
||||
// Initialize and connect WebSocket (Centralized)
|
||||
queueStore.initWebSocket(anjunganClientId.value);
|
||||
|
||||
@@ -484,6 +518,7 @@ onMounted(async () => {
|
||||
|
||||
onUnmounted(() => {
|
||||
if (resetCheckInterval) clearInterval(resetCheckInterval);
|
||||
if (periodicRefreshInterval) clearInterval(periodicRefreshInterval);
|
||||
// WebSocket is now global, we might not want to disconnect on every unmount
|
||||
// if other pages are still open, but usually for Admin/Anjungan it's okay.
|
||||
// We keep it connected unless specified otherwise.
|
||||
@@ -494,7 +529,7 @@ const apiQuota = ref(null);
|
||||
// Fetch latest quota data specifically for this loket
|
||||
const fetchQuotaFromAPI = async () => {
|
||||
try {
|
||||
const apiBase = '/klinik-api';
|
||||
const apiBase = config.public.verificationApiBaseUrl || 'http://10.10.123.140:8089/api/v1';
|
||||
const response = await fetch(
|
||||
`${apiBase}/klinik/loket`,
|
||||
);
|
||||
@@ -559,10 +594,10 @@ const selectedFastTrack = ref(null);
|
||||
// Dialog Klinik Ruang
|
||||
const showKlinikRuangDialog = ref(false);
|
||||
const klinikRuangSearch = ref("");
|
||||
const activePanel = ref(null); // Tracks the open Klinik Ruang panel
|
||||
|
||||
// Confirmation Replace Dialog
|
||||
const activePanel = ref(null); // Additional Refs
|
||||
const showConfirmReplaceDialog = ref(false);
|
||||
const showCheckInConfirmDialog = ref(false);
|
||||
const showUnfinishedPatientDialog = ref(false);
|
||||
const pendingReplaceItem = ref(null);
|
||||
const pendingReplaceAction = ref(null);
|
||||
|
||||
@@ -875,9 +910,37 @@ const nextQueueInfo = computed(() => {
|
||||
return null;
|
||||
});
|
||||
|
||||
const handlePatientAction = (action) => {
|
||||
const handlePatientAction = async (action) => {
|
||||
if (currentProcessingPatient.value) {
|
||||
processPatient(currentProcessingPatient.value, action);
|
||||
if (action === 'check-in') {
|
||||
// Periksa apakah tiket antrean ruang atau penunjang sudah dibuat
|
||||
let allPatientsArray = [];
|
||||
if (queueStore.allPatients) {
|
||||
allPatientsArray = Array.isArray(queueStore.allPatients)
|
||||
? queueStore.allPatients
|
||||
: (queueStore.allPatients.value || []);
|
||||
}
|
||||
|
||||
const hasGeneratedNextQueue = allPatientsArray.some(p =>
|
||||
(p.sourcePatientNo && p.sourcePatientNo === currentProcessingPatient.value.no) ||
|
||||
(p.referencePatient && p.referencePatient === currentProcessingPatient.value.noAntrian)
|
||||
);
|
||||
|
||||
if (!hasGeneratedNextQueue) {
|
||||
showCheckInConfirmDialog.value = true;
|
||||
return; // Hentikan proses, tunggu konfirmasi user
|
||||
}
|
||||
}
|
||||
|
||||
await processPatient(currentProcessingPatient.value, action);
|
||||
broadcastUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
const confirmCheckIn = async () => {
|
||||
if (currentProcessingPatient.value) {
|
||||
await processPatient(currentProcessingPatient.value, 'check-in');
|
||||
broadcastUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -921,17 +984,51 @@ const handleProcessNext = () => {
|
||||
|
||||
const confirmAndProcess = (item, action) => {
|
||||
if (currentProcessingPatient.value) {
|
||||
let allPatientsArray = [];
|
||||
if (queueStore.allPatients) {
|
||||
allPatientsArray = Array.isArray(queueStore.allPatients)
|
||||
? queueStore.allPatients
|
||||
: (queueStore.allPatients.value || []);
|
||||
}
|
||||
|
||||
const hasGeneratedNextQueue = allPatientsArray.some(p =>
|
||||
(p.sourcePatientNo && p.sourcePatientNo === currentProcessingPatient.value.no) ||
|
||||
(p.referencePatient && p.referencePatient === currentProcessingPatient.value.noAntrian)
|
||||
);
|
||||
|
||||
pendingReplaceItem.value = item;
|
||||
pendingReplaceAction.value = action;
|
||||
showConfirmReplaceDialog.value = true;
|
||||
|
||||
if (hasGeneratedNextQueue) {
|
||||
showUnfinishedPatientDialog.value = true;
|
||||
} else {
|
||||
showConfirmReplaceDialog.value = true;
|
||||
}
|
||||
} else {
|
||||
executeProcess(item, action);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmUnfinishedAndProcess = async () => {
|
||||
if (currentProcessingPatient.value) {
|
||||
// Selesaikan pasien aktif
|
||||
await processPatient(currentProcessingPatient.value, 'check-in');
|
||||
|
||||
// Proses pasien berikutnya / yang dipilih
|
||||
executeProcess(pendingReplaceItem.value, pendingReplaceAction.value);
|
||||
|
||||
pendingReplaceItem.value = null;
|
||||
pendingReplaceAction.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const executeProcess = async (item, action) => {
|
||||
if (action === "next") {
|
||||
processNextQueue();
|
||||
await processNextQueue();
|
||||
// Auto-call setelah memproses antrean selanjutnya
|
||||
setTimeout(() => {
|
||||
handleCallPatient();
|
||||
}, 300);
|
||||
} else {
|
||||
await processPatient(item, action);
|
||||
// If action is process, auto-call the patient after a short delay
|
||||
@@ -955,40 +1052,70 @@ const handleConfirmReplace = () => {
|
||||
|
||||
const { sendViaPost } = queueStore;
|
||||
|
||||
const broadcastUpdate = async (callData = null) => {
|
||||
// Debounce timer to prevent multiple rapid broadcasts
|
||||
let _broadcastDebounceTimer = null;
|
||||
|
||||
const broadcastUpdate = async (extraData = null) => {
|
||||
// extraData can be:
|
||||
// - null: regular refresh (debounced)
|
||||
// - { noAntrian, ... }: a CALL event (immediate, bypass debounce)
|
||||
// - { loketId }: a MOVE event (immediate, bypass debounce, notify destination)
|
||||
|
||||
const isCallEvent = extraData && extraData.noAntrian; // Patient call
|
||||
const isMoveEvent = extraData && extraData.loketId && !extraData.noAntrian; // Klinik move
|
||||
const isImmediate = isCallEvent || isMoveEvent;
|
||||
|
||||
// Debounce only plain refresh events
|
||||
if (!isImmediate) {
|
||||
if (_broadcastDebounceTimer) return; // Already pending, skip
|
||||
_broadcastDebounceTimer = setTimeout(() => {
|
||||
_broadcastDebounceTimer = null;
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
try {
|
||||
const displayClientIds = [];
|
||||
|
||||
// Base broadcast IDs for this loket
|
||||
displayClientIds.push(`anjungan-loket-${loketId.value}`);
|
||||
displayClientIds.push(`anjungan-masuk-${loketId.value}`);
|
||||
// Also broadcast to clinic specific displays if needed
|
||||
// displayClientIds.push(`anjungan-klinik-${...}`);
|
||||
|
||||
console.log('📡 [AdminLoket] Broadcasting update trigger to:', displayClientIds);
|
||||
|
||||
const payload = {
|
||||
loketId: loketId.value,
|
||||
triggerRefresh: true
|
||||
};
|
||||
|
||||
// If this is a CALL event, include the patient data
|
||||
if (callData) {
|
||||
if (isCallEvent) {
|
||||
payload.callEvent = {
|
||||
...callData,
|
||||
...extraData,
|
||||
loketId: loketId.value,
|
||||
loketName: loketName.value,
|
||||
calledAt: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
// Send all broadcasts in parallel for speed
|
||||
await Promise.all(displayClientIds.map(clientId =>
|
||||
sendViaPost({
|
||||
to_client: clientId,
|
||||
data: payload
|
||||
})
|
||||
));
|
||||
// Minimal broadcast: only send to the most relevant clients
|
||||
const criticalTargets = [
|
||||
`anjungan-loket-${loketId.value}`,
|
||||
`anjungan-masuk-${loketId.value}`,
|
||||
`admin-loket-${loketId.value}`,
|
||||
];
|
||||
|
||||
// For MOVE events, also notify the destination loket
|
||||
if (isMoveEvent && extraData.loketId && String(extraData.loketId) !== String(loketId.value)) {
|
||||
const destLoketId = extraData.loketId;
|
||||
criticalTargets.push(`admin-loket-${destLoketId}`);
|
||||
criticalTargets.push(`anjungan-loket-${destLoketId}`);
|
||||
criticalTargets.push(`anjungan-masuk-${destLoketId}`);
|
||||
console.log(`📡 [AdminLoket] Patient moved: also notifying Loket ${destLoketId}`);
|
||||
}
|
||||
|
||||
console.log('📡 [AdminLoket] Broadcasting to:', criticalTargets);
|
||||
|
||||
// Send sequentially with a delay to avoid 429 Rate Limit
|
||||
for (const clientId of criticalTargets) {
|
||||
try {
|
||||
await sendViaPost({ to_client: clientId, data: payload });
|
||||
} catch (err) {
|
||||
console.warn(`Failed to broadcast to ${clientId}:`, err?.message || err);
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 80));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ [AdminLoket] Error broadcasting update:', error);
|
||||
}
|
||||
@@ -1051,6 +1178,12 @@ onMounted(() => {
|
||||
setTimeout(() => {
|
||||
queueStore.ensureInitialData();
|
||||
}, 200);
|
||||
|
||||
// Initialize centralized WebSocket and register interest
|
||||
if (loketId.value) {
|
||||
queueStore.initWebSocket(anjunganClientId.value);
|
||||
queueStore.registerInterest(loketId.value);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -1058,6 +1191,10 @@ onUnmounted(() => {
|
||||
if (broadcastChannel) {
|
||||
broadcastChannel.close();
|
||||
}
|
||||
|
||||
if (loketId.value) {
|
||||
queueStore.unregisterInterest(loketId.value);
|
||||
}
|
||||
});
|
||||
|
||||
const closeKlinikRuangDialog = () => {
|
||||
@@ -1205,7 +1342,7 @@ const buatAntreanKlinikRuang = async (klinikRuang, ruang) => {
|
||||
|
||||
console.log("📤 Sending visit ticket to API:", visitTicketBody);
|
||||
|
||||
const visitApiBase = '/visit-api';
|
||||
const visitApiBase = config.public.externalApiBaseUrl || 'http://10.10.123.135:8084/api/v1';
|
||||
const visitResponse = await fetch(
|
||||
`${visitApiBase}/visit/ticket/klinik`,
|
||||
{
|
||||
@@ -1320,6 +1457,10 @@ const buatAntreanKlinikRuang = async (klinikRuang, ruang) => {
|
||||
snackbarColor.value = result.success ? "success" : "error";
|
||||
snackbar.value = true;
|
||||
|
||||
if (result.success) {
|
||||
broadcastUpdate();
|
||||
}
|
||||
|
||||
closeKlinikRuangDialog();
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -699,7 +699,11 @@
|
||||
</v-snackbar>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!anjunganData" class="not-found">
|
||||
<div v-else-if="anjunganStore.isLoading" class="not-found">
|
||||
<v-progress-circular indeterminate color="primary" size="64"></v-progress-circular>
|
||||
<p class="mt-4">Memuat Konfigurasi Anjungan...</p>
|
||||
</div>
|
||||
<div v-else class="not-found">
|
||||
<v-icon size="64" color="grey">mdi-alert-circle-outline</v-icon>
|
||||
<p>Anjungan tidak ditemukan</p>
|
||||
<v-btn color="primary" variant="flat" @click="backToList">Kembali</v-btn>
|
||||
@@ -791,6 +795,10 @@ onMounted(async () => {
|
||||
// Wait for stores to be hydrated and ready
|
||||
await nextTick();
|
||||
|
||||
if (anjunganItems.value.length === 0) {
|
||||
await anjunganStore.fetchAnjungan();
|
||||
}
|
||||
|
||||
// Initial fetch
|
||||
await fetchAllData();
|
||||
|
||||
@@ -1280,6 +1288,26 @@ const registerPatient = async (
|
||||
lastRegisteredPatient.value,
|
||||
);
|
||||
|
||||
// REAL-TIME SYNC: Notify AdminLoket to refresh its patient list immediately.
|
||||
// AdminLoket listens on the deterministic client ID "admin-loket-{loketId}".
|
||||
// Without this, AdminLoket only updates on manual refresh or 30s polling.
|
||||
if (result.patient.loketId && queueStore.sendViaPost) {
|
||||
const targetLoketId = result.patient.loketId;
|
||||
console.log(`📡 [Anjungan] Notifying AdminLoket ${targetLoketId} of new patient registration...`);
|
||||
queueStore.sendViaPost({
|
||||
to_client: `admin-loket-${targetLoketId}`,
|
||||
data: {
|
||||
loketId: targetLoketId,
|
||||
triggerRefresh: true,
|
||||
source: 'anjungan-registration',
|
||||
ticket: result.patient.noAntrian?.split(' |')[0] || '',
|
||||
}
|
||||
}).catch(err => {
|
||||
// Non-critical: polling will catch this within 30s anyway
|
||||
console.warn('⚠️ [Anjungan] Could not notify AdminLoket via WS:', err?.message);
|
||||
});
|
||||
}
|
||||
|
||||
// Tampilkan dialog print - gunakan nextTick untuk memastikan reactive update
|
||||
await nextTick();
|
||||
showPrintDialog.value = true;
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { useAnjunganStore } from '@/stores/anjunganStore';
|
||||
import { useMasterStore } from '@/stores/masterStore';
|
||||
import { useRoute } from '#app';
|
||||
@@ -111,6 +111,10 @@ const anjunganStore = useAnjunganStore();
|
||||
const masterStore = useMasterStore();
|
||||
const route = useRoute();
|
||||
|
||||
onMounted(() => {
|
||||
anjunganStore.fetchAnjungan();
|
||||
});
|
||||
|
||||
// Safeguard supaya tidak undefined saat store belum terisi
|
||||
const anjunganList = computed(() => {
|
||||
const fromGetter = anjunganStore.getAllAnjungan?.value;
|
||||
|
||||
@@ -746,7 +746,11 @@
|
||||
</v-snackbar>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!anjunganData" class="not-found">
|
||||
<div v-else-if="anjunganStore.isLoading" class="not-found">
|
||||
<v-progress-circular indeterminate color="primary" size="64"></v-progress-circular>
|
||||
<p class="mt-4">Memuat Konfigurasi Anjungan...</p>
|
||||
</div>
|
||||
<div v-else class="not-found">
|
||||
<v-icon size="64" color="grey">mdi-alert-circle-outline</v-icon>
|
||||
<p>Anjungan tidak ditemukan</p>
|
||||
<v-btn color="primary" variant="flat" @click="backToList">Kembali</v-btn>
|
||||
@@ -842,6 +846,10 @@ onMounted(async () => {
|
||||
// Wait for stores to be hydrated and ready
|
||||
await nextTick();
|
||||
|
||||
if (anjunganItems.value.length === 0) {
|
||||
await anjunganStore.fetchAnjungan();
|
||||
}
|
||||
|
||||
// Initial fetch
|
||||
await fetchAllData();
|
||||
|
||||
@@ -1383,6 +1391,25 @@ const registerPatient = async (
|
||||
lastRegisteredPatient.value,
|
||||
);
|
||||
|
||||
// REAL-TIME SYNC: Notify AdminLoket to refresh its patient list immediately.
|
||||
// AdminLoket listens on the deterministic client ID "admin-loket-{loketId}".
|
||||
if (result.patient.loketId && queueStore.sendViaPost) {
|
||||
const targetLoketId = result.patient.loketId;
|
||||
console.log(`📡 [AnjunganCopy] Notifying AdminLoket ${targetLoketId} of new patient registration...`);
|
||||
queueStore.sendViaPost({
|
||||
to_client: `admin-loket-${targetLoketId}`,
|
||||
data: {
|
||||
loketId: targetLoketId,
|
||||
triggerRefresh: true,
|
||||
source: 'anjungan-registration',
|
||||
ticket: result.patient.noAntrian?.split(' |')[0] || '',
|
||||
}
|
||||
}).catch(err => {
|
||||
// Non-critical: polling will catch this within 30s anyway
|
||||
console.warn('⚠️ [AnjunganCopy] Could not notify AdminLoket via WS:', err?.message);
|
||||
});
|
||||
}
|
||||
|
||||
// Tampilkan dialog print - gunakan nextTick untuk memastikan reactive update
|
||||
await nextTick();
|
||||
showPrintDialog.value = true;
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { useAnjunganStore } from '@/stores/anjunganStore';
|
||||
import { useMasterStore } from '@/stores/masterStore';
|
||||
import { useRoute } from '#app';
|
||||
@@ -111,6 +111,10 @@ const anjunganStore = useAnjunganStore();
|
||||
const masterStore = useMasterStore();
|
||||
const route = useRoute();
|
||||
|
||||
onMounted(() => {
|
||||
anjunganStore.fetchAnjungan();
|
||||
});
|
||||
|
||||
// Safeguard supaya tidak undefined saat store belum terisi
|
||||
const anjunganList = computed(() => {
|
||||
const fromGetter = anjunganStore.getAllAnjungan?.value;
|
||||
|
||||
@@ -515,7 +515,7 @@ const initWebSocketLocal = () => {
|
||||
wsInstance = useWebSocket({
|
||||
url: wsBaseUrl,
|
||||
clientId: anjunganClientId.value,
|
||||
fallbackPostUrl: '/stats-api/ws',
|
||||
fallbackPostUrl: `${config.public.externalApiBaseUrl}/ws`,
|
||||
onMessage: (data) => {
|
||||
console.log('📨 WebSocket message received:', data)
|
||||
fetchAllData()
|
||||
@@ -600,12 +600,16 @@ onMounted(() => {
|
||||
// Initialize and connect WebSocket (Centralized)
|
||||
queueStore.initWebSocket(anjunganClientId.value);
|
||||
|
||||
// Register global interest to receive staggered bulk refreshes on generic WS messages
|
||||
queueStore.registerGlobalInterest();
|
||||
// Register specific interest for each configured loket to receive immediate WS updates
|
||||
if (configuredLoketIds.value && configuredLoketIds.value.length > 0) {
|
||||
configuredLoketIds.value.forEach(id => queueStore.registerInterest(id));
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
// Unregister global interest when leaving the page
|
||||
queueStore.unregisterGlobalInterest();
|
||||
// Unregister specific interest when leaving the page
|
||||
if (configuredLoketIds.value && configuredLoketIds.value.length > 0) {
|
||||
configuredLoketIds.value.forEach(id => queueStore.unregisterInterest(id));
|
||||
}
|
||||
});
|
||||
|
||||
updateTime();
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { useAntreanMasukScreenStore } from '@/stores/antreanMasukScreenStore';
|
||||
import { useLoketStore } from '@/stores/loketStore';
|
||||
import { useRoute } from '#app';
|
||||
@@ -111,6 +111,10 @@ const antreanMasukScreenStore = useAntreanMasukScreenStore();
|
||||
const loketStore = useLoketStore();
|
||||
const route = useRoute();
|
||||
|
||||
onMounted(() => {
|
||||
antreanMasukScreenStore.fetchAntreanMasukScreens();
|
||||
});
|
||||
|
||||
// Helper to get loket name by ID
|
||||
const getLoketNameById = (loketId) => {
|
||||
const loket = loketStore.getLoketById(loketId);
|
||||
@@ -145,7 +149,7 @@ const goNext = () => {
|
||||
};
|
||||
|
||||
const navigateToScreen = (screenId) => {
|
||||
navigateTo(`/anjungan/antreanmasuk/${screenId}`);
|
||||
navigateTo(`/Anjungan/AntreanMasuk/${screenId}`);
|
||||
};
|
||||
|
||||
const navigateToSettings = () => {
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { useScreenStore } from '@/stores/screenStore';
|
||||
import { useMasterStore } from '@/stores/masterStore';
|
||||
import { useRoute } from '#app';
|
||||
@@ -104,6 +104,10 @@ const screenStore = useScreenStore();
|
||||
const masterStore = useMasterStore();
|
||||
const route = useRoute();
|
||||
|
||||
onMounted(() => {
|
||||
screenStore.fetchScreens();
|
||||
});
|
||||
|
||||
// Safeguard supaya tidak undefined saat store belum terisi
|
||||
const screens = computed(() => {
|
||||
const fromGetter = screenStore.getAllScreens?.value;
|
||||
|
||||
@@ -160,6 +160,11 @@ useHead({
|
||||
name: 'viewport',
|
||||
content: 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover'
|
||||
}
|
||||
],
|
||||
script: [
|
||||
{
|
||||
src: 'https://code.responsivevoice.org/responsivevoice.js?key=ZeMK8Joo'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
@@ -230,6 +235,43 @@ const currentDate = ref('')
|
||||
let timeInterval = null
|
||||
const broadcastedKlinikPatient = ref(null)
|
||||
|
||||
let lastPlayedTime = 0;
|
||||
let lastPlayedNo = '';
|
||||
|
||||
const playCallVoice = (patient, tipeLayanan = null, customRoomName = null) => {
|
||||
if (!patient || (!patient.noantrian && !patient.noAntrian)) return;
|
||||
|
||||
const now = Date.now();
|
||||
const noAntrianFull = patient.noantrian || patient.noAntrian;
|
||||
|
||||
// Cegah double-play berbarengan dalam waktu 2 detik
|
||||
if (noAntrianFull === lastPlayedNo && (now - lastPlayedTime < 2000)) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastPlayedNo = noAntrianFull;
|
||||
lastPlayedTime = now;
|
||||
|
||||
if (window.responsiveVoice) {
|
||||
const noAntrianRaw = noAntrianFull.split(" |")[0];
|
||||
const formattedNo = noAntrianRaw.split('').join(' ');
|
||||
|
||||
// For clinic, destination should include Ruang and Tipe Layanan
|
||||
// Contoh: "Ruang 1, untuk Pemeriksaan Awal"
|
||||
const ruang = customRoomName || patient.ruang || patient.namaRuang || (patient.nomorRuang ? `Ruang ${patient.nomorRuang}` : 'Klinik');
|
||||
const layananText = tipeLayanan ? `untuk ${tipeLayanan}` : '';
|
||||
const destination = `${ruang}, ${layananText}`;
|
||||
|
||||
const textToSpeak = `Nomor antrean, ${formattedNo}, silahkan menuju ke, ${destination}`;
|
||||
|
||||
if (window.responsiveVoice.isPlaying()) {
|
||||
window.responsiveVoice.cancel();
|
||||
}
|
||||
|
||||
window.responsiveVoice.speak(textToSpeak, "Indonesian Female", { pitch: 1, rate: 0.85, volume: 1 });
|
||||
}
|
||||
};
|
||||
|
||||
// Watch for Klinik Calls (Cross-Device Sync) from queueStore
|
||||
watch(() => queueStore.lastKlinikCall, (newCall) => {
|
||||
if (!newCall || !newCall.noantrian) return;
|
||||
@@ -274,6 +316,9 @@ watch(() => queueStore.lastKlinikCall, (newCall) => {
|
||||
ruang: existingPatient?.ruang || existingPatient?.namaRuang || `Ruang ${newCall.nomorRuang || '1'}`,
|
||||
_source: 'websocket'
|
||||
};
|
||||
|
||||
// Mainkan suara panggilan
|
||||
playCallVoice(broadcastedKlinikPatient.value, newCall.tipeLayanan, broadcastedKlinikPatient.value.ruang);
|
||||
} else {
|
||||
console.log(`⏭️ [Anjungan] Skipping call for clinic ${callKode} (this screen is for ${myKode})`);
|
||||
}
|
||||
@@ -591,7 +636,7 @@ const initWebSocketLocal = () => {
|
||||
wsInstance = useWebSocket({
|
||||
url: wsBaseUrl,
|
||||
clientId: anjunganClientId.value,
|
||||
fallbackPostUrl: '/stats-api/ws',
|
||||
fallbackPostUrl: `${config.public.externalApiBaseUrl}/ws`,
|
||||
onMessage: (data) => {
|
||||
console.log('📨 WebSocket raw message received:', data)
|
||||
let messageData = data
|
||||
|
||||
@@ -209,6 +209,9 @@ useHead({
|
||||
name: 'viewport',
|
||||
content: 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover'
|
||||
}
|
||||
],
|
||||
script: [
|
||||
{ src: 'https://code.responsivevoice.org/responsivevoice.js?key=ZeMK8Joo' }
|
||||
]
|
||||
})
|
||||
|
||||
@@ -229,6 +232,45 @@ let pollInterval = null
|
||||
let broadcastChannel = null
|
||||
const broadcastedPatient = ref(null)
|
||||
|
||||
let lastPlayedTime = 0;
|
||||
let lastPlayedNo = '';
|
||||
|
||||
const playCallVoice = (patient, customLoketName = null) => {
|
||||
console.log("🔊 playCallVoice called for:", patient?.noAntrian);
|
||||
if (!patient || !patient.noAntrian) return;
|
||||
|
||||
const now = Date.now();
|
||||
// Cegah double-play berbarengan (dari WS dan BroadcastChannel) dalam waktu 2 detik
|
||||
if (patient.noAntrian === lastPlayedNo && (now - lastPlayedTime < 2000)) {
|
||||
console.log("🔊 Skipped double play (debounce)");
|
||||
return;
|
||||
}
|
||||
|
||||
lastPlayedNo = patient.noAntrian;
|
||||
lastPlayedTime = now;
|
||||
|
||||
console.log("🔊 responsiveVoice object exists?", !!window.responsiveVoice);
|
||||
if (window.responsiveVoice) {
|
||||
const noAntrianRaw = patient.noAntrian.split(" |")[0];
|
||||
const formattedNo = noAntrianRaw.split('').join(' ');
|
||||
const destination = customLoketName || patient.loketName || loketData.value?.namaLoket || 'Loket';
|
||||
const textToSpeak = `Nomor antrean, ${formattedNo}, silahkan menuju ke, ${destination}`;
|
||||
|
||||
console.log("🔊 Speaking:", textToSpeak);
|
||||
try {
|
||||
if (window.responsiveVoice.isPlaying()) {
|
||||
window.responsiveVoice.cancel();
|
||||
}
|
||||
|
||||
window.responsiveVoice.speak(textToSpeak, "Indonesian Female", { pitch: 1, rate: 0.85, volume: 1 });
|
||||
} catch (e) {
|
||||
console.error("🔊 ResponsiveVoice Error:", e);
|
||||
}
|
||||
} else {
|
||||
console.error("🔊 ResponsiveVoice is NOT loaded yet!");
|
||||
}
|
||||
};
|
||||
|
||||
// Get loket ID from route
|
||||
const loketId = computed(() => {
|
||||
const id = route.params.id
|
||||
@@ -685,16 +727,26 @@ const isInTTSWindow = (queue) => {
|
||||
// Nomor antrian menjadi "dipanggil" jika diproses pada AdminLoket DAN sudah dipanggil oleh admin
|
||||
const currentCalledQueue = computed(() => {
|
||||
const targetLoketId = String(loketId.value)
|
||||
const CALL_DISPLAY_DURATION = 60000 // 60 seconds
|
||||
const now = new Date()
|
||||
|
||||
// Force reactivity to time passing
|
||||
const _ = currentTime.value
|
||||
|
||||
// Prioritas 0: Broadcasted patient (Real-time from BroadcastChannel)
|
||||
// Check if the broadcast came from this loket
|
||||
if (broadcastedPatient.value) {
|
||||
const rawMsg = broadcastedPatient.value._rawMessage
|
||||
const msgLoketId = rawMsg?.loketId ? String(rawMsg.loketId) : null
|
||||
|
||||
// Only show if it matches this anjungan's loket ID
|
||||
if (msgLoketId === targetLoketId) {
|
||||
return broadcastedPatient.value
|
||||
const callTime = new Date(broadcastedPatient.value.lastCalledAt || now)
|
||||
const timeDiff = now.getTime() - callTime.getTime()
|
||||
|
||||
if (timeDiff <= CALL_DISPLAY_DURATION) {
|
||||
return broadcastedPatient.value
|
||||
} else {
|
||||
broadcastedPatient.value = null // Clear if expired
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -705,10 +757,15 @@ const currentCalledQueue = computed(() => {
|
||||
const patientLoketId = processingPatient.loketId ? String(processingPatient.loketId) : "1"
|
||||
|
||||
if (patientLoketId === targetLoketId && processingPatient.calledByAdmin && processingPatient.noAntrian && processingPatient.status === 'di-loket') {
|
||||
const klinikName = getKlinikNameFromPatient(processingPatient)
|
||||
return {
|
||||
...processingPatient,
|
||||
klinik: klinikName || processingPatient.klinik || 'Klinik'
|
||||
const callTime = processingPatient.lastCalledAt ? new Date(processingPatient.lastCalledAt) : now
|
||||
const timeDiff = now.getTime() - callTime.getTime()
|
||||
|
||||
if (timeDiff <= CALL_DISPLAY_DURATION) {
|
||||
const klinikName = getKlinikNameFromPatient(processingPatient)
|
||||
return {
|
||||
...processingPatient,
|
||||
klinik: klinikName || processingPatient.klinik || 'Klinik'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -717,10 +774,15 @@ const currentCalledQueue = computed(() => {
|
||||
const allPatientsList = filteredPatientsForLoket.value
|
||||
const dbActive = allPatientsList.find(p => p.idvisit === 8 || p.idvisit === 7)
|
||||
if (dbActive) {
|
||||
const klinikName = getKlinikNameFromPatient(dbActive)
|
||||
return {
|
||||
...dbActive,
|
||||
klinik: klinikName || dbActive.klinik || 'Klinik'
|
||||
const callTime = dbActive.lastCalledAt ? new Date(dbActive.lastCalledAt) : now
|
||||
const timeDiff = now.getTime() - callTime.getTime()
|
||||
|
||||
if (timeDiff <= CALL_DISPLAY_DURATION) {
|
||||
const klinikName = getKlinikNameFromPatient(dbActive)
|
||||
return {
|
||||
...dbActive,
|
||||
klinik: klinikName || dbActive.klinik || 'Klinik'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -914,6 +976,9 @@ watch(() => queueStore.lastGlobalCall, (newCall) => {
|
||||
...newCall,
|
||||
_source: 'websocket'
|
||||
};
|
||||
|
||||
// Mainkan suara panggilan
|
||||
playCallVoice(newCall, newCall.loketName);
|
||||
|
||||
// Sync with store using isolated key
|
||||
if (queueStore.currentProcessingPatient) {
|
||||
@@ -941,7 +1006,7 @@ const initWebSocketLocal = () => {
|
||||
wsInstance = useWebSocket({
|
||||
url: wsBaseUrl,
|
||||
clientId: anjunganClientId.value,
|
||||
fallbackPostUrl: '/stats-api/ws',
|
||||
fallbackPostUrl: `${config.public.externalApiBaseUrl}/ws`,
|
||||
onMessage: (data) => {
|
||||
console.log('📨 WebSocket message received:', data)
|
||||
const messageData = data?.data || data
|
||||
@@ -1016,6 +1081,9 @@ onMounted(async () => {
|
||||
...patient, // Spread patient data
|
||||
_rawMessage: event.data // Attach metadata for filtering
|
||||
};
|
||||
|
||||
// Mainkan suara panggilan
|
||||
playCallVoice(patient, event.data.loketName);
|
||||
|
||||
// Sync with store using isolated key
|
||||
if (queueStore.currentProcessingPatient) {
|
||||
|
||||
@@ -1669,6 +1669,7 @@
|
||||
import { ref, computed, nextTick, onMounted, onUnmounted, watch } from "vue";
|
||||
import { useQueueStore } from "@/stores/queueStore";
|
||||
import { useMasterStore } from "@/stores/masterStore";
|
||||
import { useLoketStore } from "@/stores/loketStore";
|
||||
import { useThermalPrint } from "@/composables/useThermalPrint";
|
||||
import PageHeader from "@/components/common/PageHeader.vue";
|
||||
|
||||
@@ -1679,6 +1680,7 @@ definePageMeta({
|
||||
|
||||
const queueStore = useQueueStore();
|
||||
const masterStore = useMasterStore();
|
||||
const loketStore = useLoketStore();
|
||||
const { printTicketFromPatient, isPrinting } = useThermalPrint();
|
||||
const config = useRuntimeConfig();
|
||||
const wsBaseUrl =
|
||||
@@ -1802,6 +1804,7 @@ const checkInClientId = computed(() => {
|
||||
const fetchAllData = async () => {
|
||||
console.log('🔄 CheckIn refresh: Syncing data...');
|
||||
try {
|
||||
await loketStore.fetchLoketFromAPI();
|
||||
await queueStore.fetchAllPatients();
|
||||
queueStore.ensureInitialData();
|
||||
checkAndResetDaily();
|
||||
@@ -1811,6 +1814,33 @@ const fetchAllData = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const broadcastCheckIn = async (loketId) => {
|
||||
if (!loketId) return;
|
||||
try {
|
||||
const displayClientIds = [
|
||||
`anjungan-loket-${loketId}`,
|
||||
`anjungan-masuk-${loketId}`
|
||||
];
|
||||
|
||||
const payload = {
|
||||
loketId: String(loketId),
|
||||
triggerRefresh: true
|
||||
};
|
||||
|
||||
if (queueStore.sendViaPost) {
|
||||
console.log('📡 [CheckIn] Broadcasting check-in update to:', displayClientIds);
|
||||
await Promise.all(displayClientIds.map(clientId =>
|
||||
queueStore.sendViaPost({
|
||||
to_client: clientId,
|
||||
data: payload
|
||||
})
|
||||
));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ [CheckIn] Error broadcasting update:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const isConnected = computed(() => queueStore.isWsConnected);
|
||||
|
||||
// Watch for clientId changes and reconnect if needed
|
||||
@@ -3249,6 +3279,9 @@ const onDetect = async (decodedText: string) => {
|
||||
"success",
|
||||
"mdi-check-circle",
|
||||
);
|
||||
|
||||
// Broadcast WebSocket notification to instantly update loket and antrean masuk screens
|
||||
broadcastCheckIn(checkInResult.patient.loketId);
|
||||
} else {
|
||||
// Check-in gagal (misalnya validasi di checkInPatient gagal)
|
||||
saveToHistory({
|
||||
@@ -3646,6 +3679,9 @@ const checkInManual = async () => {
|
||||
if (manualForm.value) {
|
||||
(manualForm.value as any).reset();
|
||||
}
|
||||
|
||||
// Broadcast WebSocket notification to instantly update loket and antrean masuk screens
|
||||
broadcastCheckIn(checkInResult.patient.loketId);
|
||||
} else {
|
||||
// Check-in gagal (misalnya validasi di checkInPatient gagal)
|
||||
saveToHistory({
|
||||
|
||||
+106
-37
@@ -240,11 +240,18 @@
|
||||
<div class="chart-container">
|
||||
<ClientOnly>
|
||||
<Line
|
||||
v-if="visitTrendData.labels && visitTrendData.labels.length > 0"
|
||||
v-if="visitTrendData.labels && visitTrendData.labels.length > 0 && visitTrendData.datasets[0].data.length > 0 && visitTrendData.datasets[0].data.some(v => v > 0)"
|
||||
:data="visitTrendData"
|
||||
:options="areaChartOptions"
|
||||
class="chart-wrapper"
|
||||
/>
|
||||
<div v-else class="empty-chart-state">
|
||||
<div class="empty-state-content">
|
||||
<v-icon size="64" color="grey-lighten-2" class="mb-2">mdi-chart-timeline-variant</v-icon>
|
||||
<h4 class="text-h6 text-grey-darken-1 font-weight-medium mb-1">Belum Ada Data</h4>
|
||||
<p class="text-caption text-grey">Tren kunjungan bulanan akan tampil di sini</p>
|
||||
</div>
|
||||
</div>
|
||||
<template #fallback>
|
||||
<div class="chart-loading">
|
||||
<v-progress-circular indeterminate color="primary" size="50"></v-progress-circular>
|
||||
@@ -285,11 +292,18 @@
|
||||
<div class="chart-container">
|
||||
<ClientOnly>
|
||||
<Doughnut
|
||||
v-if="paymentStatusData.labels && paymentStatusData.labels.length > 0"
|
||||
v-if="paymentStatusData.labels && paymentStatusData.labels.length > 0 && paymentStatusData.datasets[0].data.length > 0 && paymentStatusData.datasets[0].data.some(v => v > 0)"
|
||||
:data="paymentStatusData"
|
||||
:options="doughnutOptions"
|
||||
class="chart-wrapper pie-chart"
|
||||
/>
|
||||
<div v-else class="empty-chart-state">
|
||||
<div class="empty-state-content">
|
||||
<v-icon size="64" color="grey-lighten-2" class="mb-2">mdi-chart-donut-variant</v-icon>
|
||||
<h4 class="text-h6 text-grey-darken-1 font-weight-medium mb-1">Belum Ada Data</h4>
|
||||
<p class="text-caption text-grey">Distribusi metode bayar akan tampil di sini</p>
|
||||
</div>
|
||||
</div>
|
||||
<template #fallback>
|
||||
<div class="chart-loading">
|
||||
<v-progress-circular indeterminate color="success" size="50"></v-progress-circular>
|
||||
@@ -330,11 +344,18 @@
|
||||
<div class="chart-container">
|
||||
<ClientOnly>
|
||||
<Bar
|
||||
v-if="waitingTimeData.labels && waitingTimeData.labels.length > 0"
|
||||
v-if="waitingTimeData.labels && waitingTimeData.labels.length > 0 && waitingTimeData.datasets[0].data.length > 0 && waitingTimeData.datasets[0].data.some(v => v > 0)"
|
||||
:data="waitingTimeData"
|
||||
:options="horizontalBarOptions"
|
||||
class="chart-wrapper"
|
||||
/>
|
||||
<div v-else class="empty-chart-state">
|
||||
<div class="empty-state-content">
|
||||
<v-icon size="64" color="grey-lighten-2" class="mb-2">mdi-clock-outline</v-icon>
|
||||
<h4 class="text-h6 text-grey-darken-1 font-weight-medium mb-1">Belum Ada Data</h4>
|
||||
<p class="text-caption text-grey">Data waktu tunggu poli akan tampil di sini</p>
|
||||
</div>
|
||||
</div>
|
||||
<template #fallback>
|
||||
<div class="chart-loading">
|
||||
<v-progress-circular indeterminate color="secondary" size="50"></v-progress-circular>
|
||||
@@ -379,11 +400,18 @@
|
||||
<div class="chart-container">
|
||||
<ClientOnly>
|
||||
<PolarArea
|
||||
v-if="attendanceData.labels && attendanceData.labels.length > 0"
|
||||
v-if="attendanceData.labels && attendanceData.labels.length > 0 && attendanceData.datasets[0].data.length > 0 && attendanceData.datasets[0].data.some(v => v > 0)"
|
||||
:data="attendanceData"
|
||||
:options="polarAreaOptions"
|
||||
class="chart-wrapper pie-chart"
|
||||
/>
|
||||
<div v-else class="empty-chart-state">
|
||||
<div class="empty-state-content">
|
||||
<v-icon size="64" color="grey-lighten-2" class="mb-2">mdi-account-check</v-icon>
|
||||
<h4 class="text-h6 text-grey-darken-1 font-weight-medium mb-1">Belum Ada Data</h4>
|
||||
<p class="text-caption text-grey">Statistik tingkat kehadiran akan tampil di sini</p>
|
||||
</div>
|
||||
</div>
|
||||
<template #fallback>
|
||||
<div class="chart-loading">
|
||||
<v-progress-circular indeterminate color="primary" size="50"></v-progress-circular>
|
||||
@@ -499,54 +527,55 @@ const exportOptions = ref([
|
||||
const currentDate = ref('');
|
||||
const filterDateFrom = ref(dayjs().format('YYYY-MM-DD'));
|
||||
const filterDateTo = ref(dayjs().format('YYYY-MM-DD'));
|
||||
const selectedYear = ref(2025);
|
||||
const availableYears = ref([2025, 2026, 2027]);
|
||||
const currentYear = dayjs().year();
|
||||
const selectedYear = ref(currentYear);
|
||||
const availableYears = ref([currentYear - 1, currentYear, currentYear + 1]);
|
||||
|
||||
// New Stats Data - Updated Metrics
|
||||
const stats = ref([
|
||||
{
|
||||
label: 'Pasien Hari Ini',
|
||||
value: '324',
|
||||
value: '-',
|
||||
icon: 'mdi-account-heart',
|
||||
iconSize: 24,
|
||||
iconColor: 'white',
|
||||
color: 'primary',
|
||||
change: '+18.2%',
|
||||
changeType: 'positive',
|
||||
changeIcon: 'mdi-trending-up'
|
||||
change: '-',
|
||||
changeType: 'neutral',
|
||||
changeIcon: 'mdi-minus'
|
||||
},
|
||||
{
|
||||
label: 'Antrean Aktif',
|
||||
value: '47',
|
||||
value: '-',
|
||||
icon: 'mdi-clock-fast',
|
||||
iconSize: 24,
|
||||
iconColor: 'white',
|
||||
color: 'secondary',
|
||||
change: '+5',
|
||||
changeType: 'positive',
|
||||
changeIcon: 'mdi-arrow-up'
|
||||
change: '-',
|
||||
changeType: 'neutral',
|
||||
changeIcon: 'mdi-minus'
|
||||
},
|
||||
{
|
||||
label: 'Rata-rata Tunggu',
|
||||
value: '12 min',
|
||||
value: '-',
|
||||
icon: 'mdi-timer-outline',
|
||||
iconSize: 24,
|
||||
iconColor: 'white',
|
||||
color: 'success',
|
||||
change: '-3 min',
|
||||
changeType: 'positive',
|
||||
changeIcon: 'mdi-trending-down'
|
||||
change: '-',
|
||||
changeType: 'neutral',
|
||||
changeIcon: 'mdi-minus'
|
||||
},
|
||||
{
|
||||
label: 'Tingkat Kehadiran',
|
||||
value: '89.5%',
|
||||
value: '-',
|
||||
icon: 'mdi-check-circle',
|
||||
iconSize: 24,
|
||||
iconColor: 'white',
|
||||
color: 'primary',
|
||||
change: '+2.3%',
|
||||
changeType: 'positive',
|
||||
changeIcon: 'mdi-trending-up'
|
||||
change: '-',
|
||||
changeType: 'neutral',
|
||||
changeIcon: 'mdi-minus'
|
||||
}
|
||||
]);
|
||||
|
||||
@@ -556,7 +585,7 @@ const visitTrendData = ref({
|
||||
datasets: [
|
||||
{
|
||||
label: 'Pasien Umum',
|
||||
data: [850, 920, 880, 1050, 1120, 1080, 1200, 1150, 1080, 1190, 1250, 1300],
|
||||
data: [],
|
||||
backgroundColor: 'rgba(86, 126, 231, 0.2)',
|
||||
borderColor: colors.primary[500],
|
||||
borderWidth: 3,
|
||||
@@ -570,7 +599,7 @@ const visitTrendData = ref({
|
||||
},
|
||||
{
|
||||
label: 'Pasien BPJS',
|
||||
data: [1200, 1350, 1280, 1480, 1550, 1620, 1700, 1650, 1590, 1720, 1800, 1850],
|
||||
data: [],
|
||||
backgroundColor: 'rgba(255, 132, 65, 0.2)',
|
||||
borderColor: colors.secondary[500],
|
||||
borderWidth: 3,
|
||||
@@ -587,10 +616,10 @@ const visitTrendData = ref({
|
||||
|
||||
// Payment Status Data (Doughnut Chart)
|
||||
const paymentStatusData = ref({
|
||||
labels: ['BPJS Kesehatan', 'Umum/Tunai', 'Asuransi Swasta', 'Corporate'],
|
||||
labels: [],
|
||||
datasets: [
|
||||
{
|
||||
data: [1850, 680, 320, 280],
|
||||
data: [],
|
||||
backgroundColor: [
|
||||
colors.primary[500],
|
||||
colors.secondary[500],
|
||||
@@ -606,11 +635,11 @@ const paymentStatusData = ref({
|
||||
|
||||
// Waiting Time per Poli Data (Horizontal Bar Chart)
|
||||
const waitingTimeData = ref({
|
||||
labels: ['Poli Umum', 'Poli Anak', 'Poli Gigi', 'Poli Mata', 'Poli THT', 'Poli Jantung'],
|
||||
labels: [],
|
||||
datasets: [
|
||||
{
|
||||
label: 'Waktu Tunggu (menit)',
|
||||
data: [15, 12, 8, 18, 10, 22],
|
||||
data: [],
|
||||
backgroundColor: [
|
||||
colors.primary[400],
|
||||
colors.secondary[400],
|
||||
@@ -635,10 +664,10 @@ const waitingTimeData = ref({
|
||||
|
||||
// Attendance Data (Polar Area Chart)
|
||||
const attendanceData = ref({
|
||||
labels: ['Hadir Tepat Waktu', 'Hadir Terlambat', 'Tidak Hadir', 'Batal', 'Reschedule'],
|
||||
labels: [],
|
||||
datasets: [
|
||||
{
|
||||
data: [1850, 420, 280, 150, 230],
|
||||
data: [],
|
||||
backgroundColor: [
|
||||
colors.success[400],
|
||||
colors.primary[400],
|
||||
@@ -681,7 +710,7 @@ const areaChartOptions = ref({
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
titleColor: colors.neutral[900],
|
||||
bodyColor: colors.neutral[700],
|
||||
borderColor: colors.neutral[400],
|
||||
borderColor: colors.neutral[500],
|
||||
borderWidth: 1,
|
||||
padding: 12,
|
||||
boxPadding: 6,
|
||||
@@ -778,7 +807,7 @@ const doughnutOptions = ref({
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
titleColor: colors.neutral[900],
|
||||
bodyColor: colors.neutral[700],
|
||||
borderColor: colors.neutral[400],
|
||||
borderColor: colors.neutral[500],
|
||||
borderWidth: 1,
|
||||
padding: 12,
|
||||
boxPadding: 6,
|
||||
@@ -814,7 +843,7 @@ const horizontalBarOptions = ref({
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
titleColor: colors.neutral[900],
|
||||
bodyColor: colors.neutral[700],
|
||||
borderColor: colors.neutral[400],
|
||||
borderColor: colors.neutral[500],
|
||||
borderWidth: 1,
|
||||
padding: 12,
|
||||
boxPadding: 6,
|
||||
@@ -889,7 +918,7 @@ const polarAreaOptions = ref({
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
titleColor: colors.neutral[900],
|
||||
bodyColor: colors.neutral[700],
|
||||
borderColor: colors.neutral[400],
|
||||
borderColor: colors.neutral[500],
|
||||
borderWidth: 1,
|
||||
padding: 12,
|
||||
boxPadding: 6,
|
||||
@@ -998,8 +1027,24 @@ const refreshDashboardStats = async (filterParams = {}) => {
|
||||
|
||||
// 4. Update Monthly Trend (Map API into datasets)
|
||||
if (data.monthly_trend && data.monthly_trend.length > 0) {
|
||||
// Logic to update trend charts can be expanded here
|
||||
// For now, we'll keep the existing structure but show where real data fits
|
||||
// TODO: Map 'data.monthly_trend' to 'visitTrendData'
|
||||
// Example:
|
||||
// visitTrendData.value.labels = data.monthly_trend.map(m => m.month_name);
|
||||
// visitTrendData.value.datasets[0].data = data.monthly_trend.map(m => m.umum_count);
|
||||
// visitTrendData.value.datasets[1].data = data.monthly_trend.map(m => m.bpjs_count);
|
||||
}
|
||||
|
||||
// 5. Update Attendance Data
|
||||
if (data.attendance_stats && Object.keys(data.attendance_stats).length > 0) {
|
||||
// TODO: Map 'data.attendance_stats' to 'attendanceData'
|
||||
// Example:
|
||||
// attendanceData.value = {
|
||||
// labels: Object.keys(data.attendance_stats),
|
||||
// datasets: [{
|
||||
// ...attendanceData.value.datasets[0],
|
||||
// data: Object.values(data.attendance_stats)
|
||||
// }]
|
||||
// };
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -1551,7 +1596,7 @@ const downloadBlob = (blob, filename) => {
|
||||
|
||||
/* Compact Stats Cards */
|
||||
.stats-row {
|
||||
margin-top: -20px;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 12px !important;
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
@@ -1841,6 +1886,7 @@ const downloadBlob = (blob, filename) => {
|
||||
background: linear-gradient(90deg, var(--color-primary-500) 0%, var(--color-primary-600) 50%, var(--color-secondary-500) 100%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&::after {
|
||||
@@ -1853,6 +1899,7 @@ const downloadBlob = (blob, filename) => {
|
||||
background: radial-gradient(circle, rgba(58, 97, 201, 0.05) 0%, transparent 70%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@@ -2080,6 +2127,28 @@ const downloadBlob = (blob, filename) => {
|
||||
}
|
||||
}
|
||||
|
||||
.empty-chart-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 200px;
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
border-radius: 12px;
|
||||
border: 1px dashed rgba(0, 0, 0, 0.1);
|
||||
margin: 16px;
|
||||
padding: 24px;
|
||||
|
||||
.empty-state-content {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.live-chip {
|
||||
font-weight: 700;
|
||||
font-size: 11px;
|
||||
|
||||
@@ -348,9 +348,9 @@ const submitForm = async () => {
|
||||
|
||||
let result;
|
||||
if (isEdit.value) {
|
||||
result = anjunganStore.updateAnjungan(formData.value);
|
||||
result = await anjunganStore.updateAnjungan(formData.value);
|
||||
} else {
|
||||
result = anjunganStore.addAnjungan(formData.value);
|
||||
result = await anjunganStore.addAnjungan(formData.value);
|
||||
}
|
||||
|
||||
snackbar.value = {
|
||||
@@ -364,14 +364,10 @@ const submitForm = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (item) => {
|
||||
const handleDelete = async (item) => {
|
||||
if (confirm(`Hapus anjungan ${item.namaAnjungan}?`)) {
|
||||
const result = anjunganStore.deleteAnjungan(item.id);
|
||||
snackbar.value = {
|
||||
show: true,
|
||||
message: result.message,
|
||||
color: result.success ? 'success' : 'error',
|
||||
};
|
||||
const result = await anjunganStore.deleteAnjungan(item.id);
|
||||
snackbar.value = { show: true, message: result.message, color: result.success ? 'success' : 'error' };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -391,6 +387,7 @@ const closePreviewDialog = () => {
|
||||
// Fetch Reguler clinics from API on mount
|
||||
onMounted(async () => {
|
||||
await clinicStore.fetchRegulerClinics();
|
||||
await anjunganStore.fetchAnjungan();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -376,20 +376,21 @@
|
||||
definePageMeta({
|
||||
middleware: ['auth', 'check-page-access']
|
||||
})
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ref, onMounted, computed, watch } from 'vue';
|
||||
import { useMasterStore } from '@/stores/masterStore';
|
||||
import { useClinicStore } from '@/stores/clinicStore';
|
||||
import { useRuangStore } from '@/stores/ruangStore';
|
||||
import { useKlinikRuangStore } from '@/stores/klinikruangstore';
|
||||
|
||||
const masterStore = useMasterStore();
|
||||
const clinicStore = useClinicStore();
|
||||
const ruangStore = useRuangStore();
|
||||
const klinikRuangStore = useKlinikRuangStore();
|
||||
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 });
|
||||
@@ -511,9 +512,9 @@ const submitForm = async () => {
|
||||
|
||||
let result;
|
||||
if (isEdit.value) {
|
||||
result = masterStore.updateRuang(formData.value);
|
||||
result = await klinikRuangStore.updateKlinikRuang(formData.value.id, formData.value);
|
||||
} else {
|
||||
result = masterStore.addRuang(formData.value);
|
||||
result = await klinikRuangStore.createKlinikRuang(formData.value);
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
@@ -524,14 +525,10 @@ const submitForm = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (item) => {
|
||||
if (confirm(`Hapus ruangan ${item.namaRuang} dari klinik ${item.namaKlinik}?`)) {
|
||||
const result = masterStore.deleteRuang(item.id);
|
||||
snackbar.value = {
|
||||
show: true,
|
||||
message: result.message,
|
||||
color: result.success ? 'success' : 'error'
|
||||
};
|
||||
const handleDelete = async (item) => {
|
||||
if (confirm(`Hapus ruangan dari klinik ${item.namaKlinik}?`)) {
|
||||
const result = await klinikRuangStore.deleteKlinikRuang(item.id);
|
||||
snackbar.value = { show: true, message: result.message, color: result.success ? 'success' : 'error' };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -552,36 +549,16 @@ const closePreviewDialog = () => {
|
||||
// Fetch clinics and sync rooms on mount
|
||||
onMounted(async () => {
|
||||
console.log('🚀 MasterKlinikRuang mounted, syncing data...');
|
||||
|
||||
try {
|
||||
// 1. Fetch clinics from API (uses cache if available)
|
||||
const fetchClinicResult = await clinicStore.fetchRegulerClinics();
|
||||
console.log('📥 Clinic fetch result:', fetchClinicResult);
|
||||
|
||||
if (!fetchClinicResult.success) {
|
||||
snackbar.value = {
|
||||
show: true,
|
||||
message: `Klinik: ${fetchClinicResult.message}`,
|
||||
color: 'warning'
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Fetch and merge rooms from API
|
||||
const syncRuangResult = await ruangStore.fetchRuangFromAPI();
|
||||
console.log('🔄 Room sync result:', syncRuangResult);
|
||||
|
||||
snackbar.value = {
|
||||
show: true,
|
||||
message: syncRuangResult.message,
|
||||
color: syncRuangResult.success ? 'success' : 'error'
|
||||
};
|
||||
await Promise.all([
|
||||
clinicStore.fetchRegulerClinics(),
|
||||
klinikRuangStore.fetchKlinikRuang(),
|
||||
ruangStore.fetchRuangFromAPI(),
|
||||
]);
|
||||
console.log('✅ MasterKlinikRuang data loaded');
|
||||
} catch (error) {
|
||||
console.error('❌ Error in onMounted:', error);
|
||||
snackbar.value = {
|
||||
show: true,
|
||||
message: `Error: ${error.message}`,
|
||||
color: 'error'
|
||||
};
|
||||
snackbar.value = { show: true, message: `Error: ${error.message}`, color: 'error' };
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+13
-23
@@ -271,12 +271,16 @@
|
||||
definePageMeta({
|
||||
middleware: ['auth', 'check-page-access']
|
||||
})
|
||||
import { ref, computed } from 'vue';
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useMasterStore } from '@/stores/masterStore';
|
||||
import { useScreenStore } from '@/stores/screenStore';
|
||||
|
||||
const masterStore = useMasterStore();
|
||||
const screenStore = useScreenStore();
|
||||
|
||||
onMounted(() => {
|
||||
screenStore.fetchScreens();
|
||||
});
|
||||
const dialog = ref(false);
|
||||
const isEdit = ref(false);
|
||||
const formRef = ref(null);
|
||||
@@ -348,34 +352,20 @@ const submitForm = async () => {
|
||||
if (!valid) return;
|
||||
|
||||
if (isEdit.value) {
|
||||
// Update screen
|
||||
const result = screenStore.updateScreen(formData.value);
|
||||
snackbar.value = {
|
||||
show: true,
|
||||
message: result.message,
|
||||
color: result.success ? 'success' : 'error'
|
||||
};
|
||||
const result = await screenStore.updateScreen(formData.value);
|
||||
snackbar.value = { show: true, message: result.message, color: result.success ? 'success' : 'error' };
|
||||
} else {
|
||||
// Add new screen
|
||||
const result = screenStore.addScreen(formData.value);
|
||||
snackbar.value = {
|
||||
show: true,
|
||||
message: result.message,
|
||||
color: result.success ? 'success' : 'error'
|
||||
};
|
||||
const result = await screenStore.addScreen(formData.value);
|
||||
snackbar.value = { show: true, message: result.message, color: result.success ? 'success' : 'error' };
|
||||
}
|
||||
|
||||
closeDialog();
|
||||
if (snackbar.value.color === 'success') closeDialog();
|
||||
};
|
||||
|
||||
const handleDelete = (item) => {
|
||||
const handleDelete = async (item) => {
|
||||
if (confirm(`Hapus screen ${item.namaScreen}?`)) {
|
||||
const result = screenStore.deleteScreen(item.id);
|
||||
snackbar.value = {
|
||||
show: true,
|
||||
message: result.message,
|
||||
color: result.success ? 'success' : 'error'
|
||||
};
|
||||
const result = await screenStore.deleteScreen(item.id);
|
||||
snackbar.value = { show: true, message: result.message, color: result.success ? 'success' : 'error' };
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -316,9 +316,9 @@ const initData = async () => {
|
||||
// Call immediately (non-blocking)
|
||||
initData();
|
||||
|
||||
// Also try onMounted for good measure
|
||||
onMounted(() => {
|
||||
initData();
|
||||
onMounted(async () => {
|
||||
await antreanMasukScreenStore.fetchAntreanMasukScreens();
|
||||
initData();
|
||||
});
|
||||
|
||||
const refreshData = async () => {
|
||||
@@ -429,34 +429,20 @@ const submitForm = async () => {
|
||||
if (!valid) return;
|
||||
|
||||
if (isEdit.value) {
|
||||
// Update screen
|
||||
const result = antreanMasukScreenStore.updateAntreanMasukScreen(formData.value);
|
||||
snackbar.value = {
|
||||
show: true,
|
||||
message: result.message,
|
||||
color: result.success ? 'success' : 'error'
|
||||
};
|
||||
const result = await antreanMasukScreenStore.updateAntreanMasukScreen(formData.value);
|
||||
snackbar.value = { show: true, message: result.message, color: result.success ? 'success' : 'error' };
|
||||
} else {
|
||||
// Add new screen
|
||||
const result = antreanMasukScreenStore.addAntreanMasukScreen(formData.value);
|
||||
snackbar.value = {
|
||||
show: true,
|
||||
message: result.message,
|
||||
color: result.success ? 'success' : 'error'
|
||||
};
|
||||
const result = await antreanMasukScreenStore.addAntreanMasukScreen(formData.value);
|
||||
snackbar.value = { show: true, message: result.message, color: result.success ? 'success' : 'error' };
|
||||
}
|
||||
|
||||
closeDialog();
|
||||
if (snackbar.value.color === 'success') closeDialog();
|
||||
};
|
||||
|
||||
const handleDelete = (item) => {
|
||||
const handleDelete = async (item) => {
|
||||
if (confirm(`Hapus screen ${item.namaScreen}?`)) {
|
||||
const result = antreanMasukScreenStore.deleteAntreanMasukScreen(item.id);
|
||||
snackbar.value = {
|
||||
show: true,
|
||||
message: result.message,
|
||||
color: result.success ? 'success' : 'error'
|
||||
};
|
||||
const result = await antreanMasukScreenStore.deleteAntreanMasukScreen(item.id);
|
||||
snackbar.value = { show: true, message: result.message, color: result.success ? 'success' : 'error' };
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
<script setup>
|
||||
import PageHeader from '@/components/common/PageHeader.vue';
|
||||
|
||||
const currentDate = computed(() => {
|
||||
const now = new Date();
|
||||
const days = ['Minggu', 'Senin', 'Selasa', 'Rabu', 'Kamis', 'Jumat', 'Sabtu'];
|
||||
const months = ['Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni', 'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember'];
|
||||
return `${days[now.getDay()]}, ${now.getDate()} ${months[now.getMonth()]} ${now.getFullYear()}`;
|
||||
});
|
||||
|
||||
const snackbar = ref({
|
||||
show: false,
|
||||
message: '',
|
||||
color: 'success'
|
||||
});
|
||||
|
||||
// Profile data
|
||||
const profileData = reactive({
|
||||
name: 'Budi Santoso',
|
||||
registrationNumber: 'RM-2023-8812',
|
||||
nim: '3573230897851332',
|
||||
phone: '+62 812-3456-7890',
|
||||
verified: true
|
||||
});
|
||||
|
||||
// Family members data
|
||||
const familyMembers = reactive([
|
||||
{
|
||||
id: 1,
|
||||
name: 'Andi Pratama',
|
||||
status: 'PENDING'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Siti Aminah',
|
||||
status: 'AKTIF'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Maimunah',
|
||||
status: 'AKTIF'
|
||||
}
|
||||
]);
|
||||
|
||||
// Activity history
|
||||
const activityHistory = reactive([
|
||||
{
|
||||
id: 1,
|
||||
title: 'Member Added',
|
||||
date: '10 Okt 2023, 14:20',
|
||||
icon: 'mdi-account-plus',
|
||||
color: 'primary'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Member Data Updated',
|
||||
date: '08 Okt 2023, 09:15',
|
||||
icon: 'mdi-pencil',
|
||||
color: 'success'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Member Removed',
|
||||
date: '07 Okt 2023, 16:45',
|
||||
icon: 'mdi-account-remove',
|
||||
color: 'error'
|
||||
}
|
||||
]);
|
||||
|
||||
// Modal state
|
||||
const isModalOpen = ref(false);
|
||||
const selectedMember = ref(null);
|
||||
|
||||
const modalData = reactive({
|
||||
namaLengkap: '',
|
||||
tanggalLahir: '',
|
||||
nik: '',
|
||||
jenisKelamin: '',
|
||||
hubungan: '',
|
||||
nomorTelepon: '',
|
||||
alamat: ''
|
||||
});
|
||||
|
||||
const genderOptions = ['Laki-laki', 'Perempuan'];
|
||||
const relationshipOptions = ['Orang tua', 'Suami/Istri', 'Anak', 'Saudara', 'Lainnya'];
|
||||
|
||||
const openModal = (member) => {
|
||||
selectedMember.value = member;
|
||||
// Reset form
|
||||
modalData.namaLengkap = member.name;
|
||||
modalData.tanggalLahir = '';
|
||||
modalData.nik = '';
|
||||
modalData.jenisKelamin = '';
|
||||
modalData.hubungan = '';
|
||||
modalData.nomorTelepon = '';
|
||||
modalData.alamat = '';
|
||||
isModalOpen.value = true;
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
isModalOpen.value = false;
|
||||
selectedMember.value = null;
|
||||
};
|
||||
|
||||
const handleApprove = () => {
|
||||
snackbar.value = {
|
||||
show: true,
|
||||
message: 'Pengajuan anggota baru disetujui.',
|
||||
color: 'success'
|
||||
};
|
||||
closeModal();
|
||||
};
|
||||
|
||||
const handleReject = () => {
|
||||
snackbar.value = {
|
||||
show: true,
|
||||
message: 'Pengajuan anggota baru ditolak.',
|
||||
color: 'error'
|
||||
};
|
||||
closeModal();
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader
|
||||
icon="mdi-shield-check"
|
||||
title="Detail Akun"
|
||||
:subtitle="currentDate"
|
||||
:show-add-button="false"
|
||||
theme="primary"
|
||||
/>
|
||||
|
||||
|
||||
<v-container class="py-6">
|
||||
|
||||
<!-- Profile Card -->
|
||||
<v-row class="mb-6">
|
||||
<v-col cols="12">
|
||||
<v-card class="pa-8" color="white" elevation="2" rounded="lg">
|
||||
<v-row class="align-center" no-gutters>
|
||||
<!-- Avatar -->
|
||||
<v-col cols="auto" class="mr-6">
|
||||
<v-card height="100" width="100" class="bg-lightPrimary d-flex align-center justify-center" elevation="0" rounded="lg">
|
||||
<v-icon size="50" color="primary">mdi-account-outline</v-icon>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<!-- Nama Lengkap & NIK -->
|
||||
<v-col cols="3">
|
||||
<div class="text-caption text-uppercase text-grey font-weight-bold">Nama Lengkap</div>
|
||||
<div class="text-h6 font-weight-bold text-primary-700 mb-4">{{ profileData.name }}</div>
|
||||
<div class="text-caption text-uppercase text-grey font-weight-bold">N.I.K</div>
|
||||
<div class="text-body2">{{ profileData.nim }}</div>
|
||||
</v-col>
|
||||
|
||||
<!-- Nomor RM & Nomor Telepon -->
|
||||
<v-col cols="3">
|
||||
<div class="text-caption text-uppercase text-grey font-weight-bold">Nomor RM</div>
|
||||
<div class="text-h6 font-weight-bold mb-4">{{ profileData.registrationNumber }}</div>
|
||||
<div class="text-caption text-uppercase text-grey font-weight-bold">Nomor Telepon</div>
|
||||
<div class="text-body2">{{ profileData.phone }}</div>
|
||||
</v-col>
|
||||
|
||||
<!-- Buttons -->
|
||||
<v-col cols="max" class="text-right">
|
||||
<v-chip
|
||||
v-if="profileData.verified"
|
||||
color="success"
|
||||
size="small"
|
||||
class="mb-3"
|
||||
>
|
||||
<v-icon start size="small">mdi-check-circle</v-icon>
|
||||
Terverifikasi
|
||||
</v-chip>
|
||||
<div>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
>
|
||||
<v-icon start>mdi-pencil</v-icon>
|
||||
Edit Profil
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Family Members Section -->
|
||||
<v-row class="mb-6">
|
||||
<v-col cols="12">
|
||||
<h3 class="text-h6 font-weight-bold">Anggota Keluarga Terhubung</h3>
|
||||
<p class="text-body-2 text-muted mb-4">Daftar anggota keluarga yang berada dalam satu kartu keluarga</p>
|
||||
|
||||
<v-card class="pa-2">
|
||||
<v-table class="elevation-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-left text-uppercase">Nama Anggota</th>
|
||||
<th class="text-center text-uppercase">Status</th>
|
||||
<th class="text-center text-uppercase">Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="member in familyMembers" :key="member.id">
|
||||
<td class="text-body-2 font-weight-bold ">{{ member.name }}</td>
|
||||
<td class="text-center">
|
||||
<v-chip
|
||||
:color="member.status === 'PENDING' ? 'secondary' : 'success'"
|
||||
size="small"
|
||||
>
|
||||
<v-icon start size="small">{{
|
||||
member.status === 'PENDING' ? 'mdi-clock-outline' : 'mdi-check-decagram'
|
||||
}}</v-icon>
|
||||
{{ member.status }}
|
||||
</v-chip>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<div class="d-flex justify-center gap-2">
|
||||
<v-btn
|
||||
v-if="member.status === 'PENDING'"
|
||||
size="small"
|
||||
color="primary"
|
||||
@click="openModal(member)"
|
||||
>
|
||||
<v-icon start>mdi-clipboard-check-outline</v-icon>
|
||||
Proses
|
||||
</v-btn>
|
||||
<div v-else>
|
||||
<v-btn
|
||||
size="small"
|
||||
color="error"
|
||||
variant="outlined"
|
||||
class="mr-2"
|
||||
>
|
||||
<v-icon start>mdi-delete</v-icon>
|
||||
Hapus
|
||||
</v-btn>
|
||||
<v-btn
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
>
|
||||
<v-icon start>mdi-pencil</v-icon>
|
||||
Edit
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Activity History Section -->
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<div class="d-flex align-center mb-6">
|
||||
<v-icon class="mr-2" color="primary">mdi-history</v-icon>
|
||||
<h3 class="text-h6 font-weight-bold">Riwayat Aktivitas</h3>
|
||||
</div>
|
||||
|
||||
|
||||
<v-card class="pa-6">
|
||||
<v-timeline
|
||||
density="compact"
|
||||
side="end">
|
||||
<v-timeline-item
|
||||
v-for="activity in activityHistory"
|
||||
:key="activity.id"
|
||||
:dot-color="activity.color"
|
||||
:icon="activity.icon"
|
||||
fill-dot
|
||||
>
|
||||
<div class="text-subtitle-1 font-weight-bold">{{ activity.title }}</div>
|
||||
<div class="text-caption text-muted">{{ activity.date }}</div>
|
||||
</v-timeline-item>
|
||||
</v-timeline>
|
||||
|
||||
<div class="text-center mt-6">
|
||||
<v-btn color="primary" variant="text">
|
||||
Lihat Semua Riwayat
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Verification Modal -->
|
||||
<v-dialog v-model="isModalOpen" max-width="700px" persistent scrollable>
|
||||
<v-card class="pa-0">
|
||||
<!-- Modal Header -->
|
||||
<v-card-title class="bg-primary text-white pa-6">
|
||||
<div class="d-flex justify-space-between align-center w-100">
|
||||
<h2 class="text-h5 font-weight-bold">Verifikasi Pengajuan Anggota Keluarga</h2>
|
||||
<v-btn icon="mdi-close" variant="text" @click="closeModal" class="text-white"></v-btn>
|
||||
</div>
|
||||
</v-card-title>
|
||||
|
||||
<!-- Form Content - Scrollable -->
|
||||
<v-card-text class="pa-6 bg-light">
|
||||
<!-- Informasi Data Diri Section - Card -->
|
||||
<v-card class="mb-6 pa-6" variant="flat">
|
||||
<div class="d-flex align-center mb-4">
|
||||
<h3 class="text-subtitle-1 font-weight-bold text-primary">INFORMASI DATA DIRI</h3>
|
||||
</div>
|
||||
|
||||
<v-form>
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<v-text-field
|
||||
v-model="modalData.namaLengkap"
|
||||
label="Nama Lengkap"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-text-field
|
||||
v-model="modalData.tanggalLahir"
|
||||
label="Tanggal Lahir"
|
||||
type="date"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-select
|
||||
v-model="modalData.jenisKelamin"
|
||||
:items="genderOptions"
|
||||
label="Jenis Kelamin"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
></v-select>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" sm="6">
|
||||
<v-text-field
|
||||
v-model="modalData.nik"
|
||||
label="NIK"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-select
|
||||
v-model="modalData.hubungan"
|
||||
:items="relationshipOptions"
|
||||
label="Hubungan"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
></v-select>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-form>
|
||||
</v-card>
|
||||
|
||||
<!-- Kontak & Alamat Section - Card -->
|
||||
<v-card class="mb-6 pa-6" variant="flat">
|
||||
<div class="d-flex align-center mb-4">
|
||||
<h3 class="text-subtitle-1 font-weight-bold text-primary">KONTAK & ALAMAT</h3>
|
||||
</div>
|
||||
|
||||
<v-form>
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<v-text-field
|
||||
v-model="modalData.nomorTelepon"
|
||||
label="Nomor Telepon"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12">
|
||||
<v-text-field
|
||||
v-model="modalData.alamat"
|
||||
label="Alamat"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-form>
|
||||
</v-card>
|
||||
</v-card-text>
|
||||
|
||||
<!-- Modal Actions -->
|
||||
<v-divider></v-divider>
|
||||
<v-card-actions class="pa-6 justify-end">
|
||||
<v-btn
|
||||
color="error"
|
||||
variant="outlined"
|
||||
@click="handleReject"
|
||||
>
|
||||
<v-icon start>mdi-close</v-icon>
|
||||
Tolak
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="handleApprove"
|
||||
>
|
||||
<v-icon start>mdi-check</v-icon>
|
||||
Setujui
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Snackbar -->
|
||||
<v-snackbar v-model="snackbar.show" :color="snackbar.color" :timeout="3000">
|
||||
<span class="body-3">{{ snackbar.message }}</span>
|
||||
<template #actions>
|
||||
<v-btn variant="text" size="small" @click="snackbar.show = false">Tutup</v-btn>
|
||||
</template>
|
||||
</v-snackbar>
|
||||
</v-container>
|
||||
</div>
|
||||
</template>
|
||||
@@ -96,11 +96,28 @@
|
||||
size="small"
|
||||
@click="openDaftarModal(item)"
|
||||
variant="flat"
|
||||
class="btn-verify"
|
||||
class="btn-verify mt-2"
|
||||
>
|
||||
<v-icon size="16" left>mdi-qrcode-scan</v-icon>
|
||||
Verifikasi
|
||||
</v-btn>
|
||||
<v-btn
|
||||
class="my-2"
|
||||
color="primary"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
@click="router.push(`/VerifikasiAkun/DetailAkun`)"
|
||||
>
|
||||
<v-icon size="16" left>mdi-account-cog-outline</v-icon>
|
||||
Kelola Akun
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
|
||||
<template v-slot:item.pending="{ item }">
|
||||
<v-chip size="small" class="chip-orange">
|
||||
{{ item.pending || 2 }}
|
||||
</v-chip>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card-text>
|
||||
@@ -137,6 +154,7 @@
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
|
||||
import { useDisplay } from 'vuetify';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useRouter } from 'vue-router';
|
||||
import QrcodeVue from 'qrcode.vue';
|
||||
import PageHeader from '@/components/common/PageHeader.vue';
|
||||
import QRVerificationDialog from '@/components/verification/QRVerificationDialog.vue';
|
||||
@@ -149,6 +167,7 @@ definePageMeta({
|
||||
const display = useDisplay();
|
||||
const verificationStore = useVerificationStore();
|
||||
const { patients, loading, error } = storeToRefs(verificationStore);
|
||||
const router = useRouter();
|
||||
|
||||
// Load initial patients on mount
|
||||
onMounted(() => {
|
||||
@@ -160,9 +179,10 @@ const headers = ref([
|
||||
{ title: 'No', value: 'no', sortable: false, width: '60px', align: 'center' },
|
||||
{ title: 'Nama Pasien', value: 'nama', sortable: true, align: 'center' },
|
||||
{ title: 'No. RM', value: 'rm', sortable: true, align: 'center' },
|
||||
{ title: 'Alamat', value: 'alamat', sortable: false, align: 'center' },
|
||||
{ title: 'Alamat', value: 'alamat', sortable: false, align: 'left' },
|
||||
{ title: 'No. Telepon', value: 'telepon', sortable: false, align: 'center' },
|
||||
{ title: 'Status', value: 'status', sortable: true, width: '180px', align: 'center' },
|
||||
{ title: 'Pending', value: 'pending', sortable: false, width: '120px', align: 'center' },
|
||||
{ title: 'Actions', value: 'actions', sortable: false, width: '200px', align: 'center' },
|
||||
]);
|
||||
|
||||
@@ -396,10 +416,10 @@ $font-weight-semibold: 600;
|
||||
color: $neutral-800;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
text-align: center !important;
|
||||
// text-align: center !important;
|
||||
|
||||
.v-data-table-header__content {
|
||||
justify-content: center !important;
|
||||
// justify-content: center !important;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -408,20 +428,28 @@ $font-weight-semibold: 600;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
color: $neutral-900;
|
||||
text-align: center !important;
|
||||
// text-align: center !important;
|
||||
}
|
||||
|
||||
// Ensure the cell content itself is centered if it contains flex/divs
|
||||
:deep(.v-data-table__td > *) {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
// justify-content: center;
|
||||
// align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CHIPS
|
||||
// ============================================
|
||||
.chip-orange {
|
||||
background-color: #FE6B22 !important;
|
||||
color: $neutral-100 !important;
|
||||
font-weight: $font-weight-medium;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.chip-success {
|
||||
background-color: #009262 !important;
|
||||
color: $neutral-100 !important;
|
||||
|
||||
+14
-1
@@ -10,7 +10,20 @@ import { aliases, mdi } from 'vuetify/iconsets/mdi-svg'
|
||||
export default defineNuxtPlugin((app) => {
|
||||
const vuetify = createVuetify({
|
||||
ssr: true,
|
||||
blueprint: md2
|
||||
blueprint: md2,
|
||||
theme: {
|
||||
themes: {
|
||||
light: {
|
||||
colors: {
|
||||
primary: '#3A5FBC',
|
||||
lightPrimary: '#DBE1FF',
|
||||
secondary: '#E65A0D',
|
||||
error: '#D82719',
|
||||
success: '#008D65',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
app.vueApp.use(vuetify)
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
// server/api/config/anjungan.get.ts
|
||||
import { getConfigDb, parseConfigRow } from '~/server/utils/configDb';
|
||||
|
||||
export default defineEventHandler(() => {
|
||||
const db = getConfigDb();
|
||||
const rows = db.prepare('SELECT * FROM config_anjungan ORDER BY id ASC').all();
|
||||
return { success: true, data: rows.map(parseConfigRow) };
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
// server/api/config/anjungan.post.ts
|
||||
import { getConfigDb, parseConfigRow } from '~/server/utils/configDb';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event);
|
||||
const { namaAnjungan, jenisPasien, klinik = [] } = body;
|
||||
|
||||
if (!namaAnjungan || !jenisPasien) {
|
||||
throw createError({ statusCode: 400, message: 'namaAnjungan dan jenisPasien wajib diisi' });
|
||||
}
|
||||
|
||||
const db = getConfigDb();
|
||||
const result = db.prepare(
|
||||
`INSERT INTO config_anjungan (namaAnjungan, jenisPasien, klinik) VALUES (?, ?, ?)`
|
||||
).run(namaAnjungan, jenisPasien, JSON.stringify(klinik));
|
||||
|
||||
const newRow = db.prepare('SELECT * FROM config_anjungan WHERE id = ?').get(result.lastInsertRowid);
|
||||
return { success: true, data: parseConfigRow(newRow), message: `Anjungan ${namaAnjungan} berhasil ditambahkan` };
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
// server/api/config/anjungan/[id].delete.ts
|
||||
import { getConfigDb } from '~/server/utils/configDb';
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const id = Number(getRouterParam(event, 'id'));
|
||||
if (!id) throw createError({ statusCode: 400, message: 'ID tidak valid' });
|
||||
|
||||
const db = getConfigDb();
|
||||
const existing = db.prepare('SELECT * FROM config_anjungan WHERE id = ?').get(id) as any;
|
||||
if (!existing) throw createError({ statusCode: 404, message: 'Anjungan tidak ditemukan' });
|
||||
|
||||
db.prepare('DELETE FROM config_anjungan WHERE id = ?').run(id);
|
||||
return { success: true, message: `Anjungan ${existing.namaAnjungan} berhasil dihapus` };
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
// server/api/config/anjungan/[id].put.ts
|
||||
import { getConfigDb, parseConfigRow } from '~/server/utils/configDb';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const id = Number(getRouterParam(event, 'id'));
|
||||
if (!id) throw createError({ statusCode: 400, message: 'ID tidak valid' });
|
||||
|
||||
const body = await readBody(event);
|
||||
const { namaAnjungan, jenisPasien, klinik } = body;
|
||||
|
||||
const db = getConfigDb();
|
||||
const existing = db.prepare('SELECT * FROM config_anjungan WHERE id = ?').get(id) as any;
|
||||
if (!existing) throw createError({ statusCode: 404, message: 'Anjungan tidak ditemukan' });
|
||||
|
||||
db.prepare(
|
||||
`UPDATE config_anjungan SET
|
||||
namaAnjungan = ?,
|
||||
jenisPasien = ?,
|
||||
klinik = ?,
|
||||
updatedAt = strftime('%s', 'now')
|
||||
WHERE id = ?`
|
||||
).run(
|
||||
namaAnjungan ?? existing.namaAnjungan,
|
||||
jenisPasien ?? existing.jenisPasien,
|
||||
klinik !== undefined ? JSON.stringify(klinik) : existing.klinik,
|
||||
id
|
||||
);
|
||||
|
||||
const updated = db.prepare('SELECT * FROM config_anjungan WHERE id = ?').get(id);
|
||||
return { success: true, data: parseConfigRow(updated), message: 'Anjungan berhasil diperbarui' };
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
// server/api/config/antrean-masuk-screen.get.ts
|
||||
import { getConfigDb, parseConfigRow } from '~/server/utils/configDb';
|
||||
|
||||
export default defineEventHandler(() => {
|
||||
const db = getConfigDb();
|
||||
const rows = db.prepare('SELECT * FROM config_antrean_masuk_screen ORDER BY id ASC').all();
|
||||
return { success: true, data: rows.map(parseConfigRow) };
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
// server/api/config/antrean-masuk-screen.post.ts
|
||||
import { getConfigDb, parseConfigRow } from '~/server/utils/configDb';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event);
|
||||
const { namaScreen, nomorScreen, loket = [] } = body;
|
||||
|
||||
if (!namaScreen || !nomorScreen) {
|
||||
throw createError({ statusCode: 400, message: 'namaScreen dan nomorScreen wajib diisi' });
|
||||
}
|
||||
|
||||
const db = getConfigDb();
|
||||
try {
|
||||
const result = db.prepare(
|
||||
`INSERT INTO config_antrean_masuk_screen (namaScreen, nomorScreen, loket) VALUES (?, ?, ?)`
|
||||
).run(namaScreen, nomorScreen, JSON.stringify(loket));
|
||||
|
||||
const newRow = db.prepare('SELECT * FROM config_antrean_masuk_screen WHERE id = ?').get(result.lastInsertRowid);
|
||||
return { success: true, data: parseConfigRow(newRow), message: `Screen ${namaScreen} berhasil ditambahkan` };
|
||||
} catch (e: any) {
|
||||
if (e.message?.includes('UNIQUE')) {
|
||||
throw createError({ statusCode: 409, message: `nomorScreen "${nomorScreen}" sudah digunakan` });
|
||||
}
|
||||
throw createError({ statusCode: 500, message: e.message });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
// server/api/config/antrean-masuk-screen/[id].delete.ts
|
||||
import { getConfigDb } from '~/server/utils/configDb';
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const id = Number(getRouterParam(event, 'id'));
|
||||
if (!id) throw createError({ statusCode: 400, message: 'ID tidak valid' });
|
||||
|
||||
const db = getConfigDb();
|
||||
const existing = db.prepare('SELECT * FROM config_antrean_masuk_screen WHERE id = ?').get(id) as any;
|
||||
if (!existing) throw createError({ statusCode: 404, message: 'Screen tidak ditemukan' });
|
||||
|
||||
db.prepare('DELETE FROM config_antrean_masuk_screen WHERE id = ?').run(id);
|
||||
return { success: true, message: `Screen ${existing.namaScreen} berhasil dihapus` };
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
// server/api/config/antrean-masuk-screen/[id].put.ts
|
||||
import { getConfigDb, parseConfigRow } from '~/server/utils/configDb';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const id = Number(getRouterParam(event, 'id'));
|
||||
if (!id) throw createError({ statusCode: 400, message: 'ID tidak valid' });
|
||||
|
||||
const body = await readBody(event);
|
||||
const { namaScreen, nomorScreen, loket } = body;
|
||||
|
||||
const db = getConfigDb();
|
||||
const existing = db.prepare('SELECT * FROM config_antrean_masuk_screen WHERE id = ?').get(id) as any;
|
||||
if (!existing) throw createError({ statusCode: 404, message: 'Screen tidak ditemukan' });
|
||||
|
||||
db.prepare(
|
||||
`UPDATE config_antrean_masuk_screen SET
|
||||
namaScreen = ?, nomorScreen = ?, loket = ?,
|
||||
updatedAt = strftime('%s', 'now')
|
||||
WHERE id = ?`
|
||||
).run(
|
||||
namaScreen ?? existing.namaScreen,
|
||||
nomorScreen ?? existing.nomorScreen,
|
||||
loket !== undefined ? JSON.stringify(loket) : existing.loket,
|
||||
id
|
||||
);
|
||||
|
||||
const updated = db.prepare('SELECT * FROM config_antrean_masuk_screen WHERE id = ?').get(id);
|
||||
return { success: true, data: parseConfigRow(updated), message: 'Screen berhasil diperbarui' };
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
// server/api/config/klinik-ruang.get.ts
|
||||
import { getConfigDb, parseConfigRow } from '~/server/utils/configDb';
|
||||
|
||||
export default defineEventHandler(() => {
|
||||
const db = getConfigDb();
|
||||
const rows = db.prepare('SELECT * FROM config_klinik_ruang ORDER BY id ASC').all();
|
||||
return { success: true, data: rows.map(parseConfigRow) };
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
// server/api/config/klinik-ruang.post.ts
|
||||
import { getConfigDb, parseConfigRow } from '~/server/utils/configDb';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event);
|
||||
const { kodeKlinik, namaKlinik, ruangList = [] } = body;
|
||||
|
||||
if (!kodeKlinik || !namaKlinik) {
|
||||
throw createError({ statusCode: 400, message: 'kodeKlinik dan namaKlinik wajib diisi' });
|
||||
}
|
||||
|
||||
const db = getConfigDb();
|
||||
const result = db.prepare(
|
||||
`INSERT INTO config_klinik_ruang (kodeKlinik, namaKlinik, ruangList) VALUES (?, ?, ?)`
|
||||
).run(kodeKlinik, namaKlinik, JSON.stringify(ruangList));
|
||||
|
||||
const newRow = db.prepare('SELECT * FROM config_klinik_ruang WHERE id = ?').get(result.lastInsertRowid);
|
||||
return { success: true, data: parseConfigRow(newRow), message: `Klinik Ruang ${namaKlinik} berhasil ditambahkan` };
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
// server/api/config/klinik-ruang/[id].delete.ts
|
||||
import { getConfigDb } from '~/server/utils/configDb';
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const id = Number(getRouterParam(event, 'id'));
|
||||
if (!id) throw createError({ statusCode: 400, message: 'ID tidak valid' });
|
||||
|
||||
const db = getConfigDb();
|
||||
const existing = db.prepare('SELECT * FROM config_klinik_ruang WHERE id = ?').get(id) as any;
|
||||
if (!existing) throw createError({ statusCode: 404, message: 'Klinik Ruang tidak ditemukan' });
|
||||
|
||||
db.prepare('DELETE FROM config_klinik_ruang WHERE id = ?').run(id);
|
||||
return { success: true, message: `Klinik Ruang ${existing.namaKlinik} berhasil dihapus` };
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
// server/api/config/klinik-ruang/[id].put.ts
|
||||
import { getConfigDb, parseConfigRow } from '~/server/utils/configDb';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const id = Number(getRouterParam(event, 'id'));
|
||||
if (!id) throw createError({ statusCode: 400, message: 'ID tidak valid' });
|
||||
|
||||
const body = await readBody(event);
|
||||
const { kodeKlinik, namaKlinik, ruangList } = body;
|
||||
|
||||
const db = getConfigDb();
|
||||
const existing = db.prepare('SELECT * FROM config_klinik_ruang WHERE id = ?').get(id) as any;
|
||||
if (!existing) throw createError({ statusCode: 404, message: 'Klinik Ruang tidak ditemukan' });
|
||||
|
||||
db.prepare(
|
||||
`UPDATE config_klinik_ruang SET
|
||||
kodeKlinik = ?, namaKlinik = ?, ruangList = ?,
|
||||
updatedAt = strftime('%s', 'now')
|
||||
WHERE id = ?`
|
||||
).run(
|
||||
kodeKlinik ?? existing.kodeKlinik,
|
||||
namaKlinik ?? existing.namaKlinik,
|
||||
ruangList !== undefined ? JSON.stringify(ruangList) : existing.ruangList,
|
||||
id
|
||||
);
|
||||
|
||||
const updated = db.prepare('SELECT * FROM config_klinik_ruang WHERE id = ?').get(id);
|
||||
return { success: true, data: parseConfigRow(updated), message: 'Klinik Ruang berhasil diperbarui' };
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
// server/api/config/screen.get.ts
|
||||
import { getConfigDb, parseConfigRow } from '~/server/utils/configDb';
|
||||
|
||||
export default defineEventHandler(() => {
|
||||
const db = getConfigDb();
|
||||
const rows = db.prepare('SELECT * FROM config_screen ORDER BY id ASC').all();
|
||||
return { success: true, data: rows.map(parseConfigRow) };
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
// server/api/config/screen.post.ts
|
||||
import { getConfigDb, parseConfigRow } from '~/server/utils/configDb';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event);
|
||||
const { namaScreen, nomorScreen, klinik = [] } = body;
|
||||
|
||||
if (!namaScreen || !nomorScreen) {
|
||||
throw createError({ statusCode: 400, message: 'namaScreen dan nomorScreen wajib diisi' });
|
||||
}
|
||||
|
||||
const db = getConfigDb();
|
||||
try {
|
||||
const result = db.prepare(
|
||||
`INSERT INTO config_screen (namaScreen, nomorScreen, klinik) VALUES (?, ?, ?)`
|
||||
).run(namaScreen, nomorScreen, JSON.stringify(klinik));
|
||||
|
||||
const newRow = db.prepare('SELECT * FROM config_screen WHERE id = ?').get(result.lastInsertRowid);
|
||||
return { success: true, data: parseConfigRow(newRow), message: `Screen ${namaScreen} berhasil ditambahkan` };
|
||||
} catch (e: any) {
|
||||
if (e.message?.includes('UNIQUE')) {
|
||||
throw createError({ statusCode: 409, message: `nomorScreen "${nomorScreen}" sudah digunakan` });
|
||||
}
|
||||
throw createError({ statusCode: 500, message: e.message });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
// server/api/config/screen/[id].delete.ts
|
||||
import { getConfigDb } from '~/server/utils/configDb';
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const id = Number(getRouterParam(event, 'id'));
|
||||
if (!id) throw createError({ statusCode: 400, message: 'ID tidak valid' });
|
||||
|
||||
const db = getConfigDb();
|
||||
const existing = db.prepare('SELECT * FROM config_screen WHERE id = ?').get(id) as any;
|
||||
if (!existing) throw createError({ statusCode: 404, message: 'Screen tidak ditemukan' });
|
||||
|
||||
db.prepare('DELETE FROM config_screen WHERE id = ?').run(id);
|
||||
return { success: true, message: `Screen ${existing.namaScreen} berhasil dihapus` };
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
// server/api/config/screen/[id].put.ts
|
||||
import { getConfigDb, parseConfigRow } from '~/server/utils/configDb';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const id = Number(getRouterParam(event, 'id'));
|
||||
if (!id) throw createError({ statusCode: 400, message: 'ID tidak valid' });
|
||||
|
||||
const body = await readBody(event);
|
||||
const { namaScreen, nomorScreen, klinik } = body;
|
||||
|
||||
const db = getConfigDb();
|
||||
const existing = db.prepare('SELECT * FROM config_screen WHERE id = ?').get(id);
|
||||
if (!existing) throw createError({ statusCode: 404, message: 'Screen tidak ditemukan' });
|
||||
|
||||
db.prepare(
|
||||
`UPDATE config_screen SET
|
||||
namaScreen = ?,
|
||||
nomorScreen = ?,
|
||||
klinik = ?,
|
||||
updatedAt = strftime('%s', 'now')
|
||||
WHERE id = ?`
|
||||
).run(
|
||||
namaScreen ?? (existing as any).namaScreen,
|
||||
nomorScreen ?? (existing as any).nomorScreen,
|
||||
klinik !== undefined ? JSON.stringify(klinik) : (existing as any).klinik,
|
||||
id
|
||||
);
|
||||
|
||||
const updated = db.prepare('SELECT * FROM config_screen WHERE id = ?').get(id);
|
||||
return { success: true, data: parseConfigRow(updated), message: 'Screen berhasil diperbarui' };
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { defineEventHandler, readBody } from 'h3';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// Path to the mock JSON file
|
||||
const filePath = path.resolve(process.cwd(), 'public/data/patient_subspesialis.json');
|
||||
|
||||
// Ensure directory and file exist
|
||||
const ensureFileExists = () => {
|
||||
const dirPath = path.dirname(filePath);
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
if (!fs.existsSync(filePath)) {
|
||||
fs.writeFileSync(filePath, JSON.stringify({}, null, 2), 'utf-8');
|
||||
}
|
||||
};
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
ensureFileExists();
|
||||
|
||||
if (event.node.req.method === 'GET') {
|
||||
// Return all mappings
|
||||
try {
|
||||
const data = fs.readFileSync(filePath, 'utf-8');
|
||||
return JSON.parse(data);
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
if (event.node.req.method === 'POST') {
|
||||
// Save a new mapping
|
||||
try {
|
||||
const body = await readBody(event);
|
||||
const { barcode, subspesialis } = body;
|
||||
|
||||
if (!barcode || !subspesialis) {
|
||||
return { success: false, message: 'Barcode and subspesialis are required' };
|
||||
}
|
||||
|
||||
const fileData = fs.readFileSync(filePath, 'utf-8');
|
||||
const data = JSON.parse(fileData);
|
||||
|
||||
// Save mapping: barcode -> subspesialis object
|
||||
data[barcode] = subspesialis;
|
||||
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
|
||||
return { success: true, message: 'Saved successfully' };
|
||||
} catch (e) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
// server/utils/configDb.ts
|
||||
// Shared SQLite utility for device configuration (screens, anjungan, klinik-ruang, etc.)
|
||||
// Designed to be a temporary layer - can be swapped to an external API with minimal changes.
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
import { join } from 'path';
|
||||
import { existsSync, mkdirSync } from 'fs';
|
||||
|
||||
const getDbPath = () => {
|
||||
const dbDir = join(process.cwd(), 'data');
|
||||
if (!existsSync(dbDir)) mkdirSync(dbDir, { recursive: true });
|
||||
return join(dbDir, 'users.db'); // Reuse same DB file
|
||||
};
|
||||
|
||||
// ─── Default Seed Data ───────────────────────────────────────────────────────
|
||||
// Used when tables are empty on first run. Mirrors the hardcoded defaults in Pinia stores.
|
||||
|
||||
const SEED_SCREENS = [
|
||||
{ namaScreen: 'Layar Screen 1', nomorScreen: 'SCR-001', klinik: JSON.stringify(['AN', 'AS', 'BD', 'GI', 'GR', 'GZ', 'IP', 'JT']) },
|
||||
{ namaScreen: 'Layar Screen 2', nomorScreen: 'SCR-002', klinik: JSON.stringify(['JW', 'KK', 'MT', 'SR', 'OB', 'PR']) },
|
||||
{ namaScreen: 'Layar Screen 3', nomorScreen: 'SCR-003', klinik: JSON.stringify(['RT', 'RM', 'HO']) },
|
||||
];
|
||||
|
||||
const SEED_ANJUNGAN = [
|
||||
{ namaAnjungan: 'Anjungan Reguler', jenisPasien: 'Reguler', klinik: JSON.stringify(['AK', 'AN', 'BD', 'GR', 'GM', 'GZ', 'HO', 'IP', 'JT', 'JW', 'KD', 'KK', 'KM', 'KO', 'MC', 'MT', 'ON', 'PR', 'RD', 'RM', 'RT', 'SR', 'TH']) },
|
||||
{ namaAnjungan: 'Anjungan Eksekutif', jenisPasien: 'Eksekutif', klinik: JSON.stringify(['AK', 'AN', 'BD', 'GM', 'GR', 'GZ', 'HO', 'IP', 'JT', 'JW', 'KD', 'KK', 'KM', 'KO', 'MC', 'MT', 'ON', 'PR', 'RD', 'RM', 'RT', 'SR', 'TH']) },
|
||||
];
|
||||
|
||||
const SEED_ANTREAN_MASUK_SCREENS = [
|
||||
{ namaScreen: 'Layar Antrean Masuk 1', nomorScreen: 'AM-001', loket: JSON.stringify([1, 2, 12, 14]) },
|
||||
{ namaScreen: 'Layar Antrean Masuk 2', nomorScreen: 'AM-002', loket: JSON.stringify([3, 4]) },
|
||||
];
|
||||
|
||||
const SEED_KLINIK_RUANG = [
|
||||
{ kodeKlinik: 'AN', namaKlinik: 'ANAK', ruangList: JSON.stringify([{ nomorRuang: '1', namaRuang: 'R. TINDAKAN', nomorScreen: '101' }]) },
|
||||
{ kodeKlinik: 'AS', namaKlinik: 'ANESTESI', ruangList: JSON.stringify([{ nomorRuang: '1', namaRuang: 'Ruang 1', nomorScreen: '201' }, { nomorRuang: '2', namaRuang: 'Ruang 2', nomorScreen: '202' }, { nomorRuang: '3', namaRuang: 'Ruang 3', nomorScreen: '203' }]) },
|
||||
{ kodeKlinik: 'BD', namaKlinik: 'BEDAH', ruangList: JSON.stringify([{ nomorRuang: '1', namaRuang: 'Ruang Konsultasi', nomorScreen: '301' }]) },
|
||||
{ kodeKlinik: 'GR', namaKlinik: 'GERIATRI', ruangList: JSON.stringify([{ nomorRuang: '1', namaRuang: 'Ruang Pemeriksaan', nomorScreen: '401' }]) },
|
||||
{ kodeKlinik: 'GI', namaKlinik: 'GIGI DAN MULUT', ruangList: JSON.stringify([{ nomorRuang: '1', namaRuang: 'Ruang 1', nomorScreen: '501' }, { nomorRuang: '2', namaRuang: 'Ruang 2', nomorScreen: '502' }, { nomorRuang: '3', namaRuang: 'Ruang 3', nomorScreen: '503' }]) },
|
||||
];
|
||||
|
||||
// ─── DB Initialization ────────────────────────────────────────────────────────
|
||||
|
||||
let _db: InstanceType<typeof Database> | null = null;
|
||||
|
||||
export const getConfigDb = () => {
|
||||
if (_db) return _db;
|
||||
|
||||
const dbPath = getDbPath();
|
||||
const db = new Database(dbPath);
|
||||
db.pragma('journal_mode = WAL'); // Better concurrent performance
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS config_screen (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
namaScreen TEXT NOT NULL,
|
||||
nomorScreen TEXT UNIQUE NOT NULL,
|
||||
klinik TEXT DEFAULT '[]',
|
||||
createdAt INTEGER DEFAULT (strftime('%s', 'now')),
|
||||
updatedAt INTEGER DEFAULT (strftime('%s', 'now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS config_anjungan (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
namaAnjungan TEXT NOT NULL,
|
||||
jenisPasien TEXT NOT NULL,
|
||||
klinik TEXT DEFAULT '[]',
|
||||
createdAt INTEGER DEFAULT (strftime('%s', 'now')),
|
||||
updatedAt INTEGER DEFAULT (strftime('%s', 'now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS config_antrean_masuk_screen (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
namaScreen TEXT NOT NULL,
|
||||
nomorScreen TEXT UNIQUE NOT NULL,
|
||||
loket TEXT DEFAULT '[]',
|
||||
createdAt INTEGER DEFAULT (strftime('%s', 'now')),
|
||||
updatedAt INTEGER DEFAULT (strftime('%s', 'now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS config_klinik_ruang (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
kodeKlinik TEXT NOT NULL,
|
||||
namaKlinik TEXT NOT NULL,
|
||||
ruangList TEXT DEFAULT '[]',
|
||||
createdAt INTEGER DEFAULT (strftime('%s', 'now')),
|
||||
updatedAt INTEGER DEFAULT (strftime('%s', 'now'))
|
||||
);
|
||||
`);
|
||||
|
||||
// Seed defaults if tables are empty
|
||||
_seedIfEmpty(db, 'config_screen', SEED_SCREENS);
|
||||
_seedIfEmpty(db, 'config_anjungan', SEED_ANJUNGAN);
|
||||
_seedIfEmpty(db, 'config_antrean_masuk_screen', SEED_ANTREAN_MASUK_SCREENS);
|
||||
_seedIfEmpty(db, 'config_klinik_ruang', SEED_KLINIK_RUANG);
|
||||
|
||||
console.log('✅ [configDb] Config database initialized');
|
||||
_db = db;
|
||||
return db;
|
||||
};
|
||||
|
||||
function _seedIfEmpty(db: InstanceType<typeof Database>, table: string, rows: Record<string, any>[]) {
|
||||
const count = (db.prepare(`SELECT COUNT(*) as c FROM ${table}`).get() as any).c;
|
||||
if (count === 0) {
|
||||
console.log(`🌱 [configDb] Seeding ${table} with ${rows.length} default rows`);
|
||||
const keys = Object.keys(rows[0]);
|
||||
const placeholders = keys.map(() => '?').join(', ');
|
||||
const stmt = db.prepare(`INSERT INTO ${table} (${keys.join(', ')}) VALUES (${placeholders})`);
|
||||
rows.forEach(row => stmt.run(...keys.map(k => row[k])));
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helper: Parse JSON columns ───────────────────────────────────────────────
|
||||
export const parseConfigRow = (row: any) => {
|
||||
if (!row) return null;
|
||||
const parsed = { ...row };
|
||||
// Auto-parse any column that looks like a JSON array/object
|
||||
for (const key of Object.keys(parsed)) {
|
||||
if (typeof parsed[key] === 'string' && (parsed[key].startsWith('[') || parsed[key].startsWith('{'))) {
|
||||
try { parsed[key] = JSON.parse(parsed[key]); } catch {}
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
+48
-60
@@ -1,23 +1,12 @@
|
||||
// stores/anjunganStore.js
|
||||
// Konfigurasi anjungan — data disimpan di server SQLite via /api/config/anjungan
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
export const useAnjunganStore = defineStore('anjungan', () => {
|
||||
// Seed: existing static Anjungan page sebagai id 1
|
||||
const anjunganItems = ref([
|
||||
{
|
||||
id: 1,
|
||||
namaAnjungan: 'Anjungan Reguler',
|
||||
jenisPasien: 'Reguler',
|
||||
klinik: ['AK', 'AN', 'BD', 'GR', 'GM', 'GZ', 'HO', 'IP', 'JT', 'JW', 'KD', 'KK', 'KM', 'KO', 'MC', 'MT', 'ON', 'PR', 'RD', 'RM', 'RT', 'SR', 'TH'],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
namaAnjungan: 'Anjungan Eksekutif',
|
||||
jenisPasien: 'Eksekutif',
|
||||
klinik: ['AK', 'AN', 'BD', 'GM', 'GR', 'GZ', 'HO', 'IP', 'JT', 'JW', 'KD', 'KK', 'KM', 'KO', 'MC', 'MT', 'ON', 'PR', 'RD', 'RM', 'RT', 'SR', 'TH'],
|
||||
},
|
||||
]);
|
||||
const anjunganItems = ref([]);
|
||||
const isLoading = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
const getAllAnjungan = computed(() => anjunganItems.value);
|
||||
|
||||
@@ -28,66 +17,65 @@ export const useAnjunganStore = defineStore('anjungan', () => {
|
||||
});
|
||||
};
|
||||
|
||||
const addAnjungan = (payload) => {
|
||||
const maxId =
|
||||
anjunganItems.value.length > 0
|
||||
? Math.max(...anjunganItems.value.map((a) => Number(a.id) || 0))
|
||||
: 0;
|
||||
const newId = maxId + 1;
|
||||
const newItem = {
|
||||
...payload,
|
||||
id: newId,
|
||||
};
|
||||
anjunganItems.value.push(newItem);
|
||||
return {
|
||||
success: true,
|
||||
message: `Anjungan ${newItem.namaAnjungan} berhasil ditambahkan`,
|
||||
data: newItem,
|
||||
};
|
||||
// ── Fetch from API ─────────────────────────────────────────────────────────
|
||||
const fetchAnjungan = async () => {
|
||||
if (isLoading.value) return;
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const res = await $fetch('/api/config/anjungan');
|
||||
anjunganItems.value = res.data || [];
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
console.error('❌ [anjunganStore] Gagal fetch anjungan config:', e);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const updateAnjungan = (payload) => {
|
||||
const idx = anjunganItems.value.findIndex(
|
||||
(a) => Number(a.id) === Number(payload.id)
|
||||
);
|
||||
if (idx === -1) {
|
||||
return { success: false, message: 'Anjungan tidak ditemukan' };
|
||||
// ── CRUD via API ───────────────────────────────────────────────────────────
|
||||
const addAnjungan = async (payload) => {
|
||||
try {
|
||||
const res = await $fetch('/api/config/anjungan', { method: 'POST', body: payload });
|
||||
anjunganItems.value.push(res.data);
|
||||
return { success: true, message: res.message, data: res.data };
|
||||
} catch (e) {
|
||||
return { success: false, message: e.data?.message || e.message };
|
||||
}
|
||||
anjunganItems.value[idx] = {
|
||||
...anjunganItems.value[idx],
|
||||
...payload,
|
||||
};
|
||||
return {
|
||||
success: true,
|
||||
message: `Anjungan ${payload.namaAnjungan} berhasil diperbarui`,
|
||||
};
|
||||
};
|
||||
|
||||
const deleteAnjungan = (id) => {
|
||||
const idx = anjunganItems.value.findIndex(
|
||||
(a) => Number(a.id) === Number(id)
|
||||
);
|
||||
if (idx === -1) {
|
||||
return { success: false, message: 'Anjungan tidak ditemukan' };
|
||||
const updateAnjungan = async (payload) => {
|
||||
try {
|
||||
const res = await $fetch(`/api/config/anjungan/${payload.id}`, { method: 'PUT', body: payload });
|
||||
const idx = anjunganItems.value.findIndex((a) => Number(a.id) === Number(payload.id));
|
||||
if (idx !== -1) anjunganItems.value[idx] = res.data;
|
||||
return { success: true, message: res.message };
|
||||
} catch (e) {
|
||||
return { success: false, message: e.data?.message || e.message };
|
||||
}
|
||||
};
|
||||
|
||||
const deleteAnjungan = async (id) => {
|
||||
try {
|
||||
const res = await $fetch(`/api/config/anjungan/${id}`, { method: 'DELETE' });
|
||||
anjunganItems.value = anjunganItems.value.filter((a) => Number(a.id) !== Number(id));
|
||||
return { success: true, message: res.message };
|
||||
} catch (e) {
|
||||
return { success: false, message: e.data?.message || e.message };
|
||||
}
|
||||
const name = anjunganItems.value[idx].namaAnjungan;
|
||||
anjunganItems.value.splice(idx, 1);
|
||||
return { success: true, message: `Anjungan ${name} berhasil dihapus` };
|
||||
};
|
||||
|
||||
return {
|
||||
anjunganItems,
|
||||
isLoading,
|
||||
error,
|
||||
getAllAnjungan,
|
||||
getAnjunganById,
|
||||
fetchAnjungan,
|
||||
addAnjungan,
|
||||
updateAnjungan,
|
||||
deleteAnjungan,
|
||||
};
|
||||
}, {
|
||||
persist: {
|
||||
key: 'anjungan-store-state',
|
||||
storage: typeof window !== 'undefined' ? localStorage : undefined,
|
||||
paths: ['anjunganItems'],
|
||||
},
|
||||
persist: false,
|
||||
});
|
||||
|
||||
@@ -1,25 +1,15 @@
|
||||
// stores/antreanMasukScreenStore.js
|
||||
// Konfigurasi layar antrean masuk — data disimpan di server SQLite via /api/config/antrean-masuk-screen
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
export const useAntreanMasukScreenStore = defineStore('antreanMasukScreen', () => {
|
||||
// Initial antrean masuk screen items data
|
||||
const antreanMasukScreenItems = ref([
|
||||
{
|
||||
id: 1,
|
||||
namaScreen: "Layar Antrean Masuk 1",
|
||||
nomorScreen: "AM-001",
|
||||
loket: [1, 2, 12, 14], // Array of loket IDs
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
namaScreen: "Layar Antrean Masuk 2",
|
||||
nomorScreen: "AM-002",
|
||||
loket: [3, 4],
|
||||
},
|
||||
]);
|
||||
const antreanMasukScreenItems = ref([]);
|
||||
const isLoading = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
const getAllAntreanMasukScreens = computed(() => antreanMasukScreenItems.value);
|
||||
|
||||
// Computed
|
||||
const getAntreanMasukScreenById = (id) => {
|
||||
return computed(() => {
|
||||
const targetId = Number(id);
|
||||
@@ -27,63 +17,65 @@ export const useAntreanMasukScreenStore = defineStore('antreanMasukScreen', () =
|
||||
});
|
||||
};
|
||||
|
||||
const getAllAntreanMasukScreens = computed(() => antreanMasukScreenItems.value);
|
||||
|
||||
// Actions
|
||||
const addAntreanMasukScreen = (screenPayload) => {
|
||||
// Ensure we get a valid ID even if antreanMasukScreenItems is empty
|
||||
const maxId = antreanMasukScreenItems.value.length > 0
|
||||
? Math.max(...antreanMasukScreenItems.value.map(s => s.id), 0)
|
||||
: 0;
|
||||
const newId = maxId + 1;
|
||||
// Pastikan id baru tidak tertimpa payload (payload.id bisa null)
|
||||
const newScreen = {
|
||||
...screenPayload,
|
||||
id: newId,
|
||||
};
|
||||
antreanMasukScreenItems.value.push(newScreen);
|
||||
return { success: true, message: `Screen ${newScreen.namaScreen} berhasil ditambahkan`, data: newScreen };
|
||||
// ── Fetch from API ─────────────────────────────────────────────────────────
|
||||
const fetchAntreanMasukScreens = async () => {
|
||||
if (isLoading.value) return;
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const res = await $fetch('/api/config/antrean-masuk-screen');
|
||||
antreanMasukScreenItems.value = res.data || [];
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
console.error('❌ [antreanMasukScreenStore] Gagal fetch config:', e);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const updateAntreanMasukScreen = (screenPayload) => {
|
||||
const index = antreanMasukScreenItems.value.findIndex(s => s.id === screenPayload.id);
|
||||
if (index !== -1) {
|
||||
antreanMasukScreenItems.value[index] = {
|
||||
...antreanMasukScreenItems.value[index],
|
||||
...screenPayload,
|
||||
};
|
||||
return { success: true, message: `Konfigurasi ${screenPayload.namaScreen} berhasil disimpan` };
|
||||
// ── CRUD via API ───────────────────────────────────────────────────────────
|
||||
const addAntreanMasukScreen = async (screenPayload) => {
|
||||
try {
|
||||
const res = await $fetch('/api/config/antrean-masuk-screen', { method: 'POST', body: screenPayload });
|
||||
antreanMasukScreenItems.value.push(res.data);
|
||||
return { success: true, message: res.message, data: res.data };
|
||||
} catch (e) {
|
||||
return { success: false, message: e.data?.message || e.message };
|
||||
}
|
||||
return { success: false, message: 'Screen tidak ditemukan' };
|
||||
};
|
||||
|
||||
const deleteAntreanMasukScreen = (screenId) => {
|
||||
const index = antreanMasukScreenItems.value.findIndex(s => s.id === screenId);
|
||||
if (index !== -1) {
|
||||
const screenName = antreanMasukScreenItems.value[index].namaScreen;
|
||||
antreanMasukScreenItems.value.splice(index, 1);
|
||||
return { success: true, message: `Screen ${screenName} berhasil dihapus` };
|
||||
const updateAntreanMasukScreen = async (screenPayload) => {
|
||||
try {
|
||||
const res = await $fetch(`/api/config/antrean-masuk-screen/${screenPayload.id}`, { method: 'PUT', body: screenPayload });
|
||||
const index = antreanMasukScreenItems.value.findIndex(s => s.id === screenPayload.id);
|
||||
if (index !== -1) antreanMasukScreenItems.value[index] = res.data;
|
||||
return { success: true, message: res.message };
|
||||
} catch (e) {
|
||||
return { success: false, message: e.data?.message || e.message };
|
||||
}
|
||||
};
|
||||
|
||||
const deleteAntreanMasukScreen = async (screenId) => {
|
||||
try {
|
||||
const res = await $fetch(`/api/config/antrean-masuk-screen/${screenId}`, { method: 'DELETE' });
|
||||
antreanMasukScreenItems.value = antreanMasukScreenItems.value.filter(s => s.id !== screenId);
|
||||
return { success: true, message: res.message };
|
||||
} catch (e) {
|
||||
return { success: false, message: e.data?.message || e.message };
|
||||
}
|
||||
return { success: false, message: 'Screen tidak ditemukan' };
|
||||
};
|
||||
|
||||
return {
|
||||
// State
|
||||
antreanMasukScreenItems,
|
||||
|
||||
// Computed
|
||||
isLoading,
|
||||
error,
|
||||
getAllAntreanMasukScreens,
|
||||
getAntreanMasukScreenById,
|
||||
|
||||
// Actions
|
||||
fetchAntreanMasukScreens,
|
||||
addAntreanMasukScreen,
|
||||
updateAntreanMasukScreen,
|
||||
deleteAntreanMasukScreen,
|
||||
};
|
||||
}, {
|
||||
persist: {
|
||||
key: 'antrean-masuk-screen-store-state',
|
||||
storage: typeof window !== 'undefined' ? localStorage : undefined,
|
||||
paths: ['antreanMasukScreenItems'],
|
||||
},
|
||||
persist: false,
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
export const useClinicStore = defineStore('clinic', () => {
|
||||
const config = useRuntimeConfig();
|
||||
|
||||
// Data clinics - Single source of truth untuk semua data klinik
|
||||
// Includes basic info (name, kode, icon, doctors, shifts) + master config (totalQuota, jamShiftPerHari, jadwalKlinik, tanggalTutup)
|
||||
@@ -744,7 +745,7 @@ export const useClinicStore = defineStore('clinic', () => {
|
||||
|
||||
let rawData;
|
||||
try {
|
||||
rawData = await $fetch('/klinik-api/klinik/reguler');
|
||||
rawData = await $fetch(`${config.public.verificationApiBaseUrl}/klinik/reguler`);
|
||||
} catch (error) {
|
||||
// Handle Rate Limiting with exponential backoff
|
||||
if (error.response?.status === 429 && retryCount < 3) {
|
||||
@@ -1000,6 +1001,6 @@ export const useClinicStore = defineStore('clinic', () => {
|
||||
persist: {
|
||||
key: 'clinic-store-state',
|
||||
storage: typeof window !== 'undefined' ? localStorage : undefined,
|
||||
paths: ['clinics', 'lastSyncTimestamp'],
|
||||
paths: ['lastSyncTimestamp'],
|
||||
},
|
||||
});
|
||||
@@ -184,6 +184,6 @@ export const useDoctorStore = defineStore('doctor', () => {
|
||||
}, {
|
||||
persist: {
|
||||
key: 'doctor-store',
|
||||
pick: ['doctorsByKlinikId', 'lastSyncTimestamp']
|
||||
pick: ['lastSyncTimestamp']
|
||||
}
|
||||
});
|
||||
+95
-247
@@ -1,4 +1,5 @@
|
||||
// stores/klinikRuangStore.js
|
||||
// stores/klinikruangstore.js
|
||||
// Konfigurasi mapping klinik ke ruangan — data disimpan di server SQLite via /api/config/klinik-ruang
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
import { useClinicStore } from './clinicStore';
|
||||
@@ -6,284 +7,131 @@ import { useClinicStore } from './clinicStore';
|
||||
export const useKlinikRuangStore = defineStore('klinikRuang', () => {
|
||||
const clinicStore = useClinicStore();
|
||||
|
||||
// State/Computed - List Master Klinik (disinkronkan dengan clinicStore)
|
||||
// Master klinik list (from clinicStore — in-memory, from API)
|
||||
const masterKlinikList = computed(() => {
|
||||
const baseList = typeof clinicStore.getClinicsForDropdown === 'function'
|
||||
? clinicStore.getClinicsForDropdown()
|
||||
: [];
|
||||
|
||||
return baseList.map((c) => ({
|
||||
kode: c.kode,
|
||||
nama: c.name,
|
||||
}));
|
||||
return baseList.map((c) => ({ kode: c.kode, nama: c.name }));
|
||||
});
|
||||
|
||||
// State - Klinik Ruang Data
|
||||
const klinikRuangList = ref([
|
||||
{
|
||||
id: 1,
|
||||
no: 1,
|
||||
kodeKlinik: 'AN',
|
||||
namaKlinik: 'ANAK',
|
||||
namaRuang: 'R. TINDAKAN',
|
||||
ruangList: [
|
||||
{ nomorRuang: '1', namaRuang: 'R. TINDAKAN', nomorScreen: '101' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
no: 2,
|
||||
kodeKlinik: 'AS',
|
||||
namaKlinik: 'ANESTESI',
|
||||
namaRuang: 'Ruang 1, Ruang 2, Ruang 3, Ruang 4, Ruang 5, Ruang 6',
|
||||
ruangList: [
|
||||
{ nomorRuang: '1', namaRuang: 'Ruang 1', nomorScreen: '201' },
|
||||
{ nomorRuang: '2', namaRuang: 'Ruang 2', nomorScreen: '202' },
|
||||
{ nomorRuang: '3', namaRuang: 'Ruang 3', nomorScreen: '203' },
|
||||
{ nomorRuang: '4', namaRuang: 'Ruang 4', nomorScreen: '204' },
|
||||
{ nomorRuang: '5', namaRuang: 'Ruang 5', nomorScreen: '205' },
|
||||
{ nomorRuang: '6', namaRuang: 'Ruang 6', nomorScreen: '206' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
no: 3,
|
||||
kodeKlinik: 'BD',
|
||||
namaKlinik: 'BEDAH',
|
||||
namaRuang: 'Ruang Konsultasi',
|
||||
ruangList: [
|
||||
{ nomorRuang: '1', namaRuang: 'Ruang Konsultasi', nomorScreen: '301' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
no: 4,
|
||||
kodeKlinik: 'GR',
|
||||
namaKlinik: 'GERIATRI',
|
||||
namaRuang: 'Ruang Pemeriksaan',
|
||||
ruangList: [
|
||||
{ nomorRuang: '1', namaRuang: 'Ruang Pemeriksaan', nomorScreen: '401' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
no: 5,
|
||||
kodeKlinik: 'GI',
|
||||
namaKlinik: 'GIGI DAN MULUT',
|
||||
namaRuang: 'Ruang 1, Ruang 2, Ruang 3',
|
||||
ruangList: [
|
||||
{ nomorRuang: '1', namaRuang: 'Ruang 1', nomorScreen: '501' },
|
||||
{ nomorRuang: '2', namaRuang: 'Ruang 2', nomorScreen: '502' },
|
||||
{ nomorRuang: '3', namaRuang: 'Ruang 3', nomorScreen: '503' }
|
||||
]
|
||||
},
|
||||
]);
|
||||
const klinikRuangList = ref([]);
|
||||
const isLoading = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
// Computed
|
||||
const totalKlinikRuang = computed(() => klinikRuangList.value.length);
|
||||
const totalRuangan = computed(() =>
|
||||
klinikRuangList.value.reduce((total, k) => total + (k.ruangList?.length || 0), 0)
|
||||
);
|
||||
|
||||
const totalRuangan = computed(() => {
|
||||
return klinikRuangList.value.reduce((total, klinik) => {
|
||||
return total + klinik.ruangList.length;
|
||||
}, 0);
|
||||
});
|
||||
|
||||
// Get klinik by code
|
||||
const getKlinikByCode = (kode) => {
|
||||
return klinikRuangList.value.find(k => k.kodeKlinik === kode);
|
||||
};
|
||||
|
||||
// Get all ruang for a specific klinik
|
||||
const getKlinikByCode = (kode) => klinikRuangList.value.find(k => k.kodeKlinik === kode);
|
||||
const getRuangByKlinik = (kodeKlinik) => {
|
||||
const klinik = klinikRuangList.value.find(k => k.kodeKlinik === kodeKlinik);
|
||||
return klinik ? klinik.ruangList : [];
|
||||
};
|
||||
|
||||
// Actions - CRUD Operations
|
||||
|
||||
// Create new Klinik Ruang
|
||||
const createKlinikRuang = (data) => {
|
||||
const newId = Math.max(...klinikRuangList.value.map(r => r.id), 0) + 1;
|
||||
const newNo = klinikRuangList.value.length + 1;
|
||||
|
||||
// Generate nama ruang untuk display
|
||||
const namaRuangDisplay = data.ruangList
|
||||
.map(r => r.namaRuang)
|
||||
.filter(n => n)
|
||||
.join(', ');
|
||||
|
||||
const newKlinikRuang = {
|
||||
id: newId,
|
||||
no: newNo,
|
||||
kodeKlinik: data.kodeKlinik,
|
||||
namaKlinik: data.namaKlinik,
|
||||
namaRuang: namaRuangDisplay,
|
||||
ruangList: data.ruangList
|
||||
};
|
||||
|
||||
klinikRuangList.value.push(newKlinikRuang);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Klinik Ruang ${data.namaKlinik} berhasil ditambahkan`,
|
||||
data: newKlinikRuang
|
||||
};
|
||||
};
|
||||
|
||||
// Update existing Klinik Ruang
|
||||
const updateKlinikRuang = (id, data) => {
|
||||
const index = klinikRuangList.value.findIndex(r => r.id === id);
|
||||
|
||||
if (index === -1) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Klinik Ruang tidak ditemukan'
|
||||
};
|
||||
}
|
||||
|
||||
// Generate nama ruang untuk display
|
||||
const namaRuangDisplay = data.ruangList
|
||||
.map(r => r.namaRuang)
|
||||
.filter(n => n)
|
||||
.join(', ');
|
||||
|
||||
klinikRuangList.value[index] = {
|
||||
...klinikRuangList.value[index],
|
||||
kodeKlinik: data.kodeKlinik,
|
||||
namaKlinik: data.namaKlinik,
|
||||
namaRuang: namaRuangDisplay,
|
||||
ruangList: data.ruangList
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Klinik Ruang ${data.namaKlinik} berhasil diupdate`,
|
||||
data: klinikRuangList.value[index]
|
||||
};
|
||||
};
|
||||
|
||||
// Delete Klinik Ruang
|
||||
const deleteKlinikRuang = (id) => {
|
||||
const index = klinikRuangList.value.findIndex(r => r.id === id);
|
||||
|
||||
if (index === -1) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Klinik Ruang tidak ditemukan'
|
||||
};
|
||||
}
|
||||
|
||||
const deletedKlinik = klinikRuangList.value[index];
|
||||
klinikRuangList.value.splice(index, 1);
|
||||
|
||||
// Reorder numbers
|
||||
klinikRuangList.value.forEach((r, idx) => {
|
||||
r.no = idx + 1;
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Klinik Ruang ${deletedKlinik.namaKlinik} berhasil dihapus`
|
||||
};
|
||||
};
|
||||
|
||||
// Add ruang to existing klinik
|
||||
const addRuangToKlinik = (klinikId, ruangData) => {
|
||||
const klinik = klinikRuangList.value.find(k => k.id === klinikId);
|
||||
|
||||
if (!klinik) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Klinik tidak ditemukan'
|
||||
};
|
||||
}
|
||||
|
||||
klinik.ruangList.push(ruangData);
|
||||
|
||||
// Update display name
|
||||
klinik.namaRuang = klinik.ruangList
|
||||
.map(r => r.namaRuang)
|
||||
.filter(n => n)
|
||||
.join(', ');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Ruang ${ruangData.namaRuang} berhasil ditambahkan`,
|
||||
data: klinik
|
||||
};
|
||||
};
|
||||
|
||||
// Remove ruang from klinik
|
||||
const removeRuangFromKlinik = (klinikId, ruangIndex) => {
|
||||
const klinik = klinikRuangList.value.find(k => k.id === klinikId);
|
||||
|
||||
if (!klinik) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Klinik tidak ditemukan'
|
||||
};
|
||||
}
|
||||
|
||||
if (klinik.ruangList.length <= 1) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Minimal harus ada 1 ruangan'
|
||||
};
|
||||
}
|
||||
|
||||
const removedRuang = klinik.ruangList[ruangIndex];
|
||||
klinik.ruangList.splice(ruangIndex, 1);
|
||||
|
||||
// Update display name
|
||||
klinik.namaRuang = klinik.ruangList
|
||||
.map(r => r.namaRuang)
|
||||
.filter(n => n)
|
||||
.join(', ');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Ruang ${removedRuang.namaRuang} berhasil dihapus`,
|
||||
data: klinik
|
||||
};
|
||||
};
|
||||
|
||||
// Search klinik ruang
|
||||
const searchKlinikRuang = (searchTerm) => {
|
||||
if (!searchTerm) return klinikRuangList.value;
|
||||
|
||||
const term = searchTerm.toLowerCase();
|
||||
return klinikRuangList.value.filter(k =>
|
||||
k.kodeKlinik.toLowerCase().includes(term) ||
|
||||
k.namaKlinik.toLowerCase().includes(term) ||
|
||||
k.namaRuang.toLowerCase().includes(term)
|
||||
return klinikRuangList.value.filter(k =>
|
||||
k.kodeKlinik?.toLowerCase().includes(term) ||
|
||||
k.namaKlinik?.toLowerCase().includes(term)
|
||||
);
|
||||
};
|
||||
|
||||
// ── Fetch from API ─────────────────────────────────────────────────────────
|
||||
const fetchKlinikRuang = async () => {
|
||||
if (isLoading.value) return;
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const res = await $fetch('/api/config/klinik-ruang');
|
||||
klinikRuangList.value = (res.data || []).map((item, idx) => ({
|
||||
...item,
|
||||
no: idx + 1,
|
||||
namaRuang: (item.ruangList || []).map(r => r.namaRuang).filter(Boolean).join(', '),
|
||||
}));
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
console.error('❌ [klinikRuangStore] Gagal fetch config:', e);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// ── CRUD via API ───────────────────────────────────────────────────────────
|
||||
const createKlinikRuang = async (data) => {
|
||||
try {
|
||||
const res = await $fetch('/api/config/klinik-ruang', { method: 'POST', body: data });
|
||||
const newItem = { ...res.data, no: klinikRuangList.value.length + 1, namaRuang: (res.data.ruangList || []).map(r => r.namaRuang).join(', ') };
|
||||
klinikRuangList.value.push(newItem);
|
||||
return { success: true, message: res.message, data: newItem };
|
||||
} catch (e) {
|
||||
return { success: false, message: e.data?.message || e.message };
|
||||
}
|
||||
};
|
||||
|
||||
const updateKlinikRuang = async (id, data) => {
|
||||
try {
|
||||
const res = await $fetch(`/api/config/klinik-ruang/${id}`, { method: 'PUT', body: data });
|
||||
const index = klinikRuangList.value.findIndex(r => r.id === id);
|
||||
if (index !== -1) {
|
||||
klinikRuangList.value[index] = {
|
||||
...res.data,
|
||||
no: klinikRuangList.value[index].no,
|
||||
namaRuang: (res.data.ruangList || []).map(r => r.namaRuang).join(', '),
|
||||
};
|
||||
}
|
||||
return { success: true, message: res.message, data: res.data };
|
||||
} catch (e) {
|
||||
return { success: false, message: e.data?.message || e.message };
|
||||
}
|
||||
};
|
||||
|
||||
const deleteKlinikRuang = async (id) => {
|
||||
try {
|
||||
const res = await $fetch(`/api/config/klinik-ruang/${id}`, { method: 'DELETE' });
|
||||
klinikRuangList.value = klinikRuangList.value.filter(r => r.id !== id);
|
||||
// Reorder 'no'
|
||||
klinikRuangList.value.forEach((r, idx) => { r.no = idx + 1; });
|
||||
return { success: true, message: res.message };
|
||||
} catch (e) {
|
||||
return { success: false, message: e.data?.message || e.message };
|
||||
}
|
||||
};
|
||||
|
||||
const addRuangToKlinik = async (klinikId, ruangData) => {
|
||||
const klinik = klinikRuangList.value.find(k => k.id === klinikId);
|
||||
if (!klinik) return { success: false, message: 'Klinik tidak ditemukan' };
|
||||
const newRuangList = [...(klinik.ruangList || []), ruangData];
|
||||
return updateKlinikRuang(klinikId, { ...klinik, ruangList: newRuangList });
|
||||
};
|
||||
|
||||
const removeRuangFromKlinik = async (klinikId, ruangIndex) => {
|
||||
const klinik = klinikRuangList.value.find(k => k.id === klinikId);
|
||||
if (!klinik) return { success: false, message: 'Klinik tidak ditemukan' };
|
||||
if ((klinik.ruangList || []).length <= 1) return { success: false, message: 'Minimal harus ada 1 ruangan' };
|
||||
const newRuangList = klinik.ruangList.filter((_, i) => i !== ruangIndex);
|
||||
return updateKlinikRuang(klinikId, { ...klinik, ruangList: newRuangList });
|
||||
};
|
||||
|
||||
return {
|
||||
// State
|
||||
masterKlinikList,
|
||||
klinikRuangList,
|
||||
|
||||
// Computed
|
||||
isLoading,
|
||||
error,
|
||||
totalKlinikRuang,
|
||||
totalRuangan,
|
||||
|
||||
// Getters
|
||||
getKlinikByCode,
|
||||
getRuangByKlinik,
|
||||
|
||||
// Actions
|
||||
searchKlinikRuang,
|
||||
fetchKlinikRuang,
|
||||
createKlinikRuang,
|
||||
updateKlinikRuang,
|
||||
deleteKlinikRuang,
|
||||
addRuangToKlinik,
|
||||
removeRuangFromKlinik,
|
||||
searchKlinikRuang,
|
||||
};
|
||||
}, {
|
||||
persist: {
|
||||
key: 'klinikruang-store-state',
|
||||
storage: typeof window !== 'undefined' ? localStorage : undefined,
|
||||
paths: ['klinikRuangList'],
|
||||
},
|
||||
persist: false,
|
||||
});
|
||||
@@ -68,6 +68,7 @@ const getPelayananContohDariAnjungan = (anjunganItems) => {
|
||||
export const useLoketStore = defineStore('loket', () => {
|
||||
const clinicStore = useClinicStore();
|
||||
const anjunganStore = useAnjunganStore();
|
||||
const config = useRuntimeConfig();
|
||||
|
||||
// ============================================
|
||||
// STATE - SEPARATED DATA SOURCES
|
||||
@@ -289,7 +290,7 @@ export const useLoketStore = defineStore('loket', () => {
|
||||
|
||||
let rawData;
|
||||
try {
|
||||
rawData = await $fetch('/klinik-api/klinik/loket');
|
||||
rawData = await $fetch(`${config.public.verificationApiBaseUrl}/klinik/loket`);
|
||||
} catch (error) {
|
||||
if (error.response?.status === 429 && retryCount < 3) {
|
||||
const delay = (retryCount + 1) * 1500;
|
||||
@@ -428,7 +429,7 @@ export const useLoketStore = defineStore('loket', () => {
|
||||
|
||||
let rawData;
|
||||
try {
|
||||
rawData = await $fetch(`/klinik-api/klinik/loket/${loketId}`);
|
||||
rawData = await $fetch(`${config.public.verificationApiBaseUrl}/klinik/loket/${loketId}`);
|
||||
} catch (error) {
|
||||
throw new Error(`HTTP error! status: ${error.response?.status || error.message}`);
|
||||
}
|
||||
|
||||
+188
-56
@@ -100,7 +100,7 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
throw new Error(`Klinik ID tidak ditemukan untuk kode: ${kodeKlinik}`);
|
||||
}
|
||||
|
||||
const url = `/visit-api/visit?klinik_id=${clinic.id}&limit=500`;
|
||||
const url = `${config.public.externalApiBaseUrl}/visit?klinik_id=${clinic.id}&limit=500`;
|
||||
console.log(`🔄 [queueStore] Fetching patients for clinic ${kodeKlinik} (ID: ${clinic.id})...`);
|
||||
|
||||
const response = await fetch(url);
|
||||
@@ -266,6 +266,8 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
allPatients.value.push(...newPatientMap.values());
|
||||
|
||||
// console.log(`✅ [queueStore] Successfully fetched ${mappedClinicPatients.length} patients for clinic ${kodeKlinik}`);
|
||||
// SYNC COUNTERS WITH API DATA SO NEW TICKETS DONT RESET
|
||||
syncCountersWithState();
|
||||
return { success: true, message: `${mappedClinicPatients.length} pasien dimuat` };
|
||||
|
||||
} catch (error) {
|
||||
@@ -393,19 +395,27 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
refreshedSomething = true;
|
||||
}
|
||||
|
||||
if (!refreshedSomething) {
|
||||
const interestingLokets = Object.keys(activeLoketInterest.value);
|
||||
const interestingClinics = Object.keys(activeClinicInterest.value);
|
||||
|
||||
if (interestingLokets.length > 0) {
|
||||
interestingLokets.forEach(loketId => { fetchPatientsForLoket(loketId, true); });
|
||||
refreshedSomething = true;
|
||||
}
|
||||
|
||||
if (interestingClinics.length > 0) {
|
||||
interestingClinics.forEach(kodeKlinik => { fetchPatientsForClinic(kodeKlinik, true); });
|
||||
refreshedSomething = true;
|
||||
}
|
||||
// ALWAYS refresh our own active interests when a WebSocket message is received,
|
||||
// because shared lists (e.g. unassigned patients in 'menunggu') might have changed.
|
||||
const interestingLokets = Object.keys(activeLoketInterest.value);
|
||||
const interestingClinics = Object.keys(activeClinicInterest.value);
|
||||
|
||||
if (interestingLokets.length > 0) {
|
||||
interestingLokets.forEach(loketId => {
|
||||
if (String(loketId) !== String(targetLoketId)) {
|
||||
fetchPatientsForLoket(loketId, true);
|
||||
refreshedSomething = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (interestingClinics.length > 0) {
|
||||
interestingClinics.forEach(kodeKlinik => {
|
||||
if (String(kodeKlinik) !== String(targetKlinikId)) {
|
||||
fetchPatientsForClinic(kodeKlinik, true);
|
||||
refreshedSomething = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!refreshedSomething) {
|
||||
@@ -419,7 +429,9 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
const { connect, disconnect, sendViaPost, isConnected } = useWebSocket({
|
||||
url: wsBaseUrl,
|
||||
clientId: wsClientId,
|
||||
fallbackPostUrl: '/stats-api/ws',
|
||||
fallbackPostUrl: `${config.public.externalApiBaseUrl}/ws`,
|
||||
reconnectInterval: 2000, // 2 seconds between reconnect attempts
|
||||
maxReconnectAttempts: 9999, // Effectively infinite — never give up on remote machines
|
||||
onOpen: () => {
|
||||
console.log('✅ [queueStore] WebSocket connected');
|
||||
isWsConnected.value = true;
|
||||
@@ -435,12 +447,60 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
onMessage: onWsMessage
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// STORE-LEVEL AUTO-POLLING (cross-device sync fallback)
|
||||
// ============================================
|
||||
// Runs on every browser instance every 30 seconds.
|
||||
// Fetches data based on whatever active interests are registered
|
||||
// (lokets, clinics, or global). This ensures any device always
|
||||
// has fresh data regardless of WS delivery reliability.
|
||||
let _autoSyncInterval = null;
|
||||
|
||||
const startAutoSync = () => {
|
||||
// Guard: only run on client, and only start once
|
||||
if (typeof window === 'undefined') return;
|
||||
if (_autoSyncInterval) return; // Already running
|
||||
|
||||
console.log('🔄 [queueStore] Starting store-level auto-sync (30s interval)');
|
||||
|
||||
_autoSyncInterval = setInterval(async () => {
|
||||
const hasLoketInterest = Object.keys(activeLoketInterest.value).length > 0;
|
||||
const hasClinicInterest = Object.keys(activeClinicInterest.value).length > 0;
|
||||
const hasGlobalInterest = globalInterestCount.value > 0;
|
||||
|
||||
if (hasGlobalInterest) {
|
||||
fetchAllPatients();
|
||||
} else {
|
||||
if (hasLoketInterest) {
|
||||
Object.keys(activeLoketInterest.value).forEach(loketId => {
|
||||
fetchPatientsForLoket(loketId, true);
|
||||
});
|
||||
}
|
||||
if (hasClinicInterest) {
|
||||
Object.keys(activeClinicInterest.value).forEach(kodeKlinik => {
|
||||
fetchPatientsForClinic(kodeKlinik, true);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 30000); // 30 seconds
|
||||
};
|
||||
|
||||
const stopAutoSync = () => {
|
||||
if (_autoSyncInterval) {
|
||||
clearInterval(_autoSyncInterval);
|
||||
_autoSyncInterval = null;
|
||||
console.log('⏹️ [queueStore] Store-level auto-sync stopped');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize Global WebSocket
|
||||
*/
|
||||
const initWebSocket = (customClientId = null) => {
|
||||
if (isConnected.value && customClientId === wsClientId.value) {
|
||||
console.log('🔌 [queueStore] WebSocket already connected with same ID.');
|
||||
// Auto-sync should still start even if WS is already connected
|
||||
startAutoSync();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -448,11 +508,14 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
wsClientId.value = customClientId;
|
||||
// Re-connect with new ID if changed
|
||||
disconnect();
|
||||
// useWebSocket will use the new wsClientId.value if it's reactive
|
||||
}
|
||||
|
||||
console.log(`🔌 [queueStore] Connecting to WebSocket: ${wsBaseUrl} as ${wsClientId.value}`);
|
||||
connect();
|
||||
|
||||
// Start store-level auto-polling if not already running (client-side only).
|
||||
// This guarantees cross-device sync even when WS messages are missed.
|
||||
startAutoSync();
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -468,6 +531,11 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
*/
|
||||
const syncApiPatientStatus = (patient, newStatus) => {
|
||||
if (!patient) return;
|
||||
|
||||
// Bersihkan LocalStorage fallback jika status sudah bukan pending/terlambat
|
||||
if (newStatus !== 'pending' && newStatus !== 'terlambat' && patient.barcode) {
|
||||
localStorage.removeItem(`patient-status-${patient.barcode}`);
|
||||
}
|
||||
|
||||
// Allow syncing if strictly 'api' OR if we can find a matching ID in the API store
|
||||
// This handles cases where we have an 'onsite' patient locally that corresponds to an API patient
|
||||
@@ -538,7 +606,8 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
|
||||
// String versions for robustness
|
||||
"1": 'menunggu', "2": 'menunggu', "3": 'anjungan', "4": 'anjungan', "5": 'di-loket',
|
||||
"6": 'di-loket', "14": 'pemeriksaan', "15": 'pemeriksaan', "28": 'pending', "29": 'terlambat'
|
||||
"6": 'di-loket', "14": 'pemeriksaan', "15": 'pemeriksaan', "28": 'pending', "29": 'terlambat',
|
||||
"30": 'pending', "31": 'terlambat', "32": 'pending', "33": 'terlambat'
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -721,10 +790,29 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
|
||||
const patientsRaw = rawData.data || [];
|
||||
|
||||
// Fetch temporary subspesialis mapping
|
||||
let subspesialisMap = {};
|
||||
try {
|
||||
const subRes = await fetch('/api/patient-subspesialis');
|
||||
if (subRes.ok) {
|
||||
subspesialisMap = await subRes.json();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch temporary subspesialis mapping', e);
|
||||
}
|
||||
|
||||
// Map API data to store format
|
||||
const mappedPatients = patientsRaw.map((apiPatient, index) =>
|
||||
mapApiPatientToStoreFormat(apiPatient, index)
|
||||
).filter(p => isTodayPatient(p));
|
||||
const mappedPatients = patientsRaw.map((apiPatient, index) => {
|
||||
const mapped = mapApiPatientToStoreFormat(apiPatient, index);
|
||||
// Merge temporary subspesialis mapping
|
||||
const barcodeKey = mapped.barcode || mapped.idtiket;
|
||||
if (barcodeKey && subspesialisMap[barcodeKey]) {
|
||||
mapped.ruang = subspesialisMap[barcodeKey].namaRuang || subspesialisMap[barcodeKey].nama;
|
||||
mapped.nomorRuang = subspesialisMap[barcodeKey].nomorRuang || subspesialisMap[barcodeKey].ruang;
|
||||
mapped.kodeRuang = subspesialisMap[barcodeKey].kodeRuang || subspesialisMap[barcodeKey].id;
|
||||
}
|
||||
return mapped;
|
||||
}).filter(p => isTodayPatient(p));
|
||||
|
||||
// Deduplicate patients by idtiket
|
||||
// API returns multiple entries if patient is at multiple positions
|
||||
@@ -828,6 +916,25 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
return true; // Keep the currently processing patient
|
||||
}
|
||||
|
||||
// CRITICAL FIX: Protect patients manually moved to a different loket via changeKlinik.
|
||||
// The backend API doesn't know about this move yet, so API refresh would wipe the patient.
|
||||
// We protect them for 10 minutes (600000ms) to give admin time to process them.
|
||||
if (p.manuallyMoved && p.movedAt) {
|
||||
const timeSinceMoved = Date.now() - p.movedAt;
|
||||
const tenMinutes = 10 * 60 * 1000;
|
||||
if (timeSinceMoved < tenMinutes) {
|
||||
console.log(`🛡️ [queueStore] Protecting manually-moved patient ${p.noAntrian?.split(' |')[0]} (moved ${Math.round(timeSinceMoved/1000)}s ago) from API overwrite`);
|
||||
// Prevent the old loket's API data from overwriting this patient
|
||||
if (newPatientMap.has(key)) {
|
||||
newPatientMap.delete(key);
|
||||
}
|
||||
return true; // Keep the moved patient
|
||||
} else {
|
||||
// After 10 minutes, allow API to take over
|
||||
console.log(`⏰ [queueStore] manuallyMoved patient ${p.noAntrian?.split(' |')[0]} protection expired. Allowing API overwrite.`);
|
||||
}
|
||||
}
|
||||
|
||||
// If it's still in loket stage but marked as processed, and it's in the API batch,
|
||||
// we might be experiencing API lag. Keep it as processed.
|
||||
if (p.status === 'processed' && newPatientMap.has(key)) {
|
||||
@@ -925,6 +1032,9 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
// console.log(`✅ [queueStore] Successfully fetched ${finalPatients.length} unique patients for loket ${loketId} (Original: ${mappedPatients.length})`);
|
||||
// console.log(`📊 [queueStore] Total patients in allPatients: ${allPatients.value.length}`);
|
||||
|
||||
// SYNC COUNTERS WITH API DATA SO NEW TICKETS DONT RESET
|
||||
syncCountersWithState();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `${mappedPatients.length} pasien berhasil dimuat`,
|
||||
@@ -1937,17 +2047,14 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
message = `Menandai pasien ${patientCode} sebagai terlambat...`;
|
||||
|
||||
const payload = {
|
||||
barcode: patient.barcode || "",
|
||||
statuspasien: "29",
|
||||
statuspasien2: "29",
|
||||
idklinikstatus: "2",
|
||||
idklinikstatus2: "2"
|
||||
visit_code: patient.barcode || patient.visitCode || patient.ticket || "",
|
||||
visit_status_id: [29]
|
||||
};
|
||||
|
||||
// POST to external API and WAIT for response
|
||||
try {
|
||||
const apiBase = config.public.verificationApiBaseUrl || 'http://10.10.123.140:8089/api/v1';
|
||||
const response = await fetch(`${apiBase}/tiket/update`, {
|
||||
const apiBase = config.public.externalApiBaseUrl || 'http://10.10.123.135:8084/api/v1';
|
||||
const response = await fetch(`${apiBase}/visit/status`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
@@ -2000,17 +2107,14 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
message = `Menandai pasien ${patientCode} sebagai pending...`;
|
||||
|
||||
const payload = {
|
||||
barcode: patient.barcode || "",
|
||||
statuspasien: "28",
|
||||
statuspasien2: "28",
|
||||
idklinikstatus: "2",
|
||||
idklinikstatus2: "2"
|
||||
visit_code: patient.barcode || patient.visitCode || patient.ticket || "",
|
||||
visit_status_id: [28]
|
||||
};
|
||||
|
||||
// POST to external API and WAIT for response
|
||||
try {
|
||||
const apiBase = config.public.verificationApiBaseUrl || 'http://10.10.123.140:8089/api/v1';
|
||||
const response = await fetch(`${apiBase}/tiket/update`, {
|
||||
const apiBase = config.public.externalApiBaseUrl || 'http://10.10.123.135:8084/api/v1';
|
||||
const response = await fetch(`${apiBase}/visit/status`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
@@ -2120,6 +2224,10 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
allPatients.value[patientIndex] = updatedPatient;
|
||||
// Set currentProcessingPatient with isolated key
|
||||
currentProcessingPatient.value[storageKey] = updatedPatient;
|
||||
|
||||
if (shouldUpdateStatus) {
|
||||
syncApiPatientStatus(updatedPatient, "di-loket");
|
||||
}
|
||||
|
||||
// POST to external API when patient is being processed (sedang diproses)
|
||||
try {
|
||||
@@ -2157,6 +2265,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
};
|
||||
allPatients.value[patientIndex] = updatedPatient;
|
||||
currentProcessingPatient.value[storageKey] = updatedPatient;
|
||||
syncApiPatientStatus(updatedPatient, "di-loket");
|
||||
} else {
|
||||
currentProcessingPatient.value[storageKey] = patient;
|
||||
}
|
||||
@@ -2231,7 +2340,8 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
if (newPatient.kodeKlinik) {
|
||||
const allLokets = loketStore.lokets || [];
|
||||
const targetLoket = allLokets.find(l =>
|
||||
l.pelayanan && Array.isArray(l.pelayanan) && l.pelayanan.includes(newPatient.kodeKlinik)
|
||||
l.pelayanan && Array.isArray(l.pelayanan) &&
|
||||
(l.pelayanan.includes(newPatient.kodeKlinik) || l.pelayanan.includes(newPatient.kodeKlinik?.split('-')[0]))
|
||||
);
|
||||
if (targetLoket) {
|
||||
newPatient.loketId = targetLoket.id;
|
||||
@@ -2606,7 +2716,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
const getPatientsByKlinikRuang = (kodeKlinik, nomorRuang, tipeLayanan = null) => {
|
||||
return computed(() => {
|
||||
let patients = allPatients.value.filter(p =>
|
||||
p.kodeKlinik === kodeKlinik &&
|
||||
(p.kodeKlinik === kodeKlinik || (p.kodeKlinik && p.kodeKlinik.split('-')[0] === kodeKlinik)) &&
|
||||
p.nomorRuang === nomorRuang &&
|
||||
p.processStage === 'klinik-ruang'
|
||||
);
|
||||
@@ -2628,7 +2738,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
// Call next patient for a specific room and tipeLayanan
|
||||
const callNextKlinikRuang = (kodeKlinik, nomorRuang, tipeLayanan, allowMultiple = false) => {
|
||||
const patients = allPatients.value.filter(p =>
|
||||
p.kodeKlinik === kodeKlinik &&
|
||||
(p.kodeKlinik === kodeKlinik || (p.kodeKlinik && p.kodeKlinik.split('-')[0] === kodeKlinik)) &&
|
||||
p.nomorRuang === nomorRuang &&
|
||||
p.tipeLayanan === tipeLayanan &&
|
||||
p.processStage === 'klinik-ruang' &&
|
||||
@@ -2723,11 +2833,10 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
case "terlambat":
|
||||
// Send API call to update status to 33 (TR PEMERIKSAAN)
|
||||
try {
|
||||
const visitApiBase = '/visit-api';
|
||||
const apiUrl = `${visitApiBase}/visit/status/finish`;
|
||||
const visitApiBase = config.public.externalApiBaseUrl || 'http://10.10.123.135:8084/api/v1';
|
||||
const apiUrl = `${visitApiBase}/visit/status`;
|
||||
const requestBody = {
|
||||
patient_visit_healthcare_service_id: patient.healthcareServiceId,
|
||||
visit_code: patient.barcode || patient.visitCode,
|
||||
visit_code: patient.barcode || patient.visitCode || patient.ticket || "",
|
||||
visit_status_id: [33]
|
||||
};
|
||||
|
||||
@@ -2748,11 +2857,10 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
case "pending":
|
||||
// Send API call to update status to 32 (PE PEMERIKSAAN)
|
||||
try {
|
||||
const visitApiBase = '/visit-api';
|
||||
const apiUrl = `${visitApiBase}/visit/status/finish`;
|
||||
const visitApiBase = config.public.externalApiBaseUrl || 'http://10.10.123.135:8084/api/v1';
|
||||
const apiUrl = `${visitApiBase}/visit/status`;
|
||||
const requestBody = {
|
||||
patient_visit_healthcare_service_id: patient.healthcareServiceId,
|
||||
visit_code: patient.barcode || patient.visitCode,
|
||||
visit_code: patient.barcode || patient.visitCode || patient.ticket || "",
|
||||
visit_status_id: [32]
|
||||
};
|
||||
|
||||
@@ -2788,7 +2896,8 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
if (newKlinik.kode) {
|
||||
const allLokets = loketStore.lokets || [];
|
||||
const foundLoket = allLokets.find(l =>
|
||||
l.pelayanan && Array.isArray(l.pelayanan) && l.pelayanan.includes(newKlinik.kode)
|
||||
l.pelayanan && Array.isArray(l.pelayanan) &&
|
||||
(l.pelayanan.includes(newKlinik.kode) || l.pelayanan.includes(newKlinik.kode?.split('-')[0]))
|
||||
);
|
||||
|
||||
if (foundLoket) {
|
||||
@@ -2815,13 +2924,16 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
}
|
||||
|
||||
// Update the patient in allPatients
|
||||
// Mark as manuallyMoved so API refresh doesn't wipe this patient from the destination loket
|
||||
const updatedPatient = {
|
||||
...oldPatient,
|
||||
klinik: newKlinik.name,
|
||||
kodeKlinik: newKlinik.kode,
|
||||
loketId: targetLoketId,
|
||||
loket: targetLoketName,
|
||||
status: newStatus
|
||||
status: newStatus,
|
||||
manuallyMoved: isMovedToDifferentLoket ? true : (oldPatient.manuallyMoved || false),
|
||||
movedAt: isMovedToDifferentLoket ? Date.now() : (oldPatient.movedAt || null)
|
||||
};
|
||||
|
||||
allPatients.value[patientIndex] = updatedPatient;
|
||||
@@ -2956,7 +3068,8 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
if (!newPatient.loketId && newPatient.kodeKlinik) {
|
||||
const allLokets = loketStore.lokets || [];
|
||||
const targetLoket = allLokets.find(l =>
|
||||
l.pelayanan && Array.isArray(l.pelayanan) && l.pelayanan.includes(newPatient.kodeKlinik)
|
||||
l.pelayanan && Array.isArray(l.pelayanan) &&
|
||||
(l.pelayanan.includes(newPatient.kodeKlinik) || l.pelayanan.includes(newPatient.kodeKlinik?.split('-')[0]))
|
||||
);
|
||||
|
||||
if (targetLoket) {
|
||||
@@ -2995,7 +3108,8 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
// Find loket that handles BOTH the clinic AND the payment type
|
||||
const targetLoket = allLokets.find(l => {
|
||||
if (l.source !== 'api' || l.id >= 1000) return false;
|
||||
const handlesClinic = l.pelayanan && Array.isArray(l.pelayanan) && l.pelayanan.includes(clinic.kode);
|
||||
const handlesClinic = l.pelayanan && Array.isArray(l.pelayanan) &&
|
||||
(l.pelayanan.includes(clinic.kode) || l.pelayanan.includes(clinic.kode?.split('-')[0]));
|
||||
if (handlesClinic) {
|
||||
return isPaymentCompatible(paymentTypeForMatching, l.pembayaran);
|
||||
}
|
||||
@@ -3113,6 +3227,22 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
allPatients.value.push(newPatient);
|
||||
// NOTE: Kita tidak perlu incrementBarcodeCounter() di sini karena barcode berasal dari API
|
||||
|
||||
// Temporary: Save subSpesialis mapping to local JSON API
|
||||
if (subSpesialis) {
|
||||
try {
|
||||
await fetch('/api/patient-subspesialis', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
barcode: barcode,
|
||||
subspesialis: subSpesialis
|
||||
})
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to save temporary subSpesialis mapping', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -3226,12 +3356,12 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
|
||||
console.log('✅ Check-in successful locally, patient status updated to di-loket');
|
||||
|
||||
// Sync to API
|
||||
const apiSyncResult = await checkInPatientViaApi(allPatients.value[patientIndex].barcode);
|
||||
if (!apiSyncResult.success) {
|
||||
console.warn('⚠️ Check-in API sync failed:', apiSyncResult.message);
|
||||
// We still return true because local state is updated, but we could also return failure if critical
|
||||
}
|
||||
// Sync to API in background (fire-and-forget to avoid blocking UI)
|
||||
checkInPatientViaApi(allPatients.value[patientIndex].barcode).then(apiSyncResult => {
|
||||
if (!apiSyncResult.success) {
|
||||
console.warn('⚠️ Check-in API sync failed:', apiSyncResult.message);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -3366,13 +3496,15 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
activeClinicInterest,
|
||||
registerGlobalInterest,
|
||||
unregisterGlobalInterest,
|
||||
startAutoSync,
|
||||
stopAutoSync,
|
||||
};
|
||||
|
||||
}, {
|
||||
persist: {
|
||||
key: 'queue-store-state',
|
||||
storage: typeof window !== 'undefined' ? localStorage : undefined,
|
||||
paths: ['allPatients', 'quotaUsed', 'currentProcessingPatient', 'apiPatientsPerLoket', 'lastUpdated'],
|
||||
paths: ['quotaUsed', 'lastUpdated'],
|
||||
serializer: {
|
||||
deserialize: JSON.parse,
|
||||
serialize: JSON.stringify,
|
||||
|
||||
@@ -552,10 +552,6 @@ export const useRuangStore = defineStore('ruang', () => {
|
||||
fetchRuangFromAPI,
|
||||
};
|
||||
}, {
|
||||
persist: {
|
||||
key: 'ruang-store-state',
|
||||
storage: typeof window !== 'undefined' ? localStorage : undefined,
|
||||
paths: ['ruangData'],
|
||||
},
|
||||
persist: false,
|
||||
});
|
||||
|
||||
+51
-65
@@ -1,31 +1,15 @@
|
||||
// stores/screenStore.js
|
||||
// Konfigurasi layar TV klinik — data disimpan di server SQLite via /api/config/screen
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
export const useScreenStore = defineStore('screen', () => {
|
||||
// Initial screen items data
|
||||
const screenItems = ref([
|
||||
{
|
||||
id: 1,
|
||||
namaScreen: "Layar Screen 1",
|
||||
nomorScreen: "SCR-001",
|
||||
klinik: ["AN", "AS", "BD", "GI", "GR", "GZ", "IP", "JT"],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
namaScreen: "Layar Screen 2",
|
||||
nomorScreen: "SCR-002",
|
||||
klinik: ["JW", "KK", "MT", "SR", "OB", "PR"],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
namaScreen: "Layar Screen 3",
|
||||
nomorScreen: "SCR-003",
|
||||
klinik: ["RT", "RM", "HO"],
|
||||
},
|
||||
]);
|
||||
const screenItems = ref([]);
|
||||
const isLoading = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
const getAllScreens = computed(() => screenItems.value);
|
||||
|
||||
// Computed
|
||||
const getScreenById = (id) => {
|
||||
return computed(() => {
|
||||
const targetId = Number(id);
|
||||
@@ -33,64 +17,66 @@ export const useScreenStore = defineStore('screen', () => {
|
||||
});
|
||||
};
|
||||
|
||||
const getAllScreens = computed(() => screenItems.value);
|
||||
|
||||
// Actions
|
||||
const addScreen = (screenPayload) => {
|
||||
// Ensure we get a valid ID even if screenItems is empty
|
||||
const maxId = screenItems.value.length > 0
|
||||
? Math.max(...screenItems.value.map(s => s.id), 0)
|
||||
: 0;
|
||||
const newId = maxId + 1;
|
||||
// Pastikan id baru tidak tertimpa payload (payload.id bisa null)
|
||||
const newScreen = {
|
||||
...screenPayload,
|
||||
id: newId,
|
||||
};
|
||||
screenItems.value.push(newScreen);
|
||||
return { success: true, message: `Screen ${newScreen.namaScreen} berhasil ditambahkan`, data: newScreen };
|
||||
// ── Fetch from API ─────────────────────────────────────────────────────────
|
||||
const fetchScreens = async () => {
|
||||
if (isLoading.value) return;
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const res = await $fetch('/api/config/screen');
|
||||
screenItems.value = res.data || [];
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
console.error('❌ [screenStore] Gagal fetch screen config:', e);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const updateScreen = (screenPayload) => {
|
||||
const index = screenItems.value.findIndex(s => s.id === screenPayload.id);
|
||||
if (index !== -1) {
|
||||
screenItems.value[index] = {
|
||||
...screenItems.value[index],
|
||||
...screenPayload,
|
||||
};
|
||||
return { success: true, message: `Konfigurasi ${screenPayload.namaScreen} berhasil disimpan` };
|
||||
// ── CRUD via API ───────────────────────────────────────────────────────────
|
||||
const addScreen = async (screenPayload) => {
|
||||
try {
|
||||
const res = await $fetch('/api/config/screen', { method: 'POST', body: screenPayload });
|
||||
screenItems.value.push(res.data);
|
||||
return { success: true, message: res.message, data: res.data };
|
||||
} catch (e) {
|
||||
return { success: false, message: e.data?.message || e.message };
|
||||
}
|
||||
return { success: false, message: 'Screen tidak ditemukan' };
|
||||
};
|
||||
|
||||
const deleteScreen = (screenId) => {
|
||||
const index = screenItems.value.findIndex(s => s.id === screenId);
|
||||
if (index !== -1) {
|
||||
const screenName = screenItems.value[index].namaScreen;
|
||||
screenItems.value.splice(index, 1);
|
||||
return { success: true, message: `Screen ${screenName} berhasil dihapus` };
|
||||
const updateScreen = async (screenPayload) => {
|
||||
try {
|
||||
const res = await $fetch(`/api/config/screen/${screenPayload.id}`, { method: 'PUT', body: screenPayload });
|
||||
const index = screenItems.value.findIndex(s => s.id === screenPayload.id);
|
||||
if (index !== -1) screenItems.value[index] = res.data;
|
||||
return { success: true, message: res.message };
|
||||
} catch (e) {
|
||||
return { success: false, message: e.data?.message || e.message };
|
||||
}
|
||||
};
|
||||
|
||||
const deleteScreen = async (screenId) => {
|
||||
try {
|
||||
const res = await $fetch(`/api/config/screen/${screenId}`, { method: 'DELETE' });
|
||||
screenItems.value = screenItems.value.filter(s => s.id !== screenId);
|
||||
return { success: true, message: res.message };
|
||||
} catch (e) {
|
||||
return { success: false, message: e.data?.message || e.message };
|
||||
}
|
||||
return { success: false, message: 'Screen tidak ditemukan' };
|
||||
};
|
||||
|
||||
return {
|
||||
// State
|
||||
screenItems,
|
||||
|
||||
// Computed
|
||||
isLoading,
|
||||
error,
|
||||
getAllScreens,
|
||||
getScreenById,
|
||||
|
||||
// Actions
|
||||
fetchScreens,
|
||||
addScreen,
|
||||
updateScreen,
|
||||
deleteScreen,
|
||||
};
|
||||
}, {
|
||||
persist: {
|
||||
key: 'screen-store-state',
|
||||
storage: typeof window !== 'undefined' ? localStorage : undefined,
|
||||
paths: ['screenItems'],
|
||||
},
|
||||
// No persist — data lives in the server DB
|
||||
persist: false,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user