Feat: Integrate Control Letter

This commit is contained in:
hasyim_kai
2025-11-04 16:56:08 +07:00
parent 2275f4dc99
commit 331f4a6b20
15 changed files with 365 additions and 173 deletions
@@ -98,7 +98,6 @@ function calculateAge(birthDate: string | Date | undefined): string {
id="birthDate"
type="date"
min="1900-01-01"
:max="new Date().toISOString().split('T')[0]"
v-bind="componentField"
:placeholder="placeholder"
@update:model-value="
@@ -3,8 +3,10 @@ import type { FormErrors } from '~/types/error'
import Combobox from '~/components/pub/my-ui/combobox/combobox.vue'
import { cn, mapToComboboxOptList } from '~/lib/utils'
import { occupationCodes } from '~/lib/constants'
import { getValueLabelList as getDoctorLabelList } from '~/services/doctor.service'
import * as DE from '~/components/pub/my-ui/doc-entry'
import type { Item } from '~/components/pub/my-ui/combobox'
const props = defineProps<{
fieldName?: string
@@ -28,8 +30,28 @@ const {
labelClass,
} = props
// Generate job options from constants, sama seperti pola genderCodes
const jobOptions = mapToComboboxOptList(occupationCodes)
const doctors = ref<Array<Item>>([])
async function fetchDpjp() {
doctors.value = await getDoctorLabelList({
serviceType: 1,
serviceDate: new Date().toISOString().substring(0, 10),
specialistCode: 0,
includes: 'employee-person',
})
}
const selectedDpjpId = inject<Ref<string | null>>("selectedDpjpId")!
function handleDpjpChange(selected: string) {
console.log(`change dphp`)
selectedDpjpId.value = selected ?? null
const dpjp = doctors.value.find(s => s.value === selectedDpjpId.value)
console.log(dpjp)
}
onMounted(() => {
fetchDpjp()
})
</script>
<template>
@@ -56,10 +78,11 @@ const jobOptions = mapToComboboxOptList(occupationCodes)
class="focus:ring-0 focus:ring-offset-0"
:id="fieldName"
v-bind="componentField"
:items="jobOptions"
:items="doctors"
:placeholder="placeholder"
search-placeholder="Cari..."
empty-message="Data tidak ditemukan"
@update:model-value="handleDpjpChange"
/>
</FormControl>
<FormMessage />
@@ -3,11 +3,15 @@ import type { FormErrors } from '~/types/error'
import Combobox from '~/components/pub/my-ui/combobox/combobox.vue'
import { cn, mapToComboboxOptList } from '~/lib/utils'
import { occupationCodes } from '~/lib/constants'
import type { Item } from '~/components/pub/my-ui/combobox'
import { getValueLabelList as getSpecialistLabelList } from '~/services/specialist.service'
import { getValueLabelList as getSubspecialistLabelList } from '~/services/subspecialist.service'
import * as DE from '~/components/pub/my-ui/doc-entry'
const props = defineProps<{
fieldName?: string
specialistFieldName?: string
subSpecialistFieldName?: string
label?: string
placeholder?: string
errors?: FormErrors
@@ -19,7 +23,8 @@ const props = defineProps<{
}>()
const {
fieldName = 'job',
specialistFieldName = 'job',
subSpecialistFieldName = 'job',
label = 'Pekerjaan',
placeholder = 'Pilih pekerjaan',
errors,
@@ -28,43 +33,103 @@ const {
labelClass,
} = props
// Generate job options from constants, sama seperti pola genderCodes
const jobOptions = mapToComboboxOptList(occupationCodes)
const specialists = ref<Array<Item>>([])
const subspecialists = ref<Array<Item>>([])
async function fetchSpecialists() {
specialists.value = await getSpecialistLabelList({
serviceType: 1,
serviceDate: new Date().toISOString().substring(0, 10),
specialistCode: 0,
})
subspecialists.value = await getSubspecialistLabelList({
serviceType: 1,
serviceDate: new Date().toISOString().substring(0, 10),
specialistCode: 0,
})
}
const selectedSpecialistId = inject<Ref<string | null>>("selectedSpecialistId")!
function handleSpecialistChange(selected: string) {
selectedSpecialistId.value = selected ?? null
}
onMounted(() => {
fetchSpecialists()
})
</script>
<template>
<DE.Cell :class="cn('select-field-group', fieldGroupClass, containerClass)">
<DE.Label
:label-for="fieldName"
<DE.Block :col-count="2" :class="cn('select-field-group', fieldGroupClass, containerClass)">
<div>
<DE.Label
:label-for="specialistFieldName"
:class="cn('select-field-label', labelClass)"
:is-required="isRequired"
>
{{ label }}
Spesialis
</DE.Label>
<DE.Field
:id="fieldName"
:id="specialistFieldName"
:errors="errors"
:class="cn('select-field-wrapper')"
>
<FormField
v-slot="{ componentField }"
:name="fieldName"
:name="specialistFieldName"
>
<FormItem>
<FormControl>
<Combobox
class="focus:ring-0 focus:ring-offset-0"
:id="fieldName"
:id="specialistFieldName"
v-bind="componentField"
:items="jobOptions"
:items="specialists"
:placeholder="placeholder"
search-placeholder="Cari..."
empty-message="Data tidak ditemukan"
@update:model-value="handleSpecialistChange"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</DE.Field>
</DE.Cell>
</div>
<div>
<DE.Label
:label-for="subSpecialistFieldName"
:class="cn('select-field-label', labelClass)"
:is-required="isRequired"
>
Sub Spesialis
</DE.Label>
<DE.Field
:id="subSpecialistFieldName"
:errors="errors"
:class="cn('select-field-wrapper')"
>
<FormField
v-slot="{ componentField }"
:name="subSpecialistFieldName"
>
<FormItem>
<FormControl>
<Combobox
class="focus:ring-0 focus:ring-offset-0"
:id="subSpecialistFieldName"
v-bind="componentField"
:items="subspecialists"
:placeholder="placeholder"
search-placeholder="Cari..."
empty-message="Data tidak ditemukan"
:is-disabled="!selectedSpecialistId"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</DE.Field>
</div>
</DE.Block>
</template>
@@ -45,20 +45,21 @@ defineExpose({
:is-disabled="true"
/>
<SelectDate
field-name="controlDate"
field-name="date"
label="Tanggal Rencana Kontrol"
:errors="errors"
is-required
/>
<SelectSpeciality
field-name="SpesialisSubSpesialis"
specialist-field-name="spesialist_id"
sub-specialist-field-name="subspesialist_id"
label="Spesialis/Sub Spesialis"
placeholder="Pilih Spesialis/Sub Spesialis"
:errors="errors"
is-required
/>
<SelectDpjp
field-name="dpjp"
field-name="doctor_id"
label="DPJP"
placeholder="Pilih DPJP"
:errors="errors"
@@ -7,19 +7,20 @@ import { calculateAge } from '~/lib/utils'
const action = defineAsyncComponent(() => import('~/components/pub/my-ui/data/dropdown-action-dud.vue'))
export const config: Config = {
cols: [{}, {}, {}, {}, {width: 3},],
cols: [{width: 180}, {}, {}, {}, {}, {width: 30},],
headers: [
[
{ label: 'Tgl Rencana Kontrol' },
{ label: 'Spesialis/Sub Spesialis' },
{ label: 'Spesialis' },
{ label: 'Sub Spesialis' },
{ label: 'DPJP' },
{ label: 'Status SEP' },
{ label: 'Action' },
],
],
keys: ['birth_date', 'number', 'person.name', 'birth_date', 'action'],
keys: ['date', 'specialist.name', 'subspecialist.name', 'doctor.employee.person.name', 'sep_status', 'action'],
delKeyNames: [
{ key: 'code', label: 'Kode' },
@@ -27,50 +28,20 @@ export const config: Config = {
],
parses: {
patientId: (rec: unknown): unknown => {
const patient = rec as Patient
return patient.number
},
identity_number: (rec: unknown): unknown => {
const { person } = rec as Patient
if (person.nationality == 'WNA') {
return person.passportNumber
date: (rec: unknown): unknown => {
const date = (rec as any).date
if (typeof date == 'object' && date) {
return (date as Date).toLocaleDateString('id-ID')
} else if (typeof date == 'string') {
return (date as string).substring(0, 10)
}
return person.residentIdentityNumber || '-'
return date
},
birth_date: (rec: unknown): unknown => {
const { person } = rec as Patient
if (typeof person.birthDate == 'object' && person.birthDate) {
return (person.birthDate as Date).toLocaleDateString('id-ID')
} else if (typeof person.birthDate == 'string') {
return (person.birthDate as string).substring(0, 10)
}
return person.birthDate
},
patient_age: (rec: unknown): unknown => {
const { person } = rec as Patient
return calculateAge(person.birthDate)
},
gender: (rec: unknown): unknown => {
const { person } = rec as Patient
if (typeof person.gender_code == 'number' && person.gender_code >= 0) {
return person.gender_code
} else if (typeof person.gender_code === 'string' && person.gender_code) {
return genderCodes[person.gender_code] || '-'
}
specialist_subspecialist: (rec: unknown): unknown => {
return '-'
},
education: (rec: unknown): unknown => {
const { person } = rec as Patient
if (typeof person.education_code == 'number' && person.education_code >= 0) {
return person.education_code
} else if (typeof person.education_code === 'string' && person.education_code) {
return educationCodes[person.education_code] || '-'
}
dpjp: (rec: unknown): unknown => {
// const { person } = rec as Patient
return '-'
},
},
@@ -86,8 +57,8 @@ export const config: Config = {
},
htmls: {
patient_address(_rec) {
return '-'
sep_status(_rec) {
return 'SEP Internal'
},
},
}
@@ -1,32 +1,13 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
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 { ControlLetterSchema } from '~/schemas/control-letter.schema'
import {
// for form entry
isReadonly,
isProcessing,
isFormEntryDialogOpen,
isRecordConfirmationOpen,
onResetState,
handleActionSave,
handleCancelForm,
} from '~/handlers/patient.handler'
import { handleActionSave,} from '~/handlers/control-letter.handler'
import { toast } from '~/components/pub/ui/toast'
import Confirmation from '~/components/pub/my-ui/confirmation/confirmation.vue'
import { type ControlLetter } from '~/models/control-letter'
// #region Props & Emits
const props = defineProps<{
@@ -34,12 +15,16 @@ const props = defineProps<{
}>()
// form related state
const personPatientForm = ref<ExposedForm<any> | null>(null)
const route = useRoute()
const encounterId = typeof route.params.id == 'string' ? parseInt(route.params.id) : 0
const controlLetterForm = ref<ExposedForm<any> | null>(null)
// #endregion
// #region State & Computed
const router = useRouter()
const isConfirmationOpen = ref(false)
const selectedSpecialistId = ref<number|null>(null)
const selectedDpjpId = ref<number|null>(null)
// #endregion
// #region Lifecycle Hooks
@@ -51,12 +36,42 @@ function goBack() {
}
async function handleConfirmAdd() {
// handleActionClick('submit')
console.log(`tersubmit wak`)
const controlLetter: ControlLetter = await composeFormData()
let createdControlLetterId = 0
const response = await handleActionSave(
controlLetter,
() => { },
() => { },
toast,
)
const data = (response?.body?.data ?? null)
if (!data) return
createdControlLetterId = data.id
// // If has callback provided redirect to callback with patientData
if (props.callbackUrl) {
navigateTo(props.callbackUrl + '?control-letter-id=' + controlLetter.id)
}
goBack()
}
function handleCancelAdd() {
isConfirmationOpen.value = false
async function composeFormData(): Promise<ControlLetter> {
const [controlLetter,] = await Promise.all([
controlLetterForm.value?.validate(),
])
const results = [controlLetter]
const allValid = results.every((r) => r?.valid)
// exit, if form errors happend during validation
if (!allValid) return Promise.reject('Form validation failed')
const formData = controlLetter?.values
formData.encounter_id = encounterId
return new Promise((resolve) => resolve(formData))
}
// #endregion region
@@ -64,29 +79,6 @@ function handleCancelAdd() {
async function handleActionClick(eventType: string) {
if (eventType === 'submit') {
isConfirmationOpen.value = true
// 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 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('/outpatient/encounter')
// return
}
if (eventType === 'cancel') {
@@ -96,9 +88,15 @@ async function handleActionClick(eventType: string) {
}
goBack()
// handleCancelForm()
}
}
function handleCancelAdd() {
isConfirmationOpen.value = false
}
provide("selectedSpecialistId", selectedSpecialistId);
provide("selectedDpjpId", selectedDpjpId);
// #endregion
// #region Watchers
@@ -108,22 +106,19 @@ async function handleActionClick(eventType: string) {
<template>
<div class="mb-5 border-b border-b-slate-300 pb-3 text-lg font-semibold xl:text-xl">Tambah Surat Kontrol</div>
<AppOutpatientEncounterEntryForm
ref="personPatientForm"
:schema="ControlLetterSchema"
/>
ref="controlLetterForm"
:schema="ControlLetterSchema" />
<div class="my-2 flex justify-end py-2">
<Action :enable-draft="false" @click="handleActionClick" />
</div>
<Confirmation
v-model:open="isConfirmationOpen"
<Confirmation v-model:open="isConfirmationOpen"
title="Simpan Data"
message="Apakah Anda yakin ingin menyimpan data ini?"
confirm-text="Simpan"
@confirm="handleConfirmAdd"
@cancel="handleCancelAdd"
/>
@cancel="handleCancelAdd" />
</template>
<style scoped>
@@ -4,12 +4,11 @@ import { withBase } from '~/models/_base'
import type { HeaderPrep } from '~/components/pub/my-ui/data/types'
import type { Patient } from '~/models/patient'
import type { Person } from '~/models/person'
import { getDetail } from '~/services/control-letter.service'
// Components
import Header from '~/components/pub/my-ui/nav-header/prep.vue'
import { getPatientDetail } from '~/services/patient.service'
// #region Props & Emits
const props = defineProps<{
patientId: number
@@ -12,6 +12,7 @@ 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 { getDetail, update } from '~/services/control-letter.service'
import {
// for form entry
@@ -1,24 +1,23 @@
<script setup lang="ts">
import type { HeaderPrep, RefSearchNav } from '~/components/pub/my-ui/data/types'
import type { Summary } from '~/components/pub/my-ui/summary-card/type'
// #region Imports
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 SummaryCard from '~/components/pub/my-ui/summary-card/summary-card.vue'
import { usePaginatedList } from '~/composables/usePaginatedList'
import { getPatients, removePatient } from '~/services/patient.service'
import { getList, remove } from '~/services/control-letter.service'
import { toast } from '~/components/pub/ui/toast'
// #endregion
// #region State
const route = useRoute()
const encounterId = typeof route.params.id == 'string' ? parseInt(route.params.id) : 0
const { data, isLoading, paginationMeta, searchInput, handlePageChange, handleSearch, fetchData } = usePaginatedList({
fetchFn: (params) => getPatients({ ...params, includes: ['person', 'person-Addresses'] }),
entityName: 'patient',
fetchFn: (params) => getList({ ...params, includes: 'specialist,subspecialist,doctor-employee-person', }),
entityName: 'control-letter',
})
const refSearchNav: RefSearchNav = {
@@ -45,24 +44,27 @@ const headerPrep: HeaderPrep = {
icon: 'i-lucide-newspaper',
addNav: {
label: "Surat Kontrol",
onClick: () => navigateTo('/outpatient/encounter/add'),
onClick: () => navigateTo({
name: 'rehab-encounter-id-control-letter-add',
params: { id: encounterId },
}),
},
}
// #endregion
// #region Lifecycle Hooks
onMounted(() => {
getPatientSummary()
getListData()
})
// #endregion
// #region Functions
async function getPatientSummary() {
async function getListData() {
try {
summaryLoading.value = true
await new Promise((resolve) => setTimeout(resolve, 500))
} catch (error) {
console.error('Error fetching patient summary:', error)
console.error('Error fetching Data:', error)
} finally {
summaryLoading.value = false
}
@@ -70,22 +72,17 @@ async function getPatientSummary() {
// Handle confirmation result
async function handleConfirmDelete(record: any, action: string) {
console.log('Confirmed action:', action, 'for record:', record)
if (action === 'delete' && record?.id) {
try {
const result = await removePatient(record.id)
const result = await remove(record.id)
if (result.success) {
console.log('Patient deleted successfully')
// Refresh the list
toast({ title: 'Berhasil', description: 'Data berhasil dihapus', variant: 'default' })
await fetchData()
} else {
console.error('Failed to delete patient:', result)
// Handle error - show error message to user
toast({ title: 'Gagal', description: `Data gagal dihapus`, variant: 'destructive' })
}
} catch (error) {
console.error('Error deleting patient:', error)
// Handle error - show error message to user
toast({ title: 'Gagal', description: `Something went wrong`, variant: 'destructive' })
}
}
}
@@ -134,28 +131,10 @@ watch([recId, recAction], () => {
</script>
<template>
<Header v-model:search="searchInput" :prep="{ ...headerPrep }" :ref-search-nav="refSearchNav" />
<!-- Disable dulu, ayahab kalo diminta beneran -->
<!-- <div class="my-4 flex flex-1 flex-col gap-4 md:gap-8">
<div class="grid gap-4 md:grid-cols-2 md:gap-8 lg:grid-cols-4">
<template v-if="summaryLoading">
<SummaryCard
v-for="n in 4"
:key="n"
is-skeleton
/>
</template>
<template v-else>
<SummaryCard
v-for="card in summaryData"
:key="card.title"
:stat="card"
/>
</template>
</div>
</div>
-->
<Header v-model:search="searchInput"
:prep="{ ...headerPrep }"
:ref-search-nav="refSearchNav"
@search="handleSearch" />
<AppOutpatientEncounterList :data="data" :pagination-meta="paginationMeta" @page-change="handlePageChange" />
+24
View File
@@ -0,0 +1,24 @@
// Handlers
import { genCrudHandler } from '~/handlers/_handler'
// Services
import { create, update, remove } from '~/services/control-letter.service'
export const {
recId,
recAction,
recItem,
isReadonly,
isProcessing,
isFormEntryDialogOpen,
isRecordConfirmationOpen,
onResetState,
handleActionSave,
handleActionEdit,
handleActionRemove,
handleCancelForm,
} = genCrudHandler({
create,
update,
remove,
})
+18
View File
@@ -0,0 +1,18 @@
import { type Base, genBase } from "./_base"
export interface ControlLetter extends Base {
sep_status: string
control_plan_date: string
specialist_sub_specialist_id: string
dpjp_id: string
}
export function genControlLetter(): ControlLetter {
return {
...genBase(),
sep_status: '',
control_plan_date: '',
specialist_sub_specialist_id: '',
dpjp_id: '',
}
}
@@ -0,0 +1,42 @@
<script setup lang="ts">
import type { PagePermission } from '~/models/role'
import Error from '~/components/pub/my-ui/error/error.vue'
import { PAGE_PERMISSIONS } from '~/lib/page-permission'
definePageMeta({
middleware: ['rbac'],
roles: ['doctor', 'nurse', 'admisi', 'pharmacy', 'billing', 'management'],
title: 'Tambah Surat Kontrol',
contentFrame: 'cf-full-width',
})
const route = useRoute()
useHead({
title: () => route.meta.title as string,
})
const roleAccess: PagePermission = PAGE_PERMISSIONS['/patient']
const { checkRole, hasReadAccess } = useRBAC()
// 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 callbackUrl = route.query['return-path'] as string | undefined
</script>
<template>
<div>
<div v-if="canRead">
<ContentOutpatientEncounterAdd :callback-url="callbackUrl" />
</div>
<Error v-else :status-code="403" />
</div>
</template>
+10 -5
View File
@@ -3,14 +3,19 @@ import { z } from 'zod'
const ControlLetterSchema = z.object({
sepStatus: z.string({
required_error: 'Mohon isi status SEP',
}).default('SEP Internal'),
spesialist_id: z.number({
required_error: 'Mohon isi Spesialis/Sub Spesialis',
}),
SpesialisSubSpesialis: z.string({
required_error: 'Mohon isi status Spesialis/Sub Spesialis',
subspesialist_id: z.number({
required_error: 'Mohon isi Spesialis/Sub Spesialis',
}),
dpjp: z.string({
required_error: 'Mohon isi status DPJP',
doctor_id: z.number({
required_error: 'Mohon isi DPJP',
}),
controlDate: z.string({
encounter_id: z.number().optional(),
unit_id: z.number().optional(),
date: z.string({
required_error: 'Mohon lengkapi Tanggal Kontrol',
})
.refine(
+28
View File
@@ -0,0 +1,28 @@
// Base
import * as base from './_crud-base'
// Constants
import { encounterClassCodes } from '~/lib/constants'
const path = '/api/v1/control-letter'
const name = 'control-letter'
export function create(data: any) {
return base.create(path, data, name)
}
export function getList(params: any = null) {
return base.getList(path, params, name)
}
export function getDetail(id: number | string, params?: any) {
return base.getDetail(path, id, name, params)
}
export function update(id: number | string, data: any) {
return base.update(path, id, data, name)
}
export function remove(id: number | string) {
return base.remove(path, id, name)
}
+42
View File
@@ -0,0 +1,42 @@
// Base
import * as base from './_crud-base'
// Types
import type { Doctor } from '~/models/doctor'
const path = '/api/v1/doctor'
const name = 'doctor'
export function create(data: any) {
return base.create(path, data, name)
}
export function getList(params: any = null) {
return base.getList(path, params, name)
}
export function getDetail(id: number | string) {
return base.getDetail(path, id, name)
}
export function update(id: number | string, data: any) {
return base.update(path, id, data, name)
}
export function remove(id: number | string) {
return base.remove(path, id, name)
}
export async function getValueLabelList(params: any = null): Promise<{ value: string; label: string }[]> {
let data: { value: string; label: string }[] = []
const result = await getList(params)
if (result.success) {
const resultData = result.body?.data || []
data = resultData.map((item: Doctor) => ({
value: item.id ? Number(item.id) : item.id,
label: item.employee.person.name,
parent: item.unit_id ? Number(item.unit_id) : null,
}))
}
return data
}