mirror of
https://github.com/faiztyanirh/clqms-shadcn-v1.git
synced 2026-04-25 10:32:06 +07:00
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
362 lines
16 KiB
Svelte
362 lines
16 KiB
Svelte
<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 ReusableCalendarTimepicker from "$lib/components/reusable/reusable-calendar-timepicker.svelte";
|
|
import CustodianModal from "$lib/components/patient/list/modal/custodian-modal.svelte";
|
|
import LinktoModal from "$lib/components/patient/list/modal/linkto-modal.svelte";
|
|
import ReusableUpload from "$lib/components/reusable/reusable-upload.svelte";
|
|
import CheckIcon from "@lucide/svelte/icons/check";
|
|
import XIcon from "@lucide/svelte/icons/x";
|
|
|
|
let {
|
|
formState,
|
|
formFields,
|
|
uploadErrors,
|
|
isChecking = {},
|
|
linkToDisplay,
|
|
validateIdentifier,
|
|
validateFieldAsync,
|
|
originalData = null,
|
|
mode = 'create'
|
|
} = $props();
|
|
|
|
let searchQuery = $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())
|
|
);
|
|
}
|
|
|
|
$effect(() => {
|
|
initializeDefaultValues();
|
|
});
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
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, valueKey, labelKey })}
|
|
<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]);
|
|
}
|
|
}}
|
|
/>
|
|
{: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);
|
|
}
|
|
}}
|
|
/>
|
|
{: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[key]}
|
|
disabled={key === "TimeOfDeath" && isDeathDateDisabled}
|
|
onValueChange={(val) => {
|
|
formState.form[key] = val;
|
|
if (validateOn?.includes("input")) {
|
|
formState.validateField(key, formState.form[key], false);
|
|
}
|
|
}}
|
|
/>
|
|
{: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]}
|
|
></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, 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}>
|
|
{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;
|
|
formState.form.PatIdt_Identifier = ''
|
|
formState.errors.PatIdt_Identifier = null
|
|
}}
|
|
>
|
|
<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.Custodian_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 attachments={formState.form[key]} 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 if type === "toggle"}
|
|
<div class="flex items-center w-full">
|
|
<Toggle
|
|
aria-label="Toggle discharge"
|
|
variant="outline"
|
|
class="w-full transition-all data-[state=on]:text-primary"
|
|
bind:pressed={formState.form.isDischarge}
|
|
>
|
|
|
|
{#if formState.form.isDischarge}
|
|
<XIcon class="mr-2 h-4 w-4" />
|
|
{:else}
|
|
<CheckIcon class="mr-2 h-4 w-4" />
|
|
{/if}
|
|
{formState.form.isDischarge ? "Discharged" : "Active"}
|
|
</Toggle>
|
|
</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}
|
|
|
|
<!-- <div class="flex-1 min-h-0 overflow-y-auto p-2 space-y-6"> -->
|
|
<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}
|
|
<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> |