mirror of
https://github.com/faiztyanirh/clqms-shadcn-v1.git
synced 2026-04-23 17:49:28 +07:00
487 lines
23 KiB
Svelte
487 lines
23 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 { 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";
|
|
|
|
let {
|
|
formState,
|
|
formFields,
|
|
mode = 'create',
|
|
disabledResultTypes = [],
|
|
disabledReferenceTypes = [],
|
|
disabledSign = false,
|
|
joinFields = $bindable(),
|
|
hiddenFields,
|
|
handleTestTypeChange,
|
|
handleResultTypeChange,
|
|
handleRefTypeChange,
|
|
} = $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())
|
|
);
|
|
}
|
|
|
|
$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;
|
|
}
|
|
}
|
|
|
|
// function hasExactKeyword(input, keyword) {
|
|
// const regex = new RegExp(`\\b${keyword}\\b`, 'i');
|
|
// return regex.test(input);
|
|
// }
|
|
</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}
|
|
<!-- {#if required || dynamicRequiredFields.includes(key)}
|
|
<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);
|
|
}
|
|
}}
|
|
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 selectedValues = Array.isArray(formState.form[key]) ? formState.form[key] : []}
|
|
{@const selectedOptions = selectedValues
|
|
.map(val => formState.selectOptions?.[key]?.find(opt => opt.value === val))
|
|
.filter(Boolean)}
|
|
|
|
<Select.Root type="multiple" bind:value={formState.form[key]}
|
|
onValueChange={(vals) => {
|
|
formState.form[key] = vals;
|
|
if (validateOn?.includes("input")) {
|
|
formState.validateField?.(key, formState.form[key], false);
|
|
}
|
|
if (key === 'FormulaInput') {
|
|
formState.validateField?.('FormulaCode', formState.form['FormulaCode'], false);
|
|
}
|
|
}}
|
|
onOpenChange={(open) => {
|
|
if (open && optionsEndpoint) {
|
|
formState.fetchOptions?.(
|
|
{ key, optionsEndpoint, dependsOn, endpointParamKey, valueKey, labelKey },
|
|
formState.form
|
|
);
|
|
}
|
|
}}
|
|
>
|
|
<Select.Trigger class="w-full min-h-[42px]">
|
|
{#if selectedOptions.length === 0}
|
|
<span class="text-muted-foreground">Choose</span>
|
|
{:else}
|
|
<div class="flex flex-wrap gap-1">
|
|
{#each selectedOptions as option}
|
|
<Badge variant="secondary" class="text-xs">
|
|
{option.label}
|
|
</Badge>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</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>
|
|
{#if selectedValues.length > 0}
|
|
<button
|
|
class="w-full px-2 py-1.5 text-left text-sm hover:bg-accent hover:text-accent-foreground"
|
|
onclick={() => formState.form[key] = []}
|
|
>
|
|
Unselect All
|
|
</button>
|
|
{/if}
|
|
</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]}
|
|
/>
|
|
<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}
|
|
{#each formState.selectOptions?.[key] || [] as option}
|
|
<DropdownMenu.Item
|
|
onclick={() => {
|
|
formState.form[key] = option.value;
|
|
dropdownOpen[key] = false;
|
|
}}
|
|
>
|
|
{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}/>
|
|
<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}/>
|
|
<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}/>
|
|
<InputGroup.Addon align="inline-end">
|
|
<InputGroup.Text>Day</InputGroup.Text>
|
|
</InputGroup.Addon>
|
|
</InputGroup.Root>
|
|
</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 === 'FormulaCode' ? 'top-20' : 'top-8'
|
|
}`}
|
|
>
|
|
<!-- {#if formState.errors[key] || dynamicRequiredFields.includes(key)}
|
|
<span class="text-destructive text-sm leading-none">
|
|
{formState.errors[key]}
|
|
</span>
|
|
{/if} -->
|
|
{#if formState.errors[key]}
|
|
<span class="text-destructive text-sm leading-none">
|
|
{formState.errors[key]}
|
|
</span>
|
|
<!-- {:else if dynamicRequiredFields.includes(key)}
|
|
<span class="text-destructive text-sm leading-none">
|
|
Required
|
|
</span> -->
|
|
{/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>
|
|
|
|
<!-- <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 && row.columns[0].fullWidth !== false}
|
|
class:md:grid-cols-2={row.columns.length === 2 || (row.columns.length === 1 && row.columns[0].fullWidth === false)}
|
|
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 && col.columns[0].fullWidth !== false}
|
|
class:md:grid-cols-2={col.columns.length === 2 || (col.columns.length === 1 && col.columns[0].fullWidth === false)}
|
|
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> --> |