update verif dkk

This commit is contained in:
Fanrouver
2026-02-05 12:06:25 +07:00
parent e2a5d43e76
commit 2838016244
7 changed files with 368 additions and 155 deletions

No files matched your search

+81
View File
@@ -0,0 +1,81 @@
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
/**
* Reusable Infinite Scroll Composable
* @param {import('vue').Ref<Array>} sourceData - The full source array of data
* @param {number} pageSize - Number of items to load per "page"
* @param {Object} options - Additional options
* @returns {Object} { visibleItems, targetRef, loadMore, reset }
*/
export function useInfiniteScroll(sourceData, pageSize = 20, options = {}) {
const visibleCount = ref(pageSize);
const targetRef = ref(null);
// Compute visible items based on current count
const visibleItems = computed(() => {
const data = sourceData.value || [];
return data.slice(0, visibleCount.value);
});
// Check if there are more items to load
const hasMore = computed(() => {
const data = sourceData.value || [];
return visibleCount.value < data.length;
});
const loadMore = () => {
if (hasMore.value) {
visibleCount.value += pageSize;
}
};
const reset = () => {
visibleCount.value = pageSize;
};
// Setup IntersectionObserver
let observer = null;
onMounted(() => {
observer = new IntersectionObserver((entries) => {
const entry = entries[0];
if (entry.isIntersecting && hasMore.value) {
loadMore();
}
}, {
root: null,
rootMargin: '100px', // Preload before reaching bottom
threshold: 0.1,
...options
});
if (targetRef.value) {
observer.observe(targetRef.value);
}
});
onUnmounted(() => {
if (observer) observer.disconnect();
});
// Watch for targetRef changes (e.g., if valid status changes)
watch(targetRef, (el) => {
if (observer) {
observer.disconnect();
if (el) observer.observe(el);
}
});
// Reset when source data changes significantly (optional, depending on use case)
// watch(sourceData, () => {
// reset(); // Uncomment if you want to reset scroll on data refresh
// });
return {
visibleItems,
targetRef,
loadMore,
reset,
hasMore
};
}
+2 -1
View File
@@ -104,7 +104,8 @@ const ruangStore = useRuangStore();
const loading = ref(false);
const klinikRuangList = computed(() => {
return masterStore.ruangData || [];
const list = masterStore.ruangData || [];
return [...list].sort((a, b) => a.namaKlinik.localeCompare(b.namaKlinik));
});
const navigateToKlinik = (kodeKlinik, jenisLayanan) => {
+12 -27
View File
@@ -19,9 +19,9 @@
</div>
<div class="klinik-selection-container">
<div class="kliniks-grid" v-if="paginatedKliniks.length > 0">
<div class="kliniks-grid" v-if="visibleKliniks.length > 0">
<div
v-for="klinik in paginatedKliniks"
v-for="klinik in visibleKliniks"
:key="`${klinik.kodeKlinik}-${klinik.jenisLayanan}`"
class="klinik-card"
@click="navigateToKlinik(klinik)"
@@ -79,6 +79,9 @@
</v-btn>
</div>
</div>
<!-- Sentinel for Infinite Scroll -->
<div ref="targetRef" class="sentinel" style="height: 20px; width: 100%; grid-column: 1 / -1;"></div>
</div>
<div v-else class="empty-state">
@@ -96,11 +99,7 @@
</v-btn>
</div>
<div v-if="paginatedKliniks.length > 0 && totalPages > 1" class="pagination">
<v-btn variant="outlined" @click="goPrev" :disabled="page <= 1">Prev</v-btn>
<span class="page-info">Page {{ page }} / {{ totalPages }}</span>
<v-btn variant="outlined" @click="goNext" :disabled="page >= totalPages">Next</v-btn>
</div>
</div>
</div>
</template>
@@ -110,6 +109,7 @@ import { ref, computed, onMounted } from 'vue';
import { useMasterStore } from '@/stores/masterStore';
import { useClinicStore } from '@/stores/clinicStore';
import { useRuangStore } from '@/stores/ruangStore';
import { useInfiniteScroll } from '@/composables/useInfiniteScroll';
import { useRoute } from '#app';
definePageMeta({
@@ -123,30 +123,15 @@ const route = useRoute();
// Simplified computed to use masterStore.ruangData (which is already grouped/processed by ruangStore)
const kliniksWithRuang = computed(() => {
return masterStore.ruangData || [];
const list = masterStore.ruangData || [];
return [...list].sort((a, b) => a.namaKlinik.localeCompare(b.namaKlinik));
});
// Pagination
const itemsPerPage = 12; // Increased for better fill
const page = computed({
get: () => Number(route.query.page || 1),
set: (val) => navigateTo({ query: { ...route.query, page: val } }),
});
// Infinite Scroll
const { visibleItems: visibleKliniks, targetRef, hasMore } = useInfiniteScroll(kliniksWithRuang, 12);
const totalPages = computed(() => Math.max(1, Math.ceil(kliniksWithRuang.value.length / itemsPerPage)));
// Deprecated: Pagination logic removed
const paginatedKliniks = computed(() => {
const start = (page.value - 1) * itemsPerPage;
return kliniksWithRuang.value.slice(start, start + itemsPerPage);
});
const goPrev = () => {
if (page.value > 1) page.value = page.value - 1;
};
const goNext = () => {
if (page.value < totalPages.value) page.value = page.value + 1;
};
const navigateToKlinik = (klinik) => {
// Build URL with jenisLayanan query parameter to differentiate same clinic codes
+78 -86
View File
@@ -18,80 +18,86 @@
</div>
</div>
<div class="loket-selection-container">
<div v-if="paginatedLokets.length > 0" class="lokets-grid">
<div
v-for="loket in paginatedLokets"
:key="loket.id"
class="loket-card"
@click="navigateToLoket(loket.id)"
>
<div class="loket-card-header">
<div class="loket-info">
<h3 class="loket-name">{{ loket.namaLoket }}</h3>
<ClientOnly>
<div class="loket-selection-container">
<div v-if="visibleLokets.length > 0" class="lokets-grid">
<div
v-for="loket in visibleLokets"
:key="loket.id"
class="loket-card"
@click="navigateToLoket(loket.id)"
>
<div class="loket-card-header">
<div class="loket-info">
<h3 class="loket-name">{{ loket.namaLoket }}</h3>
</div>
</div>
</div>
<div class="loket-preview">
<div class="loket-service-count">
<v-icon size="18" color="primary-600">mdi-hospital-building</v-icon>
<span>{{ loket.pelayanan?.length || 0 }} Pelayanan</span>
<div class="loket-preview">
<div class="loket-service-count">
<v-icon size="18" color="primary-600">mdi-hospital-building</v-icon>
<span>{{ loket.pelayanan?.length || 0 }} Pelayanan</span>
</div>
<div class="loket-tags">
<v-chip
v-for="(pelayananKode, idx) in (loket.pelayanan || []).slice(0, 3)"
:key="idx"
size="small"
class="ma-1 chip-preview"
>
{{ getKlinikName(pelayananKode, loket) }}
</v-chip>
<v-chip
v-if="(loket.pelayanan || []).length > 3"
size="small"
class="ma-1 chip-more"
>
+{{ (loket.pelayanan || []).length - 3 }}
</v-chip>
</div>
</div>
<div class="loket-tags">
<v-chip
v-for="(pelayananKode, idx) in (loket.pelayanan || []).slice(0, 3)"
:key="idx"
size="small"
class="ma-1 chip-preview"
<div class="loket-card-footer">
<v-btn
color="primary-600"
variant="flat"
size="large"
block
class="btn-view text-white"
>
{{ getKlinikName(pelayananKode, loket) }}
</v-chip>
<v-chip
v-if="(loket.pelayanan || []).length > 3"
size="small"
class="ma-1 chip-more"
>
+{{ (loket.pelayanan || []).length - 3 }}
</v-chip>
<v-icon left>mdi-eye</v-icon>
Tampilkan
</v-btn>
</div>
</div>
<div class="loket-card-footer">
<v-btn
color="primary-600"
variant="flat"
size="large"
block
class="btn-view text-white"
>
<v-icon left>mdi-eye</v-icon>
Tampilkan
</v-btn>
<!-- Sentinel for Infinite Scroll (placed inside grid or after it, depending on layout structure) -->
<!-- Since grid is flex/grid, we might need it to be full width or just present -->
<div ref="targetRef" style="height: 20px; width: 100%; grid-column: 1 / -1;"></div>
</div>
<div v-else class="empty-state">
<v-icon size="64" color="grey-lighten-1">mdi-view-dashboard-off</v-icon>
<h3>Tidak Ada Loket Tersedia</h3>
<p>Silakan tambah loket terlebih dahulu di halaman master</p>
<v-btn
color="primary-600"
variant="flat"
class="mt-4"
@click="navigateToSettings"
>
<v-icon left>mdi-cog</v-icon>
Ke Halaman Master
</v-btn>
</div>
</div>
<template #fallback>
<div class="d-flex justify-center align-center" style="height: 400px;">
<v-progress-circular indeterminate color="primary" size="64"></v-progress-circular>
</div>
</div>
</div>
<div v-else class="empty-state">
<v-icon size="64" color="grey-lighten-1">mdi-view-dashboard-off</v-icon>
<h3>Tidak Ada Loket Tersedia</h3>
<p>Silakan tambah loket terlebih dahulu di halaman master</p>
<v-btn
color="primary-600"
variant="flat"
class="mt-4"
@click="navigateToSettings"
>
<v-icon left>mdi-cog</v-icon>
Ke Halaman Master
</v-btn>
</div>
<div v-if="paginatedLokets.length > 0 && totalPages > 1" class="pagination">
<v-btn variant="outlined" :disabled="page <= 1" @click="goPrev">Prev</v-btn>
<span class="page-info">Page {{ page }} / {{ totalPages }}</span>
<v-btn variant="outlined" :disabled="page >= totalPages" @click="goNext">Next</v-btn>
</div>
</div>
</template>
</ClientOnly>
</div>
</template>
@@ -99,6 +105,7 @@
import { computed, onMounted } from 'vue';
import { useLoketStore } from '@/stores/loketStore';
import { useMasterStore } from '@/stores/masterStore';
import { useInfiniteScroll } from '@/composables/useInfiniteScroll';
import { useRoute } from '#app';
definePageMeta({
@@ -138,27 +145,12 @@ const allLokets = computed(() => {
return Array.isArray(lokets) ? lokets : (lokets.value || [])
});
// Pagination
const itemsPerPage = 20;
const page = computed({
get: () => Number(route.query.page || 1),
set: (val) => navigateTo({ query: { ...route.query, page: val } }),
});
// Infinite Scroll (Replaces Pagination)
const pageSize = 20;
const { visibleItems: visibleLokets, targetRef, hasMore } = useInfiniteScroll(allLokets, pageSize);
const totalPages = computed(() => Math.max(1, Math.ceil(allLokets.value.length / itemsPerPage)));
// Deprecated: Pagination logic removed
const paginatedLokets = computed(() => {
const start = (page.value - 1) * itemsPerPage;
return allLokets.value.slice(start, start + itemsPerPage);
});
const goPrev = () => {
if (page.value > 1) page.value = page.value - 1;
};
const goNext = () => {
if (page.value < totalPages.value) page.value = page.value + 1;
};
const navigateToLoket = (loketId) => {
navigateTo(`/anjungan/antrianloket/${loketId}`);
+65 -40
View File
@@ -134,38 +134,39 @@
</template>
<script setup>
import { ref, computed } from 'vue';
import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
import { useDisplay } from 'vuetify';
import { storeToRefs } from 'pinia';
import QrcodeVue from 'qrcode.vue';
import PageHeader from '@/components/common/PageHeader.vue';
import QRVerificationDialog from '@/components/verification/QRVerificationDialog.vue';
import { useVerificationStore } from '~/stores/verificationStore';
definePageMeta({
middleware: ['auth']
});
const display = useDisplay();
const verificationStore = useVerificationStore();
const { patients, loading, error } = storeToRefs(verificationStore);
// Load initial patients on mount
onMounted(() => {
verificationStore.fetchAllPatients();
});
// Table Headers
const headers = ref([
{ title: 'No', value: 'no', sortable: false, width: '60px' },
{ title: 'Nama Pasien', value: 'nama', sortable: true },
{ title: 'No. RM', value: 'rm', sortable: true },
{ title: 'Alamat', value: 'alamat', sortable: false },
{ title: 'No. Telepon', value: 'telepon', sortable: false },
{ title: 'Status', value: 'status', sortable: true, width: '180px' },
{ title: 'Actions', value: 'actions', sortable: false, width: '200px' },
]);
// Data
const patients = ref([
{ nama: 'Budi Santoso', rm: '00123456', alamat: 'Jl. Melati No. 10', telepon: '', status: 'Belum Terverifikasi' },
{ nama: 'Siti Aminah', rm: '00123457', alamat: 'Perum. Indah Blok A', telepon: '081234567890', status: 'Terverifikasi' },
{ nama: 'Joko Susilo', rm: '00123458', alamat: 'Jl. Pahlawan 5', telepon: '', status: 'Belum Terverifikasi' },
{ nama: 'Dewi Lestari', rm: '00123459', alamat: 'Gang Mawar 12', telepon: '089876543210', status: 'Terverifikasi' },
{ nama: 'Rina Wijaya', rm: '00123460', alamat: 'Jl. Sudirman 21', telepon: '', status: 'Belum Terverifikasi' },
{ 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: 'No. Telepon', value: 'telepon', sortable: false, align: 'center' },
{ title: 'Status', value: 'status', sortable: true, width: '180px', align: 'center' },
{ title: 'Actions', value: 'actions', sortable: false, width: '200px', align: 'center' },
]);
// Local State
const filterStatus = ref(0);
const search = ref('');
const isModalOpen = ref(false);
@@ -173,6 +174,26 @@ const selectedPatient = ref({});
const tempTelepon = ref('');
const qrCodeGenerated = ref(false);
// Debounced Search
let searchTimeout;
watch(search, (newVal) => {
clearTimeout(searchTimeout);
if (!newVal || newVal.length < 2) {
if (!newVal) verificationStore.fetchAllPatients();
return;
}
// Set loading via store action if possible or utilize store's async nature
// Ideally store handles loading state internally during fetch
searchTimeout = setTimeout(() => {
if (/^\d+$/.test(newVal)) {
verificationStore.fetchPatientByRm(newVal);
} else {
verificationStore.searchPatientsByName(newVal);
}
}, 600);
});
// Computed
const currentDate = computed(() => {
const now = new Date();
@@ -197,21 +218,15 @@ const filteredPatients = computed(() => {
} else if (filterStatus.value === 2) {
statusFiltered = verifiedPatients.value;
}
if (!search.value) return statusFiltered;
const searchTerm = search.value.toLowerCase();
return statusFiltered.filter(p =>
p.nama.toLowerCase().includes(searchTerm) ||
p.rm.includes(searchTerm)
);
return statusFiltered;
});
const qrCodeData = computed(() => {
return qrCodeGenerated.value
? JSON.stringify({
rm: selectedPatient.value.rm,
phone: tempTelepon.value,
phone: tempTelepon.value, // Using tempTelepon which is bound to input
action: 'verify-account',
timestamp: Date.now()
})
@@ -241,35 +256,31 @@ const closeModal = () => {
const generateQrCode = () => {
if (tempTelepon.value.length >= 8) {
console.log(`[API CALL] Generating QR for ${selectedPatient.value.nama}. QR Data: ${qrCodeData.value}`);
setTimeout(() => {
qrCodeGenerated.value = true;
}, 200);
qrCodeGenerated.value = true;
}
};
const reloadQr = () => {
console.log('[API CALL] Reloading QR Code...');
qrCodeGenerated.value = false;
setTimeout(() => {
qrCodeGenerated.value = true;
console.log('QR Code reloaded.');
}, 500);
};
const completeVerification = () => {
const patientIndex = patients.value.findIndex(
(p) => p.rm === selectedPatient.value.rm
);
// Use store action to update local state optimistically
verificationStore.updatePatientVerification(selectedPatient.value.rm, tempTelepon.value);
// TODO: Add API call in store to save verification
if (patientIndex !== -1) {
patients.value[patientIndex].status = 'Terverifikasi';
patients.value[patientIndex].telepon = tempTelepon.value;
}
filterStatus.value = 2;
filterStatus.value = 2; // Switch tab to verified
closeModal();
};
// Cleanup
onUnmounted(() => {
verificationStore.clearPatients();
});
</script>
<style scoped lang="scss">
@@ -385,12 +396,26 @@ $font-weight-semibold: 600;
color: $neutral-800;
font-size: 14px;
line-height: 20px;
text-align: center !important;
.v-data-table-header__content {
justify-content: center !important;
width: 100%;
}
}
:deep(.v-data-table__td) {
font-size: 14px;
line-height: 20px;
color: $neutral-900;
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;
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
const config = useRuntimeConfig();
// Define session duration (default to 1 hour if not specified in config)
const SESSION_DURATION = (config.sessionDurationHours || 1) * 60 * 60;
const SESSION_DURATION = (config.sessionDurationHours || 1) * 60 * 60 * 24;
// This is the MAIN SESSION duration. It controls how long a user stays logged in.
// Current configuration: 1 hour (3600 seconds).
+129
View File
@@ -0,0 +1,129 @@
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useVerificationStore = defineStore('verification', () => {
// State
const patients = ref([]);
const loading = ref(false);
const error = ref(null);
// API Base URL
const API_BASE_URL = 'http://10.10.150.131:8089/api/v1';
// Helpers
const formatAddress = (p) => {
return [p.alamat, p.namakelurahan, p.namakecamatan, p.nama_kota, p.namaprovinsi]
.filter(Boolean)
.join(', ');
};
const mapPatientData = (apiData) => {
return {
nama: apiData.nama,
rm: apiData.nomr,
alamat: formatAddress(apiData),
telepon: apiData.notlp || '',
status: apiData.notlp ? 'Terverifikasi' : 'Belum Terverifikasi',
originalData: apiData // Keep original data if needed
};
};
// Actions
const fetchAllPatients = async () => {
loading.value = true;
error.value = null;
try {
const result = await $fetch(`${API_BASE_URL}/pasien/search`, {
params: { nama: 'AA' }
});
if (result) {
const data = result.data || result;
const results = Array.isArray(data) ? data : [];
patients.value = results.map(mapPatientData);
} else {
patients.value = [];
}
} catch (e) {
console.error('Exception fetching all patients:', e);
error.value = e;
patients.value = [];
} finally {
loading.value = false;
}
};
const fetchPatientByRm = async (rm) => {
loading.value = true;
error.value = null;
try {
const result = await $fetch(`${API_BASE_URL}/pasien/${rm}`);
if (result) {
const data = result.data || result;
if (data && data.nomr) {
patients.value = [mapPatientData(data)];
} else {
patients.value = [];
}
} else {
patients.value = [];
}
} catch (e) {
console.error('Exception fetching patient by RM:', e);
error.value = e;
patients.value = [];
} finally {
loading.value = false;
}
};
const searchPatientsByName = async (name) => {
loading.value = true;
error.value = null;
try {
const result = await $fetch(`${API_BASE_URL}/pasien/search`, {
params: { nama: name }
});
if (result) {
const data = result.data || result;
const results = Array.isArray(data) ? data : [];
patients.value = results.map(mapPatientData);
} else {
patients.value = [];
}
} catch (e) {
console.error('Exception searching patients:', e);
error.value = e;
patients.value = [];
} finally {
loading.value = false;
}
};
const clearPatients = () => {
patients.value = [];
error.value = null;
};
// Optimistic update for verification status
const updatePatientVerification = (rm, phoneNumber) => {
const patientIndex = patients.value.findIndex(p => p.rm === rm);
if (patientIndex !== -1) {
patients.value[patientIndex].status = 'Terverifikasi';
patients.value[patientIndex].telepon = phoneNumber;
}
}
return {
patients,
loading,
error,
fetchAllPatients,
fetchPatientByRm,
searchPatientsByName,
clearPatients,
updatePatientVerification
};
});