continue edit & add rule threshold

This commit is contained in:
faiztyanirh 2026-03-26 16:57:47 +07:00
parent 1389eac272
commit 0f4dd0d522
7 changed files with 341 additions and 75 deletions

View File

@ -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 }
}

View File

@ -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,

View File

@ -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);

View File

@ -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;
}
}
</script>
<FormPageContainer title="Edit Test" {primaryAction} {secondaryActions}>
@ -380,7 +458,12 @@
{formState}
formFields={formFields}
mode="edit"
{hiddenFields}
{disabledResultTypes}
{disabledReferenceTypes}
{hiddenFields}
{handleTestTypeChange}
{handleResultTypeChange}
{handleRefTypeChange}
/>
</Tabs.Content>
<Tabs.Content value="calculation">

View File

@ -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 9199
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;
}
});
</script>
<div class="flex flex-col gap-4 w-full">
@ -277,7 +439,7 @@ $inspect(props.refNumState.form)
-
{/if}
</Table.Cell>
<Table.Cell class="font-medium flex justify-between">
<!-- <Table.Cell class="font-medium flex justify-between">
<div>
{row.LowSign ? row.LowSign : ''}
{row.Low || 'null'} &ndash;
@ -287,8 +449,26 @@ $inspect(props.refNumState.form)
<Badge variant="outline" class="border-dashed border-primary border-2"
>{numRefTypeBadge(row.NumRefType)}</Badge
>
</Table.Cell> -->
<Table.Cell class="font-medium flex justify-between">
<div>
{#if row.Low && row.High}
{row.LowSign} {row.Low} &ndash; {row.HighSign} {row.High}
{:else if row.Low}
{row.LowSign} {row.Low}
{:else if row.High}
{row.HighSign} {row.High}
{:else}
-
{/if}
</div>
<Badge variant="outline" class="border-dashed border-primary border-2">
{numRefTypeBadge(row.NumRefType)}
</Badge>
</Table.Cell>
<Table.Cell class="font-medium">{row.NumRefType === "RANGE" ? "AUTO" : row.Flag}</Table.Cell>
<Table.Cell class="font-medium"
>{row.NumRefType === 'RANGE' ? 'AUTO' : row.Flag}</Table.Cell
>
<Table.Cell class="font-medium">{row.Interpretation}</Table.Cell>
<Table.Cell class="font-medium">{row.Notes}</Table.Cell>
<Table.Cell>

View File

@ -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');
}
}}
/>

View File

@ -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;