various fix:

ubah initialform ke flat, tadinya nested (untuk patidt dan custodian)
add payload builder saat save patient
fix custodian tidak jalan di edit page
fix dictionary usedictionaryform tidak jalan di page selain test
hilangkan refnumtype di refnum
fix bug di refnum saat insert data ke tempnum
This commit is contained in:
faiztyanirh 2026-03-10 16:59:44 +07:00
parent 45c8d6969a
commit 0c0bbd6e26
18 changed files with 81 additions and 2259 deletions

View File

@ -25,7 +25,7 @@
// }
// }
export function useDictionaryForm(formState, getActiveFormStates) {
export function useDictionaryForm(formState, getActiveFormStates = () => ({})) {
let hasErrors = $derived.by(() => {
const mainHasError = Object.values(formState.errors).some(v => v !== null);
const activeHasError = Object.values(getActiveFormStates()).some(fs =>

View File

@ -29,7 +29,7 @@ export function useMasterDetail(options = {}) {
const isDirty = $derived(
JSON.stringify(formState.form) !== JSON.stringify(formSnapshot)
);
// $inspect(formSnapshot)
// $inspect(formState.form)
async function select(item) {
mode = "view";

View File

@ -40,17 +40,17 @@ export function usePatientForm(formState, patientSchema) {
}
function validateIdentifier() {
const identifierType = formState.form.PatIdt?.IdentifierType;
const identifierValue = formState.form.PatIdt?.Identifier;
const identifierType = formState.form.PatIdt_IdentifierType;
const identifierValue = formState.form.PatIdt_Identifier;
if (!identifierType || !identifierValue) {
formState.errors['PatIdt.Identifier'] = null;
formState.errors.PatIdt_Identifier = null;
return;
}
const schema = getIdentifierValidation(identifierType);
const result = schema.safeParse(identifierValue);
formState.errors['PatIdt.Identifier'] = result.success ? null : result.error.issues[0].message;
formState.errors.PatIdt_Identifier = result.success ? null : result.error.issues[0].message;
}
function getIdentifierValidation(identifierType) {

View File

@ -726,18 +726,19 @@ export const refNumFormFields = [
{
type: 'row',
columns: [
{
key: 'NumRefType',
label: 'Reference Type',
required: false,
type: 'text'
},
// {
// key: 'NumRefType',
// label: 'Reference Type',
// required: false,
// type: 'text'
// },
{
key: 'RangeType',
label: 'Range Type',
required: false,
type: 'select',
optionsEndpoint: `${API.BASE_URL}${API.VALUESET}/range_type`
optionsEndpoint: `${API.BASE_URL}${API.VALUESET}/range_type`,
fullWidth: false
}
]
},

View File

@ -1,128 +0,0 @@
<script>
import * as Select from '$lib/components/ui/select/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { testCalSchema } from '$lib/components/dictionary/test/config/test-form-config';
import { API } from '$lib/config/api';
import { Textarea } from "$lib/components/ui/textarea/index.js";
import { Label } from "$lib/components/ui/label/index.js";
import DictionaryFormRenderer from '$lib/components/reusable/form/dictionary-form-renderer.svelte';
let props = $props();
const operators = ['+', '-', '*', '/', '^', '(', ')'];
const logicalop = ['IF', 'THEN', 'ELSE', 'END', 'AND', 'OR', 'NOT', 'MIN', 'MAX'];
const comparisonop = ['=', '!=', '<', '>', '<=', '>='];
const customval = ['abc', '123'];
let expression = $state('');
let cursorPosition = $state(0);
function unselectAll(key) {
props.calFormState.form[key] = [];
props.calFormState.validateField?.(key, [], false);
}
function getErrorStatus(formulaCode = '') {
const selected = props.calFormState.form.FormulaInput;
if (!Array.isArray(selected)) return [];
return selected.map((item) => ({
value: item.value,
done: new RegExp(`\\b${item.value}\\b`, 'i').test(formulaCode)
}));
}
function addToExpression(text) {
const before = expression.slice(0, cursorPosition);
const after = expression.slice(cursorPosition);
expression = before + text + after;
cursorPosition += text.length;
}
function addOperator(op) {
addToExpression(op);
props.calFormState.form.FormulaCode = expression;
props.calFormState.validateField?.('FormulaCode', expression, false);
}
function addValue(val) {
addToExpression(val);
props.calFormState.form.FormulaCode = expression;
props.calFormState.validateField?.('FormulaCode', expression, false);
}
// function handleInput(e) {
// expression = e.target.value;
// cursorPosition = e.target.selectionStart;
// formState.form.FormulaCode = expression;
// formState.validateField?.('FormulaCode', expression, false);
// }
// function handleClick(e) {
// cursorPosition = e.target.selectionStart;
// }
function handleContainerClick(e) {
const rect = e.currentTarget.getBoundingClientRect();
const text = expression;
const charWidth = 8.5;
const padding = 12;
const clickX = e.clientX - rect.left - padding;
let newPos = Math.floor(clickX / charWidth);
newPos = Math.max(0, Math.min(newPos, text.length));
cursorPosition = newPos;
}
function moveCursorLeft() {
if (cursorPosition > 0) {
cursorPosition -= 1;
}
}
function moveCursorRight() {
if (cursorPosition < expression.length) {
cursorPosition += 1;
}
}
function deleteChar() {
if (cursorPosition > 0) {
const before = expression.slice(0, cursorPosition - 1);
const after = expression.slice(cursorPosition);
expression = before + after;
cursorPosition -= 1;
props.calFormState.form.FormulaCode = expression;
props.calFormState.validateField?.('FormulaCode', expression, false);
}
}
function clearExpression() {
expression = '';
cursorPosition = 0;
props.calFormState.form.FormulaCode = expression;
props.calFormState.validateField?.('FormulaCode', expression, false);
}
</script>
<div class="flex flex-col gap-4 w-full">
<DictionaryFormRenderer
formState={props.calFormState}
formFields={props.testCalFormFields}
customval={customval}
operators={operators}
logicalop={logicalop}
comparisonop={comparisonop}
expression={expression}
cursorPosition={cursorPosition}
onUnselectAll={unselectAll}
onGetErrorStatus={getErrorStatus}
onAddOperator={addOperator}
onAddValue={addValue}
onHandleContainerClick={handleContainerClick}
onMoveCursorLeft={moveCursorLeft}
onMoveCursorRight={moveCursorRight}
onDeleteChar={deleteChar}
onClearExpression={clearExpression}
/>
</div>

View File

@ -54,7 +54,14 @@
}
function resetForm() {
const currentRefType = props.refNumState.form.NumRefType;
props.refNumState.reset?.();
if (currentRefType) {
props.refNumState.form.NumRefType = currentRefType;
}
joinFields = {
AgeStart: { DD: '', MM: '', YY: '' },
AgeEnd: { DD: '', MM: '', YY: '' }

View File

@ -1,62 +0,0 @@
<script>
import { useForm } from "$lib/components/composable/use-form.svelte";
import { admissionSchema, admissionInitialForm, admissionDefaultErrors, admissionFormFields, getAdmissionFormActions, buildPayload } from "$lib/components/patient/admission/config/admission-form-config";
import { createAdmission } from "$lib/components/patient/admission/api/patient-admission-api";
import { usePatientForm } from "$lib/components/composable/use-patient-form.svelte";
import FormPageContainer from "$lib/components/patient/reusable/form-page-container.svelte";
import PatientFormRenderer from "$lib/components/patient/reusable/patient-form-renderer.svelte";
let props = $props();
let formState = useForm({
schema: admissionSchema,
initialForm: admissionInitialForm,
defaultErrors: admissionDefaultErrors,
mode: 'create',
modeOpt: 'default',
saveEndpoint: createAdmission,
editEndpoint: null,
});
const helpers = usePatientForm(formState, admissionSchema);
const handlers = {
clearForm: () => {
formState.reset();
}
};
const actions = getAdmissionFormActions(handlers);
async function handleSave() {
const payload = buildPayload(formState.form);
console.log(payload);
}
const primaryAction = $derived({
label: 'Save',
onClick: handleSave,
disabled: helpers.hasErrors || formState.isSaving.current,
loading: formState.isSaving.current
});
const secondaryActions = [];
$effect(() => {
if (props.masterDetail.form?.PatientID) {
formState.setForm({
...formState.form,
...props.masterDetail.form
});
}
});
</script>
<FormPageContainer title="Create Admission" {primaryAction} {secondaryActions} {actions}>
<PatientFormRenderer
{formState}
formFields={admissionFormFields}
mode="create"
/>
</FormPageContainer>

View File

@ -52,7 +52,7 @@ export const detailSections = [
isGroup: true,
class: "grid grid-cols-2",
fields: [
{ key: "City", label: "City" },
{ key: "CityLabel", label: "City" },
{ key: "ZIP", label: "ZIP" },
],
},
@ -62,7 +62,7 @@ export const detailSections = [
isGroup: true,
class: "grid grid-cols-2",
fields: [
{ key: "Province", label: "Province" },
{ key: "ProvinceLabel", label: "Province" },
{ key: "CountryLabel", label: "Country" },
],
},

View File

@ -20,10 +20,8 @@ export const patientSchema = z.object({
export const patientInitialForm = {
PatientID: "",
AlternatePID: "",
PatIdt: {
IdentifierType: "",
Identifier: ""
},
PatIdt_IdentifierType: "",
PatIdt_Identifier: "",
NameFirst: "",
Prefix: "",
Sex: "",
@ -32,10 +30,8 @@ export const patientInitialForm = {
NameMaiden: "",
MaritalStatus: "",
NameLast: "",
Custodian: {
InternalPID: "",
PatientID: ""
},
Custodian_InternalPID: "",
Custodian_PatientID: "",
Ethnic: "",
Suffix: "",
PlaceOfBirth: "",
@ -69,7 +65,7 @@ export const patientDefaultErrors = {
Birthdate: "Required",
EmailAddress1: null,
EmailAddress2: null,
'PatIdt.Identifier': null,
PatIdt_Identifier: null,
Phone: null,
MobilePhone: null,
};
@ -95,7 +91,7 @@ export const patientFormFields = [
type: "text"
},
{
key: "PatIdt.Identifier",
key: "PatIdt_Identifier",
label: "Identifier",
required: false,
type: "identity",
@ -310,4 +306,26 @@ export function getPatientFormActions(handlers) {
onClick: handlers.clearForm,
},
];
}
export function buildPatientPayload(form) {
const {
PatIdt_IdentifierType,
PatIdt_Identifier,
Custodian_InternalPID,
Custodian_PatientID,
...rest
} = form;
return {
...rest,
PatIdt: {
IdentifierType: PatIdt_IdentifierType,
Identifier: PatIdt_Identifier
},
Custodian: {
InternalPID: Custodian_InternalPID,
PatientID: Custodian_PatientID
}
};
}

View File

@ -28,17 +28,18 @@
isOpen = open;
if (open) {
if (props.formState.form.Custodian) {
if (props.formState.form.Custodian_PatientID) {
selectedPatient = {
InternalPID: props.formState.form.Custodian.InternalPID || null,
PatientID: props.formState.form.Custodian.PatientID || null,
InternalPID: props.formState.form.Custodian_InternalPID || null,
PatientID: props.formState.form.Custodian_PatientID || null,
};
}
}
}
function confirmCustodian() {
props.formState.form.Custodian = { ...selectedPatient };
props.formState.form.Custodian_InternalPID = selectedPatient.InternalPID
props.formState.form.Custodian_PatientID = selectedPatient.PatientID
selectedPatient = { InternalPID: null, PatientID: null };
isOpen = false;
@ -46,7 +47,12 @@
function populateCustodian() {
custodianModalMode.mode = mode;
setSelectedPatient(editPatientForm.Custodian);
if (props.formState.form.Custodian_PatientID) {
selectedPatient = {
InternalPID: props.formState.form.Custodian_InternalPID,
PatientID: props.formState.form.Custodian_PatientID,
}
}
}
function togglePatientSelection(patient) {

View File

@ -1,403 +0,0 @@
<script>
import TopbarWrapper from "$lib/components/topbar/topbar-wrapper.svelte";
import { Button } from "$lib/components/ui/button/index.js";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
import { Input } from "$lib/components/ui/input/index.js";
import { Label } from "$lib/components/ui/label/index.js";
import ChevronUpIcon from "@lucide/svelte/icons/chevron-up";
import { Spinner } from "$lib/components/ui/spinner/index.js";
import { useForm } from "$lib/components/composable/use-form.svelte";
import { patientSchema, patientInitialForm, patientDefaultErrors, patientFormFields, getPatientFormActions } from "../config/patient-form-config";
import { createPatient } from "../api/patient-list-api";
import * as Select from "$lib/components/ui/select/index.js";
import ReusableCalendar from "$lib/components/reusable/reusable-calendar.svelte";
import ReusableCalendarTimepicker from "$lib/components/reusable/reusable-calendar-timepicker.svelte";
import CustodianModal from "../modal/custodian-modal.svelte";
import LinktoModal from "../modal/linkto-modal.svelte";
import ReusableUpload from "$lib/components/reusable/reusable-upload.svelte";
import { API } from "$lib/config/api";
import { z } from "zod";
import FormPageContainer from "../reusable/form-page-container.svelte";
import { usePatientForm } from "$lib/components/composable/use-patient-form.svelte";
import PatientFormRenderer from "../reusable/patient-form-renderer.svelte";
let props = $props();
// let searchQuery = $state({});
// let uploadErrors = $state({});
// let isChecking = $state({});
const formState = useForm({
schema: patientSchema,
initialForm: patientInitialForm,
defaultErrors: patientDefaultErrors,
mode: 'create',
modeOpt: 'cascade',
saveEndpoint: createPatient,
editEndpoint: null,
});
const helpers = usePatientForm(formState, patientSchema);
const handlers = {
clearForm: () => {
formState.reset();
}
};
const actions = getPatientFormActions(handlers);
let linkToDisplay = $derived(
Array.isArray(formState.form.LinkTo)
? formState.form.LinkTo
.map(p => p.PatientID)
.filter(Boolean)
.join(', ')
: ''
);
async function handleSave() {
const result = await formState.save();
if (result.status === 'success') {
console.log('Patient saved successfully');
props.masterDetail?.exitForm();
} else {
console.error('Failed to save patient');
}
}
// function getFilteredOptions(key) {
// const query = searchQuery[key] || "";
// if (!query) return formState.selectOptions[key] ?? [];
// return (formState.selectOptions[key] ?? []).filter(opt =>
// opt.label.toLowerCase().includes(query.toLowerCase())
// );
// }
// async function validateFieldAsync(field) {
// isChecking[field] = true;
// try {
// const asyncSchema = patientSchema.extend({
// PatientID: patientSchema.shape.PatientID.refine(
// async (value) => {
// if (!value) return false;
// const res = await fetch(`${API.BASE_URL}${API.CHECK}?PatientID=${value}`);
// const { status, data } = await res.json();
// return status === "success" && data === false ? false : true;
// },
// { message: "Patient ID already used" }
// )
// });
// const partial = asyncSchema.pick({ [field]: true });
// const result = await partial.safeParseAsync({ [field]: formState.form[field] });
// formState.errors[field] = result.success ? null : result.error.issues[0].message;
// } catch (err) {
// console.error('Async validation error:', err);
// } finally {
// isChecking[field] = false;
// }
// }
// function validateIdentifier() {
// const identifierType = formState.form.PatIdt.IdentifierType;
// const identifierValue = formState.form.PatIdt.Identifier;
// if (!identifierType || !identifierValue) {
// formState.errors['PatIdt.Identifier'] = null;
// return;
// }
// const schema = getIdentifierValidation(identifierType);
// const result = schema.safeParse(identifierValue);
// formState.errors['PatIdt.Identifier'] = result.success ? null : result.error.issues[0].message;
// }
// function getIdentifierValidation(identifierType) {
// switch (identifierType) {
// case 'KTP':
// return z.string()
// .length(16, "Must be 16 characters")
// .regex(/^$|^[0-9]+$/, "Can only contain numbers");
// case 'PASS':
// return z.string()
// .max(9, "Max 9 chars")
// .regex(/^[A-Z0-9]+$/, "Must be uppercase letters and numbers");
// case 'SSN':
// return z.string()
// .max(9, "Max 9 chars")
// .regex(/^$|^[0-9]+$/, "Can only contain numbers");
// case 'SIM':
// return z.string()
// .max(20, "Max 20 chars")
// .regex(/^$|^[0-9]+$/, "Can only contain numbers");
// case 'KTAS':
// return z.string()
// .max(11, "Max 11 chars")
// .regex(/^[A-Z0-9]+$/, "Must be uppercase letters and numbers");
// default:
// return z.string().min(1, "Identifier required");
// }
// }
// let hasErrors = $derived(
// Object.values(formState.errors).some(value => value !== null)
// );
function handleSaveAndOrder() {
console.log('save and order');
}
const primaryAction = $derived({
label: 'Save',
onClick: handleSave,
disabled: hasErrors || formState.isSaving.current,
loading: formState.isSaving.current
});
const secondaryActions = [
{
label: 'Save and Order',
onClick: handleSaveAndOrder
}
];
</script>
<!-- {#snippet Fieldset({ key, label, required, type, optionsEndpoint, options, validateOn, dependsOn, endpointParamKey })}
<div class="flex w-full flex-col gap-1.5">
<div class="flex justify-between items-center w-full">
<Label>{label}</Label>
{#if required}
<span class="text-destructive text-xl leading-none h-3.5">*</span>
{/if}
</div>
<div class="relative flex flex-col items-center w-full">
{#if type === "input" || type === "email" || type === "number"}
<Input
type={type === "number" ? "number" : "text"}
bind:value={formState.form[key]}
oninput={() => {
if (validateOn?.includes("input")) {
formState.validateField(key);
}
}}
onblur={() => {
if (validateOn?.includes("blur")) {
validateFieldAsync(key);
}
}}
/>
{:else if type === "date"}
<ReusableCalendar
bind:value={formState.form[key]}
parentFunction={(dateStr) => {
formState.form[key] = dateStr;
if (validateOn?.includes("input")) {
formState.validateField(key, dateStr, false);
}
}}
/>
{:else if type === "datetime"}
<ReusableCalendarTimepicker
bind:value={formState.form.TimeOfDeath}
/>
{:else if type === "textarea"}
<textarea
class="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
bind:value={formState.form[key]}
></textarea>
{:else if type === "select"}
{@const selectedLabel = formState.selectOptions[key]?.find(opt => opt.value === formState.form[key])?.label || "Choose"}
{@const filteredOptions = getFilteredOptions(key)}
<Select.Root type="single" bind:value={formState.form[key]}
onValueChange={(val) => {
formState.form[key] = val;
if (validateOn?.includes("input")) {
formState.validateField(key, formState.form[key], false);
}
if (key === "Province") {
formState.form.City = "";
formState.selectOptions.City = [];
formState.lastFetched.City = null;
}
}}
onOpenChange={(open) => {
if (open && optionsEndpoint) {
formState.fetchOptions(
{ key, optionsEndpoint, dependsOn, endpointParamKey },
formState.form
);
}
}}
>
<Select.Trigger class="w-full truncate">
{selectedLabel}
</Select.Trigger>
<Select.Content>
<div class="p-2">
<input
type="text"
placeholder="Search..."
class="w-full border rounded px-2 py-1 text-sm"
bind:value={searchQuery[key]}
/>
</div>
{#if formState.loadingOptions[key]}
<Select.Item disabled value="loading">Loading...</Select.Item>
{:else}
{#if !required}
<Select.Item value="">- None -</Select.Item>
{/if}
{#each filteredOptions as option}
<Select.Item value={option.value}>
{option.label}
</Select.Item>
{/each}
{/if}
</Select.Content>
</Select.Root>
{:else if type === "identity"}
{@const selectedLabel = formState.selectOptions[key]?.find(opt => opt.value === formState.form.PatIdt.IdentifierType)?.label || "Choose"}
<div class="flex items-center w-full">
<Select.Root type="single" bind:value={formState.form.PatIdt.IdentifierType}
onOpenChange={(open) => {
if (open && optionsEndpoint) {
formState.fetchOptions({ key, optionsEndpoint});
}
}}
onValueChange={(val) => {
formState.form.PatIdt = {
IdentifierType: val,
Identifier:''
};
}}
>
<Select.Trigger class="w-full truncate text-muted-foreground rounded-r-none">
{selectedLabel}
</Select.Trigger>
<Select.Content>
{#if formState.loadingOptions[key]}
<Select.Item disabled value="loading">Loading...</Select.Item>
{:else}
{#if !required}
<Select.Item value="">- None -</Select.Item>
{/if}
{#each formState.selectOptions[key] ?? [] as option}
<Select.Item value={option.value}>
{option.label}
</Select.Item>
{/each}
{/if}
</Select.Content>
</Select.Root>
<Input type="text" class="rounded-l-none" disabled={!formState.form.PatIdt.IdentifierType} bind:value={formState.form.PatIdt.Identifier} oninput={validateIdentifier} />
</div>
{:else if type === "custodian"}
<div class="flex items-center w-full">
<Input type="text" class="rounded-r-none" readonly bind:value={formState.form[key].PatientID} />
<CustodianModal {formState} mode="new"/>
</div>
{:else if type === "linkto"}
<div class="flex items-center w-full">
<Input
type="text"
class="rounded-r-none"
readonly
value={linkToDisplay}
placeholder="No linked patients"
/>
<LinktoModal {formState} />
</div>
{:else if type === "fileupload"}
<div class="flex flex-col w-full">
<ReusableUpload bind:attachments={formState.form[key]} bind:errors={uploadErrors}/>
{#if Object.keys(uploadErrors).length > 0}
<div class="flex flex-col justify-start text-destructive">
{#each Object.entries(uploadErrors) as [file, msg]}
<span>{msg}</span>
{/each}
</div>
{/if}
</div>
{:else}
<Input
type="text"
bind:value={formState.form[key]}
placeholder="Custom field type: {type}"
/>
{/if}
<div class="absolute top-8 min-h-[1rem] w-full">
{#if isChecking[key]}
<div class="flex items-center gap-1 mt-1">
<Spinner />
<span class="text-sm text-muted-foreground">Checking...</span>
</div>
{:else if formState.errors[key]}
<span class="text-destructive text-sm leading-none">
{formState.errors[key]}
</span>
{/if}
</div>
</div>
</div>
{/snippet} -->
<FormPageContainer title="Create Patient" {primaryAction} {secondaryActions}>
<PatientFormRenderer
bind:formState
formFields={patientFormFields}
bind:searchQuery={helpers.searchQuery}
bind:uploadErrors={helpers.uploadErrors}
bind:isChecking={helpers.isChecking}
linkToDisplay={helpers.linkToDisplay}
validateIdentifier={helpers.validateIdentifier}
mode="create"
/>
<!-- <div class="flex-1 min-h-0 overflow-y-auto p-2 space-y-6">
{#each patientFormFields as group}
<div class="space-y-6">
{#if group.title}
<div class="text-md 2xl:text-lg font-semibold italic">
<span class="border-b-2 border-primary">{group.title}</span>
</div>
{/if}
{#each group.rows as row}
<div
class="grid grid-cols-1 space-y-2 gap-6 md:gap-4"
class:md:grid-cols-1={row.columns.length === 1}
class:md:grid-cols-2={row.columns.length === 2}
class:md:grid-cols-3={row.columns.length === 3}
>
{#each row.columns as col}
{#if col.type === "group"}
<div
class="grid grid-cols-1 gap-6 md:gap-2"
class:md:grid-cols-1={col.columns.length === 1}
class:md:grid-cols-2={col.columns.length === 2}
class:md:grid-cols-3={col.columns.length === 3}
>
{#each col.columns as child}
{@render Fieldset(child)}
{/each}
</div>
{:else}
{@render Fieldset(col)}
{/if}
{/each}
</div>
{/each}
</div>
{/each}
</div> -->
</FormPageContainer>

View File

@ -4,6 +4,7 @@
import PatientFormRenderer from "$lib/components/patient/reusable/patient-form-renderer.svelte";
import { toast } from "svelte-sonner";
import ReusableAlertDialog from "$lib/components/reusable/reusable-alert-dialog.svelte";
import { buildPatientPayload } from "$lib/components/patient/list/config/patient-form-config";
let props = $props();
@ -29,13 +30,14 @@
showConfirm = true;
}
}
// $inspect(formState.form)
function confirmDiscard() {
masterDetail.exitForm(true);
}
async function handleSave() {
const result = await formState.save(masterDetail.mode);
const payload = buildPatientPayload(formState.form);
const result = await formState.save(masterDetail.mode, payload);
if (result.status === 'success') {
toast('Patient Created!');

View File

@ -1,523 +0,0 @@
<script>
import TopbarWrapper from "$lib/components/topbar/topbar-wrapper.svelte";
import { Button } from "$lib/components/ui/button/index.js";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
// import { API } from "$lib/config/api";
import { Input } from "$lib/components/ui/input/index.js";
import { Label } from "$lib/components/ui/label/index.js";
import ChevronUpIcon from "@lucide/svelte/icons/chevron-up";
// import EraserIcon from "@lucide/svelte/icons/eraser";
import { Spinner } from "$lib/components/ui/spinner/index.js";
import { useForm } from "$lib/components/composable/use-form.svelte";
import { patientSchema, patientInitialForm, patientDefaultErrors, patientFormFields, getPatientFormActions } from "../config/patient-form-config";
import { editPatient } from "../api/patient-list-api";
import * as Select from "$lib/components/ui/select/index.js";
import ReusableCalendar from "$lib/components/reusable/reusable-calendar.svelte";
import ReusableCalendarTimepicker from "$lib/components/reusable/reusable-calendar-timepicker.svelte";
import CustodianModal from "../modal/custodian-modal.svelte";
import LinktoModal from "../modal/linkto-modal.svelte";
import ReusableUpload from "$lib/components/reusable/reusable-upload.svelte";
import { API } from "$lib/config/api";
import { z } from "zod";
import { untrack } from "svelte";
import FormPageContainer from "../reusable/form-page-container.svelte";
let props = $props();
let searchQuery = $state({});
let uploadErrors = $state({});
let isChecking = $state({});
const actions = [];
const formState = useForm({
schema: patientSchema,
initialForm: patientInitialForm,
defaultErrors: {},
mode: 'edit',
modeOpt: 'cascade',
saveEndpoint: null,
editEndpoint: editPatient,
});
$effect(() => {
// if (props.masterDetail?.selectedItem?.patient) {
// formState.setForm(props.masterDetail.selectedItem.patient);
// }
const backendData = props.masterDetail?.selectedItem?.patient;
if (!backendData) return;
untrack(() => {
const formData = {
...backendData,
PatIdt: backendData.PatIdt ?? patientInitialForm.PatIdt,
LinkTo: backendData.LinkTo ?? [],
Custodian: backendData.Custodian ?? patientInitialForm.Custodian,
Sex: backendData.SexKey || backendData.Sex,
Religion: backendData.ReligionKey || backendData.Religion,
MaritalStatus: backendData.MaritalStatusKey || backendData.MaritalStatus,
Ethnic: backendData.EthnicKey || backendData.Ethnic,
Race: backendData.RaceKey || backendData.Race,
Country: backendData.CountryKey || backendData.Country,
DeathIndicator: backendData.DeathIndicatorKey || backendData.DeathIndicator,
Province: backendData.ProvinceID || backendData.Province,
City: backendData.CityID || backendData.City,
};
formState.setForm(formData);
// Jalankan fetch options hanya sekali saat inisialisasi data
patientFormFields.forEach(group => {
group.rows.forEach(row => {
row.columns.forEach(col => {
if (col.type === "group") {
col.columns.forEach(child => {
if (child.type === "select" && child.optionsEndpoint) {
formState.fetchOptions(child, formData);
}
});
} else if ((col.type === "select" || col.type === "identity") && col.optionsEndpoint) {
formState.fetchOptions(col, formData);
}
});
});
});
if (formData.Province && formData.City) {
formState.fetchOptions(
{
key: "City",
optionsEndpoint: `${API.BASE_URL}${API.CITY}`,
dependsOn: "Province",
endpointParamKey: "Parent"
},
formData
);
}
});
});
// $inspect(formState.selectOptions)
let linkToDisplay = $derived(
Array.isArray(formState.form.LinkTo)
? formState.form.LinkTo
.map(p => p.PatientID)
.filter(Boolean)
.join(', ')
: ''
);
async function handleEdit() {
const result = await formState.save();
if (result.status === 'success') {
console.log('Patient updated successfully');
props.masterDetail?.exitForm();
} else {
console.error('Failed to update patient:', result.message);
}
}
function getFilteredOptions(key) {
const query = searchQuery[key] || "";
if (!query) return formState.selectOptions[key] ?? [];
return (formState.selectOptions[key] ?? []).filter(opt =>
opt.label.toLowerCase().includes(query.toLowerCase())
);
}
async function validateFieldAsync(field) {
isChecking[field] = true;
try {
const asyncSchema = patientSchema.extend({
PatientID: patientSchema.shape.PatientID.refine(
async (value) => {
if (!value) return false;
const res = await fetch(`${API.BASE_URL}${API.CHECK}?PatientID=${value}`);
const { status, data } = await res.json();
return status === "success" && data === false ? false : true;
},
{ message: "Patient ID already used" }
)
});
const partial = asyncSchema.pick({ [field]: true });
const result = await partial.safeParseAsync({ [field]: formState.form[field] });
formState.errors[field] = result.success ? null : result.error.issues[0].message;
} catch (err) {
console.error('Async validation error:', err);
} finally {
isChecking[field] = false;
}
}
function validateIdentifier() {
const identifierType = formState.form.PatIdt.IdentifierType;
const identifierValue = formState.form.PatIdt.Identifier;
if (!identifierType || !identifierValue) {
formState.errors['PatIdt.Identifier'] = null;
return;
}
const schema = getIdentifierValidation(identifierType);
const result = schema.safeParse(identifierValue);
formState.errors['PatIdt.Identifier'] = result.success ? null : result.error.issues[0].message;
}
function getIdentifierValidation(identifierType) {
switch (identifierType) {
case 'KTP':
return z.string()
.max(16, "Max 16 chars")
.regex(/^$|^[0-9]+$/, "Can only contain numbers");
case 'PASS':
return z.string()
.max(9, "Max 9 chars")
.regex(/^[A-Z0-9]+$/, "Must be uppercase letters and numbers");
case 'SSN':
return z.string()
.max(9, "Max 9 chars")
.regex(/^$|^[0-9]+$/, "Can only contain numbers");
case 'SIM':
return z.string()
.max(20, "Max 20 chars")
.regex(/^$|^[0-9]+$/, "Can only contain numbers");
case 'KTAS':
return z.string()
.max(11, "Max 11 chars")
.regex(/^[A-Z0-9]+$/, "Must be uppercase letters and numbers");
default:
return z.string().min(1, "Identifier required");
}
}
let hasErrors = $derived(
Object.values(formState.errors).some(value => value !== null)
);
const primaryAction = $derived({
label: 'Edit',
onClick: handleEdit,
disabled: hasErrors || formState.isSaving.current,
loading: formState.isSaving.current
});
const secondaryActions = [];
</script>
{#snippet Fieldset({ key, label, required, type, optionsEndpoint, options, validateOn, dependsOn, endpointParamKey })}
<div class="flex w-full flex-col gap-1.5">
<div class="flex justify-between items-center w-full">
<Label>{label}</Label>
{#if required}
<span class="text-destructive text-xl leading-none h-3.5">*</span>
{/if}
</div>
<div class="relative flex flex-col items-center w-full">
{#if type === "input" || type === "email" || type === "number"}
<Input
type={type === "number" ? "number" : "text"}
bind:value={formState.form[key]}
oninput={() => {
if (validateOn?.includes("input")) {
formState.validateField(key);
}
}}
onblur={() => {
if (validateOn?.includes("blur")) {
validateFieldAsync(key);
}
}}
/>
{:else if type === "date"}
<ReusableCalendar
bind:value={formState.form[key]}
parentFunction={(dateStr) => {
formState.form[key] = dateStr;
if (validateOn?.includes("input")) {
formState.validateField(key, dateStr, false);
}
}}
/>
{:else if type === "datetime"}
<!-- <ReusableCalendarTimepicker
bind:value={formState.form.TimeOfDeath}
parentFunction={(val) => {
formState.validateField('TimeOfDeath');
}}
/> -->
<ReusableCalendarTimepicker
bind:value={formState.form.TimeOfDeath}
/>
{:else if type === "textarea"}
<textarea
class="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
bind:value={formState.form[key]}
></textarea>
{:else if type === "select"}
{@const selectedLabel = formState.selectOptions[key]?.find(opt => opt.value === formState.form[key])?.label || "Choose"}
{@const filteredOptions = getFilteredOptions(key)}
<Select.Root type="single" bind:value={formState.form[key]}
onValueChange={(val) => {
formState.form[key] = val;
if (validateOn?.includes("input")) {
formState.validateField(key, formState.form[key], false);
}
if (key === "Province") {
formState.form.City = "";
formState.selectOptions.City = [];
formState.lastFetched.City = null;
}
}}
onOpenChange={(open) => {
if (open && optionsEndpoint) {
formState.fetchOptions({ key, optionsEndpoint, dependsOn, endpointParamKey}, formState.form );
}
}}
>
<Select.Trigger class="w-full truncate">
{selectedLabel}
</Select.Trigger>
<Select.Content>
<div class="p-2">
<input
type="text"
placeholder="Search..."
class="w-full border rounded px-2 py-1 text-sm"
bind:value={searchQuery[key]}
/>
</div>
{#if formState.loadingOptions[key]}
<Select.Item disabled value="loading">Loading...</Select.Item>
{:else}
{#if !required}
<Select.Item value="">- None -</Select.Item>
{/if}
{#each filteredOptions as option}
<Select.Item value={option.value}>
{option.label}
</Select.Item>
{/each}
{/if}
</Select.Content>
</Select.Root>
{:else if type === "identity"}
{@const selectedLabel = formState.selectOptions[key]?.find(opt => opt.value === formState.form.PatIdt.IdentifierType)?.label || "Choose"}
<div class="flex items-center w-full">
<Select.Root type="single" bind:value={formState.form.PatIdt.IdentifierType}
onOpenChange={(open) => {
if (open && optionsEndpoint) {
formState.fetchOptions({ key, optionsEndpoint});
}
}}
onValueChange={(val) => {
formState.form.PatIdt = {
IdentifierType: val,
Identifier:''
};
}}
>
<Select.Trigger class="w-full truncate text-muted-foreground rounded-r-none">
{selectedLabel}
</Select.Trigger>
<Select.Content>
{#if formState.loadingOptions[key]}
<Select.Item disabled value="loading">Loading...</Select.Item>
{:else}
{#if !required}
<Select.Item value="">- None -</Select.Item>
{/if}
{#each formState.selectOptions[key] ?? [] as option}
<Select.Item value={option.value}>
{option.label}
</Select.Item>
{/each}
{/if}
</Select.Content>
</Select.Root>
<Input type="text" class="rounded-l-none" disabled={!formState.form.PatIdt.IdentifierType} bind:value={formState.form.PatIdt.Identifier} oninput={validateIdentifier} />
</div>
{:else if type === "custodian"}
<div class="flex items-center w-full">
<Input type="text" class="rounded-r-none" readonly bind:value={formState.form[key].PatientID} />
<CustodianModal {formState} mode="new"/>
</div>
{:else if type === "linkto"}
<div class="flex items-center w-full">
<Input
type="text"
class="rounded-r-none"
readonly
value={linkToDisplay}
placeholder="No linked patients"
/>
<LinktoModal {formState} />
</div>
{:else if type === "fileupload"}
<div class="flex flex-col w-full">
<ReusableUpload bind:attachments={formState.form[key]} bind:errors={uploadErrors}/>
{#if Object.keys(uploadErrors).length > 0}
<div class="flex flex-col justify-start text-destructive">
{#each Object.entries(uploadErrors) as [file, msg]}
<span>{msg}</span>
{/each}
</div>
{/if}
</div>
{:else}
<Input
type="text"
bind:value={formState.form[key]}
placeholder="Custom field type: {type}"
/>
{/if}
<div class="absolute top-8 min-h-[1rem] w-full">
<!-- {#if formState.errors[key]}
<span class="text-destructive text-sm leading-none">
{formState.errors[key]}
</span>
{/if} -->
{#if isChecking[key]}
<div class="flex items-center gap-1 mt-1">
<Spinner />
<span class="text-sm text-muted-foreground">Checking...</span>
</div>
{:else if formState.errors[key]}
<span class="text-destructive text-sm leading-none">
{formState.errors[key]}
</span>
{/if}
</div>
</div>
</div>
{/snippet}
<FormPageContainer title="Edit Patient" {primaryAction} {secondaryActions}>
<div class="flex-1 min-h-0 overflow-y-auto p-2 space-y-6">
{#each patientFormFields as group}
<div class="space-y-6">
{#if group.title}
<div class="text-md 2xl:text-lg font-semibold italic">
<span class="border-b-2 border-primary">{group.title}</span>
</div>
{/if}
{#each group.rows as row}
<div
class="grid grid-cols-1 space-y-2 gap-6 md:gap-4"
class:md:grid-cols-1={row.columns.length === 1}
class:md:grid-cols-2={row.columns.length === 2}
class:md:grid-cols-3={row.columns.length === 3}
>
{#each row.columns as col}
{#if col.type === "group"}
<div
class="grid grid-cols-1 gap-6 md:gap-2"
class:md:grid-cols-1={col.columns.length === 1}
class:md:grid-cols-2={col.columns.length === 2}
class:md:grid-cols-3={col.columns.length === 3}
>
{#each col.columns as child}
{@render Fieldset(child)}
{/each}
</div>
{:else}
{@render Fieldset(col)}
{/if}
{/each}
</div>
{/each}
</div>
{/each}
</div>
</FormPageContainer>
<!-- <div class="flex flex-col p-2 gap-4 h-full w-full">
<TopbarWrapper {actions} title="Edit Patient"/>
<div class="flex-1 min-h-0 overflow-y-auto p-2 space-y-6">
{#each patientFormFields as group}
<div class="space-y-6">
{#if group.title}
<div class="text-md 2xl:text-lg font-semibold italic">
<span class="border-b-2 border-primary">{group.title}</span>
</div>
{/if}
{#each group.rows as row}
<div
class="grid grid-cols-1 space-y-2 gap-6 md:gap-4"
class:md:grid-cols-1={row.columns.length === 1}
class:md:grid-cols-2={row.columns.length === 2}
class:md:grid-cols-3={row.columns.length === 3}
>
{#each row.columns as col}
{#if col.type === "group"}
<div
class="grid grid-cols-1 gap-6 md:gap-2"
class:md:grid-cols-1={col.columns.length === 1}
class:md:grid-cols-2={col.columns.length === 2}
class:md:grid-cols-3={col.columns.length === 3}
>
{#each col.columns as child}
{@render Fieldset(child)}
{/each}
</div>
{:else}
{@render Fieldset(col)}
{/if}
{/each}
</div>
{/each}
</div>
{/each}
</div>
<div class="mt-auto flex justify-end items-center pt-2">
<Button
size="sm"
class="rounded-r-none cursor-pointer"
disabled={hasErrors || formState.isSaving.current}
onclick={handleSave}
>
{#if formState.isSaving.current}
<Spinner />
{:else}
Save
{/if}
</Button>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button
size="icon"
class="size-8 rounded-l-none"
disabled={hasErrors || formState.isSaving.current}
>
<ChevronUpIcon />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content collisionPadding={8}>
<DropdownMenu.Group>
<DropdownMenu.Item onclick={handleSaveAndOrder}>
Save and Order
</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleSave}>
Save
</DropdownMenu.Item>
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
</div> -->

View File

@ -6,10 +6,10 @@
import { untrack } from "svelte";
import { API } from "$lib/config/api";
import ReusableAlertDialog from "$lib/components/reusable/reusable-alert-dialog.svelte";
import { buildPatientPayload } from "$lib/components/patient/list/config/patient-form-config";
let props = $props();
// const { masterDetail, formFields, formActions, schema, initialForm, defaultError } = props.context;
const { masterDetail, formFields, formActions, schema, initialForm } = props.context;
const { formState } = masterDetail;
@ -30,38 +30,7 @@
}
$effect(() => {
// const backendData = masterDetail?.selectedItem?.patient;
// if (!backendData) return;
untrack(() => {
// const formData = {
// ...backendData,
// PatIdt: backendData.PatIdt ?? initialForm.PatIdt,
// LinkTo: backendData.LinkTo ?? [],
// Custodian: backendData.Custodian ?? initialForm.Custodian,
// Sex: backendData.SexKey || backendData.Sex,
// Religion: backendData.ReligionKey || backendData.Religion,
// MaritalStatus: backendData.MaritalStatusKey || backendData.MaritalStatus,
// Ethnic: backendData.EthnicKey || backendData.Ethnic,
// Race: backendData.RaceKey || backendData.Race,
// Country: backendData.CountryKey || backendData.Country,
// DeathIndicator: backendData.DeathIndicatorKey || backendData.DeathIndicator,
// Province: backendData.ProvinceID || backendData.Province,
// City: backendData.CityID || backendData.City,
// };
// formState.setForm(formData);
// Ensure PatIdt is always an object to prevent binding errors
// if (!formState.form.PatIdt) {
// formState.form.PatIdt = {
// IdentifierType: "",
// Identifier: ""
// };
// }
formFields.forEach(group => {
group.rows.forEach(row => {
row.columns.forEach(col => {
@ -93,11 +62,11 @@
});
});
// $inspect(masterDetail?.selectedItem?.patient)
$inspect(masterDetail?.selectedItem?.patient)
// $inspect(formState.form)
async function handleEdit() {
// console.log(formState.form);
const result = await formState.save(masterDetail.mode);
const payload = buildPatientPayload(formState.form);
const result = await formState.save(masterDetail.mode, payload);
if (result.status === 'success') {
console.log('Patient updated successfully');

View File

@ -220,19 +220,18 @@
</Select.Content>
</Select.Root>
{:else if type === "identity"}
{@const selectedLabel = formState.selectOptions[key]?.find(opt => opt.value === formState.form.PatIdt?.IdentifierType)?.label || "Choose"}
{@const selectedLabel = formState.selectOptions[key]?.find(opt => opt.value === formState.form.PatIdt_IdentifierType)?.label || "Choose"}
<div class="flex items-center w-full">
<Select.Root type="single" bind:value={formState.form.PatIdt.IdentifierType}
<Select.Root type="single" bind:value={formState.form.PatIdt_IdentifierType}
onOpenChange={(open) => {
if (open && optionsEndpoint) {
formState.fetchOptions({ key, optionsEndpoint});
}
}}
onValueChange={(val) => {
formState.form.PatIdt = {
IdentifierType: val,
Identifier:''
};
formState.form.PatIdt_IdentifierType = val;
formState.form.PatIdt_Identifier = ''
formState.errors.PatIdt_Identifier = null
}}
>
<Select.Trigger class="w-full truncate text-muted-foreground rounded-r-none">
@ -253,11 +252,11 @@
{/if}
</Select.Content>
</Select.Root>
<Input type="text" class="rounded-l-none" disabled={!formState.form.PatIdt?.IdentifierType} bind:value={formState.form.PatIdt.Identifier} oninput={validateIdentifier} />
<Input type="text" class="rounded-l-none" disabled={!formState.form.PatIdt_IdentifierType} bind:value={formState.form.PatIdt_Identifier} oninput={validateIdentifier} />
</div>
{:else if type === "custodian"}
<div class="flex items-center w-full">
<Input type="text" class="rounded-r-none" readonly bind:value={formState.form[key].PatientID} />
<Input type="text" class="rounded-r-none" readonly bind:value={formState.form.Custodian_PatientID} />
<CustodianModal {formState} mode="new"/>
</div>
{:else if type === "linkto"}

View File

@ -1,770 +0,0 @@
<script>
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import * as Select from '$lib/components/ui/select/index.js';
import { Toggle } from '$lib/components/ui/toggle/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import { Spinner } from '$lib/components/ui/spinner/index.js';
import ReusableCalendar from '$lib/components/reusable/reusable-calendar.svelte';
import { Badge } from '$lib/components/ui/badge/index.js';
import * as InputGroup from '$lib/components/ui/input-group/index.js';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import { Textarea } from '$lib/components/ui/textarea/index.js';
import MoveLeftIcon from '@lucide/svelte/icons/move-left';
import MoveRightIcon from '@lucide/svelte/icons/move-right';
import BrushCleaningIcon from '@lucide/svelte/icons/brush-cleaning';
import DeleteIcon from '@lucide/svelte/icons/delete';
import Trash2Icon from '@lucide/svelte/icons/trash-2';
import PlusIcon from '@lucide/svelte/icons/plus';
import CornerDownLeftIcon from '@lucide/svelte/icons/corner-down-left';
let {
formState,
formFields,
mode = 'create',
disabledResultTypes = [],
disabledReferenceTypes = [],
disabledSign = false,
joinFields = $bindable(),
hiddenFields,
handleTestTypeChange,
handleResultTypeChange,
handleRefTypeChange,
onAddMember,
onRemoveMember,
customval,
operators,
logicalop,
comparisonop,
expression,
cursorPosition,
onUnselectAll,
onGetErrorStatus,
onAddOperator,
onAddValue,
onHandleContainerClick,
onMoveCursorLeft,
onMoveCursorRight,
onDeleteChar,
onClearExpression,
} = $props();
let searchQuery = $state({});
let dropdownOpen = $state({});
function getFilteredOptions(key) {
const query = searchQuery[key] || '';
if (!query) return formState.selectOptions?.[key] ?? [];
return (formState.selectOptions?.[key] ?? []).filter((opt) =>
opt.label.toLowerCase().includes(query.toLowerCase())
);
}
async function initializeDefaultValues() {
for (const group of formFields) {
for (const row of group.rows) {
for (const col of row.columns) {
if (col.type === 'group') {
for (const child of col.columns) {
await handleDefaultValue(child);
}
} else {
await handleDefaultValue(col);
}
}
}
}
}
async function handleDefaultValue(field) {
if (!field.defaultValue || !field.optionsEndpoint) return;
await formState.fetchOptions(field, formState.form);
if (!formState.form[field.key]) {
formState.form[field.key] = field.defaultValue;
}
}
$effect(() => {
initializeDefaultValues();
});
</script>
{#snippet Fieldset({
key,
label,
required,
type,
optionsEndpoint,
options,
validateOn,
dependsOn,
endpointParamKey,
valueKey,
labelKey,
txtKey
})}
<div class="flex w-full flex-col gap-1.5">
<div class="flex justify-between items-center w-full">
<Label>{label}</Label>
{#if required}
<span class="text-destructive text-xl leading-none h-3.5">*</span>
{/if}
</div>
<div class="relative flex flex-col items-center w-full">
{#if type === 'text'}
<Input
type="text"
bind:value={formState.form[key]}
oninput={() => {
if (validateOn?.includes('input')) {
formState.validateField(key, formState.form[key], false);
}
}}
onblur={() => {
if (validateOn?.includes('blur')) {
validateFieldAsync(key, mode, originalData?.[key]);
}
}}
readonly={key === 'NumRefType' || key === 'TxtRefType' || key === 'Level'}
/>
{:else if type === 'email'}
<Input
type="email"
bind:value={formState.form[key]}
oninput={() => {
if (validateOn?.includes('input')) {
formState.validateField(key, formState.form[key], false);
}
}}
onblur={() => {
if (validateOn?.includes('blur')) {
formState.validateField(key, formState.form[key], false);
}
}}
/>
{:else if type === 'number'}
<Input
type="number"
bind:value={formState.form[key]}
oninput={() => {
if (validateOn?.includes('input')) {
formState.validateField(key, formState.form[key], false);
}
}}
onblur={() => {
if (validateOn?.includes('blur')) {
formState.validateField(key, formState.form[key], false);
}
}}
onkeydown={(e) => ['e', 'E', '+', '-'].includes(e.key) && e.preventDefault()}
/>
{:else if type === 'textarea'}
<Textarea
class="flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
oninput={() => {
if (validateOn?.includes('input')) {
formState.validateField(key, formState.form[key], false);
}
}}
onblur={() => {
if (validateOn?.includes('blur')) {
formState.validateField(key, formState.form[key], false);
}
}}
bind:value={formState.form[key]}
/>
{:else if type === 'select'}
{@const selectedLabel =
formState.selectOptions?.[key]?.find((opt) => opt.value === formState.form[key])?.label ||
'Choose'}
{@const filteredOptions = getFilteredOptions(key)}
<Select.Root
type="single"
bind:value={formState.form[key]}
onValueChange={(val) => {
formState.form[key] = val;
if (validateOn?.includes('input')) {
formState.validateField?.(key, formState.form[key], false);
}
if (key === 'TestType') {
handleTestTypeChange(val);
}
if (key === 'Province') {
formState.form.City = '';
if (formState.selectOptions) {
formState.selectOptions.City = [];
}
if (formState.lastFetched) {
formState.lastFetched.City = null;
}
}
if (key === 'ResultType') {
handleResultTypeChange(val);
}
if (key === 'RefType') {
handleRefTypeChange(val);
}
if (key === 'HostType') {
formState.form.HostID = '';
formState.form.HostTestCode = '';
formState.form.HostTestName = '';
formState.selectOptions.HostTestCode = [];
formState.selectOptions.HostTestName = [];
}
if (key === 'HostID') {
formState.form.HostTestCode = '';
formState.form.HostTestName = '';
formState.selectOptions.HostTestCode = [];
formState.selectOptions.HostTestName = [];
}
if (key === 'ClientType') {
formState.form.ClientID = '';
formState.form.ClientTestCode = '';
formState.form.ClientTestName = '';
formState.selectOptions.ClientTestCode = [];
formState.selectOptions.ClientTestName = [];
}
if (key === 'ClientID') {
formState.form.ClientTestCode = '';
formState.form.ClientTestName = '';
formState.selectOptions.ClientTestCode = [];
formState.selectOptions.ClientTestName = [];
}
}}
onOpenChange={(open) => {
if (open && optionsEndpoint) {
formState.fetchOptions?.(
{ key, optionsEndpoint, dependsOn, endpointParamKey, valueKey, labelKey },
formState.form
);
}
}}
>
<Select.Trigger class="w-full truncate">
{selectedLabel}
</Select.Trigger>
<Select.Content>
<div class="p-2">
<input
type="text"
placeholder="Search..."
class="w-full border rounded px-2 py-1 text-sm"
bind:value={searchQuery[key]}
/>
</div>
{#if formState.loadingOptions?.[key]}
<Select.Item disabled value="loading">Loading...</Select.Item>
{:else}
{#if !required}
<Select.Item value="">- None -</Select.Item>
{/if}
{#each filteredOptions as option}
<Select.Item
value={option.value}
disabled={(key === 'ResultType' && disabledResultTypes.includes(option.value)) ||
(key === 'RefType' && disabledReferenceTypes.includes(option.value))}
>
{option.label}
</Select.Item>
{/each}
{/if}
</Select.Content>
</Select.Root>
{:else if type === 'selectmultiple'}
{@const filteredOptions = getFilteredOptions(key)}
{@const currentValues = Array.isArray(formState.form[key]) ? formState.form[key] : []}
<Select.Root
type="multiple"
value={currentValues.map((item) => item.value)}
onValueChange={(val) => {
const selectedObjects = (formState.selectOptions?.[key] ?? []).filter((opt) =>
val.includes(opt.value)
);
formState.form[key] = selectedObjects;
if (validateOn?.includes('input')) {
formState.validateField?.(key, selectedObjects, false);
formState.validateField?.('FormulaCode', expression, false);
}
}}
onOpenChange={(open) => {
if (open && optionsEndpoint) {
formState.fetchOptions?.(
{ key, optionsEndpoint, dependsOn, endpointParamKey, valueKey, labelKey },
formState.form
);
}
}}
>
<Select.Trigger class="w-full">
{currentValues.length
? (formState.selectOptions?.[key] ?? [])
.filter((o) => currentValues.some((f) => f.value === o.value))
.map((o) => o.label)
.join(', ')
: 'Choose'}
</Select.Trigger>
<Select.Content>
<div class="p-2">
<input
type="text"
placeholder="Search..."
class="w-full border rounded px-2 py-1 text-sm"
bind:value={searchQuery[key]}
/>
</div>
<Select.Group>
{#if formState.loadingOptions?.[key]}
<div class="p-2 text-sm text-muted-foreground">Loading...</div>
{:else}
{#if currentValues.length > 0}
<Select.Separator />
<button
class="w-full px-2 py-1.5 text-left text-sm hover:bg-accent hover:text-accent-foreground"
onclick={() => onUnselectAll?.(key)}
>
Unselect All
</button>
{/if}
{#each filteredOptions as opt (opt.value)}
<Select.Item value={opt.value} label={opt.label}>
{opt.label}
</Select.Item>
{/each}
{/if}
</Select.Group>
</Select.Content>
</Select.Root>
{:else if type === 'date'}
<ReusableCalendar
bind:value={formState.form[key]}
parentFunction={(dateStr) => {
formState.form[key] = dateStr;
if (validateOn?.includes('input')) {
formState.validateField(key, dateStr, false);
}
}}
/>
{:else if type === 'signvalue'}
<InputGroup.Root>
<InputGroup.Input
placeholder="Type here"
bind:value={formState.form[txtKey]}
oninput={() => {
if (validateOn?.includes('input')) {
formState.validateField('Low', formState.form[txtKey]);
formState.validateField('LowSign');
formState.validateField('High', formState.form[txtKey]);
formState.validateField('HighSign');
}
}}
/>
<InputGroup.Addon align="inline-start">
<DropdownMenu.Root
onOpenChange={(open) => {
dropdownOpen[key] = open;
if (open && optionsEndpoint) {
formState.fetchOptions?.(
{ key, optionsEndpoint, valueKey, labelKey },
formState.form
);
}
}}
>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<InputGroup.Button {...props} variant="ghost" disabled={disabledSign}>
{formState.selectOptions?.[key]?.find(
(opt) => opt.value === formState.form[key]
)?.label || 'Choose'}
<ChevronDownIcon />
</InputGroup.Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="start">
{#if formState.loadingOptions?.[key]}
<DropdownMenu.Item disabled>Loading...</DropdownMenu.Item>
{:else}
<DropdownMenu.Item
onSelect={() => {
formState.form[key] = '';
dropdownOpen[key] = false;
formState.validateField('LowSign');
formState.validateField('HighSign');
}}
>
- None -
</DropdownMenu.Item>
{#each formState.selectOptions?.[key] || [] as option}
<DropdownMenu.Item
onclick={() => {
formState.form[key] = option.value;
dropdownOpen[key] = false;
}}
onSelect={() => {
formState.form[key] = option.value;
formState.validateField('LowSign');
formState.validateField('HighSign');
}}
>
{option.label}
</DropdownMenu.Item>
{/each}
{/if}
</DropdownMenu.Content>
</DropdownMenu.Root>
</InputGroup.Addon>
</InputGroup.Root>
{:else if type === 'agejoin'}
<div class="flex items-center gap-2 w-full">
<InputGroup.Root>
<InputGroup.Input
type="number"
bind:value={joinFields[key].YY}
oninput={() => {
if (validateOn?.includes('input')) {
formState.validateField('AgeStart');
formState.validateField('AgeEnd');
}
}}
/>
<InputGroup.Addon align="inline-end">
<InputGroup.Text>Year</InputGroup.Text>
</InputGroup.Addon>
</InputGroup.Root>
<InputGroup.Root>
<InputGroup.Input
type="number"
bind:value={joinFields[key].MM}
oninput={() => {
if (validateOn?.includes('input')) {
formState.validateField('AgeStart');
formState.validateField('AgeEnd');
}
}}
/>
<InputGroup.Addon align="inline-end">
<InputGroup.Text>Month</InputGroup.Text>
</InputGroup.Addon>
</InputGroup.Root>
<InputGroup.Root>
<InputGroup.Input
type="number"
bind:value={joinFields[key].DD}
oninput={() => {
if (validateOn?.includes('input')) {
formState.validateField('AgeStart');
formState.validateField('AgeEnd');
}
}}
/>
<InputGroup.Addon align="inline-end">
<InputGroup.Text>Day</InputGroup.Text>
</InputGroup.Addon>
</InputGroup.Root>
</div>
{:else if type === 'formulabuilder'}
<div class="flex flex-col gap-8 w-full">
<div class="flex gap-1 w-full">
<Button type="button" variant="outline" size="icon" onclick={onMoveCursorLeft}>
<MoveLeftIcon class="w-4 h-4" />
</Button>
<div class="relative flex-1">
<button
class="flex flex-1 h-27 w-full min-w-0 items-center rounded-md border bg-background px-3 py-2 font-mono text-sm whitespace-nowrap"
role="textbox"
tabindex="0"
onclick={onHandleContainerClick}
>
{#each expression.split('') as char, i}
{#if i === cursorPosition}
<span class="animate-cursor inline-block h-6 w-0.5 bg-primary"></span>
{/if}
<span class="">{char}</span>
{/each}
{#if cursorPosition === expression.length}
<span class="animate-cursor inline-block h-6 w-0.5 bg-primary"></span>
{/if}
</button>
</div>
<Button type="button" variant="outline" size="icon" onclick={onMoveCursorRight}>
<MoveRightIcon class="w-4 h-4" />
</Button>
<Button type="button" variant="outline" size="icon" onclick={onDeleteChar}>
<DeleteIcon class="w-4 h-4" />
</Button>
<Button type="button" variant="outline" size="icon" onclick={onClearExpression}>
<BrushCleaningIcon class="w-4 h-4" />
</Button>
<Button type="button" variant="outline" size="icon">
<CornerDownLeftIcon class="w-4 h-4" />
</Button>
</div>
{#if formState.form.FormulaInput.length > 0}
<div>
<div class="flex flex-col gap-2">
<div>
<span class="text-sm font-medium">Selected Tests</span>
<div class="flex flex-wrap gap-2">
{#each formState.form.FormulaInput as item (item)}
<Button
type="button"
variant="outline"
class="h-auto w-auto p-2"
onclick={() => onAddValue?.(item.value)}
>
{item.value}
</Button>
{/each}
</div>
</div>
<div>
<span class="text-sm font-medium">Possible Values (from selected tests)</span>
<div class="flex flex-wrap gap-2">
</div>
</div>
<div>
<span class="text-sm font-medium">Custom Values</span>
<div class="flex flex-wrap gap-2">
{#each customval as op}
<Button
type="button"
variant="outline"
class="h-auto w-auto p-2"
onclick={() => onAddOperator?.(op)}
>
{op}
</Button>
{/each}
</div>
</div>
<div>
<span class="text-sm font-medium">Logical Operators</span>
<div class="flex flex-wrap gap-2">
{#each logicalop as op}
<Button
type="button"
variant="outline"
class="h-auto w-auto p-2"
onclick={() => onAddOperator?.(op)}
>
{op}
</Button>
{/each}
</div>
</div>
<div>
<span class="text-sm font-medium">Comparison Operators</span>
<div class="flex flex-wrap gap-2">
{#each comparisonop as op}
<Button
type="button"
variant="outline"
size="icon"
onclick={() => onAddOperator?.(op)}
>
{op}
</Button>
{/each}
</div>
</div>
<div>
<span class="text-sm font-medium">Math Operators</span>
<div class="flex flex-wrap gap-2">
{#each operators as op}
<Button
type="button"
variant="outline"
size="icon"
onclick={() => onAddOperator?.(op)}
>
{op}
</Button>
{/each}
</div>
</div>
</div>
</div>
{/if}
</div>
{:else if type === 'members'}
{@const filteredOptions = getFilteredOptions(key)}
<div class="flex flex-col gap-2 w-full">
{#each formState.form.Members as member, index (member.id)}
{@const selectedLabel =
formState.selectOptions?.[key]?.find((opt) => opt.value === member.value)?.label ||
'Choose'}
<div class="flex gap-1 w-full">
<Button type="button" variant="outline" size="icon" disabled>
{index + 1}
</Button>
<div class="flex-1">
<Select.Root
type="single"
bind:value={member.value}
onOpenChange={(open) => {
if (open && optionsEndpoint) {
formState.fetchOptions?.(
{ key, optionsEndpoint, dependsOn, endpointParamKey, valueKey, labelKey },
formState.form
);
}
}}
onValueChange={() => {
if (validateOn?.includes('input')) {
formState.validateField?.(key, formState.form[key], false);
}
}}
>
<Select.Trigger class="w-full">
{selectedLabel}
</Select.Trigger>
<Select.Content>
<div class="p-2">
<input
type="text"
placeholder="Search..."
class="w-full border rounded px-2 py-1 text-sm"
bind:value={searchQuery[key]}
/>
</div>
{#if formState.loadingOptions?.[key]}
<Select.Item disabled value="loading">Loading...</Select.Item>
{:else}
{#if !required}
<Select.Item value="">- None -</Select.Item>
{/if}
{#each filteredOptions as option}
<Select.Item value={option.value}>
{option.label}
</Select.Item>
{/each}
{/if}
</Select.Content>
</Select.Root>
</div>
<Button
type="button"
variant="outline"
size="icon"
onclick={() => onRemoveMember(member.id)}
>
<Trash2Icon class="size-4" />
</Button>
</div>
{/each}
<Button variant="outline" onclick={onAddMember}>
<PlusIcon class="size-4" />
Add Test
</Button>
</div>
{:else}
<Input
type="text"
bind:value={formState.form[key]}
placeholder="Custom field type: {type}"
/>
{/if}
<div class={`absolute min-h-[1rem] w-full ${key === 'Members' ? 'right-0 -top-5 text-right' : 'top-10'}`}>
{#if key !== 'FormulaCode' && (formState.errors[key] || formState.errors[txtKey])}
{@const errorMessage = formState.errors[key] ?? formState.errors[txtKey]}
<div class="text-sm text-destructive">
{errorMessage}
</div>
{/if}
{#if key === 'FormulaCode' && formState.form.FormulaInput?.length}
{@const inputStatus = onGetErrorStatus?.(expression)}
<div class="flex items-center gap-2 text-sm text-destructive">
<span>Must included :</span>
<div class="flex gap-1 flex-wrap">
{#each inputStatus as item (item.value)}
<Badge variant={item.done ? 'default' : 'destructive'}>
{item.value}
</Badge>
{/each}
</div>
</div>
{/if}
</div>
</div>
</div>
{/snippet}
<div class="p-2 space-y-6">
{#each formFields as group}
<div class="space-y-6">
{#if group.title}
<div class="text-md 2xl:text-lg font-semibold italic">
<span class="border-b-2 border-primary">{group.title}</span>
</div>
{/if}
{#each group.rows as row}
{@const visibleColumns = row.columns.filter((col) => !hiddenFields?.includes(col.key))}
{#if visibleColumns.length > 0}
<div
class="grid grid-cols-1 space-y-2 gap-6 md:gap-4"
class:md:grid-cols-1={visibleColumns.length === 1 &&
visibleColumns[0].fullWidth !== false}
class:md:grid-cols-2={visibleColumns.length === 2 ||
(visibleColumns.length === 1 && visibleColumns[0].fullWidth === false)}
class:md:grid-cols-3={visibleColumns.length === 3}
>
{#each visibleColumns as col}
{#if col.type === 'group'}
{@const visibleChildColumns = col.columns.filter(
(child) => !hiddenFields?.includes(child.key)
)}
{#if visibleChildColumns.length > 0}
<div
class="grid grid-cols-1 gap-6 md:gap-2"
class:md:grid-cols-1={visibleChildColumns.length === 1 &&
visibleChildColumns[0].fullWidth !== false}
class:md:grid-cols-2={visibleChildColumns.length === 2 ||
(visibleChildColumns.length === 1 &&
visibleChildColumns[0].fullWidth === false)}
class:md:grid-cols-3={visibleChildColumns.length === 3}
>
{#each visibleChildColumns as child}
{@render Fieldset(child)}
{/each}
</div>
{/if}
{:else}
{@render Fieldset(col)}
{/if}
{/each}
</div>
{/if}
{/each}
</div>
{/each}
</div>
<style>
@keyframes cursor-blink {
0%,
50% {
opacity: 1;
}
51%,
100% {
opacity: 0;
}
}
.animate-cursor {
animation: cursor-blink 1s infinite;
}
</style>

View File

@ -1,290 +0,0 @@
<script>
import * as Select from '$lib/components/ui/select/index.js';
import { fruits } from '$lib/components/multiselect/multiselect-form-config';
import { Badge } from '$lib/components/ui/badge/index.js';
import { z } from 'zod';
let selectedValues = $state([]);
let expression = $state('');
let cursorPosition = $state(0);
const operators = ['+', '-', '*', '/', '^', '(', ')'];
function hasExactKeyword(input, keyword) {
const regex = new RegExp(`\\b${keyword}\\b`, 'i');
return regex.test(input);
}
const schema = z
.object({
selected: z.array(z.string()),
input: z.string()
})
.refine(
(data) => {
return data.selected.every((v) => hasExactKeyword(data.input, v));
},
{
message: 'Input must contain all selected values',
path: ['input']
}
);
let errors = $state({});
$effect(() => {
const result = schema.safeParse({ selected: selectedValues, input: expression });
if (!result.success) {
const fieldErrors = {};
for (const issue of result.error.issues) {
fieldErrors[issue.path[0]] = issue.message;
}
errors = fieldErrors;
} else {
errors = {};
}
});
const selectedLabel = $derived(
selectedValues.length === 0
? 'Select a fruit'
: selectedValues.map((v) => fruits.find((f) => f.value === v)?.value).join(', ')
);
const hasErrors = $derived(Object.keys(errors).length > 0);
const inputStatus = $derived(
selectedValues
.filter((v) => v)
.map((v) => ({
value: v,
label: fruits.find((f) => f.value === v)?.label,
done: hasExactKeyword(expression, v)
}))
);
function unselectAll() {
selectedValues = [];
}
function removeSelectedItem(item) {
selectedValues = selectedValues.filter((v) => v !== item);
}
function addToExpression(text) {
const before = expression.slice(0, cursorPosition);
const after = expression.slice(cursorPosition);
expression = before + text + after;
cursorPosition += text.length;
setCursorPosition();
}
function addOperator(op) {
addToExpression(op);
}
function addValue(val) {
addToExpression(val);
}
function handleInput(e) {
expression = e.target.value;
cursorPosition = e.target.selectionStart;
}
function handleKeydown(e) {
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
setTimeout(setCursorPosition, 0);
}
}
function setCursorPosition() {}
function handleClick(e) {
cursorPosition = e.target.selectionStart;
}
function handleContainerClick(e) {
const rect = e.currentTarget.getBoundingClientRect();
const text = expression;
const charWidth = 8.5;
const padding = 12;
const clickX = e.clientX - rect.left - padding;
let newPos = Math.floor(clickX / charWidth);
newPos = Math.max(0, Math.min(newPos, text.length));
cursorPosition = newPos;
}
function moveCursorLeft() {
if (cursorPosition > 0) {
cursorPosition -= 1;
setCursorPosition();
}
}
function moveCursorRight() {
if (cursorPosition < expression.length) {
cursorPosition += 1;
setCursorPosition();
}
}
function deleteChar() {
if (cursorPosition > 0) {
const before = expression.slice(0, cursorPosition - 1);
const after = expression.slice(cursorPosition);
expression = before + after;
cursorPosition -= 1;
setCursorPosition();
}
}
function clearExpression() {
expression = '';
cursorPosition = 0;
}
</script>
<div class="flex flex-col gap-4">
<Select.Root type="multiple" name="favoriteFruit" bind:value={selectedValues}>
<Select.Trigger class="w-full">
{selectedLabel}
</Select.Trigger>
<Select.Content>
<Select.Group>
<Select.Label>Fruits</Select.Label>
{#each fruits as fruit (fruit.value)}
<Select.Item value={fruit.value} label={fruit.label}>
{fruit.label}
</Select.Item>
{/each}
</Select.Group>
{#if selectedValues.length > 0}
<Select.Separator />
<button
class="w-full px-2 py-1.5 text-left text-sm hover:bg-accent hover:text-accent-foreground"
onclick={unselectAll}
>
Unselect All
</button>
{/if}
</Select.Content>
</Select.Root>
{#if selectedValues.length > 0}
<div class="flex flex-col gap-2">
<label class="text-sm font-medium">Selected Values</label>
<div class="flex flex-wrap gap-2">
{#each selectedValues as item (item)}
<button
type="button"
class="flex items-center gap-1 rounded-md bg-blue-100 px-3 py-1.5 text-sm font-medium text-blue-800 hover:bg-blue-200"
onclick={() => addValue(item)}
>
{item}
</button>
{/each}
</div>
</div>
{/if}
<div class="flex flex-col gap-2">
<label class="text-sm font-medium">Expression</label>
<div class="flex gap-1">
<button
type="button"
onclick={moveCursorLeft}
class="rounded-md border border-input bg-background px-3 py-2 text-sm hover:bg-accent"
>
</button>
<div class="relative flex-1">
<div
class="flex h-10 items-center overflow-x-auto rounded-md border border-input bg-background px-3 py-2 font-mono text-sm whitespace-nowrap"
onclick={handleContainerClick}
role="textbox"
tabindex="0"
>
{#each expression.split('') as char, i}
{#if i === cursorPosition}
<span class="animate-cursor inline-block h-6 w-0.5 bg-primary"></span>
{/if}
<span class="">{char}</span>
{/each}
{#if cursorPosition === expression.length}
<span class="animate-cursor inline-block h-6 w-0.5 bg-primary"></span>
{/if}
</div>
</div>
<button
type="button"
onclick={moveCursorRight}
class="rounded-md border border-input bg-background px-3 py-2 text-sm hover:bg-accent"
>
</button>
<button
type="button"
onclick={deleteChar}
class="rounded-md border border-input bg-background px-3 py-2 text-sm hover:bg-accent"
>
Del
</button>
<button
type="button"
onclick={clearExpression}
class="rounded-md border border-input bg-background px-3 py-2 text-sm hover:bg-accent"
>
Clear
</button>
</div>
</div>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium">Operators</label>
<div class="flex flex-wrap gap-2">
{#each operators as op}
<button
type="button"
class="rounded-md bg-secondary px-4 py-2 text-sm font-medium text-secondary-foreground hover:bg-secondary/80"
onclick={() => addOperator(op)}
>
{op}
</button>
{/each}
</div>
</div>
{#if errors.input}
<div class="flex items-center gap-2 text-sm text-red-500">
<span>{errors.input}:</span>
<div class="flex gap-1">
{#each inputStatus as item (item.value)}
<Badge variant={item.done ? 'default' : 'destructive'}>
{item.value}
</Badge>
{/each}
</div>
</div>
{/if}
<button disabled={hasErrors} class="rounded bg-blue-500 px-4 py-2 text-white disabled:opacity-50">
Save
</button>
</div>
<style>
@keyframes cursor-blink {
0%,
50% {
opacity: 1;
}
51%,
100% {
opacity: 0;
}
}
.animate-cursor {
animation: cursor-blink 1s infinite;
}
</style>

View File

@ -22,15 +22,11 @@
editEndpoint: editPatient,
mapToForm: (data) => ({
...data,
PatIdt: {
IdentifierType: data.PatIdt?.IdentifierType ?? "",
Identifier: data.PatIdt?.Identifier ?? ""
},
PatIdt_IdentifierType: data.PatIdt?.IdentifierType ?? "",
PatIdt_Identifier: data.PatIdt?.Identifier ?? "",
Custodian_InternalPID: data.Custodian?.InternalPID ?? "",
Custodian_PatientID: data.Custodian?.PatientID ?? "",
LinkTo: Array.isArray(data.LinkTo) ? data.LinkTo : [],
Custodian: data.Custodian ?? {
InternalPID: "",
PatientID: ""
},
})
}
});