mirror of
https://github.com/faiztyanirh/clqms-shadcn-v1.git
synced 2026-04-28 00:45:53 +07:00
continue ordertest page :
-search param modal -create page
This commit is contained in:
parent
cba87ecbbe
commit
84a14c118f
@ -49,7 +49,7 @@ export const detailSections = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function orderTestActions(masterDetail, selectedPatient) {
|
export function orderTestActions(masterDetail, selectedPatient, selectedVisit) {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
Icon: PlusIcon,
|
Icon: PlusIcon,
|
||||||
|
|||||||
@ -5,11 +5,129 @@ import { cleanEmptyStrings } from "$lib/utils/cleanEmptyStrings";
|
|||||||
|
|
||||||
export const orderTestSchema = z.object({});
|
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 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) {
|
export function getOrderTestFormActions(handlers) {
|
||||||
return [
|
return [
|
||||||
|
|||||||
@ -59,15 +59,14 @@
|
|||||||
|
|
||||||
function handleButtonClick() {
|
function handleButtonClick() {
|
||||||
if (tempSelectedVisit) {
|
if (tempSelectedVisit) {
|
||||||
props.onConfirm(tempSelectedPatient);
|
props.onConfirm(tempSelectedPatient, tempSelectedVisit);
|
||||||
|
|
||||||
tempSelectedVisit = null;
|
tempSelectedVisit = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$inspect(tempSelectedVisit)
|
|
||||||
</script>
|
</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 gap-4 h-full">
|
||||||
<div class="flex flex-col w-1/3 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">
|
<div class="flex-1 overflow-y-auto min-h-0 space-y-2 px-2">
|
||||||
@ -113,7 +112,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col w-2/3 h-full">
|
<div class="flex flex-col w-2/3 h-full">
|
||||||
{#if props.search.searchData && props.search.searchData.length > 0}
|
{#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">
|
<div class="flex-1">
|
||||||
<Table.Root>
|
<Table.Root>
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
@ -154,25 +153,26 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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}
|
{#if isLoadingVisit}
|
||||||
<Spinner />
|
<Spinner />
|
||||||
{:else if visitData && visitData.data?.length > 0}
|
{: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 overflow-y-auto min-h-0 flex flex-col gap-2">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<span class="font-medium">Active Visit</span>
|
|
||||||
<Table.Root>
|
<Table.Root>
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Row class="hover:bg-transparent">
|
<Table.Row class="hover:bg-transparent">
|
||||||
<Table.Head class="w-8"></Table.Head>
|
<Table.Head class="w-8"></Table.Head>
|
||||||
<Table.Head class="">Visit ID</Table.Head>
|
<Table.Head class="">Visit ID</Table.Head>
|
||||||
<Table.Head class="">Visit Date</Table.Head>
|
<Table.Head class="">Visit Date</Table.Head>
|
||||||
|
<Table.Head class="">Location</Table.Head>
|
||||||
|
<Table.Head class="">Doctor</Table.Head>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
{#each visitData.data as visit, i}
|
{#each visitData.data as visit, i}
|
||||||
<Table.Row
|
<Table.Row
|
||||||
class="cursor-pointer hover:bg-muted/50"
|
class="cursor-pointer hover:bg-muted/50 h-12"
|
||||||
onclick={() => handleCheckboxVisit(visit)}
|
onclick={() => handleCheckboxVisit(visit)}
|
||||||
>
|
>
|
||||||
<Table.Cell onclick={(e) => e.stopPropagation()}>
|
<Table.Cell onclick={(e) => e.stopPropagation()}>
|
||||||
@ -182,8 +182,23 @@
|
|||||||
onCheckedChange={() => handleCheckboxVisit(visit)}
|
onCheckedChange={() => handleCheckboxVisit(visit)}
|
||||||
/>
|
/>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell class="">{visit.PVID}</Table.Cell>
|
<Table.Cell class="font-medium">{visit.PVID}</Table.Cell>
|
||||||
<Table.Cell class="">{formatUTCDate(visit.PVCreateDate)}</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>
|
</Table.Row>
|
||||||
{/each}
|
{/each}
|
||||||
</Table.Body>
|
</Table.Body>
|
||||||
|
|||||||
@ -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}
|
||||||
|
/>
|
||||||
@ -14,17 +14,19 @@
|
|||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { getPatient } from "$lib/components/patient/list/api/patient-list-api";
|
import { getPatient } from "$lib/components/patient/list/api/patient-list-api";
|
||||||
import { patientStore } from "$lib/components/patient/list/store/patient-store.svelte";
|
import { patientStore } from "$lib/components/patient/list/store/patient-store.svelte";
|
||||||
|
import { formatUTCDate } from "$lib/utils/formatUTCDate";
|
||||||
|
|
||||||
let props = $props();
|
let props = $props();
|
||||||
|
|
||||||
let selectedPatient = $state(null);
|
let selectedPatient = $state(null);
|
||||||
|
let selectedVisit = $state(null);
|
||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
let searchData = $state([]);
|
let searchData = $state([]);
|
||||||
|
$inspect(selectedPatient)
|
||||||
const search = useSearch(searchFields, searchParam);
|
const search = useSearch(searchFields, searchParam);
|
||||||
|
|
||||||
let actions = $derived.by(() => {
|
let actions = $derived.by(() => {
|
||||||
return orderTestActions(props.masterDetail, selectedPatient).map(action => {
|
return orderTestActions(props.masterDetail, selectedPatient, selectedVisit).map(action => {
|
||||||
if (action.label === 'Search Parameters') {
|
if (action.label === 'Search Parameters') {
|
||||||
return { ...action, popoverContent: searchParamSnippet };
|
return { ...action, popoverContent: searchParamSnippet };
|
||||||
}
|
}
|
||||||
@ -34,12 +36,13 @@
|
|||||||
|
|
||||||
let activeRowId = $state(null);
|
let activeRowId = $state(null);
|
||||||
|
|
||||||
async function handlePatientConfirm(patient) {
|
async function handlePatientConfirm(patient, visit) {
|
||||||
selectedPatient = patient;
|
selectedPatient = patient;
|
||||||
|
selectedVisit = visit;
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
try {
|
try {
|
||||||
searchData = await getOrderList({
|
searchData = await getOrderList({
|
||||||
InternalPID: selectedPatient.InternalPID
|
PVADTID: selectedVisit.PVADTID
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Search failed:', error);
|
console.error('Search failed:', error);
|
||||||
@ -61,31 +64,15 @@
|
|||||||
SexLabel: `${patient.patient.SexLabel}`,
|
SexLabel: `${patient.patient.SexLabel}`,
|
||||||
};
|
};
|
||||||
console.log(transformedPatient);
|
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);
|
handlePatientConfirm(transformedPatient);
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// const pid = $page.url.searchParams.get('InternalPID'); //kalau dari url parameter
|
|
||||||
// if (pid) {
|
|
||||||
// loadPatientFromUrl(pid);
|
|
||||||
// }
|
|
||||||
if (patientStore.pending) {
|
if (patientStore.pending) {
|
||||||
loadPatientFromUrl(patientStore.pending.InternalPID);
|
loadPatientFromUrl(patientStore.pending.InternalPID);
|
||||||
patientStore.pending = null;
|
patientStore.pending = null;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
$inspect(patientStore)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet searchParamSnippet()}
|
{#snippet searchParamSnippet()}
|
||||||
@ -131,13 +118,18 @@
|
|||||||
<span class="font-semibold">
|
<span class="font-semibold">
|
||||||
{selectedPatient?.FullName}
|
{selectedPatient?.FullName}
|
||||||
</span>
|
</span>
|
||||||
|
<span class="text-xs">
|
||||||
|
{selectedPatient?.Age ? selectedPatient.Age.split(' ').slice(0, 2).join(' ') : 'N/A'} -
|
||||||
|
{selectedPatient?.SexLabel} -
|
||||||
|
{selectedPatient?.BirthdateConversion}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col justify-end items-end">
|
<div class="flex flex-col justify-end items-end">
|
||||||
<span class="font-bold tracking-wide">
|
<span class="font-bold tracking-wide underline">
|
||||||
{selectedPatient?.Birthdate}
|
{selectedVisit?.PVID}
|
||||||
</span>
|
</span>
|
||||||
<span class="font-semibold">
|
<span class="text-xs">
|
||||||
{selectedPatient?.SexLabel}
|
{formatUTCDate(selectedVisit?.PVCreateDate)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -147,7 +139,8 @@
|
|||||||
<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="7"/>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex h-full">
|
<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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -7,8 +7,4 @@ export const orderTestColumns = [
|
|||||||
accessorKey: "PlacerID",
|
accessorKey: "PlacerID",
|
||||||
header: "Host ID",
|
header: "Host ID",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
accessorKey: "Priority",
|
|
||||||
header: "Priority",
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
</script>
|
</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}/>
|
<TopbarWrapper actions={actions} title={title}/>
|
||||||
<div class="flex-1 min-h-0 overflow-y-auto p-2">
|
<div class="flex-1 min-h-0 overflow-y-auto p-2">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
|||||||
@ -21,6 +21,7 @@
|
|||||||
import CornerDownLeftIcon from '@lucide/svelte/icons/corner-down-left';
|
import CornerDownLeftIcon from '@lucide/svelte/icons/corner-down-left';
|
||||||
import CheckIcon from "@lucide/svelte/icons/check";
|
import CheckIcon from "@lucide/svelte/icons/check";
|
||||||
import XIcon from "@lucide/svelte/icons/x";
|
import XIcon from "@lucide/svelte/icons/x";
|
||||||
|
import ReusableUpload from "$lib/components/reusable/reusable-upload.svelte";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
formState,
|
formState,
|
||||||
@ -860,6 +861,10 @@
|
|||||||
{isOn ? toggleOn.label : toggleOff.label}
|
{isOn ? toggleOn.label : toggleOff.label}
|
||||||
</Toggle>
|
</Toggle>
|
||||||
</div>
|
</div>
|
||||||
|
{:else if type === "fileupload"}
|
||||||
|
<div class="flex flex-col w-full">
|
||||||
|
<ReusableUpload attachments={formState.form[key]} />
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
|
|||||||
345
src/lib/components/reusable/form/order-form-renderer.svelte
Normal file
345
src/lib/components/reusable/form/order-form-renderer.svelte
Normal 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>
|
||||||
@ -7,10 +7,12 @@
|
|||||||
import { getLocalTimeZone, today, parseDate } from "@internationalized/date";
|
import { getLocalTimeZone, today, parseDate } from "@internationalized/date";
|
||||||
|
|
||||||
const id = $props.id();
|
const id = $props.id();
|
||||||
let { title, parentFunction, value = $bindable("") } = $props();
|
let { title, parentFunction, value = $bindable(""), allowFuture = false } = $props();
|
||||||
let open = $state(false);
|
let open = $state(false);
|
||||||
let calendarValue = $state();
|
let calendarValue = $state();
|
||||||
|
|
||||||
|
const maxValue = $derived(allowFuture ? undefined : today(getLocalTimeZone()));
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (value && typeof value === "string") {
|
if (value && typeof value === "string") {
|
||||||
try {
|
try {
|
||||||
@ -64,7 +66,7 @@
|
|||||||
bind:value={calendarValue}
|
bind:value={calendarValue}
|
||||||
captionLayout="dropdown"
|
captionLayout="dropdown"
|
||||||
onValueChange={handleChange}
|
onValueChange={handleChange}
|
||||||
maxValue={today(getLocalTimeZone())}
|
maxValue={maxValue}
|
||||||
/>
|
/>
|
||||||
</Popover.Content>
|
</Popover.Content>
|
||||||
</Popover.Root>
|
</Popover.Root>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user