clqms-fe1/src/routes/(app)/patients/PatientFormModal.svelte
mahdahar 8d77370357 refactor(tests): Move TestModal to route folder and add technical config support
- Move TestModal from lib/components to routes/(app)/master-data/tests
- Add technical configuration form (ResultType, RefType, SpcType, units, etc.)
- Add GroupMembersTab for managing group test members
- Enhance reference ranges with refvset and refthold support
- Update API to handle new test fields (ReqQty, Factor, Decimal, TAT, etc.)
- Add database schema documentation (DBML format)
- Remove old test-types-reference.md documentation
- UI improvements: compact design, updated sidebar, modal sizing
- Update DataTable, Modal, SelectDropdown components for compact style
- Enhance patient and visit modals with compact layout
2026-02-18 16:31:20 +07:00

677 lines
21 KiB
Svelte

<script>
import { onMount } from 'svelte';
import { fetchProvinces, fetchCities } from '$lib/api/geography.js';
import { createPatient, updatePatient } from '$lib/api/patients.js';
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
import Modal from '$lib/components/Modal.svelte';
import SelectDropdown from '$lib/components/SelectDropdown.svelte';
import { User, MapPin, Contact } from 'lucide-svelte';
/** @type {{ open: boolean, patient: any | null, onSave: () => void, patientLoading?: boolean }} */
let { open = $bindable(false), patient = null, onSave, patientLoading = false } = $props();
let loading = $state(false);
let saving = $state(false);
let provinces = $state([]);
let cities = $state([]);
let formErrors = $state({});
let activeTab = $state('personal'); // 'personal' or 'location'
let formData = $state({
InternalPID: null,
PatientID: '',
Prefix: '',
NameFirst: '',
NameMiddle: '',
NameLast: '',
NameMaiden: '',
Suffix: '',
Sex: '',
Birthdate: '',
PlaceOfBirth: '',
Citizenship: '',
Street_1: '',
Street_2: '',
Street_3: '',
Province: '',
City: '',
ZIP: '',
Country: '',
Phone: '',
MobilePhone: '',
EmailAddress1: '',
EmailAddress2: '',
Race: '',
MaritalStatus: '',
Religion: '',
Ethnic: '',
});
// Track last loaded province to prevent infinite loops
let lastLoadedProvince = $state('');
// Track which patient we've initialized the form for
let initializedPatientId = $state(null);
const prefixOptions = [
{ value: 'Mr', label: 'Mr' },
{ value: 'Mrs', label: 'Mrs' },
{ value: 'Ms', label: 'Ms' },
{ value: 'Dr', label: 'Dr' },
];
const provinceOptions = $derived(
provinces.map((p) => ({
value: p.AreaCode || p.value || p.code || p.Code || p.id || p.ID || '',
label: p.AreaName || p.label || p.name || p.Name || p.description || p.Description || ''
}))
);
const cityOptions = $derived(
cities.map((c) => ({
value: c.AreaCode || c.value || c.code || c.Code || c.id || c.ID || '',
label: c.AreaName || c.label || c.name || c.Name || c.description || c.Description || ''
}))
);
const isEdit = $derived(!!patient);
onMount(async () => {
await loadProvinces();
});
$effect(() => {
const currentPatientId = patient?.InternalPID || null;
if (open && patient && !patientLoading && provinces.length > 0 && initializedPatientId !== currentPatientId) {
// Edit mode - populate form (only when provinces are loaded and for a new patient)
initializedPatientId = currentPatientId;
const patientProvince = patient.Province || '';
const patientCity = patient.City || '';
formData = {
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 || '',
Province: patientProvince,
City: patientCity, // Set immediately, will be validated once cities load
ZIP: patient.ZIP || '',
Country: patient.Country || '',
Phone: patient.Phone || '',
MobilePhone: patient.MobilePhone || '',
EmailAddress1: patient.EmailAddress1 || '',
EmailAddress2: patient.EmailAddress2 || '',
Race: patient.Race || '',
MaritalStatus: patient.MaritalStatus || '',
Religion: patient.Religion || '',
Ethnic: patient.Ethnic || '',
};
// Load cities for this province
lastLoadedProvince = patientProvince;
if (patientProvince) {
loadCities(patientProvince);
}
} else if (open && !patient && !patientLoading && initializedPatientId !== 'create') {
// Create mode - reset form
initializedPatientId = 'create';
formData = {
InternalPID: null,
PatientID: '',
Prefix: '',
NameFirst: '',
NameMiddle: '',
NameLast: '',
NameMaiden: '',
Suffix: '',
Sex: '',
Birthdate: '',
PlaceOfBirth: '',
Citizenship: '',
Street_1: '',
Street_2: '',
Street_3: '',
Province: '',
City: '',
ZIP: '',
Country: '',
Phone: '',
MobilePhone: '',
EmailAddress1: '',
EmailAddress2: '',
Race: '',
MaritalStatus: '',
Religion: '',
Ethnic: '',
};
cities = [];
lastLoadedProvince = '';
} else if (!open) {
// Reset initialization when modal closes
initializedPatientId = null;
}
});
async function loadProvinces() {
try {
const response = await fetchProvinces();
provinces = Array.isArray(response.data) ? response.data : [];
} catch (err) {
console.error('Failed to load provinces:', err);
}
}
async function loadCities(provinceCode) {
if (!provinceCode) {
cities = [];
return;
}
try {
const response = await fetchCities(provinceCode);
cities = Array.isArray(response.data) ? response.data : [];
} catch (err) {
console.error('Failed to load cities:', err);
cities = [];
}
}
function validateForm() {
const errors = {};
if (!formData.PatientID?.trim()) {
errors.PatientID = 'Patient ID is required';
}
if (!formData.NameFirst?.trim()) {
errors.NameFirst = 'First name is required';
}
if (!formData.Sex) {
errors.Sex = 'Sex is required';
}
if (!formData.Birthdate) {
errors.Birthdate = 'Birthdate is required';
}
formErrors = errors;
return Object.keys(errors).length === 0;
}
async function handleSubmit() {
if (!validateForm()) return;
saving = true;
try {
const payload = {
...formData,
Birthdate: formData.Birthdate ? new Date(formData.Birthdate).toISOString() : undefined,
};
// Remove empty fields
Object.keys(payload).forEach((key) => {
if (payload[key] === '' || payload[key] === null) {
delete payload[key];
}
});
if (isEdit) {
await updatePatient(payload);
toastSuccess('Patient updated successfully');
} else {
await createPatient(payload);
toastSuccess('Patient created successfully');
}
open = false;
onSave?.();
} catch (err) {
toastError(err.message || 'Failed to save patient');
} finally {
saving = false;
}
}
$effect(() => {
const province = formData.Province;
if (province && province !== lastLoadedProvince) {
lastLoadedProvince = province;
// Load cities first, then clear city (prevents empty string display during loading)
loadCities(province).then(() => {
if (initializedPatientId !== null) {
formData.City = '';
}
});
} else if (!province) {
cities = [];
lastLoadedProvince = '';
if (initializedPatientId !== null) {
formData.City = '';
}
}
});
</script>
<Modal
bind:open
title={isEdit ? 'Edit Patient' : 'Add Patient'}
size="lg"
>
{#if patientLoading}
<div class="flex items-center justify-center py-12">
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>
{:else}
<form class="space-y-3" onsubmit={(e) => e.preventDefault()}>
<!-- DaisyUI Tabs -->
<div class="tabs tabs-bordered">
<button
type="button"
class="tab tab-lg {activeTab === 'personal' ? 'tab-active' : ''}"
onclick={() => activeTab = 'personal'}
>
<User class="w-4 h-4 mr-2" />
Personal Info
</button>
<button
type="button"
class="tab tab-lg {activeTab === 'location' ? 'tab-active' : ''}"
onclick={() => activeTab = 'location'}
>
<MapPin class="w-4 h-4 mr-2" />
Location & Contact
</button>
</div>
<!-- Personal Info Tab (Basic + Demographics) -->
{#if activeTab === 'personal'}
<div class="space-y-3">
<!-- Basic Info Section -->
<div>
<h4 class="font-medium text-gray-700 mb-4">Basic Information</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="patientId">
<span class="label-text font-medium">Patient ID</span>
<span class="label-text-alt text-error">*</span>
</label>
<input
id="patientId"
type="text"
class="input input-sm input-bordered w-full"
class:input-error={formErrors.PatientID}
bind:value={formData.PatientID}
placeholder="Enter patient ID"
disabled={isEdit}
/>
{#if formErrors.PatientID}
<span class="text-error text-sm mt-1">{formErrors.PatientID}</span>
{/if}
</div>
<SelectDropdown
label="Prefix"
name="prefix"
bind:value={formData.Prefix}
options={prefixOptions}
placeholder="Select..."
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4">
<div class="form-control">
<label class="label" for="nameFirst">
<span class="label-text font-medium">First Name</span>
<span class="label-text-alt text-error">*</span>
</label>
<input
id="nameFirst"
type="text"
class="input input-sm input-bordered w-full"
class:input-error={formErrors.NameFirst}
bind:value={formData.NameFirst}
placeholder="Enter first name"
/>
{#if formErrors.NameFirst}
<span class="text-error text-sm mt-1">{formErrors.NameFirst}</span>
{/if}
</div>
<div class="form-control">
<label class="label" for="nameMiddle">
<span class="label-text font-medium">Middle Name</span>
</label>
<input
id="nameMiddle"
type="text"
class="input input-sm input-bordered w-full"
bind:value={formData.NameMiddle}
placeholder="Enter middle name"
/>
</div>
<div class="form-control">
<label class="label" for="nameLast">
<span class="label-text font-medium">Last Name</span>
</label>
<input
id="nameLast"
type="text"
class="input input-sm input-bordered w-full"
bind:value={formData.NameLast}
placeholder="Enter last name"
/>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4">
<div class="form-control">
<label class="label" for="nameMaiden">
<span class="label-text font-medium">Maiden Name</span>
</label>
<input
id="nameMaiden"
type="text"
class="input input-sm input-bordered w-full"
bind:value={formData.NameMaiden}
placeholder="Enter maiden name"
/>
</div>
<div class="form-control">
<label class="label" for="suffix">
<span class="label-text font-medium">Suffix</span>
</label>
<input
id="suffix"
type="text"
class="input input-sm input-bordered w-full"
bind:value={formData.Suffix}
placeholder="Enter suffix (e.g., Jr, Sr, III)"
/>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4">
<SelectDropdown
label="Sex"
name="sex"
bind:value={formData.Sex}
valueSetKey="sex"
placeholder="Select sex..."
required={true}
error={formErrors.Sex}
/>
<div class="form-control">
<label class="label" for="birthdate">
<span class="label-text font-medium">Birthdate</span>
<span class="label-text-alt text-error">*</span>
</label>
<input
id="birthdate"
type="date"
class="input input-sm input-bordered w-full"
class:input-error={formErrors.Birthdate}
bind:value={formData.Birthdate}
/>
{#if formErrors.Birthdate}
<span class="text-error text-sm mt-1">{formErrors.Birthdate}</span>
{/if}
</div>
<div class="form-control">
<label class="label" for="placeOfBirth">
<span class="label-text font-medium">Place of Birth</span>
</label>
<input
id="placeOfBirth"
type="text"
class="input input-sm input-bordered w-full"
bind:value={formData.PlaceOfBirth}
placeholder="Enter place of birth"
/>
</div>
</div>
</div>
<!-- Demographics Section -->
<div class="border-t border-base-200 pt-6">
<h4 class="font-medium text-gray-700 mb-4">Demographics</h4>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<SelectDropdown
label="Race"
name="race"
bind:value={formData.Race}
valueSetKey="race"
placeholder="Select race..."
/>
<SelectDropdown
label="Marital Status"
name="maritalStatus"
bind:value={formData.MaritalStatus}
valueSetKey="marital_status"
placeholder="Select marital status..."
/>
<SelectDropdown
label="Religion"
name="religion"
bind:value={formData.Religion}
valueSetKey="religion"
placeholder="Select religion..."
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4">
<SelectDropdown
label="Ethnicity"
name="ethnic"
bind:value={formData.Ethnic}
valueSetKey="ethnic"
placeholder="Select ethnicity..."
/>
<div class="form-control">
<label class="label" for="citizenship">
<span class="label-text font-medium">Citizenship</span>
</label>
<input
id="citizenship"
type="text"
class="input input-sm input-bordered w-full"
bind:value={formData.Citizenship}
placeholder="Enter citizenship"
/>
</div>
</div>
</div>
</div>
{/if}
<!-- Location & Contact Tab -->
{#if activeTab === 'location'}
<div class="space-y-3">
<!-- Address Section -->
<div>
<h4 class="font-medium text-gray-700 mb-4">Address Information</h4>
<div class="form-control mb-4">
<label class="label" for="street1">
<span class="label-text font-medium">Street Address</span>
</label>
<input
id="street1"
type="text"
class="input input-sm input-bordered w-full"
bind:value={formData.Street_1}
placeholder="Enter street address"
/>
</div>
<div class="form-control mb-4">
<label class="label" for="street2">
<span class="label-text font-medium">Street Address Line 2</span>
</label>
<input
id="street2"
type="text"
class="input input-sm input-bordered w-full"
bind:value={formData.Street_2}
placeholder="Enter street address line 2"
/>
</div>
<div class="form-control mb-4">
<label class="label" for="street3">
<span class="label-text font-medium">Street Address Line 3</span>
</label>
<input
id="street3"
type="text"
class="input input-sm input-bordered w-full"
bind:value={formData.Street_3}
placeholder="Enter street address line 3"
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<SelectDropdown
label="Province"
name="province"
bind:value={formData.Province}
options={provinceOptions}
placeholder="Select province..."
/>
<SelectDropdown
label="City"
name="city"
bind:value={formData.City}
options={cityOptions}
placeholder={formData.Province ? "Select city..." : "Select province first"}
disabled={!formData.Province}
/>
<div class="form-control">
<label class="label" for="zip">
<span class="label-text font-medium">ZIP Code</span>
</label>
<input
id="zip"
type="text"
class="input input-sm input-bordered w-full"
bind:value={formData.ZIP}
placeholder="Enter ZIP code"
/>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4">
<SelectDropdown
label="Country"
name="country"
bind:value={formData.Country}
valueSetKey="country"
placeholder="Select country..."
/>
</div>
</div>
<!-- Contact Section -->
<div class="border-t border-base-200 pt-6">
<h4 class="font-medium text-gray-700 mb-4">Contact Information</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="phone">
<span class="label-text font-medium">Phone</span>
</label>
<input
id="phone"
type="tel"
class="input input-sm input-bordered w-full"
bind:value={formData.Phone}
placeholder="Enter phone number"
/>
</div>
<div class="form-control">
<label class="label" for="mobilePhone">
<span class="label-text font-medium">Mobile Phone</span>
</label>
<input
id="mobilePhone"
type="tel"
class="input input-sm input-bordered w-full"
bind:value={formData.MobilePhone}
placeholder="Enter mobile number"
/>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
<div class="form-control">
<label class="label" for="email1">
<span class="label-text font-medium">Email Address</span>
</label>
<input
id="email1"
type="email"
class="input input-sm input-bordered w-full"
bind:value={formData.EmailAddress1}
placeholder="Enter email address"
/>
</div>
<div class="form-control">
<label class="label" for="email2">
<span class="label-text font-medium">Secondary Email</span>
</label>
<input
id="email2"
type="email"
class="input input-sm input-bordered w-full"
bind:value={formData.EmailAddress2}
placeholder="Enter secondary email"
/>
</div>
</div>
</div>
</div>
{/if}
</form>
{/if}
{#snippet footer()}
{#if patientLoading}
<div class="flex justify-end gap-2">
<button class="btn btn-ghost" disabled type="button">Cancel</button>
<button class="btn btn-primary" disabled type="button">Loading...</button>
</div>
{:else}
<div class="flex justify-end gap-2">
<button class="btn btn-ghost" onclick={() => (open = false)} type="button">
Cancel
</button>
<button
class="btn btn-primary"
onclick={handleSubmit}
disabled={saving}
type="button"
>
{#if saving}
<span class="loading loading-spinner loading-sm mr-2"></span>
{/if}
{saving ? 'Saving...' : 'Save'}
</button>
</div>
{/if}
{/snippet}
</Modal>