feat (encounter): implement general consent feature

This commit is contained in:
Abizrh
2025-11-11 08:57:49 +07:00
parent db15ec9445
commit e62ee1b37e
9 changed files with 798 additions and 1 deletions
@@ -0,0 +1,241 @@
<script setup lang="ts">
import Block from '~/components/pub/my-ui/doc-entry/block.vue'
import Cell from '~/components/pub/my-ui/doc-entry/cell.vue'
import Field from '~/components/pub/my-ui/doc-entry/field.vue'
import Label from '~/components/pub/my-ui/doc-entry/label.vue'
// Helpers
import type z from 'zod'
import { toTypedSchema } from '@vee-validate/zod'
import { useForm } from 'vee-validate'
import { genBase } from '~/models/_base'
const props = defineProps<{
modelValue: any
schema: z.ZodSchema<any>
excludeFields?: string[]
isReadonly?: boolean
}>()
const emit = defineEmits<{
(e: 'update:modelValue', val: any): void
(e: 'submit', val: any): void
}>()
// Setup form
const {
validate: _validate,
defineField,
handleSubmit,
errors,
values,
} = useForm({
validationSchema: toTypedSchema(props.schema),
initialValues: props.modelValue,
})
watch(values, (val) => emit('update:modelValue', val), { deep: true })
const [primaryComplaint, primaryComplaintAttrs] = defineField('prim-compl')
const [pastDisease, pastDiseaseAttrs] = defineField('past-disease')
const [currentDisease, currentDiseaseAttrs] = defineField('current-disease')
const [gcs, gcsAttrs] = defineField('gcs')
const [respiratoryRate, respiratoryRateAttrs] = defineField('respiratory-rate')
const [respiratoryRateType, respiratoryRateTypeAttrs] = defineField('respiratory-rate-type')
const [pulse, pulseAttrs] = defineField('pulse')
const [pulseType, pulseTypeAttrs] = defineField('pulse-type')
const [rightArmBp, rightArmBpAttrs] = defineField('right-arm-bp')
const [leftArmBp, leftArmBpAttrs] = defineField('left-arm-bp')
const [axillaryTemp, axillaryTempAttrs] = defineField('axillary-temp')
const [rektalTemp, rektalTempAttrs] = defineField('rektal-temp')
const [skin, skinAttrs] = defineField('skin')
const [head, headAttrs] = defineField('head')
const [ear, earAttrs] = defineField('ear')
const [nose, noseAttrs] = defineField('nose')
const [oralCavity, oralCavityAttrs] = defineField('oral-cavity')
const [eye, eyeAttrs] = defineField('eye')
const [otherBodyPart, otherBodyPartAttrs] = defineField('other-body-part')
const [neck, neckAttrs] = defineField('neck')
const [thyroid, thyroidAttrs] = defineField('thyroid')
const [thorax, thoraxAttrs] = defineField('thorax')
const [heart, heartAttrs] = defineField('heart')
const [lung, lungAttrs] = defineField('lung')
const [abdomen, abdomenAttrs] = defineField('abdomen')
const [heart2, heart2Attrs] = defineField('heart2')
const [lien, lienAttrs] = defineField('lien')
const [back, backAttrs] = defineField('back')
const [extremity, extremityAttrs] = defineField('extremity')
const [gender, genderAttrs] = defineField('gender')
const [rectum, rectumAttrs] = defineField('rectum')
const [systemSyaraf, systemSyarafAttrs] = defineField('system-syaraf')
const [nervousSystem, nervousSystemAttrs] = defineField('nervous-system')
const [cardioRespiratory, cardioRespiratoryAttrs] = defineField('cardio-respiratory')
const [imaging, imagingAttrs] = defineField('imaging')
const [laboratory, laboratoryAttrs] = defineField('laboratory')
const validate = async () => {
const result = await _validate()
console.log('Component validate() result:', result)
return {
valid: true,
data: result.values,
errors: result.errors,
}
}
defineExpose({ validate })
const icdPreview = inject('icdPreview')
const isExcluded = (key: string) => props.excludeFields?.includes(key)
const disorders = ref<string[]>([])
const therapies = ref<string[]>([])
const summary = ref('')
const disorderOptions = [
'Fungsi Otot',
'Fungsi Sendi',
'Fungsi Jalan',
'Fungsi Syaraf',
'Fungsi Koordinasi',
'Jantung',
'Fungsi Respirasi',
'Fungsi Menelan',
'Fungsi Bladder',
'Fungsi Bowel',
'Fungsi Luhur',
'Fungsi Kontrol Postur',
'Fungsi Eksekusi',
'Fungsi Ortosa/Protesa',
'Gangguan Aktivitas Sehari-hari',
'Lainnya',
]
const therapyOptions = ['Terapi Latihan', 'Modalitas Fisik', 'Protesa/Ortosa', 'Medikamentosa', 'Lain-lain: Konsultasi']
</script>
<template>
<form id="entry-form">
<div class="mb-5 border-b border-b-slate-300 pb-3 text-lg xl:text-xl">
<Block>
<Cell>
<Label dynamic>Tanggal</Label>
<Field :errMessage="errors['prim-compl']">
<Textarea
v-model="primaryComplaint"
v-bind="primaryComplaintAttrs"
/>
</Field>
</Cell>
</Block>
<Separator class="mt-8" />
<div class="my-2">
<h1 class="font-semibold">Anggota Keluarga dan Penanggung Jawab</h1>
</div>
<div class="my-2 rounded-md border border-slate-300 p-4">
<Block :colCount="2">
<Cell>
<Label dynamic>Pernapasan</Label>
<Field>
<Input
v-model="respiratoryRate"
v-bind="respiratoryRateAttrs"
/>
</Field>
</Cell>
<Cell>
<Label dynamic>Jenis</Label>
<Field>
<Input
v-model="respiratoryRateType"
v-bind="respiratoryRateTypeAttrs"
/>
</Field>
</Cell>
</Block>
</div>
<div class="my-2">
<h1 class="font-semibold">Penanggung Jawab</h1>
</div>
<div class="my-2 rounded-md border border-slate-300 p-4">
<Block :colCount="2">
<Cell>
<Label dynamic>Pernapasan</Label>
<Field>
<Input
v-model="respiratoryRate"
v-bind="respiratoryRateAttrs"
/>
</Field>
</Cell>
<Cell>
<Label dynamic>Jenis</Label>
<Field>
<Input
v-model="respiratoryRateType"
v-bind="respiratoryRateTypeAttrs"
/>
</Field>
</Cell>
</Block>
</div>
<Separator class="mt-8" />
<div class="my-2">
<h1 class="font-semibold">Pemberi Informasi</h1>
</div>
<div class="my-2 rounded-md border border-slate-300 p-4">
<Block>
<Cell>
<Label dynamic>Jenis</Label>
<Field>
<Input
v-model="respiratoryRateType"
v-bind="respiratoryRateTypeAttrs"
/>
</Field>
</Cell>
</Block>
</div>
<Separator class="mt-8" />
<div class="my-2">
<h1 class="font-semibold">Saksi</h1>
</div>
<div class="my-2 rounded-md border border-slate-300 p-4">
<Block :colCount="2">
<Cell>
<Label dynamic>Pernapasan</Label>
<Field>
<Input
v-model="respiratoryRate"
v-bind="respiratoryRateAttrs"
/>
</Field>
</Cell>
<Cell>
<Label dynamic>Jenis</Label>
<Field>
<Input
v-model="respiratoryRateType"
v-bind="respiratoryRateTypeAttrs"
/>
</Field>
</Cell>
</Block>
</div>
</div>
</form>
</template>
@@ -0,0 +1,57 @@
import type { Config, RecComponent, RecStrFuncComponent, RecStrFuncUnknown } from '~/components/pub/my-ui/data-table'
import { defineAsyncComponent } from 'vue'
import type { GeneralConsent } from '~/models/general-consent'
type SmallDetailDto = any
const action = defineAsyncComponent(() => import('~/components/pub/my-ui/data/dropdown-action-ud.vue'))
export const config: Config = {
cols: [{ width: 100 }, {}, {}, {}, { width: 50 }],
headers: [
[
{ label: 'Tanggal' },
{ label: 'Anggota Keluarga' },
{ label: 'Penanggung Jawab' },
{ label: 'Pemberi Informasi' },
{ label: 'Saksi 1' },
{ label: 'Saksi 2' },
{ label: '' },
],
],
keys: ['date', 'dstUnit.name', 'dstDoctor.name', 'responsible', 'problem', 'solution', 'action'],
delKeyNames: [
{ key: 'data', label: 'Tanggal' },
{ key: 'dstDoctor.name', label: 'Dokter' },
],
parses: {
action(rec, idx) {
const res: RecComponent = {
idx,
rec: rec as object,
component: action,
props: {
size: 'sm',
},
}
return res
},
date(rec) {
const recX = rec as GeneralConsent
return recX.date?.substring(0, 10) || '-'
},
},
components: {
action(rec, idx) {
const res: RecComponent = {
idx,
rec: rec as object,
component: action,
props: {
size: 'sm',
},
}
return res
},
} as RecStrFuncComponent,
htmls: {} as RecStrFuncUnknown,
}
@@ -0,0 +1,34 @@
<script setup lang="ts">
import type { PaginationMeta } from '~/components/pub/my-ui/pagination/pagination.type'
import PaginationView from '~/components/pub/my-ui/pagination/pagination-view.vue'
import { config } from './list.cfg'
interface Props {
data: any[]
paginationMeta: PaginationMeta
}
const props = defineProps<Props>()
const emit = defineEmits<{
pageChange: [page: number]
}>()
function handlePageChange(page: number) {
emit('pageChange', page)
}
</script>
<template>
<div class="space-y-4">
<PubMyUiDataTable
v-bind="config"
:rows="data"
:skeleton-size="paginationMeta?.pageSize"
/>
<!-- FIXME: pindahkan ke content/division/list.vue -->
<PaginationView
:pagination-meta="paginationMeta"
@page-change="handlePageChange"
/>
</div>
</template>
+2 -1
View File
@@ -16,6 +16,7 @@ import EarlyMedicalAssesmentList from '~/components/content/soapi/entry.vue'
import EarlyMedicalRehabList from '~/components/content/soapi/entry.vue' import EarlyMedicalRehabList from '~/components/content/soapi/entry.vue'
import PrescriptionList from '~/components/content/prescription/list.vue' import PrescriptionList from '~/components/content/prescription/list.vue'
import Consultation from '~/components/content/consultation/list.vue' import Consultation from '~/components/content/consultation/list.vue'
import GeneralConsentList from '~/components/content/general-consent/entry.vue'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -58,7 +59,7 @@ const tabs: TabItem[] = [
}, },
{ value: 'therapy-protocol', label: 'Protokol Terapi' }, { value: 'therapy-protocol', label: 'Protokol Terapi' },
{ value: 'education-assessment', label: 'Asesmen Kebutuhan Edukasi' }, { value: 'education-assessment', label: 'Asesmen Kebutuhan Edukasi' },
{ value: 'consent', label: 'General Consent' }, { value: 'consent', label: 'General Consent', component: GeneralConsentList, props: { encounter: data } },
{ value: 'patient-note', label: 'CPRJ' }, { value: 'patient-note', label: 'CPRJ' },
{ value: 'prescription', label: 'Order Obat', component: PrescriptionList }, { value: 'prescription', label: 'Order Obat', component: PrescriptionList },
{ value: 'device', label: 'Order Alkes' }, { value: 'device', label: 'Order Alkes' },
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useQueryMode } from '@/composables/useQueryMode'
import List from './list.vue'
import Form from './form.vue'
// Models
import type { Encounter } from '~/models/encounter'
// Props
interface Props {
encounter: Encounter
}
const props = defineProps<Props>()
const route = useRoute()
const { mode, goToEntry, backToList } = useQueryCRUDMode('mode')
</script>
<template>
<div>
<List
v-if="mode === 'list'"
:encounter="props.encounter"
@add="goToEntry"
@edit="goToEntry"
/>
<Form
v-else
@back="backToList"
/>
</div>
</template>
@@ -0,0 +1,181 @@
<script setup lang="ts">
import { z } from 'zod'
import Entry from '~/components/app/general-consent/entry.vue'
import Action from '~/components/pub/my-ui/nav-footer/ba-dr-su-pr.vue'
import ActionDialog from '~/components/pub/my-ui/nav-footer/ba-su.vue'
import Dialog from '~/components/pub/my-ui/modal/dialog.vue'
import { FunctionSoapiSchema } from '~/schemas/soapi.schema'
import { toast } from '~/components/pub/ui/toast'
import { handleActionSave, handleActionEdit } from '~/handlers/soapi-early.handler'
const { backToList } = useQueryMode('mode')
const route = useRoute()
const isOpenProcedure = ref(false)
const isOpenDiagnose = ref(false)
const isOpenFungsional = ref(false)
const procedures = ref([])
const diagnoses = ref([])
const fungsional = ref([])
const selectedProcedure = ref<any>(null)
const selectedDiagnose = ref<any>(null)
const selectedFungsional = ref<any>(null)
const schema = FunctionSoapiSchema
const payload = ref({
encounter_id: 0,
time: '',
typeCode: 'function',
value: '',
})
const model = ref({
'prim-compl': '',
'past-disease': '',
'current-disease': '',
gcs: '',
'respiratory-rate': '',
'respiratory-rate-type': '',
pulse: '',
'pulse-type': '',
'right-arm-bp': '',
'left-arm-bp': '',
'axillary-temp': '',
'rektal-temp': '',
skin: '',
head: '',
ear: '',
nose: '',
'oral-cavity': '',
eye: '',
'other-body-part': '',
neck: '',
thyroid: '',
thorax: '',
heart: '',
lung: '',
abdomen: '',
heart2: '',
lien: '',
back: '',
extremity: '',
gender: '',
rectum: '',
'system-syaraf': '',
'nervous-system': '',
'cardio-respiratory': '',
imaging: '',
laboratory: '',
})
const isLoading = reactive<DataTableLoader>({
isTableLoading: false,
})
async function getDiagnoses() {
isLoading.isTableLoading = true
const resp = await xfetch('/api/v1/diagnose-src')
if (resp.success) {
diagnoses.value = (resp.body as Record<string, any>).data
}
isLoading.isTableLoading = false
}
async function getProcedures() {
isLoading.isTableLoading = true
const resp = await xfetch('/api/v1/procedure-src')
if (resp.success) {
procedures.value = (resp.body as Record<string, any>).data
}
isLoading.isTableLoading = false
}
onMounted(() => {
getProcedures()
getDiagnoses()
})
function handleClick(type: string) {
if (type === 'prosedur') {
isOpenProcedure.value = true
} else if (type === 'diagnosa') {
isOpenDiagnose.value = true
} else if (type === 'fungsional') {
isOpenDiagnose.value = true
}
}
const entryRehabRef = ref()
async function actionHandler(type: string) {
if (type === 'back') {
backToList()
return
}
if (type === 'print') {
console.log('print')
isOpenDiagnose.value = true
return
}
const result = await entryRehabRef.value?.validate()
if (result?.valid) {
if (
selectedProcedure.value?.length > 0 ||
selectedDiagnose.value?.length > 0 ||
selectedFungsional.value?.length > 0
) {
result.data.procedure = selectedProcedure.value || []
result.data.diagnose = selectedDiagnose.value || []
result.data.fungsional = selectedFungsional.value || []
}
console.log('data', result.data)
handleActionSave(
{
...payload.value,
value: JSON.stringify(result.data),
encounter_id: +route.params.id,
time: new Date().toISOString(),
},
{},
toast,
)
} else {
console.log('Ada error di form', result)
}
}
const icdPreview = ref({
procedures: [],
diagnoses: [],
})
function actionDialogHandler(type: string) {
if (type === 'submit') {
icdPreview.value.procedures = selectedProcedure.value || []
icdPreview.value.diagnoses = selectedDiagnose.value || []
icdPreview.value.fungsional = selectedFungsional.value || []
}
isOpenProcedure.value = false
isOpenDiagnose.value = false
}
provide('table_data_loader', isLoading)
provide('icdPreview', icdPreview)
</script>
<template>
<Entry
ref="entryRehabRef"
v-model="model"
:schema="schema"
type="function"
@click="handleClick"
/>
<div class="my-2 flex justify-end py-2">
<Action @click="actionHandler" />
</div>
<Dialog
v-model:open="isOpenDiagnose"
title="Preview General Content"
size="xl"
prevent-outside
>
<div class="my-2 flex justify-end py-2">
<ActionDialog @click="actionDialogHandler" />
</div>
</Dialog>
</template>
@@ -0,0 +1,187 @@
<script setup lang="ts">
// Components
import Dialog from '~/components/pub/my-ui/modal/dialog.vue'
import Header from '~/components/pub/my-ui/nav-header/prep.vue'
import RecordConfirmation from '~/components/pub/my-ui/confirmation/record-confirmation.vue'
import List from '~/components/app/general-consent/list.vue'
import Entry from '~/components/app/general-consent/entry.vue'
// Helpers
import { usePaginatedList } from '~/composables/usePaginatedList'
import { toast } from '~/components/pub/ui/toast'
// Types
import { ActionEvents, type HeaderPrep } from '~/components/pub/my-ui/data/types'
import { GeneralConsentSchema, type GeneralConsentFormData } from '~/schemas/general-consent.schema'
// Handlers
import {
recId,
recAction,
recItem,
isReadonly,
isProcessing,
isFormEntryDialogOpen,
isRecordConfirmationOpen,
onResetState,
handleActionSave,
handleActionEdit,
handleActionRemove,
handleCancelForm,
} from '~/handlers/consultation.handler'
// Services
import { getList, getDetail } from '~/services/consultation.service'
import { getValueLabelList } from '~/services/unit.service'
// Models
import type { Encounter } from '~/models/encounter'
// Props
interface Props {
encounter: Encounter
}
const props = defineProps<Props>()
const emits = defineEmits(['add', 'edit'])
const { recordId } = useQueryCRUDRecordId()
const { goToEntry, backToList } = useQueryCRUDMode('mode')
let units = ref<{ value: string; label: string }[]>([])
const encounterId = ref<number>(props?.encounter?.id || 0)
const title = ref('')
const {
data,
isLoading,
paginationMeta,
searchInput,
handlePageChange,
handleSearch,
fetchData: getMyList,
} = usePaginatedList({
fetchFn: async ({ page, search }) => {
const result = await getList({ 'encounter-id': props.encounter.id, includes: 'encounter,dstUnit', search, page })
if (result.success) {
data.value = result.body.data
}
return { success: result.success || false, body: result.body || {} }
},
entityName: 'general-consent',
})
const headerPrep: HeaderPrep = {
title: 'General Consent',
icon: 'i-lucide-box',
refSearchNav: {
placeholder: 'Cari (min. 3 karakter)...',
minLength: 3,
debounceMs: 500,
showValidationFeedback: true,
onInput: (value: string) => {
searchInput.value = value
},
onClick: () => {},
onClear: () => {},
},
addNav: {
label: 'Tambah',
icon: 'i-lucide-plus',
onClick: () => {
goToEntry()
emits('add')
// recItem.value = {
// encounter_id: encounterId.value,
// date: today.toISOString().slice(0, 10),
// unit_id: 0,
// problem: '',
// }
// recId.value = 0
// isFormEntryDialogOpen.value = true
// isReadonly.value = false
},
},
}
const today = new Date()
provide('rec_id', recId)
provide('rec_action', recAction)
provide('rec_item', recItem)
provide('table_data_loader', isLoading)
const getMyDetail = async (id: number | string) => {
const result = await getDetail(id)
if (result.success) {
const currentValue = result.body?.data || {}
recItem.value = currentValue
isFormEntryDialogOpen.value = true
}
}
// Watch for row actions when recId or recAction changes
watch([recId, recAction], () => {
switch (recAction.value) {
case ActionEvents.showDetail:
getMyDetail(recId.value)
title.value = 'Detail Konsultasi'
isReadonly.value = true
break
case ActionEvents.showEdit:
getMyDetail(recId.value)
title.value = 'Edit Konsultasi'
isReadonly.value = false
break
case ActionEvents.showConfirmDelete:
isRecordConfirmationOpen.value = true
break
}
})
onMounted(async () => {
await getMyList()
units.value = await getValueLabelList()
})
</script>
<template>
<Header
v-model="searchInput"
:prep="headerPrep"
:ref-search-nav="headerPrep.refSearchNav"
@search="handleSearch"
class="mb-4 xl:mb-5"
/>
<List
:data="data"
:pagination-meta="paginationMeta"
@page-change="handlePageChange"
/>
<!-- Record Confirmation Modal -->
<RecordConfirmation
v-model:open="isRecordConfirmationOpen"
action="delete"
:record="recItem"
@confirm="() => handleActionRemove(recId, getMyList, toast)"
@cancel=""
>
<template #default="{ record }">
<div class="text-sm">
<p>
<strong>ID:</strong>
{{ record?.id }}
</p>
<p v-if="record?.name">
<strong>Nama:</strong>
{{ record.name }}
</p>
<p v-if="record?.code">
<strong>Kode:</strong>
{{ record.code }}
</p>
</div>
</template>
</RecordConfirmation>
</template>
+47
View File
@@ -0,0 +1,47 @@
export interface GeneralConsent {
id: number
encounter_id: number
date?: string
unit_id: number
doctor_id?: number
problem: string
solution?: string
repliedAt?: string
}
export interface CreateDto {
encounter_id: number
date: string
problem: string
dstUnit_id: number
}
export interface UpdateDto {
id: number
problem: string
unit_id: number
}
export interface DeleteDto {
id: number
}
export function genCreateDto(): CreateDto {
return {
encounter_id: 0,
problem: '',
unit_id: 0,
}
}
export function genConsultation(): GeneralConsent {
return {
id: 0,
encounter_id: 0,
unit_id: 0,
doctor_id: 0,
problem: '',
solution: '',
repliedAt: '',
}
}
+13
View File
@@ -0,0 +1,13 @@
import { z } from 'zod'
import type { CreateDto } from '~/models/general-consent'
const GeneralConsentSchema = z.object({
date: z.string({ required_error: 'Tanggal harus diisi' }),
dstUnit_id: z.number({ required_error: 'Unit harus diisi' }),
problem: z.string({ required_error: 'Uraian harus diisi' }).min(20, 'Uraian minimum 20 karakter'),
})
type GeneralConsentFormData = z.infer<typeof GeneralConsentSchema> & CreateDto
export { GeneralConsentSchema }
export type { GeneralConsentFormData }