refactor(patient-contact): improve contact form components and validation

- Remove hardcoded contact limit and use prop instead
- Add ButtonAction component to form exports
- Enhance contact schema validation with better error messages
- Refactor contact type select component to use doc-entry components
- Improve form layout and consistency between contact and relative forms
This commit is contained in:
Khafid Prayoga
2025-11-24 14:08:01 +07:00
parent 96a7ada059
commit f3474eb0b5
6 changed files with 87 additions and 92 deletions

No files matched your search

@@ -2,16 +2,18 @@
import { toTypedSchema } from '@vee-validate/zod' import { toTypedSchema } from '@vee-validate/zod'
// components // components
import * as DE from '~/components/pub/my-ui/doc-entry'
import { Form } from '~/components/pub/ui/form' import { Form } from '~/components/pub/ui/form'
import { FieldArray } from 'vee-validate' import { FieldArray } from 'vee-validate'
import { SelectContactType } from './fields' import { SelectContactType } from './fields'
import { InputBase } from '~/components/pub/my-ui/form' import { ButtonAction, InputBase } from '~/components/pub/my-ui/form'
const props = defineProps<{ const props = defineProps<{
title: string title: string
schema: any schema: any
contactLimit: number contactLimit?: number
initialValues?: any initialValues?: any
isReadonly?: boolean
}>() }>()
const { contactLimit = 5 } = props const { contactLimit = 5 } = props
@@ -24,6 +26,8 @@ defineExpose({
setValues: (values: any, shouldValidate = true) => formRef.value?.setValues(values, shouldValidate), setValues: (values: any, shouldValidate = true) => formRef.value?.setValues(values, shouldValidate),
values: computed(() => formRef.value?.values), values: computed(() => formRef.value?.values),
}) })
const { title = 'Kontak Pasien', isReadonly = false } = props
</script> </script>
<template> <template>
@@ -33,79 +37,67 @@ defineExpose({
keep-values keep-values
:validation-schema="formSchema" :validation-schema="formSchema"
:validate-on-mount="false" :validate-on-mount="false"
:initial-values="initialValues ? initialValues : {}"
validation-mode="onSubmit" validation-mode="onSubmit"
:initial-values="initialValues || { contacts: [{ contactType: '', contactNumber: '' }] }"
> >
<div> <div>
<p <p class="text-md mb-2 mt-1 font-semibold">
v-if="props.title" {{ title }}
class="mb-2 text-sm font-semibold 2xl:mb-3 2xl:text-base"
>
{{ props.title || 'Kontak Pasien' }}
</p> </p>
</div> </div>
<div class="mb-5 border-b border-b-slate-300 pb-3 text-lg xl:text-xl"> <div class="mb-5 space-y-4">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3"> <FieldArray
<FieldArray v-slot="{ fields, push, remove }"
v-slot="{ fields, push, remove }" name="contacts"
name="contacts" >
> <template v-if="fields.length === 0">
<div {{ push({ relation: '', name: '', address: '', phone: '' }) }}
</template>
<div class="space-y-4">
<DE.Block
v-for="(field, idx) in fields" v-for="(field, idx) in fields"
:key="field.key" :key="field.key"
class="flex-row gap-2 md:flex" :col-count="5"
:cell-flex="false"
> >
<div class="min-w-0 flex-[2]"> <SelectContactType
<SelectContactType :label="idx === 0 ? 'Jenis Kontak' : undefined"
:field-name="`contacts[${idx}].contactType`" :field-name="`contacts[${idx}].contactType`"
:label="`Kontak ${idx + 1}`" :is-disabled="isReadonly"
:is-required="idx === 0" />
/> <InputBase
</div> :label="idx === 0 ? 'Nomor Kontak' : undefined"
<div class="min-w-0 flex-[1.5]"> :field-name="`contacts[${idx}].contactNumber`"
<InputBase placeholder="081234567890"
:field-name="`contacts[${idx}].contactNumber`" :max-length="15"
placeholder="081234567890" numeric-only
label="No" :is-disabled="isReadonly"
numeric-only />
:max-length="15" <DE.Cell class="flex items-start justify-start">
:is-required="idx === 0" <DE.Field :class="idx === 0 ? 'mt-[30px]' : 'mt-0'">
/> <ButtonAction
</div> v-if="idx !== 0"
<div class="flex-1 self-start md:pt-8"> :disabled="isReadonly"
<Button preset="delete"
type="button" :title="`Hapus Kontak ${idx + 1}`"
variant="outline" icon-only
size="icon" @click="remove(idx)"
class="text-red-600 hover:border-red-400 hover:bg-red-50 hover:text-red-700 dark:border-red-400 dark:text-red-400 dark:hover:border-red-300 dark:hover:bg-red-900/20"
:class="{ invisible: idx === 0 }"
@click="remove(idx)"
>
<Icon
name="i-lucide-trash-2"
class="h-5 w-5"
/> />
</Button> </DE.Field>
</div> </DE.Cell>
</div> </DE.Block>
</div>
<div class="self-center pt-3"> <ButtonAction
<Button preset="add"
:disabled="fields.length >= contactLimit" label="Tambah Kontak"
type="button" title="Tambah Kontak ke Daftar Kontak"
variant="outline" :disabled="fields.length >= contactLimit || isReadonly"
size="sm" :full-width-mobile="true"
@click="push({ contactType: '', contactNumber: '' })" class="mt-4"
> @click="push({ contactType: '', contactNumber: '' })"
<Icon />
name="i-lucide-plus" </FieldArray>
class="h-5 w-5"
/>
Tambah Kontak
</Button>
</div>
</FieldArray>
</div>
</div> </div>
</Form> </Form>
</template> </template>
@@ -1,14 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import type { FormErrors } from '~/types/error' import type { FormErrors } from '~/types/error'
import FieldGroup from '~/components/pub/my-ui/form/field-group.vue'
import Field from '~/components/pub/my-ui/form/field.vue'
import Label from '~/components/pub/my-ui/form/label.vue'
import Select from '~/components/pub/my-ui/form/select.vue'
import { cn } from '~/lib/utils' import { cn } from '~/lib/utils'
import * as DE from '~/components/pub/my-ui/doc-entry'
import Select from '~/components/pub/my-ui/form/select.vue'
const props = defineProps<{ const props = defineProps<{
fieldName: string fieldName: string
label: string label?: string
placeholder?: string placeholder?: string
errors?: FormErrors errors?: FormErrors
class?: string class?: string
@@ -17,11 +16,12 @@ const props = defineProps<{
labelClass?: string labelClass?: string
isRequired?: boolean isRequired?: boolean
isDisabled?: boolean isDisabled?: boolean
colSpan?: number
}>() }>()
const { fieldName = 'phoneNumber', errors, class: containerClass, selectClass, fieldGroupClass, labelClass } = props const { fieldName = 'phoneNumber', errors, class: containerClass, selectClass, fieldGroupClass, labelClass } = props
const contactOptions = [ const opts = [
{ label: 'Nomor HP', value: 'phoneNumber' }, { label: 'Nomor HP', value: 'phoneNumber' },
{ label: 'Nomor Telepon Kantor', value: 'officePhoneNumber' }, { label: 'Nomor Telepon Kantor', value: 'officePhoneNumber' },
{ label: 'Nomor Telepon Rumah', value: 'homePhoneNumber' }, { label: 'Nomor Telepon Rumah', value: 'homePhoneNumber' },
@@ -29,17 +29,20 @@ const contactOptions = [
</script> </script>
<template> <template>
<FieldGroup :class="cn('select-field-group', fieldGroupClass, containerClass)"> <DE.Cell
<Label :col-span="colSpan"
:class="cn('select-field-group', fieldGroupClass, containerClass)"
>
<DE.Label
v-if="label"
:label-for="fieldName" :label-for="fieldName"
:class="cn('select-field-label', labelClass)" :class="cn('select-field-label', labelClass)"
:is-required="isRequired && !isDisabled" :is-required="isRequired"
> >
{{ label }} {{ label }}
</Label> </DE.Label>
<Field <DE.Field
:id="fieldName" :id="fieldName"
:errors="errors"
:class="cn('select-field-wrapper')" :class="cn('select-field-wrapper')"
> >
<FormField <FormField
@@ -49,15 +52,15 @@ const contactOptions = [
<FormItem> <FormItem>
<FormControl> <FormControl>
<Select <Select
:disabled="isDisabled"
:id="fieldName" :id="fieldName"
v-bind="componentField" v-bind="componentField"
:is-disabled="isDisabled" :items="opts"
:items="contactOptions" :placeholder="placeholder"
:placeholder="placeholder || 'Pilih jenis kontak'"
:preserve-order="true" :preserve-order="true"
:class=" :class="
cn( cn(
'min-w-[190px] text-sm transition-all duration-200 focus:outline-none focus:ring-1 focus:ring-black focus:ring-offset-0', 'text-sm transition-all duration-200 focus:outline-none focus:ring-1 focus:ring-black focus:ring-offset-0',
selectClass, selectClass,
) )
" "
@@ -66,6 +69,6 @@ const contactOptions = [
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
</FormField> </FormField>
</Field> </DE.Field>
</FieldGroup> </DE.Cell>
</template> </template>
@@ -6,14 +6,14 @@ import { FieldArray } from 'vee-validate'
import * as DE from '~/components/pub/my-ui/doc-entry' import * as DE from '~/components/pub/my-ui/doc-entry'
import { Form } from '~/components/pub/ui/form' import { Form } from '~/components/pub/ui/form'
import { SelectRelations } from './fields' import { SelectRelations } from './fields'
import InputBase from '~/components/pub/my-ui/form/input-base.vue' import { ButtonAction, InputBase } from '~/components/pub/my-ui/form'
import ButtonAction from '~/components/pub/my-ui/form/button-action.vue'
const props = defineProps<{ const props = defineProps<{
title: string title: string
schema: any schema: any
isReadonly?: boolean isReadonly?: boolean
initialValues?: any initialValues?: any
contactLimit?: number
}>() }>()
const formSchema = toTypedSchema(props.schema) const formSchema = toTypedSchema(props.schema)
@@ -26,7 +26,7 @@ defineExpose({
values: computed(() => formRef.value?.values), values: computed(() => formRef.value?.values),
}) })
const { title = 'Kontak Pasien', isReadonly = false } = props const { title = 'Kontak Pasien', isReadonly = false, contactLimit = 5 } = props
</script> </script>
<template> <template>
@@ -102,9 +102,9 @@ const { title = 'Kontak Pasien', isReadonly = false } = props
<ButtonAction <ButtonAction
preset="add" preset="add"
label="Tambah Kontak" label="Tambah Penanggung Jawab"
title="Tambah Kontak ke Daftar Kontak" title="Tambah Penanggung Jawab"
:disabled="fields.length >= 5 || isReadonly" :disabled="fields.length >= contactLimit || isReadonly"
:full-width-mobile="true" :full-width-mobile="true"
class="mt-4" class="mt-4"
@click="push({ relation: '', name: '', address: '', phone: '' })" @click="push({ relation: '', name: '', address: '', phone: '' })"
-1
View File
@@ -303,7 +303,6 @@ watch(
<AppPersonContactEntryForm <AppPersonContactEntryForm
ref="personContactForm" ref="personContactForm"
title="Kontak Pasien" title="Kontak Pasien"
:contact-limit="10"
:schema="PersonContactListSchema" :schema="PersonContactListSchema"
/> />
<AppPersonRelativeEntryForm <AppPersonRelativeEntryForm
+1
View File
@@ -1,4 +1,5 @@
export { default as Block } from './block.vue' export { default as Block } from './block.vue'
export { default as ButtonAction } from './button-action.vue'
export { default as FieldGroup } from './field-group.vue' export { default as FieldGroup } from './field-group.vue'
export { default as Field } from './field.vue' export { default as Field } from './field.vue'
export { default as FileField } from './file-field.vue' export { default as FileField } from './file-field.vue'
+2 -2
View File
@@ -1,8 +1,8 @@
import { z } from 'zod' import { z } from 'zod'
const PersonContactBaseSchema = z.object({ const PersonContactBaseSchema = z.object({
contactType: z.string().min(1, 'Mohon pilih tipe kontak'), contactType: z.string({ required_error: 'Mohon pilih tipe kontak' }).min(1, 'Mohon pilih tipe kontak'),
contactNumber: z.string().min(8, 'Nomor minimal 8 digit'), contactNumber: z.string({ required_error: 'Nomor kontak harus diisi' }).min(8, 'Nomor minimal 8 digit'),
}) })
const PersonContactListSchema = z.object({ const PersonContactListSchema = z.object({