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) {
const query = provinceId ? `?province_id=${provinceId}` : '';
const query = provinceId ? `?province_id=${encodeURIComponent(provinceId)}` : '';
return get(`/api/areageo/cities${query}`);
}

View File

@ -29,6 +29,16 @@
lg: 'modal-lg',
xl: 'modal-xl',
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} />
<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 -->
<div class="flex items-center justify-between mb-4">
{#if title}

View File

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

View File

@ -11,6 +11,9 @@ import { error as toastError } from '$lib/utils/toast.js';
function createValueSetsStore() {
const { subscribe, set, update } = writable({});
// Keep track of in-flight requests to prevent duplicates
const inflightRequests = new Map();
return {
subscribe,
@ -20,44 +23,42 @@ function createValueSetsStore() {
* @returns {Promise<Array>} - Array of value set items
*/
async load(key) {
let result = [];
console.log('valuesets.load() called for key:', key);
update((cache) => {
// If already loading, return current state
if (cache[key]?.loading) {
return cache;
// If there's already an in-flight request, return its promise
if (inflightRequests.has(key)) {
console.log('valuesets: returning existing in-flight request for:', key);
return inflightRequests.get(key);
}
// If already loaded, return cached items
if (cache[key]?.loaded) {
result = cache[key].items;
return cache;
// Check if already loaded in cache
let cacheState = null;
subscribe((state) => { cacheState = state; })();
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
return {
update((cache) => ({
...cache,
[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 {
console.log('valuesets: calling API for key:', key);
const response = await fetchValueSetByKey(key);
console.log('valuesets: API response for', key, ':', response);
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
items.sort((a, b) => (a.Sequence || 0) - (b.Sequence || 0));
@ -67,12 +68,14 @@ function createValueSetsStore() {
[key]: { items, loaded: true, loading: false, error: null },
}));
console.log('valuesets: returning items for', key);
return items;
} else {
throw new Error(response.message || 'Failed to load value set');
}
} catch (err) {
const errorMessage = err.message || `Failed to load value set: ${key}`;
console.error('valuesets: error for', key, ':', err);
update((cache) => ({
...cache,
@ -81,7 +84,13 @@ function createValueSetsStore() {
toastError(errorMessage);
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();
/**

View File

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