continue ordertest create & edit

This commit is contained in:
faiztyanirh 2026-04-22 17:18:56 +07:00
parent 84a14c118f
commit f8af8c21e6
14 changed files with 341 additions and 94 deletions

View File

@ -46,7 +46,6 @@ export async function searchWithParams(endpoint, searchQuery) {
: `${API.BASE_URL}${endpoint}`;
const res = await fetch(url);
const data = await res.json();
console.log(url);
return data.data || [];
} catch (err) {
console.error('Search Error:', err);

View File

@ -0,0 +1,12 @@
export function useOrderForm(formState) {
let uploadErrors = $state({});
let isChecking = $state({});
let hasErrors = $derived(
Object.values(formState.errors).some(value => value !== null)
);
return {
get hasErrors() { return hasErrors },
}
}

View File

@ -385,7 +385,6 @@
reset: () => { tempMap = []; idCounter = 0; editingId = null; },
});
});
$inspect(testMapDetailFormState.form)
</script>
<FormPageContainer title="Edit Test Map" {primaryAction} {secondaryActions}>

View File

@ -43,8 +43,12 @@ export const detailSections = [
{
class: "grid grid-cols-2 gap-4 items-center",
fields: [
{ key: "OrderID", label: "Order ID" },
{ key: "PlacerID", label: "Host ID" },
{ key: "PriorityLabel", label: "Priority" },
{ key: "TrnDate", label: "Transaction Date" },
{ key: "EffDate", label: "Effective Date" },
{ key: "Comment", label: "Comment" },
{ key: "Tests", label: "Tests", fullWidth: true },
]
},
];
@ -56,7 +60,9 @@ export function orderTestActions(masterDetail, selectedPatient, selectedVisit) {
label: 'Add Order',
onClick: () => masterDetail.enterCreate({
PatientID: selectedPatient?.PatientID,
InternalPID: selectedPatient?.InternalPID
PatientName: selectedPatient?.FullName,
InternalPID: selectedPatient?.InternalPID,
PVADTID: selectedVisit?.PVADTID,
}),
disabled: !selectedPatient,
},

View File

@ -3,7 +3,32 @@ import EraserIcon from "@lucide/svelte/icons/eraser";
import { z } from "zod";
import { cleanEmptyStrings } from "$lib/utils/cleanEmptyStrings";
export const orderTestSchema = z.object({});
export const orderTestSchema = z.object({
Tests: z.array(
z.object({
id: z.number(),
TestSiteCode: z.string(),
TestSiteName: z.string()
})
)
.min(1, " "),
})
.superRefine((data, ctx) => {
if (!data.Tests) return;
const values = data.Tests.map((m) => m.testCode).filter(Boolean);
const duplicates = values.filter((v, i) => values.indexOf(v) !== i);
if (duplicates.length) {
const uniqueDuplicates = [...new Set(duplicates)];
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Duplicate test : ${uniqueDuplicates.join(', ')}`,
path: ['Tests']
});
}
});
export const orderTestInitialForm = {
InternalOID: '',
@ -18,10 +43,12 @@ export const orderTestInitialForm = {
EffDate: '',
Comment: '',
OrderAtt: '',
Tests: '',
Tests: [],
};
export const orderTestDefaultErrors = {};
export const orderTestDefaultErrors = {
Tests: '',
};
export const orderTestFormFields = [
{
@ -72,14 +99,14 @@ export const orderTestFormFields = [
key: "TrnDate",
label: "Transaction Date",
required: false,
type: "date",
type: "datetime",
allowFuture: true
},
{
key: "EffDate",
label: "Effective Date",
required: false,
type: "date",
type: "datetime",
allowFuture: true
},
]
@ -117,11 +144,12 @@ export const orderTestFormFields = [
{
key: "Tests",
label: "Search Test",
required: false,
required: true,
type: "tests",
optionsEndpoint: `${API.BASE_URL}${API.TEST}`,
valueKey: 'TestSiteCode',
labelKey: (item) => `${item.TestSiteCode} - ${item.TestSiteName}`,
validateOn: ['input']
},
]
},
@ -137,4 +165,17 @@ export function getOrderTestFormActions(handlers) {
onClick: handlers.clearForm,
},
];
}
export function buildOrderTestPayload(mainForm){
const { InternalOID, PatientID, PatientName, Tests, ...rest } = mainForm;
let payload = {
...rest,
// Tests: Tests.map(test => ({
// TestSiteID: test.testSiteID,
// }))
}
return cleanEmptyStrings(payload)
}

View File

@ -1,9 +1,10 @@
<script>
import { usePatientForm } from "$lib/components/composable/use-patient-form.svelte";
import FormPageContainer from "$lib/components/patient/reusable/form-page-container.svelte";
import { useOrderForm } from "$lib/components/composable/use-order-form.svelte";
import FormPageContainer from "$lib/components/reusable/form/form-page-container.svelte";
import OrderFormRenderer from "$lib/components/reusable/form/order-form-renderer.svelte";
import { toast } from "svelte-sonner";
import ReusableAlertDialog from "$lib/components/reusable/reusable-alert-dialog.svelte";
import { buildOrderTestPayload } from "$lib/components/order/ordertest/config/ordertest-form-config";
let props = $props();
@ -11,7 +12,7 @@
const { formState } = masterDetail;
const helpers = usePatientForm(formState, schema);
const helpers = useOrderForm(formState);
const handlers = {
clearForm: () => {
@ -24,15 +25,21 @@
let showConfirm = $state(false);
async function handleSave() {
// const payload = buildPayload(formState.form);
const payload = buildOrderTestPayload(formState.form);
console.log(payload);
// const result = await formState.save(masterDetail.mode, payload);
// console.log(payload);
// toast('Visit Created!');
// masterDetail?.exitForm(true);
// if (result.status === 'success') {
// toast('Order Test Created!');
// masterDetail?.exitForm(true);
// } else {
// console.error('Failed to save order test');
// const errorMessages = result.messages ? Object.values(result.messages).join('\n') : 'Failed to save order test';
// toast.error(errorMessages)
// }
}
$inspect(formState.form)
const primaryAction = $derived({
label: 'Save',
onClick: handleSave,
@ -41,18 +48,9 @@
});
const secondaryActions = [];
$inspect(formState.form)
// $effect(() => {
// if (masterDetail.form?.PatientID) {
// formState.setForm({
// ...formState.form,
// ...masterDetail.form
// });
// }
// });
</script>
<FormPageContainer title="Create Order for {formState.form?.PatientID}" {primaryAction} {secondaryActions} {actions}>
<FormPageContainer title="Create Order for {formState.form?.PatientID} - {formState.form?.PatientName}" {primaryAction} {secondaryActions} {actions}>
<OrderFormRenderer
{formState}
formFields={formFields}

View File

@ -0,0 +1,69 @@
<script>
import { useOrderForm } from "$lib/components/composable/use-order-form.svelte";
import FormPageContainer from "$lib/components/reusable/form/form-page-container.svelte";
import OrderFormRenderer from "$lib/components/reusable/form/order-form-renderer.svelte";
import { toast } from "svelte-sonner";
import ReusableAlertDialog from "$lib/components/reusable/reusable-alert-dialog.svelte";
import { buildOrderTestPayload } from "$lib/components/order/ordertest/config/ordertest-form-config";
let props = $props();
const { masterDetail, formFields, formActions, schema } = props.context;
const { formState } = masterDetail;
const helpers = useOrderForm(formState);
let showConfirm = $state(false);
async function handleEdit() {
console.log('edit');
// const payload = buildOrderTestPayload(formState.form);
// console.log(payload);
// const result = await formState.save(masterDetail.mode, payload);
// if (result.status === 'success') {
// toast('Order Test Created!');
// masterDetail?.exitForm(true);
// } else {
// console.error('Failed to save order test');
// const errorMessages = result.messages ? Object.values(result.messages).join('\n') : 'Failed to save order test';
// toast.error(errorMessages)
// }
}
const primaryAction = $derived({
label: 'Save',
onClick: handleEdit,
disabled: helpers.hasErrors || formState.isSaving.current,
loading: formState.isSaving.current
});
const secondaryActions = [];
// $effect(() => {
// const maxId = formState.form.Tests.reduce((max, row) => {
// const rowId = typeof row.id === 'number' ? row.id : 0;
// return rowId > max ? rowId : max;
// }, 0);
// if (maxId > idCounter) {
// idCounter = maxId;
// }
// });
$inspect(formState.form.Tests)
</script>
<FormPageContainer title="Edit Order for {formState.form?.PatientID} - {formState.form?.PatientName}" {primaryAction} {secondaryActions}>
<OrderFormRenderer
{formState}
formFields={formFields}
mode="create"
/>
</FormPageContainer>
<ReusableAlertDialog
bind:open={masterDetail.showExitConfirm}
onConfirm={masterDetail.confirmExit}
/>

View File

@ -22,7 +22,7 @@
let selectedVisit = $state(null);
let isLoading = $state(false);
let searchData = $state([]);
$inspect(selectedPatient)
const search = useSearch(searchFields, searchParam);
let actions = $derived.by(() => {
@ -85,7 +85,7 @@ $inspect(selectedPatient)
onclick={() => props.masterDetail.isFormMode && props.masterDetail.exitForm()}
onkeydown={(e) => e.key === 'Enter' && props.masterDetail.isFormMode && props.masterDetail.exitForm()}
class={`
${props.masterDetail.isMobile ? "w-full" : props.masterDetail.isFormMode ? "w-[3%] cursor-pointer" : "w-[35%]"}
${props.masterDetail.isMobile ? "w-full" : props.masterDetail.isFormMode ? "w-[3%] cursor-pointer" : "w-[55%]"}
transition-all duration-300 flex flex-col items-center p-2 h-full overflow-y-auto
`}
>
@ -136,7 +136,7 @@ $inspect(selectedPatient)
{/if}
<div class="flex-1 w-full h-full">
{#if searchData?.length > 0}
<ReusableDataTable data={searchData} columns={orderTestColumns} handleRowClick={props.masterDetail.select} {activeRowId} rowIdKey="InternalOID" offset="7"/>
<ReusableDataTable data={searchData} columns={orderTestColumns} handleRowClick={props.masterDetail.select} {activeRowId} rowIdKey="InternalOID" offset="8"/>
{:else}
<div class="flex h-full">
<!-- <ReusableEmpty icon={ClipboardXIcon} desc="Try searching from search parameters"/> -->

View File

@ -3,6 +3,8 @@
import { detailSections, viewActions } from "$lib/components/order/ordertest/config/ordertest-config";
import TopbarWrapper from "$lib/components/topbar/topbar-wrapper.svelte";
import ReusableEmpty from "$lib/components/reusable/reusable-empty.svelte";
import * as Table from "$lib/components/ui/table/index.js";
import { Spinner } from "$lib/components/ui/spinner/index.js";
let props = $props();
@ -28,8 +30,45 @@
return field.parentKey ? order[field.parentKey]?.[field.key] : order[field.key];
}
</script>
{#snippet DetailsTable({ value, label })}
<div class="space-y-1.5 w-full">
<dt class="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{label}
</dt>
<dd>
{#if value && Array.isArray(value) && value.length > 0}
<div class="border rounded-md">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Test Code</Table.Head>
<Table.Head>Test Name</Table.Head>
<Table.Head>Discipline</Table.Head>
<Table.Head>Create Date</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each value as row, i}
<Table.Row>
<Table.Cell>{row.TestSiteCode ?? '-'}</Table.Cell>
<Table.Cell>{row.TestSiteName ?? '-'}</Table.Cell>
<Table.Cell>{row.Discipline.DisciplineName ?? '-'}</Table.Cell>
<Table.Cell>{formatUTCDate(row.CreateDate) ?? '-'}</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{:else}
<span class="text-sm font-medium">-</span>
{/if}
</dd>
</div>
{/snippet}
{#snippet Fieldset({ value, label, isUTCDate = false })}
<div class="space-y-1.5">
<dt class="text-xs font-medium text-muted-foreground uppercase tracking-wider">
@ -45,7 +84,11 @@
</div>
{/snippet}
{#if masterDetail.selectedItem}
{#if masterDetail.isLoadingDetail}
<div class="h-full w-full flex items-center justify-center">
<Spinner class="size-6" />
</div>
{:else if masterDetail.selectedItem}
<div class="flex flex-col px-2 py-1 gap-2 h-full w-full">
<TopbarWrapper
title={masterDetail.selectedItem.data.OrderID}
@ -53,14 +96,29 @@
/>
<div class="flex-1 min-h-0 overflow-y-auto space-y-4">
{#each detailSections as section}
<div class="p-4">
<div class={section.class}>
<div class="flex flex-col px-4 py-2 gap-2">
<div class="{section.class} w-full">
{#each section.fields as field}
{@render Fieldset({
label: field.label,
value: getFieldValue(field),
isUTCDate: field.isUTCDate
})}
{#if field.fullWidth}
<div class="col-span-2 mt-2">
{#if field.key === "Tests"}
{@render DetailsTable({ label: field.label, value: getFieldValue(field) })}
{:else}
{@render Fieldset({ label: field.label, value: getFieldValue(field), isUTCDate: field.isUTCDate })}
{/if}
</div>
{:else if field.key === "Tests"}
{@render DetailsTable({
label: field.label,
value: getFieldValue(field),
})}
{:else}
{@render Fieldset({
label: field.label,
value: getFieldValue(field),
isUTCDate: field.isUTCDate
})}
{/if}
{/each}
</div>
</div>

View File

@ -7,4 +7,16 @@ export const orderTestColumns = [
accessorKey: "PlacerID",
header: "Host ID",
},
{
accessorKey: "TrnDate",
header: "Transaction Date",
},
{
accessorKey: "EffDate",
header: "Effective Date",
},
{
accessorKey: "Priority",
header: "Priority",
},
];

View File

@ -20,7 +20,6 @@
<div class="flex-1 min-h-0 overflow-y-auto p-2">
{@render children()}
</div>
<!-- <div class="mt-auto flex justify-end items-center pt-2"> -->
<div class="shrink-0 border-t pt-2 flex justify-end items-center">
<Button
size="sm"

View File

@ -7,7 +7,7 @@
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 ReusableCalendarTimepicker from "$lib/components/reusable/reusable-calendar-timepicker.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';
@ -33,9 +33,12 @@
} = $props();
let searchQuery = $state({});
const leftGroups = $derived([formFields[0]]);
const rightGroups = $derived(formFields.slice(1));
let selectedTest = $state(null);
let selectedCodes = $derived(
new Set((formState.form.Tests ?? []).map(t => t.TestSiteCode))
);
function getFilteredOptions(key) {
const query = searchQuery[key] || '';
@ -72,9 +75,36 @@
}
}
function addTest() {
const testCode = selectedTest?.value;
const testName = selectedTest?.rawItem?.TestSiteName;
const testSiteID = selectedTest?.rawItem?.TestSiteID;
if (!testCode) return;
const exists = formState.form.Tests?.some(t => t.testCode === testCode);
if (exists) {
formState.errors.Tests = 'Test already added';
return;
}
formState.form.Tests = [
...(formState.form.Tests ?? []),
{ id: Date.now(), TestSiteCode: testCode, TestSiteName: testName, TestSiteID: testSiteID }
];
selectedTest = null;
formState.validateField?.('Tests', formState.form.Tests, false);
}
function removeTest(id) {
formState.form.Tests = formState.form.Tests.filter((t) => t.id !== id);
formState.validateField?.('Tests', formState.form.Tests, false);
}
$effect(() => {
initializeDefaultValues();
});
</script>
{#snippet Fieldset({
@ -110,7 +140,6 @@
</div>
{/if}
</div>
<div class="relative flex flex-col items-start w-full">
{#if type === 'text'}
<Input
@ -142,6 +171,9 @@
bind:value={formState.form[key]}
onValueChange={(val) => {
formState.form[key] = val;
if (validateOn?.includes('input')) {
formState.validateField?.(key, formState.form[key], false);
}
}}
onOpenChange={(open) => {
if (open) {
@ -184,30 +216,31 @@
{/if}
</Select.Content>
</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 === "datetime"}
<ReusableCalendarTimepicker
bind:value={formState.form[key]}
parentFunction={(val) => {
formState.form[key] = val;
if (validateOn?.includes("input")) {
formState.validateField(key, val, false);
}
}}
allowFuture={allowFuture}
/>
/>
{:else if type === "fileupload"}
<div class="flex flex-col w-full">
<ReusableUpload attachments={formState.form[key]} />
</div>
{:else if type === "tests"}
{@const selectedLabel =
formState.selectOptions?.[key]?.find((opt) => opt.value === formState.form[key])?.label ||
'Choose'}
{@const selectedLabel = selectedTest?.label || 'Choose'}
{@const filteredOptions = getFilteredOptions(key)}
<div class="flex gap-2 w-full">
<div class="flex-1">
<Select.Root
type="single"
onValueChange={(val) => {
selectedTest = filteredOptions.find((opt) => opt.value === val) ?? null;
}}
onOpenChange={(open) => {
if (open && optionsEndpoint) {
formState.fetchOptions?.(
@ -236,58 +269,70 @@
<Select.Item value="">- None -</Select.Item>
{/if}
{#each filteredOptions as option}
<Select.Item value={option.value}>
{option.label}
</Select.Item>
<Select.Item
value={option.value}
disabled={selectedCodes.has(option.value)}
>
{option.label}
</Select.Item>
{/each}
{/if}
</Select.Content>
</Select.Root>
</div>
<Button>
<Button onclick={addTest}>
<PlusIcon class="size-4" />
Add Test
</Button>
</div>
<Table.Root>
<Table.Root class="mt-3">
<Table.Header>
<Table.Row class="hover:bg-transparent">
<Table.Head>No</Table.Head>
<Table.Head class="w-[40px]">No</Table.Head>
<Table.Head>Test Name</Table.Head>
<Table.Head>Discipline</Table.Head>
<Table.Head>Container</Table.Head>
<Table.Head>Test Code</Table.Head>
<Table.Head class="w-[40px]"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
<Table.Row
class="cursor-pointer hover:bg-muted/50"
>
<Table.Cell>1</Table.Cell>
<Table.Cell>Hematologi Rutin</Table.Cell>
<Table.Cell>Hematologi</Table.Cell>
<Table.Cell>EDTA</Table.Cell>
<Table.Cell>
<div class="flex gap-1">
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger>
<Button
size="icon"
variant="outline"
class="h-7 w-7 cursor-pointer"
>
<Trash2Icon class="h-3.5 w-3.5" />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<p>Delete</p>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
</div>
</Table.Cell>
</Table.Row>
{#if formState.form.Tests.length === 0}
<Table.Row>
<Table.Cell colspan={4} class="text-center text-muted-foreground py-6">
No data. Add a test from Search Test above.
</Table.Cell>
</Table.Row>
{:else}
{#each formState.form.Tests as test, index (test.id)}
<Table.Row
class="cursor-pointer hover:bg-muted/50"
>
<Table.Cell>{index + 1}</Table.Cell>
<Table.Cell>{test.TestSiteName}</Table.Cell>
<Table.Cell>{test.TestSiteCode}</Table.Cell>
<Table.Cell>
<div class="flex gap-1">
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger>
<Button
size="icon"
variant="outline"
class="h-7 w-7 cursor-pointer"
onclick={() => removeTest(test.id)}
>
<Trash2Icon class="h-3.5 w-3.5" />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<p>Delete</p>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
</div>
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
{:else}

View File

@ -8,11 +8,13 @@
import Clock2Icon from "@lucide/svelte/icons/clock-2";
const id = $props.id();
let { title, parentFunction, value = $bindable(""), disabled = false } = $props();
let { title, parentFunction, value = $bindable(""), disabled = false, allowFuture = false } = $props();
let open = $state(false);
let calendarValue = $state();
let timeValue = $state("00:00:00");
const maxValue = $derived(allowFuture ? undefined : today(getLocalTimeZone()));
$effect(() => {
if (value && typeof value === "string") {
try {
@ -81,7 +83,7 @@
bind:value={calendarValue}
captionLayout="dropdown"
onValueChange={handleChange}
maxValue={today(getLocalTimeZone())}
maxValue={maxValue}
/>
</div>
<div class="flex flex-col gap-6 border-t p-4">

View File

@ -20,6 +20,13 @@
modeOpt: 'default',
saveEndpoint: createOrder,
editEndpoint: editOrder,
mapToForm: (data) => ({
...data,
Tests: (data.Tests || []).map((t, i) => ({
...t,
id: t.id ?? i + 1
}))
})
}
});