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); 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 { return {
...state, //form, resetForm, setForm, isSaving ...state, //form, resetForm, setForm, isSaving
...val, //errors, validateField, resetErrors ...val, //errors, validateField, resetErrors, validateAll
...options, //selectOptions, loadingOptions, fetchOptions, lastFetched, clearDependentOptions ...options, //selectOptions, loadingOptions, fetchOptions, lastFetched, clearDependentOptions
save, save,
reset, reset,

View File

@ -103,13 +103,13 @@ export const testGroupSchema = z
export const refNumSchema = z export const refNumSchema = z
.object({ .object({
AgeStart: z.string().optional(), AgeStart: z.coerce.string().optional(),
AgeEnd: z.string().optional(), AgeEnd: z.coerce.string().optional(),
Low: z.string().optional(), Low: z.coerce.string().optional(),
High: z.string().optional(), High: z.coerce.string().optional(),
LowSign: z.string().optional(), LowSign: z.string().optional(),
HighSign: z.string().optional(), HighSign: z.string().optional(),
NumRefType: z.string().optional() NumRefType: z.string().optional()
}) })
.superRefine((data, ctx) => { .superRefine((data, ctx) => {
const start = toDays(data.AgeStart); const start = toDays(data.AgeStart);

View File

@ -37,6 +37,7 @@
import Group from './tabs/group.svelte'; import Group from './tabs/group.svelte';
import { API } from "$lib/config/api"; import { API } from "$lib/config/api";
import { untrack } from "svelte"; import { untrack } from "svelte";
import { buildAgeText, daysToAge } from '$lib/utils/ageUtils';
let props = $props(); let props = $props();
@ -128,15 +129,24 @@
let showConfirm = $state(false); let showConfirm = $state(false);
async function handleEdit() { async function handleEdit() {
const result = await formState.save(masterDetail.mode); const mainForm = masterDetail.formState.form;
const testType = mainForm.TestType;
const cleanMapData = mapData.map(({ options, ...rest }) => rest);
if (result.status === 'success') { const payload = buildTestPayload({
console.log('Test updated successfully'); mainForm,
toast('Test Updated!'); activeFormStates,
masterDetail.exitForm(true); testType: testType,
} else { refNumData: refNumData,
console.error('Failed to update test:', result.message); refTxtData: refTxtData,
} mapData: cleanMapData,
});
console.log(payload);
const result = await formState.save(masterDetail.mode, payload);
toast('Test Updated!');
masterDetail?.exitForm(true);
} }
const primaryAction = $derived({ const primaryAction = $derived({
@ -318,8 +328,13 @@
$effect(() => { $effect(() => {
const mainForm = formState.form; const mainForm = formState.form;
if (mainForm.refnum && Array.isArray(mainForm.refnum)) { if (mainForm.refnum && Array.isArray(mainForm.refnum)) {
refNumData = 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] = ''; 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> </script>
<FormPageContainer title="Edit Test" {primaryAction} {secondaryActions}> <FormPageContainer title="Edit Test" {primaryAction} {secondaryActions}>
@ -380,7 +458,12 @@
{formState} {formState}
formFields={formFields} formFields={formFields}
mode="edit" mode="edit"
{hiddenFields} {disabledResultTypes}
{disabledReferenceTypes}
{hiddenFields}
{handleTestTypeChange}
{handleResultTypeChange}
{handleRefTypeChange}
/> />
</Tabs.Content> </Tabs.Content>
<Tabs.Content value="calculation"> <Tabs.Content value="calculation">

View File

@ -4,7 +4,7 @@
import * as Table from '$lib/components/ui/table/index.js'; import * as Table from '$lib/components/ui/table/index.js';
import { Button } from '$lib/components/ui/button/index.js'; import { Button } from '$lib/components/ui/button/index.js';
import { Badge } from '$lib/components/ui/badge/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 PencilIcon from '@lucide/svelte/icons/pencil';
import Trash2Icon from '@lucide/svelte/icons/trash-2'; import Trash2Icon from '@lucide/svelte/icons/trash-2';
import { untrack } from 'svelte'; import { untrack } from 'svelte';
@ -40,8 +40,8 @@
if (refType === 'THOLD') return false; if (refType === 'THOLD') return false;
return false; return false;
}) });
// $inspect(props.refNumState.form.NumRefType)
function snapshotForm() { function snapshotForm() {
const f = props.refNumState.form; const f = props.refNumState.form;
return { return {
@ -78,25 +78,148 @@
editingId = null; 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() { 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 newStart = toDays(props.refNumState.form.AgeStart);
const newEnd = toDays(props.refNumState.form.AgeEnd); const newEnd = toDays(props.refNumState.form.AgeEnd);
// const row = { id: ++idCounter, ...snapshotForm() };
// tempNumeric = [...tempNumeric, row];
// resetForm();
const isOverlap = tempNumeric.some((row) => { 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 existingStart = toDays(row.AgeStart);
const existingEnd = toDays(row.AgeEnd); const existingEnd = toDays(row.AgeEnd);
if (existingStart == null || existingEnd == null) return false; if (existingStart == null || existingEnd == null) return false;
if (row.RangeType !== props.refNumState.form.RangeType) return false; return (newStart <= existingEnd && newEnd >= existingStart);
return !(newEnd < existingStart || newStart > existingEnd);
}); });
if (isOverlap) { if (isOverlap) {
@ -104,6 +227,12 @@
return; return;
} }
const tholdError = validateTholdContinuity();
if (tholdError) {
props.refNumState.errors[tholdError.field] = tholdError.message;
return;
}
const row = { const row = {
id: ++idCounter, id: ++idCounter,
...snapshotForm() ...snapshotForm()
@ -113,33 +242,37 @@
resetForm(); resetForm();
} }
$inspect(props.refNumState.form)
function handleEdit(row) { function handleEdit(row) {
editingId = row.id; 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']) { for (const key of ['AgeStart', 'AgeEnd']) {
const val = row[key] ?? ''; const val = row[key] ?? '';
const match = val.match(/(\d+)Y\s*(\d+)M\s*(\d+)D/); if (typeof val === 'number') {
joinFields[key] = match joinFields[key] = daysToAge(val);
? { YY: match[1], MM: match[2], DD: match[3] } } else {
: { DD: '', MM: '', YY: '' }; 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() { function handleUpdate() {
@ -183,6 +316,24 @@ $inspect(props.refNumState.form)
for (const key of ['AgeStart', 'AgeEnd']) { for (const key of ['AgeStart', 'AgeEnd']) {
props.refNumState.form[key] = buildAgeText(joinFields[key]); 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(() => { $effect(() => {
@ -216,6 +367,17 @@ $inspect(props.refNumState.form)
props.refNumState.form.HighSign = ''; 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> </script>
<div class="flex flex-col gap-4 w-full"> <div class="flex flex-col gap-4 w-full">
@ -277,7 +439,7 @@ $inspect(props.refNumState.form)
- -
{/if} {/if}
</Table.Cell> </Table.Cell>
<Table.Cell class="font-medium flex justify-between"> <!-- <Table.Cell class="font-medium flex justify-between">
<div> <div>
{row.LowSign ? row.LowSign : ''} {row.LowSign ? row.LowSign : ''}
{row.Low || 'null'} &ndash; {row.Low || 'null'} &ndash;
@ -287,8 +449,26 @@ $inspect(props.refNumState.form)
<Badge variant="outline" class="border-dashed border-primary border-2" <Badge variant="outline" class="border-dashed border-primary border-2"
>{numRefTypeBadge(row.NumRefType)}</Badge >{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>
<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.Interpretation}</Table.Cell>
<Table.Cell class="font-medium">{row.Notes}</Table.Cell> <Table.Cell class="font-medium">{row.Notes}</Table.Cell>
<Table.Cell> <Table.Cell>

View File

@ -394,10 +394,12 @@
bind:value={formState.form[txtKey]} bind:value={formState.form[txtKey]}
oninput={() => { oninput={() => {
if (validateOn?.includes('input')) { if (validateOn?.includes('input')) {
formState.validateField('Low', formState.form[txtKey]); formState.validateField(txtKey, formState.form[txtKey]);
formState.validateField('LowSign'); formState.validateField(key);
formState.validateField('High', formState.form[txtKey]); // formState.validateField('Low', formState.form[txtKey]);
formState.validateField('HighSign'); // 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 }; 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) { export function toDays(ageString) {
if (!ageString || typeof ageString !== "string") return null; if (!ageString || typeof ageString !== "string") return null;