continue ordertest page :

-search param modal
-create page
This commit is contained in:
faiztyanirh 2026-04-21 17:23:31 +07:00
parent cba87ecbbe
commit 84a14c118f
10 changed files with 584 additions and 44 deletions

View File

@ -49,7 +49,7 @@ export const detailSections = [
},
];
export function orderTestActions(masterDetail, selectedPatient) {
export function orderTestActions(masterDetail, selectedPatient, selectedVisit) {
return [
{
Icon: PlusIcon,

View File

@ -5,11 +5,129 @@ import { cleanEmptyStrings } from "$lib/utils/cleanEmptyStrings";
export const orderTestSchema = z.object({});
export const orderTestInitialForm = {};
export const orderTestInitialForm = {
InternalOID: '',
OrderID: '',
PlacerID: '',
InternalPID: '',
SiteID: '',
PVADTID: '',
ReqApp: '',
Priority: '',
TrnDate: '',
EffDate: '',
Comment: '',
OrderAtt: '',
Tests: '',
};
export const orderTestDefaultErrors = {};
export const orderTestFormFields = [];
export const orderTestFormFields = [
{
title: "Order Details",
rows: [
{
type: "row",
columns: [
{
key: "SiteID",
label: "Site",
required: false,
type: "select",
optionsEndpoint: `${API.BASE_URL}${API.SITE}`,
valueKey: "SiteID",
labelKey: "SiteName",
},
{
key: "Priority",
label: "Priority",
required: false,
type: "select",
optionsEndpoint: `${API.BASE_URL}${API.VALUESET}/order_priority`,
},
]
},
{
type: "row",
columns: [
{
key: "PlacerID",
label: "Placer ID",
required: false,
type: "text",
},
{
key: "ReqApp",
label: "Requested Application",
required: false,
type: "text",
},
]
},
{
type: "row",
columns: [
{
key: "TrnDate",
label: "Transaction Date",
required: false,
type: "date",
allowFuture: true
},
{
key: "EffDate",
label: "Effective Date",
required: false,
type: "date",
allowFuture: true
},
]
},
{
type: "row",
columns: [
{
key: "Comment",
label: "Comment",
required: false,
type: "textarea",
},
]
},
{
type: "row",
columns: [
{
key: "OrderAtt",
label: "Attachment",
required: false,
type: "fileupload",
},
]
},
]
},
{
title: "Tests",
rows: [
{
type: "row",
columns: [
{
key: "Tests",
label: "Search Test",
required: false,
type: "tests",
optionsEndpoint: `${API.BASE_URL}${API.TEST}`,
valueKey: 'TestSiteCode',
labelKey: (item) => `${item.TestSiteCode} - ${item.TestSiteName}`,
},
]
},
]
},
];
export function getOrderTestFormActions(handlers) {
return [

View File

@ -59,15 +59,14 @@
function handleButtonClick() {
if (tempSelectedVisit) {
props.onConfirm(tempSelectedPatient);
props.onConfirm(tempSelectedPatient, tempSelectedVisit);
tempSelectedVisit = null;
}
}
$inspect(tempSelectedVisit)
</script>
<div class="w-280 h-110 transition-all duration-300">
<div class="w-320 h-110 transition-all duration-300">
<div class="flex gap-4 h-full">
<div class="flex flex-col w-1/3 h-full">
<div class="flex-1 overflow-y-auto min-h-0 space-y-2 px-2">
@ -113,7 +112,7 @@
</div>
<div class="flex flex-col w-2/3 h-full">
{#if props.search.searchData && props.search.searchData.length > 0}
<div class="flex-1 overflow-y-auto min-h-0 flex flex-col gap-2">
<div class="flex-1 overflow-y-auto min-h-0 flex flex-col gap-2 [scrollbar-width:thin] [&::-webkit-scrollbar]:w-1">
<div class="flex-1">
<Table.Root>
<Table.Header>
@ -154,25 +153,26 @@
</div>
{/if}
</div>
<div class="min-w-60 flex flex-col h-full items-center justify-center">
<div class="min-w-100 flex flex-col h-full items-center justify-center">
{#if isLoadingVisit}
<Spinner />
{:else if visitData && visitData.data?.length > 0}
<div class="flex-1 overflow-y-auto min-h-0 flex flex-col gap-2">
<div class="flex-1">
<span class="font-medium">Active Visit</span>
<Table.Root>
<Table.Header>
<Table.Row class="hover:bg-transparent">
<Table.Head class="w-8"></Table.Head>
<Table.Head class="">Visit ID</Table.Head>
<Table.Head class="">Visit Date</Table.Head>
<Table.Head class="">Location</Table.Head>
<Table.Head class="">Doctor</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each visitData.data as visit, i}
<Table.Row
class="cursor-pointer hover:bg-muted/50"
class="cursor-pointer hover:bg-muted/50 h-12"
onclick={() => handleCheckboxVisit(visit)}
>
<Table.Cell onclick={(e) => e.stopPropagation()}>
@ -182,8 +182,23 @@
onCheckedChange={() => handleCheckboxVisit(visit)}
/>
</Table.Cell>
<Table.Cell class="">{visit.PVID}</Table.Cell>
<Table.Cell class="">{formatUTCDate(visit.PVCreateDate)}</Table.Cell>
<Table.Cell class="font-medium">{visit.PVID}</Table.Cell>
<Table.Cell>
<div>{formatUTCDate(visit.PVCreateDate).split(' ')[0]}</div>
<div class="text-xs opacity-60">{formatUTCDate(visit.PVCreateDate).split(' ')[1]}</div>
</Table.Cell>
<Table.Cell>
<div class="flex flex-col py-1">
<span class="font-medium">{visit.LocCode ?? "-"}</span>
<span class="text-xs opacity-60">{visit.LocFull ?? "-"}</span>
</div>
</Table.Cell>
<Table.Cell class="flex flex-col py-2">
<div class="flex flex-col py-1">
<span class="font-medium">{visit.AttDocContactCode ?? "-"}</span>
<span class="text-xs opacity-60">{visit.AttDocFirstName} {visit.AttDocLastName}</span>
</div>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>

View File

@ -0,0 +1,66 @@
<script>
import { usePatientForm } from "$lib/components/composable/use-patient-form.svelte";
import FormPageContainer from "$lib/components/patient/reusable/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";
let props = $props();
const { masterDetail, formFields, formActions, schema } = props.context;
const { formState } = masterDetail;
const helpers = usePatientForm(formState, schema);
const handlers = {
clearForm: () => {
formState.reset();
}
};
const actions = formActions(handlers);
let showConfirm = $state(false);
async function handleSave() {
// const payload = buildPayload(formState.form);
// const result = await formState.save(masterDetail.mode, payload);
// console.log(payload);
// toast('Visit Created!');
// masterDetail?.exitForm(true);
}
const primaryAction = $derived({
label: 'Save',
onClick: handleSave,
disabled: helpers.hasErrors || formState.isSaving.current,
loading: formState.isSaving.current
});
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}>
<OrderFormRenderer
{formState}
formFields={formFields}
mode="create"
/>
</FormPageContainer>
<ReusableAlertDialog
bind:open={masterDetail.showExitConfirm}
onConfirm={masterDetail.confirmExit}
/>

View File

@ -14,17 +14,19 @@
import { page } from '$app/stores';
import { getPatient } from "$lib/components/patient/list/api/patient-list-api";
import { patientStore } from "$lib/components/patient/list/store/patient-store.svelte";
import { formatUTCDate } from "$lib/utils/formatUTCDate";
let props = $props();
let selectedPatient = $state(null);
let selectedVisit = $state(null);
let isLoading = $state(false);
let searchData = $state([]);
$inspect(selectedPatient)
const search = useSearch(searchFields, searchParam);
let actions = $derived.by(() => {
return orderTestActions(props.masterDetail, selectedPatient).map(action => {
return orderTestActions(props.masterDetail, selectedPatient, selectedVisit).map(action => {
if (action.label === 'Search Parameters') {
return { ...action, popoverContent: searchParamSnippet };
}
@ -34,12 +36,13 @@
let activeRowId = $state(null);
async function handlePatientConfirm(patient) {
async function handlePatientConfirm(patient, visit) {
selectedPatient = patient;
selectedVisit = visit;
isLoading = true;
try {
searchData = await getOrderList({
InternalPID: selectedPatient.InternalPID
PVADTID: selectedVisit.PVADTID
});
} catch (error) {
console.error('Search failed:', error);
@ -61,31 +64,15 @@
SexLabel: `${patient.patient.SexLabel}`,
};
console.log(transformedPatient);
// return {
// InternalPID: p.InternalPID,
// PatientID: p.PatientID,
// FullName: [p.NameFirst, p.NameMiddle, p.NameLast].filter(Boolean).join(' '),
// Sex: p.Sex,
// Birthdate: p.Birthdate,
// Email: p.Email,
// MobilePhone: p.MobilePhone,
// SexLabel: p.SexLabel,
// }
handlePatientConfirm(transformedPatient);
}
onMount(() => {
// const pid = $page.url.searchParams.get('InternalPID'); //kalau dari url parameter
// if (pid) {
// loadPatientFromUrl(pid);
// }
if (patientStore.pending) {
loadPatientFromUrl(patientStore.pending.InternalPID);
patientStore.pending = null;
}
})
$inspect(patientStore)
</script>
{#snippet searchParamSnippet()}
@ -131,13 +118,18 @@
<span class="font-semibold">
{selectedPatient?.FullName}
</span>
<span class="text-xs">
{selectedPatient?.Age ? selectedPatient.Age.split(' ').slice(0, 2).join(' ') : 'N/A'} -
{selectedPatient?.SexLabel} -
{selectedPatient?.BirthdateConversion}
</span>
</div>
<div class="flex flex-col justify-end items-end">
<span class="font-bold tracking-wide">
{selectedPatient?.Birthdate}
<span class="font-bold tracking-wide underline">
{selectedVisit?.PVID}
</span>
<span class="font-semibold">
{selectedPatient?.SexLabel}
<span class="text-xs">
{formatUTCDate(selectedVisit?.PVCreateDate)}
</span>
</div>
</div>
@ -147,7 +139,8 @@
<ReusableDataTable data={searchData} columns={orderTestColumns} handleRowClick={props.masterDetail.select} {activeRowId} rowIdKey="InternalOID" offset="7"/>
{:else}
<div class="flex h-full">
<ReusableEmpty icon={ClipboardXIcon} desc="Try searching from search parameters"/>
<!-- <ReusableEmpty icon={ClipboardXIcon} desc="Try searching from search parameters"/> -->
<ReusableEmpty title="No Order" icon={ClipboardXIcon} desc={searchData ? "No order available" : "Try searching from search parameters"}/>
</div>
{/if}
</div>

View File

@ -7,8 +7,4 @@ export const orderTestColumns = [
accessorKey: "PlacerID",
header: "Host ID",
},
{
accessorKey: "Priority",
header: "Priority",
},
];

View File

@ -15,7 +15,7 @@
</script>
<div class="flex flex-col p-2 gap-4 h-full w-full">
<div class="flex flex-col p-2 h-full w-full">
<TopbarWrapper actions={actions} title={title}/>
<div class="flex-1 min-h-0 overflow-y-auto p-2">
{@render children()}

View File

@ -21,6 +21,7 @@
import CornerDownLeftIcon from '@lucide/svelte/icons/corner-down-left';
import CheckIcon from "@lucide/svelte/icons/check";
import XIcon from "@lucide/svelte/icons/x";
import ReusableUpload from "$lib/components/reusable/reusable-upload.svelte";
let {
formState,
@ -860,6 +861,10 @@
{isOn ? toggleOn.label : toggleOff.label}
</Toggle>
</div>
{:else if type === "fileupload"}
<div class="flex flex-col w-full">
<ReusableUpload attachments={formState.form[key]} />
</div>
{:else}
<Input
type="text"

View File

@ -0,0 +1,345 @@
<script>
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import * as Select from '$lib/components/ui/select/index.js';
import * as Popover from "$lib/components/ui/popover/index.js";
import { Toggle } from '$lib/components/ui/toggle/index.js';
import { Button } from '$lib/components/ui/button/index.js';
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 { 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';
import MoveLeftIcon from '@lucide/svelte/icons/move-left';
import MoveRightIcon from '@lucide/svelte/icons/move-right';
import BrushCleaningIcon from '@lucide/svelte/icons/brush-cleaning';
import DeleteIcon from '@lucide/svelte/icons/delete';
import Trash2Icon from '@lucide/svelte/icons/trash-2';
import PlusIcon from '@lucide/svelte/icons/plus';
import CornerDownLeftIcon from '@lucide/svelte/icons/corner-down-left';
import CheckIcon from "@lucide/svelte/icons/check";
import XIcon from "@lucide/svelte/icons/x";
import ReusableUpload from "$lib/components/reusable/reusable-upload.svelte";
import * as Table from "$lib/components/ui/table/index.js";
import * as Tooltip from "$lib/components/ui/tooltip/index.js";
let {
formState,
formFields,
uploadErrors,
mode = 'create',
} = $props();
let searchQuery = $state({});
const leftGroups = $derived([formFields[0]]);
const rightGroups = $derived(formFields.slice(1));
function getFilteredOptions(key) {
const query = searchQuery[key] || '';
if (!query) return formState.selectOptions?.[key] ?? [];
return (formState.selectOptions?.[key] ?? []).filter((opt) =>
opt.label.toLowerCase().includes(query.toLowerCase())
);
}
async function initializeDefaultValues() {
for (const group of formFields) {
for (const row of group.rows) {
for (const col of row.columns) {
if (col.type === 'group') {
for (const child of col.columns) {
await handleDefaultValue(child);
}
} else {
await handleDefaultValue(col);
}
}
}
}
}
async function handleDefaultValue(field) {
if (!field.defaultValue || !field.optionsEndpoint) return;
await formState.fetchOptions(field, formState.form);
if (!formState.form[field.key]) {
formState.form[field.key] = field.defaultValue;
}
}
$effect(() => {
initializeDefaultValues();
});
</script>
{#snippet Fieldset({
key,
label,
required,
type,
optionsEndpoint,
options,
validateOn,
allowFuture,
valueKey,
labelKey,
})}
<div class="flex w-full flex-col gap-1.5">
<div class="flex justify-between items-center w-full">
<Label>{label}</Label>
{#if required}
<span class="text-destructive text-xl leading-none h-3.5">*</span>
{/if}
{#if key === 'FormulaCode' && formState.form.FormulaInput?.length}
{@const inputStatus = onGetErrorStatus?.()}
<div class="flex items-center gap-2 text-sm text-destructive">
<span>Must included :</span>
<div class="flex gap-1 flex-wrap">
{#each inputStatus as item (item.value)}
<Badge class="px-1 text-[10px]" variant={item.done ? 'default' : 'destructive'}>
{item.value}
</Badge>
{/each}
</div>
</div>
{/if}
</div>
<div class="relative flex flex-col items-start w-full">
{#if type === 'text'}
<Input
type="text"
bind:value={formState.form[key]}
oninput={() => {
if (validateOn?.includes('input')) {
formState.validateField(key, formState.form[key], false);
}
}}
/>
{:else if type === 'textarea'}
<Textarea
class="flex min-h-[60px] 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"
bind:value={formState.form[key]}
oninput={() => {
if (validateOn?.includes('input')) {
formState.validateField(key, formState.form[key], false);
}
}}
/>
{:else if type === 'select'}
{@const selectedLabel =
formState.selectOptions?.[key]?.find((opt) => opt.value === formState.form[key])?.label ||
'Choose'}
{@const filteredOptions = getFilteredOptions(key)}
<Select.Root
type="single"
bind:value={formState.form[key]}
onValueChange={(val) => {
formState.form[key] = val;
}}
onOpenChange={(open) => {
if (open) {
if (options && options.length > 0) {
if (formState.selectOptions) {
formState.selectOptions[key] = options;
}
} else if (optionsEndpoint) {
formState.fetchOptions?.(
{ key, optionsEndpoint, valueKey, labelKey },
formState.form
);
}
}
}}
>
<Select.Trigger class="w-full truncate">
{selectedLabel}
</Select.Trigger>
<Select.Content>
<div class="p-2">
<input
type="text"
placeholder="Search..."
class="w-full border rounded px-2 py-1 text-sm"
bind:value={searchQuery[key]}
/>
</div>
{#if formState.loadingOptions?.[key]}
<Select.Item disabled value="loading">Loading...</Select.Item>
{:else}
{#if !required}
<Select.Item value="">- None -</Select.Item>
{/if}
{#each filteredOptions as option}
<Select.Item value={option.value}>
{option.label}
</Select.Item>
{/each}
{/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);
}
}}
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 filteredOptions = getFilteredOptions(key)}
<div class="flex gap-2 w-full">
<div class="flex-1">
<Select.Root
type="single"
onOpenChange={(open) => {
if (open && optionsEndpoint) {
formState.fetchOptions?.(
{ key, optionsEndpoint, valueKey, labelKey },
formState.form
);
}
}}
>
<Select.Trigger class="w-full">
{selectedLabel}
</Select.Trigger>
<Select.Content>
<div class="p-2">
<input
type="text"
placeholder="Search..."
class="w-full border rounded px-2 py-1 text-sm"
bind:value={searchQuery[key]}
/>
</div>
{#if formState.loadingOptions?.[key]}
<Select.Item disabled value="loading">Loading...</Select.Item>
{:else}
{#if !required}
<Select.Item value="">- None -</Select.Item>
{/if}
{#each filteredOptions as option}
<Select.Item value={option.value}>
{option.label}
</Select.Item>
{/each}
{/if}
</Select.Content>
</Select.Root>
</div>
<Button>
<PlusIcon class="size-4" />
Add Test
</Button>
</div>
<Table.Root>
<Table.Header>
<Table.Row class="hover:bg-transparent">
<Table.Head>No</Table.Head>
<Table.Head>Test Name</Table.Head>
<Table.Head>Discipline</Table.Head>
<Table.Head>Container</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>
</Table.Body>
</Table.Root>
{:else}
<Input
type="text"
bind:value={formState.form[key]}
placeholder="Custom field type: {type}"
/>
{/if}
<div class="absolute top-8 min-h-[1rem] w-full">
{#if formState.errors[key]}
<span class="text-destructive text-sm leading-none">
{formState.errors[key]}
</span>
{/if}
</div>
</div>
</div>
{/snippet}
{#snippet GroupBlock(groups)}
<div class="p-2 space-y-6">
{#each groups as group}
<div class="space-y-6">
{#if group.title}
<div class="text-md 2xl:text-lg font-semibold italic">
<span class="border-b-2 border-primary">{group.title}</span>
</div>
{/if}
{#each group.rows as row}
<div
class="grid grid-cols-1 space-y-2 gap-6 md:gap-4"
class:md:grid-cols-1={row.columns.length === 1}
class:md:grid-cols-2={row.columns.length === 2}
class:md:grid-cols-3={row.columns.length === 3}
>
{#each row.columns as col}
{@render Fieldset(col)}
{/each}
</div>
{/each}
</div>
{/each}
</div>
{/snippet}
<div class="flex w-full h-full gap-1">
<div class="w-1/3 h-full min-w-0 overflow-y-auto [scrollbar-width:thin] [&::-webkit-scrollbar]:w-1">
{@render GroupBlock(leftGroups)}
</div>
<div class="flex-1 h-full min-w-0 overflow-y-auto [scrollbar-width:thin] [&::-webkit-scrollbar]:w-1">
{@render GroupBlock(rightGroups)}
</div>
</div>

View File

@ -7,10 +7,12 @@
import { getLocalTimeZone, today, parseDate } from "@internationalized/date";
const id = $props.id();
let { title, parentFunction, value = $bindable("") } = $props();
let { title, parentFunction, value = $bindable(""), allowFuture = false } = $props();
let open = $state(false);
let calendarValue = $state();
const maxValue = $derived(allowFuture ? undefined : today(getLocalTimeZone()));
$effect(() => {
if (value && typeof value === "string") {
try {
@ -64,7 +66,7 @@
bind:value={calendarValue}
captionLayout="dropdown"
onValueChange={handleChange}
maxValue={today(getLocalTimeZone())}
maxValue={maxValue}
/>
</Popover.Content>
</Popover.Root>