mirror of
https://github.com/faiztyanirh/clqms-shadcn-v1.git
synced 2026-04-26 02:46:32 +07:00
initial dict location done
This commit is contained in:
parent
9cada5fbd4
commit
b82441d9af
@ -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";
|
||||
|
||||
|
||||
@ -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"]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
@ -12,7 +12,7 @@
|
||||
let location = $derived(masterDetail?.selectedItem?.data);
|
||||
|
||||
const handlers = {
|
||||
editPatient: () => masterDetail.enterEdit("data"),
|
||||
editLocation: () => masterDetail.enterEdit("data"),
|
||||
};
|
||||
|
||||
const actions = viewActions(handlers);
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
233
src/lib/components/reusable/form/dictionary-form-renderer.svelte
Normal file
233
src/lib/components/reusable/form/dictionary-form-renderer.svelte
Normal 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>
|
||||
61
src/lib/components/reusable/form/form-page-container.svelte
Normal file
61
src/lib/components/reusable/form/form-page-container.svelte
Normal 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>
|
||||
@ -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()}
|
||||
>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user