Files
Khafid Prayoga 51725d7f73 feat(patient): doc preview, integration to select ethnic and lang (#229)
* 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
2025-12-12 16:12:24 +07:00

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>