implement selective update patient list

This commit is contained in:
faiztyanirh 2026-04-06 15:16:23 +07:00
parent 905ce97f5e
commit 45a6f116cc
21 changed files with 503 additions and 74 deletions

17
package-lock.json generated
View File

@ -3724,23 +3724,6 @@
"node": ">=0.10.0"
}
},
"node_modules/yaml": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"license": "ISC",
"optional": true,
"peer": true,
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View File

@ -15,25 +15,6 @@ function cleanQuery(searchQuery) {
return result;
}
// export async function getById(endpoint, id) {
// try {
// const res = await fetch(`${API.BASE_URL}${endpoint}/${id}`);
// if (!res.ok) {
// const error = await res.json();
// console.error('API Error:', error);
// return { data: null, error };
// }
// const response = await res.json();
// console.log(response);
// return { data: response.data?.[0] || response.data, error: null };
// } catch (err) {
// console.error('Network Error:', err);
// return { data: null, error: err };
// }
// }
export async function getById(endpoint, id, returnAll = false) {
try {
const res = await fetch(`${API.BASE_URL}${endpoint}/${id}`);

View File

@ -157,6 +157,22 @@ export function getTestMapFormActions(handlers) {
];
}
export function buildTestPayload() {
export function buildTestMapPayload({
mainForm,
tempMap,
}) {
const { HostTestCode, HostTestName, ClientTestCode, ClientTestName, ConDefID, ...rest } = mainForm;
let payload = {
...rest,
details: tempMap.map((item) => ({
HostTestCode: item.HostTestCode,
HostTestName: item.HostTestName,
ClientTestCode: item.ClientTestCode,
ClientTestName: item.ClientTestName,
ConDefID: item.ConDefID,
})),
};
return cleanEmptyStrings(payload);
}

View File

@ -11,6 +11,7 @@
import { Button } from "$lib/components/ui/button/index.js";
import { untrack } from "svelte";
import { API } from '$lib/config/api';
import { buildTestMapPayload } from "$lib/components/dictionary/testmap/config/testmap-form-config";
let props = $props();
@ -37,21 +38,12 @@
function snapshotForm() {
return untrack(() => {
const f = formState.form;
// const options = {};
// for (const key in masterDetail.formState.selectOptions) {
// options[key] = [...masterDetail.formState.selectOptions[key]];
// }
return {
HostType: f.HostType ?? "",
HostID: f.HostID ?? "",
HostTestCode: f.HostTestCode ?? "",
HostTestName: f.HostTestName ?? "",
ClientType: f.ClientType ?? "",
ClientID: f.ClientID ?? "",
ClientTestCode: f.ClientTestCode ?? "",
ClientTestName: f.ClientTestName ?? "",
ConDefID: f.ConDefID ?? "",
// options: options
};
});
}
@ -61,6 +53,18 @@
editingId = null;
}
function resetTest() {
untrack(() => {
const f = formState.form;
f.HostTestCode = null;
f.HostTestName = null;
f.ClientTestCode = null;
f.ClientTestName = null;
f.ConDefID = null;
});
editingId = null;
}
function handleInsert() {
const row = {
id: ++idCounter,
@ -69,7 +73,7 @@
tempMap = [...tempMap, row];
resetForm();
resetTest();
}
async function handleEdit(row) {
@ -77,13 +81,12 @@
untrack(() => {
const f = formState.form;
console.log(row);
f.HostType = row.HostType;
f.HostID = row.HostID;
// f.HostType = row.HostType;
// f.HostID = row.HostID;
f.HostTestCode = row.HostTestCode;
f.HostTestName = row.HostTestName;
f.ClientType = row.ClientType;
f.ClientID = row.ClientID;
// f.ClientType = row.ClientType;
// f.ClientID = row.ClientID;
f.ClientTestCode = row.ClientTestCode;
f.ClientTestName = row.ClientTestName;
f.ConDefID = row.ConDefID;
@ -100,22 +103,29 @@
tempMap = tempMap.map((row) =>
row.id === editingId ? { id: row.id, ...snapshotForm() } : row
);
resetForm();
resetTest();
}
function handleCancelEdit() {
resetForm();
resetTest();
}
function handleRemove(id) {
tempMap = tempMap.filter((row) => row.id !== id);
if (editingId === id) {
resetForm();
resetTest();
}
}
async function handleSave() {
const result = await formState.save(masterDetail.mode);
const mainForm = masterDetail.formState.form;
const payload = buildTestMapPayload({
mainForm,
tempMap,
});
console.log(payload)
const result = await formState.save(masterDetail.mode, payload);
toast('Test Map Created!');
masterDetail?.exitForm(true);
@ -243,12 +253,12 @@
<Table.Root>
<Table.Header>
<Table.Row class="hover:bg-transparent">
<Table.Head>Host Type</Table.Head>
<Table.Head>Host ID</Table.Head>
<!-- <Table.Head>Host Type</Table.Head>
<Table.Head>Host ID</Table.Head> -->
<Table.Head>Host Test Code</Table.Head>
<Table.Head>Host Test Name</Table.Head>
<Table.Head>Client Type</Table.Head>
<Table.Head>Client ID</Table.Head>
<!-- <Table.Head>Client Type</Table.Head>
<Table.Head>Client ID</Table.Head> -->
<Table.Head>Client Test Code</Table.Head>
<Table.Head>Client Test Name</Table.Head>
<Table.Head>Container</Table.Head>
@ -267,12 +277,8 @@
<Table.Row
class="cursor-pointer hover:bg-muted/50"
>
<Table.Cell>{row.HostType}</Table.Cell>
<Table.Cell>{row.HostID}</Table.Cell>
<Table.Cell>{row.HostTestCode}</Table.Cell>
<Table.Cell>{row.HostTestName}</Table.Cell>
<Table.Cell>{row.ClientType}</Table.Cell>
<Table.Cell>{row.ClientID}</Table.Cell>
<Table.Cell>{row.ClientTestCode}</Table.Cell>
<Table.Cell>{row.ClientTestName}</Table.Cell>
<Table.Cell>{row.ConDefID}</Table.Cell>

View File

@ -0,0 +1,22 @@
import { API } from '$lib/config/api.js';
import { getById, searchWithParams, create, update } from '$lib/api/api-client';
export async function searchParam(searchQuery) {
return await searchWithParams(API.PATIENTS, searchQuery)
}
export async function getVisitList(searchQuery) {
return await getById(API.VISITLIST, searchQuery, true)
}
export async function getVisit(searchQuery) {
return await getById(API.PATVISIT, searchQuery)
}
export async function createOrder(newOrderForm) {
return await create(API.ORDER, newOrderForm)
}
export async function editOrder(editOrderForm) {
return await update(API.ORDER, editOrderForm)
}

View File

@ -0,0 +1,72 @@
import PlusIcon from "@lucide/svelte/icons/plus";
import Settings2Icon from "@lucide/svelte/icons/settings-2";
import PencilIcon from "@lucide/svelte/icons/pencil";
export const searchFields = [
{
key: "PatientID",
label: "Patient ID",
placeholder: "",
type: "text",
defaultValue: "",
},
{
key: "Name",
label: "Patient Name",
placeholder: "",
type: "text",
defaultValue: "",
},
{
key: "Birthdate",
label: "Birthdate",
type: "date"
},
{
key: "Identifier",
label: "Identifier",
type: "text"
},
{
key: "VisitID",
label: "Visit ID",
type: "text"
},
{
key: "EpisodeID",
label: "Episode ID",
type: "text"
},
];
export const detailSections = [];
export function orderActions(masterDetail, selectedPatient) {
return [
{
Icon: PlusIcon,
label: 'Add Order',
onClick: () => masterDetail.enterCreate({
PatientID: selectedPatient?.PatientID,
InternalPID: selectedPatient?.InternalPID
}),
disabled: !selectedPatient,
},
{
Icon: Settings2Icon,
label: 'Search Parameters',
popoverWidth: "w-full",
collisionPadding: 12,
},
];
}
export function viewActions(handlers){
return [
{
Icon: PencilIcon,
label: 'Edit Order',
onClick: handlers.editOrder,
},
]
}

View File

@ -0,0 +1,22 @@
import { API } from "$lib/config/api";
import EraserIcon from "@lucide/svelte/icons/eraser";
import { z } from "zod";
import { cleanEmptyStrings } from "$lib/utils/cleanEmptyStrings";
export const orderSchema = z.object({});
export const orderInitialForm = {};
export const orderDefaultErrors = {};
export const orderFormFields = [];
export function getOrderFormActions(handlers) {
return [
{
Icon: EraserIcon,
label: 'Clear Form',
onClick: handlers.clearForm,
},
];
}

View File

@ -0,0 +1,142 @@
<script>
import * as Table from "$lib/components/ui/table/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import { Label } from "$lib/components/ui/label/index.js";
import { Input } from "$lib/components/ui/input/index.js";
import ReusableCalendar from "$lib/components/reusable/reusable-calendar.svelte";
import { Spinner } from "$lib/components/ui/spinner/index.js";
import * as Select from "$lib/components/ui/select/index.js";
import { useSearch } from "$lib/components/composable/use-search.svelte";
import { searchFields } from "$lib/components/order/config/order-config";
import { searchParam } from "$lib/components/order/api/order-api";
import ReusableEmpty from "$lib/components/reusable/reusable-empty.svelte";
import { Checkbox } from "$lib/components/ui/checkbox/index.js";
import * as Popover from "$lib/components/ui/popover/index.js";
let { selectedPatient = $bindable(null), ...props } = $props();
let tempSelectedPatient = $state(null);
let activeRowId = $state(null);
let isPatientEmpty = $derived(!tempSelectedPatient);
function handleCheckboxChange(patient) {
tempSelectedPatient = patient;
}
function handleButtonClick() {
if (tempSelectedPatient) {
selectedPatient = tempSelectedPatient;
props.onConfirm(tempSelectedPatient);
tempSelectedPatient = null;
}
}
</script>
<div class="w-full h-110">
<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">
{#each props.searchFields as field}
{#if field.type === "text"}
<div class="space-y-2">
<Label for={field.key}>{field.label}</Label>
<Input type="text" id={field.key} placeholder={field.placeholder} bind:value={props.search.searchQuery[field.key]} autocomplete=off/>
</div>
{:else if field.type === "date"}
<div class="space-y-2">
<ReusableCalendar title={field.label} bind:value={props.search.searchQuery[field.key]}/>
</div>
{:else if field.type === "select"}
<div class="space-y-2">
<Label for={field.key}>{field.label}</Label>
<Select.Root bind:value={props.search.searchQuery[field.key]}>
<Select.Trigger id={field.key}>
<Select.Value placeholder={field.placeholder} />
</Select.Trigger>
<Select.Content>
{#each field.options as opt}
<Select.Item value={opt.value}>
{opt.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
{/if}
{/each}
</div>
<div class="flex justify-end gap-2 mt-4">
<Button variant="outline" size="sm" class="cursor-pointer" onclick={props.search.handleReset}>Reset</Button>
<Button size="sm" class="cursor-pointer" onclick={props.search.handleSearch} disabled={props.search.isLoading}>
{#if props.search.isLoading}
<Spinner />
{:else}
Search
{/if}
</Button>
</div>
</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">
<Table.Root>
<Table.Header>
<Table.Row class="hover:bg-transparent">
<Table.Head class="w-8"></Table.Head>
<Table.Head class="w-32">Patient ID</Table.Head>
<Table.Head class="w-full">Patient Name</Table.Head>
<Table.Head class="w-32">Birthdate</Table.Head>
<Table.Head class="w-8">Sex</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each props.search.searchData as patient, i}
<Table.Row
class="cursor-pointer hover:bg-muted/50"
onclick={() => handleCheckboxChange(patient)}
>
<Table.Cell onclick={(e) => e.stopPropagation()}>
<Checkbox
class="cursor-pointer hover:bg-muted/50"
checked={tempSelectedPatient?.InternalPID === patient.InternalPID}
onCheckedChange={() => handleCheckboxChange(patient)}
/>
</Table.Cell>
<Table.Cell class="font-medium">{patient.PatientID}</Table.Cell>
<Table.Cell class="">{patient.FullName}</Table.Cell>
<Table.Cell class="text-muted-foreground">{patient.Birthdate ? patient.Birthdate.split(" ")[0] : ""}</Table.Cell>
<Table.Cell class="font-medium">{patient.SexLabel}</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
</div>
<div class="flex justify-end gap-2 mt-4 w-full">
<Popover.Close>
<Button
size="sm"
class="cursor-pointer"
disabled={isPatientEmpty}
onclick={handleButtonClick}
>
Select Patient
</Button>
</Popover.Close>
</div>
{:else}
<div class="flex h-full">
<ReusableEmpty desc="Try searching from search parameters"/>
</div>
{/if}
</div>
<div class="flex flex-col w-1/3 h-full">
<div class="flex h-full">
<ReusableEmpty desc="Try searching from search parameters"/>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,90 @@
<script>
import { orderColumns } from "$lib/components/order/table/order-columns";
import { searchParam, getVisitList } from "$lib/components/order/api/order-api";
import { useSearch } from "$lib/components/composable/use-search.svelte";
import { searchFields, orderActions } from "$lib/components/order/config/order-config";
import TopbarWrapper from "$lib/components/topbar/topbar-wrapper.svelte";
import ReusableSearchParam from "$lib/components/reusable/reusable-search-param.svelte";
import ReusableEmpty from "$lib/components/reusable/reusable-empty.svelte";
import ReusableDataTable from "$lib/components/reusable/reusable-data-table.svelte";
import SearchParamModal from "$lib/components/order/modal/search-param-modal.svelte";
let props = $props();
let selectedPID = $state(null);
let selectedPatient = $state(null);
let tableData = $state([]);
let isLoading = $state(false);
let searchData = $state([]);
const search = useSearch(searchFields, searchParam);
let actions = $derived.by(() => {
return orderActions(props.masterDetail, selectedPatient).map(action => {
if (action.label === 'Search Parameters') {
return { ...action, popoverContent: searchParamSnippet };
}
return action;
});
});
let activeRowId = $state(null);
async function handlePatientConfirm(patient) {
selectedPatient = patient;
selectedPID = patient.InternalPID;
isLoading = true;
try {
searchData = await getVisitList(patient.InternalPID);
} catch (error) {
console.error('Search failed:', error);
} finally {
isLoading = false;
}
}
</script>
{#snippet searchParamSnippet()}
<SearchParamModal {search} {searchFields} bind:selectedPatient onConfirm={handlePatientConfirm}/>
{/snippet}
<div
role="button"
tabindex="0"
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%]"}
transition-all duration-300 flex flex-col items-center p-2 h-full overflow-y-auto
`}
>
<div class={`flex w-full ${props.masterDetail.isFormMode ? "flex-col justify-center h-full items-center" : "flex-col justify-start h-full"}`} >
{#if props.masterDetail.isFormMode}
<span class="flex flex-col items-center justify-center gap-4 tracking-widest font-semibold select-none">
{#each "ADMISSION".split("") as c}
<span class="leading-none">{c}</span>
{/each}
</span>
{/if}
{#if !props.masterDetail.isFormMode}
<div role="button" tabindex="0" class="flex flex-1 flex-col" onclick={(e) => e.stopPropagation()} onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
}
}}>
<TopbarWrapper {actions}/>
<div class="flex-1 w-full h-full">
{#if searchData?.data?.length > 0}
<ReusableDataTable data={searchData.data} columns={visitColumns} handleRowClick={props.masterDetail.select} {activeRowId} rowIdKey="InternalPVID"/>
{:else}
<div class="flex h-full">
<ReusableEmpty desc="Try searching from search parameters"/>
</div>
{/if}
</div>
</div>
{/if}
</div>
</div>

View File

@ -0,0 +1,14 @@
export const orderColumns = [
{
accessorKey: "InternalPVID",
header: "Patient ID"
},
{
accessorKey: "PVID",
header: "Visit ID",
},
{
accessorKey: "EpisodeID",
header: "Episode ID",
},
];

View File

@ -93,7 +93,8 @@ export function admissionActions(masterDetail, selectedPatient) {
{
Icon: Settings2Icon,
label: 'Search Parameters',
popoverWidth: "w-256",
popoverWidth: "w-full",
collisionPadding: 12,
},
];
}

View File

@ -46,7 +46,7 @@
<div class="w-full h-110">
<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">
<div class="flex-1 overflow-y-auto min-h-0 space-y-2 px-2">
{#each props.searchFields as field}
{#if field.type === "text"}
<div class="space-y-2">

View File

@ -76,10 +76,10 @@ export const detailSections = [
{ parentKey: "Custodian", key: "PatientID", label: "Custodian ID" },
],
},
{ key: "DeathIndicatorLabel", label: "Death Indicator" },
{ key: "isDeadLabel", label: "Deceased" },
{ key: "CreateDate", label: "Create Date", isUTCDate: true },
{ key: "DelDate", label: "Disabled Date" },
{ key: "TimeOfDeath", label: "Death Date", isUTCDate: true },
{ key: "TimeOfDeath", label: "Time of Death", isUTCDate: true },
]
},
// {

View File

@ -270,7 +270,7 @@ export const patientFormFields = [
type: "group",
columns: [
{
key: "DeathIndicator",
key: "isDead",
label: "Deceased",
required: false,
type: "select",

View File

@ -47,7 +47,6 @@
});
});
// if (formState.form.ProvinceID && formState.form.CityID) {
if (formState.form.Province) {
formState.fetchOptions(
{
@ -61,9 +60,35 @@
}
});
});
function getChangedFields(original, current) {
const changed = {};
for (const key in current) {
if (JSON.stringify(current[key]) !== JSON.stringify(original[key])) {
changed[key] = current[key];
}
}
return changed;
}
async function handleEdit() {
const payload = buildPatientPayload(formState.form);
const currentPayload = buildPatientPayload(formState.form);
const originalPayload = buildPatientPayload(masterDetail.formSnapshot);
const changedFields = getChangedFields(originalPayload, currentPayload);
if (Object.keys(changedFields).length === 0) {
toast('No changes detected');
return;
}
const payload = {
InternalPID: formState.form.InternalPID,
...changedFields
};
console.log('Payload:', payload);
const result = await formState.save(masterDetail.mode, payload);
if (result.status === 'success') {

View File

@ -68,7 +68,7 @@
}
let isDeathDateDisabled = $derived(
formState.form.DeathIndicator !== 'Y'
formState.form.isDead !== 'Y'
);
$effect(() => {

View File

@ -27,7 +27,7 @@
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content ${props.collisionPadding ?? 0} class={props.popoverWidth ?? "w-72"}>
<Popover.Content collisionPadding={props.collisionPadding ?? 0} class={props.popoverWidth ?? "w-72"}>
{@render props.popoverContent()}
</Popover.Content>
</Popover.Root>

View File

@ -0,0 +1,55 @@
<script>
import { Separator } from "$lib/components/ui/separator/index.js";
import { useMasterDetail } from "$lib/components/composable/use-master-detail.svelte";
import { getVisit, createOrder, editOrder } from "$lib/components/order/api/order-api";
import MasterPage from "$lib/components/order/page/master-page.svelte";
import ViewPage from "$lib/components/order/page/view-page.svelte";
import CreatePage from "$lib/components/order/page/create-page.svelte";
import EditPage from "$lib/components/order/page/edit-page.svelte";
import { orderSchema, orderInitialForm, orderDefaultErrors, orderFormFields, getOrderFormActions } from "$lib/components/order/config/order-form-config";
const masterDetail = useMasterDetail({
onSelect: async (row) => {
return await getVisit(row.PVID);
},
formConfig: {
schema: orderSchema,
initialForm: orderInitialForm,
defaultErrors: orderDefaultErrors,
mode: 'create',
modeOpt: 'default',
saveEndpoint: createOrder,
editEndpoint: editOrder,
}
});
const pageContext = {
masterDetail,
formFields: orderFormFields,
formActions: getOrderFormActions,
schema: orderSchema,
initialForm: orderInitialForm,
defaultErrors: {
create: orderDefaultErrors,
edit: {}
}
}
</script>
<div class="flex w-full h-full overflow-hidden">
{#if masterDetail.showMaster}
<MasterPage {masterDetail} />
{/if}
<Separator orientation="vertical"/>
{#if masterDetail.showDetail}
<main class={`${masterDetail.isMobile ? 'w-full' : masterDetail.isFormMode ? 'w-[97%] flex flex-col items-start' : 'w-[65%]'} h-full overflow-y-auto flex flex-col items-center transition-all duration-300`}>
{#if masterDetail.mode === "view"}
<ViewPage context={pageContext}/>
{:else if masterDetail.mode === "create"}
<CreatePage context={pageContext}/>
{:else if masterDetail.mode === "edit"}
<EditPage context={pageContext}/>
{/if}
</main>
{/if}
</div>