* impl: opts for ethnic and lang * fix: doc related for preview fix: upload dokumen kk dan ktp fix dokumen preview fix: add preview doc on edit form
541 lines
17 KiB
Vue
541 lines
17 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'
|
|
import Dialog from '~/components/pub/my-ui/modal/dialog.vue'
|
|
import DocPreviewDialog from '~/components/pub/my-ui/modal/doc-preview-dialog.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 { getValueLabelList as getEthnicOpts } from '~/services/ethnic.service'
|
|
import { getValueLabelList as getLanguageOpts } from '~/services/language.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>()
|
|
|
|
// Dialog preview dokumen
|
|
const isDocPreviewDialogOpen = ref<boolean>(false)
|
|
const docPreviewUrl = ref<string>('')
|
|
|
|
// Computed untuk URL dokumen tersimpan
|
|
const identityFileUrl = computed(() => {
|
|
return patientDetail.value.person?.residentIdentityFileUrl || ''
|
|
})
|
|
const familyCardFileUrl = computed(() => {
|
|
return patientDetail.value.person?.familyIdentityFileUrl || ''
|
|
})
|
|
|
|
function handlePreviewDoc(url: string) {
|
|
docPreviewUrl.value = url
|
|
isDocPreviewDialogOpen.value = true
|
|
}
|
|
|
|
// 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 ethnicOptions = ref<{ value: string; label: string }[]>([])
|
|
const languageOptions = ref<{ value: string; label: string }[]>([])
|
|
|
|
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 () => {
|
|
const optsReq = {
|
|
'page-no-limit': true,
|
|
}
|
|
|
|
ethnicOptions.value = await getEthnicOpts(optsReq, true)
|
|
languageOptions.value = await getLanguageOpts(optsReq, true)
|
|
|
|
// 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 createdPersonId = 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
|
|
createdPersonId = data.person_id!
|
|
|
|
if (residentIdentityFile.value) {
|
|
void uploadAttachment(residentIdentityFile.value, createdPersonId, 'ktp')
|
|
}
|
|
if (familyCardFile.value) {
|
|
void uploadAttachment(familyCardFile.value, createdPersonId, '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
|
|
ref="personPatientForm"
|
|
:key="`patient-${formKey}`"
|
|
:is-readonly="isProcessing || isReadonly"
|
|
:initial-values="patientFormInitialValues"
|
|
:language-options="languageOptions"
|
|
:ethnic-options="ethnicOptions"
|
|
:mode="mode"
|
|
:identity-file-url="identityFileUrl"
|
|
:family-card-file-url="familyCardFileUrl"
|
|
@preview="handlePreviewDoc"
|
|
/>
|
|
<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>
|
|
|
|
<!-- Dialog Preview Dokumen -->
|
|
<Dialog
|
|
v-model:open="isDocPreviewDialogOpen"
|
|
title="Preview Dokumen"
|
|
size="2xl"
|
|
>
|
|
<DocPreviewDialog :link="docPreviewUrl" />
|
|
</Dialog>
|
|
</template>
|
|
|
|
<style scoped>
|
|
/* component style */
|
|
</style>
|