convert edit patient to composable

This commit is contained in:
faiztyanirh 2026-02-02 13:33:30 +07:00
parent 0c1e54fc3d
commit d470e18df5
15 changed files with 569 additions and 565 deletions

View File

@ -1,4 +1,4 @@
import { useResponsive } from "./useResponsive.svelte.js";
import { useResponsive } from "./use-responsive.svelte.js";
export function useMasterDetail(options = {}) {
const { confirmMessage = "You have unsaved changes. Discard them?", onSelect = null, } = options;
@ -7,13 +7,11 @@ export function useMasterDetail(options = {}) {
let mode = $state("view");
let isLoadingDetail = $state(false);
// Form state
let form = $state({});
let formSnapshot = $state({});
const { isMobile } = useResponsive();
// Derived states
const isFormMode = $derived(mode === "create" || mode === "edit");
const showMaster = $derived(!isMobile || (mode === "view" && !selectedItem));
@ -29,7 +27,6 @@ export function useMasterDetail(options = {}) {
JSON.stringify(form) !== JSON.stringify(formSnapshot)
);
// Actions
async function select(item) {
mode = "view";
@ -60,7 +57,6 @@ export function useMasterDetail(options = {}) {
if (!selectedItem) return;
mode = "edit";
// Auto exclude 'id' or use custom mapping
const formData = mapToForm
? mapToForm(selectedItem)
: (() => {

View File

@ -11,7 +11,7 @@
import { searchParam } from "$lib/components/patient/api/patient-api";
import { Spinner } from "$lib/components/ui/spinner/index.js";
import { searchFields } from "../config/patient-config";
import { useSearch } from "$lib/components/composable/useSearch.svelte";
import { useSearch } from "$lib/components/composable/use-search.svelte";
import ReusableEmpty from "$lib/components/reusable/reusable-empty.svelte";
let props = $props();
@ -38,10 +38,8 @@
}
function confirmCustodian() {
// Update form state dengan selected patient
props.formState.form.Custodian = { ...selectedPatient };
// Reset and close
selectedPatient = { InternalPID: null, PatientID: null };
isOpen = false;
}

View File

@ -11,7 +11,7 @@
import ReusableCalendar from "$lib/components/reusable/reusable-calendar.svelte";
import { Spinner } from "$lib/components/ui/spinner/index.js";
import { searchFields } from "../config/patient-config";
import { useSearch } from "$lib/components/composable/useSearch.svelte";
import { useSearch } from "$lib/components/composable/use-search.svelte";
import ReusableEmpty from "$lib/components/reusable/reusable-empty.svelte";
import { Checkbox } from "$lib/components/ui/checkbox/index.js";
@ -26,33 +26,12 @@
isOpen = open;
if (open) {
// Populate existing linked patients when opening
if (Array.isArray(props.formState.form.LinkTo)) {
selectedPatients = [...props.formState.form.LinkTo];
} else {
selectedPatients = [];
}
}
// if (open) {
// // Populate existing linked patients when opening
// if (props.formState.form.LinkTo) {
// // Assuming LinkTo is comma-separated InternalPIDs or array
// const linkTo = props.formState.form.LinkTo;
// if (typeof linkTo === 'string') {
// // Parse comma-separated string to array
// selectedPatients = linkTo.split(',')
// .filter(Boolean)
// .map(id => ({ InternalPID: id.trim() }));
// } else if (Array.isArray(linkTo)) {
// selectedPatients = [...linkTo];
// } else {
// selectedPatients = [];
// }
// } else {
// selectedPatients = [];
// }
// }
}
function togglePatientSelection(patient) {

View File

@ -1,5 +1,4 @@
<script>
import TopbarWrapper from "$lib/components/topbar/topbar-wrapper.svelte";
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-api";
@ -57,7 +56,6 @@
onClick: handleSaveAndOrder
}
];
</script>
<FormPageContainer title="Create Patient" {primaryAction} {secondaryActions} {actions}>

View File

@ -0,0 +1,523 @@
<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-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

@ -1,35 +1,16 @@
<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-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";
import { usePatientForm } from "$lib/components/composable/use-patient-form.svelte";
import PatientFormRenderer from "../reusable/patient-form-renderer.svelte";
import { API } from "$lib/config/api";
import { untrack } from "svelte";
let props = $props();
let searchQuery = $state({});
let uploadErrors = $state({});
let isChecking = $state({});
const actions = [];
const formState = useForm({
let formState = useForm({
schema: patientSchema,
initialForm: patientInitialForm,
defaultErrors: {},
@ -39,12 +20,10 @@
editEndpoint: editPatient,
});
const helpers = usePatientForm(formState, patientSchema);
$effect(() => {
// if (props.masterDetail?.selectedItem?.patient) {
// formState.setForm(props.masterDetail.selectedItem.patient);
// }
const backendData = props.masterDetail?.selectedItem?.patient;
if (!backendData) return;
untrack(() => {
@ -68,7 +47,6 @@
formState.setForm(formData);
// Jalankan fetch options hanya sekali saat inisialisasi data
patientFormFields.forEach(group => {
group.rows.forEach(row => {
row.columns.forEach(col => {
@ -99,16 +77,6 @@
});
});
// $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();
@ -120,404 +88,25 @@
}
}
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,
disabled: helpers.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> -->
<PatientFormRenderer
{formState}
formFields={patientFormFields}
searchQuery={helpers.searchQuery}
uploadErrors={helpers.uploadErrors}
isChecking={helpers.isChecking}
linkToDisplay={helpers.linkToDisplay}
validateIdentifier={helpers.validateIdentifier}
mode="edit"
/>
</FormPageContainer>

View File

@ -5,7 +5,7 @@
import ReusableEmpty from "$lib/components/reusable/reusable-empty.svelte";
import { searchParam } from "$lib/components/patient/api/patient-api";
import ReusableDataTable from "$lib/components/reusable/reusable-data-table.svelte";
import { useSearch } from "$lib/components/composable/useSearch.svelte";
import { useSearch } from "$lib/components/composable/use-search.svelte";
import { searchFields, patientActions } from "../config/patient-config";
let props = $props();

View File

@ -39,7 +39,6 @@
});
async function initializeDefaultValues() {
console.log('object');
for (const group of formFields) {
for (const row of group.rows) {
for (const col of row.columns) {
@ -58,14 +57,22 @@
async function handleDefaultValue(field) {
if (!field.defaultValue || !field.optionsEndpoint) return;
// Fetch options
await formState.fetchOptions(field, formState.form);
// Set default jika form masih kosong
if (!formState.form[field.key]) {
formState.form[field.key] = field.defaultValue;
}
}
let isDeathDateDisabled = $derived(
formState.form.DeathIndicator !== 'Y'
);
$effect(() => {
if (isDeathDateDisabled && formState.form.TimeOfDeath) {
formState.form.TimeOfDeath = "";
}
});
</script>
{#snippet Fieldset({ key, label, required, type, optionsEndpoint, options, validateOn, dependsOn, endpointParamKey })}
@ -134,8 +141,9 @@
}}
/>
{:else if type === "datetime"}
<ReusableCalendarTimepicker
<ReusableCalendarTimepicker
bind:value={formState.form[key]}
disabled={key === "TimeOfDeath" && isDeathDateDisabled}
onValueChange={(val) => {
formState.form[key] = val;
if (validateOn?.includes("input")) {

View File

@ -6,16 +6,9 @@
import { Input } from "$lib/components/ui/input/index.js";
import { getLocalTimeZone, fromDate, today, parseDate } from "@internationalized/date";
import Clock2Icon from "@lucide/svelte/icons/clock-2";
// const id = $props.id();
// let { title, parentFunction, initialValue } = $props();
// let value = $state('');
// let calendarValue = $state(null);
// let timeValue = $state("00:00:00");
// let open = $state(false);
const id = $props.id();
let { title, parentFunction, value = $bindable("") } = $props();
let { title, parentFunction, value = $bindable(""), disabled = false } = $props();
let open = $state(false);
let calendarValue = $state();
let timeValue = $state("00:00:00");
@ -62,39 +55,6 @@
const dt = new Date(val);
return dt.toLocaleString("sv-SE");
}
// function updateDateTime() {
// if (!calendarValue) return;
// const [h, m, s] = timeValue.split(":").map(Number);
// const dt = calendarValue.toDate(getLocalTimeZone());
// dt.setHours(h, m, s);
// value = dt.toISOString();
// parentFunction?.(value);
// }
// function formatDateTime(val) {
// if (!val) return "Select date";
// const dt = new Date(val);
// return dt.toLocaleString("sv-SE");
// }
// $effect(() => {
// if (initialValue) {
// const dt = new Date(initialValue);
// calendarValue = fromDate(dt, getLocalTimeZone());
// timeValue = dt.toLocaleTimeString("sv-SE", {
// hour12: false,
// hour: "2-digit",
// minute: "2-digit",
// second: "2-digit",
// });
// value = initialValue;
// }
// });
</script>
<div class="flex flex-col w-full">
@ -108,6 +68,7 @@
{...props}
variant="outline"
class="w-full justify-between font-normal text-muted-foreground truncate"
disabled={disabled}
>
{formatDateTime(value)}
</Button>
@ -143,54 +104,4 @@
</div>
</Popover.Content>
</Popover.Root>
</div>
<!-- <div class="flex flex-col gap-1.5 w-full">
{#if title}
<Label for="{id}-date">{title}</Label>
{/if}
<Popover.Root bind:open>
<Popover.Trigger id="{id}-date" >
{#snippet child({ props })}
<Button
{...props}
variant="outline"
class="w-full justify-between font-normal text-xs truncate 2xl:text-base px-2"
>
{formatDateTime(value, timeValue)}
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-auto overflow-hidden p-0" align="start">
<Card.Root class="w-fit py-1">
<Card.Content class="px-1">
<Calendar
type="single"
bind:value={calendarValue}
captionLayout="dropdown"
onValueChange={updateDateTime}
maxValue={today(getLocalTimeZone())}
/>
</Card.Content>
<Card.Footer class="flex flex-col gap-6 border-t p-4">
<div class="flex w-full flex-col gap-3">
<Label for="time-from">Time</Label>
<div class="relative flex w-full items-center gap-2">
<Clock2Icon
class="text-muted-foreground pointer-events-none absolute left-2.5 size-4 select-none"
/>
<Input
id="time-from"
type="time"
step="1"
bind:value={timeValue}
oninput={updateDateTime}
class="appearance-none pl-8 [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
/>
</div>
</div>
</Card.Footer>
</Card.Root>
</Popover.Content>
</Popover.Root>
</div> -->
</div>

View File

@ -61,14 +61,14 @@
});
</script>
<div class="h-full flex flex-col relative">
<div class="flex items-center absolute top-[-2.5rem]">
<div class="flex items-center absolute top-[-2rem]">
<Input
placeholder="Filter all columns..."
value={globalFilter}
oninput={(e) => {
globalFilter = e.currentTarget.value;
}}
class="h-8 w-64 text-xs px-2"
class="h-7 w-64 text-xs px-2"
/>
</div>
<div class="rounded-md border h-full flex flex-col">

View File

@ -46,7 +46,7 @@
</div>
</header>
<Separator />
<div class="flex-1 min-h-0 overflow-hidden p-2">
<div class="flex-1 min-h-0 overflow-hidden">
{@render children?.()}
</div>
</Sidebar.Inset>

View File

@ -1,2 +1,4 @@
<h1>Welcome to SvelteKit</h1>
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
<div class="p-2">
<h1>Welcome to SvelteKit</h1>
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
</div>

View File

@ -1,6 +1,6 @@
<script>
import { Separator } from "$lib/components/ui/separator/index.js";
import { useMasterDetail } from "$lib/components/composable/useMasterDetail.svelte";
import { useMasterDetail } from "$lib/components/composable/use-master-detail.svelte";
import { getPatient } from "$lib/components/patient/api/patient-api";
import MasterPage from "$lib/components/patient/page/master-page.svelte";
import ViewPage from "$lib/components/patient/page/view-page.svelte";