continue dict test; add feature calculation val

This commit is contained in:
faiztyanirh 2026-02-21 20:34:51 +07:00
parent 10b6e5bda3
commit ea43e44809
11 changed files with 382 additions and 35 deletions

View File

@ -2,7 +2,7 @@ import { useFormState } from "./use-form-state.svelte";
import { useFormOptions } from "./use-form-option.svelte";
import { useFormValidation } from "./use-form-validation.svelte";
export function useForm({schema, initialForm, defaultErrors, mode, modeOpt, saveEndpoint, editEndpoint}) {
export function useForm({schema, initialForm, defaultErrors = {}, mode = 'create', modeOpt = 'default', saveEndpoint = null, editEndpoint = null}) {
const state = useFormState(initialForm);
const val = useFormValidation(schema, state.form, defaultErrors, mode);
const options = useFormOptions(modeOpt);

View File

@ -3,7 +3,30 @@ import EraserIcon from "@lucide/svelte/icons/eraser";
import { z } from "zod";
import { cleanEmptyStrings } from "$lib/utils/cleanEmptyStrings";
export const testSchema = z.object({});
export const testSchema = z.object({
TestSiteCode: z.string().min(1, "Required"),
TestSiteName: z.string().min(1, "Required"),
});
export const testCalSchema = z.object({
FormulaInput: z.array(z.string()),
FormulaCode: z.string()
}).refine(
(data) => {
if (data.FormulaInput.length === 0) return true;
function hasExactKeyword(input, keyword) {
const regex = new RegExp(`\\b${keyword}\\b`, 'i');
return regex.test(input);
}
return data.FormulaInput.every((v) => hasExactKeyword(data.FormulaCode, v));
},
{
message: 'Formula must contain all selected input parameters',
path: ['FormulaCode']
}
);
export const testInitialForm = {
TestSiteID: "",
@ -62,7 +85,10 @@ export const refNumInitialForm = {
Notes: "",
}
export const testDefaultErrors = {};
export const testDefaultErrors = {
TestSiteCode: "Required",
TestSiteName: "Required",
};
export const testCalDefaultErrors = {};
@ -106,7 +132,7 @@ export const testFormFields = [
{
key: "TestType",
label: "Test Type",
required: true,
required: false,
type: "select",
optionsEndpoint: `${API.BASE_URL}${API.VALUESET}/test_type`,
}
@ -363,6 +389,7 @@ export const testCalFormFields = [
label: "Formula",
required: false,
type: "textarea",
validateOn: ["input"]
}
]
},
@ -518,4 +545,23 @@ export function getTestFormActions(handlers) {
onClick: handlers.clearForm,
},
];
}
export function buildTestPayload({
mainForm,
calForm,
testType
}) {
let payload = {
...mainForm
};
if (testType === 'CALC') {
payload = {
...payload,
...calForm
};
}
return cleanEmptyStrings(payload);
}

View File

@ -6,10 +6,11 @@
import ReusableAlertDialog from "$lib/components/reusable/reusable-alert-dialog.svelte";
import * as Tabs from "$lib/components/ui/tabs/index.js";
import { useForm } from "$lib/components/composable/use-form.svelte";
import { testCalInitialForm, testCalDefaultErrors, testCalFormFields, refNumInitialForm, refNumFormFields } from "$lib/components/dictionary/test/config/test-form-config";
import { buildTestPayload, testCalSchema, testCalInitialForm, testCalDefaultErrors, testCalFormFields, refNumInitialForm, refNumFormFields } from "$lib/components/dictionary/test/config/test-form-config";
import ReusableEmpty from "$lib/components/reusable/reusable-empty.svelte";
import RefNum from "./reference/ref-num.svelte";
import RefTxt from "./reference/ref-txt.svelte";
import RefNum from "./tabs/ref-num.svelte";
import RefTxt from "./tabs/ref-txt.svelte";
import Calculation from "./tabs/calculation.svelte";
let props = $props();
@ -20,11 +21,6 @@
const calFormState = useForm({
schema: null,
initialForm: testCalInitialForm,
defaultErrors: {},
mode: 'create',
modeOpt: 'default',
saveEndpoint: null,
editEndpoint: null,
});
const refNumState = useForm({
@ -50,10 +46,19 @@
let showConfirm = $state(false);
async function handleSave() {
const result = await formState.save(masterDetail.mode);
const mainForm = masterDetail.formState.form;
const calForm = calFormState.form;
toast('Test Created!');
masterDetail?.exitForm(true);
const payload = buildTestPayload({
mainForm,
calForm,
testType: mainForm.TestType
});
console.log(payload);
// const result = await formState.save(masterDetail.mode);
// toast('Test Created!');
// masterDetail?.exitForm(true);
}
const primaryAction = $derived({
@ -134,6 +139,7 @@
activeTab = availableTabs[0];
}
});
</script>
<FormPageContainer title="Create Test" {primaryAction} {secondaryActions} {actions}>
@ -165,10 +171,7 @@
/>
</Tabs.Content>
<Tabs.Content value="calculation">
<DictionaryFormRenderer
formState={calFormState}
formFields={testCalFormFields}
/>
<Calculation {calFormState}/>
</Tabs.Content>
<Tabs.Content value="group">
group

View File

@ -0,0 +1,158 @@
<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";
let props = $props();
const formState = props.calFormState;
let options = $state([]);
let isLoading = $state(false);
let errors = $state({});
function hasExactKeyword(input, keyword) {
const regex = new RegExp(`\\b${keyword}\\b`, 'i');
return regex.test(input);
}
// 🔹 FETCH OPTIONS
async function fetchTests() {
isLoading = true;
try {
const res = await fetch(`${API.BASE_URL}${API.TEST}`);
const data = await res.json();
console.log(data);
options = data.data.map((item) => ({
value: item.TestSiteCode,
label: `${item.TestSiteCode} - ${item.TestSiteName}`
}));
} catch (err) {
console.error('Failed to fetch tests', err);
} finally {
isLoading = false;
}
}
$effect(() => {
fetchTests();
});
// 🔹 VALIDATION
$effect(() => {
const result = testCalSchema.safeParse(formState.form);
if (!result.success) {
const fieldErrors = {};
for (const issue of result.error.issues) {
fieldErrors[issue.path[0]] = issue.message;
}
errors = fieldErrors;
} else {
errors = {};
}
});
// 🔹 Badge status
const inputStatus = $derived.by(() => {
const inputs = formState.form.FormulaInput || [];
const code = formState.form.FormulaCode || '';
return inputs.map((v) => ({
value: v,
done: hasExactKeyword(code, v)
}));
});
function unselectAll() {
formState.form.FormulaInput = [];
errors = {};
}
</script>
<div class="p-2 space-y-6">
<div class="grid grid-cols-1 space-y-2 gap-6 md:gap-4">
<div class="flex w-full flex-col gap-1.5">
<div class="flex justify-between items-center w-full">
<Label>Formula Input</Label>
</div>
<div class="relative flex flex-col items-center w-full">
<Select.Root
type="multiple"
bind:value={formState.form.FormulaInput}
>
<Select.Trigger class="w-full">
{formState.form.FormulaInput?.length
? options
.filter(o => formState.form.FormulaInput.includes(o.value))
.map(o => o.label)
.join(', ')
: 'Select parameters'}
</Select.Trigger>
<Select.Content>
<Select.Group>
{#if isLoading}
<div class="p-2 text-sm text-muted-foreground">
Loading...
</div>
{:else}
{#if formState.form.FormulaInput.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}
{#each options as opt (opt.value)}
<Select.Item value={opt.value} label={opt.label}>
{opt.label}
</Select.Item>
{/each}
{/if}
</Select.Group>
</Select.Content>
</Select.Root>
</div>
</div>
<div class="flex w-full flex-col gap-1.5">
<div class="flex justify-between items-center w-full">
<Label>Formula Code</Label>
</div>
<div class="relative flex flex-col items-center w-full">
<Textarea
class="border rounded p-2"
bind:value={formState.form.FormulaCode}
/>
</div>
</div>
</div>
{#if errors.FormulaCode}
<div class="flex flex-col gap-2 text-sm text-destructive">
<span>{errors.FormulaCode}</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}
</div>

View File

@ -0,0 +1,112 @@
<script>
import * as Select from '$lib/components/ui/select/index.js';
import { fruits } from '$lib/components/multiselect/multiselect-form-config';
import { Input } from '$lib/components/ui/input/index.js';
import { z } from 'zod';
import { Badge } from '$lib/components/ui/badge/index.js';
let value = $state([]);
let inputValue = $state('');
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: value, input: inputValue });
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(
value.length === 0
? 'Select a fruit'
: value.map((v) => fruits.find((f) => f.value === v)?.value).join(', ')
);
const hasErrors = $derived(Object.keys(errors).length > 0);
const inputStatus = $derived(
value
.filter((v) => v)
.map((v) => ({
value: v,
label: fruits.find((f) => f.value === v)?.label,
done: hasExactKeyword(inputValue, v)
}))
);
function unselectAll() {
value = [];
}
</script>
<div class="flex flex-col gap-2">
<Select.Root type="multiple" name="favoriteFruit" bind:value>
<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 value.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>
<Input bind:value={inputValue} />
{#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>

View File

@ -24,17 +24,6 @@
let showConfirm = $state(false);
// function handleExit() {
// const ok = masterDetail.exitForm();
// if (!ok) {
// showConfirm = true;
// }
// }
// function confirmDiscard() {
// masterDetail.exitForm(true);
// }
async function handleSave() {
const payload = buildPayload(formState.form);

View File

@ -10,6 +10,7 @@
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,
@ -61,6 +62,11 @@
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 })}
@ -120,7 +126,7 @@
}}
/>
{:else if type === "textarea"}
<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")) {
@ -133,7 +139,7 @@
}
}}
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)}
@ -232,6 +238,9 @@
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) {
@ -280,6 +289,14 @@
{/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
@ -372,12 +389,34 @@
/>
{/if}
<div class="absolute top-8 min-h-[1rem] w-full">
{#if formState.errors[key]}
<div
class={`absolute min-h-[1rem] w-full ${
key === 'FormulaCode' ? 'top-20' : 'top-8'
}`}
>
<!-- {#if formState.errors[key]}
<span class="text-destructive text-sm leading-none">
{formState.errors[key]}
</span>
{/if} -->
{#if key === 'FormulaCode' && formState.errors[key]}
<div class="flex items-center gap-2 text-sm text-destructive">
<span>{formState.errors[key]}:</span>
<div class="flex gap-1">
{#each formState.form.FormulaInput || [] as item}
{@const hasItem = hasExactKeyword(formState.form.FormulaCode, item)}
<Badge variant={hasItem ? 'default' : 'destructive'}>
{item}
</Badge>
{/each}
</div>
</div>
{:else if formState.errors[key]}
<span class="text-destructive text-sm leading-none">
{formState.errors[key]}
</span>
{/if}
</div>
</div>
</div>

View File

@ -6,7 +6,7 @@
import ViewPage from "$lib/components/dictionary/test/page/view-page.svelte";
import CreatePage from "$lib/components/dictionary/test/page/create-page.svelte";
import EditPage from "$lib/components/dictionary/test/page/edit-page.svelte";
import { testSchema, testInitialForm, testDefaultErrors, testFormFields, getTestFormActions } from "$lib/components/dictionary/test/config/test-form-config";
import { testSchema, testInitialForm, testDefaultErrors, testFormFields, getTestFormActions, testCalDefaultErrors } from "$lib/components/dictionary/test/config/test-form-config";
const masterDetail = useMasterDetail({
onSelect: async (row) => {