initial dict location done

This commit is contained in:
faiztyanirh 2026-02-14 22:39:01 +07:00
parent 9cada5fbd4
commit b82441d9af
9 changed files with 409 additions and 18 deletions

View File

@ -29,7 +29,7 @@ export function useMasterDetail(options = {}) {
const isDirty = $derived(
JSON.stringify(formState.form) !== JSON.stringify(formSnapshot)
);
// $inspect(formState.form)
// $inspect(formSnapshot)
async function select(item) {
mode = "view";
@ -63,6 +63,7 @@ export function useMasterDetail(options = {}) {
}
function enterEdit(param) {
console.log('f');
if (!selectedItem) return;
mode = "edit";

View File

@ -5,6 +5,9 @@ import { z } from "zod";
export const locationSchema = z.object({
LocCode: z.string().min(1, "Required"),
LocFull: z.string().min(1, "Required"),
Email: z.string().trim().optional().refine((val) => !val || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val),"Invalid email format"),
Phone: z.string().max(14, "Max 14 chars").regex(/^$|^[0-9]+$/, "Can only contain numbers"),
ZIP: z.string().regex(/^$|^[0-9]+$/, "Can only contain numbers"),
});
export const locationInitialForm = {
@ -27,6 +30,9 @@ export const locationInitialForm = {
export const locationDefaultErrors = {
LocCode: "Required",
LocFull: "Required",
Email: null,
Phone: null,
ZIP: null,
};
export const locationFormFields = [
@ -41,7 +47,9 @@ export const locationFormFields = [
label: "Site ID",
required: false,
type: "select",
optionsEndpoint: `${API.BASE_URL}${API.SITE}`,
optionsEndpoint: `${API.BASE_URL}${API.SITE}`,
valueKey: "SiteID",
labelKey: (item) => `${item.SiteCode} - ${item.SiteName}`,
},
{
key: "LocCode",
@ -59,7 +67,8 @@ export const locationFormFields = [
key: "LocType",
label: "Location Type",
required: false,
type: "text",
type: "select",
optionsEndpoint: `${API.BASE_URL}${API.VALUESET}/location_type`,
},
{
key: "LocFull",
@ -121,6 +130,7 @@ export const locationFormFields = [
label: "ZIP",
required: false,
type: "text",
validateOn: ["input"]
},
]
},
@ -136,13 +146,15 @@ export const locationFormFields = [
key: "Phone",
label: "Phone",
required: false,
type: "text"
type: "text",
validateOn: ["input"]
},
{
key: "Email",
label: "Email",
required: false,
type: "text",
type: "text",
validateOn: ["input"]
}
]
},

View File

@ -1,7 +1,7 @@
<script>
import { useDictionaryForm } from "$lib/components/composable/use-dictionary-form.svelte";
import FormPageContainer from "$lib/components/patient/reusable/form-page-container.svelte";
import PatientFormRenderer from "$lib/components/patient/reusable/patient-form-renderer.svelte";
import FormPageContainer from "$lib/components/reusable/form/form-page-container.svelte";
import DictionaryFormRenderer from "$lib/components/reusable/form/dictionary-form-renderer.svelte";
import { toast } from "svelte-sonner";
import ReusableAlertDialog from "$lib/components/reusable/reusable-alert-dialog.svelte";
@ -47,11 +47,11 @@
});
}
});
$inspect(formState.errors)
$inspect(formState.form)
</script>
<FormPageContainer title="Create Location" {primaryAction} {secondaryActions} {actions}>
<PatientFormRenderer
<DictionaryFormRenderer
{formState}
formFields={formFields}
mode="create"

View File

@ -0,0 +1,85 @@
<script>
import { useDictionaryForm } from "$lib/components/composable/use-dictionary-form.svelte";
import FormPageContainer from "$lib/components/reusable/form/form-page-container.svelte";
import DictionaryFormRenderer from "$lib/components/reusable/form/dictionary-form-renderer.svelte";
import { toast } from "svelte-sonner";
import { untrack } from "svelte";
import { API } from "$lib/config/api";
import ReusableAlertDialog from "$lib/components/reusable/reusable-alert-dialog.svelte";
let props = $props();
const { masterDetail, formFields, formActions, schema, initialForm } = props.context;
const { formState } = masterDetail;
const helpers = useDictionaryForm(formState);
let showConfirm = $state(false);
$effect(() => {
untrack(() => {
formFields.forEach(group => {
group.rows.forEach(row => {
row.columns.forEach(col => {
if (col.type === "group") {
col.columns.forEach(child => {
if (child.type === "select" && child.optionsEndpoint) {
formState.fetchOptions(child, formState.form);
}
});
} else if ((col.type === "select" || col.type === "identity") && col.optionsEndpoint) {
formState.fetchOptions(col, formState.form);
}
});
});
});
if (formState.form.Province) {
formState.fetchOptions(
{
key: "City",
optionsEndpoint: `${API.BASE_URL}${API.CITY}`,
dependsOn: "Province",
endpointParamKey: "Parent"
},
formState.form
);
}
});
});
async function handleEdit() {
const result = await formState.save(masterDetail.mode);
if (result.status === 'success') {
console.log('Location updated successfully');
toast('Location Updated!');
masterDetail.exitForm(true);
} else {
console.error('Failed to update location:', result.message);
}
}
const primaryAction = $derived({
label: 'Edit',
onClick: handleEdit,
disabled: helpers.hasErrors || formState.isSaving.current,
loading: formState.isSaving.current
});
const secondaryActions = [];
</script>
<FormPageContainer title="Edit Location" {primaryAction} {secondaryActions}>
<DictionaryFormRenderer
{formState}
formFields={formFields}
mode="edit"
/>
</FormPageContainer>
<ReusableAlertDialog
bind:open={masterDetail.showExitConfirm}
onConfirm={masterDetail.confirmExit}
/>

View File

@ -12,7 +12,7 @@
let location = $derived(masterDetail?.selectedItem?.data);
const handlers = {
editPatient: () => masterDetail.enterEdit("data"),
editLocation: () => masterDetail.enterEdit("data"),
};
const actions = viewActions(handlers);

View File

@ -11,7 +11,6 @@
import CustodianModal from "$lib/components/patient/list/modal/custodian-modal.svelte";
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";

View File

@ -0,0 +1,233 @@
<script>
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
import * as Select from "$lib/components/ui/select/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";
let {
formState,
formFields,
mode = 'create'
} = $props();
let searchQuery = $state({});
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())
);
}
$effect(() => {
initializeDefaultValues();
});
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;
}
}
</script>
{#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>
{#if required}
<span class="text-destructive text-xl leading-none h-3.5">*</span>
{/if}
</div>
<div class="relative flex flex-col items-center 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);
}
}}
onblur={() => {
if (validateOn?.includes("blur")) {
validateFieldAsync(key, mode, originalData?.[key]);
}
}}
/>
{:else if type === "email"}
<Input
type="email"
bind:value={formState.form[key]}
oninput={() => {
if (validateOn?.includes("input")) {
formState.validateField(key, formState.form[key], false);
}
}}
onblur={() => {
if (validateOn?.includes("blur")) {
formState.validateField(key, formState.form[key], false);
}
}}
/>
{:else if type === "number"}
<Input
type="number"
bind:value={formState.form[key]}
oninput={() => {
if (validateOn?.includes("input")) {
formState.validateField(key, formState.form[key], false);
}
}}
onblur={() => {
if (validateOn?.includes("blur")) {
formState.validateField(key, formState.form[key], false);
}
}}
/>
{:else if type === "textarea"}
<textarea
class="flex min-h-[80px] 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"
oninput={() => {
if (validateOn?.includes("input")) {
formState.validateField(key, formState.form[key], false);
}
}}
onblur={() => {
if (validateOn?.includes("blur")) {
formState.validateField(key, formState.form[key], false);
}
}}
bind:value={formState.form[key]}
></textarea>
{: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;
if (validateOn?.includes("input")) {
formState.validateField(key, formState.form[key], false);
}
if (key === "Province") {
formState.form.City = "";
formState.selectOptions.City = [];
formState.lastFetched.City = null;
}
}}
onOpenChange={(open) => {
if (open && optionsEndpoint) {
formState.fetchOptions(
{ key, optionsEndpoint, dependsOn, endpointParamKey, 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}
<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}
<div class="flex-1 min-h-0 overflow-y-auto p-2 space-y-6">
{#each formFields 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}
{#if col.type === "group"}
<div
class="grid grid-cols-1 gap-6 md:gap-2"
class:md:grid-cols-1={col.columns.length === 1}
class:md:grid-cols-2={col.columns.length === 2}
class:md:grid-cols-3={col.columns.length === 3}
>
{#each col.columns as child}
{@render Fieldset(child)}
{/each}
</div>
{:else}
{@render Fieldset(col)}
{/if}
{/each}
</div>
{/each}
</div>
{/each}
</div>

View File

@ -0,0 +1,61 @@
<script>
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import { Spinner } from "$lib/components/ui/spinner/index.js";
import TopbarWrapper from "$lib/components/topbar/topbar-wrapper.svelte";
import ChevronUpIcon from "@lucide/svelte/icons/chevron-up";
let {
title,
primaryAction,
secondaryActions,
actions,
children
} = $props();
</script>
<div class="flex flex-col p-2 gap-4 h-full w-full">
<TopbarWrapper actions={actions} title={title}/>
{@render children()}
<div class="mt-auto flex justify-end items-center pt-2">
<Button
size="sm"
class="cursor-pointer {secondaryActions.length ? 'rounded-r-none' : ''}"
disabled={primaryAction.disabled}
onclick={primaryAction.onClick}
>
{#if primaryAction.loading}
<Spinner />
{:else}
{primaryAction.label}
{/if}
</Button>
{#if secondaryActions.length}
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button
size="icon"
class="size-8 rounded-l-none"
disabled={primaryAction.disabled}
>
<ChevronUpIcon />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content collisionPadding={8}>
<DropdownMenu.Group>
{#each secondaryActions as action}
<DropdownMenu.Item
disabled={action.disabled}
onclick={action.onClick}
>
{action.label}
</DropdownMenu.Item>
{/each}
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Root>
{/if}
</div>
</div>

View File

@ -118,7 +118,7 @@
</Table.Root>
<div class="flex items-center justify-between p-2 mt-auto">
<div class="flex items-center space-x-2">
<p class="text-sm font-medium">Rows per page</p>
<p class="text-sm font-medium">Rows</p>
<Select.Root
allowDeselect={false}
type="single"
@ -127,11 +127,11 @@
table.setPageSize(Number(value));
}}
>
<Select.Trigger class="h-8 w-[70px]">
<Select.Trigger class="h-7 w-[70px]">
{String(table.getState().pagination.pageSize)}
</Select.Trigger>
<Select.Content side="top">
{#each [1, 2, 3, 4, 5] as pageSize (pageSize)}
{#each [5, 10, 15, 20, 25] as pageSize (pageSize)}
<Select.Item value={`${pageSize}`}>
{pageSize}
</Select.Item>
@ -147,7 +147,7 @@
<div class="flex items-center space-x-2">
<Button
variant="outline"
class="hidden size-8 p-0 lg:flex"
class="hidden size-7 p-0 lg:flex"
onclick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
@ -156,7 +156,7 @@
</Button>
<Button
variant="outline"
class="size-8 p-0"
class="size-7 p-0"
onclick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
@ -165,7 +165,7 @@
</Button>
<Button
variant="outline"
class="size-8 p-0"
class="size-7 p-0"
onclick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
@ -174,7 +174,7 @@
</Button>
<Button
variant="outline"
class="hidden size-8 p-0 lg:flex"
class="hidden size-7 p-0 lg:flex"
onclick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>