diff --git a/src/lib/components/composable/use-form-validation.svelte.js b/src/lib/components/composable/use-form-validation.svelte.js index 5a36552..99b1e54 100644 --- a/src/lib/components/composable/use-form-validation.svelte.js +++ b/src/lib/components/composable/use-form-validation.svelte.js @@ -60,5 +60,20 @@ export function useFormValidation(schema, form, defaultErrors, valMode) { Object.assign(errors, defaultErrors); } - return { errors, validateField, resetErrors } + function validateAll() { + const result = schema.safeParse(form); + + Object.assign(errors, defaultErrors); + + if (!result.success) { + for (const issue of result.error.issues) { + const field = issue.path[0]; + if (field && field in errors) { + errors[field] = issue.message; + } + } + } + } + + return { errors, validateField, resetErrors, validateAll } } \ No newline at end of file diff --git a/src/lib/components/composable/use-form.svelte.js b/src/lib/components/composable/use-form.svelte.js index b767c4e..6cb1a64 100644 --- a/src/lib/components/composable/use-form.svelte.js +++ b/src/lib/components/composable/use-form.svelte.js @@ -39,7 +39,7 @@ export function useForm({schema, initialForm, defaultErrors = {}, mode = 'create return { ...state, //form, resetForm, setForm, isSaving - ...val, //errors, validateField, resetErrors + ...val, //errors, validateField, resetErrors, validateAll ...options, //selectOptions, loadingOptions, fetchOptions, lastFetched, clearDependentOptions save, reset, diff --git a/src/lib/components/dictionary/test/config/test-form-config.js b/src/lib/components/dictionary/test/config/test-form-config.js index d98029a..00d4d9e 100644 --- a/src/lib/components/dictionary/test/config/test-form-config.js +++ b/src/lib/components/dictionary/test/config/test-form-config.js @@ -103,13 +103,13 @@ export const testGroupSchema = z export const refNumSchema = z .object({ - AgeStart: z.string().optional(), - AgeEnd: z.string().optional(), - Low: z.string().optional(), - High: z.string().optional(), - LowSign: z.string().optional(), - HighSign: z.string().optional(), - NumRefType: z.string().optional() + AgeStart: z.coerce.string().optional(), + AgeEnd: z.coerce.string().optional(), + Low: z.coerce.string().optional(), + High: z.coerce.string().optional(), + LowSign: z.string().optional(), + HighSign: z.string().optional(), + NumRefType: z.string().optional() }) .superRefine((data, ctx) => { const start = toDays(data.AgeStart); diff --git a/src/lib/components/dictionary/test/page/edit-page.svelte b/src/lib/components/dictionary/test/page/edit-page.svelte index 77cd5bd..2ed9d8d 100644 --- a/src/lib/components/dictionary/test/page/edit-page.svelte +++ b/src/lib/components/dictionary/test/page/edit-page.svelte @@ -37,6 +37,7 @@ import Group from './tabs/group.svelte'; import { API } from "$lib/config/api"; import { untrack } from "svelte"; + import { buildAgeText, daysToAge } from '$lib/utils/ageUtils'; let props = $props(); @@ -128,15 +129,24 @@ let showConfirm = $state(false); async function handleEdit() { - const result = await formState.save(masterDetail.mode); - - if (result.status === 'success') { - console.log('Test updated successfully'); - toast('Test Updated!'); - masterDetail.exitForm(true); - } else { - console.error('Failed to update test:', result.message); - } + const mainForm = masterDetail.formState.form; + const testType = mainForm.TestType; + const cleanMapData = mapData.map(({ options, ...rest }) => rest); + + const payload = buildTestPayload({ + mainForm, + activeFormStates, + testType: testType, + refNumData: refNumData, + refTxtData: refTxtData, + mapData: cleanMapData, + }); + console.log(payload); + + const result = await formState.save(masterDetail.mode, payload); + + toast('Test Updated!'); + masterDetail?.exitForm(true); } const primaryAction = $derived({ @@ -318,8 +328,13 @@ $effect(() => { const mainForm = formState.form; - if (mainForm.refnum && Array.isArray(mainForm.refnum)) { - refNumData = mainForm.refnum + if (mainForm.refnum && Array.isArray(mainForm.refnum)) { + refNumData = mainForm.refnum.map((row, index) => ({ + id: row.id ?? index + 1, + ...row, + AgeStart: typeof row.AgeStart === 'number' ? buildAgeText(daysToAge(row.AgeStart)) : row.AgeStart, + AgeEnd: typeof row.AgeEnd === 'number' ? buildAgeText(daysToAge(row.AgeEnd)) : row.AgeEnd, + })); } }) @@ -354,6 +369,69 @@ formState.form[key] = ''; } }); + + function handleTestTypeChange(value) { + formState.form.TestType = value; + + formState.form.ResultType = ''; + formState.errors.ResultType = null; + formState.form.RefType = ''; + formState.errors.RefType = null; + + calFormState.reset(); + refNumState.reset(); + refTxtState.reset(); + + resetRefNum?.(); + resetRefTxt?.(); + } + + function handleResultTypeChange(value) { + formState.form.ResultType = value; + + formState.form.RefType = ''; + formState.errors.RefType = null; + + calFormState.reset(); + refNumState.reset(); + refTxtState.reset(); + + resetRefNum?.(); + resetRefTxt?.(); + + let newRefType = ''; + if (value === 'TEXT') { + newRefType = 'TEXT'; + } + if (value === 'VSET') { + newRefType = 'VSET'; + } + if (value === 'NORES') { + newRefType = 'NOREF'; + } + + if (newRefType) { + formState.form.RefType = newRefType; + handleRefTypeChange(newRefType); + } + } + + function handleRefTypeChange(value) { + formState.form.RefType = value; + + refNumState.reset(); + refTxtState.reset(); + + resetRefNum?.(); + resetRefTxt?.(); + + if (value === 'RANGE' || value === 'THOLD') { + refNumState.form.NumRefType = value; + } + if (value === 'TEXT' || value === 'VSET') { + refTxtState.form.TxtRefType = value; + } + } @@ -380,7 +458,12 @@ {formState} formFields={formFields} mode="edit" - {hiddenFields} + {disabledResultTypes} + {disabledReferenceTypes} + {hiddenFields} + {handleTestTypeChange} + {handleResultTypeChange} + {handleRefTypeChange} /> diff --git a/src/lib/components/dictionary/test/page/tabs/ref-num.svelte b/src/lib/components/dictionary/test/page/tabs/ref-num.svelte index d5e710d..3275775 100644 --- a/src/lib/components/dictionary/test/page/tabs/ref-num.svelte +++ b/src/lib/components/dictionary/test/page/tabs/ref-num.svelte @@ -4,7 +4,7 @@ import * as Table from '$lib/components/ui/table/index.js'; import { Button } from '$lib/components/ui/button/index.js'; import { Badge } from '$lib/components/ui/badge/index.js'; - import { buildAgeText } from '$lib/utils/ageUtils'; + import { buildAgeText, daysToAge } from '$lib/utils/ageUtils'; import PencilIcon from '@lucide/svelte/icons/pencil'; import Trash2Icon from '@lucide/svelte/icons/trash-2'; import { untrack } from 'svelte'; @@ -40,8 +40,8 @@ if (refType === 'THOLD') return false; return false; - }) - + }); +// $inspect(props.refNumState.form.NumRefType) function snapshotForm() { const f = props.refNumState.form; return { @@ -70,7 +70,7 @@ if (currentRefType) { props.refNumState.form.NumRefType = currentRefType; } - + joinFields = { AgeStart: { DD: '', MM: '', YY: '' }, AgeEnd: { DD: '', MM: '', YY: '' } @@ -78,25 +78,148 @@ editingId = null; } + function validateTholdContinuity(excludeId = null) { + const form = props.refNumState.form; + if (form.NumRefType !== 'THOLD') return null; + + // Ambil semua record THOLD dalam group yang sama, exclude row yang sedang diedit + const peers = tempNumeric.filter(row => + row.id !== excludeId && + row.NumRefType === 'THOLD' && + row.SpcType === form.SpcType && + row.Sex === form.Sex && + row.RangeType === form.RangeType + ); + + console.log('peers:', JSON.stringify(peers)); + console.log('newLow:', form.Low, 'newLowSign:', form.LowSign); + console.log('newHigh:', form.High, 'newHighSign:', form.HighSign); + + if (peers.length === 0) return null; + + const newLow = form.Low !== '' && form.Low != null ? Number(form.Low) : null; + const newHigh = form.High !== '' && form.High != null ? Number(form.High) : null; + const newLowSign = form.LowSign ?? ''; + const newHighSign = form.HighSign ?? ''; + + // Cari row yang high-nya paling dekat di bawah newLow + const closestBelow = peers + .filter(r => r.High !== '' && r.High != null) + .map(r => ({ ...r, highNum: Number(r.High) })) + .filter(r => r.highNum <= newLow) + .sort((a, b) => b.highNum - a.highNum)[0]; + + if (closestBelow) { + const prevHigh = closestBelow.highNum; + const prevHighSign = closestBelow.HighSign ?? ''; + + if (prevHigh !== newLow) { + // Ada celah + return { field: 'Low', message: `Gap between intervals. Previous interval ends at ${prevHigh}, new interval starts at ${newLow}` }; + } + } + + + + for (const row of peers) { + const rowHigh = row.High !== '' && row.High != null ? Number(row.High) : null; + const rowLow = row.Low !== '' && row.Low != null ? Number(row.Low) : null; + const rowHighSign = row.HighSign ?? ''; + const rowLowSign = row.LowSign ?? ''; + + console.log('rowHigh:', rowHigh, typeof rowHigh); + console.log('newLow:', newLow, typeof newLow); + console.log('equal?:', rowHigh === newLow); + + // No. 4 & 6 — tanpa celah & tanpa sign kontradiktif + // Cek apakah interval baru harus dimulai tepat setelah interval sebelumnya berakhir + if (rowHigh != null && newLow != null && rowHigh === newLow) { + console.log('boundary check:', rowHighSign, newLowSign); + + // rowHighSign <= 200, newLowSign harus > 200 + // rowHighSign < 200, newLowSign harus >= 200 + const prevEndInclusive = rowHighSign === '<='; + const newStartInclusive = newLowSign === '>='; + console.log('prevEndInclusive:', prevEndInclusive, 'newStartInclusive:', newStartInclusive); + + if (prevEndInclusive && newStartInclusive) { + return { field: 'LowSign', message: 'Sign contradicts previous interval (overlap at boundary). Use > instead of >=' }; + } + if (!prevEndInclusive && !newStartInclusive) { + return { field: 'LowSign', message: 'Gap between intervals. Use >= instead of >' }; + } + } + + if (rowLow != null && newHigh != null && rowLow === newHigh) { + const prevStartInclusive = rowLowSign === '>='; + const newEndInclusive = newHighSign === '<='; + + if (prevStartInclusive && newEndInclusive) { + return { field: 'HighSign', message: 'Sign contradicts next interval (overlap at boundary). Use < instead of <=' }; + } + if (!prevStartInclusive && !newEndInclusive) { + return { field: 'HighSign', message: 'Gap between intervals. Use <= instead of <' }; + } + } + + // No. 5 — overlap: cek apakah ada nilai yang bisa memenuhi kedua interval + // Contoh: row adalah "< 100", new adalah "> 90" → overlap di 91–99 + if (rowHigh != null && newLow != null) { + const prevEnd = rowHigh; + const newStart = newLow; + + const prevInclusive = rowHighSign === '<='; + const newInclusive = newLowSign === '>='; + + const isOverlap = + newStart < prevEnd || + (newStart === prevEnd && prevInclusive && newInclusive); + + if (isOverlap) { + return { field: 'Low', message: 'This interval overlaps with an existing interval' }; + } + } + + if (rowLow != null && newHigh != null) { + const prevStart = rowLow; + const newEnd = newHigh; + + const prevInclusive = rowLowSign === '>='; + const newInclusive = newHighSign === '<='; + + const isOverlap = + newEnd > prevStart || + (newEnd === prevStart && prevInclusive && newInclusive); + + if (isOverlap) { + return { field: 'High', message: 'This interval overlaps with an existing interval' }; + } + } + + + } + + return null; + } +// $inspect(tempNumeric) function handleInsert() { - // console.log(props.refNumState.form); - // const low = Number(props.refNumState.form.Low); - // const high = Number(props.refNumState.form.High); const newStart = toDays(props.refNumState.form.AgeStart); const newEnd = toDays(props.refNumState.form.AgeEnd); - // const row = { id: ++idCounter, ...snapshotForm() }; - // tempNumeric = [...tempNumeric, row]; - // resetForm(); const isOverlap = tempNumeric.some((row) => { + if (row.SpcType !== props.refNumState.form.SpcType) return false; + if (row.Sex !== props.refNumState.form.Sex) return false; + if (row.NumRefType !== props.refNumState.form.NumRefType) return false; + if (row.RangeType !== props.refNumState.form.RangeType) return false; + + if (row.id === editingId) return false; + const existingStart = toDays(row.AgeStart); const existingEnd = toDays(row.AgeEnd); if (existingStart == null || existingEnd == null) return false; - if (row.RangeType !== props.refNumState.form.RangeType) return false; - - return !(newEnd < existingStart || newStart > existingEnd); + return (newStart <= existingEnd && newEnd >= existingStart); }); if (isOverlap) { @@ -104,6 +227,12 @@ return; } + const tholdError = validateTholdContinuity(); + if (tholdError) { + props.refNumState.errors[tholdError.field] = tholdError.message; + return; + } + const row = { id: ++idCounter, ...snapshotForm() @@ -113,33 +242,37 @@ resetForm(); } -$inspect(props.refNumState.form) + function handleEdit(row) { editingId = row.id; - const f = props.refNumState.form; - f.SpcType = row.SpcType; - f.Sex = row.Sex; - f.AgeStart = row.AgeStart; - f.AgeEnd = row.AgeEnd; - f.NumRefType = row.NumRefType; - f.RangeType = row.RangeType; - f.LowSign = row.LowSign; - f.Low = row.Low; - f.HighSign = row.HighSign; - f.High = row.High; - f.Display = row.Display; - f.Flag = row.Flag; - f.Interpretation = row.Interpretation; - f.Notes = row.Notes; - for (const key of ['AgeStart', 'AgeEnd']) { const val = row[key] ?? ''; - const match = val.match(/(\d+)Y\s*(\d+)M\s*(\d+)D/); - joinFields[key] = match - ? { YY: match[1], MM: match[2], DD: match[3] } - : { DD: '', MM: '', YY: '' }; + if (typeof val === 'number') { + joinFields[key] = daysToAge(val); + } else { + const match = val.match(/(\d+)Y\s*(\d+)M\s*(\d+)D/); + joinFields[key] = match + ? { YY: match[1], MM: match[2], DD: match[3] } + : { DD: '', MM: '', YY: '' }; + } } + + const f = props.refNumState.form; + f.SpcType = row.SpcType ?? ''; + f.Sex = row.Sex ?? ''; + f.AgeStart = row.AgeStart ?? ''; + f.AgeEnd = row.AgeEnd ?? ''; + f.NumRefType = row.NumRefType ?? ''; + f.RangeType = row.RangeType ?? ''; + f.LowSign = row.LowSign ?? ''; + f.Low = row.Low ?? ''; + f.HighSign = row.HighSign ?? ''; + f.High = row.High ?? ''; + f.Display = row.Display ?? ''; + f.Flag = row.Flag ?? ''; + f.Interpretation = row.Interpretation ?? ''; + f.Notes = row.Notes ?? ''; } function handleUpdate() { @@ -183,6 +316,24 @@ $inspect(props.refNumState.form) for (const key of ['AgeStart', 'AgeEnd']) { props.refNumState.form[key] = buildAgeText(joinFields[key]); } + untrack(() => { + props.refNumState.validateAll?.(); + }); + }); + + $effect(() => { + const currentEditingId = editingId; + if (currentEditingId === null) return; + + untrack(() => { + for (const key of ['AgeStart', 'AgeEnd']) { + const val = props.refNumState.form[key] ?? ''; + const match = val.match(/(\d+)Y\s*(\d+)M\s*(\d+)D/); + if (match) { + joinFields[key] = { YY: match[1], MM: match[2], DD: match[3] }; + } + } + }); }); $effect(() => { @@ -216,6 +367,17 @@ $inspect(props.refNumState.form) props.refNumState.form.HighSign = ''; } }); + + $effect(() => { + const maxId = tempNumeric.reduce((max, row) => { + const rowId = typeof row.id === 'number' ? row.id : 0; + return rowId > max ? rowId : max; + }, 0); + + if (maxId > idCounter) { + idCounter = maxId; + } + });
@@ -277,7 +439,7 @@ $inspect(props.refNumState.form) - {/if} - + + +
+ {#if row.Low && row.High} + {row.LowSign} {row.Low} – {row.HighSign} {row.High} + {:else if row.Low} + {row.LowSign} {row.Low} + {:else if row.High} + {row.HighSign} {row.High} + {:else} + - + {/if} +
+ + {numRefTypeBadge(row.NumRefType)} +
- {row.NumRefType === "RANGE" ? "AUTO" : row.Flag} + {row.NumRefType === 'RANGE' ? 'AUTO' : row.Flag} {row.Interpretation} {row.Notes} diff --git a/src/lib/components/reusable/form/dictionary-form-renderer.svelte b/src/lib/components/reusable/form/dictionary-form-renderer.svelte index ade28a6..dc60dec 100644 --- a/src/lib/components/reusable/form/dictionary-form-renderer.svelte +++ b/src/lib/components/reusable/form/dictionary-form-renderer.svelte @@ -394,10 +394,12 @@ bind:value={formState.form[txtKey]} oninput={() => { if (validateOn?.includes('input')) { - formState.validateField('Low', formState.form[txtKey]); - formState.validateField('LowSign'); - formState.validateField('High', formState.form[txtKey]); - formState.validateField('HighSign'); + formState.validateField(txtKey, formState.form[txtKey]); + formState.validateField(key); + // formState.validateField('Low', formState.form[txtKey]); + // formState.validateField('LowSign'); + // formState.validateField('High', formState.form[txtKey]); + // formState.validateField('HighSign'); } }} /> diff --git a/src/lib/utils/ageUtils.js b/src/lib/utils/ageUtils.js index fa51087..c8945d5 100644 --- a/src/lib/utils/ageUtils.js +++ b/src/lib/utils/ageUtils.js @@ -33,20 +33,6 @@ export function daysToAge(totalDays) { return { YY, MM, DD }; } -// export function toDays(ageString) { -// if (!ageString || typeof ageString !== 'string') return null; - -// const match = ageString.match(/(\d+)\s*Y?\s*(\d+)\s*M?\s*(\d+)\s*D?/i); - -// if (!match) return null; - -// const YY = parseInt(match[1]) || 0; -// const MM = parseInt(match[2]) || 0; -// const DD = parseInt(match[3]) || 0; - -// return YY * 365 + MM * 30 + DD; -// } - export function toDays(ageString) { if (!ageString || typeof ageString !== "string") return null;