Merge pull request #228 from dikstub-rssa/feat/patient-63-adjustment

Feat/patient 63 adjustment
This commit is contained in:
Munawwirul Jamal
2025-12-11 16:23:47 +07:00
committed by GitHub
66 changed files with 8083 additions and 9195 deletions
@@ -1,140 +0,0 @@
<script setup lang="ts">
import type { FormErrors } from '~/types/error'
import { differenceInDays, differenceInMonths, differenceInYears, parseISO } from 'date-fns'
import { Input } from '~/components/pub/ui/input'
import { cn } from '~/lib/utils'
import * as DE from '~/components/pub/my-ui/doc-entry'
const props = defineProps<{
fieldName?: string
label?: string
placeholder?: string
errors?: FormErrors
class?: string
selectClass?: string
fieldGroupClass?: string
labelClass?: string
isRequired?: boolean
}>()
const {
fieldName = 'birthDate',
label = 'Tanggal Lahir',
placeholder = 'Pilih tanggal lahir',
errors,
class: containerClass,
fieldGroupClass,
labelClass,
} = props
// Reactive variables for age calculation
const patientAge = ref<string>('Masukkan tanggal lahir')
// Function to calculate age with years, months, and days
function calculateAge(birthDate: string | Date | undefined): string {
if (!birthDate) {
return 'Masukkan tanggal lahir'
}
try {
let dateObj: Date
if (typeof birthDate === 'string') {
dateObj = parseISO(birthDate)
} else {
dateObj = birthDate
}
const today = new Date()
// Calculate years, months, and days
const totalYears = differenceInYears(today, dateObj)
// Calculate remaining months after years
const yearsPassed = new Date(dateObj)
yearsPassed.setFullYear(yearsPassed.getFullYear() + totalYears)
const remainingMonths = differenceInMonths(today, yearsPassed)
// Calculate remaining days after years and months
const monthsPassed = new Date(yearsPassed)
monthsPassed.setMonth(monthsPassed.getMonth() + remainingMonths)
const remainingDays = differenceInDays(today, monthsPassed)
// Format the result
const parts = []
if (totalYears > 0) parts.push(`${totalYears} Tahun`)
if (remainingMonths > 0) parts.push(`${remainingMonths} Bulan`)
if (remainingDays > 0) parts.push(`${remainingDays} Hari`)
return parts.length > 0 ? parts.join(' ') : '0 Hari'
} catch {
return 'Masukkan tanggal lahir'
}
}
</script>
<template>
<DE.Cell :class="cn('select-field-group', fieldGroupClass, containerClass)">
<DE.Label
:label-for="fieldName"
:class="cn('select-field-label', labelClass)"
:is-required="isRequired"
>
{{ label }}
</DE.Label>
<DE.Field
:id="fieldName"
:errors="errors"
:class="cn('select-field-wrapper')"
>
<FormField
v-slot="{ componentField }"
:name="fieldName"
>
<FormItem>
<FormControl>
<Input
id="birthDate"
type="date"
min="1900-01-01"
:max="new Date().toISOString().split('T')[0]"
v-bind="componentField"
:placeholder="placeholder"
@update:model-value="
(value: string | number) => {
const dateStr = typeof value === 'number' ? String(value) : value
patientAge = calculateAge(dateStr)
}
"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</DE.Field>
</DE.Cell>
<DE.Cell>
<DE.Label label-for="patientAge">Usia</DE.Label>
<DE.Field id="patientAge">
<FormField name="patientAge">
<FormItem>
<FormControl>
<Input
:value="patientAge"
disabled
readonly
placeholder="Masukkan tanggal lahir"
:class="
cn(
'cursor-not-allowed bg-gray-50 focus:border-primary focus:ring-2 focus:ring-primary focus:ring-offset-0',
)
"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</DE.Field>
</DE.Cell>
</template>
+136 -70
View File
@@ -1,64 +1,121 @@
<script setup lang="ts">
import type { FormErrors } from '~/types/error'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { Form } from '~/components/pub/ui/form'
import InputBase from '~/components/pub/my-ui/form/input-base.vue'
import FileUpload from '~/components/pub/my-ui/form/file-field.vue'
import InputName from './_common/input-name.vue'
import RadioCommunicationBarrier from './_common/radio-communication-barrier.vue'
import RadioDisability from './_common/radio-disability.vue'
import SelectGender from './_common/select-gender.vue'
import RadioNationality from './_common/radio-nationality.vue'
import RadioNewborn from './_common/radio-newborn.vue'
import SelectBirthPlace from '~/components/app/person/_common/select-birth-place.vue'
import SelectDisability from './_common/select-disability.vue'
import SelectDob from './_common/select-dob.vue'
import SelectEducation from './_common/select-education.vue'
import SelectEthnicity from './_common/select-ethnicity.vue'
import SelectJob from './_common/select-job.vue'
import SelectLanguage from './_common/select-lang.vue'
import SelectMaritalStatus from './_common/select-marital-status.vue'
import SelectReligion from './_common/select-religion.vue'
import Separator from '~/components/pub/ui/separator/Separator.vue'
// types
import { type PatientFormData, PatientSchema } from '~/schemas/patient.schema'
// utils
import { calculateAge } from '~/models/person'
// components
import * as DE from '~/components/pub/my-ui/doc-entry'
import { InputBase, FileField as FileUpload } from '~/components/pub/my-ui/form'
import { SelectBirthPlace } from '~/components/app/person/fields'
import {
InputName,
RadioCommunicationBarrier,
RadioDisability,
SelectGender,
RadioNationality,
RadioNewborn,
SelectDisability,
SelectDob,
SelectEducation,
SelectEthnicity,
SelectJob,
SelectLang as SelectLanguage,
SelectMaritalStatus,
SelectReligion,
} from './fields'
const props = defineProps<{
schema: any
initialValues?: any
errors?: FormErrors
}>()
interface FormData extends PatientFormData {
_calculatedAge: string | Date
}
const formSchema = toTypedSchema(props.schema)
const formRef = ref()
// Type untuk initial values (sebelum transform schema)
interface PatientFormInput {
identityNumber?: string
drivingLicenseNumber?: string
passportNumber?: string
fullName?: string
isNewBorn?: string
gender?: string
birthPlace?: string
birthDate?: string
education?: string
job?: string
maritalStatus?: string
nationality?: string
ethnicity?: string
language?: string
religion?: string
communicationBarrier?: string
disability?: string
disabilityType?: string
note?: string
residentIdentityFile?: File
familyIdentityFile?: File
}
interface Props {
isReadonly: boolean
initialValues?: PatientFormInput
}
const props = defineProps<Props>()
const formSchema = toTypedSchema(PatientSchema)
const { values, resetForm, setValues, setFieldValue, validate, setFieldError } = useForm<FormData>({
name: 'patientForm',
validationSchema: formSchema,
initialValues: (props.initialValues ?? {}) as any,
validateOnMount: false,
})
defineExpose({
validate: () => formRef.value?.validate(),
resetForm: () => formRef.value?.resetForm(),
setValues: (values: any, shouldValidate = true) => formRef.value?.setValues(values, shouldValidate),
values: computed(() => formRef.value?.values),
validate,
resetForm,
setValues,
values,
})
watch(
() => values.birthDate,
(newValue) => {
if (newValue) {
setFieldValue('_calculatedAge', calculateAge(newValue))
}
},
{
immediate: true,
},
)
watch(
() => values.disability,
(newValue) => {
if (newValue === 'no') {
setFieldValue('disabilityType', undefined)
}
},
)
</script>
<template>
<Form
ref="formRef"
v-slot="{ values }"
as=""
keep-values
:validation-schema="formSchema"
:validate-on-mount="false"
validation-mode="onSubmit"
:initial-values="initialValues ? initialValues : {}"
>
<p class="text-sm 2xl:text-base mb-2 2xl:mb-3 font-semibold">Data Diri Pasien</p>
<DE.Block :col-count="4" :cell-flex="false">
<form @submit.prevent="">
<p class="mb-2 text-sm font-semibold 2xl:mb-3 2xl:text-base">Data Diri Pasien</p>
<DE.Block
:col-count="4"
:cell-flex="false"
>
<InputBase
field-name="identityNumber"
label="No. KTP"
placeholder="Masukkan NIK"
:errors="errors"
numeric-only
:is-disabled="isReadonly"
:max-length="16"
/>
<InputBase
field-name="drivingLicenseNumber"
@@ -66,146 +123,155 @@ defineExpose({
placeholder="Masukkan nomor SIM"
numeric-only
:max-length="20"
:errors="errors"
:is-disabled="isReadonly"
/>
<InputBase
field-name="passportNumber"
label="No. Paspor"
placeholder="Masukkan nomor paspor"
:max-length="20"
:errors="errors"
:is-disabled="isReadonly"
/>
<InputName
field-name-alias="alias"
field-name-input="fullName"
label-for-input="Nama Lengkap"
placeholder="Masukkan nama lengkap pasien"
:errors="errors"
is-required
:is-disabled="isReadonly"
/>
<RadioNewborn
field-name="isNewBorn"
label="Pasien Bayi"
placeholder="Pilih status pasien"
:errors="errors"
is-required
:is-disabled="isReadonly"
/>
<SelectGender
field-name="gender"
label="Jenis Kelamin"
placeholder="Pilih jenis kelamin"
:errors="errors"
is-required
:is-disabled="isReadonly"
/>
<SelectBirthPlace
field-name="birthPlace"
label="Tempat Lahir"
placeholder="Pilih tempat lahir"
:errors="errors"
is-required
:is-disabled="isReadonly"
/>
<SelectDob
field-name="birthDate"
label="Tanggal Lahir"
:errors="errors"
is-required
:is-disabled="isReadonly"
/>
<InputBase
field-name="_calculatedAge"
label="Usia"
placeholder="-"
numeric-only
is-disabled
/>
<SelectEducation
field-name="education"
label="Pendidikan"
placeholder="Pilih pendidikan"
:errors="errors"
is-required
:is-disabled="isReadonly"
/>
<SelectJob
field-name="job"
label="Pekerjaan"
placeholder="Pilih pekerjaan"
:errors="errors"
is-required
:is-disabled="isReadonly"
/>
<SelectMaritalStatus
field-name="maritalStatus"
label="Status Perkawinan"
placeholder="Pilih status Perkawinan"
:errors="errors"
is-required
:is-disabled="isReadonly"
/>
<DE.Cell />
<RadioNationality
field-name="nationality"
label="Kebangsaan"
placeholder="Pilih kebangsaan"
:errors="errors"
is-required
:is-disabled="isReadonly"
/>
<SelectEthnicity
field-name="ethnicity"
label="Suku"
placeholder="Pilih suku bangsa"
:errors="errors"
:is-disabled="values.nationality !== 'WNI'"
:is-disabled="isReadonly || values.nationality !== 'WNI'"
/>
<SelectLanguage
field-name="language"
label="Bahasa"
placeholder="Pilih preferensi bahasa"
:errors="errors"
is-required
:is-disabled="isReadonly"
/>
<SelectReligion
field-name="religion"
label="Agama"
placeholder="Pilih agama"
:errors="errors"
is-required
:is-disabled="isReadonly"
/>
<RadioCommunicationBarrier
field-name="communicationBarrier"
label="Hambatan Berkomunikasi"
:errors="errors"
is-required
:is-disabled="isReadonly"
/>
<RadioDisability
field-name="disability"
label="Disabilitas"
:errors="errors"
is-required
:is-disabled="isReadonly"
/>
<SelectDisability
label="Jenis Disabilitas"
field-name="disabilityType"
:errors="errors"
:is-disabled="values.disability !== 'YA'"
:is-required="values.disability === 'YA'"
:is-disabled="isReadonly || values.disability !== 'yes'"
:is-required="values.disability === 'yes'"
/>
<InputBase
field-name="note"
label="Kepercayaan"
placeholder="Contoh: tidak ingin diperiksa oleh dokter laki-laki"
:errors="errors"
:is-disabled="isReadonly"
/>
</DE.Block>
<div class="h-6"></div>
<p class="text-sm 2xl:text-base mb-2 2xl:mb-3 font-semibold">Dokumen Identitas</p>
<DE.Block :col-count="2" :cell-flex="false">
<p class="mb-2 text-sm font-semibold 2xl:mb-3 2xl:text-base">Dokumen Identitas</p>
<DE.Block
:col-count="2"
:cell-flex="false"
>
<FileUpload
field-name="identityCardFile"
label="Dokumen KTP"
placeholder="Unggah scan dokumen KTP"
:errors="errors"
:accept="['pdf', 'jpg', 'png']"
:max-size-mb="1"
:is-disabled="isReadonly"
/>
<FileUpload
field-name="familyCardFile"
label="Dokumen KK"
placeholder="Unggah scan dokumen KK"
:errors="errors"
:accept="['pdf', 'jpg', 'png']"
:max-size-mb="1"
:is-disabled="isReadonly"
/>
</DE.Block>
</Form>
</form>
</template>
@@ -0,0 +1,16 @@
export { default as InputFile } from './input-file.vue'
export { default as InputName } from './input-name.vue'
export { default as RadioCommunicationBarrier } from './radio-communication-barrier.vue'
export { default as RadioDisability } from './radio-disability.vue'
export { default as RadioGender } from './radio-gender.vue'
export { default as RadioNationality } from './radio-nationality.vue'
export { default as RadioNewborn } from './radio-newborn.vue'
export { default as SelectDisability } from './select-disability.vue'
export { default as SelectDob } from './select-dob.vue'
export { default as SelectEducation } from './select-education.vue'
export { default as SelectEthnicity } from './select-ethnicity.vue'
export { default as SelectGender } from './select-gender.vue'
export { default as SelectJob } from './select-job.vue'
export { default as SelectLang } from './select-lang.vue'
export { default as SelectMaritalStatus } from './select-marital-status.vue'
export { default as SelectReligion } from './select-religion.vue'
@@ -17,6 +17,7 @@ defineProps<{
labelClass?: string
maxLength?: number
isRequired?: boolean
isDisabled?: boolean
}>()
</script>
@@ -40,6 +41,7 @@ defineProps<{
<FormItem>
<FormControl>
<Input
:disabled="isDisabled"
v-bind="componentField"
type="text"
:placeholder="placeholder"
@@ -15,6 +15,7 @@ const props = defineProps<{
radioItemClass?: string
labelClass?: string
isRequired?: boolean
isDisabled?: boolean
}>()
const {
@@ -28,8 +29,8 @@ const {
} = props
const genderOptions = [
{ label: 'Ya', value: 'YA' },
{ label: 'Tidak', value: 'TIDAK' },
{ label: 'Ya', value: 'yes' },
{ label: 'Tidak', value: 'no' },
]
</script>
@@ -46,7 +47,7 @@ const genderOptions = [
:id="fieldName"
:errors="errors"
class="pt-0.5"
>
>
<FormField
v-slot="{ componentField }"
:name="fieldName"
@@ -55,6 +56,7 @@ const genderOptions = [
<FormControl>
<RadioGroup
v-bind="componentField"
:disabled="isDisabled"
:class="cn('flex flex-row flex-wrap gap-4 sm:gap-6', radioGroupClass)"
>
<div
@@ -63,20 +65,22 @@ const genderOptions = [
:class="cn('flex min-w-fit items-center space-x-2', radioItemClass)"
>
<RadioGroupItem
:disabled="isDisabled"
:id="`${fieldName}-${index}`"
:value="option.value"
:class="
cn(
'relative h-4 w-4 rounded-full border-muted-foreground before:absolute before:inset-1 before:rounded-full before:bg-primary before:opacity-0 before:transition-opacity data-[state=checked]:border-primary data-[state=checked]:bg-white data-[state=checked]:before:opacity-100 sm:h-5 sm:w-5',
'peer relative h-4 w-4 rounded-full border-muted-foreground before:absolute before:inset-1 before:rounded-full before:bg-primary before:opacity-0 before:transition-opacity data-[state=checked]:border-primary data-[state=checked]:bg-white data-[state=checked]:before:opacity-100 sm:h-5 sm:w-5',
containerClass,
)
"
/>
<RadioLabel
:for="`${fieldName}-${index}`"
:for="isDisabled ? undefined : `${fieldName}-${index}`"
:class="
cn(
'cursor-pointer select-none font-normal text-xs leading-none transition-colors hover:text-primary peer-disabled:cursor-not-allowed peer-disabled:opacity-70 sm:text-sm',
'select-none text-xs !font-normal leading-none transition-colors sm:text-sm',
isDisabled ? 'cursor-not-allowed opacity-70' : 'cursor-pointer hover:text-primary',
labelClass,
)
"
@@ -15,6 +15,7 @@ const props = defineProps<{
radioItemClass?: string
labelClass?: string
isRequired?: boolean
isDisabled?: boolean
}>()
const {
@@ -27,9 +28,9 @@ const {
labelClass,
} = props
const dissabilityOptions = [
{ label: 'Ya', value: 'YA' },
{ label: 'Tidak', value: 'TIDAK' },
const disabilityOptions = [
{ label: 'Ya', value: 'yes' },
{ label: 'Tidak', value: 'no' },
]
</script>
@@ -55,28 +56,31 @@ const dissabilityOptions = [
<FormControl>
<RadioGroup
v-bind="componentField"
:disabled="isDisabled"
:class="cn('flex flex-row flex-wrap gap-4 sm:gap-6', radioGroupClass)"
>
<div
v-for="(option, index) in dissabilityOptions"
v-for="(option, index) in disabilityOptions"
:key="option.value"
:class="cn('flex min-w-fit items-center space-x-2', radioItemClass)"
>
<RadioGroupItem
:disabled="isDisabled"
:id="`${fieldName}-${index}`"
:value="option.value"
:class="
cn(
'relative h-4 w-4 rounded-full border-1 border-muted-foreground before:absolute before:inset-1 before:rounded-full before:bg-primary before:opacity-0 before:transition-opacity data-[state=checked]:border-primary data-[state=checked]:bg-white data-[state=checked]:before:opacity-100 sm:h-5 sm:w-5',
'border-1 peer relative h-4 w-4 rounded-full border-muted-foreground before:absolute before:inset-1 before:rounded-full before:bg-primary before:opacity-0 before:transition-opacity data-[state=checked]:border-primary data-[state=checked]:bg-white data-[state=checked]:before:opacity-100 sm:h-5 sm:w-5',
containerClass,
)
"
/>
<RadioLabel
:for="`${fieldName}-${index}`"
:for="isDisabled ? undefined : `${fieldName}-${index}`"
:class="
cn(
'cursor-pointer select-none text-xs !font-normal leading-none transition-colors hover:text-primary peer-disabled:cursor-not-allowed peer-disabled:opacity-70 sm:text-sm',
'select-none text-xs !font-normal leading-none transition-colors',
isDisabled ? 'cursor-not-allowed opacity-70' : 'cursor-pointer hover:text-primary',
labelClass,
)
"
@@ -15,6 +15,7 @@ const props = defineProps<{
radioItemClass?: string
labelClass?: string
isRequired?: boolean
isDisabled?: boolean
}>()
const {
@@ -55,6 +56,7 @@ const nationalityOptions = [
<FormControl>
<RadioGroup
v-bind="componentField"
:disabled="isDisabled"
:class="cn('flex flex-row flex-wrap gap-4 sm:gap-6', radioGroupClass)"
>
<div
@@ -63,20 +65,24 @@ const nationalityOptions = [
:class="cn('flex min-w-fit items-center space-x-2', radioItemClass)"
>
<RadioGroupItem
:disabled="isDisabled"
:id="`${fieldName}-${index}`"
:value="option.value"
:class="
cn(
'relative h-4 w-4 rounded-full border-1 border-muted-foreground before:absolute before:inset-1 before:rounded-full before:bg-primary before:opacity-0 before:transition-opacity data-[state=checked]:border-primary data-[state=checked]:bg-white data-[state=checked]:before:opacity-100 sm:h-5 sm:w-5',
'peer relative h-4 w-4 rounded-full border-1 border-muted-foreground before:absolute before:inset-1 before:rounded-full before:bg-primary before:opacity-0 before:transition-opacity data-[state=checked]:border-primary data-[state=checked]:bg-white data-[state=checked]:before:opacity-100 sm:h-5 sm:w-5',
containerClass,
)
"
/>
<RadioLabel
:for="`${fieldName}-${index}`"
:for="isDisabled ? undefined : `${fieldName}-${index}`"
:class="
cn(
'cursor-pointer select-none text-xs !font-normal leading-none transition-colors hover:text-primary peer-disabled:cursor-not-allowed peer-disabled:opacity-70 sm:text-sm',
'select-none text-xs !font-normal leading-none transition-colors sm:text-sm',
isDisabled
? 'cursor-not-allowed opacity-70'
: 'cursor-pointer hover:text-primary',
labelClass,
)
"
@@ -15,6 +15,7 @@ const props = defineProps<{
radioItemClass?: string
labelClass?: string
isRequired?: boolean
isDisabled?: boolean
}>()
const {
@@ -28,13 +29,16 @@ const {
} = props
const newbornOptions = [
{ label: 'Ya', value: 'YA' },
{ label: 'Tidak', value: 'TIDAK' },
{ label: 'Ya', value: 'yes' },
{ label: 'Tidak', value: 'no' },
]
</script>
<template>
<DE.Cell :class="cn('radio-group-field', containerClass)" :col-span="2">
<DE.Cell
:class="cn('radio-group-field', containerClass)"
:col-span="2"
>
<DE.Label
:label-for="fieldName"
:is-required="isRequired"
@@ -52,6 +56,7 @@ const newbornOptions = [
<FormItem>
<FormControl>
<RadioGroup
:disabled="isDisabled"
v-bind="componentField"
:class="cn('flex flex-row flex-wrap gap-4 sm:gap-6', radioGroupClass)"
>
@@ -61,20 +66,22 @@ const newbornOptions = [
:class="cn('flex min-w-fit items-center space-x-2 pt-1', radioItemClass)"
>
<RadioGroupItem
:disabled="isDisabled"
:id="`${fieldName}-${index}`"
:value="option.value"
:class="
cn(
'relative h-4 w-4 rounded-full border-muted-foreground before:absolute before:inset-1 before:rounded-full before:bg-primary before:opacity-0 before:transition-opacity data-[state=checked]:border-primary data-[state=checked]:bg-white data-[state=checked]:before:opacity-100 sm:h-5 sm:w-5',
'peer relative h-4 w-4 rounded-full border-muted-foreground before:absolute before:inset-1 before:rounded-full before:bg-primary before:opacity-0 before:transition-opacity data-[state=checked]:border-primary data-[state=checked]:bg-white data-[state=checked]:before:opacity-100 sm:h-5 sm:w-5',
containerClass,
)
"
/>
<RadioLabel
:for="`${fieldName}-${index}`"
:for="isDisabled ? undefined : `${fieldName}-${index}`"
:class="
cn(
'cursor-pointer select-none text-xs font-normal leading-none transition-colors hover:text-primary peer-disabled:cursor-not-allowed peer-disabled:opacity-70 sm:text-sm',
'select-none text-xs !font-normal leading-none transition-colors',
isDisabled ? 'cursor-not-allowed opacity-70' : 'cursor-pointer hover:text-primary',
labelClass,
)
"
@@ -5,6 +5,9 @@ import { cn } from '~/lib/utils'
import * as DE from '~/components/pub/my-ui/doc-entry'
import { disabilityCodes } from '~/lib/constants'
import { mapToComboboxOptList } from '~/lib/utils'
const props = defineProps<{
fieldName?: string
label?: string
@@ -26,16 +29,10 @@ const {
fieldGroupClass,
} = props
const disabilityOptions = [
{ label: 'Tuna Daksa', value: 'daksa' },
{ label: 'Tuna Netra', value: 'netra' },
{ label: 'Tuna Rungu', value: 'rungu' },
{ label: 'Tuna Wicara', value: 'wicara' },
{ label: 'Tuna Rungu-Wicara', value: 'rungu_wicara' },
{ label: 'Tuna Grahita', value: 'grahita' },
{ label: 'Tuna Laras', value: 'laras' },
{ label: 'Lainnya', value: 'other', priority: -100 },
]
const disabilityOptions = mapToComboboxOptList(disabilityCodes).map(({ label, value }) => ({
label,
value,
}))
</script>
<template>
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { calculateAge } from '~/models/person'
import { cn } from '~/lib/utils'
// componenets
import * as DE from '~/components/pub/my-ui/doc-entry'
import { Input } from '~/components/pub/ui/input'
const props = defineProps<{
fieldName?: string
label?: string
placeholder?: string
class?: string
selectClass?: string
fieldGroupClass?: string
labelClass?: string
isRequired?: boolean
isDisabled?: boolean
}>()
const {
fieldName = 'birthDate',
label = 'Tanggal Lahir',
placeholder = 'Pilih tanggal lahir',
class: containerClass,
fieldGroupClass,
labelClass,
} = props
// Reactive variables for age calculation
const patientAge = ref<string>('-')
</script>
<template>
<DE.Cell :class="cn('select-field-group', fieldGroupClass, containerClass)">
<DE.Label
:label-for="fieldName"
:class="cn('select-field-label', labelClass)"
:is-required="isRequired"
>
{{ label }}
</DE.Label>
<DE.Field
:id="fieldName"
:class="cn('select-field-wrapper')"
>
<FormField
v-slot="{ componentField }"
:name="fieldName"
>
<FormItem>
<FormControl>
<Input
:disabled="isDisabled"
id="birthDate"
type="date"
min="1900-01-01"
:max="new Date().toISOString().split('T')[0]"
v-bind="componentField"
:placeholder="placeholder"
@update:model-value="
(value: string | number) => {
const dateStr = typeof value === 'number' ? String(value) : value
patientAge = calculateAge(dateStr)
}
"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</DE.Field>
</DE.Cell>
</template>
@@ -21,7 +21,6 @@ const props = defineProps<{
const {
fieldName = 'education',
label = 'Pendidikan',
placeholder = 'Pilih pendidikan terakhir',
errors,
class: containerClass,
@@ -47,6 +46,7 @@ const educationOptions = [
<template>
<DE.Cell :class="cn('select-field-group', fieldGroupClass, containerClass)">
<DE.Label
v-if="label"
:label-for="fieldName"
:class="cn('select-field-label', labelClass)"
:is-required="isRequired && !isDisabled"
@@ -16,11 +16,11 @@ const props = defineProps<{
fieldGroupClass?: string
labelClass?: string
isRequired?: boolean
isDisabled?: boolean
}>()
const {
fieldName = 'job',
label = 'Pekerjaan',
placeholder = 'Pilih pekerjaan',
errors,
class: containerClass,
@@ -29,15 +29,19 @@ const {
} = props
// Generate job options from constants, sama seperti pola genderCodes
const jobOptions = mapToComboboxOptList(occupationCodes)
const jobOptions = mapToComboboxOptList(occupationCodes).map(({ label, value }) => ({
label,
value,
}))
</script>
<template>
<DE.Cell :class="cn('select-field-group', fieldGroupClass, containerClass)">
<DE.Label
v-if="label"
:label-for="fieldName"
:class="cn('select-field-label', labelClass)"
:is-required="isRequired"
:is-required="isRequired && !isDisabled"
>
{{ label }}
</DE.Label>
@@ -53,8 +57,9 @@ const jobOptions = mapToComboboxOptList(occupationCodes)
<FormItem>
<FormControl>
<Combobox
:id="fieldName"
v-bind="componentField"
:is-disabled="isDisabled"
:id="fieldName"
:items="jobOptions"
:placeholder="placeholder"
search-placeholder="Cari..."
@@ -15,6 +15,7 @@ const props = defineProps<{
fieldGroupClass?: string
labelClass?: string
isRequired?: boolean
isDisabled?: boolean
}>()
const {
@@ -60,6 +61,7 @@ const langOptions = [
<FormItem>
<FormControl>
<Select
:is-disabled="isDisabled"
:id="fieldName"
v-bind="componentField"
:items="langOptions"
@@ -1,7 +1,9 @@
<script setup lang="ts">
import type { FormErrors } from '~/types/error'
import Select from '~/components/pub/my-ui/form/select.vue'
import { cn } from '~/lib/utils'
import { maritalStatusCodes } from '~/const/key-val/person'
import { cn, mapToComboboxOptList } from '~/lib/utils'
import * as DE from '~/components/pub/my-ui/doc-entry'
@@ -14,6 +16,7 @@ const props = defineProps<{
fieldGroupClass?: string
labelClass?: string
isRequired?: boolean
isDisabled?: boolean
placeholder?: string
}>()
@@ -26,13 +29,10 @@ const {
placeholder = 'Pilih',
} = props
const maritalStatusOptions = [
{ label: 'Tidak Diketahui', value: 'TIDAK_DIKETAHUI' },
{ label: 'Belum Kawin', value: 'BELUM_KAWIN' },
{ label: 'Kawin', value: 'KAWIN' },
{ label: 'Cerai Hidup', value: 'CERAI_HIDUP' },
{ label: 'Cerai Mati', value: 'CERAI_MATI' },
]
const maritalStatusOptions = mapToComboboxOptList(maritalStatusCodes).map(({ label, value }) => ({
label,
value,
}))
</script>
<template>
@@ -56,8 +56,9 @@ const maritalStatusOptions = [
<FormItem>
<FormControl>
<Select
:id="fieldName"
v-bind="componentField"
:is-disabled="isDisabled"
:id="fieldName"
:items="maritalStatusOptions"
:placeholder="placeholder"
:preserve-order="true"
@@ -16,6 +16,7 @@ const props = defineProps<{
fieldGroupClass?: string
labelClass?: string
isRequired?: boolean
isDisabled?: boolean
}>()
const {
@@ -34,13 +35,17 @@ const extendOptions = [
{ label: 'Kepercayaan Lain', value: 'other', priority: -1 },
]
const religionOptions = [
...mapToComboboxOptList(religionCodes).map(({ label, value }) => ({
label,
value,
})),
...extendOptions,
]
const religionOptions = Array.from(
new Map(
[
...mapToComboboxOptList(religionCodes).map(({ label, value }) => ({
label,
value,
})),
...extendOptions,
].map((item) => [item.value, item]),
).values(),
)
</script>
<template>
@@ -64,6 +69,7 @@ const religionOptions = [
<FormItem>
<FormControl>
<Select
:is-disabled="isDisabled"
:id="fieldName"
v-bind="componentField"
:items="religionOptions"
+232 -124
View File
@@ -1,11 +1,17 @@
<script setup lang="ts">
import type { Patient } from '~/models/patient'
import DetailRow from '~/components/pub/my-ui/form/view/detail-row.vue'
import DetailSection from '~/components/pub/my-ui/form/view/detail-section.vue'
import { formatAddress } from '~/models/person-address'
import { format } from 'date-fns'
import { formatInTimeZone } from 'date-fns-tz'
import { id } from 'date-fns/locale'
// types
import type { Patient } from '~/models/patient'
import type { ClickType } from '~/components/pub/my-ui/nav-footer'
// helper
import { formatAddress } from '~/models/person-address'
import {
addressLocationTypeCode,
disabilityCodes,
educationCodes,
genderCodes,
occupationCodes,
@@ -14,6 +20,15 @@ import {
religionCodes,
} from '~/lib/constants'
import { mapToComboboxOptList } from '~/lib/utils'
import { calculateAge } from '~/models/person'
// components
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '~/components/pub/ui/accordion'
import { Fragment } from '~/components/pub/my-ui/form/'
import DetailRow from '~/components/pub/my-ui/form/view/detail-row.vue'
import DetailSection from '~/components/pub/my-ui/form/view/detail-section.vue'
import { Skeleton } from '~/components/pub/ui/skeleton'
import { toZoned } from '@internationalized/date'
// #region Props & Emits
const props = defineProps<{
@@ -21,7 +36,8 @@ const props = defineProps<{
}>()
const emit = defineEmits<{
(e: 'click', type: string): void
(e: 'back'): void
(e: 'edit'): void
}>()
// #endregion
@@ -33,6 +49,7 @@ const educationOptions = mapToComboboxOptList(educationCodes)
const occupationOptions = mapToComboboxOptList(occupationCodes)
const relationshipOptions = mapToComboboxOptList(relationshipCodes)
const personContactTypeOptions = mapToComboboxOptList(personContactTypes)
const disabilityOptions = mapToComboboxOptList(disabilityCodes)
// Computed addresses from nested data
const domicileAddress = computed(() => {
@@ -51,14 +68,7 @@ const patientAge = computed(() => {
if (!props.patient.person.birthDate) {
return '-'
}
const birthDate = new Date(props.patient.person.birthDate)
const today = new Date()
let age = today.getFullYear() - birthDate.getFullYear()
const monthDiff = today.getMonth() - birthDate.getMonth()
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) {
age--
}
return age
return calculateAge(props.patient.person.birthDate)
})
// #endregion
@@ -70,8 +80,9 @@ const patientAge = computed(() => {
// #endregion region
// #region Utilities & event handlers
function onClick(type: string) {
emit('click', type)
function onNavigate(type: ClickType) {
if (type == 'back') emit('back')
if (type == 'edit') emit('edit')
}
// #endregion
@@ -80,120 +91,217 @@ function onClick(type: string) {
</script>
<template>
<DetailSection title="Data Pasien">
<DetailRow label="Nomor">{{ patient.number || '-' }}</DetailRow>
<DetailRow label="Nama Lengkap">{{ patient.person.name || '-' }}</DetailRow>
<DetailRow label="Tempat, tanggal lahir">
{{ patient.person.birthRegency?.name || '-' }},
{{ patient.person.birthDate ? new Date(patient.person.birthDate).toLocaleDateString('id-ID') : '-' }}
</DetailRow>
<DetailRow label="Usia">{{ patientAge || '-' }} Tahun</DetailRow>
<DetailRow label="Tanggal Daftar">
{{ patient.person.createdAt ? new Date(patient.person.createdAt).toLocaleDateString('id-ID') : '-' }}
</DetailRow>
<DetailRow label="Jenis Kelamin">
{{ genderOptions.find((item) => item.code === patient.person.gender_code)?.label || '-' }}
</DetailRow>
<Accordion
type="multiple"
class="w-full"
collapsible
:defaultValue="['item-patient', 'item-document', 'item-address', 'item-contact', 'item-parents', 'item-relative']"
>
<Fragment
v-slot="{ section }"
title="Data Pasien"
>
<AccordionItem value="item-patient">
<AccordionTrigger>{{ section }}</AccordionTrigger>
<AccordionContent>
<DetailRow label="Nomor">{{ patient.number || '-' }}</DetailRow>
<DetailRow label="Nama Lengkap">{{ patient.person.name || '-' }}</DetailRow>
<DetailRow label="Tempat, tanggal lahir">
{{ patient.person.birthRegency?.name || '-' }},
{{
patient.person.birthDate
? format(new Date(patient.person.birthDate), 'dd MMMM yyyy', { locale: id })
: '-'
}}
</DetailRow>
<DetailRow label="Usia">{{ patientAge || '-' }}</DetailRow>
<DetailRow label="Tanggal Daftar">
{{
patient.person.createdAt
? formatInTimeZone(new Date(patient.person.createdAt), 'Asia/Jakarta', "dd MMMM yyyy, HH:mm:ss 'WIB'", {
locale: id,
})
: '-'
}}
</DetailRow>
<DetailRow label="Jenis Kelamin">
{{ genderOptions.find((item) => item.code === patient.person.gender_code)?.label || '-' }}
</DetailRow>
<DetailRow label="NIK">{{ patient.person.residentIdentityNumber || '-' }}</DetailRow>
<DetailRow label="No. SIM">{{ patient.person.drivingLicenseNumber || '-' }}</DetailRow>
<DetailRow label="No. Paspor">{{ patient.person.passportNumber || '-' }}</DetailRow>
<DetailRow label="NIK">{{ patient.person.residentIdentityNumber || '-' }}</DetailRow>
<DetailRow label="No. SIM">{{ patient.person.drivingLicenseNumber || '-' }}</DetailRow>
<DetailRow label="No. Paspor">{{ patient.person.passportNumber || '-' }}</DetailRow>
<DetailRow label="Agama">
{{ religionOptions.find((item) => item.code === patient.person.religion_code)?.label || '-' }}
</DetailRow>
<DetailRow label="Suku">{{ patient.person.ethnic?.name || '-' }}</DetailRow>
<DetailRow label="Bahasa">{{ patient.person.language?.name || '-' }}</DetailRow>
<DetailRow label="Pendidikan">
{{ educationOptions.find((item) => item.code === patient.person.education_code)?.label || '-' }}
</DetailRow>
<DetailRow label="Pekerjaan">
{{
occupationOptions.find((item) => item.code === patient.person.occupation_code)?.label ||
patient.person.occupation_name ||
'-'
}}
</DetailRow>
</DetailSection>
<DetailRow label="Agama">
{{ religionOptions.find((item) => item.code === patient.person.religion_code)?.label || '-' }}
</DetailRow>
<DetailRow label="Suku">{{ patient.person.ethnic?.name || '-' }}</DetailRow>
<DetailRow label="Bahasa">{{ patient.person.language?.name || '-' }}</DetailRow>
<DetailRow label="Pendidikan">
{{ educationOptions.find((item) => item.code === patient.person.education_code)?.label || '-' }}
</DetailRow>
<DetailRow label="Pekerjaan">
{{
occupationOptions.find((item) => item.code === patient.person.occupation_code)?.label ||
patient.person.occupation_name ||
'-'
}}
</DetailRow>
<DetailRow label="Pasien Bayi">{{ patient.newBornStatus ? 'Ya' : 'Tidak' }}</DetailRow>
<DetailRow label="Hambatan Komunikasi">
{{ patient.person.communicationIssueStatus ? 'Ya' : 'Tidak' }}
</DetailRow>
<DetailRow label="Disabilitas">
{{ disabilityOptions.find((item) => item.code === patient.person.disability)?.label || 'Tidak' }}
</DetailRow>
</AccordionContent>
</AccordionItem>
</Fragment>
<DetailSection title="Alamat">
<DetailRow :label="addressLocationTypeCode.domicile || 'Alamat Domisili'">{{ domicileAddress || '-' }}</DetailRow>
<DetailRow :label="addressLocationTypeCode.identity || 'Alamat KTP'">{{ identityAddress || '-' }}</DetailRow>
</DetailSection>
<DetailSection title="Kontak">
<template v-if="patient.person.contacts && patient.person.contacts.length > 0">
<template
v-for="contactType in personContactTypeOptions"
:key="contactType.code"
>
<DetailRow :label="contactType.label">
{{ patient.person.contacts.find((item) => item.type_code === contactType.code)?.value || '-' }}
</DetailRow>
</template>
</template>
<template v-else>
<DetailRow label="Kontak">-</DetailRow>
</template>
</DetailSection>
<DetailSection title="Orang Tua">
<template v-if="patient.person.relatives && patient.person.relatives.filter((rel) => !rel.responsible).length > 0">
<template
v-for="(relative, index) in patient.person.relatives.filter((rel) => !rel.responsible)"
:key="relative.id"
>
<div
v-if="index > 0"
class="mt-3 border-t border-gray-200 pt-3"
></div>
<DetailRow label="Nama">{{ relative.name || '-' }}</DetailRow>
<DetailRow label="Hubungan">
{{ relationshipOptions.find((item) => item.code === relative.relationship_code)?.label || '-' }}
</DetailRow>
<!-- <DetailRow label="Jenis Kelamin">
<Fragment
v-slot="{ section }"
title="Dokumen"
>
<AccordionItem value="item-document">
<AccordionTrigger>{{ section }}</AccordionTrigger>
<AccordionContent>
<div class="grid grid-cols-2 gap-4">
<div class="flex flex-col gap-2">
<span class="text-sm text-muted-foreground">Dokumen KTP</span>
<Skeleton class="h-32 w-64 rounded-md" />
</div>
<div class="flex flex-col gap-2">
<span class="text-sm text-muted-foreground">Dokumen KK</span>
<Skeleton class="h-32 w-64 rounded-md" />
</div>
</div>
</AccordionContent>
</AccordionItem>
</Fragment>
<Fragment
v-slot="{ section }"
title="Alamat"
>
<AccordionItem value="item-address">
<AccordionTrigger>{{ section }}</AccordionTrigger>
<AccordionContent>
<DetailRow :label="addressLocationTypeCode.domicile || 'Alamat Domisili'">
{{ domicileAddress || '-' }}
</DetailRow>
<DetailRow :label="addressLocationTypeCode.identity || 'Alamat KTP'">
{{ identityAddress || '-' }}
</DetailRow>
</AccordionContent>
</AccordionItem>
</Fragment>
<Fragment
v-slot="{ section }"
title="Kontak"
>
<AccordionItem value="item-contact">
<AccordionTrigger>{{ section }}</AccordionTrigger>
<AccordionContent>
<template v-if="patient.person.contacts && patient.person.contacts.length > 0">
<template
v-for="contactType in personContactTypeOptions"
:key="contactType.code"
>
<DetailRow :label="contactType.label">
{{ patient.person.contacts.find((item) => item.type_code === contactType.code)?.value || '-' }}
</DetailRow>
</template>
</template>
<template v-else>
<DetailRow label="Kontak">-</DetailRow>
</template>
</AccordionContent>
</AccordionItem>
</Fragment>
<Fragment
v-slot="{ section }"
title="Data Orang Tua"
>
<AccordionItem value="item-parents">
<AccordionTrigger>{{ section }}</AccordionTrigger>
<AccordionContent>
<template
v-if="patient.person.relatives && patient.person.relatives.filter((rel) => !rel.responsible).length > 0"
>
<template
v-for="(relative, index) in patient.person.relatives.filter((rel) => !rel.responsible)"
:key="relative.id"
>
<div
v-if="index > 0"
class="mt-3 border-t border-gray-200 pt-3"
></div>
<DetailRow label="Nama">{{ relative.name || '-' }}</DetailRow>
<DetailRow label="Hubungan">
{{ relationshipOptions.find((item) => item.code === relative.relationship_code)?.label || '-' }}
</DetailRow>
<!-- <DetailRow label="Jenis Kelamin">
{{ genderOptions.find((item) => item.code === relative.gender_code)?.label || '-' }}
</DetailRow> -->
<DetailRow label="Pendidikan">
{{ educationOptions.find((item) => item.code === relative.education_code)?.label || '-' }}
</DetailRow>
<DetailRow label="Pekerjaan">
{{
occupationOptions.find((item) => item.code === relative.occupation_code)?.label ||
relative.occupation_name ||
'-'
}}
</DetailRow>
<!-- <DetailRow label="Alamat">{{ relative.address || '-' }}</DetailRow> -->
<!-- <DetailRow label="Nomor HP">{{ relative.phoneNumber || '-' }}</DetailRow> -->
</template>
</template>
<template v-else>
<DetailRow label="Orang Tua">-</DetailRow>
</template>
</DetailSection>
<DetailSection title="Penanggung Jawab">
<template v-if="patient.person.relatives && patient.person.relatives.filter((rel) => rel.responsible).length > 0">
<template
v-for="(relative, index) in patient.person.relatives.filter((rel) => rel.responsible)"
:key="relative.id"
>
<div
v-if="index > 0"
class="mt-3 border-t border-gray-200 pt-3"
></div>
<DetailRow label="Nama">{{ relative.name || '-' }}</DetailRow>
<DetailRow label="Hubungan">
{{ relationshipOptions.find((item) => item.code === relative.relationship_code)?.label || '-' }}
</DetailRow>
<DetailRow label="Alamat">{{ relative.address || '-' }}</DetailRow>
<DetailRow label="Nomor HP">{{ relative.phoneNumber || '-' }}</DetailRow>
</template>
</template>
<template v-else>
<DetailRow label="Penanggung Jawab">-</DetailRow>
</template>
</DetailSection>
<div class="border-t-1 my-2 flex justify-end border-t-slate-300 py-2">
<PubMyUiNavFooterBaEd @click="onClick" />
<DetailRow label="Pendidikan">
{{ educationOptions.find((item) => item.code === relative.education_code)?.label || '-' }}
</DetailRow>
<!-- <DetailRow label="Pekerjaan">
{{
occupationOptions.find((item) => item.code === relative.occupation_code)?.label ||
relative.occupation_name ||
'-'
}}
</DetailRow> -->
<!-- <DetailRow label="Alamat">{{ relative.address || '-' }}</DetailRow> -->
<!-- <DetailRow label="Nomor HP">{{ relative.phoneNumber || '-' }}</DetailRow> -->
</template>
</template>
<template v-else>
<DetailRow label="Orang Tua">-</DetailRow>
</template>
</AccordionContent>
</AccordionItem>
</Fragment>
<Fragment
v-slot="{ section }"
title="Data Penanggung Jawab"
>
<AccordionItem value="item-relative">
<AccordionTrigger>{{ section }}</AccordionTrigger>
<AccordionContent>
<template
v-if="patient.person.relatives && patient.person.relatives.filter((rel) => rel.responsible).length > 0"
>
<template
v-for="(relative, index) in patient.person.relatives.filter((rel) => rel.responsible)"
:key="relative.id"
>
<div
v-if="index > 0"
class="mt-3 border-t border-gray-200 pt-3"
></div>
<DetailRow label="Nama">{{ relative.name || '-' }}</DetailRow>
<DetailRow label="Hubungan">
{{ relationshipOptions.find((item) => item.code === relative.relationship_code)?.label || '-' }}
</DetailRow>
<DetailRow label="Alamat">{{ relative.address || '-' }}</DetailRow>
<DetailRow label="Nomor HP">{{ relative.phoneNumber || '-' }}</DetailRow>
</template>
</template>
<template v-else>
<DetailRow label="Penanggung Jawab">-</DetailRow>
</template>
</AccordionContent>
</AccordionItem>
</Fragment>
</Accordion>
<div class="my-2 flex justify-end py-2">
<PubMyUiNavFooterBaEd @click="onNavigate" />
</div>
</template>
@@ -1,40 +1,46 @@
<script setup lang="ts">
import type { FormErrors } from '~/types/error'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import Block from '~/components/pub/my-ui/form/block.vue'
import FieldGroup from '~/components/pub/my-ui/form/field-group.vue'
import Field from '~/components/pub/my-ui/form/field.vue'
import InputBase from '~/components/pub/my-ui/form/input-base.vue'
import Label from '~/components/pub/my-ui/form/label.vue'
// schemas
import {
type PersonAddressRelativeFormData,
PersonAddressRelativeSchema,
} from '~/schemas/person-address-relative.schema'
// components
import * as DE from '~/components/pub/my-ui/doc-entry'
import { Label as RadioLabel } from '~/components/pub/ui/label'
import { RadioGroup, RadioGroupItem } from '~/components/pub/ui/radio-group'
import SelectDistrict from './_common/select-district.vue'
import SelectPostal from './_common/select-postal.vue'
import SelectProvince from './_common/select-province.vue'
import SelectRegency from './_common/select-regency.vue'
import SelectVillage from './_common/select-village.vue'
import { Form } from '~/components/pub/ui/form'
import { SelectDistrict, SelectPostal, SelectProvince, SelectRegency, SelectVillage } from './fields'
import { InputBase } from '~/components/pub/my-ui/form'
import * as DE from '~/components/pub/my-ui/doc-entry'
interface FormData extends PersonAddressRelativeFormData {}
const props = defineProps<{
interface Props {
title: string
isReadonly: boolean
conf?: {
withAddressName?: boolean
}
schema: any
initialValues?: any
errors?: FormErrors
}>()
}
const props = defineProps<Props>()
const formSchema = toTypedSchema(props.schema)
const formRef = ref()
const formSchema = toTypedSchema(PersonAddressRelativeSchema)
const { values, resetForm, setFieldValue, setValues, validate, setFieldError } = useForm<FormData>({
name: 'encounterActionReportForm',
validationSchema: formSchema,
initialValues: props.initialValues ? props.initialValues : { isSameAddress: '1', locationType_code: 'identity' },
validateOnMount: false,
})
defineExpose({
validate: () => formRef.value?.validate(),
resetForm: () => formRef.value?.resetForm(),
setValues: (values: any, shouldValidate = true) => formRef.value?.setValues(values, shouldValidate),
values: computed(() => formRef.value?.values),
validate,
resetForm,
setValues,
values,
})
// Watchers untuk cascading reset
@@ -53,16 +59,22 @@ const fieldStates: Record<string, { dependsOn?: string; placeholder: string }> =
// Computed untuk konversi boolean ke string untuk radio group
const isSameAddressString = computed(() => {
const value = formRef.value?.values?.isSameAddress
const value = values.isSameAddress
if (typeof value === 'boolean') {
return value ? '1' : '0'
}
return value || '1'
})
// Computed untuk cek apakah alamat sama (menangani boolean dan string)
const isSameAddress = computed(() => {
const value = values.isSameAddress as boolean | string | undefined
return value === true || value === '1'
})
// #region Function Helper
function getFieldState(field: string) {
const state = fieldStates[field]
const isSame = formRef.value?.values?.isSameAddress === true || formRef.value?.values?.isSameAddress === '1'
const isSame = isSameAddress.value
// Jika alamat sama, semua field kecuali provinsi disabled
if (['address', 'rt', 'rw'].includes(field) && isSame) {
@@ -71,7 +83,7 @@ function getFieldState(field: string) {
// Untuk field yang tergantung pada field lain
if (state?.dependsOn) {
const dependencyValue = formRef.value?.values?.[state.dependsOn]
const dependencyValue = values[state.dependsOn as keyof FormData]
const isDisabledByDependency = !dependencyValue
// Jika isSame, semua field location disabled
@@ -98,14 +110,14 @@ function getFieldState(field: string) {
// Watch province_code changes
watch(
() => formRef.value?.values?.province_code,
() => values.province_code,
(newValue, oldValue) => {
if (isResetting || !formRef.value || newValue === oldValue) return
if (isResetting || newValue === oldValue) return
if (oldValue && newValue !== oldValue) {
isResetting = true
formRef.value.setValues(
setValues(
{
regency_code: undefined,
district_code: undefined,
@@ -124,14 +136,14 @@ watch(
// Watch regency_code changes
watch(
() => formRef.value?.values?.regency_code,
() => values.regency_code,
(newValue, oldValue) => {
if (isResetting || !formRef.value || newValue === oldValue) return
if (isResetting || newValue === oldValue) return
if (oldValue && newValue !== oldValue) {
isResetting = true
formRef.value.setValues(
setValues(
{
district_code: undefined,
village_code: undefined,
@@ -149,14 +161,14 @@ watch(
// Watch district_code changes
watch(
() => formRef.value?.values?.district_code,
() => values.district_code,
(newValue, oldValue) => {
if (isResetting || !formRef.value || newValue === oldValue) return
if (isResetting || newValue === oldValue) return
if (oldValue && newValue !== oldValue) {
isResetting = true
formRef.value.setValues(
setValues(
{
village_code: undefined,
postalRegion_code: undefined,
@@ -173,14 +185,14 @@ watch(
// Watch village_code changes
watch(
() => formRef.value?.values?.village_code,
() => values.village_code,
(newValue, oldValue) => {
if (isResetting || !formRef.value || newValue === oldValue) return
if (isResetting || newValue === oldValue) return
if (oldValue && newValue !== oldValue) {
isResetting = true
formRef.value.setValues(
setValues(
{
postalRegion_code: undefined,
},
@@ -196,19 +208,19 @@ watch(
// Watch isSameAddress changes untuk trigger validasi
watch(
() => formRef.value?.values?.isSameAddress,
() => values.isSameAddress,
(newValue, oldValue) => {
if (!formRef.value || newValue === oldValue) return
if (isResetting || newValue === oldValue) return
// Konversi ke boolean untuk perbandingan yang konsisten
const newBool = newValue === true || newValue === '1'
const oldBool = oldValue === true || oldValue === '1'
const newBool = newValue === true || newValue === ('1' as unknown)
const oldBool = oldValue === true || oldValue === ('1' as unknown)
// Ketika berubah dari true ke false, clear empty strings dan trigger validasi
if (oldBool && !newBool) {
nextTick(() => {
// Set empty strings ke undefined untuk trigger required validation
const currentValues = formRef.value.values
const currentValues = values
const updatedValues = { ...currentValues }
// Convert empty strings to undefined untuk field yang sekarang required
@@ -220,11 +232,11 @@ watch(
if (updatedValues.address === '') updatedValues.address = undefined
// Update values dan trigger validasi
formRef.value.setValues(updatedValues, false)
setValues(updatedValues, false)
// Trigger validasi untuk menampilkan error
setTimeout(() => {
formRef.value?.validate()
validate()
}, 50)
})
}
@@ -233,14 +245,14 @@ watch(
if (!oldBool && newBool) {
nextTick(() => {
// Clear error messages untuk field yang tidak lagi required
formRef.value?.setFieldError('province_code', undefined)
formRef.value?.setFieldError('regency_code', undefined)
formRef.value?.setFieldError('district_code', undefined)
formRef.value?.setFieldError('village_code', undefined)
formRef.value?.setFieldError('postalRegion_code', undefined)
formRef.value?.setFieldError('address', undefined)
formRef.value?.setFieldError('rt', undefined)
formRef.value?.setFieldError('rw', undefined)
setFieldError('province_code', undefined)
setFieldError('regency_code', undefined)
setFieldError('district_code', undefined)
setFieldError('village_code', undefined)
setFieldError('postalRegion_code', undefined)
setFieldError('address', undefined)
setFieldError('rt', undefined)
setFieldError('rw', undefined)
})
}
},
@@ -249,20 +261,11 @@ watch(
</script>
<template>
<Form
ref="formRef"
v-slot="{ values }"
as=""
keep-values
:validation-schema="formSchema"
:validate-on-mount="false"
validation-mode="onSubmit"
:initial-values="initialValues ?? { isSameAddress: '1', locationType_code: 'identity' }"
>
<form @submit-prevent="">
<div>
<p
v-if="props.title"
class="text-sm 2xl:text-base mb-2 2xl:mb-3 font-semibold"
class="mb-2 text-sm font-semibold 2xl:mb-3 2xl:text-base"
>
{{ props.title }}
</p>
@@ -278,7 +281,10 @@ watch(
value="identity"
/>
</FormField>
<DE.Block :col-count="4" :cell-flex="false">
<DE.Block
:col-count="4"
:cell-flex="false"
>
<DE.Cell :col-span="4">
<DE.Label
size="fit"
@@ -287,10 +293,7 @@ watch(
>
Apakah alamat KTP sama dengan alamat sekarang?
</DE.Label>
<DE.Field
id="isSameAddress"
:errors="errors"
>
<DE.Field id="isSameAddress">
<FormField
v-slot="{ componentField }"
name="isSameAddress"
@@ -311,6 +314,7 @@ watch(
class="flex min-w-fit items-center space-x-2"
>
<RadioGroupItem
:disabled="isReadonly"
:id="`isSameAddress-${index}`"
:value="option.value"
class="relative h-4 w-4 rounded-full border-2 border-muted-foreground before:absolute before:inset-1 before:rounded-full before:bg-primary before:opacity-0 before:transition-opacity data-[state=checked]:border-primary data-[state=checked]:bg-white data-[state=checked]:before:opacity-100 sm:h-5 sm:w-5"
@@ -332,52 +336,49 @@ watch(
<SelectProvince
field-name="province_code"
placeholder="Pilih"
:is-disabled="values.isSameAddress === true || values.isSameAddress === '1'"
:is-required="values.isSameAddress !== true && values.isSameAddress !== '1'"
:is-disabled="isReadonly || isSameAddress"
:is-required="!isSameAddress"
/>
<SelectRegency
field-name="regency_code"
:province-code="values.province_code"
:is-disabled="getFieldState('regency_code').disabled"
:is-required="values.isSameAddress !== true && values.isSameAddress !== '1'"
:is-disabled="isReadonly || getFieldState('regency_code').disabled"
:is-required="!isSameAddress"
/>
<SelectDistrict
field-name="district_code"
:regency-code="values.regency_code"
:is-disabled="getFieldState('district_code').disabled"
:is-required="values.isSameAddress !== true && values.isSameAddress !== '1'"
:is-disabled="isReadonly || getFieldState('district_code').disabled"
:is-required="!isSameAddress"
/>
<SelectVillage
field-name="village_code"
:district-code="values.district_code"
:is-disabled="getFieldState('village_code').disabled"
:is-required="values.isSameAddress !== true && values.isSameAddress !== '1'"
:is-disabled="isReadonly || getFieldState('village_code').disabled"
:is-required="!isSameAddress"
/>
<InputBase
field-name="address"
label="Alamat"
:placeholder="getFieldState('address').placeholder"
:is-disabled="getFieldState('address').disabled"
:errors="errors"
:is-required="values.isSameAddress !== true && values.isSameAddress !== '1'"
:is-disabled="isReadonly || getFieldState('address').disabled"
:is-required="!isSameAddress"
:col-span="2"
/>
<div class="grid grid-cols-2 gap-1">
<InputBase
field-name="rt"
label="RT"
:errors="errors"
numeric-only
:max-length="2"
:placeholder="getFieldState('rt').placeholder"
:is-disabled="getFieldState('rt').disabled"
:is-disabled="isReadonly || getFieldState('rt').disabled"
/>
<InputBase
field-name="rw"
label="RW"
:placeholder="getFieldState('rw').placeholder"
:is-disabled="getFieldState('rw').disabled"
:errors="errors"
:is-disabled="isReadonly || getFieldState('rw').disabled"
:max-length="2"
numeric-only
/>
@@ -386,9 +387,8 @@ watch(
field-name="postalRegion_code"
:village-code="values.village_code"
:placeholder="getFieldState('postalRegion_code').placeholder"
:is-disabled="getFieldState('postalRegion_code').disabled || !values.village_code"
:is-disabled="isReadonly || getFieldState('postalRegion_code').disabled || !values.village_code"
/>
</DE.Block>
</Form>
</form>
</template>
@@ -1,37 +1,45 @@
<script setup lang="ts">
import type { FormErrors } from '~/types/error'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import FieldGroup from '~/components/pub/my-ui/form/field-group.vue'
import Field from '~/components/pub/my-ui/form/field.vue'
import InputBase from '~/components/pub/my-ui/form/input-base.vue'
import Label from '~/components/pub/my-ui/form/label.vue'
import SelectDistrict from './_common/select-district.vue'
import SelectPostal from './_common/select-postal.vue'
import SelectProvince from './_common/select-province.vue'
import SelectRegency from './_common/select-regency.vue'
import SelectVillage from './_common/select-village.vue'
// types
import { type PersonAddressFormData, PersonAddressSchema } from '~/schemas/person-address.schema'
// components
import { Form } from '~/components/pub/ui/form'
import * as DE from '~/components/pub/my-ui/doc-entry'
import InputBase from '~/components/pub/my-ui/form/input-base.vue'
import { SelectDistrict, SelectPostal, SelectProvince, SelectRegency, SelectVillage } from './fields'
const props = defineProps<{
interface FormData extends PersonAddressFormData {}
interface Props {
title: string
isReadonly: boolean
conf?: {
withAddressName?: boolean
}
schema: any
initialValues?: any
errors?: FormErrors
}>()
}
const formSchema = toTypedSchema(props.schema)
const formRef = ref()
const props = defineProps<Props>()
const formSchema = toTypedSchema(PersonAddressSchema)
const { values, resetForm, setValues, validate } = useForm<FormData>({
name: 'patientForm',
validationSchema: formSchema,
initialValues: {
locationType_code: 'domicile',
...props.initialValues,
},
validateOnMount: false,
})
defineExpose({
validate: () => formRef.value?.validate(),
resetForm: () => formRef.value?.resetForm(),
setValues: (values: any, shouldValidate = true) => formRef.value?.setValues(values, shouldValidate),
values: computed(() => formRef.value?.values),
validate,
resetForm,
setValues,
values,
})
// Watchers untuk cascading reset
@@ -40,22 +48,22 @@ let isResetting = false
// #region Watch provinceCode changes
watch(
() => formRef.value?.values?.provinceCode,
() => values.province_code,
(newValue, oldValue) => {
if (isResetting || !formRef.value || newValue === oldValue) return
if (isResetting || !values || newValue === oldValue) return
if (oldValue && newValue !== oldValue) {
isResetting = true
// Delay reset untuk memberikan waktu composable menyelesaikan request
setTimeout(() => {
if (formRef.value) {
formRef.value.setValues(
if (values) {
setValues(
{
regencyId: undefined,
districtId: undefined,
villageId: undefined,
zipCode: undefined,
regency_code: undefined,
district_code: undefined,
village_code: undefined,
postalRegion_code: undefined,
},
false,
)
@@ -71,21 +79,21 @@ watch(
// Watch regencyId changes
watch(
() => formRef.value?.values?.regencyId,
() => values.regency_code,
(newValue, oldValue) => {
if (isResetting || !formRef.value || newValue === oldValue) return
if (isResetting || !values || newValue === oldValue) return
if (oldValue && newValue !== oldValue) {
isResetting = true
// Delay reset untuk memberikan waktu composable menyelesaikan request
setTimeout(() => {
if (formRef.value) {
formRef.value.setValues(
if (values) {
setValues(
{
districtId: undefined,
villageId: undefined,
zipCode: undefined,
district_code: undefined,
village_code: undefined,
postalRegion_code: undefined,
},
false,
)
@@ -101,20 +109,20 @@ watch(
// Watch districtId changes
watch(
() => formRef.value?.values?.districtId,
() => values.district_code,
(newValue, oldValue) => {
if (isResetting || !formRef.value || newValue === oldValue) return
if (isResetting || !values || newValue === oldValue) return
if (oldValue && newValue !== oldValue) {
isResetting = true
// Delay reset untuk memberikan waktu composable menyelesaikan request
setTimeout(() => {
if (formRef.value) {
formRef.value.setValues(
if (values) {
setValues(
{
villageId: undefined,
zipCode: undefined,
village_code: undefined,
postalRegion_code: undefined,
},
false,
)
@@ -130,16 +138,16 @@ watch(
// Watch villageId changes
watch(
() => formRef.value?.values?.villageId,
() => values.village_code,
(newValue, oldValue) => {
if (isResetting || !formRef.value || newValue === oldValue) return
if (isResetting || !values || newValue === oldValue) return
if (oldValue && newValue !== oldValue) {
isResetting = true
formRef.value.setValues(
setValues(
{
zipCode: undefined,
postalRegion_code: undefined,
},
false,
)
@@ -154,22 +162,11 @@ watch(
</script>
<template>
<Form
ref="formRef"
v-slot="{ values }"
as=""
keep-values
:validation-schema="formSchema"
:validate-on-mount="false"
validation-mode="onSubmit"
:initial-values="
initialValues ? { locationType_code: 'domicile', ...initialValues } : { locationType_code: 'domicile' }
"
>
<form>
<div>
<p
v-if="props.title"
class="text-sm 2xl:text-base mb-2 2xl:mb-3 font-semibold"
class="mb-2 text-sm font-semibold 2xl:mb-3 2xl:text-base"
>
{{ props.title }}
</p>
@@ -185,33 +182,40 @@ watch(
/>
</FormField>
<DE.Block :col-count="4" :cell-flex="false">
<DE.Block
:col-count="4"
:cell-flex="false"
>
<SelectProvince
field-name="province_code"
placeholder="Pilih"
:is-disabled="isReadonly"
is-required
/>
<SelectRegency
field-name="regency_code"
:province-code="values.province_code"
:is-disabled="isReadonly"
is-required
/>
<SelectDistrict
field-name="district_code"
:regency-code="values.regency_code"
:is-disabled="isReadonly"
is-required
/>
<SelectVillage
field-name="village_code"
:district-code="values.district_code"
:is-disabled="isReadonly"
is-required
/>
<InputBase
field-name="address"
label="Alamat"
placeholder="Masukkan alamat"
:errors="errors"
is-required
:is-disabled="isReadonly"
:col-span="2"
/>
<DE.Cell class="flex-row gap-2">
@@ -220,26 +224,26 @@ watch(
field-name="rt"
label="RT"
placeholder="01"
:errors="errors"
numeric-only
:max-length="2"
/>
:is-disabled="isReadonly"
/>
<InputBase
field-name="rw"
label="RW"
placeholder="02"
:errors="errors"
:max-length="2"
numeric-only
:is-disabled="isReadonly"
/>
</div>
</DE.Cell>
<SelectPostal
field-name="postalRegion_code"
placeholder="Pilih kelurahan dahulu"
:village-code="values.village_code"
:is-disabled="!values.village_code"
/>
field-name="postalRegion_code"
placeholder="Pilih kelurahan dahulu"
:village-code="values.village_code"
:is-disabled="!values.village_code"
/>
</DE.Block>
</Form>
</form>
</template>
@@ -0,0 +1,6 @@
export { default as RadioResidence } from './radio-residence.vue'
export { default as SelectDistrict } from './select-district.vue'
export { default as SelectPostal } from './select-postal.vue'
export { default as SelectProvince } from './select-province.vue'
export { default as SelectRegency } from './select-regency.vue'
export { default as SelectVillage } from './select-village.vue'
@@ -1,41 +1,45 @@
<script setup lang="ts">
import type { FormErrors } from '~/types/error'
import { useForm, FieldArray } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { FieldArray } from 'vee-validate'
import InputBase from '~/components/pub/my-ui/form/input-base.vue'
import SelectContactType from './_common/select-contact-type.vue'
import { Form } from '~/components/pub/ui/form'
const props = defineProps<{
// type
import { type PersonContactFormData, PersonContactListSchema } from '~/schemas/person-contact.schema'
// components
import * as DE from '~/components/pub/my-ui/doc-entry'
import { SelectContactType } from './fields'
import { ButtonAction, InputBase } from '~/components/pub/my-ui/form'
interface FormData extends PersonContactFormData {}
interface Props {
title: string
schema: any
contactLimit: number
isReadonly: boolean
contactLimit?: number
initialValues?: any
errors?: FormErrors
}>()
}
const props = defineProps<Props>()
const { contactLimit = 5 } = props
const formSchema = toTypedSchema(props.schema)
const formRef = ref()
const formSchema = toTypedSchema(PersonContactListSchema)
const { values, resetForm, setValues, validate } = useForm<FormData>({
name: 'personContactForm',
validationSchema: formSchema,
initialValues: props.initialValues ? props.initialValues : {},
validateOnMount: false,
})
defineExpose({
validate: () => formRef.value?.validate(),
resetForm: () => formRef.value?.resetForm(),
setValues: (values: any, shouldValidate = true) => formRef.value?.setValues(values, shouldValidate),
values: computed(() => formRef.value?.values),
validate,
resetForm,
setValues,
values,
})
</script>
<template>
<Form
ref="formRef"
as=""
keep-values
:validation-schema="formSchema"
:validate-on-mount="false"
validation-mode="onSubmit"
:initial-values="initialValues || { contacts: [{ contactType: '', contactNumber: '' }] }"
>
<form @submit.prevent>
<div>
<p
v-if="props.title"
@@ -44,68 +48,59 @@ defineExpose({
{{ props.title || 'Kontak Pasien' }}
</p>
</div>
<div class="mb-5 border-b border-b-slate-300 pb-3 text-lg xl:text-xl">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3">
<FieldArray
v-slot="{ fields, push, remove }"
name="contacts"
>
<div
<div class="mb-5 space-y-4">
<FieldArray
v-slot="{ fields, push, remove }"
name="contacts"
>
<template v-if="fields.length === 0">
{{ push({ relation: '', name: '', address: '', phone: '' }) }}
</template>
<div class="space-y-4">
<DE.Block
v-for="(field, idx) in fields"
:key="field.key"
class="flex-row gap-2 md:flex"
:col-count="5"
:cell-flex="false"
>
<div class="min-w-0 flex-[2]">
<SelectContactType
:field-name="`contacts[${idx}].contactType`"
:label="`Kontak ${idx + 1}`"
:is-required="idx === 0"
/>
</div>
<div class="min-w-0 flex-[1.5]">
<InputBase
:field-name="`contacts[${idx}].contactNumber`"
placeholder="081234567890"
label="No"
numeric-only
:max-length="15"
:is-required="idx === 0"
/>
</div>
<div class="flex-1 self-start md:pt-8">
<Button
type="button"
variant="outline"
size="icon"
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"
<SelectContactType
:label="idx === 0 ? 'Jenis Kontak' : undefined"
:field-name="`contacts[${idx}].contactType`"
:is-disabled="isReadonly"
/>
<InputBase
:label="idx === 0 ? 'Nomor Kontak' : undefined"
:field-name="`contacts[${idx}].contactNumber`"
placeholder="081234567890"
:max-length="15"
numeric-only
:is-disabled="isReadonly"
/>
<DE.Cell class="flex items-start justify-start">
<DE.Field :class="idx === 0 ? 'mt-[30px]' : 'mt-0'">
<ButtonAction
v-if="idx !== 0"
:disabled="isReadonly"
preset="delete"
:title="`Hapus Kontak ${idx + 1}`"
icon-only
@click="remove(idx)"
/>
</Button>
</div>
</div>
</DE.Field>
</DE.Cell>
</DE.Block>
</div>
<div class="self-center pt-3">
<Button
:disabled="fields.length >= contactLimit"
type="button"
variant="outline"
size="sm"
@click="push({ contactType: '', contactNumber: '' })"
>
<Icon
name="i-lucide-plus"
class="h-5 w-5"
/>
Tambah Kontak
</Button>
</div>
</FieldArray>
</div>
<ButtonAction
preset="add"
label="Tambah Kontak"
title="Tambah Kontak ke Daftar Kontak"
:disabled="isReadonly || fields.length >= contactLimit"
:full-width-mobile="true"
class="mt-4"
@click="push({ contactType: '', contactNumber: '' })"
/>
</FieldArray>
</div>
</Form>
</form>
</template>
@@ -0,0 +1 @@
export { default as SelectContactType } from './select-contact-type.vue'
@@ -1,14 +1,13 @@
<script setup lang="ts">
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 * as DE from '~/components/pub/my-ui/doc-entry'
import Select from '~/components/pub/my-ui/form/select.vue'
const props = defineProps<{
fieldName: string
label: string
label?: string
placeholder?: string
errors?: FormErrors
class?: string
@@ -17,11 +16,12 @@ const props = defineProps<{
labelClass?: string
isRequired?: boolean
isDisabled?: boolean
colSpan?: number
}>()
const { fieldName = 'phoneNumber', errors, class: containerClass, selectClass, fieldGroupClass, labelClass } = props
const contactOptions = [
const opts = [
{ label: 'Nomor HP', value: 'phoneNumber' },
{ label: 'Nomor Telepon Kantor', value: 'officePhoneNumber' },
{ label: 'Nomor Telepon Rumah', value: 'homePhoneNumber' },
@@ -29,17 +29,20 @@ const contactOptions = [
</script>
<template>
<FieldGroup :class="cn('select-field-group', fieldGroupClass, containerClass)">
<Label
<DE.Cell
:col-span="colSpan"
:class="cn('select-field-group', fieldGroupClass, containerClass)"
>
<DE.Label
v-if="label"
:label-for="fieldName"
:class="cn('select-field-label', labelClass)"
:is-required="isRequired && !isDisabled"
:is-required="isRequired"
>
{{ label }}
</Label>
<Field
</DE.Label>
<DE.Field
:id="fieldName"
:errors="errors"
:class="cn('select-field-wrapper')"
>
<FormField
@@ -49,15 +52,15 @@ const contactOptions = [
<FormItem>
<FormControl>
<Select
:disabled="isDisabled"
:id="fieldName"
v-bind="componentField"
:is-disabled="isDisabled"
:items="contactOptions"
:placeholder="placeholder || 'Pilih jenis kontak'"
:items="opts"
:placeholder="placeholder"
:preserve-order="true"
:class="
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,
)
"
@@ -66,6 +69,6 @@ const contactOptions = [
<FormMessage />
</FormItem>
</FormField>
</Field>
</FieldGroup>
</DE.Field>
</DE.Cell>
</template>
+86 -124
View File
@@ -1,46 +1,49 @@
<script setup lang="ts">
import type { FormErrors } from '~/types/error'
import { useForm, FieldArray } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { FieldArray } from 'vee-validate'
import InputBase from '~/components/pub/my-ui/form/input-base.vue'
import Label from '~/components/pub/my-ui/form/label.vue'
import SelectContactRelation from './_common/select-relations.vue'
import { Form } from '~/components/pub/ui/form'
const props = defineProps<{
// schemas
import { type PersonRelativeFormData, ResponsiblePersonRelativeSchema } from '~/schemas/person-relative.schema'
// components
import * as DE from '~/components/pub/my-ui/doc-entry'
import { SelectRelations } from './fields'
import { ButtonAction, InputBase } from '~/components/pub/my-ui/form'
interface FormData extends PersonRelativeFormData {}
interface Props {
title: string
schema: any
isReadonly: boolean
initialValues?: any
errors?: FormErrors
}>()
contactLimit?: number
}
const props = defineProps<Props>()
const formSchema = toTypedSchema(props.schema)
const formRef = ref()
const formSchema = toTypedSchema(ResponsiblePersonRelativeSchema)
const { values, resetForm, setValues, validate } = useForm<FormData>({
name: 'personRelativeForm',
validationSchema: formSchema,
initialValues: props.initialValues ? props.initialValues : {},
validateOnMount: false,
})
defineExpose({
validate: () => formRef.value?.validate(),
resetForm: () => formRef.value?.resetForm(),
setValues: (values: any, shouldValidate = true) => formRef.value?.setValues(values, shouldValidate),
values: computed(() => formRef.value?.values),
validate,
resetForm,
setValues,
values: values,
})
const { title = 'Kontak Pasien', contactLimit = 5 } = props
</script>
<template>
<Form
ref="formRef"
as=""
keep-values
:validation-schema="formSchema"
:validate-on-mount="false"
validation-mode="onSubmit"
:initial-values="initialValues || { contacts: [{ relation: '', name: '', address: '', phone: '' }] }"
>
<form @submit.prevent>
<div>
<p
v-if="props.title"
class="text-md mb-2 mt-1 font-semibold"
>
{{ props.title || 'Kontak Pasien' }}
<p class="text-md mb-2 mt-1 font-semibold">
{{ title }}
</p>
</div>
<div class="mb-5 space-y-4">
@@ -48,108 +51,67 @@ defineExpose({
v-slot="{ fields, push, remove }"
name="contacts"
>
<template v-if="fields.length === 0">
{{ push({ relation: '', name: '', address: '', phone: '' }) }}
</template>
<div class="space-y-4">
<div
<DE.Block
v-for="(field, idx) in fields"
:key="field.key"
class="rounded-lg border border-gray-200 bg-gray-50/50 p-4 dark:border-gray-700 dark:bg-gray-800/50"
:col-count="5"
:cell-flex="false"
>
<div class="mb-3 flex items-center justify-between">
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300">Penanggung Jawab {{ idx + 1 }}</h4>
<Button
v-if="idx !== 0"
type="button"
variant="outline"
size="sm"
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"
@click="remove(idx)"
>
<Icon
name="i-lucide-trash-2"
class="h-4 w-4"
<SelectRelations
:label="idx === 0 ? 'Hubungan dengan Pasien' : undefined"
:field-name="`contacts[${idx}].relation`"
placeholder="Pilih"
:is-disabled="isReadonly"
/>
<InputBase
:label="idx === 0 ? 'Nama' : undefined"
:field-name="`contacts[${idx}].name`"
placeholder="Masukkan Nama"
:is-disabled="isReadonly"
/>
<InputBase
:label="idx === 0 ? 'Alamat' : undefined"
:field-name="`contacts[${idx}].address`"
placeholder="Masukkan Alamat"
:is-disabled="isReadonly"
/>
<InputBase
:label="idx === 0 ? 'Nomor HP' : undefined"
:field-name="`contacts[${idx}].phone`"
placeholder="081234567890"
:max-length="15"
numeric-only
:is-disabled="isReadonly"
/>
<DE.Cell class="flex items-start justify-start">
<DE.Field :class="idx === 0 ? 'mt-[30px]' : 'mt-0'">
<ButtonAction
v-if="idx !== 0"
:disabled="isReadonly"
preset="delete"
:title="`Hapus Kontak ${idx + 1}`"
icon-only
@click="remove(idx)"
/>
</Button>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
<div class="space-y-2">
<Label
class="text-sm font-medium text-gray-700 dark:text-gray-300"
is-required
>
Hubungan dengan Pasien
</Label>
<SelectContactRelation
:field-name="`contacts[${idx}].relation`"
placeholder="Pilih"
:errors="errors"
field-group-class="mb-0"
/>
</div>
<div class="space-y-2">
<Label
class="text-sm font-medium text-gray-700 dark:text-gray-300"
is-required
>
Nama
</Label>
<InputBase
label=""
:field-name="`contacts[${idx}].name`"
placeholder="Masukkan Nama"
:errors="errors"
/>
</div>
<div class="space-y-2">
<Label
class="text-sm font-medium text-gray-700 dark:text-gray-300"
is-required
>
Alamat
</Label>
<InputBase
:field-name="`contacts[${idx}].address`"
label=""
placeholder="Masukkan Alamat"
:errors="errors"
/>
</div>
<div class="space-y-2">
<Label
class="text-sm font-medium text-gray-700 dark:text-gray-300"
is-required
>
Nomor HP
</Label>
<InputBase
:field-name="`contacts[${idx}].phone`"
label=""
placeholder="081234567890"
:max-length="15"
numeric-only
:errors="errors"
/>
</div>
</div>
</div>
</DE.Field>
</DE.Cell>
</DE.Block>
</div>
<Button
type="button"
variant="outline"
class="w-full rounded-md border border-primary bg-white px-4 py-2 text-primary hover:border-primary hover:bg-primary hover:text-white sm:w-auto sm:text-sm"
<ButtonAction
preset="add"
label="Tambah Penanggung Jawab"
title="Tambah Penanggung Jawab"
:disabled="fields.length >= contactLimit || isReadonly"
:full-width-mobile="true"
class="mt-4"
@click="push({ relation: '', name: '', address: '', phone: '' })"
>
<Icon
name="i-lucide-plus"
class="mr-2 h-4 w-4 align-middle transition-colors"
/>
Tambah Penanggung Jawab
</Button>
/>
</FieldArray>
</div>
</Form>
</form>
</template>
@@ -0,0 +1 @@
export { default as SelectRelations } from './select-relations.vue'
@@ -1,36 +1,47 @@
<script setup lang="ts">
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 Select from '~/components/pub/my-ui/form/select.vue'
import { cn } from '~/lib/utils'
import { relationshipCodes } from '~/lib/constants'
import * as DE from '~/components/pub/my-ui/doc-entry'
import Select from '~/components/pub/my-ui/form/select.vue'
const props = defineProps<{
fieldName: string
label?: string
placeholder?: string
errors?: FormErrors
class?: string
selectClass?: string
fieldGroupClass?: string
labelClass?: string
isDisabled?: boolean
isRequired?: boolean
colSpan?: number
}>()
const { fieldName = 'phoneNumber', errors, class: containerClass, selectClass, fieldGroupClass } = props
const { class: containerClass, selectClass, fieldGroupClass } = props
const emergencyContactOptions = Object.entries(relationshipCodes).map(([value, label]) => ({
const opts = Object.entries(relationshipCodes).map(([value, label]) => ({
label,
value,
...(value === 'other' && { priority: -1 })
...(value === 'other' && { priority: -1 }),
}))
</script>
<template>
<FieldGroup :class="cn('select-field-group', fieldGroupClass, containerClass)">
<Field
<DE.Cell
:col-span="colSpan"
:class="cn('select-field-group', fieldGroupClass, containerClass)"
>
<DE.Label
v-if="label"
:label-for="fieldName"
:class="cn('select-field-label', labelClass)"
:is-required="isRequired"
>
{{ label }}
</DE.Label>
<DE.Field
:id="fieldName"
:errors="errors"
:class="cn('select-field-wrapper')"
>
<FormField
@@ -40,10 +51,10 @@ const emergencyContactOptions = Object.entries(relationshipCodes).map(([value, l
<FormItem>
<FormControl>
<Select
:disabled="isDisabled"
:id="fieldName"
v-bind="componentField"
:is-disabled="isDisabled"
:items="emergencyContactOptions"
:items="opts"
:placeholder="placeholder"
:preserve-order="true"
:class="
@@ -57,6 +68,6 @@ const emergencyContactOptions = Object.entries(relationshipCodes).map(([value, l
<FormMessage />
</FormItem>
</FormField>
</Field>
</FieldGroup>
</DE.Field>
</DE.Cell>
</template>
+104 -134
View File
@@ -1,167 +1,137 @@
<script setup lang="ts">
import type { PersonFamilyFormData as FamilyData } from '~/schemas/person-family.schema'
import type { FormErrors } from '~/types/error'
import { type PersonRelativeFormData, ResponsiblePersonRelativeSchema } from '~/schemas/person-relative.schema'
import { toTypedSchema } from '@vee-validate/zod'
import { FieldArray } from 'vee-validate'
import SelectEducation from '~/components/app/patient/_common/select-education.vue'
import InputBase from '~/components/pub/my-ui/form/input-base.vue'
import RadioParentsInput from './_common/radio-parents-input.vue'
import { Form } from '~/components/pub/ui/form'
import { useForm, FieldArray } from 'vee-validate'
const props = defineProps<{
// component
import * as DE from '~/components/pub/my-ui/doc-entry'
import { InputBase } from '~/components/pub/my-ui/form'
import { RadioParentsInput } from './fields'
import { SelectJob, SelectEducation } from '~/components/app/patient/fields'
import { SelectRelations } from '~/components/app/person-relative/fields'
interface FormData extends PersonRelativeFormData {}
interface Props {
title: string
schema: any
initialValues?: any
errors?: FormErrors
}>()
isReadonly: boolean
initialValues?: FormData
}
const formSchema = toTypedSchema(props.schema)
const formRef = ref()
const props = defineProps<Props>()
// Watcher untuk mengatur families ketika shareFamilyData berubah
watch(
() => formRef.value?.values?.shareFamilyData,
(newValue) => {
if (newValue === '1' && formRef.value) {
// Ketika memilih "Ya", pastikan ada data families untuk mother dan father
const currentFamilies = formRef.value.values?.families || []
if (currentFamilies.length === 0) {
formRef.value.setFieldValue('families', [
{ relation: 'mother', name: undefined, education: undefined, occupation: undefined },
{ relation: 'father', name: undefined, education: undefined, occupation: undefined },
])
}
} else if (newValue === '0' && formRef.value) {
// Ketika memilih "Tidak", kosongkan families
formRef.value.setFieldValue('families', [])
}
},
{ immediate: false },
)
const formSchema = toTypedSchema(ResponsiblePersonRelativeSchema)
const isFamilyFormDisabled = ref(true)
const { values, resetForm, setValues, validate, setFieldValue } = useForm<FormData>({
name: 'familyParentsForm',
validationSchema: formSchema,
initialValues: props.initialValues ? props.initialValues : {},
validateOnMount: false,
})
defineExpose({
validate: () => formRef.value?.validate(),
resetForm: () => formRef.value?.resetForm(),
values: computed(() => formRef.value?.values),
validate,
resetForm,
setValues,
values,
})
watch(
() => values._shareFamilyData,
(newValue) => {
if (!newValue) return
if (newValue === 'yes') {
isFamilyFormDisabled.value = false
const fam = values?.families || []
const needsReset = fam.length !== 2 || fam[0]?.relation !== 'mother' || fam[1]?.relation !== 'father'
if (needsReset) {
setFieldValue('families', [
{ relation: 'mother', name: '', education_code: '', occupation_code: '' },
{ relation: 'father', name: '', education_code: '', occupation_code: '' },
])
}
return
}
isFamilyFormDisabled.value = true
setFieldValue('families', [])
},
{ immediate: true },
)
</script>
<template>
<Form
ref="formRef"
v-slot="{ values }"
as=""
keep-values
:validation-schema="formSchema"
:validate-on-mount="false"
validation-mode="onSubmit"
:initial-values="
initialValues || {
shareFamilyData: '0',
families: [],
}
"
>
<form @submit.prevent>
<div>
<p
v-if="props.title"
class="text-sm 2xl:text-base mb-2 2xl:mb-3 font-semibold"
class="mb-2 text-sm font-semibold 2xl:mb-3 2xl:text-base"
>
{{ props.title }}
</p>
</div>
<div class="mb-5 border-b border-b-slate-300 pb-3 text-lg xl:text-xl">
<!-- Radio Button Section - Full Width -->
<div class="mb-6">
<RadioParentsInput field-name="shareFamilyData" />
<div class="mb-5 pb-3 text-lg xl:text-xl">
<div>
<RadioParentsInput
field-name="_shareFamilyData"
:is-disabled="isReadonly"
/>
</div>
<!-- Form Fields Section - Only show when "Ya" is selected -->
<div
v-if="values.shareFamilyData === '1'"
class="space-y-6"
>
<div :key="values._shareFamilyData">
<FieldArray
v-slot="{ fields }"
v-slot="{ fields, push, remove }"
name="families"
>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<template
<template v-if="fields.length === 0">
{{ push({ relation: 'mother', name: '', address: '', education_code: '' }) }}
{{ push({ relation: 'father', name: '', address: '', education_code: '' }) }}
</template>
<div class="space-y-4">
<DE.Block
v-for="(field, idx) in fields"
:key="field.key"
:col-count="5"
:cell-flex="false"
>
<div
class="space-y-4 rounded-lg border border-gray-200 bg-gray-50/50 p-4 dark:border-gray-700 dark:bg-gray-800/50"
>
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ (values.families as FamilyData[])?.[idx]?.relation === 'mother' ? 'Data Ibu' : 'Data Ayah' }}
</h4>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<InputBase
:field-name="`families[${idx}].name`"
:label="
(values.families as FamilyData[])?.[idx]?.relation === 'mother'
? 'Nama Ibu Kandung'
: (values.families as FamilyData[])?.[idx]?.relation === 'father'
? 'Nama Ayah Kandung'
: 'Nama Keluarga'
"
:placeholder="
(values.families as FamilyData[])?.[idx]?.relation === 'mother'
? 'Masukkan nama ibu pasien'
: (values.families as FamilyData[])?.[idx]?.relation === 'father'
? 'Masukkan nama ayah pasien'
: 'Masukkan nama'
"
:errors="errors"
is-required
/>
</div>
<div>
<SelectEducation
:field-name="`families[${idx}].education`"
:label="
(values.families as FamilyData[])?.[idx]?.relation === 'mother'
? 'Pendidikan Ibu'
: (values.families as FamilyData[])?.[idx]?.relation === 'father'
? 'Pendidikan Ayah'
: 'Pendidikan'
"
placeholder="Pilih"
:errors="errors"
/>
</div>
</div>
<div>
<InputBase
:field-name="`families[${idx}].occupation`"
:label="
(values.families as FamilyData[])?.[idx]?.relation === 'mother'
? 'Pekerjaan Ibu'
: (values.families as FamilyData[])?.[idx]?.relation === 'father'
? 'Pekerjaan Ayah'
: 'Pekerjaan'
"
:placeholder="
(values.families as FamilyData[])?.[idx]?.relation === 'mother'
? 'Masukkan pekerjaan ibu'
: (values.families as FamilyData[])?.[idx]?.relation === 'father'
? 'Masukkan pekerjaan ayah'
: 'Masukkan pekerjaan'
"
:errors="errors"
/>
</div>
</div>
</template>
<SelectRelations
:label="idx === 0 ? 'Hubungan dengan Pasien' : undefined"
:field-name="`families[${idx}].relation`"
placeholder="Pilih"
is-disabled
/>
<InputBase
:label="idx === 0 ? 'Nama' : undefined"
:field-name="`families[${idx}].name`"
placeholder="Masukkan Nama"
:is-disabled="isReadonly || values._shareFamilyData === 'no'"
/>
<!-- <SelectJob
:label="idx === 0 ? 'Pekerjaan' : undefined"
:field-name="`families[${idx}].occupation_code`"
placeholder="Pilih pekerjaan"
is-required
:is-disabled="isReadonly || values.shareFamilyData === 'no'"
/> -->
<SelectEducation
:label="idx === 0 ? 'Pendidikan' : undefined"
:field-name="`families[${idx}].education_code`"
placeholder="Pilih pendidikan"
is-required
:is-disabled="isReadonly || values._shareFamilyData === 'no'"
/>
</DE.Block>
</div>
</FieldArray>
</div>
</div>
</Form>
</form>
</template>
@@ -0,0 +1,2 @@
export { default as RadioParentsInput } from './radio-parents-input.vue'
export { default as SelectBirthPlace } from './select-birth-place.vue'
@@ -16,6 +16,7 @@ const props = defineProps<{
radioItemClass?: string
labelClass?: string
isRequired?: boolean
isDisabled?: boolean
}>()
const {
@@ -28,8 +29,8 @@ const {
} = props
const residenceOptions = [
{ label: 'Ya', value: '1' },
{ label: 'Tidak', value: '0' },
{ label: 'Ya', value: 'yes' },
{ label: 'Tidak', value: 'no' },
]
</script>
@@ -55,6 +56,7 @@ const residenceOptions = [
<FormControl>
<RadioGroup
v-bind="componentField"
:disabled="isDisabled"
:class="cn('flex flex-row flex-wrap gap-4 sm:gap-6', radioGroupClass)"
>
<div
@@ -63,20 +65,22 @@ const residenceOptions = [
:class="cn('flex min-w-fit items-center space-x-2', radioItemClass)"
>
<RadioGroupItem
:disabled="isDisabled"
:id="`${fieldName}-${index}`"
:value="option.value"
:class="
cn(
'relative h-4 w-4 rounded-full border-2 border-muted-foreground before:absolute before:inset-1 before:rounded-full before:bg-primary before:opacity-0 before:transition-opacity data-[state=checked]:border-primary data-[state=checked]:bg-white data-[state=checked]:before:opacity-100 sm:h-5 sm:w-5',
'peer relative h-4 w-4 rounded-full border-2 border-muted-foreground before:absolute before:inset-1 before:rounded-full before:bg-primary before:opacity-0 before:transition-opacity data-[state=checked]:border-primary data-[state=checked]:bg-white data-[state=checked]:before:opacity-100 sm:h-5 sm:w-5',
containerClass,
)
"
/>
<RadioLabel
:for="`${fieldName}-${index}`"
:for="isDisabled ? undefined : `${fieldName}-${index}`"
:class="
cn(
'cursor-pointer select-none text-xs font-medium leading-none transition-colors hover:text-primary peer-disabled:cursor-not-allowed peer-disabled:opacity-70 sm:text-sm',
'select-none text-xs font-medium leading-none transition-colors sm:text-sm',
isDisabled ? 'cursor-not-allowed opacity-70' : 'cursor-pointer hover:text-primary',
labelClass,
)
"
@@ -1,9 +1,6 @@
<script setup lang="ts">
import type { FormErrors } from '~/types/error'
import Combobox from '~/components/pub/my-ui/combobox/combobox.vue'
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 { cn } from '~/lib/utils'
import * as DE from '~/components/pub/my-ui/doc-entry'
@@ -52,11 +49,21 @@ onMounted(() => {
<template>
<DE.Cell :class="cn('select-field-group', fieldGroupClass, containerClass)">
<DE.Label :label-for="fieldName" :is-required="isRequired">
<DE.Label
:label-for="fieldName"
:is-required="isRequired"
>
Tempat Lahir
</DE.Label>
<DE.Field :id="fieldName" :errors="errors" :class="cn('select-field-wrapper')">
<FormField v-slot="{ componentField }" :name="fieldName">
<DE.Field
:id="fieldName"
:errors="errors"
:class="cn('select-field-wrapper')"
>
<FormField
v-slot="{ componentField }"
:name="fieldName"
>
<FormItem>
<FormControl>
<Combobox
-9
View File
@@ -1,9 +0,0 @@
<script setup lang="ts"></script>
<template>
<div class="mb-5 border-b border-b-slate-300 pb-3 text-lg xl:text-xl">
<Icon name="i-lucide-user" class="me-2" />
<span class="font-semibold">Tambah</span> Pasien
</div>
<AppPatientEntryForm />
</template>
+28 -23
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { withBase } from '~/models/_base'
import type { HeaderPrep } from '~/components/pub/my-ui/data/types'
import type { Patient } from '~/models/patient'
import type { PatientEntity } from '~/models/patient'
import type { Person } from '~/models/person'
// Components
@@ -18,7 +18,7 @@ const props = defineProps<{
// #region State & Computed
const patient = ref(
withBase<Patient>({
withBase<PatientEntity>({
person: {} as Person,
personAddresses: [],
personContacts: [],
@@ -47,19 +47,18 @@ onMounted(async () => {
// #endregion region
// #region Utilities & event handlers
function handleAction(type: string) {
switch (type) {
case 'edit':
// TODO: Handle edit action
console.log('editing data')
break
case 'cancel':
navigateTo({
name: 'client-patient',
})
break
}
async function onBack() {
await navigateTo({
name: 'client-patient',
})
}
async function onEdit() {
await navigateTo({
name: 'client-patient-id-edit',
params: {
id: props.patientId,
},
})
}
// #endregion
@@ -68,13 +67,19 @@ function handleAction(type: string) {
</script>
<template>
<Header
:prep="headerPrep"
:ref-search-nav="headerPrep.refSearchNav"
/>
<div
v-if="patient"
:key="patient.id"
>
<Header
:prep="headerPrep"
:ref-search-nav="headerPrep.refSearchNav"
/>
<AppPatientPreview
:patient="patient"
@click="handleAction"
/>
<AppPatientPreview
:patient="patient"
@back="onBack"
@edit="onEdit"
/>
</div>
</template>
-309
View File
@@ -1,309 +0,0 @@
<script setup lang="ts">
import type { Patient, genPatientProps } from '~/models/patient'
import type { ExposedForm } from '~/types/form'
import type { PatientBase } from '~/models/patient'
import Action from '~/components/pub/my-ui/nav-footer/ba-dr-su.vue'
import { genPatient } from '~/models/patient'
import { PatientSchema } from '~/schemas/patient.schema'
import { PersonAddressRelativeSchema } from '~/schemas/person-address-relative.schema'
import { PersonAddressSchema } from '~/schemas/person-address.schema'
import { PersonContactListSchema } from '~/schemas/person-contact.schema'
import { PersonFamiliesSchema } from '~/schemas/person-family.schema'
import { ResponsiblePersonSchema } from '~/schemas/person-relative.schema'
import { uploadAttachment } from '~/services/patient.service'
import {
// for form entry
isReadonly,
isProcessing,
isFormEntryDialogOpen,
isRecordConfirmationOpen,
onResetState,
handleActionSave,
handleCancelForm,
} from '~/handlers/patient.handler'
import { toast } from '~/components/pub/ui/toast'
// #region Props & Emits
const props = defineProps<{
callbackUrl?: string
}>()
const residentIdentityFile = ref<File>()
const familyCardFile = ref<File>()
// form related state
const personAddressForm = ref<ExposedForm<any> | null>(null)
const personAddressRelativeForm = ref<ExposedForm<any> | null>(null)
const personContactForm = ref<ExposedForm<any> | null>(null)
const personEmergencyContactRelative = ref<ExposedForm<any> | null>(null)
const personFamilyForm = ref<ExposedForm<any> | null>(null)
const personPatientForm = ref<ExposedForm<any> | null>(null)
// #endregion
// #region State & Computed
// #endregion
// #region Lifecycle Hooks
onMounted(() => {
// Initial synchronization when forms are mounted and isSameAddress is true by default
nextTick(() => {
const isSameAddress = personAddressRelativeForm.value?.values?.isSameAddress
if (
(isSameAddress === true || isSameAddress === '1') &&
personAddressForm.value?.values &&
personAddressRelativeForm.value
) {
const currentAddressValues = personAddressForm.value.values
if (Object.keys(currentAddressValues).length > 0) {
personAddressRelativeForm.value.setValues(
{
...personAddressRelativeForm.value.values,
province_code: currentAddressValues.province_code || undefined,
regency_code: currentAddressValues.regency_code || undefined,
district_code: currentAddressValues.district_code || undefined,
village_code: currentAddressValues.village_code || undefined,
postalRegion_code: currentAddressValues.postalRegion_code || undefined,
address: currentAddressValues.address || undefined,
rt: currentAddressValues.rt || undefined,
rw: currentAddressValues.rw || undefined,
},
false,
)
}
}
})
})
// #endregion
// #region Functions
async function composeFormData(): Promise<Patient> {
const [patient, address, addressRelative, families, contacts, emergencyContact] = await Promise.all([
personPatientForm.value?.validate(),
personAddressForm.value?.validate(),
personAddressRelativeForm.value?.validate(),
personFamilyForm.value?.validate(),
personContactForm.value?.validate(),
personEmergencyContactRelative.value?.validate(),
])
const results = [patient, address, addressRelative, families, contacts, emergencyContact]
console.log(results)
const allValid = results.every((r) => r?.valid)
// exit, if form errors happend during validation
// for example: dropdown not selected
if (!allValid) return Promise.reject('Form validation failed')
const formDataRequest: genPatientProps = {
patient: patient?.values,
residentAddress: address?.values,
cardAddress: addressRelative?.values,
familyData: families?.values,
contacts: contacts?.values,
responsible: emergencyContact?.values,
}
const formData = genPatient(formDataRequest)
if (patient?.values.residentIdentityFile) {
residentIdentityFile.value = patient?.values.residentIdentityFile
}
if (patient?.values.familyIdentityFile) {
familyCardFile.value = patient?.values.familyIdentityFile
}
return new Promise((resolve) => resolve(formData))
}
// #endregion region
// #region Utilities & event handlers
async function handleActionClick(eventType: string) {
if (eventType === 'submit') {
const patient: Patient = await composeFormData()
let createdPatientId = 0
const response = await handleActionSave(
patient,
() => {},
() => {},
toast,
)
const data = (response?.body?.data ?? null) as PatientBase | null
if (!data) return
createdPatientId = data.id
if (residentIdentityFile.value) {
void uploadAttachment(residentIdentityFile.value, createdPatientId, 'ktp')
}
if (familyCardFile.value) {
void uploadAttachment(familyCardFile.value, createdPatientId, 'kk')
}
// If has callback provided redirect to callback with patientData
if (props.callbackUrl) {
await navigateTo(props.callbackUrl + '?patient-id=' + patient.id)
return
}
// Navigate to patient list or show success message
await navigateTo('/client/patient')
return
}
if (eventType === 'cancel') {
if (props.callbackUrl) {
await navigateTo(props.callbackUrl)
return
}
await navigateTo({
name: 'client-patient',
})
// handleCancelForm()
}
}
// #endregion
// #region Watchers
// Watcher untuk sinkronisasi initial ketika kedua form sudah ready
watch(
[() => personAddressForm.value, () => personAddressRelativeForm.value],
([addressForm, relativeForm]) => {
if (addressForm && relativeForm) {
// Trigger initial sync jika isSameAddress adalah true
nextTick(() => {
const isSameAddress = relativeForm.values?.isSameAddress
if ((isSameAddress === true || isSameAddress === '1') && addressForm.values) {
const currentAddressValues = addressForm.values
if (Object.keys(currentAddressValues).length > 0) {
relativeForm.setValues(
{
...relativeForm.values,
province_code: currentAddressValues.province_code || undefined,
regency_code: currentAddressValues.regency_code || undefined,
district_code: currentAddressValues.district_code || undefined,
village_code: currentAddressValues.village_code || undefined,
postalRegion_code: currentAddressValues.postalRegion_code || undefined,
address: currentAddressValues.address || undefined,
rt: currentAddressValues.rt || undefined,
rw: currentAddressValues.rw || undefined,
},
false,
)
}
}
})
}
},
{ immediate: true },
)
// Watcher untuk sinkronisasi alamat ketika isSameAddress = true
watch(
() => personAddressForm.value?.values,
(newAddressValues) => {
// Cek apakah alamat KTP harus sama dengan alamat sekarang
const isSameAddress = personAddressRelativeForm.value?.values?.isSameAddress
if ((isSameAddress === true || isSameAddress === '1') && newAddressValues && personAddressRelativeForm.value) {
// Sinkronkan semua field alamat dari alamat sekarang ke alamat KTP
personAddressRelativeForm.value.setValues(
{
...personAddressRelativeForm.value.values,
province_code: newAddressValues.province_code || undefined,
regency_code: newAddressValues.regency_code || undefined,
district_code: newAddressValues.district_code || undefined,
village_code: newAddressValues.village_code || undefined,
postalRegion_code: newAddressValues.postalRegion_code || undefined,
address: newAddressValues.address || undefined,
rt: newAddressValues.rt || undefined,
rw: newAddressValues.rw || undefined,
},
false,
)
}
},
{ deep: true },
)
// Watcher untuk memantau perubahan isSameAddress
watch(
() => personAddressRelativeForm.value?.values?.isSameAddress,
(isSameAddress) => {
if (
(isSameAddress === true || isSameAddress === '1') &&
personAddressForm.value?.values &&
personAddressRelativeForm.value?.values
) {
// Ketika isSameAddress diubah menjadi true, copy alamat sekarang ke alamat KTP
const currentAddressValues = personAddressForm.value.values
personAddressRelativeForm.value.setValues(
{
...personAddressRelativeForm.value.values,
province_code: currentAddressValues.province_code || undefined,
regency_code: currentAddressValues.regency_code || undefined,
district_code: currentAddressValues.district_code || undefined,
village_code: currentAddressValues.village_code || undefined,
postalRegion_code: currentAddressValues.postalRegion_code || undefined,
address: currentAddressValues.address || undefined,
rt: currentAddressValues.rt || undefined,
rw: currentAddressValues.rw || undefined,
},
false,
)
}
},
)
// #endregion
</script>
<template>
<div class="mb-5 border-b border-b-slate-300 pb-3 text-lg font-semibold xl:text-xl">Tambah Pasien</div>
<AppPatientEntryForm
ref="personPatientForm"
:schema="PatientSchema"
/>
<div class="h-6"></div>
<AppPersonAddressEntryForm
ref="personAddressForm"
title="Alamat Sekarang"
:schema="PersonAddressSchema"
/>
<div class="h-6"></div>
<AppPersonAddressEntryFormRelative
ref="personAddressRelativeForm"
title="Alamat KTP"
:schema="PersonAddressRelativeSchema"
/>
<div class="h-6"></div>
<AppPersonFamilyParentsForm
ref="personFamilyForm"
title="Identitas Orang Tua"
:schema="PersonFamiliesSchema"
/>
<div class="h-6"></div>
<AppPersonContactEntryForm
ref="personContactForm"
title="Kontak Pasien"
:contact-limit="10"
:schema="PersonContactListSchema"
/>
<AppPersonRelativeEntryForm
ref="personEmergencyContactRelative"
title="Penanggung Jawab"
:schema="ResponsiblePersonSchema"
/>
<div class="my-2 flex justify-end py-2">
<Action @click="handleActionClick" />
</div>
</template>
<style scoped>
/* component style */
</style>
+490
View File
@@ -0,0 +1,490 @@
<script setup lang="ts">
// type
import { withBase } from '~/models/_base'
import type { Person } from '~/models/person'
import type { PersonAddress } from '~/models/person-address'
import type { PersonContact } from '~/models/person-contact'
import type { PersonRelative } from '~/models/person-relative'
import type { ExposedForm } from '~/types/form'
import type { PatientEntity, PatientBase, Patient, genPatientProps } from '~/models/patient'
import { genPatientEntity } from '~/models/patient'
// components
import Action from '~/components/pub/my-ui/nav-footer/ba-dr-su.vue'
import AppPatientEntryForm from '~/components/app/patient/entry-form.vue'
import AppPersonAddressEntryForm from '~/components/app/person-address/entry-form.vue'
import AppPersonAddressEntryFormRelative from '~/components/app/person-address/entry-form-relative.vue'
import AppPersonFamilyParentsForm from '~/components/app/person/family-parents-form.vue'
import AppPersonContactEntryForm from '~/components/app/person-contact/entry-form.vue'
import AppPersonRelativeEntryForm from '~/components/app/person-relative/entry-form.vue'
// helper
import { format, parseISO } from 'date-fns'
import { id as localeID } from 'date-fns/locale'
// services
import { getPatientDetail, uploadAttachment } from '~/services/patient.service'
import { isReadonly, isProcessing, handleActionSave, handleActionEdit } from '~/handlers/patient.handler'
import { toast } from '~/components/pub/ui/toast'
// reverse mapping untuk contact type (backend → UI)
const reverseContactTypeMapping: Record<string, string> = {
'm-phone': 'phoneNumber',
phone: 'homePhoneNumber',
email: 'email',
fax: 'fax',
}
// #region Props & Emits
const props = defineProps<{
callbackUrl?: string
mode: 'add' | 'edit'
patientId?: string | number
}>()
const residentIdentityFile = ref<File>()
const familyCardFile = ref<File>()
// form related state
const personAddressForm = ref<ExposedForm<any> | null>(null)
const personAddressRelativeForm = ref<ExposedForm<any> | null>(null)
const personContactForm = ref<ExposedForm<any> | null>(null)
const personEmergencyContactRelative = ref<ExposedForm<any> | null>(null)
const personFamilyForm = ref<ExposedForm<any> | null>(null)
const personPatientForm = ref<ExposedForm<any> | null>(null)
// #endregion
// #region State & Computed
const patientDetail = ref(
withBase<PatientEntity>({
person: {} as Person,
personAddresses: [],
personContacts: [],
personRelatives: [],
}),
)
// Key untuk memaksa re-render form saat data berubah
const formKey = ref(0)
// Computed: unwrap patient data untuk form patient
const patientFormInitialValues = computed(() => {
const p = patientDetail.value
const person = p.person
if (!person || !person.name) return undefined
const hasDisability = String(person?.disability).length > 0
const date = parseISO(person.birthDate!)
const birthDate = format(date, 'yyyy-MM-dd', { locale: localeID })
return {
identityNumber: person.residentIdentityNumber || undefined,
drivingLicenseNumber: person.drivingLicenseNumber || undefined,
passportNumber: person.passportNumber || undefined,
fullName: person.name || '',
isNewBorn: p.newBornStatus ? 'yes' : 'no',
gender: person.gender_code || '',
birthPlace: person.birthRegency_code || '',
birthDate: birthDate,
education: person.education_code || '',
job: person.occupation_code || '',
maritalStatus: person?.maritalStatus_code,
nationality: person?.nationality || 'WNI',
ethnicity: person?.ethnic_code || '',
language: person?.language_code || '',
religion: person?.religion_code || '',
communicationBarrier: person?.communicationIssueStatus ? 'yes' : 'no',
disability: hasDisability ? 'yes' : 'no',
disabilityType: hasDisability ? String(person?.disability) || '' : '',
note: '',
}
})
// Computed: unwrap alamat domisili (alamat sekarang)
const addressFormInitialValues = computed(() => {
if (!patientDetail.value.person?.addresses) return {}
const addresses = patientDetail.value.person?.addresses
const domicileAddress = addresses.find((a: PersonAddress) => a.locationType_code === 'domicile')
if (!domicileAddress) return undefined
// extract kode wilayah dari preload data
const village = domicileAddress.postalRegion?.village
const district = village?.district
const regency = district?.regency
const province = regency?.province
return {
id: domicileAddress.id || 0,
locationType_code: 'domicile',
province_code: province?.code || '',
regency_code: regency?.code || '',
district_code: district?.code || '',
village_code: village?.code || domicileAddress.village_code || '',
postalRegion_code: domicileAddress.postalRegion_code || '',
address: domicileAddress.address || '',
rt: domicileAddress.rt || '',
rw: domicileAddress.rw || '',
}
})
// Computed: unwrap alamat KTP (identity)
const addressRelativeFormInitialValues = computed(() => {
if (!patientDetail.value.person?.addresses) return {}
const addresses = patientDetail.value.person?.addresses
const domicileAddress = addresses.find((a: PersonAddress) => a.locationType_code === 'domicile')!
const identityAddress = addresses.find((a: PersonAddress) => a.locationType_code === 'identity')!
// extract kode wilayah dari preload data
const village = identityAddress.postalRegion?.village
const district = village?.district
const regency = district?.regency
const province = regency?.province
const isSame = domicileAddress.village_code === identityAddress.village_code ? '1' : '0'
return {
isSameAddress: isSame,
id: identityAddress.id || 0,
locationType_code: 'identity',
province_code: province?.code || '',
regency_code: regency?.code || '',
district_code: district?.code || '',
village_code: village?.code || identityAddress.village_code || '',
postalRegion_code: identityAddress.postalRegion_code || '',
address: identityAddress.address || '',
rt: identityAddress.rt || '',
rw: identityAddress.rw || '',
}
})
// Computed: unwrap data orang tua
const familyFormInitialValues = computed(() => {
const relatives = patientDetail.value.person?.relatives || patientDetail.value.personRelatives || []
const parents = relatives.filter(
(r: PersonRelative) => !r.responsible && (r.relationship_code === 'mother' || r.relationship_code === 'father'),
)
if (parents.length === 0) {
return {
_shareFamilyData: 'no' as const,
families: [],
}
}
return {
_shareFamilyData: 'yes' as const,
families: parents.map((parent: PersonRelative) => ({
id: parent.id || 0,
relation: parent.relationship_code || '',
name: parent.name || '',
occupation_code: parent.occupation_code || '',
education_code: parent.education_code || '',
})),
}
})
// Computed: unwrap kontak pasien
const contactFormInitialValues = computed(() => {
const contacts = patientDetail.value.person?.contacts || patientDetail.value.personContacts || []
if (contacts.length === 0) return undefined
return {
contacts: contacts.map((contact: PersonContact) => ({
id: contact.id || 0,
contactType: reverseContactTypeMapping[contact.type_code] || contact.type_code || '',
contactNumber: contact.value || '',
})),
}
})
// Computed: unwrap penanggung jawab
const responsibleFormInitialValues = computed(() => {
const relatives = patientDetail.value.person?.relatives || patientDetail.value.personRelatives || []
const responsibles = relatives.filter((r: PersonRelative) => r.responsible === true)
if (responsibles.length === 0) return undefined
return {
contacts: responsibles.map((r: PersonRelative) => ({
id: r.id || 0,
relation: r.relationship_code || '',
name: r.name || '',
address: r.address || '',
phone: r.phoneNumber || '',
})),
}
})
// #endregion
// #region Lifecycle Hooks
onMounted(async () => {
// if edit mode, fetch patient detail
if (props.mode === 'edit' && props.patientId) {
await loadInitData(props.patientId)
}
})
// #endregion
// #region Functions
async function composeFormData(): Promise<PatientEntity> {
const [patient, address, addressRelative, families, contacts, emergencyContact] = await Promise.all([
personPatientForm.value?.validate(),
personAddressForm.value?.validate(),
personAddressRelativeForm.value?.validate(),
personFamilyForm.value?.validate(),
personContactForm.value?.validate(),
personEmergencyContactRelative.value?.validate(),
])
const results = [patient, address, addressRelative, families, contacts, emergencyContact]
const allValid = results.every((r) => r?.valid)
console.log(results)
// exit, if form errors happend during validation
// for example: dropdown not selected
if (!allValid) return Promise.reject('Form validation failed')
const formDataRequest: genPatientProps = {
patient: {
...patient?.values,
// casting comp. val to backend well known reflect value
ethnic: patient?.values.nationality === 'WNI' ? patient?.values.ethnic : null,
birthDate: parseISO(patient?.values.birthDate || ''),
isNewBorn: patient?.values.isNewBorn === 'yes',
communicationBarrier: patient?.values.communicationBarrier === 'yes',
disability: patient?.values.disability === 'yes' ? patient?.values.disabilityType : '',
},
residentAddress: address?.values,
cardAddress: addressRelative?.values,
familyData: families?.values,
contacts: contacts?.values,
responsible: emergencyContact?.values,
}
const formData = genPatientEntity(formDataRequest, patientDetail.value)
if (patient?.values.residentIdentityFile) {
residentIdentityFile.value = patient?.values.residentIdentityFile
}
if (patient?.values.familyIdentityFile) {
familyCardFile.value = patient?.values.familyIdentityFile
}
return new Promise((resolve) => resolve(formData))
}
// #endregion region
// #region Utilities & event handlers
async function loadInitData(id: number | string) {
isProcessing.value = true
try {
const response = await getPatientDetail(id as number)
if (response.success) {
patientDetail.value = response.body.data || {}
// Increment key untuk memaksa re-render form dengan data baru
formKey.value++
}
isProcessing.value = false
} catch (error) {
console.error(error)
toast({
title: 'Error',
description: 'Terjadi kesalahan saat mengambil data',
variant: 'destructive',
})
}
}
async function handleActionClick(eventType: string) {
try {
if (eventType === 'submit') {
const patient: Patient = await composeFormData()
let createdPatientId = 0
let response: any
// return
if (props.mode === 'edit' && props.patientId) {
response = await handleActionEdit(
patientDetail.value.id,
patient,
() => {},
() => {},
toast,
)
} else {
response = await handleActionSave(
patient,
() => {},
() => {},
toast,
)
}
const data = (response?.body?.data ?? null) as PatientBase | null
if (!data) return
createdPatientId = data.id
if (residentIdentityFile.value) {
void uploadAttachment(residentIdentityFile.value, createdPatientId, 'ktp')
}
if (familyCardFile.value) {
void uploadAttachment(familyCardFile.value, createdPatientId, 'kk')
}
// If has callback provided redirect to callback with patientData
if (props.callbackUrl && props.callbackUrl.length > 0) {
await navigateTo(props.callbackUrl + '?patient-id=' + createdPatientId)
return
}
// Navigate to patient list or show success message
await navigateTo('/client/patient')
return
}
if (eventType === 'cancel') {
if (props.callbackUrl) {
await navigateTo(props.callbackUrl)
return
}
// handleCancelForm()
}
if (eventType === 'back') {
await navigateTo({
name: 'client-patient',
})
}
} catch (error) {
// Show error toast to user
if (typeof error === 'string') {
toast({
title: 'Error',
description: error,
variant: 'destructive',
})
} else if (error instanceof Error) {
toast({
title: 'Error',
description: error.message || 'Terjadi kesalahan saat menyimpan data',
variant: 'destructive',
})
} else {
toast({
title: 'Error',
description: 'Terjadi kesalahan saat menyimpan data',
variant: 'destructive',
})
}
}
}
// #endregion
// #region Watchers
// Helper: Cek apakah isSameAddress aktif
const isSameAddressActive = () => {
const val = personAddressRelativeForm.value?.values?.isSameAddress
return val === true || val === '1'
}
// Helper: Sinkronkan alamat sekarang ke alamat KTP
const syncAddressToRelative = () => {
const source = personAddressForm.value?.values
const target = personAddressRelativeForm.value
if (!source || !target) return
const addressFields = [
'province_code',
'regency_code',
'district_code',
'village_code',
'postalRegion_code',
'address',
'rt',
'rw',
] as const
const syncedValues = Object.fromEntries(addressFields.map((key) => [key, source[key] || undefined]))
target.setValues({ ...target.values, ...syncedValues }, false)
}
// Watcher: Sinkronisasi saat nilai alamat berubah atau isSameAddress diaktifkan
watch(
[() => personAddressForm.value?.values, () => personAddressRelativeForm.value?.values?.isSameAddress],
() => {
if (isSameAddressActive()) {
syncAddressToRelative()
}
},
{ deep: true, immediate: true },
)
// #endregion
</script>
<template>
<div class="mb-5 border-b border-b-slate-300 pb-3 text-lg font-semibold xl:text-xl">
{{ mode === 'edit' ? 'Edit Pasien' : 'Tambah Pasien' }}
</div>
<AppPatientEntryForm
:key="`patient-${formKey}`"
ref="personPatientForm"
:is-readonly="isProcessing || isReadonly"
:initial-values="patientFormInitialValues"
/>
<div class="h-6"></div>
<AppPersonAddressEntryForm
:key="`address-${formKey}`"
ref="personAddressForm"
title="Alamat Sekarang"
:is-readonly="isProcessing || isReadonly"
:initial-values="addressFormInitialValues"
/>
<div class="h-6"></div>
<AppPersonAddressEntryFormRelative
:key="`address-rel-${formKey}`"
ref="personAddressRelativeForm"
title="Alamat KTP"
:is-readonly="isProcessing || isReadonly"
:initial-values="addressRelativeFormInitialValues"
/>
<div class="h-6"></div>
<AppPersonFamilyParentsForm
:key="`family-${formKey}`"
ref="personFamilyForm"
title="Identitas Orang Tua"
:is-readonly="isProcessing || isReadonly"
:initial-values="familyFormInitialValues"
/>
<div class="h-6"></div>
<AppPersonContactEntryForm
:key="`contact-${formKey}`"
ref="personContactForm"
title="Kontak Pasien"
:is-readonly="isProcessing || isReadonly"
:initial-values="contactFormInitialValues"
/>
<AppPersonRelativeEntryForm
:key="`responsible-${formKey}`"
ref="personEmergencyContactRelative"
title="Penanggung Jawab"
:is-readonly="isProcessing || isReadonly"
:initial-values="responsibleFormInitialValues"
/>
<div class="my-2 flex justify-end py-2">
<Action @click="handleActionClick" />
</div>
</template>
<style scoped>
/* component style */
</style>
+11 -6
View File
@@ -6,7 +6,7 @@ import { Calendar, Hospital, UserCheck, UsersRound } from 'lucide-vue-next'
import RecordConfirmation from '~/components/pub/my-ui/confirmation/record-confirmation.vue'
import { ActionEvents } from '~/components/pub/my-ui/data/types'
import Header from '~/components/pub/my-ui/nav-header/prep.vue'
import Header from '~/components/pub/my-ui/nav-header/header.vue'
import SummaryCard from '~/components/pub/my-ui/summary-card/summary-card.vue'
import { usePaginatedList } from '~/composables/usePaginatedList'
@@ -46,6 +46,7 @@ const headerPrep: HeaderPrep = {
label: 'Tambah',
onClick: () => navigateTo('/client/patient/add'),
},
refSearchNav: refSearchNav,
}
// Disable dulu, ayahab kalo diminta
@@ -137,18 +138,22 @@ provide('table_data_loader', isLoading)
// #endregion
// #region Watchers
watch([recId, recAction], () => {
switch (recAction.value) {
watch([recId, recAction], ([newId, newAction]) => {
switch (newAction) {
case ActionEvents.showDetail:
navigateTo({
name: 'client-patient-id',
params: { id: recId.value },
params: { id: newId },
})
break
case ActionEvents.showEdit:
// TODO: Handle edit action
// isFormEntryDialogOpen.value = true
navigateTo({
name: 'client-patient-id-edit',
params: {
id: newId,
},
})
break
case ActionEvents.showConfirmDelete:
+19 -17
View File
@@ -11,7 +11,7 @@ import { PersonAddressRelativeSchema } from '~/schemas/person-address-relative.s
import { PersonAddressSchema } from '~/schemas/person-address.schema'
import { PersonContactListSchema } from '~/schemas/person-contact.schema'
import { PersonFamiliesSchema } from '~/schemas/person-family.schema'
import { ResponsiblePersonSchema } from '~/schemas/person-relative.schema'
import { ResponsiblePersonRelativeSchema } from '~/schemas/person-relative.schema'
import { uploadAttachment } from '~/services/patient.service'
import { getDetail, update } from '~/services/surgery-report.service'
import type { SurgeryReport } from '~/models/surgery-report'
@@ -28,17 +28,18 @@ import { handleActionEdit } from '~/handlers/surgery-report.handler'
import type { HeaderPrep, RefSearchNav } from '~/components/pub/my-ui/data/types'
// #region Props & Emits
const props = withDefaults(defineProps<{
encounter_id: number
callbackUrl?: string
record_id: number
}>(), {
})
const props = withDefaults(
defineProps<{
encounter_id: number
callbackUrl?: string
record_id: number
}>(),
{},
)
// form related state
const { data, isLoading, paginationMeta, searchInput, handlePageChange, handleSearch, fetchData } = usePaginatedList({
fetchFn: (params) => getList({ ...params, includes: 'specialist,subspecialist,doctor-employee-person', }),
fetchFn: (params) => getList({ ...params, includes: 'specialist,subspecialist,doctor-employee-person' }),
entityName: 'surgery-report',
})
// #endregion
@@ -56,7 +57,7 @@ const selectedOperativeAction = ref<any>(null)
onMounted(async () => {
const result = await getDetail(props.record_id)
if (result.success) {
const responseData = {...result.body.data, date: formatDateYyyyMmDd(result.body.data.date)}
const responseData = { ...result.body.data, date: formatDateYyyyMmDd(result.body.data.date) }
surgeryReport.value = responseData
inputForm.value?.setValues(responseData)
}
@@ -72,17 +73,15 @@ async function handleConfirmAdd() {
const response = await handleActionEdit(
props.record_id,
await composeFormData(),
() => { },
() => { },
() => {},
() => {},
toast,
)
goBack()
}
async function composeFormData(): Promise<SurgeryReport> {
const [input,] = await Promise.all([
inputForm.value?.validate(),
])
const [input] = await Promise.all([inputForm.value?.validate()])
const results = [input]
const allValid = results.every((r) => r?.valid)
@@ -129,9 +128,12 @@ function handleCancelAdd() {
:schema="SurgeryReportSchema"
:operative-action-list="[]"
/>
<div class="my-2 flex justify-end py-2">
<Action :enable-draft="false" @click="handleActionClick" />
<Action
:enable-draft="false"
@click="handleActionClick"
/>
</div>
<Confirmation
+1 -1
View File
@@ -122,7 +122,7 @@ watch(
<SelectTrigger
:class="
cn(
'rounded-md focus:outline-none focus:ring-1 focus:ring-black dark:focus:ring-white',
'h-8 rounded-md font-normal focus:outline-none focus:ring-1 focus:ring-black dark:focus:ring-white md:text-xs 2xl:h-9 2xl:text-sm',
{
'cursor-not-allowed bg-gray-100 opacity-50': isDisabled,
'bg-white text-black dark:bg-gray-800 dark:text-white': !isDisabled,
+1 -1
View File
@@ -1 +1 @@
export type ClickType = 'back' | 'draft' | 'submit'
export type ClickType = 'back' | 'draft' | 'submit' | 'edit'
+8 -2
View File
@@ -165,16 +165,20 @@ export function genCrudHandler<T = any>(crud: {
refresh: () => void,
reset: () => void,
toast: ToastFn,
) {
): Promise<any | null> {
isProcessing.value = true
let successResponse: any = null
await handleAsyncAction<[number | string, any], any>({
action: crud.update,
args: [id, values],
toast,
successMessage: 'Data berhasil diubah',
errorMessage: 'Gagal mengubah data',
onSuccess: () => {
onSuccess: (result) => {
isFormEntryDialogOpen.value = false
successResponse = result
if (refresh) refresh()
},
onFinally: (isSuccess: boolean) => {
@@ -182,6 +186,8 @@ export function genCrudHandler<T = any>(crud: {
isProcessing.value = false
},
})
return successResponse
}
async function handleActionRemove(id: number | string, refresh: () => void, toast: ToastFn) {
+63 -41
View File
@@ -74,18 +74,18 @@ export const bigTimeUnitCodes: Record<string, string> = {
}
export const dischargeMethodCodes: Record<string, string> = {
home: "Pulang",
"home-request": "Pulang Atas Permintaan Sendiri",
"consul-back": "Konsultasi Balik / Lanjutan",
"consul-poly": "Konsultasi Poliklinik Lain",
"consul-executive": "Konsultasi Antar Dokter Eksekutif",
"consul-ch-day": "Konsultasi Hari Lain",
emergency: "Rujuk IGD",
"emergency-covid": "Rujuk IGD Covid",
inpatient: "Rujuk Rawat Inap",
external: "Rujuk Faskes Lain",
death: "Meninggal",
"death-on-arrival": "Meninggal Saat Tiba"
home: 'Pulang',
'home-request': 'Pulang Atas Permintaan Sendiri',
'consul-back': 'Konsultasi Balik / Lanjutan',
'consul-poly': 'Konsultasi Poliklinik Lain',
'consul-executive': 'Konsultasi Antar Dokter Eksekutif',
'consul-ch-day': 'Konsultasi Hari Lain',
emergency: 'Rujuk IGD',
'emergency-covid': 'Rujuk IGD Covid',
inpatient: 'Rujuk Rawat Inap',
external: 'Rujuk Faskes Lain',
death: 'Meninggal',
'death-on-arrival': 'Meninggal Saat Tiba',
}
export const genderCodes: Record<string, string> = {
@@ -387,13 +387,13 @@ export const medicalActionTypeCode: Record<string, string> = {
export type medicalActionTypeCodeKey = keyof typeof medicalActionTypeCode
export const encounterDocTypeCode: Record<string, string> = {
"person-resident-number": 'person-resident-number',
"person-driving-license": 'person-driving-license',
"person-passport": 'person-passport',
"person-family-card": 'person-family-card',
"mcu-item-result": 'mcu-item-result',
"vclaim-sep": 'vclaim-sep',
"vclaim-sipp": 'vclaim-sipp',
'person-resident-number': 'person-resident-number',
'person-driving-license': 'person-driving-license',
'person-passport': 'person-passport',
'person-family-card': 'person-family-card',
'mcu-item-result': 'mcu-item-result',
'vclaim-sep': 'vclaim-sep',
'vclaim-sipp': 'vclaim-sipp',
} as const
export type encounterDocTypeCodeKey = keyof typeof encounterDocTypeCode
export const encounterDocOpt: { label: string; value: encounterDocTypeCodeKey }[] = [
@@ -406,20 +406,19 @@ export const encounterDocOpt: { label: string; value: encounterDocTypeCodeKey }[
{ label: 'Klaim SIPP', value: 'vclaim-sipp' },
]
export const docTypeCode = {
"encounter-patient": 'encounter-patient',
"encounter-support": 'encounter-support',
"encounter-other": 'encounter-other',
"vclaim-sep": 'vclaim-sep',
"vclaim-sipp": 'vclaim-sipp',
'encounter-patient': 'encounter-patient',
'encounter-support': 'encounter-support',
'encounter-other': 'encounter-other',
'vclaim-sep': 'vclaim-sep',
'vclaim-sipp': 'vclaim-sipp',
} as const
export const docTypeLabel = {
"encounter-patient": 'Data Pasien',
"encounter-support": 'Data Penunjang',
"encounter-other": 'Lain - Lain',
"vclaim-sep": 'SEP',
"vclaim-sipp": 'SIPP',
'encounter-patient': 'Data Pasien',
'encounter-support': 'Data Penunjang',
'encounter-other': 'Lain - Lain',
'vclaim-sep': 'SEP',
'vclaim-sipp': 'SIPP',
} as const
export type docTypeCodeKey = keyof typeof docTypeCode
export const supportingDocOpt = [
@@ -428,8 +427,7 @@ export const supportingDocOpt = [
{ label: 'Lain - Lain', value: 'encounter-other' },
]
export type SurgeryType = "kecil" | "sedang" | "besar" | "khusus"
export type SurgeryType = 'kecil' | 'sedang' | 'besar' | 'khusus'
export const SurgeryTypeOptList: { label: string; value: SurgeryType }[] = [
{ label: 'Kecil', value: 'kecil' },
{ label: 'Sedang', value: 'sedang' },
@@ -437,14 +435,14 @@ export const SurgeryTypeOptList: { label: string; value: SurgeryType }[] = [
{ label: 'Khusus', value: 'khusus' },
]
export type BillingCodeType = "general" | "regional" | "local"
export type BillingCodeType = 'general' | 'regional' | 'local'
export const BillingCodeTypeOptList: { label: string; value: BillingCodeType }[] = [
{ label: 'General', value: 'general' },
{ label: 'Regional', value: 'regional' },
{ label: 'Local', value: 'local' },
]
export type SurgerySystemType = "cito" | "urgent" | "efektif" | "khusus"
export type SurgerySystemType = 'cito' | 'urgent' | 'efektif' | 'khusus'
export const SurgerySystemTypeOptList: { label: string; value: SurgerySystemType }[] = [
{ label: 'Cito', value: 'cito' },
{ label: 'Urgent', value: 'urgent' },
@@ -452,7 +450,7 @@ export const SurgerySystemTypeOptList: { label: string; value: SurgerySystemType
{ label: 'Khusus', value: 'khusus' },
]
export type DissectionType = "bersih" | "bersih terkontaminasi" | "terkontaminasi kotor" | "kotor"
export type DissectionType = 'bersih' | 'bersih terkontaminasi' | 'terkontaminasi kotor' | 'kotor'
export const DissectionTypeOptList: { label: string; value: DissectionType }[] = [
{ label: 'Bersih', value: 'bersih' },
{ label: 'Bersih terkontaminasi', value: 'bersih terkontaminasi' },
@@ -460,19 +458,25 @@ export const DissectionTypeOptList: { label: string; value: DissectionType }[] =
{ label: 'Kotor', value: 'kotor' },
]
export type SurgeryOrderType = "satu" | "ulangan"
export type SurgeryOrderType = 'satu' | 'ulangan'
export const SurgeryOrderTypeOptList: { label: string; value: SurgeryOrderType }[] = [
{ label: 'Satu', value: 'satu' },
{ label: 'Ulangan', value: 'ulangan' },
]
export type BirthDescriptionType = "lahir hidup" | "lahir mati"
export type BirthDescriptionType = 'lahir hidup' | 'lahir mati'
export const BirthDescriptionTypeOptList: { label: string; value: BirthDescriptionType }[] = [
{ label: 'Lahir Hidup', value: 'lahir hidup' },
{ label: 'Lahir Mati', value: 'lahir mati' },
]
export type BirthPlaceDescriptionType = "rssa" | "bidan luar" | "dokter luar" | "dukun bayi" | "puskesmas" | "paramedis luar"
export type BirthPlaceDescriptionType =
| 'rssa'
| 'bidan luar'
| 'dokter luar'
| 'dukun bayi'
| 'puskesmas'
| 'paramedis luar'
export const BirthPlaceDescriptionTypeOptList: { label: string; value: BirthPlaceDescriptionType }[] = [
{ label: 'RSSA', value: 'rssa' },
{ label: 'Bidan luar', value: 'bidan luar' },
@@ -482,7 +486,7 @@ export const BirthPlaceDescriptionTypeOptList: { label: string; value: BirthPlac
{ label: 'Paramedis luar', value: 'paramedis luar' },
]
export type SpecimenType = "pa" | "mikrobiologi" | "laborat" | "tidak perlu"
export type SpecimenType = 'pa' | 'mikrobiologi' | 'laborat' | 'tidak perlu'
export const SpecimenTypeOptList: { label: string; value: SpecimenType }[] = [
{ label: 'PA', value: 'pa' },
{ label: 'Mikrobiologi', value: 'mikrobiologi' },
@@ -490,7 +494,15 @@ export const SpecimenTypeOptList: { label: string; value: SpecimenType }[] = [
{ label: 'Tidak perlu', value: 'tidak perlu' },
]
export type PrbProgramType = "ashma" | "diabetes mellitus" | "hipertensi" | "penyakit jantung" | "ppok" | "schizopherenia" | "stroke" | "systemic lupus erythematosus"
export type PrbProgramType =
| 'ashma'
| 'diabetes mellitus'
| 'hipertensi'
| 'penyakit jantung'
| 'ppok'
| 'schizopherenia'
| 'stroke'
| 'systemic lupus erythematosus'
export const PrbProgramTypeOptList: { label: string; value: PrbProgramType }[] = [
{ label: 'ASHMA', value: 'ashma' },
{ label: 'Diabetes Mellitus', value: 'diabetes mellitus' },
@@ -500,4 +512,14 @@ export const PrbProgramTypeOptList: { label: string; value: PrbProgramType }[] =
{ label: 'Schizopherenia', value: 'schizopherenia' },
{ label: 'Stroke', value: 'stroke' },
{ label: 'Systemic Lupus Erythematosus', value: 'systemic lupus erythematosus' },
]
]
export const disabilityCodes: Record<string, string> = {
daksa: 'Tuna Daksa',
netra: 'Tuna Netra',
rungu: 'Tuna Rungu',
wicara: 'Tuna Wicara',
rungu_wicara: 'Tuna Rungu-Wicara',
grahita: 'Tuna Grahita',
laras: 'Tuna Laras',
other: 'Lainnya',
}
+2 -2
View File
@@ -1,4 +1,4 @@
import type { RoleAccess } from '~/models/role'
import type { RoleAccesses } from '~/models/role'
export const PAGE_PERMISSIONS = {
'/client/patient': {
@@ -63,4 +63,4 @@ export const PAGE_PERMISSIONS = {
'emp|pay': ['R'],
'emp|mng': ['R'],
},
} as const satisfies Record<string, RoleAccess>
} as const satisfies Record<string, RoleAccesses>
+1 -3
View File
@@ -19,9 +19,7 @@ export interface TreeItem {
export function genBase(): Base {
return {
// -1 buat mock data
// backend harusnya non-negative/ > 0 (untuk auto increment constraint) jadi harusnya aman ya
id: -1,
id: 0,
createdAt: '',
updatedAt: '',
}
+37 -32
View File
@@ -1,12 +1,11 @@
import { type Base, genBase } from './_base'
import { type Person, genPerson } from './person'
import type { PatientFormData } from '~/schemas/patient.schema'
import type { PersonAddressFormData } from '~/schemas/person-address.schema'
import type { PersonAddressRelativeFormData } from '~/schemas/person-address-relative.schema'
import type { PersonFamiliesFormData } from '~/schemas/person-family.schema'
import type { PersonContactFormData } from '~/schemas/person-contact.schema'
import type { PersonRelativeFormData } from '~/schemas/person-relative.schema'
import { genPerson, type Person } from './person'
import type { PersonAddress } from './person-address'
import type { PersonContact } from './person-contact'
import type { PersonRelative } from './person-relative'
@@ -14,11 +13,11 @@ import type { PersonRelative } from './person-relative'
import { contactTypeMapping } from '~/lib/constants'
export interface PatientBase extends Base {
person_id?: number
newBornStatus?: boolean
person_id?: number | null
newBornStatus?: boolean | string
registeredAt?: Date | string | null
status_code?: string
number?: string
status_code?: string | null
number?: string | null
}
export interface PatientEntity extends PatientBase {
@@ -32,15 +31,16 @@ export interface genPatientProps {
patient: PatientFormData
residentAddress: PersonAddressFormData
cardAddress: PersonAddressRelativeFormData
familyData: PersonFamiliesFormData
familyData: PersonRelativeFormData
contacts: PersonContactFormData
responsible: PersonRelativeFormData
}
export function genPatientEntity(props: genPatientProps): PatientEntity {
export function genPatientEntity(props: genPatientProps, patientData: PatientEntity | null): PatientEntity {
const { patient, residentAddress, cardAddress, familyData, contacts, responsible } = props
const addresses: PersonAddress[] = [{ ...genBase(), person_id: 0, ...residentAddress }]
// const val = toRaw(patientData)
const addresses: PersonAddress[] = [{ ...genBase(), person_id: patientData?.person?.id || 0, ...residentAddress }]
const familiesContact: PersonRelative[] = []
const personContacts: PersonContact[] = []
@@ -49,15 +49,17 @@ export function genPatientEntity(props: genPatientProps): PatientEntity {
addresses.push({
...genBase(),
...residentAddress,
id: cardAddress.id || 0,
person_id: 0,
locationType_code: cardAddress.locationType_code || 'identity'
locationType_code: cardAddress.locationType_code || 'identity',
})
} else {
// jika alamat berbeda, tambahkan alamat relatif
// Pastikan semua field yang diperlukan ada
const relativeAddress = {
...genBase(),
person_id: 0,
id: cardAddress.id || 0,
person_id: patientData?.person?.id || 0,
locationType_code: cardAddress.locationType_code || 'identity',
address: cardAddress.address || '',
province_code: cardAddress.province_code || '',
@@ -72,15 +74,15 @@ export function genPatientEntity(props: genPatientProps): PatientEntity {
}
// add data orang tua
if (familyData.shareFamilyData === '1') {
if (familyData._shareFamilyData === 'yes' && familyData.families && familyData.families.length > 0) {
for (const family of familyData.families) {
familiesContact.push({
id: 0,
id: family.id || 0,
relationship_code: family.relation,
name: family.name,
education_code: family.education,
occupation_name: family.occupation,
occupation_code: family.occupation,
education_code: family.education_code,
occupation_name: family.occupation_name,
occupation_code: family.occupation_code,
responsible: false,
})
}
@@ -94,7 +96,8 @@ export function genPatientEntity(props: genPatientProps): PatientEntity {
personContacts.push({
...genBase(),
person_id: 0,
id: contact.id || 0,
person_id: patientData?.person?.id || 0,
type_code: mappedContactType || '',
value: contact.contactNumber,
})
@@ -102,10 +105,10 @@ export function genPatientEntity(props: genPatientProps): PatientEntity {
}
// add penanggung jawab
if (responsible) {
if (responsible.contacts && responsible.contacts.length > 0) {
for (const contact of responsible.contacts) {
familiesContact.push({
id: 0,
id: contact.id || 0,
relationship_code: contact.relation,
name: contact.name,
address: contact.address,
@@ -117,15 +120,15 @@ export function genPatientEntity(props: genPatientProps): PatientEntity {
return {
person: {
id: 0,
id: patientData?.person?.id || 0,
name: patient.fullName,
// alias: patient.alias,
birthDate: patient.birthDate,
birthRegency_code: patient.birthPlace,
gender_code: patient.gender,
residentIdentityNumber: patient.identityNumber,
passportNumber: patient.passportNumber,
drivingLicenseNumber: patient.drivingLicenseNumber,
residentIdentityNumber: patient.identityNumber || null,
passportNumber: patient.passportNumber || null,
drivingLicenseNumber: patient.drivingLicenseNumber || null,
religion_code: patient.religion,
education_code: patient.education,
occupation_code: patient.job,
@@ -139,6 +142,8 @@ export function genPatientEntity(props: genPatientProps): PatientEntity {
// passportFileUrl: patient.passportFileUrl,
// drivingLicenseFileUrl: patient.drivingLicenseFileUrl,
// familyIdentityFileUrl: patient.familyIdentityFileUrl,
maritalStatus_code: patient.maritalStatus,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
@@ -149,9 +154,9 @@ export function genPatientEntity(props: genPatientProps): PatientEntity {
registeredAt: new Date(),
status_code: 'active',
newBornStatus: patient.isNewBorn,
person_id: 0,
id: 0,
number: '',
person_id: patientData?.person?.id || null,
id: patientData?.id || 0,
number: patientData?.number || null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
@@ -160,12 +165,12 @@ export function genPatientEntity(props: genPatientProps): PatientEntity {
// New model
export interface Patient extends Base {
person_id?: number
person_id?: number | null
person: Person
newBornStatus?: boolean
newBornStatus?: boolean | string
registeredAt?: Date | string | null
status_code?: string
number?: string
status_code?: string | null
number?: string | null
}
export function genPatient(): Patient {
@@ -178,4 +183,4 @@ export function genPatient(): Patient {
newBornStatus: false,
person: genPerson(),
}
}
}
+67 -23
View File
@@ -1,3 +1,5 @@
import { differenceInDays, differenceInMonths, differenceInYears, parseISO } from 'date-fns'
import { type Base, genBase } from './_base'
import type { PersonAddress } from './person-address'
import type { PersonContact } from './person-contact'
@@ -9,27 +11,28 @@ import type { Regency } from './regency'
export interface Person extends Base {
name: string
// alias?: string
frontTitle?: string
endTitle?: string
frontTitle?: string | null
endTitle?: string | null
birthDate?: string
birthRegency_code?: string
gender_code?: string
residentIdentityNumber?: string
passportNumber?: string
drivingLicenseNumber?: string
religion_code?: string
education_code?: string
occupation_code?: string
occupation_name?: string
ethnic_code?: string
language_code?: string
nationality?: string
communicationIssueStatus?: boolean
disability?: string
residentIdentityFileUrl?: string
passportFileUrl?: string
drivingLicenseFileUrl?: string
familyIdentityFileUrl?: string
birthRegency_code?: string | null
gender_code?: string | null
residentIdentityNumber?: string | null
passportNumber?: string | null
drivingLicenseNumber?: string | null
religion_code?: string | null
education_code?: string | null
occupation_code?: string | null
occupation_name?: string | null
ethnic_code?: string | null
language_code?: string | null
nationality?: string | null
communicationIssueStatus?: boolean | string
disability?: string | null
residentIdentityFileUrl?: string | null
passportFileUrl?: string | null
drivingLicenseFileUrl?: string | null
familyIdentityFileUrl?: string | null
maritalStatus_code?: string
// preload data for detail patient
birthRegency?: Regency | null
@@ -43,9 +46,9 @@ export interface Person extends Base {
export function genPerson(): Person {
return {
...genBase(),
frontTitle: '[MOCK] dr. ',
name: 'Agus Iwan Setiawan',
endTitle: 'Sp.Bo',
frontTitle: '',
name: '',
endTitle: '',
}
}
@@ -55,3 +58,44 @@ export function parseName(person: Person): string {
return fullName
}
export function calculateAge(birthDate: string | Date | undefined): string {
if (!birthDate) {
return 'Masukkan tanggal lahir'
}
try {
let dateObj: Date
if (typeof birthDate === 'string') {
dateObj = parseISO(birthDate)
} else {
dateObj = birthDate
}
const today = new Date()
// Calculate years, months, and days
const totalYears = differenceInYears(today, dateObj)
// Calculate remaining months after years
const yearsPassed = new Date(dateObj)
yearsPassed.setFullYear(yearsPassed.getFullYear() + totalYears)
const remainingMonths = differenceInMonths(today, yearsPassed)
// Calculate remaining days after years and months
const monthsPassed = new Date(yearsPassed)
monthsPassed.setMonth(monthsPassed.getMonth() + remainingMonths)
const remainingDays = differenceInDays(today, monthsPassed)
// Format the result
const parts = []
if (totalYears > 0) parts.push(`${totalYears} Tahun`)
if (remainingMonths > 0) parts.push(`${remainingMonths} Bulan`)
if (remainingDays > 0) parts.push(`${remainingDays} Hari`)
return parts.length > 0 ? parts.join(' ') : '0 Hari'
} catch {
return 'Masukkan tanggal lahir'
}
}
@@ -1,12 +1,12 @@
<script setup lang="ts">
// import type { PagePermission } from '~/models/role'
import type { RoleAccesses } from '~/models/role'
import Error from '~/components/pub/my-ui/error/error.vue'
// import { PAGE_PERMISSIONS } from '~/lib/page-permission'
import { PAGE_PERMISSIONS } from '~/lib/page-permission'
definePageMeta({
// middleware: ['rbac'],
middleware: ['rbac'],
roles: ['doctor', 'nurse', 'admisi', 'pharmacy', 'billing', 'management'],
title: 'Daftar Divisi',
title: 'Edit Pasien',
contentFrame: 'cf-full-width',
})
@@ -16,24 +16,28 @@ useHead({
title: () => route.meta.title as string,
})
// const roleAccess: PagePermission = PAGE_PERMISSIONS['/patient']
const roleAccess: RoleAccesses = PAGE_PERMISSIONS['/client/patient']
// const { checkRole, hasReadAccess } = useRBAC()
const { checkRole, hasReadAccess } = useRBAC()
// // Check if user has access to this page
// const hasAccess = checkRole(roleAccess)
// if (!hasAccess) {
// navigateTo('/403')
// }
// Check if user has access to this page
const hasAccess = checkRole(roleAccess)
if (!hasAccess) {
navigateTo('/403')
}
// Define permission-based computed properties
// const canRead = hasReadAccess(roleAccess)
const canRead = true
const canRead = hasReadAccess(roleAccess)
</script>
<template>
<div>
<div v-if="canRead">Edit patient id: {{ route.params.id }}</div>
<div v-if="canRead">
<ContentPatientForm
:mode="'edit'"
:patient-id="route.params.id as string"
/>
</div>
<Error
v-else
:status-code="403"
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { PagePermission } from '~/models/role'
import type { RoleAccesses } from '~/models/role'
import Error from '~/components/pub/my-ui/error/error.vue'
import { PAGE_PERMISSIONS } from '~/lib/page-permission'
@@ -16,7 +16,7 @@ useHead({
title: () => route.meta.title as string,
})
const roleAccess: PagePermission = PAGE_PERMISSIONS['/patient']
const roleAccess: RoleAccesses = PAGE_PERMISSIONS['/client/patient']
const { checkRole, hasReadAccess } = useRBAC()
+6 -3
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { PagePermission } from '~/models/role'
import type { RoleAccesses } from '~/models/role'
import Error from '~/components/pub/my-ui/error/error.vue'
import { permissions } from '~/const/page-permission/client'
@@ -16,7 +16,7 @@ useHead({
title: () => route.meta.title as string,
})
const roleAccess: PagePermission = permissions['/client/patient/add']
const roleAccess: RoleAccesses = permissions['/client/patient/add'] ?? {}
const { checkRole, hasReadAccess } = useRBAC()
@@ -34,7 +34,10 @@ const callbackUrl = route.query['return-path'] as string | undefined
<template>
<div>
<div v-if="canRead">
<ContentPatientEntry :callback-url="callbackUrl" />
<ContentPatientForm
:mode="'add'"
:callback-url="callbackUrl"
/>
</div>
<Error
v-else
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { PagePermission } from '~/models/role'
import type { RoleAccesses } from '~/models/role'
import Error from '~/components/pub/my-ui/error/error.vue'
import { PAGE_PERMISSIONS } from '~/lib/page-permission'
@@ -16,8 +16,8 @@ useHead({
title: () => route.meta.title as string,
})
const roleAccess: PagePermission = PAGE_PERMISSIONS['/patient']
const roleAccess: RoleAccesses = PAGE_PERMISSIONS['/client/patient']
console.log(roleAccess)
const { checkRole, hasReadAccess } = useRBAC()
// Check if user has access to this page
+34 -86
View File
@@ -1,28 +1,11 @@
import { z } from 'zod'
const CommunicationBarrierSchema = z
.enum(['YA', 'TIDAK'], {
required_error: 'Mohon lengkapi Status Hambatan Berkomunikasi',
})
.transform((val) => val === 'YA')
const IsNewBornSchema = z
.enum(['YA', 'TIDAK'], {
required_error: 'Mohon lengkapi status pasien',
})
.transform((val) => val === 'YA')
const ACCEPTED_UPLOAD_TYPES = ['image/jpeg', 'image/png', 'application/pdf']
const PatientSchema = z
.object({
// Data Diri Pasien
identityNumber: z.string().optional(),
// .string({
// required_error: 'Mohon lengkapi NIK',
// })
// .min(16, 'NIK harus berupa angka 16 digit')
// .regex(/^\d+$/, 'NIK harus berupa angka 16 digit'),
residentIdentityFile: z
.any()
.optional()
@@ -40,12 +23,7 @@ const PatientSchema = z
message: 'Format file harus JPG, PNG, atau PDF',
})
.refine((f) => !f || f.size <= 1 * 1024 * 1024, { message: 'Maksimal 1MB' }),
// .refine(f => ['image/jpeg', 'image/png'].includes(f.type), 'Hanya JPG/PNG')
// Informasi Dasar
// alias: z.string({
// required_error: 'Mohon pilih sapaan',
// }),
fullName: z.string({
required_error: 'Mohon lengkapi Nama',
}),
@@ -54,35 +32,15 @@ const PatientSchema = z
required_error: 'Mohon lengkapi Tempat Lahir',
})
.min(1, 'Mohon lengkapi Tempat Lahir'),
birthDate: z
.string({
required_error: 'Mohon lengkapi Tanggal Lahir',
})
.refine(
(date) => {
// Jika kosong, return false untuk required validation
if (!date || date.trim() === '') return false
// Jika ada isi, validasi format tanggal
try {
const dateObj = new Date(date)
// Cek apakah tanggal valid dan tahun >= 1900
return !isNaN(dateObj.getTime()) && dateObj.getFullYear() >= 1900
} catch {
return false
}
},
{
message: 'Mohon lengkapi Tanggal Lahir dengan format yang valid',
},
)
.transform((dateStr) => new Date(dateStr).toISOString()),
birthDate: z.string({
required_error: 'Mohon lengkapi Tanggal Lahir',
}),
// Jenis Kelamin & Status
gender: z.string({
required_error: 'Pilih Jenis Kelamin',
}),
maritalStatus: z.enum(['TIDAK_DIKETAHUI', 'BELUM_KAWIN', 'KAWIN', 'CERAI_HIDUP', 'CERAI_MATI'], {
maritalStatus: z.enum(['S', 'M', 'D', 'W'], {
required_error: 'Pilih Status Perkawinan',
}),
@@ -95,7 +53,9 @@ const PatientSchema = z
nationality: z.string({
required_error: 'Pilih Kebangsaan',
}),
isNewBorn: IsNewBornSchema,
isNewBorn: z.union([z.boolean(), z.string()], {
required_error: 'Mohon lengkapi Status Disabilitas',
}),
language: z.string({
required_error: 'Mohon pilih Preferensi Bahasa',
}),
@@ -112,65 +72,53 @@ const PatientSchema = z
ethnicity: z.string().optional(),
// Disabilitas
disability: z.enum(['YA', 'TIDAK'], {
disability: z.string({
required_error: 'Mohon lengkapi Status Disabilitas',
}),
disabilityType: z.string().optional(),
// Informasi Kontak
passportNumber: z.string().optional(),
communicationBarrier: CommunicationBarrierSchema,
communicationBarrier: z.union([z.boolean(), z.string()], {
required_error: 'Mohon lengkapi Status Hambatan Berkomunikasi',
}),
note: z.string().optional(),
drivingLicenseNumber: z.string().optional(),
})
.refine(
(data) => {
// Jika disability = 'TIDAK', maka disabilityType harus kosong atau undefined
if (data.disability === 'TIDAK') {
return !data.disabilityType || data.disabilityType.trim() === ''
}
return true
},
{
message: "Jenis Disabilitas harus kosong jika Status Disabilitas = 'TIDAK'",
path: ['disabilityType'],
},
)
.refine(
(data) => {
// Jika disability = 'YA', maka disabilityType wajib diisi
if (data.disability === 'YA') {
if (data.disability === 'yes') {
return !!data.disabilityType?.trim()
}
return true
},
{
message: 'Mohon pilih Jenis Disabilitas',
message: 'Mohon pilih jenis Disabilitas',
path: ['disabilityType'],
},
)
// .refine((data) => {
// // Jika nationality = 'WNA', maka passportNumber wajib diisi
// if (data.nationality === 'WNA') {
// return !!data.passportNumber?.trim()
// }
// return true
// }, {
// message: 'Nomor Paspor wajib diisi untuk Warga Negara Asing',
// path: ['passportNumber'],
// })
.transform((data) => {
// Transform untuk backend: hanya kirim disabilityType sesuai kondisi
return {
...data,
// Jika disability = 'YA', kirim disabilityType
// Jika disability = 'TIDAK', kirim null untuk disabilityType
disabilityType: data.disability === 'YA' ? data.disabilityType : null,
// Hapus field disability karena yang dikirim ke backend adalah disabilityType
disability: undefined,
}
})
.refine(
(data) => {
if (data.nationality === 'WNI') {
const nik = data.identityNumber?.trim()
return !!nik && nik.length === 16 && /^\d+$/.test(nik)
}
return true
},
{
message: 'NIK harus berupa angka 16 digit',
path: ['identityNumber'],
},
)
// .transform((data) => {
// return {
// ...data,
// // newBornStatus: data.isNewBorn === 'yes',
// // communicationBarrier: data.communicationBarrier === 'yes',
// // disability: data.disability ? data.disabilityType : null,
// }
// })
type PatientFormData = z.infer<typeof PatientSchema>
+2
View File
@@ -1,6 +1,8 @@
import { z } from 'zod'
const PersonAddressSchema = z.object({
id: z.number().optional(),
locationType_code: z.string({
required_error: 'Mohon pilih jenis alamat',
}),
+4 -2
View File
@@ -1,8 +1,10 @@
import { z } from 'zod'
const PersonContactBaseSchema = z.object({
contactType: z.string().min(1, 'Mohon pilih tipe kontak'),
contactNumber: z.string().min(8, 'Nomor minimal 8 digit'),
id: z.number().optional(),
contactType: z.string({ required_error: 'Mohon pilih tipe kontak' }).min(1, 'Mohon pilih tipe kontak'),
contactNumber: z.string({ required_error: 'Nomor kontak harus diisi' }).min(8, 'Nomor minimal 8 digit'),
})
const PersonContactListSchema = z.object({
+13 -1
View File
@@ -1,6 +1,7 @@
import { z } from 'zod'
const PersonFamilySchema = z.object({
id: z.number().optional(),
relation: z.enum(['mother', 'father', 'guardian', 'emergency_contact']),
name: z
.string({
@@ -26,8 +27,19 @@ const PersonFamiliesSchema = z.discriminatedUnion('shareFamilyData', [
}),
])
interface PersonFamiliesFormData {
shareFamilyData: '0' | '1'
families: {
id?: number
relation: 'mother' | 'father' | 'guardian' | 'emergency_contact'
name: string
education: string
occupation?: string
}[]
}
type PersonFamilyFormData = z.infer<typeof PersonFamilySchema>
type PersonFamiliesFormData = z.infer<typeof PersonFamiliesSchema>
// type PersonFamiliesFormData = z.infer<typeof PersonFamiliesSchema>
export { PersonFamilySchema, PersonFamiliesSchema }
export type { PersonFamilyFormData, PersonFamiliesFormData }
+16 -8
View File
@@ -1,20 +1,28 @@
import { z } from 'zod'
const ResponsibleContactPersonSchema = z.object({
const PersonRelativeSchema = z.object({
id: z.number().optional(),
relation: z.string({ required_error: 'Pilih jenis Penanggung Jawab' }).min(1, 'Pilih jenis Penanggung Jawab'),
name: z.string({ required_error: 'Mohon lengkapi Nama' }).min(3, 'Mohon lengkapi Nama'),
address: z.string({ required_error: 'Mohon lengkapi Alamat' }).min(3, 'Mohon lengkapi Alamat'),
name: z.string({ required_error: 'Mohon lengkapi Nama' }).optional(),
address: z.string({ required_error: 'Mohon lengkapi Alamat' }).optional(),
phone: z
.string({ required_error: 'Mohon lengkapi Nomor HP' })
.min(8, 'Nomor HP minimal 10 digit')
.max(15, 'Nomor HP maksimal 15 digit'),
.max(15, 'Nomor HP maksimal 15 digit')
.optional(),
occupation_name: z.string().optional(),
occupation_code: z.string().optional(),
education_code: z.string().optional(),
})
const ResponsiblePersonSchema = z.object({
contacts: z.array(ResponsibleContactPersonSchema).min(1, 'Minimal harus ada 1 penanggung jawab'),
const ResponsiblePersonRelativeSchema = z.object({
contacts: z.array(PersonRelativeSchema).optional(),
families: z.array(PersonRelativeSchema).optional(),
_shareFamilyData: z.enum(['yes', 'no']).optional(),
})
type PersonRelativeFormData = z.infer<typeof ResponsiblePersonSchema>
type PersonRelativeFormData = z.infer<typeof ResponsiblePersonRelativeSchema>
export { ResponsiblePersonSchema }
export { ResponsiblePersonRelativeSchema }
export type { PersonRelativeFormData }
+7 -1
View File
@@ -13,6 +13,11 @@ export async function getPatients(params: any = null) {
searchParams.append(key, params[key])
}
}
if (params.search) {
url += `/search/${params.search}`
searchParams.delete('search')
}
const queryString = searchParams.toString()
if (queryString) url += `?${queryString}`
}
@@ -42,7 +47,8 @@ export async function getPatientDetail(id: number) {
export async function getPatientByIdentifier(search: string) {
try {
const urlPath = search.length === 16 ? `by-resident-identity/search=${encodeURIComponent(search)}` : `/search/${search}`
const urlPath =
search.length === 16 ? `by-resident-identity/search=${encodeURIComponent(search)}` : `/search/${search}`
const url = `${mainUrl}/${urlPath}`
const resp = await xfetch(url, 'GET')
const result: any = {}
+1
View File
@@ -21,6 +21,7 @@
"@unovis/ts": "^1.5.1",
"@unovis/vue": "^1.5.1",
"date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0",
"embla-carousel": "^8.5.2",
"embla-carousel-vue": "^8.5.2",
"file-saver": "^2.0.5",
+6196 -7677
View File
File diff suppressed because it is too large Load Diff