refactor create contact ui

This commit is contained in:
faiztyanirh 2026-04-06 21:28:30 +07:00
parent 45a6f116cc
commit ec14173256
8 changed files with 589 additions and 37 deletions

View File

@ -1,10 +1,21 @@
import { API } from "$lib/config/api";
import { Contact } from "@lucide/svelte";
import EraserIcon from "@lucide/svelte/icons/eraser";
import { z } from "zod";
import { cleanEmptyStrings } from "$lib/utils/cleanEmptyStrings";
export const contactSchema = z.object({
NameFirst: z.string().min(1, "Required"),
Initial: z.string().min(1, "Required"),
EmailAddress1: z.string().trim().optional().refine((val) => !val || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val),"Invalid email format"),
EmailAddress2: z.string().trim().optional().refine((val) => !val || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val),"Invalid email format"),
MobilePhone1: z.string().max(14, "Max 14 chars").regex(/^$|^[0-9]+$/, "Can only contain numbers"),
MobilePhone2: z.string().max(14, "Max 14 chars").regex(/^$|^[0-9]+$/, "Can only contain numbers"),
Phone: z.string().max(14, "Max 14 chars").regex(/^$|^[0-9]+$/, "Can only contain numbers"),
});
export const contactDetailSchema = z.object({
ContactEmail: z.string().trim().optional().refine((val) => !val || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val),"Invalid email format"),
});
export const contactInitialForm = {
@ -24,8 +35,8 @@ export const contactInitialForm = {
};
export const contactDetailInitialForm = {
SiteID: '',
ContactDetID: '',
SiteID: '',
ContactCode: '',
ContactEmail: '',
OccupationID: '',
@ -36,6 +47,15 @@ export const contactDetailInitialForm = {
export const contactDefaultErrors = {
NameFirst: "Required",
Initial: "Required",
EmailAddress1: null,
EmailAddress2: null,
MobilePhone1: null,
MobilePhone2: null,
Phone: null,
};
export const contactDetailDefaultErrors = {
ContactEmail: null,
};
export const contactFormFields = [
@ -102,13 +122,15 @@ export const contactFormFields = [
key: "EmailAddress1",
label: "Email Address 1",
required: false,
type: "text",
type: "email",
validateOn: ["input"],
},
{
key: "EmailAddress2",
label: "Email Address 2",
required: false,
type: "text",
type: "email",
validateOn: ["input"],
},
]
},
@ -119,19 +141,22 @@ export const contactFormFields = [
key: "MobilePhone1",
label: "Mobile Phone 1",
required: false,
type: "text",
type: "text",
validateOn: ["input"],
},
{
key: "MobilePhone2",
label: "Mobile Phone 2",
required: false,
type: "text",
type: "text",
validateOn: ["input"],
},
{
key: "Phone",
label: "Phone",
required: false,
type: "text",
validateOn: ["input"],
},
]
},
@ -163,6 +188,7 @@ export const contactFormFields = [
export const contactDetailFormFields = [
{
title: "Contact Detail Information",
rows: [
{
type: "row",
@ -221,4 +247,23 @@ export function getContactFormActions(handlers) {
onClick: handlers.clearForm,
},
];
}
export function buildContactPayload({
mainForm,
tempDetailContact,
}) {
let payload = {
...mainForm,
Details: tempDetailContact.map((item) => ({
SiteID: item.SiteID,
ContactCode: item.ContactCode,
ContactEmail: item.ContactEmail,
Department: item.Department,
OccupationID: item.OccupationID,
JobTitle: item.JobTitle,
})),
};
return cleanEmptyStrings(payload);
}

View File

@ -4,6 +4,14 @@
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";
import { useForm } from "$lib/components/composable/use-form.svelte";
import { contactDetailSchema, contactDetailInitialForm, contactDetailDefaultErrors, contactDetailFormFields, buildContactPayload } from "$lib/components/dictionary/contact/config/contact-form-config";
import { Separator } from '$lib/components/ui/separator/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import PencilIcon from "@lucide/svelte/icons/pencil";
import Trash2Icon from "@lucide/svelte/icons/trash-2";
import { untrack } from "svelte";
let props = $props();
@ -11,11 +19,23 @@
const { formState } = masterDetail;
let editingId = $state(null);
let idCounter = $state(0);
let tempDetailContact = $state([]);
const contactDetailFormState = useForm({
schema: contactDetailSchema,
initialForm: contactDetailInitialForm,
defaultErrors: contactDetailDefaultErrors,
});
const helpers = useDictionaryForm(formState);
const handlers = {
clearForm: () => {
formState.reset();
contactDetailFormState.reset();
tempDetailContact = [];
}
};
@ -23,11 +43,80 @@
let showConfirm = $state(false);
async function handleSave() {
const result = await formState.save(masterDetail.mode);
function snapshotForm() {
return untrack(() => {
const f = contactDetailFormState.form;
return {
SiteID: f.SiteID ?? "",
ContactCode: f.ContactCode ?? "",
ContactEmail: f.ContactEmail ?? "",
Department: f.Department ?? "",
OccupationID: f.OccupationID ?? "",
JobTitle: f.JobTitle ?? "",
};
});
}
toast('Contact Created!');
masterDetail?.exitForm(true);
function resetContactDetailForm() {
contactDetailFormState.reset();
editingId = null;
}
function handleInsertDetail() {
const row = {
id: ++idCounter,
...snapshotForm()
};
tempDetailContact = [...tempDetailContact, row];
resetContactDetailForm();
}
async function handleEditDetail(row) {
editingId = row.id;
untrack(() => {
const f = contactDetailFormState.form;
f.SiteID = row.SiteID;
f.ContactCode = row.ContactCode;
f.ContactEmail = row.ContactEmail;
f.Department = row.Department;
f.OccupationID = row.OccupationID;
f.JobTitle = row.JobTitle;
});
}
function handleUpdateDetail() {
tempDetailContact = tempDetailContact.map((row) =>
row.id === editingId ? { id: row.id, ...snapshotForm() } : row
);
resetContactDetailForm();
}
function handleCancelEditDetail() {
resetContactDetailForm();
}
function handleRemoveDetail(id) {
tempDetailContact = tempDetailContact.filter((row) => row.id !== id);
if (editingId === id) {
resetContactDetailForm();
}
}
async function handleSave() {
const mainForm = masterDetail.formState.form;
const payload = buildContactPayload({
mainForm,
tempDetailContact,
});
console.log(payload)
// const result = await formState.save(masterDetail.mode);
// toast('Contact Created!');
// masterDetail?.exitForm(true);
}
const primaryAction = $derived({
@ -46,6 +135,83 @@
formFields={formFields}
mode="create"
/>
<Separator class="my-4"/>
<div>
<DictionaryFormRenderer
formState={contactDetailFormState}
formFields={contactDetailFormFields}
mode="create"
/>
<div class="flex gap-2 mt-1 ms-2">
{#if editingId !== null}
<Button size="sm" class="cursor-pointer" onclick={handleUpdateDetail}>Update</Button>
<Button size="sm" variant="outline" class="cursor-pointer" onclick={handleCancelEditDetail}>
Cancel
</Button>
{:else}
<Button size="sm" class="cursor-pointer" onclick={handleInsertDetail}>Insert</Button>
{/if}
</div>
</div>
<div class="mt-4">
<Separator />
<Table.Root>
<Table.Header>
<Table.Row class="hover:bg-transparent">
<Table.Head>Site</Table.Head>
<Table.Head>Code</Table.Head>
<Table.Head>Department</Table.Head>
<Table.Head>Occupation</Table.Head>
<Table.Head>Job Title</Table.Head>
<Table.Head>Email</Table.Head>
<Table.Head class="w-[80px]"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if tempDetailContact.length === 0}
<Table.Row>
<Table.Cell colspan={9} class="text-center text-muted-foreground py-6">
No data. Fill the form above and click Insert.
</Table.Cell>
</Table.Row>
{:else}
{#each tempDetailContact as row (row.id)}
<Table.Row>
<Table.Cell>{row.SiteID}</Table.Cell>
<Table.Cell>{row.ContactCode}</Table.Cell>
<Table.Cell>{row.Department}</Table.Cell>
<Table.Cell>{row.OccupationID}</Table.Cell>
<Table.Cell>{row.JobTitle}</Table.Cell>
<Table.Cell>{row.ContactEmail}</Table.Cell>
<Table.Cell class="w-[80px]">
<div class="flex gap-1">
<Button
size="icon"
variant="ghost"
class="h-7 w-7 cursor-pointer"
onclick={() => handleEditDetail(row)}
>
<PencilIcon class="h-3.5 w-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
class="h-7 w-7 cursor-pointer"
onclick={() => handleRemoveDetail(row.id)}
>
<Trash2Icon class="h-3.5 w-3.5" />
</Button>
</div>
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</div>
</FormPageContainer>
<ReusableAlertDialog

View File

@ -0,0 +1,309 @@
<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";
import { Separator } from "$lib/components/ui/separator/index.js";
import PlusIcon from "@lucide/svelte/icons/plus";
import * as Card from "$lib/components/ui/card/index.js";
import { Badge } from "$lib/components/ui/badge/index.js";
import TopbarWrapper from "$lib/components/topbar/topbar-wrapper.svelte";
import { contactDetailInitialForm, contactDetailFormFields } from "$lib/components/dictionary/contact/config/contact-form-config";
import { Button } from "$lib/components/ui/button/index.js";
import { useForm } from "$lib/components/composable/use-form.svelte";
import XIcon from "@lucide/svelte/icons/x";
import Edit2Icon from "@lucide/svelte/icons/edit-2";
import { getChangedFields } from "$lib/utils/getChangedFields";
let props = $props();
const { masterDetail, formFields, formActions, schema, initialForm } = props.context;
const { formState } = masterDetail;
const detailFormState = useForm({
schema: null,
initialForm: contactDetailInitialForm,
defaultErrors: {},
mode: 'create',
modeOpt: 'default',
saveEndpoint: null,
editEndpoint: null,
});
let showDetailForm = $state(false);
let editingDetailIndex = $state(null);
let isEditingDetail = $derived(editingDetailIndex !== null);
const helpers = useDictionaryForm(formState);
let showConfirm = $state(false);
function getLabel(fieldKey, value) {
if (!detailFormState.selectOptions?.[fieldKey]) return value;
const option = detailFormState.selectOptions[fieldKey].find(opt => opt.value === value);
return option?.label || value || "-";
}
$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.optionsEndpoint) {
formState.fetchOptions(col, formState.form);
}
});
});
});
});
});
$effect(() => {
untrack(() => {
contactDetailFormFields.forEach(group => {
group.rows.forEach(row => {
row.columns.forEach(col => {
if (col.type === "select" && col.optionsEndpoint) {
detailFormState.fetchOptions(col, detailFormState.form);
}
});
});
});
});
});
let editedDetails = $state(masterDetail.selectedItem?.data?.Details || []);
async function handleEdit() {
const originalDetails = masterDetail.selectedItem?.data?.Details || [];
const currentPayload = $state.snapshot({ ...formState.form, Details: editedDetails });
const originalPayload = $state.snapshot({ ...masterDetail.formSnapshot, Details: originalDetails });
console.log('Current Payload:', editedDetails);
console.log('Original Payload:', originalDetails);
// const customPayload = {
// ...formState.form,
// Details: masterDetail.selectedItem?.data?.Details || []
// };
// console.log('Custom Payload for Edit:', JSON.stringify(customPayload));
// const result = await formState.save(masterDetail.mode, customPayload);
// ***
const changedFields = getChangedFields(originalPayload, currentPayload);
console.log('Changed Fields:', JSON.stringify(changedFields));
// if (Object.keys(changedFields).length === 0) {
// toast('No changes detected');
// return;
// }
// const payload = {
// ContactID: formState.form.ContactID,
// ...changedFields
// };
// console.log('Custom Payload for Edit:', payload);
// ***
// const result = await formState.save(masterDetail.mode, payload);
// if (result.status === 'success') {
// console.log('Contact updated successfully');
// toast('Contact Updated!');
// masterDetail.exitForm(true);
// } else {
// console.error('Failed to update contact:', result.message);
// }
}
const primaryAction = $derived({
label: 'Edit',
onClick: handleEdit,
disabled: helpers.hasErrors || formState.isSaving.current,
loading: formState.isSaving.current
});
const secondaryActions = [];
const actionsDetail = [
{
Icon: PlusIcon,
label: 'Add Contact Detail',
onClick: () => addDetail(),
},
];
function addDetail() {
editingDetailIndex = null; // Mode create baru
detailFormState.reset(); // Reset form ke initialForm
detailFormState.setForm({ ...contactDetailInitialForm }); // Set form kosong
showDetailForm = true;
}
async function saveDetail() {
// Ambil current form dari detailFormState.form
const newDetail = { ...detailFormState.form };
if (isEditingDetail) {
// Mode edit: update detail yang ada
masterDetail.selectedItem.data.Details[editingDetailIndex] = newDetail;
toast('Contact Detail Updated!');
} else {
// Mode create: tambah detail baru
if (!masterDetail.selectedItem.data.Details) {
masterDetail.selectedItem.data.Details = [];
}
masterDetail.selectedItem.data.Details.push(newDetail);
toast('Contact Detail Added!');
}
// Reset form dan tutup form
detailFormState.reset();
detailFormState.setForm({ ...contactDetailInitialForm });
editingDetailIndex = null;
showDetailForm = false;
}
function editDetail(index) {
const detailToEdit = masterDetail.selectedItem.data.Details[index];
editingDetailIndex = index; // Set mode edit dengan index
detailFormState.setForm({ ...detailToEdit }); // Load data ke form
showDetailForm = true;
}
function cancelDetail() {
detailFormState.reset();
detailFormState.setForm({ ...contactDetailInitialForm });
editingDetailIndex = null;
showDetailForm = false;
}
function removeDetail(index) {
masterDetail.selectedItem.data.Details = masterDetail.selectedItem.data.Details.filter((_, i) => i !== index);
toast('Contact Detail Removed!');
// Jika sedang mengedit detail yang dihapus, reset form
if (editingDetailIndex === index) {
cancelDetail();
} else if (editingDetailIndex !== null && editingDetailIndex > index) {
// Adjust index jika mengedit detail setelah yang dihapus
editingDetailIndex--;
}
}
</script>
<FormPageContainer title="Edit Contact" {primaryAction} {secondaryActions}>
<DictionaryFormRenderer
{formState}
formFields={formFields}
mode="edit"
/>
<Separator class="my-4"/>
<div class="flex flex-col px-2 py-1 gap-2 h-fit w-full">
<TopbarWrapper
title="Contact Detail"
actions={actionsDetail}
/>
<div class="flex flex-col gap-4">
{#if showDetailForm}
<Card.Root class="w-full gap-2 2xl:gap-4 py-2 2xl:py-4">
<Card.Content class="space-y-3">
<DictionaryFormRenderer
formState={detailFormState}
formFields={contactDetailFormFields}
/>
</Card.Content>
<Card.Footer class="flex justify-end flex-end gap-2">
<Button size="sm" onclick={saveDetail}>
{isEditingDetail ? 'Update Detail' : 'Save Detail'}
</Button>
<Button size="sm" variant="outline" onclick={cancelDetail}>Cancel</Button>
</Card.Footer>
</Card.Root>
{/if}
{#each masterDetail.selectedItem?.data?.Details as contactdetail, index}
<Card.Root class="w-full gap-2 2xl:gap-4 py-2 2xl:py-4">
<Card.Header>
<div class="flex items-start justify-between">
<div class="space-y-1">
<Card.Title class="text-sm font-medium">
{contactdetail.ContactCode || "null"}
</Card.Title>
<Card.Description class="text-sm font-medium">
{contactdetail.ContactEmail || "null"}
</Card.Description>
</div>
<div class="flex items-center gap-4">
<Badge variant="outline" class="text-xs">
{getLabel('SiteID', contactdetail.SiteID)}
</Badge>
<div>
<Button
size="icon"
variant="ghost" class="size-7"
onclick={() => editDetail(index)}
>
<Edit2Icon class="h-4 w-4" />
</Button>
<Button
size="icon"
variant="ghost" class="size-7"
onclick={() => removeDetail(index)}
>
<XIcon class="h-4 w-4" />
</Button>
</div>
</div>
</div>
</Card.Header>
<Card.Content class="space-y-3">
<div class="grid grid-cols-3 gap-3">
<div class="space-y-1">
<p class="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Department
</p>
<p class="text-sm font-medium">
{getLabel('Department', contactdetail.Department)}
</p>
</div>
<div class="space-y-1">
<p class="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Job Title
</p>
<p class="text-sm font-medium">
{contactdetail.JobTitle || "-"}
</p>
</div>
<div class="space-y-1">
<p class="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Occupation
</p>
<p class="text-sm font-medium">
<!-- {contactdetail.OccupationID || "-"} -->
{getLabel('OccupationID', contactdetail.OccupationID)}
</p>
</div>
</div>
</Card.Content>
</Card.Root>
{/each}
</div>
</div>
</FormPageContainer>
<ReusableAlertDialog
bind:open={masterDetail.showExitConfirm}
onConfirm={masterDetail.confirmExit}
/>

View File

@ -16,6 +16,7 @@
import { useForm } from "$lib/components/composable/use-form.svelte";
import XIcon from "@lucide/svelte/icons/x";
import Edit2Icon from "@lucide/svelte/icons/edit-2";
import { getChangedFields } from "$lib/utils/getChangedFields";
let props = $props();
@ -43,6 +44,10 @@
let showConfirm = $state(false);
let editingId = $state(null);
let idCounter = $state(0);
let tempMap = $state([]);
function getLabel(fieldKey, value) {
if (!detailFormState.selectOptions?.[fieldKey]) return value;
const option = detailFormState.selectOptions[fieldKey].find(opt => opt.value === value);
@ -83,21 +88,49 @@
});
});
async function handleEdit() {
const customPayload = {
...formState.form,
Details: masterDetail.selectedItem?.data?.Details || []
};
let editedDetails = $state(masterDetail.selectedItem?.data?.Details || []);
const result = await formState.save(masterDetail.mode, customPayload);
async function handleEdit() {
const originalDetails = masterDetail.selectedItem?.data?.Details || [];
const currentPayload = $state.snapshot({ ...formState.form, Details: editedDetails });
const originalPayload = $state.snapshot({ ...masterDetail.formSnapshot, Details: originalDetails });
console.log('Current Payload:', editedDetails);
console.log('Original Payload:', originalDetails);
if (result.status === 'success') {
console.log('Contact updated successfully');
toast('Contact Updated!');
masterDetail.exitForm(true);
} else {
console.error('Failed to update contact:', result.message);
}
// const customPayload = {
// ...formState.form,
// Details: masterDetail.selectedItem?.data?.Details || []
// };
// console.log('Custom Payload for Edit:', JSON.stringify(customPayload));
// const result = await formState.save(masterDetail.mode, customPayload);
// ***
const changedFields = getChangedFields(originalPayload, currentPayload);
console.log('Changed Fields:', JSON.stringify(changedFields));
// if (Object.keys(changedFields).length === 0) {
// toast('No changes detected');
// return;
// }
// const payload = {
// ContactID: formState.form.ContactID,
// ...changedFields
// };
// console.log('Custom Payload for Edit:', payload);
// ***
// const result = await formState.save(masterDetail.mode, payload);
// if (result.status === 'success') {
// console.log('Contact updated successfully');
// toast('Contact Updated!');
// masterDetail.exitForm(true);
// } else {
// console.error('Failed to update contact:', result.message);
// }
}
const primaryAction = $derived({

View File

@ -125,10 +125,10 @@
tempMap,
});
console.log(payload)
const result = await formState.save(masterDetail.mode, payload);
// const result = await formState.save(masterDetail.mode, payload);
toast('Test Map Created!');
masterDetail?.exitForm(true);
// toast('Test Map Created!');
// masterDetail?.exitForm(true);
}
const primaryAction = $derived({
@ -223,7 +223,6 @@
// formState.form.ClientTestName = 'nyaho';
// }
// })
$inspect(mapFormFieldsTransformed)
</script>
<FormPageContainer title="Create Test Map" {primaryAction} {secondaryActions} {actions}>

View File

@ -103,7 +103,7 @@ export function viewActions(handlers){
return [
{
Icon: PencilIcon,
label: 'Edit Patient',
label: 'Edit Visit',
onClick: handlers.editPatient,
},
]

View File

@ -7,6 +7,7 @@
import { API } from "$lib/config/api";
import ReusableAlertDialog from "$lib/components/reusable/reusable-alert-dialog.svelte";
import { buildPatientPayload } from "$lib/components/patient/list/config/patient-form-config";
import { getChangedFields } from "$lib/utils/getChangedFields";
let props = $props();
@ -60,16 +61,6 @@
}
});
});
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 currentPayload = buildPatientPayload(formState.form);

View File

@ -0,0 +1,9 @@
export 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;
}