// stores/loketStore.js import { defineStore } from 'pinia'; import { ref, computed } from 'vue'; import { useClinicStore } from './clinicStore'; import { useAnjunganStore } from './anjunganStore'; // Helper function: Convert nomor loket ke huruf (1 -> A, 2 -> B, dst) const numberToLetter = (num) => { if (num < 1) return 'A'; // 1 -> A (65), 2 -> B (66), ..., 26 -> Z (90) const charCode = 64 + num; // 64 = '@', 65 = 'A' return String.fromCharCode(Math.min(charCode, 90)); // Maksimal Z (90) }; // Helper function: Get pelayanan contoh dari master anjungan // Mengambil data klinik dari anjungan pertama (Reguler) sebagai contoh const getPelayananContohDariAnjungan = (anjunganItems) => { // Ambil data klinik dari anjungan pertama (Reguler) jika ada let klinikAnjunganReguler = []; if (anjunganItems && anjunganItems.length > 0) { // Cari anjungan dengan jenisPasien 'Reguler' atau ambil yang pertama const anjunganReguler = anjunganItems.find(a => a.jenisPasien === 'Reguler') || anjunganItems[0]; klinikAnjunganReguler = anjunganReguler.klinik || []; } // Jika tidak ada data dari anjungan, gunakan data default dari master anjungan if (klinikAnjunganReguler.length === 0) { klinikAnjunganReguler = ['AN', 'AS', 'BD', 'GR', 'HO', 'GI', 'GZ', 'IP', 'JT', 'JW', 'KK', 'MT', 'OB', 'PR', 'RT', 'RM', 'SR']; } // Distribusi pelayanan ke beberapa loket sebagai contoh // Menggunakan data klinik yang tersedia dari master anjungan return { loket1: klinikAnjunganReguler.filter(k => ['AN','RT', 'RM', 'TD'].includes(k)).length > 0 ? klinikAnjunganReguler.filter(k => ['RT', 'RM', 'TD'].includes(k)) : klinikAnjunganReguler.slice(0, 3), // Ambil 3 pertama jika tidak ada match loket2: klinikAnjunganReguler.filter(k => ['JW', 'SR'].includes(k)).length > 0 ? klinikAnjunganReguler.filter(k => ['JW', 'SR'].includes(k)) : klinikAnjunganReguler.slice(3, 5), // Ambil 2 berikutnya loket3: klinikAnjunganReguler.filter(k => ['AS', 'JT'].includes(k)).length > 0 ? klinikAnjunganReguler.filter(k => ['AS', 'JT'].includes(k)) : klinikAnjunganReguler.slice(5, 7), // Ambil 2 berikutnya loket4: klinikAnjunganReguler.filter(k => ['KK', 'PR'].includes(k)).length > 0 ? klinikAnjunganReguler.filter(k => ['KK', 'PR'].includes(k)) : klinikAnjunganReguler.slice(7, 9), // Ambil 2 berikutnya loket5: klinikAnjunganReguler.filter(k => ['BD', 'GI'].includes(k)).length > 0 ? klinikAnjunganReguler.filter(k => ['BD', 'GI'].includes(k)) : klinikAnjunganReguler.slice(9, 11), // Ambil 2 berikutnya loket6: klinikAnjunganReguler.filter(k => ['GR', 'GZ'].includes(k)).length > 0 ? klinikAnjunganReguler.filter(k => ['GR', 'GZ'].includes(k)) : klinikAnjunganReguler.slice(11, 13), // Ambil 2 berikutnya loket7: klinikAnjunganReguler.filter(k => ['IP', 'MT'].includes(k)).length > 0 ? klinikAnjunganReguler.filter(k => ['IP', 'MT'].includes(k)) : klinikAnjunganReguler.slice(13, 15), // Ambil 2 berikutnya loket8: klinikAnjunganReguler.filter(k => ['OB', 'HO'].includes(k)).length > 0 ? klinikAnjunganReguler.filter(k => ['OB', 'HO'].includes(k)) : klinikAnjunganReguler.slice(15, 17), // Ambil 2 berikutnya loket9: klinikAnjunganReguler.filter(k => ['AN', 'BD'].includes(k)).length > 0 ? klinikAnjunganReguler.filter(k => ['AN', 'BD'].includes(k)) : klinikAnjunganReguler.slice(0, 2), // Ambil 2 pertama jika tidak ada match loket10: klinikAnjunganReguler.filter(k => ['GI', 'GZ'].includes(k)).length > 0 ? klinikAnjunganReguler.filter(k => ['GI', 'GZ'].includes(k)) : klinikAnjunganReguler.slice(2, 4), // Ambil 2 berikutnya }; }; export const useLoketStore = defineStore('loket', () => { const clinicStore = useClinicStore(); const anjunganStore = useAnjunganStore(); // ============================================ // STATE - SEPARATED DATA SOURCES // ============================================ // API Data (JKN/REGULER) - ID 1-14 dari backend // TIDAK di-persist ke localStorage, selalu fetch fresh dari API const apiLoketData = ref([]) // Local Data (EKSEKUTIF) - ID 1000+ manual input // DI-persist ke localStorage. Default 14 loket hardcoded. const localLoketData = ref(Array.from({ length: 14 }, (_, i) => { // Distribute services securely // Loket 1-5: Single service // Loket 6-10: Dual services // Loket 11+: Single service let services = []; if (i < 5) { services = [1000 + i]; } else if (i < 10) { services = [1000 + i, 1005 + i]; } else { services = [1000 + i]; } return { id: 1000 + i, no: i + 1, namaLoket: `LOKET ${i + 1} EKS`, kuota: 500, pelayanan: services, pembayaran: ['EKSEKUTIF'], // Changed to array for consistency tipeLoket: 'EKSEKUTIF', // Add tipeLoket field for consistency keterangan: 'ONLINE', statusPelayanan: 'RAWAT JALAN', source: 'local', loketAktif: true }; })) // Merged data (computed) - Gabungan API + Local const loketData = computed(() => { // Debug log if (apiLoketData.value.length > 0) { console.log('📊 [loketData computed] API data found:', apiLoketData.value.length, 'items'); } if (localLoketData.value.length > 0) { console.log('📊 [loketData computed] Local data found:', localLoketData.value.length, 'items'); } // Create new objects to prevent state mutation in computed const merged = [ ...apiLoketData.value.map(i => ({...i})), ...localLoketData.value.map(i => ({...i})) ] console.log('📊 [loketData computed] Merged total:', merged.length, 'items'); // Sort by ID merged.sort((a, b) => a.id - b.id) // Recalculate sequential No return merged.map((item, index) => ({ ...item, no: index + 1 })) }) // Computed - Available services (reference dari clinicStore) // Mengambil data dari clinicStore untuk dropdown pelayanan // Menggunakan getAllClinics computed untuk memastikan reactivity const availableServices = computed(() => { try { // Gunakan getAllClinics computed dari clinicStore // Ini akan otomatis reactive ketika clinics berubah const clinicsComputed = clinicStore.getAllClinics; if (!clinicsComputed) { console.warn('clinicStore.getAllClinics is not available'); return []; } // getAllClinics adalah computed, akses .value untuk mendapatkan array // Vue akan otomatis track dependency ini const clinicsArray = clinicsComputed.value || []; // Pastikan clinicsArray adalah array if (!Array.isArray(clinicsArray)) { console.warn('getAllClinics.value is not an array:', typeof clinicsArray, clinicsArray); return []; } if (clinicsArray.length === 0) { console.warn('No clinics available from clinicStore'); return []; } // Map ke format yang dibutuhkan form: { id, nama, kode } // id menggunakan kode untuk kompatibilitas dengan form yang menggunakan item-value="id" return clinicsArray.map(c => { if (!c || !c.kode || !c.name) { console.warn('Invalid clinic data:', c); return null; } return { id: c.kode, // Menggunakan kode sebagai id (sesuai dengan item-value="id" di form) nama: c.name, kode: c.kode, }; }).filter(Boolean); // Filter out null values } catch (error) { console.error('Error getting available services from clinicStore:', error); return []; } }); // Actions - CRUD Operations // ADD: Hanya untuk EKSEKUTIF (ID 1000+) const addLoket = (loketPayload) => { // Generate ID starting from 1000 for EKSEKUTIF const maxLocalId = localLoketData.value.length > 0 ? Math.max(...localLoketData.value.map(l => l.id)) : 999; const newId = maxLocalId + 1; const newNo = loketData.value.length + 1; const newLoket = { id: newId, no: newNo, ...loketPayload, pembayaran: loketPayload.pembayaran || ['EKSEKUTIF'], // Ensure array format tipeLoket: loketPayload.tipeLoket || 'EKSEKUTIF', // Add tipeLoket support source: 'local', // Mark as local data loketAktif: loketPayload.loketAktif ?? true, // Default aktif }; localLoketData.value.push(newLoket); return { success: true, message: `Loket ${newLoket.namaLoket} berhasil ditambahkan`, data: newLoket }; }; // UPDATE: Hanya untuk EKSEKUTIF (source='local') const updateLoket = (loketPayload) => { // Check if trying to edit API data if (loketPayload.source === 'api' || (loketPayload.id >= 1 && loketPayload.id <= 100)) { return { success: false, message: 'Data JKN dari API tidak bisa diedit. Hanya data EKSEKUTIF yang bisa diubah.' }; } const index = localLoketData.value.findIndex(l => l.id === loketPayload.id); if (index !== -1) { localLoketData.value[index] = { ...localLoketData.value[index], ...loketPayload, source: 'local', // Ensure source stays local pembayaran: loketPayload.pembayaran && Array.isArray(loketPayload.pembayaran) ? loketPayload.pembayaran : ['EKSEKUTIF'], // Ensure array format tipeLoket: loketPayload.tipeLoket || 'EKSEKUTIF', // Add tipeLoket support }; return { success: true, message: `Loket ${loketPayload.namaLoket} berhasil diupdate` }; } return { success: false, message: 'Loket tidak ditemukan' }; }; // DELETE: Hanya untuk EKSEKUTIF (source='local') const deleteLoket = (loketId) => { // Check if trying to delete API data if (loketId >= 1 && loketId <= 100) { return { success: false, message: 'Data JKN dari API tidak bisa dihapus. Hanya data EKSEKUTIF yang bisa dihapus.' }; } const index = localLoketData.value.findIndex(l => l.id === loketId); if (index !== -1) { const loketName = localLoketData.value[index].namaLoket; localLoketData.value.splice(index, 1); return { success: true, message: `Loket ${loketName} berhasil dihapus` }; } return { success: false, message: 'Loket tidak ditemukan' }; }; const getLoketById = (id) => { return loketData.value.find(l => l.id === id); }; // Helper function untuk convert nomor ke huruf (export untuk digunakan di komponen) const getLoketLetter = (num) => numberToLetter(num); // ============================================ // API INTEGRATION // ============================================ // State untuk API management const isLoadingAPI = ref(false); const apiError = ref(null); const lastSyncTimestamp = ref(null); let activeFetchPromise = null; /** * Fetch loket data dari API backend * MENGGUNAKAN endpoint /api/v1/klinik/loket yang sudah terstruktur per Loket */ const fetchLoketFromAPI = async (force = false, retryCount = 0) => { if (activeFetchPromise && retryCount === 0) { console.log('⏳ Loket fetch in progress, reusing existing request...'); return activeFetchPromise; } const hasData = apiLoketData.value && apiLoketData.value.length > 0; const hasCachedData = hasData && lastSyncTimestamp.value; // If we have cached data and not forcing refresh, use cache if (hasCachedData && !force && retryCount === 0) { const cacheAge = Date.now() - new Date(lastSyncTimestamp.value).getTime(); const cacheAgeMinutes = Math.floor(cacheAge / 60000); console.log(`📦 Using cached API data (${cacheAgeMinutes} minutes old, ${apiLoketData.value.length} items)`); return { success: true, message: `Data tersedia dari cache (${cacheAgeMinutes} menit yang lalu)`, cached: true }; } const performFetch = async () => { isLoadingAPI.value = true; apiError.value = null; try { console.log(`🔄 [loketStore] Fetching loket configuration (Try ${retryCount + 1})...`); const response = await fetch('http://10.10.150.131:8089/api/v1/klinik/loket'); if (!response.ok) { if (response.status === 429 && retryCount < 3) { const delay = (retryCount + 1) * 1500; await new Promise(resolve => setTimeout(resolve, delay)); activeFetchPromise = null; return fetchLoketFromAPI(force, retryCount + 1); } throw new Error(`HTTP error! status: ${response.status}`); } const rawData = await response.json(); const loketsRaw = rawData.data || []; // MAPPING: Convert API Structure to Store Format const mappedLokets = loketsRaw.map(l => { const id = parseInt(l.idloket); // Map pelayanan (clinic codes) dan detail spesialis const spesialisDetail = (l.spesialis || []).map(s => { // Coba cari kode klinik dari clinicStore untuk konsistensi const clinic = clinicStore.clinics.find(c => String(c.id) === String(s.idklinik) || c.name === s.namaklinik ); return { idklinik: s.idklinik, namaklinik: s.namaklinik, code: clinic ? clinic.kode : (s.kode || s.idklinik) }; }); // Extract unique codes for 'pelayanan' field const pelayananCodes = [...new Set(spesialisDetail.map(s => s.code))]; // Map payment types as array for multiple chips display const pembayaranArray = (l.pembayaran || []) .map(p => p.pembayaran) .filter(Boolean); // If no payment types, default to ['JKN'] const pembayaran = pembayaranArray.length > 0 ? pembayaranArray : ['JKN']; return { id: id, namaLoket: l.namaloket, kodeLoket: l.kodeloket, kuota: parseInt(l.kuotaloket) || 100, pelayanan: pelayananCodes, _spesialisDetail: spesialisDetail, pembayaran: pembayaran, // Now an array instead of joined string tipeLoket: l.tipeloket || 'REGULER', // Map tipeloket to tipeLoket (capital L) source: 'api', loketAktif: l.loketaktif ?? true, jenisloket: l.jenisloket, tipeloket: l.tipeloket // Keep original for backward compatibility }; }); // Sort by id for stability and set sequential No mappedLokets.sort((a, b) => a.id - b.id); mappedLokets.forEach((loket, idx) => { loket.no = idx + 1; }); console.log(`✅ [loketStore] Successfully mapped ${mappedLokets.length} lokets from API`); apiLoketData.value = mappedLokets; lastSyncTimestamp.value = new Date().toISOString(); return { success: true, message: `${mappedLokets.length} loket berhasil dikonfigurasi`, data: mappedLokets }; } catch (error) { console.error('❌ [loketStore] Error fetching loket config:', error); apiError.value = error.message; // FALLBACK 1: Use cached data if available if (apiLoketData.value.length > 0) { const cacheAge = lastSyncTimestamp.value ? Math.floor((Date.now() - new Date(lastSyncTimestamp.value).getTime()) / 60000) : 'unknown'; console.warn(`⚠️ [loketStore] API failed, using cached data (${cacheAge} minutes old)`); return { success: true, message: `API gagal, menggunakan data cache (${cacheAge} menit yang lalu)`, warning: true, cached: true }; } // FALLBACK 2: If no cache, populate with dummy Reguler data for Dev/Offline mode console.warn('⚠️ [loketStore] No cache available, using FALLBACK dummy data...'); const dummyLokets = Array.from({length: 6}, (_, i) => ({ id: i + 1, namaLoket: `LOKET ${i + 1} REG (Mock)`, kodeLoket: `L${i+1}`, kuota: 100, pelayanan: ['UM', 'BP', 'OB', 'AN', 'IP', 'SR', 'TH', 'MT', 'KK', 'PR'], // Mock all services _spesialisDetail: [], // Empty detail pembayaran: ['BPJS', 'UMUM'], // Changed to array for consistency tipeLoket: 'REGULER', // Add tipeLoket for fallback data source: 'api', // Mimic API loketAktif: true, jenisloket: 'REGULER', tipeloket: 'REGULER', no: i + 1 })); apiLoketData.value = dummyLokets; return { success: true, message: 'Menggunakan data fallback (API Offline)', warning: true }; return { success: false, message: `Gagal memuat: ${error.message}` }; } finally { isLoadingAPI.value = false; activeFetchPromise = null; } }; activeFetchPromise = performFetch(); return activeFetchPromise; }; /** * Fetch detail data for a SPECIFIC loket * Endpoint: /api/v1/loket/:loketid */ const fetchSingleLoketFromAPI = async (loketId) => { if (!loketId) return { success: false, message: 'ID Loket diperlukan' }; isLoadingAPI.value = true; apiError.value = null; try { console.log(`🔄 [loketStore] Fetching detail for loket ${loketId}...`); const response = await fetch(`http://10.10.150.131:8089/api/v1/loket/${loketId}`); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const rawData = await response.json(); if (rawData.metadata && rawData.metadata.code !== 200) { throw new Error(rawData.message || 'API returned error status'); } // Check if data is array or single object const data = rawData.data; if (!data) throw new Error('No data returned from API'); // The API /api/v1/loket/:id seems to return structure similar to /api/v1/klinik/loket // but filtered for one loket. Let's handle both array and object. const l = Array.isArray(data) ? data[0] : data; if (!l) throw new Error('Loket data empty'); // Map to store format const id = parseInt(l.idloket || l.id); const spesialisDetail = (l.spesialis || []).map(s => { const clinic = clinicStore.clinics.find(c => String(c.id) === String(s.idklinik) || c.name === s.namaklinik ); return { idklinik: s.idklinik, namaklinik: s.namaklinik, code: clinic ? clinic.kode : (s.kode || s.idklinik) }; }); const pelayananCodes = [...new Set(spesialisDetail.map(s => s.code))]; const pembayaranArray = (l.pembayaran || []).map(p => p.pembayaran).filter(Boolean); const pembayaran = pembayaranArray.length > 0 ? pembayaranArray : ['JKN']; const mapped = { id: id, namaLoket: l.namaloket, kodeLoket: l.kodeloket, kuota: parseInt(l.kuotaloket) || 100, // Kuota is part of the detail but we keep it pelayanan: pelayananCodes, _spesialisDetail: spesialisDetail, pembayaran: pembayaran, tipeLoket: l.tipeloket || 'REGULER', source: 'api', loketAktif: l.loketaktif ?? true, jenisloket: l.jenisloket, tipeloket: l.tipeloket }; // Sync to apiLoketData const index = apiLoketData.value.findIndex(item => item.id === id); if (index !== -1) { // PRESERVE sequential 'no' if exists mapped.no = apiLoketData.value[index].no; apiLoketData.value[index] = mapped; } else { mapped.no = apiLoketData.value.length + 1; apiLoketData.value.push(mapped); apiLoketData.value.sort((a, b) => a.id - b.id); } return { success: true, message: `Detail loket ${mapped.namaLoket} diperbarui`, data: mapped }; } catch (error) { console.error(`❌ [loketStore] Error fetching detail for loket ${loketId}:`, error); apiError.value = error.message; return { success: false, message: `Gagal memuat detail loket: ${error.message}` }; } finally { isLoadingAPI.value = false; } }; return { // State - Base refs (for persist) apiLoketData, // Raw API data array (persisted) localLoketData, // Raw local data array (persisted) // State - Computed loketData, lokets: loketData, // Alias for backward compatibility/clarity availableServices, // API State isLoadingAPI, apiError, lastSyncTimestamp, // Actions addLoket, updateLoket, deleteLoket, getLoketById, getLoketLetter, // API Actions fetchLoketFromAPI, fetchSingleLoketFromAPI, }; }, { persist: { key: 'loket-store-state', storage: typeof window !== 'undefined' ? localStorage : undefined, paths: [ 'localLoketData', // Persist EKSEKUTIF data (ID 1000+) 'apiLoketData', // Persist API data (REGULER) to minimize data loss 'lastSyncTimestamp' // Track when API data was last fetched ], serializer: { deserialize: JSON.parse, serialize: JSON.stringify, }, }, });