fix: Improve SelectDropdown handling and valueset caching

This commit is contained in:
mahdahar 2026-02-11 20:04:46 +07:00
parent 4accf7b6d6
commit 4641668f78
5 changed files with 226 additions and 231 deletions

View File

@ -9,6 +9,6 @@ export async function fetchProvinces() {
} }
export async function fetchCities(provinceId = null) { export async function fetchCities(provinceId = null) {
const query = provinceId ? `?province_id=${provinceId}` : ''; const query = provinceId ? `?province_id=${encodeURIComponent(provinceId)}` : '';
return get(`/api/areageo/cities${query}`); return get(`/api/areageo/cities${query}`);
} }

View File

@ -29,6 +29,16 @@
lg: 'modal-lg', lg: 'modal-lg',
xl: 'modal-xl', xl: 'modal-xl',
full: 'modal-full', full: 'modal-full',
wide: 'modal-wide',
};
const widthStyles = {
sm: 'max-width: 400px;',
md: 'max-width: 500px;',
lg: 'max-width: 800px;',
xl: 'max-width: 1200px;',
full: 'max-width: 100%; width: 100%; height: 100%;',
wide: 'max-width: 90vw; width: 1200px;',
}; };
/** /**
@ -67,7 +77,7 @@
<svelte:window onkeydown={handleKeydown} /> <svelte:window onkeydown={handleKeydown} />
<dialog class="modal {sizeClasses[size] || ''}" class:modal-open={open}> <dialog class="modal {sizeClasses[size] || ''}" class:modal-open={open}>
<div class="modal-box" role="dialog" aria-modal="true" aria-labelledby={title ? 'modal-title' : undefined}> <div class="modal-box" style={widthStyles[size] || ''} role="dialog" aria-modal="true" aria-labelledby={title ? 'modal-title' : undefined}>
<!-- Header --> <!-- Header -->
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
{#if title} {#if title}

View File

@ -38,12 +38,20 @@
onMount(async () => { onMount(async () => {
if (valueSetKey) { if (valueSetKey) {
loading = true; loading = true;
try {
const items = await valueSets.load(valueSetKey); const items = await valueSets.load(valueSetKey);
if (!items || items.length === 0) {
console.warn('SelectDropdown: No items loaded for valueset:', valueSetKey);
}
dropdownOptions = items.map((item) => ({ dropdownOptions = items.map((item) => ({
value: item.Value, value: item.value || item.Value || item.code || item.Code || item.ItemCode || item.itemCode || '',
label: item.Label, label: item.label || item.Label || item.description || item.Description || item.name || item.Name || item.value || item.Value || '',
})); }));
} catch (err) {
console.error('SelectDropdown error loading valueset:', err);
} finally {
loading = false; loading = false;
}
} else if (options.length > 0) { } else if (options.length > 0) {
dropdownOptions = options; dropdownOptions = options;
} }
@ -51,7 +59,7 @@
// Watch for changes in manual options // Watch for changes in manual options
$effect(() => { $effect(() => {
if (!valueSetKey && options.length > 0) { if (!valueSetKey) {
dropdownOptions = options; dropdownOptions = options;
} }
}); });

View File

@ -11,6 +11,9 @@ import { error as toastError } from '$lib/utils/toast.js';
function createValueSetsStore() { function createValueSetsStore() {
const { subscribe, set, update } = writable({}); const { subscribe, set, update } = writable({});
// Keep track of in-flight requests to prevent duplicates
const inflightRequests = new Map();
return { return {
subscribe, subscribe,
@ -20,44 +23,42 @@ function createValueSetsStore() {
* @returns {Promise<Array>} - Array of value set items * @returns {Promise<Array>} - Array of value set items
*/ */
async load(key) { async load(key) {
let result = []; console.log('valuesets.load() called for key:', key);
update((cache) => { // If there's already an in-flight request, return its promise
// If already loading, return current state if (inflightRequests.has(key)) {
if (cache[key]?.loading) { console.log('valuesets: returning existing in-flight request for:', key);
return cache; return inflightRequests.get(key);
} }
// If already loaded, return cached items // Check if already loaded in cache
if (cache[key]?.loaded) { let cacheState = null;
result = cache[key].items; subscribe((state) => { cacheState = state; })();
return cache;
if (cacheState?.[key]?.loaded) {
console.log('valuesets: returning cached items for:', key);
return cacheState[key].items;
} }
// Create the fetch promise
const fetchPromise = (async () => {
console.log('valuesets: starting fetch for:', key);
// Mark as loading // Mark as loading
return { update((cache) => ({
...cache, ...cache,
[key]: { items: [], loaded: false, loading: true, error: null }, [key]: { items: [], loaded: false, loading: true, error: null },
}; }));
});
// If already loaded, return immediately
if (result.length > 0) {
return result;
}
// If already loading, wait a bit and retry
const currentState = getCurrentState();
if (currentState[key]?.loading) {
await new Promise((resolve) => setTimeout(resolve, 100));
return this.load(key);
}
try { try {
console.log('valuesets: calling API for key:', key);
const response = await fetchValueSetByKey(key); const response = await fetchValueSetByKey(key);
console.log('valuesets: API response for', key, ':', response);
if (response.status === 'success' && response.data) { if (response.status === 'success' && response.data) {
const items = response.data.Items || []; // Handle both response.data being an array or having an Items property
let items = Array.isArray(response.data) ? response.data : (response.data.Items || []);
console.log('valuesets: items for', key, ':', items);
// Sort by Sequence if available // Sort by Sequence if available
items.sort((a, b) => (a.Sequence || 0) - (b.Sequence || 0)); items.sort((a, b) => (a.Sequence || 0) - (b.Sequence || 0));
@ -67,12 +68,14 @@ function createValueSetsStore() {
[key]: { items, loaded: true, loading: false, error: null }, [key]: { items, loaded: true, loading: false, error: null },
})); }));
console.log('valuesets: returning items for', key);
return items; return items;
} else { } else {
throw new Error(response.message || 'Failed to load value set'); throw new Error(response.message || 'Failed to load value set');
} }
} catch (err) { } catch (err) {
const errorMessage = err.message || `Failed to load value set: ${key}`; const errorMessage = err.message || `Failed to load value set: ${key}`;
console.error('valuesets: error for', key, ':', err);
update((cache) => ({ update((cache) => ({
...cache, ...cache,
@ -81,7 +84,13 @@ function createValueSetsStore() {
toastError(errorMessage); toastError(errorMessage);
return []; return [];
} finally {
inflightRequests.delete(key);
} }
})();
inflightRequests.set(key, fetchPromise);
return fetchPromise;
}, },
/** /**
@ -163,15 +172,6 @@ function createValueSetsStore() {
}; };
} }
// Helper to get current state synchronously
function getCurrentState() {
let state = {};
valueSets.subscribe((s) => {
state = s;
})();
return state;
}
export const valueSets = createValueSetsStore(); export const valueSets = createValueSetsStore();
/** /**

View File

@ -2,10 +2,10 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { import {
fetchPatients, fetchPatients,
fetchPatient,
createPatient, createPatient,
updatePatient, updatePatient,
deletePatient, deletePatient,
checkPatientExists,
} from '$lib/api/patients.js'; } from '$lib/api/patients.js';
import { fetchProvinces, fetchCities } from '$lib/api/geography.js'; import { fetchProvinces, fetchCities } from '$lib/api/geography.js';
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js'; import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
@ -16,6 +16,7 @@
// State // State
let loading = $state(false); let loading = $state(false);
let modalLoading = $state(false);
let patients = $state([]); let patients = $state([]);
let provinces = $state([]); let provinces = $state([]);
let cities = $state([]); let cities = $state([]);
@ -26,6 +27,7 @@
let deleteItem = $state(null); let deleteItem = $state(null);
let currentStep = $state(1); let currentStep = $state(1);
let formErrors = $state({}); let formErrors = $state({});
let previousProvince = $state('');
// Search and pagination // Search and pagination
let searchQuery = $state(''); let searchQuery = $state('');
@ -69,7 +71,9 @@
PatCom: '', PatCom: '',
}); });
// Dropdown options
// Dropdown options (prefix - valueset not available yet)
const prefixOptions = [ const prefixOptions = [
{ value: 'Mr', label: 'Mr' }, { value: 'Mr', label: 'Mr' },
{ value: 'Mrs', label: 'Mrs' }, { value: 'Mrs', label: 'Mrs' },
@ -78,35 +82,13 @@
{ value: 'Prof', label: 'Prof' }, { value: 'Prof', label: 'Prof' },
]; ];
const sexOptions = [
{ value: '1', label: 'Female' },
{ value: '2', label: 'Male' },
];
const maritalStatusOptions = [
{ value: 'A', label: 'Annulled' },
{ value: 'B', label: 'Separated' },
{ value: 'D', label: 'Divorced' },
{ value: 'M', label: 'Married' },
{ value: 'S', label: 'Single' },
{ value: 'W', label: 'Widowed' },
];
const identifierTypeOptions = [
{ value: 'KTP', label: 'KTP (National ID)' },
{ value: 'PASS', label: 'Passport' },
{ value: 'SSN', label: 'SSN' },
{ value: 'SIM', label: 'Driver License' },
{ value: 'KTAS', label: 'KTAS' },
];
// Derived values // Derived values
const provinceOptions = $derived( const provinceOptions = $derived(
provinces.map((p) => ({ value: p.AreaCode, label: p.AreaName })) provinces.map((p) => ({ value: p.value, label: p.label }))
); );
const cityOptions = $derived( const cityOptions = $derived(
cities.map((c) => ({ value: c.AreaCode, label: c.AreaName })) cities.map((c) => ({ value: c.value, label: c.label }))
); );
const columns = [ const columns = [
@ -118,7 +100,10 @@
]; ];
onMount(async () => { onMount(async () => {
await Promise.all([loadPatients(), loadProvinces()]); await Promise.all([
loadPatients(),
loadProvinces(),
]);
}); });
async function loadPatients() { async function loadPatients() {
@ -164,7 +149,7 @@
} }
try { try {
const response = await fetchCities(provinceCode); const response = await fetchCities(provinceCode);
cities = Array.isArray(response.data) ? response.data : []; cities = Array.isArray(response.data) ? response.data : (Array.isArray(response) ? response : []);
} catch (err) { } catch (err) {
console.error('Failed to load cities:', err); console.error('Failed to load cities:', err);
cities = []; cities = [];
@ -224,50 +209,62 @@
modalOpen = true; modalOpen = true;
} }
function openEditModal(row) { async function openEditModal(row) {
modalMode = 'edit'; modalMode = 'edit';
currentStep = 1; currentStep = 1;
formErrors = {}; formErrors = {};
modalLoading = true;
try {
// Fetch full patient details including individual name fields
const response = await fetchPatient(row.InternalPID);
const patient = response.data || response;
formData = { formData = {
InternalPID: row.InternalPID, InternalPID: patient.InternalPID,
PatientID: row.PatientID || '', PatientID: patient.PatientID || '',
Prefix: row.Prefix || '', Prefix: patient.Prefix || '',
NameFirst: row.NameFirst || '', NameFirst: patient.NameFirst || '',
NameMiddle: row.NameMiddle || '', NameMiddle: patient.NameMiddle || '',
NameLast: row.NameLast || '', NameLast: patient.NameLast || '',
NameMaiden: row.NameMaiden || '', NameMaiden: patient.NameMaiden || '',
Suffix: row.Suffix || '', Suffix: patient.Suffix || '',
Sex: row.Sex || '', Sex: patient.Sex || '',
Birthdate: row.Birthdate ? row.Birthdate.split('T')[0] : '', Birthdate: patient.Birthdate ? patient.Birthdate.split('T')[0] : '',
PlaceOfBirth: row.PlaceOfBirth || '', PlaceOfBirth: patient.PlaceOfBirth || '',
Citizenship: row.Citizenship || '', Citizenship: patient.Citizenship || '',
Street_1: row.Street_1 || '', Street_1: patient.Street_1 || '',
Street_2: row.Street_2 || '', Street_2: patient.Street_2 || '',
Street_3: row.Street_3 || '', Street_3: patient.Street_3 || '',
ZIP: row.ZIP || '', ZIP: patient.ZIP || '',
Province: row.Province || '', Province: patient.Province || '',
City: row.City || '', City: patient.City || '',
Country: row.Country || 'Indonesia', Country: patient.Country || 'Indonesia',
Phone: row.Phone || '', Phone: patient.Phone || '',
MobilePhone: row.MobilePhone || '', MobilePhone: patient.MobilePhone || '',
EmailAddress1: row.EmailAddress1 || '', EmailAddress1: patient.EmailAddress1 || '',
EmailAddress2: row.EmailAddress2 || '', EmailAddress2: patient.EmailAddress2 || '',
PatIdt: row.PatIdt || { IdentifierType: '', Identifier: '' }, PatIdt: patient.PatIdt || { IdentifierType: '', Identifier: '' },
Race: row.Race || '', Race: patient.Race || '',
MaritalStatus: row.MaritalStatus || '', MaritalStatus: patient.MaritalStatus || '',
Religion: row.Religion || '', Religion: patient.Religion || '',
Ethnic: row.Ethnic || '', Ethnic: patient.Ethnic || '',
DeathIndicator: row.DeathIndicator || 'N', DeathIndicator: patient.DeathIndicator || 'N',
TimeOfDeath: row.TimeOfDeath ? row.TimeOfDeath.split('T')[0] : '', TimeOfDeath: patient.TimeOfDeath ? patient.TimeOfDeath.split('T')[0] : '',
PatCom: row.PatCom || '', PatCom: patient.PatCom || '',
}; };
// Load cities if province is set // Load cities if province is set
if (formData.Province) { if (formData.Province) {
loadCities(formData.Province); await loadCities(formData.Province);
} }
modalOpen = true; modalOpen = true;
} catch (err) {
toastError(err.message || 'Failed to load patient details');
} finally {
modalLoading = false;
}
} }
function validateStep(step) { function validateStep(step) {
@ -358,10 +355,13 @@
} }
} }
function handleProvinceChange() { $effect(() => {
if (formData.Province !== previousProvince) {
previousProvince = formData.Province;
formData.City = ''; formData.City = '';
loadCities(formData.Province); loadCities(formData.Province);
} }
});
</script> </script>
<div class="p-6"> <div class="p-6">
@ -401,10 +401,10 @@
{columns} {columns}
data={patients.map((p) => ({ data={patients.map((p) => ({
...p, ...p,
FullName: [p.Prefix, p.NameFirst, p.NameMiddle, p.NameLast, p.Suffix] FullName: p.FullName || [p.Prefix, p.NameFirst, p.NameMiddle, p.NameLast, p.Suffix]
.filter(Boolean) .filter(Boolean)
.join(' '), .join(' ') || '-',
SexLabel: p.Sex === '1' ? 'Female' : p.Sex === '2' ? 'Male' : '-', SexLabel: p.SexLabel || (p.Sex === '1' ? 'Female' : p.Sex === '2' ? 'Male' : '-'),
BirthdateFormatted: p.Birthdate BirthdateFormatted: p.Birthdate
? new Date(p.Birthdate).toLocaleDateString() ? new Date(p.Birthdate).toLocaleDateString()
: '-', : '-',
@ -464,69 +464,64 @@
<Modal <Modal
bind:open={modalOpen} bind:open={modalOpen}
title={modalMode === 'create' ? 'Add Patient' : 'Edit Patient'} title={modalMode === 'create' ? 'Add Patient' : 'Edit Patient'}
size="xl" size="lg"
> >
<!-- Step Indicator --> <!-- Tabs -->
<div class="mb-6"> <div class="mb-6">
<div class="flex items-center justify-center gap-4"> <div class="flex items-center justify-center gap-2">
<button <button
class="flex items-center gap-2 px-4 py-2 rounded-full transition-colors" class="px-4 py-2 rounded-lg transition-colors"
class:bg-emerald-100={currentStep === 1} class:bg-emerald-600={currentStep === 1}
class:text-emerald-700={currentStep === 1} class:text-white={currentStep === 1}
class:bg-gray-100={currentStep !== 1} class:bg-gray-100={currentStep !== 1}
class:text-gray-600={currentStep !== 1} class:text-gray-600={currentStep !== 1}
class:font-semibold={currentStep === 1}
onclick={() => (currentStep = 1)} onclick={() => (currentStep = 1)}
> >
<span class="w-6 h-6 rounded-full bg-current text-white flex items-center justify-center text-sm font-medium"> Basic Info
1
</span>
<span class="font-medium">Basic Info</span>
</button> </button>
<div class="w-8 h-px bg-gray-300"></div>
<button <button
class="flex items-center gap-2 px-4 py-2 rounded-full transition-colors" class="px-4 py-2 rounded-lg transition-colors"
class:bg-emerald-100={currentStep === 2} class:bg-emerald-600={currentStep === 2}
class:text-emerald-700={currentStep === 2} class:text-white={currentStep === 2}
class:bg-gray-100={currentStep !== 2} class:bg-gray-100={currentStep !== 2}
class:text-gray-600={currentStep !== 2} class:text-gray-600={currentStep !== 2}
onclick={() => currentStep > 2 && (currentStep = 2)} class:font-semibold={currentStep === 2}
onclick={() => (currentStep = 2)}
> >
<span class="w-6 h-6 rounded-full bg-current text-white flex items-center justify-center text-sm font-medium"> Address
2
</span>
<span class="font-medium">Address</span>
</button> </button>
<div class="w-8 h-px bg-gray-300"></div>
<button <button
class="flex items-center gap-2 px-4 py-2 rounded-full transition-colors" class="px-4 py-2 rounded-lg transition-colors"
class:bg-emerald-100={currentStep === 3} class:bg-emerald-600={currentStep === 3}
class:text-emerald-700={currentStep === 3} class:text-white={currentStep === 3}
class:bg-gray-100={currentStep !== 3} class:bg-gray-100={currentStep !== 3}
class:text-gray-600={currentStep !== 3} class:text-gray-600={currentStep !== 3}
onclick={() => currentStep > 3 && (currentStep = 3)} class:font-semibold={currentStep === 3}
onclick={() => (currentStep = 3)}
> >
<span class="w-6 h-6 rounded-full bg-current text-white flex items-center justify-center text-sm font-medium"> Contact & ID
3
</span>
<span class="font-medium">Contact & ID</span>
</button> </button>
<div class="w-8 h-px bg-gray-300"></div>
<button <button
class="flex items-center gap-2 px-4 py-2 rounded-full transition-colors" class="px-4 py-2 rounded-lg transition-colors"
class:bg-emerald-100={currentStep === 4} class:bg-emerald-600={currentStep === 4}
class:text-emerald-700={currentStep === 4} class:text-white={currentStep === 4}
class:bg-gray-100={currentStep !== 4} class:bg-gray-100={currentStep !== 4}
class:text-gray-600={currentStep !== 4} class:text-gray-600={currentStep !== 4}
onclick={() => currentStep > 4 && (currentStep = 4)} class:font-semibold={currentStep === 4}
onclick={() => (currentStep = 4)}
> >
<span class="w-6 h-6 rounded-full bg-current text-white flex items-center justify-center text-sm font-medium"> Additional
4
</span>
<span class="font-medium">Additional</span>
</button> </button>
</div> </div>
</div> </div>
{#if modalLoading}
<div class="flex flex-col items-center justify-center py-12">
<span class="loading loading-spinner loading-lg text-primary"></span>
<p class="text-gray-500 mt-4">Loading patient data...</p>
</div>
{:else}
<form class="space-y-5" onsubmit={(e) => e.preventDefault()}> <form class="space-y-5" onsubmit={(e) => e.preventDefault()}>
<!-- Step 1: Basic Information --> <!-- Step 1: Basic Information -->
{#if currentStep === 1} {#if currentStep === 1}
@ -635,7 +630,7 @@
label="Sex" label="Sex"
name="sex" name="sex"
bind:value={formData.Sex} bind:value={formData.Sex}
options={sexOptions} valueSetKey="sex"
placeholder="Select sex..." placeholder="Select sex..."
required={true} required={true}
/> />
@ -748,7 +743,6 @@
label="Province" label="Province"
name="province" name="province"
bind:value={formData.Province} bind:value={formData.Province}
onchange={handleProvinceChange}
options={provinceOptions} options={provinceOptions}
placeholder="Select province..." placeholder="Select province..."
/> />
@ -841,7 +835,7 @@
label="ID Type" label="ID Type"
name="idType" name="idType"
bind:value={formData.PatIdt.IdentifierType} bind:value={formData.PatIdt.IdentifierType}
options={identifierTypeOptions} valueSetKey="identifier_type"
placeholder="Select ID type..." placeholder="Select ID type..."
/> />
<div class="form-control"> <div class="form-control">
@ -869,49 +863,34 @@
label="Marital Status" label="Marital Status"
name="maritalStatus" name="maritalStatus"
bind:value={formData.MaritalStatus} bind:value={formData.MaritalStatus}
options={maritalStatusOptions} valueSetKey="marital_status"
placeholder="Select status..." placeholder="Select status..."
/> />
<div class="form-control"> <SelectDropdown
<label class="label" for="religion"> label="Religion"
<span class="label-text font-medium">Religion</span> name="religion"
</label>
<input
id="religion"
type="text"
class="input input-bordered w-full"
bind:value={formData.Religion} bind:value={formData.Religion}
placeholder="Enter religion" valueSetKey="religion"
placeholder="Select religion..."
/> />
</div> </div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control"> <SelectDropdown
<label class="label" for="race"> label="Race"
<span class="label-text font-medium">Race</span> name="race"
</label>
<input
id="race"
type="text"
class="input input-bordered w-full"
bind:value={formData.Race} bind:value={formData.Race}
placeholder="Enter race" valueSetKey="race"
placeholder="Select race..."
/> />
</div> <SelectDropdown
<div class="form-control"> label="Ethnicity"
<label class="label" for="ethnic"> name="ethnic"
<span class="label-text font-medium">Ethnicity</span>
</label>
<input
id="ethnic"
type="text"
class="input input-bordered w-full"
bind:value={formData.Ethnic} bind:value={formData.Ethnic}
placeholder="Enter ethnicity" valueSetKey="ethnic"
placeholder="Select ethnicity..."
/> />
</div> </div>
</div>
<div class="border-t border-base-200 pt-5 mt-5"> <div class="border-t border-base-200 pt-5 mt-5">
<h4 class="font-medium text-gray-700 mb-4">Other Information</h4> <h4 class="font-medium text-gray-700 mb-4">Other Information</h4>
@ -920,10 +899,7 @@
label="Death Indicator" label="Death Indicator"
name="deathIndicator" name="deathIndicator"
bind:value={formData.DeathIndicator} bind:value={formData.DeathIndicator}
options={[ valueSetKey="death_indicator"
{ value: 'N', label: 'No' },
{ value: 'Y', label: 'Yes' },
]}
placeholder="Select..." placeholder="Select..."
/> />
{#if formData.DeathIndicator === 'Y'} {#if formData.DeathIndicator === 'Y'}
@ -957,6 +933,7 @@
</div> </div>
{/if} {/if}
</form> </form>
{/if}
{#snippet footer()} {#snippet footer()}
<div class="flex justify-between w-full"> <div class="flex justify-between w-full">