910b641750
feat(patient): add edit functionality to patient form - Modify genPatientEntity to accept existing patient data for updates - Add handleActionEdit handler for edit mode - Update form to handle both create and edit modes - Rename patient ref to patientDetail for clarity refactor(patient): update marital status codes and job options mapping - Change marital status enum values to standardized codes (S, M, D, W) - Simplify job options and marital status options mapping using mapToComboboxOptList - Add error handling in patient data loading ajust styling text based on combobox wip: edit patient redirect refactor(models): update type definitions and form field handling - Add field-name prop to SelectDob component for better form handling - Update Person and Patient interfaces to use null for optional fields - Add maritalStatus_code field to Person interface - Improve type safety by replacing undefined with null for optional fields fix casting radio str to boolean and parsing date error
511 lines
16 KiB
Vue
511 lines
16 KiB
Vue
<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,
|
|
isFormEntryDialogOpen,
|
|
isRecordConfirmationOpen,
|
|
onResetState,
|
|
handleActionSave,
|
|
handleActionEdit,
|
|
handleCancelForm,
|
|
} 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(() => {
|
|
const addresses = patientDetail.value.person?.addresses || patientDetail.value.personAddresses || []
|
|
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 {
|
|
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(() => {
|
|
const addresses = patientDetail.value.person?.addresses || patientDetail.value.personAddresses || []
|
|
const domicileAddress = addresses.find((a: PersonAddress) => a.locationType_code === 'domicile')
|
|
const identityAddress = addresses.find((a: PersonAddress) => a.locationType_code === 'identity')
|
|
|
|
// Jika tidak ada alamat KTP terpisah, berarti sama dengan domisili
|
|
if (!identityAddress) {
|
|
return {
|
|
isSameAddress: '1',
|
|
locationType_code: 'identity',
|
|
}
|
|
}
|
|
|
|
// Cek apakah alamat sama dengan domisili
|
|
const isSame =
|
|
domicileAddress &&
|
|
identityAddress.village_code === domicileAddress.village_code &&
|
|
identityAddress.address === domicileAddress.address
|
|
|
|
if (isSame) {
|
|
return {
|
|
isSameAddress: '1',
|
|
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
|
|
|
|
return {
|
|
isSameAddress: '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: '0',
|
|
families: [],
|
|
}
|
|
}
|
|
|
|
return {
|
|
shareFamilyData: '1',
|
|
families: parents.map((parent: PersonRelative) => ({
|
|
relation: parent.relationship_code || '',
|
|
name: parent.name || '',
|
|
education: parent.education_code || '',
|
|
occupation: parent.occupation_name || parent.occupation_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) => ({
|
|
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) => ({
|
|
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)
|
|
|
|
// 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
|
|
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
|
|
|
|
// If edit mode, update patient
|
|
if (props.mode === 'edit' && props.patientId) {
|
|
response = await handleActionEdit(
|
|
patientDetail.value.id,
|
|
patient,
|
|
() => {},
|
|
() => {},
|
|
toast,
|
|
)
|
|
}
|
|
// If create mode, create patient
|
|
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>
|