- 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
677 lines
21 KiB
Svelte
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>
|