mirror of
https://github.com/faiztyanirh/clqms-shadcn-v1.git
synced 2026-04-22 17:19:52 +07:00
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:
parent
45c8d6969a
commit
0c0bbd6e26
@ -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 =>
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@ -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>
|
||||
@ -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: '' }
|
||||
|
||||
@ -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>
|
||||
@ -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" },
|
||||
],
|
||||
},
|
||||
|
||||
@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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>
|
||||
@ -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!');
|
||||
|
||||
@ -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> -->
|
||||
@ -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');
|
||||
|
||||
@ -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"}
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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: ""
|
||||
},
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user