feat: Refactor patients page into modular components with separate modals

This commit is contained in:
mahdahar 2026-02-13 07:02:54 +07:00
parent 382b05d98e
commit d5864d40ec
6 changed files with 1318 additions and 1403 deletions

View File

@ -819,35 +819,83 @@ components:
format: date-time
# ValueSets
ValueSetLibItem:
type: object
description: Library/system value set item from JSON files
properties:
value:
type: string
description: The value/key code
label:
type: string
description: The display label
ValueSetDef:
type: object
description: User-defined value set definition (from database)
properties:
id:
VSetID:
type: integer
VSetCode:
description: Primary key
SiteID:
type: integer
description: Site reference
VSName:
type: string
VSetName:
description: Value set name
VSDesc:
type: string
Description:
description: Value set description
CreateDate:
type: string
Category:
format: date-time
description: Creation timestamp
EndDate:
type: string
format: date-time
nullable: true
description: Soft delete timestamp
ItemCount:
type: integer
description: Number of items in this value set
ValueSetItem:
type: object
description: User-defined value set item (from database)
properties:
id:
VID:
type: integer
description: Primary key
SiteID:
type: integer
description: Site reference
VSetID:
type: integer
description: Reference to value set definition
VOrder:
type: integer
description: Display order
VValue:
type: string
VLabel:
description: The value code
VDesc:
type: string
VSeq:
type: integer
IsActive:
type: boolean
description: The display description/label
VCategory:
type: string
description: Category code
CreateDate:
type: string
format: date-time
description: Creation timestamp
EndDate:
type: string
format: date-time
nullable: true
description: Soft delete timestamp
VSName:
type: string
description: Value set name (from joined definition)
# Master Data
Location:
@ -1391,10 +1439,21 @@ paths:
properties:
status:
type: string
message:
type: string
data:
type: array
items:
$ref: '#/components/schemas/PatientVisit'
total:
type: integer
description: Total number of records
page:
type: integer
description: Current page number
per_page:
type: integer
description: Number of records per page
post:
tags: [Patient Visits]
@ -2863,7 +2922,7 @@ paths:
get:
tags: [ValueSets]
summary: List lib value sets
description: List all library/system value sets from JSON files
description: List all library/system value sets from JSON files with item counts. Returns an object where keys are value set names and values are item counts.
security:
- bearerAuth: []
parameters:
@ -2871,7 +2930,7 @@ paths:
in: query
schema:
type: string
description: Optional search term to filter value sets
description: Optional search term to filter value set names
responses:
'200':
description: List of lib value sets with item counts
@ -2882,17 +2941,72 @@ paths:
properties:
status:
type: string
example: success
data:
type: object
additionalProperties:
type: integer
description: Number of items in each value set
example:
sex: 3
marital_status: 6
order_status: 6
/api/valueset/{key}:
get:
tags: [ValueSets]
summary: Get lib value set by key
description: Get a specific library/system value set from JSON files
description: |
Get a specific library/system value set from JSON files.
**Available value set keys:**
- `activity_result` - Activity Result
- `additive` - Additive
- `adt_event` - ADT Event
- `area_class` - Area Class
- `body_site` - Body Site
- `collection_method` - Collection Method
- `container_cap_color` - Container Cap Color
- `container_class` - Container Class
- `container_size` - Container Size
- `country` - Country
- `death_indicator` - Death Indicator
- `did_type` - DID Type
- `enable_disable` - Enable/Disable
- `entity_type` - Entity Type
- `ethnic` - Ethnic
- `fasting_status` - Fasting Status
- `formula_language` - Formula Language
- `generate_by` - Generate By
- `identifier_type` - Identifier Type
- `location_type` - Location Type
- `marital_status` - Marital Status
- `math_sign` - Math Sign
- `numeric_ref_type` - Numeric Reference Type
- `operation` - Operation (CRUD)
- `order_priority` - Order Priority
- `order_status` - Order Status
- `race` - Race (Ethnicity)
- `range_type` - Range Type
- `reference_type` - Reference Type
- `religion` - Religion
- `requested_entity` - Requested Entity
- `result_type` - Result Type
- `result_unit` - Result Unit
- `sex` - Sex
- `site_class` - Site Class
- `site_type` - Site Type
- `specimen_activity` - Specimen Activity
- `specimen_condition` - Specimen Condition
- `specimen_role` - Specimen Role
- `specimen_status` - Specimen Status
- `specimen_type` - Specimen Type
- `test_activity` - Test Activity
- `test_type` - Test Type
- `text_ref_type` - Text Reference Type
- `unit` - Unit
- `v_category` - VCategory
- `ws_type` - Workstation Type
security:
- bearerAuth: []
parameters:
@ -2901,7 +3015,8 @@ paths:
required: true
schema:
type: string
description: Value set key (e.g., marital_status, sex)
enum: [activity_result, additive, adt_event, area_class, body_site, collection_method, container_cap_color, container_class, container_size, country, death_indicator, did_type, enable_disable, entity_type, ethnic, fasting_status, formula_language, generate_by, identifier_type, location_type, marital_status, math_sign, numeric_ref_type, operation, order_priority, order_status, race, range_type, reference_type, religion, requested_entity, result_type, result_unit, sex, site_class, site_type, specimen_activity, specimen_condition, specimen_role, specimen_status, specimen_type, test_activity, test_type, text_ref_type, unit, v_category, ws_type]
description: Value set key name
responses:
'200':
description: Lib value set details
@ -2915,23 +3030,29 @@ paths:
data:
type: array
items:
type: object
properties:
value:
type: string
label:
type: string
$ref: '#/components/schemas/ValueSetLibItem'
/api/valueset/refresh:
post:
tags: [ValueSets]
summary: Refresh lib ValueSet cache
description: Clear and reload the library/system ValueSet cache from JSON files
description: Clear and reload the library/system ValueSet cache from JSON files. Call this after modifying JSON files in app/Libraries/Data/.
security:
- bearerAuth: []
responses:
'200':
description: Lib ValueSet cache refreshed
content:
application/json:
schema:
type: object
properties:
status:
type: string
example: success
message:
type: string
example: Cache cleared
/api/valueset/user/items:
get:
@ -2946,6 +3067,16 @@ paths:
schema:
type: integer
description: Filter by ValueSet ID
- name: search
in: query
schema:
type: string
description: Search term to filter by VValue, VDesc, or VSName
- name: param
in: query
schema:
type: string
description: Alternative search parameter (alias for search)
responses:
'200':
description: List of user value set items
@ -2954,6 +3085,8 @@ paths:
schema:
type: object
properties:
status:
type: string
data:
type: array
items:
@ -2970,10 +3103,39 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/ValueSetItem'
type: object
required:
- VSetID
properties:
SiteID:
type: integer
description: Site reference (default 1)
VSetID:
type: integer
description: Reference to value set definition (required)
VOrder:
type: integer
description: Display order (default 0)
VValue:
type: string
description: The value code
VDesc:
type: string
description: The display description/label
responses:
'201':
description: User value set item created
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
$ref: '#/components/schemas/ValueSetItem'
/api/valueset/user/items/{id}:
get:
@ -2991,6 +3153,15 @@ paths:
responses:
'200':
description: User value set item details
content:
application/json:
schema:
type: object
properties:
status:
type: string
data:
$ref: '#/components/schemas/ValueSetItem'
put:
tags: [ValueSets]
@ -3009,10 +3180,37 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/ValueSetItem'
type: object
properties:
SiteID:
type: integer
description: Site reference
VSetID:
type: integer
description: Reference to value set definition
VOrder:
type: integer
description: Display order
VValue:
type: string
description: The value code
VDesc:
type: string
description: The display description/label
responses:
'200':
description: User value set item updated
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
$ref: '#/components/schemas/ValueSetItem'
delete:
tags: [ValueSets]
@ -3029,6 +3227,15 @@ paths:
responses:
'200':
description: User value set item deleted
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
/api/valueset/user/def:
get:
@ -3037,9 +3244,47 @@ paths:
description: List value set definitions from database (user-defined)
security:
- bearerAuth: []
parameters:
- name: search
in: query
schema:
type: string
description: Optional search term to filter definitions
- name: page
in: query
schema:
type: integer
default: 1
description: Page number for pagination
- name: limit
in: query
schema:
type: integer
default: 100
description: Number of items per page
responses:
'200':
description: List of user value set definitions
content:
application/json:
schema:
type: object
properties:
status:
type: string
data:
type: array
items:
$ref: '#/components/schemas/ValueSetDef'
meta:
type: object
properties:
total:
type: integer
page:
type: integer
limit:
type: integer
post:
tags: [ValueSets]
@ -3052,10 +3297,31 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/ValueSetDef'
type: object
properties:
SiteID:
type: integer
description: Site reference (default 1)
VSName:
type: string
description: Value set name
VSDesc:
type: string
description: Value set description
responses:
'201':
description: User value set definition created
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
$ref: '#/components/schemas/ValueSetDef'
/api/valueset/user/def/{id}:
get:
@ -3073,6 +3339,15 @@ paths:
responses:
'200':
description: User value set definition details
content:
application/json:
schema:
type: object
properties:
status:
type: string
data:
$ref: '#/components/schemas/ValueSetDef'
put:
tags: [ValueSets]
@ -3091,10 +3366,31 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/ValueSetDef'
type: object
properties:
SiteID:
type: integer
description: Site reference
VSName:
type: string
description: Value set name
VSDesc:
type: string
description: Value set description
responses:
'200':
description: User value set definition updated
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
$ref: '#/components/schemas/ValueSetDef'
delete:
tags: [ValueSets]
@ -3111,6 +3407,15 @@ paths:
responses:
'200':
description: User value set definition deleted
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
# ========================================
# Master Data Routes

View File

@ -63,6 +63,11 @@
dropdownOptions = options;
}
});
// Check if current value exists in options
let hasValidValue = $derived(
!value || dropdownOptions.some(opt => opt.value === value)
);
</script>
<div class="form-control w-full {className}">
@ -87,7 +92,11 @@
>
<option value="">{placeholder}</option>
{#each dropdownOptions as option}
{#if value && !hasValidValue}
<option value={value} selected>{value}</option>
{/if}
{#each dropdownOptions as option (option.value)}
<option value={option.value}>{option.label}</option>
{/each}
</select>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,127 @@
<script>
import { User, MapPin, Activity, FileText } from 'lucide-svelte';
import Modal from '$lib/components/Modal.svelte';
/** @type {{ open: boolean, patient: any | null }} */
let { open = $bindable(false), patient = null } = $props();
</script>
<Modal bind:open title="Patient Details" size="xl">
{#if patient}
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="card bg-base-100 shadow border border-base-200">
<div class="card-body">
<h3 class="card-title text-lg flex items-center gap-2">
<User class="w-5 h-5 text-primary" />
Basic Information
</h3>
<div class="space-y-3 mt-4">
<div class="flex justify-between py-2 border-b border-base-200">
<span class="text-gray-500">Patient ID</span>
<span class="font-medium">{patient.PatientID}</span>
</div>
<div class="flex justify-between py-2 border-b border-base-200">
<span class="text-gray-500">Full Name</span>
<span class="font-medium">
{[patient.Prefix, patient.NameFirst, patient.NameMiddle, patient.NameLast, patient.Suffix]
.filter(Boolean)
.join(' ')}
</span>
</div>
<div class="flex justify-between py-2 border-b border-base-200">
<span class="text-gray-500">Sex</span>
<span class="font-medium">{patient.Sex === '1' ? 'Female' : patient.Sex === '2' ? 'Male' : '-'}</span>
</div>
<div class="flex justify-between py-2 border-b border-base-200">
<span class="text-gray-500">Birthdate</span>
<span class="font-medium">{patient.Birthdate ? new Date(patient.Birthdate).toLocaleDateString() : '-'}</span>
</div>
<div class="flex justify-between py-2">
<span class="text-gray-500">Citizenship</span>
<span class="font-medium">{patient.Citizenship || '-'}</span>
</div>
</div>
</div>
</div>
<div class="card bg-base-100 shadow border border-base-200">
<div class="card-body">
<h3 class="card-title text-lg flex items-center gap-2">
<MapPin class="w-5 h-5 text-primary" />
Address
</h3>
<div class="space-y-3 mt-4">
<div class="flex justify-between py-2 border-b border-base-200">
<span class="text-gray-500">Street</span>
<span class="font-medium text-right">
{[patient.Street_1, patient.Street_2, patient.Street_3].filter(Boolean).join(', ') || '-'}
</span>
</div>
<div class="flex justify-between py-2 border-b border-base-200">
<span class="text-gray-500">City</span>
<span class="font-medium">{patient.City || '-'}</span>
</div>
<div class="flex justify-between py-2 border-b border-base-200">
<span class="text-gray-500">Province</span>
<span class="font-medium">{patient.Province || '-'}</span>
</div>
<div class="flex justify-between py-2">
<span class="text-gray-500">ZIP</span>
<span class="font-medium">{patient.ZIP || '-'}</span>
</div>
</div>
</div>
</div>
<div class="card bg-base-100 shadow border border-base-200">
<div class="card-body">
<h3 class="card-title text-lg flex items-center gap-2">
<Activity class="w-5 h-5 text-primary" />
Contact
</h3>
<div class="space-y-3 mt-4">
<div class="flex justify-between py-2 border-b border-base-200">
<span class="text-gray-500">Phone</span>
<span class="font-medium">{patient.Phone || '-'}</span>
</div>
<div class="flex justify-between py-2 border-b border-base-200">
<span class="text-gray-500">Mobile</span>
<span class="font-medium">{patient.MobilePhone || '-'}</span>
</div>
<div class="flex justify-between py-2">
<span class="text-gray-500">Email</span>
<span class="font-medium">{patient.EmailAddress1 || '-'}</span>
</div>
</div>
</div>
</div>
<div class="card bg-base-100 shadow border border-base-200">
<div class="card-body">
<h3 class="card-title text-lg flex items-center gap-2">
<FileText class="w-5 h-5 text-primary" />
Additional
</h3>
<div class="space-y-3 mt-4">
<div class="flex justify-between py-2 border-b border-base-200">
<span class="text-gray-500">Marital Status</span>
<span class="font-medium">{patient.MaritalStatus || '-'}</span>
</div>
<div class="flex justify-between py-2 border-b border-base-200">
<span class="text-gray-500">Religion</span>
<span class="font-medium">{patient.Religion || '-'}</span>
</div>
<div class="flex justify-between py-2">
<span class="text-gray-500">Race</span>
<span class="font-medium">{patient.Race || '-'}</span>
</div>
</div>
</div>
</div>
</div>
{/if}
{#snippet footer()}
<button class="btn btn-ghost" onclick={() => (open = false)} type="button">Close</button>
{/snippet}
</Modal>

View File

@ -0,0 +1,637 @@
<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';
/** @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 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;
// Clear city when province changes (only after form is initialized)
if (initializedPatientId !== null) {
formData.City = '';
}
loadCities(province);
} 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-6" onsubmit={(e) => e.preventDefault()}>
<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-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">
<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-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-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-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">
<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-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-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">
<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-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-bordered w-full"
bind:value={formData.PlaceOfBirth}
placeholder="Enter place of birth"
/>
</div>
</div>
<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 mb-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">
<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-bordered w-full"
bind:value={formData.Citizenship}
placeholder="Enter citizenship"
/>
</div>
</div>
</div>
<div class="border-t border-base-200 pt-6">
<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-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-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-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-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>
<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-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-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-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-bordered w-full"
bind:value={formData.EmailAddress2}
placeholder="Enter secondary email"
/>
</div>
</div>
</div>
</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>

View File

@ -0,0 +1,153 @@
<script>
import { onMount } from 'svelte';
import { fetchVisitsByPatient } from '$lib/api/visits.js';
import { error as toastError } from '$lib/utils/toast.js';
import Modal from '$lib/components/Modal.svelte';
import { Calendar, Clock, MapPin, FileText, Plus } from 'lucide-svelte';
/** @type {{ open: boolean, patient: any | null }} */
let { open = $bindable(false), patient = null } = $props();
let visits = $state([]);
let loading = $state(false);
onMount(() => {
if (patient && open) {
loadVisits();
}
});
$effect(() => {
if (open && patient) {
loadVisits();
}
});
async function loadVisits() {
if (!patient?.InternalPID) return;
loading = true;
try {
const response = await fetchVisitsByPatient(patient.InternalPID);
visits = Array.isArray(response.data) ? response.data : [];
} catch (err) {
toastError(err.message || 'Failed to load visits');
visits = [];
} finally {
loading = false;
}
}
function formatDate(dateStr) {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleDateString();
}
function formatDateTime(dateStr) {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleString();
}
</script>
<Modal bind:open title="Patient Visits" size="xl">
{#if patient}
<div class="mb-4 p-4 bg-base-200 rounded-lg">
<div class="flex items-center gap-4">
<div>
<h3 class="font-bold text-lg">
{[patient.Prefix, patient.NameFirst, patient.NameMiddle, patient.NameLast].filter(Boolean).join(' ')}
</h3>
<p class="text-sm text-gray-600">Patient ID: {patient.PatientID}</p>
</div>
</div>
</div>
{#if loading}
<div class="flex items-center justify-center py-12">
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>
{:else if visits.length === 0}
<div class="text-center py-12 text-gray-500">
<Calendar class="w-16 h-16 mx-auto mb-4 opacity-50" />
<p class="text-lg">No visits found</p>
<p class="text-sm">This patient has no visit records.</p>
</div>
{:else}
<div class="space-y-4">
{#each visits as visit}
<div class="card bg-base-100 shadow border border-base-200 hover:shadow-md transition-shadow">
<div class="card-body p-4">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2 mb-2">
<Calendar class="w-4 h-4 text-primary" />
<span class="font-semibold">{formatDate(visit.AdmissionDateTime)}</span>
{#if visit.VisitStatus}
<span class="badge badge-sm {visit.VisitStatus === 'Active' ? 'badge-success' : 'badge-ghost'}">
{visit.VisitStatus}
</span>
{/if}
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
{#if visit.AdmissionType}
<div>
<span class="text-gray-500">Type:</span>
<span class="ml-1">{visit.AdmissionType}</span>
</div>
{/if}
{#if visit.Location}
<div class="flex items-center gap-1">
<MapPin class="w-3 h-3 text-gray-400" />
<span class="text-gray-500">Location:</span>
<span class="ml-1">{visit.Location}</span>
</div>
{/if}
{#if visit.DoctorName}
<div>
<span class="text-gray-500">Doctor:</span>
<span class="ml-1">{visit.DoctorName}</span>
</div>
{/if}
{#if visit.AdmissionDateTime}
<div class="flex items-center gap-1">
<Clock class="w-3 h-3 text-gray-400" />
<span class="text-gray-500">Time:</span>
<span class="ml-1">{formatDateTime(visit.AdmissionDateTime)}</span>
</div>
{/if}
</div>
{#if visit.Diagnosis}
<div class="mt-3 pt-3 border-t border-base-200">
<div class="flex items-start gap-2">
<FileText class="w-4 h-4 text-gray-400 mt-0.5" />
<div>
<span class="text-gray-500 text-sm">Diagnosis:</span>
<p class="text-sm mt-1">{visit.Diagnosis}</p>
</div>
</div>
</div>
{/if}
</div>
</div>
</div>
</div>
{/each}
</div>
{/if}
{/if}
{#snippet footer()}
<div class="flex justify-between items-center w-full">
<button class="btn btn-primary btn-sm" onclick={() => {}} disabled>
<Plus class="w-4 h-4 mr-1" />
New Visit
</button>
<button class="btn btn-ghost" onclick={() => (open = false)} type="button">Close</button>
</div>
{/snippet}
</Modal>