initial create admission, fix view detail admission

This commit is contained in:
faiztyanirh 2026-02-09 17:31:02 +07:00
parent a4c2e0ec5f
commit aee9ea4b8f
14 changed files with 656 additions and 19 deletions

View File

@ -8,8 +8,17 @@ const optionsMode = {
const res = await fetch(field.optionsEndpoint);
const json = await res.json();
selectOptions[field.key] = json?.data ?? [];
// selectOptions[field.key] = json?.data ?? [];
const data = json?.data ?? [];
const valueKey = field.valueKey ?? 'value';
const labelKey = field.labelKey ?? 'label';
selectOptions[field.key] = data.map((item) => ({
value: item[valueKey],
label: typeof labelKey === 'function' ? labelKey(item) : item[labelKey],
}));
console.log(selectOptions);
} catch (err) {
console.error("Failed to fetch options for", field.key, err);
selectOptions[field.key] = [];
@ -52,8 +61,17 @@ const optionsMode = {
const res = await fetch(endpoint);
const json = await res.json();
selectOptions[field.key] = json?.data ?? [];
// selectOptions[field.key] = json?.data ?? [];
const data = json?.data ?? [];
const valueKey = field.valueKey ?? 'value';
const labelKey = field.labelKey ?? 'label';
selectOptions[field.key] = data.map((item) => ({
value: item[valueKey],
label: typeof labelKey === 'function' ? labelKey(item) : item[labelKey],
}));
console.log(selectOptions);
// Track last fetched parent value for dependent fields
if (field.dependsOn) {
lastFetched[field.key] = parentValue;

View File

@ -13,17 +13,10 @@ export async function getVisit(searchQuery) {
return await getById(API.PATVISIT, searchQuery)
}
export async function getPatient(searchQuery) {
const { data: patient, error } = await getById(API.PATIENTS, searchQuery)
return { patient };
export async function createAdmission(newAdmissionForm) {
return await create(API.PATVISIT, newAdmissionForm)
}
export async function createPatient(newContactForm) {
// console.log(JSON.stringify(newContactForm));
return await create(API.PATIENTS, newContactForm)
}
export async function editPatient(editContactForm) {
// console.log(JSON.stringify(editContactForm));
return await update(API.PATIENTS, editContactForm)
export async function editAdmission(editAdmissionForm) {
return await update(API.PATVISIT, editAdmissionForm)
}

View File

@ -1,5 +1,6 @@
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 = [
{
@ -38,6 +39,66 @@ export const searchFields = [
},
];
// export const detailSections = [
// {
// class: "grid grid-cols-1 sm:grid-cols-2 gap-3",
// fields: [
// { key: "PVID", label: "Visit ID" },
// { key: "EpisodeID", label: "Episode ID" },
// { key: "", label: "Visit Class" },
// { key: "", label: "Service Class" },
// { key: "LocationID", label: "Location" },
// { key: "AttDoc", label: "Attending Doctor" },
// { key: "RefDoc", label: "Reffering Doctor" },
// { key: "AdmDoc", label: "Admitting Doctor" },
// { key: "CnsDoc", label: "Consulting Doctor" },
// { key: "", label: "Admission Date", isUTCDate: true },
// { key: "", label: "Discharge Date", isUTCDate: true },
// { key: "Diagnosis", label: "Clinical Diagnosis" },
// ]
// },
// ]
export const detailSections = [
{
title: "", // No title for top row
class: "grid grid-cols-1 md:grid-cols-2 gap-4",
groups: [
{
class: "space-y-3",
fields: [
{ key: "PVID", label: "Visit ID" },
{ key: "EpisodeID", label: "Episode ID" },
{ key: "VisitClass", label: "Visit Class" },
{ key: "ServiceClass", label: "Service Class" },
]
},
{
class: "space-y-3",
fields: [
{ key: "LocationID", label: "Location" },
{ key: "AttDoc", label: "Attending Doctor" },
{ key: "RefDoc", label: "Referring Doctor" },
{ key: "AdmDoc", label: "Admitting Doctor" },
{ key: "CnsDoc", label: "Consulting Doctor" },
]
}
]
},
{
class: "grid grid-cols-2 gap-4 items-center",
fields: [
{ key: "AdmissionDate", label: "Admission Date", isUTCDate: true },
{ key: "DischargeDate", label: "Discharge Date", isUTCDate: true },
]
},
{
fields: [
{ key: "Diagnosis", label: "Clinical Diagnosis" },
]
}
];
export function admissionActions(masterDetail) {
return [
{
@ -51,4 +112,15 @@ export function admissionActions(masterDetail) {
popoverWidth: "w-256",
},
];
}
export function viewActions(handlers){
return [
{
Icon: PencilIcon,
label: 'Edit Patient',
onClick: handlers.editPatient,
},
]
}

View File

@ -0,0 +1,209 @@
import { API } from "$lib/config/api";
import EraserIcon from "@lucide/svelte/icons/eraser";
import { z } from "zod";
export const admissionSchema = z.object({});
export const admissionInitialForm = {
InternalPVID: "",
InternalPID: "",
PVID: "",
EpisodeID: "",
DiagCode: "",
Diagnosis: "",
ADTCode: "",
LocationID: "",
AttDoc: "",
RefDoc: "",
AdmDoc: "",
CnsDoc: "",
};
export const admissionDefaultErrors = {};
export const admissionFormFields = [
{
title: "Visit Information",
rows: [
{
type: "row",
columns: [
{
key: "PVID",
label: "Visit ID",
required: false,
type: "text",
},
{
key: "EpisodeID",
label: "Episode ID",
required: false,
type: "text",
}
]
}
]
},
{
title: "Medical Team",
rows: [
{
type: "row",
columns: [
{
key: "AttDoc",
label: "Attended Doctor",
required: false,
type: "select",
optionsEndpoint: `${API.BASE_URL}${API.CONTACT}`,
valueKey: "ContactID",
labelKey: (item) => `${item.Initial} - ${item.NameFirst} ${item.NameLast}`,
},
{
key: "RefDoc",
label: "Reference Doctor",
required: false,
type: "select",
optionsEndpoint: `${API.BASE_URL}${API.CONTACT}`,
valueKey: "ContactID",
labelKey: (item) => `${item.Initial} - ${item.NameFirst} ${item.NameLast}`,
}
]
},
{
type: "row",
columns: [
{
key: "AdmDoc",
label: "Admitted Doctor",
required: false,
type: "select",
optionsEndpoint: `${API.BASE_URL}${API.CONTACT}`,
valueKey: "ContactID",
labelKey: (item) => `${item.Initial} - ${item.NameFirst} ${item.NameLast}`,
},
{
key: "CnsDoc",
label: "Consulte Doctor",
required: false,
type: "select",
optionsEndpoint: `${API.BASE_URL}${API.CONTACT}`,
valueKey: "ContactID",
labelKey: (item) => `${item.Initial} - ${item.NameFirst} ${item.NameLast}`,
}
]
}
]
},
{
title: "Visit Classification",
rows: [
{
type: "row",
columns: [
{
key: "LocationID",
label: "Location",
required: false,
type: "select",
optionsEndpoint: `${API.BASE_URL}${API.LOCATION}`,
valueKey: "LocationID",
labelKey: (item) => `${item.LocCode} - ${item.LocFull}`,
},
{
key: "VisitClass",
label: "Visit Class",
required: false,
type: "select",
optionsEndpoint: `${API.BASE_URL}${API.VALUESET}/visit_classes`,
}
]
},
{
type: "row",
columns: [
{
key: "ServiceClass",
label: "Service Class",
required: false,
type: "select",
optionsEndpoint: `${API.BASE_URL}${API.VALUESET}/service_classes`,
},
{
key: "Discharge",
label: "Discharge Status",
required: false,
type: "toggle",
defaultValue: false,
}
]
}
]
},
{
title: "Clinical Information",
rows: [
{
type: "row",
columns: [
{
key: "Diagnosis",
label: "Diagnosis",
required: false,
type: "textarea",
rows: 4,
maxLength: 1000,
}
]
}
]
}
];
export function getAdmissionFormActions(handlers) {
return [
{
Icon: EraserIcon,
label: 'Clear Form',
onClick: handlers.clearForm,
},
];
}
const admissionTemplate = {
PVID: 'PVID',
InternalPID: 'InternalPID',
EpisodeID: 'EpisodeID',
PatDiag: {
DiagCode: 'DiagCode',
Diagnosis: 'Diagnosis',
},
PatVisitADT: {
ADTCode: () => 'A04',
LocationID: 'LocationID',
AttDoc: 'AttDoc',
RefDoc: 'RefDoc',
AdmDoc: 'AdmDoc',
CnsDoc: 'CnsDoc',
},
};
export function buildPayload(form, schema = admissionTemplate) {
const payload = {};
for (const [key, config] of Object.entries(schema)) {
if (typeof config === 'string') {
// Kirim nilai dari form, atau null jika tidak ada (agar key tetap ada)
payload[key] = form[config] ?? null;
}
else if (typeof config === 'function') {
payload[key] = config(form);
}
else if (typeof config === 'object' && config !== null) {
// Rekursif tanpa pengecekan panjang keys, agar objek nested selalu dibuat
payload[key] = buildPayload(form, config);
}
}
return payload;
}

View File

@ -1 +1,60 @@
cr
<script>
import { useForm } from "$lib/components/composable/use-form.svelte";
import { admissionSchema, admissionInitialForm, admissionDefaultErrors, admissionFormFields, getAdmissionFormActions, buildPayload } from "$lib/components/patient/admission/config/admission-form-config";
import { createAdmission } from "$lib/components/patient/admission/api/patient-admission-api";
import { usePatientForm } from "$lib/components/composable/use-patient-form.svelte";
import FormPageContainer from "$lib/components/patient/reusable/form-page-container.svelte";
import PatientFormRenderer from "$lib/components/patient/reusable/patient-form-renderer.svelte";
let props = $props();
let formState = useForm({
schema: admissionSchema,
initialForm: admissionInitialForm,
defaultErrors: admissionDefaultErrors,
mode: 'create',
modeOpt: 'default',
saveEndpoint: createAdmission,
editEndpoint: null,
});
const helpers = usePatientForm(formState, admissionSchema);
const handlers = {
clearForm: () => {
formState.reset();
}
};
const actions = getAdmissionFormActions(handlers);
async function handleSave() {
const payload = buildPayload(formState.form);
console.log("Payload siap kirim:", payload);
}
const primaryAction = $derived({
label: 'Save',
onClick: handleSave,
disabled: helpers.hasErrors || formState.isSaving.current,
loading: formState.isSaving.current
});
const secondaryActions = [];
</script>
<FormPageContainer title="Create Admission" {primaryAction} {secondaryActions} {actions}>
<PatientFormRenderer
{formState}
formFields={admissionFormFields}
uploadErrors={helpers.uploadErrors}
isChecking={helpers.isChecking}
linkToDisplay={helpers.linkToDisplay}
validateIdentifier={helpers.validateIdentifier}
validateFieldAsync={helpers.validateFieldAsync}
mode="create"
/>
</FormPageContainer>

View File

@ -1 +1,112 @@
vw
<script>
import { formatUTCDate } from "$lib/utils/formatUTCDate";
import { detailSections, viewActions } from "$lib/components/patient/admission/config/admission-config";
import TopbarWrapper from "$lib/components/topbar/topbar-wrapper.svelte";
import ReusableEmpty from "$lib/components/reusable/reusable-empty.svelte";
let props = $props();
let visit = $derived(props.masterDetail?.selectedItem?.data);
const handlers = {
editPatient: () => props.masterDetail.enterEdit(),
};
const actions = viewActions(handlers);
function getFieldValue(field) {
if (!visit) return "-";
if (field.keys) {
return field.keys
.map(k => field.parentKey ? visit[field.parentKey]?.[k] : visit[k])
.filter(val => val && val.trim() !== "")
.join(" / ");
}
return field.parentKey ? visit[field.parentKey]?.[field.key] : visit[field.key];
}
</script>
{#snippet Fieldset({ value, label, isUTCDate = false })}
<div class="space-y-1.5">
<dt class="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{label}
</dt>
<dd class="text-sm font-medium">
{#if isUTCDate}
{formatUTCDate(value)}
{:else}
{value ?? "-"}
{/if}
</dd>
</div>
{/snippet}
{#if props.masterDetail.selectedItem}
<div class="flex flex-col px-2 py-1 gap-2 h-full w-full">
<TopbarWrapper
title={props.masterDetail.selectedItem.data.PVID}
{actions}
/>
<div class="flex-1 min-h-0 overflow-y-auto space-y-4">
{#each detailSections as section}
<div class="p-4">
{#if section.groups}
<div class={section.class}>
{#each section.groups as group}
<div>
<div class={group.class}>
{#each group.fields as field}
{@render Fieldset({
label: field.label,
value: getFieldValue(field),
isUTCDate: field.isUTCDate
})}
{/each}
</div>
</div>
{/each}
</div>
{:else}
<div class={section.class}>
{#each section.fields as field}
{@render Fieldset({
label: field.label,
value: getFieldValue(field),
isUTCDate: field.isUTCDate
})}
{/each}
</div>
{/if}
</div>
{/each}
</div>
</div>
{:else}
<ReusableEmpty desc="Select a visit to see details"/>
{/if}
<!-- {#if props.masterDetail.selectedItem}
<div class="flex flex-col px-2 py-1 gap-2 h-full w-full">
<TopbarWrapper title={props.masterDetail.selectedItem.data.PVID} {actions} />
<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}>
{#each section.fields as field}
{@render Fieldset({
label: field.label,
value: getFieldValue(field),
isUTCDate: field.isUTCDate,
})}
{/each}
</div>
</div>
{/each}
</div>
</div>
{:else}
<ReusableEmpty desc="Select a visit to see details"/>
{/if} -->

View File

@ -6,7 +6,6 @@
import ReusableEmpty from "$lib/components/reusable/reusable-empty.svelte";
let props = $props();
let patient = $derived(props.masterDetail?.selectedItem?.patient);
const handlers = {

View File

@ -1,6 +1,7 @@
<script>
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
import * as Select from "$lib/components/ui/select/index.js";
import * as ToggleGroup from "$lib/components/ui/toggle-group/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";
@ -11,6 +12,8 @@
import LinktoModal from "$lib/components/patient/list/modal/linkto-modal.svelte";
import ReusableUpload from "$lib/components/reusable/reusable-upload.svelte";
import ChevronUpIcon from "@lucide/svelte/icons/chevron-up";
import CheckIcon from "@lucide/svelte/icons/check";
import XIcon from "@lucide/svelte/icons/x";
let {
formState,
@ -75,7 +78,7 @@
});
</script>
{#snippet Fieldset({ key, label, required, type, optionsEndpoint, options, validateOn, dependsOn, endpointParamKey })}
{#snippet Fieldset({ key, label, required, type, optionsEndpoint, options, validateOn, dependsOn, endpointParamKey, valueKey, labelKey })}
<div class="flex w-full flex-col gap-1.5">
<div class="flex justify-between items-center w-full">
<Label>{label}</Label>
@ -184,7 +187,7 @@
onOpenChange={(open) => {
if (open && optionsEndpoint) {
formState.fetchOptions(
{ key, optionsEndpoint, dependsOn, endpointParamKey },
{ key, optionsEndpoint, dependsOn, endpointParamKey, valueKey, labelKey },
formState.form
);
}
@ -279,6 +282,26 @@
</div>
{/if}
</div>
{:else if type === "toggle"}
<div class="flex items-center w-full">
<ToggleGroup.Root variant="outline" type="single" class="w-full" >
<ToggleGroup.Item
value="yes"
aria-label="Toggle Yes"
class="flex gap-2 px-4 w-1/2 transition-all data-[state=on]:bg-green-50 data-[state=on]:text-green-700 data-[state=on]:border-green-200 data-[state=on]:*:[svg]:stroke-[5px]"
>
<CheckIcon class="h-4 w-4" />
</ToggleGroup.Item>
<ToggleGroup.Item
value="no"
aria-label="Toggle No"
class="flex gap-2 px-4 w-1/2 transition-all data-[state=on]:bg-red-50 data-[state=on]:text-red-700 data-[state=on]:border-red-200 data-[state=on]:*:[svg]:stroke-[5px]"
>
<XIcon class="h-6 w-6" />
</ToggleGroup.Item>
</ToggleGroup.Root>
</div>
{:else}
<Input
type="text"

View File

@ -0,0 +1,10 @@
import Root from "./toggle-group.svelte";
import Item from "./toggle-group-item.svelte";
export {
Root,
Item,
//
Root as ToggleGroup,
Item as ToggleGroupItem,
};

View File

@ -0,0 +1,35 @@
<script>
import { ToggleGroup as ToggleGroupPrimitive } from "bits-ui";
import { getToggleGroupCtx } from "./toggle-group.svelte";
import { cn } from "$lib/utils.js";
import { toggleVariants } from "$lib/components/ui/toggle/index.js";
let {
ref = $bindable(null),
value = $bindable(),
class: className,
size,
variant,
...restProps
} = $props();
const ctx = getToggleGroupCtx();
</script>
<ToggleGroupPrimitive.Item
bind:ref
data-slot="toggle-group-item"
data-variant={ctx.variant || variant}
data-size={ctx.size || size}
data-spacing={ctx.spacing}
class={cn(
toggleVariants({
variant: ctx.variant || variant,
size: ctx.size || size,
}),
"w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10 data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l",
className
)}
{value}
{...restProps}
/>

View File

@ -0,0 +1,52 @@
<script module>
import { getContext, setContext } from "svelte";
import { toggleVariants } from "$lib/components/ui/toggle/index.js";
export function setToggleGroupCtx(props) {
setContext("toggleGroup", props);
}
export function getToggleGroupCtx() {
return getContext("toggleGroup");
}
</script>
<script>
import { ToggleGroup as ToggleGroupPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
value = $bindable(),
class: className,
size = "default",
spacing = 0,
variant = "default",
...restProps
} = $props();
setToggleGroupCtx({
variant,
size,
spacing,
});
</script>
<!--
Discriminated Unions + Destructing (required for bindable) do not
get along, so we shut typescript up by casting `value` to `never`.
-->
<ToggleGroupPrimitive.Root
bind:value={value}
bind:ref
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
style={`--gap: ${spacing}`}
class={cn(
"group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs",
className
)}
{...restProps}
/>

View File

@ -0,0 +1,11 @@
import Root from "./toggle.svelte";
export {
toggleVariants,
} from "./toggle.svelte";
export {
Root,
//
Root as Toggle,
};

View File

@ -0,0 +1,46 @@
<script module>
import { tv } from "tailwind-variants";
export const toggleVariants = tv({
base: "hover:bg-muted hover:text-muted-foreground data-[state=on]:bg-accent data-[state=on]:text-accent-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
variants: {
variant: {
default: "bg-transparent",
outline:
"border-input hover:bg-accent hover:text-accent-foreground border bg-transparent shadow-xs",
},
size: {
default: "h-9 min-w-9 px-2",
sm: "h-8 min-w-8 px-1.5",
lg: "h-10 min-w-10 px-2.5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
</script>
<script>
import { Toggle as TogglePrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
pressed = $bindable(false),
class: className,
size = "default",
variant = "default",
...restProps
} = $props();
</script>
<TogglePrimitive.Root
bind:ref
bind:pressed
data-slot="toggle"
class={cn(toggleVariants({ variant, size }), className)}
{...restProps}
/>

View File

@ -12,7 +12,6 @@
return await getVisit(row.PVID);
},
});
$inspect(masterDetail.selectedItem)
</script>
<div class="flex w-full h-full overflow-hidden">