mirror of
https://github.com/faiztyanirh/clqms-shadcn-v1.git
synced 2026-04-26 02:46:32 +07:00
334 lines
15 KiB
Svelte
334 lines
15 KiB
Svelte
<script>
|
|
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 * 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";
|
|
|
|
let {
|
|
formState,
|
|
formFields,
|
|
uploadErrors,
|
|
isChecking,
|
|
linkToDisplay,
|
|
validateIdentifier,
|
|
validateFieldAsync,
|
|
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() {
|
|
console.log('object');
|
|
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;
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
</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 === "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);
|
|
}
|
|
}}
|
|
/>
|
|
{: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]}
|
|
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 },
|
|
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 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}
|
|
<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">
|
|
{#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> |