Merge branch 'dev' into feat/procedure-room-order
This commit is contained in:
@@ -21,7 +21,7 @@ import {
|
||||
|
||||
// Apps
|
||||
import { getList, getDetail } from '~/services/mcu-order.service'
|
||||
import List from '~/components/app/mcu-order/list.vue'
|
||||
import List from '~/components/app/mcu-order/micro-list.vue'
|
||||
import type { McuOrder } from '~/models/mcu-order'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -55,7 +55,7 @@ const {
|
||||
})
|
||||
|
||||
const headerPrep: HeaderPrep = {
|
||||
title: 'Order Lab PK',
|
||||
title: 'Order Lab Mikro',
|
||||
icon: 'i-lucide-box',
|
||||
refSearchNav: {
|
||||
placeholder: 'Cari (min. 3 karakter)...',
|
||||
|
||||
@@ -63,7 +63,7 @@ const {
|
||||
fetchFn: async ({ page, search }) => {
|
||||
const result = await getList({
|
||||
'encounter-id': id,
|
||||
typeCode: 'dev-record',
|
||||
'type-code': 'dev-record',
|
||||
includes: 'encounter',
|
||||
search,
|
||||
page,
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
import { Button } from '~/components/pub/ui/button'
|
||||
import AppEncounterEntryForm from '~/components/app/encounter/entry-form.vue'
|
||||
import AppViewPatient from '~/components/app/patient/view-patient.vue'
|
||||
import AppViewHistory from '~/components/app/sep/view-history.vue'
|
||||
|
||||
// Helpers
|
||||
import { refDebounced } from '@vueuse/core'
|
||||
|
||||
// Handlers
|
||||
import { getDetail as getDoctorDetail } from '~/services/doctor.service'
|
||||
import { useEncounterEntry } from '~/handlers/encounter-entry.handler'
|
||||
import { genDoctor, type Doctor } from '~/models/doctor'
|
||||
import { useIntegrationSepEntry } from '~/handlers/integration-sep-entry.handler'
|
||||
|
||||
// Props
|
||||
const props = defineProps<{
|
||||
@@ -34,54 +34,33 @@ const {
|
||||
isLoadingDetail,
|
||||
formObjects,
|
||||
openPatient,
|
||||
isMemberValid,
|
||||
isSepValid,
|
||||
isCheckingSep,
|
||||
isSaveDisabled,
|
||||
isSaving,
|
||||
isLoading,
|
||||
patients,
|
||||
selectedDoctor,
|
||||
selectedPatient,
|
||||
selectedPatientObject,
|
||||
paginationMeta,
|
||||
toNavigateSep,
|
||||
getListPath,
|
||||
handleInit,
|
||||
loadEncounterDetail,
|
||||
getFetchEncounterDetail,
|
||||
handleSaveEncounter,
|
||||
getPatientsList,
|
||||
getPatientCurrent,
|
||||
getPatientByIdentifierSearch,
|
||||
getIsSubspecialist,
|
||||
// getIsSubspecialist,
|
||||
getDoctorInfo,
|
||||
getValidateMember,
|
||||
getValidateSepNumber,
|
||||
handleFetchDoctors,
|
||||
} = useEncounterEntry(props)
|
||||
const { recSepId, openHistory, histories, getMonitoringHistoryMappers } = useIntegrationSepEntry()
|
||||
|
||||
const debouncedSepNumber = refDebounced(sepNumber, 500)
|
||||
const selectedDoctor = ref<Doctor>(genDoctor())
|
||||
|
||||
provide('rec_select_id', recSelectId)
|
||||
provide('table_data_loader', isLoading)
|
||||
|
||||
watch(debouncedSepNumber, async (newValue) => {
|
||||
await getValidateSepNumber(newValue)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => formObjects.value?.paymentType,
|
||||
(newValue) => {
|
||||
isSepValid.value = false
|
||||
if (newValue !== 'jkn') {
|
||||
sepNumber.value = ''
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
await handleInit()
|
||||
if (props.id > 0) {
|
||||
await loadEncounterDetail()
|
||||
}
|
||||
})
|
||||
|
||||
///// Functions
|
||||
function handleSavePatient() {
|
||||
@@ -116,12 +95,28 @@ async function handleEvent(menu: string, value?: any) {
|
||||
}
|
||||
toNavigateSep({
|
||||
isService: 'false',
|
||||
encounterId: props.id || null,
|
||||
sourcePath: route.path,
|
||||
resource: `${props.classCode}-${props.subClassCode}`,
|
||||
...value,
|
||||
})
|
||||
} else if (menu === 'sep-number-changed') {
|
||||
await getValidateSepNumber(String(value || ''))
|
||||
const sepNumberText = String(value || '').trim()
|
||||
if (sepNumberText.length > 5) {
|
||||
await getValidateSepNumber(sepNumberText)
|
||||
}
|
||||
} else if (menu === 'member-changed') {
|
||||
const memberText = String(value || '').trim()
|
||||
if (memberText.length > 5) {
|
||||
await getValidateMember(memberText)
|
||||
}
|
||||
} else if (menu === 'search-sep') {
|
||||
const memberText = String(value?.cardNumber || '').trim()
|
||||
if (memberText.length < 5) return
|
||||
getMonitoringHistoryMappers(memberText).then(() => {
|
||||
openHistory.value = true
|
||||
})
|
||||
return
|
||||
} else if (menu === 'save') {
|
||||
await handleSaveEncounter(value)
|
||||
} else if (menu === 'cancel') {
|
||||
@@ -129,13 +124,39 @@ async function handleEvent(menu: string, value?: any) {
|
||||
}
|
||||
}
|
||||
|
||||
async function getDoctorInfo(value: string) {
|
||||
const resp = await getDoctorDetail(value, { includes: 'unit,specialist,subspecialist'})
|
||||
if (resp.success) {
|
||||
selectedDoctor.value = resp.body.data
|
||||
// console.log(selectedDoctor.value)
|
||||
provide('rec_select_id', recSelectId)
|
||||
provide('rec_sep_id', recSepId)
|
||||
provide('table_data_loader', isLoading)
|
||||
|
||||
watch(debouncedSepNumber, async (newValue) => {
|
||||
await getValidateSepNumber(newValue)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => formObjects.value?.paymentType,
|
||||
(newValue) => {
|
||||
isSepValid.value = false
|
||||
if (newValue !== 'jkn') {
|
||||
sepNumber.value = ''
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.id,
|
||||
async (newId) => {
|
||||
if (props.formType === 'edit' && newId > 0) {
|
||||
await getFetchEncounterDetail()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
await handleInit()
|
||||
if (props.formType === 'edit' && props.id > 0) {
|
||||
await getFetchEncounterDetail()
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -144,13 +165,15 @@ async function getDoctorInfo(value: string) {
|
||||
name="i-lucide-user"
|
||||
class="me-2"
|
||||
/>
|
||||
<span class="font-semibold">{{ props.formType }}</span>
|
||||
<span class="font-semibold">{{ props.formType === 'add' ? 'Tambah' : 'Ubah' }}</span>
|
||||
Kunjungan
|
||||
</div>
|
||||
|
||||
<AppEncounterEntryForm
|
||||
ref="formRef"
|
||||
:mode="props.formType"
|
||||
:is-loading="isLoadingDetail"
|
||||
:is-member-valid="isMemberValid"
|
||||
:is-sep-valid="isSepValid"
|
||||
:is-checking-sep="isCheckingSep"
|
||||
:payments="paymentsList"
|
||||
@@ -165,7 +188,6 @@ async function getDoctorInfo(value: string) {
|
||||
@event="handleEvent"
|
||||
@fetch="handleFetch"
|
||||
/>
|
||||
|
||||
<AppViewPatient
|
||||
v-model:open="openPatient"
|
||||
v-model:selected="selectedPatient"
|
||||
@@ -184,7 +206,11 @@ async function getDoctorInfo(value: string) {
|
||||
"
|
||||
@save="handleSavePatient"
|
||||
/>
|
||||
|
||||
<AppViewHistory
|
||||
v-model:open="openHistory"
|
||||
:is-action="true"
|
||||
:histories="histories"
|
||||
/>
|
||||
<!-- Footer Actions -->
|
||||
<div class="mt-6 flex justify-end gap-2 border-t border-t-slate-300 pt-4">
|
||||
<Button
|
||||
|
||||
@@ -34,8 +34,13 @@ const props = defineProps<{
|
||||
const { setOpen } = useSidebar()
|
||||
setOpen(true)
|
||||
|
||||
// Role reactivities
|
||||
const { getActiveRole } = useUserStore()
|
||||
|
||||
// Main data
|
||||
const data = ref([])
|
||||
const dataFiltered = ref([])
|
||||
const activeServicePosition = ref(getServicePosition(getActiveRole()))
|
||||
const isLoading = reactive<DataTableLoader>({
|
||||
summary: false,
|
||||
isTableLoading: false,
|
||||
@@ -87,29 +92,25 @@ const filter = ref<{
|
||||
schema: {},
|
||||
})
|
||||
|
||||
// Role reactivities
|
||||
const { getActiveRole } = useUserStore()
|
||||
const activeServicePosition = ref(getServicePosition(getActiveRole()))
|
||||
provide('activeServicePosition', activeServicePosition)
|
||||
watch(getActiveRole, (role? : string) => {
|
||||
activeServicePosition.value = getServicePosition(role)
|
||||
})
|
||||
|
||||
// Recrod reactivities
|
||||
provide('activeServicePosition', activeServicePosition)
|
||||
provide('rec_id', recId)
|
||||
provide('rec_action', recAction)
|
||||
provide('rec_item', recItem)
|
||||
provide('table_data_loader', isLoading)
|
||||
|
||||
watch(getActiveRole, (role? : string) => {
|
||||
activeServicePosition.value = getServicePosition(role)
|
||||
})
|
||||
|
||||
watch(() => recAction.value, () => {
|
||||
const basePath = `/${props.classCode}/encounter`
|
||||
// console.log(`${basePath}/${recId.value}`, recAction.value)
|
||||
// return
|
||||
if (recAction.value === ActionEvents.showConfirmDelete) {
|
||||
isRecordConfirmationOpen.value = true
|
||||
} else if (recAction.value === ActionEvents.showCancel) {
|
||||
isRecordCancelOpen.value = true
|
||||
} else if (recAction.value === ActionEvents.showDetail) {
|
||||
navigateTo(`${basePath}/${recId.value}`)
|
||||
navigateTo(`${basePath}/${recId.value}/detail`)
|
||||
} else if (recAction.value === ActionEvents.showEdit) {
|
||||
navigateTo(`${basePath}/${recId.value}/edit`)
|
||||
} else if (recAction.value === ActionEvents.showProcess) {
|
||||
@@ -138,6 +139,7 @@ async function getPatientList() {
|
||||
const result = await getEncounterList(params)
|
||||
if (result.success) {
|
||||
data.value = result.body?.data || []
|
||||
dataFiltered.value = [...data.value]
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching encounter list:', error)
|
||||
@@ -240,6 +242,7 @@ function handleRemoveConfirmation() {
|
||||
<template>
|
||||
<CH.ContentHeader v-bind="hreaderPrep">
|
||||
<FilterNav
|
||||
:active-positon="activeServicePosition"
|
||||
@onFilterClick="() => isFilterFormDialogOpen = true"
|
||||
@onExportPdf="() => {}"
|
||||
@onExportExcel="() => {}"
|
||||
@@ -261,6 +264,7 @@ function handleRemoveConfirmation() {
|
||||
|
||||
<!-- Batal -->
|
||||
<RecordConfirmation
|
||||
v-if="canDelete"
|
||||
v-model:open="isRecordCancelOpen"
|
||||
custom-title="Batalkan Kunjungan"
|
||||
custom-message="Apakah anda yakin ingin membatalkan kunjungan pasien berikut?"
|
||||
|
||||
@@ -16,8 +16,24 @@ import { genEncounter, type Encounter } from '~/models/encounter'
|
||||
// Handlers
|
||||
import type { EncounterProps } from '~/handlers/encounter-init.handler'
|
||||
import { getEncounterData } from '~/handlers/encounter-process.handler'
|
||||
import { getMenuItems } from "~/handlers/encounter-init.handler"
|
||||
import { getMenuItems } from '~/handlers/encounter-init.handler'
|
||||
|
||||
// PLASE ORDER BY TAB POSITION
|
||||
import Status from '~/components/content/encounter/status.vue'
|
||||
import AssesmentFunctionList from '~/components/content/soapi/entry.vue'
|
||||
import EarlyMedicalAssesmentList from '~/components/content/soapi/entry.vue'
|
||||
import EarlyMedicalRehabList from '~/components/content/soapi/entry.vue'
|
||||
import DeviceOrder from '~/components/content/device-order/main.vue'
|
||||
import Prescription from '~/components/content/prescription/main.vue'
|
||||
import CpLabOrder from '~/components/content/cp-lab-order/main.vue'
|
||||
import Radiology from '~/components/content/radiology-order/main.vue'
|
||||
import Consultation from '~/components/content/consultation/list.vue'
|
||||
import Cprj from '~/components/content/cprj/entry.vue'
|
||||
import DocUploadList from '~/components/content/document-upload/list.vue'
|
||||
import GeneralConsentList from '~/components/content/general-consent/entry.vue'
|
||||
import ResumeList from '~/components/content/resume/list.vue'
|
||||
import ControlLetterList from '~/components/content/control-letter/list.vue'
|
||||
import InitialNursingStudy from '~/components/content/initial-nursing/entry.vue'
|
||||
// App Components
|
||||
import EncounterPatientInfo from '~/components/app/encounter/quick-info.vue'
|
||||
import EncounterHistoryButtonMenu from '~/components/app/encounter/quick-shortcut.vue'
|
||||
@@ -51,7 +67,8 @@ const isShowPatient = computed(() => data.value && data.value?.patient?.person)
|
||||
const { setOpen } = useSidebar()
|
||||
setOpen(false)
|
||||
|
||||
if (activePosition.value === 'none') { // if user position is none, redirect to home page
|
||||
if (activePosition.value === 'none') {
|
||||
// if user position is none, redirect to home page
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
@@ -66,6 +83,45 @@ const protocolRows = [
|
||||
action: '',
|
||||
},
|
||||
{
|
||||
value: 'rehab-medical-assessment',
|
||||
label: 'Pengkajian Awal Medis Rehabilitasi Medis',
|
||||
component: EarlyMedicalRehabList,
|
||||
props: { encounter: data, type: 'early-rehab', label: 'Pengkajian Awal Medis Rehabilitasi Medis' },
|
||||
},
|
||||
{
|
||||
value: 'function-assessment',
|
||||
label: 'Asesmen Fungsi',
|
||||
component: AssesmentFunctionList,
|
||||
props: { encounter: data, type: 'function', label: 'Asesmen Fungsi' },
|
||||
},
|
||||
{ value: 'therapy-protocol', label: 'Protokol Terapi' },
|
||||
{ value: 'education-assessment', label: 'Asesmen Kebutuhan Edukasi' },
|
||||
{ value: 'patient-note', label: 'CPRJ', component: Cprj, props: { encounter: data } },
|
||||
{ value: 'consent', label: 'General Consent', component: GeneralConsentList, props: { encounter: data } },
|
||||
{
|
||||
value: 'initial-nursing-study',
|
||||
label: 'Kajian Awal Keperawatan',
|
||||
component: InitialNursingStudy,
|
||||
props: { encounter: data },
|
||||
},
|
||||
{ value: 'prescription', label: 'Order Obat', component: Prescription, props: { encounter_id: data.value.id } },
|
||||
{ value: 'device-order', label: 'Order Alkes', component: DeviceOrder, props: { encounter_id: data.value.id } },
|
||||
{ value: 'device', label: 'Order Alkes' },
|
||||
{ value: 'mcu-radiology', label: 'Order Radiologi', component: Radiology, props: { encounter_id: data.id } },
|
||||
{ value: 'mcu-lab-cp', label: 'Order Lab PK', component: CpLabOrder, props: { encounter_id: data.id } },
|
||||
{ value: 'mcu-lab-micro', label: 'Order Lab Mikro' },
|
||||
{ value: 'mcu-lab-pa', label: 'Order Lab PA' },
|
||||
{ value: 'medical-action', label: 'Order Ruang Tindakan' },
|
||||
{ value: 'mcu-result', label: 'Hasil Penunjang' },
|
||||
{ value: 'consultation', label: 'Konsultasi', component: Consultation, props: { encounter: data } },
|
||||
{ value: 'resume', label: 'Resume', component: ResumeList, props: { encounter: data } },
|
||||
{ value: 'control', label: 'Surat Kontrol', component: ControlLetterList, props: { encounter: data } },
|
||||
{ value: 'screening', label: 'Skrinning MPP' },
|
||||
{
|
||||
value: 'supporting-document',
|
||||
label: 'Upload Dokumen Pendukung',
|
||||
component: DocUploadList,
|
||||
props: { encounter: data },
|
||||
number: '2',
|
||||
tanggal: new Date().toISOString().substring(0, 10),
|
||||
siklus: 'II',
|
||||
@@ -103,13 +159,19 @@ function handleClick(type: string) {
|
||||
}
|
||||
|
||||
function initMenus() {
|
||||
menus.value = getMenuItems(id, props, user, {
|
||||
encounter: data.value
|
||||
} as any, {
|
||||
protocolTheraphy: paginationMeta,
|
||||
protocolChemotherapy: paginationMeta,
|
||||
medicineProtocolChemotherapy: paginationMeta,
|
||||
})
|
||||
menus.value = getMenuItems(
|
||||
id,
|
||||
props,
|
||||
user,
|
||||
{
|
||||
encounter: data.value,
|
||||
} as any,
|
||||
{
|
||||
protocolTheraphy: paginationMeta,
|
||||
protocolChemotherapy: paginationMeta,
|
||||
medicineProtocolChemotherapy: paginationMeta,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
async function getData() {
|
||||
@@ -119,24 +181,37 @@ async function getData() {
|
||||
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div class="bg-white dark:bg-slate-800 p-4 2xl:p-5">
|
||||
<div class="bg-white p-4 dark:bg-slate-800 2xl:p-5">
|
||||
<div class="mb-4 flex">
|
||||
<div>
|
||||
<ContentNavBa label="Kembali" @click="handleClick" />
|
||||
<ContentNavBa
|
||||
label="Kembali"
|
||||
@click="handleClick"
|
||||
/>
|
||||
</div>
|
||||
<!-- <div class="ms-auto pe-3 pt-1 text-end text-xl 2xl:text-2xl font-semibold">
|
||||
Pasien: {{ data.patient.person.name }} --- No. RM: {{ data.patient.number }}
|
||||
</div> -->
|
||||
</div>
|
||||
<ContentSwitcher :active="1" :height="150">
|
||||
<ContentSwitcher
|
||||
:active="1"
|
||||
:height="150"
|
||||
>
|
||||
<template v-slot:content1>
|
||||
<EncounterPatientInfo v-if="isShowPatient" :data="data" />
|
||||
<EncounterPatientInfo
|
||||
v-if="isShowPatient"
|
||||
:data="data"
|
||||
/>
|
||||
</template>
|
||||
<template v-slot:content2>
|
||||
<EncounterHistoryButtonMenu v-if="isShowPatient" />
|
||||
</template>
|
||||
</ContentSwitcher>
|
||||
</div>
|
||||
<SubMenu :data="menus" :initial-active-menu="activeMenu" @change-menu="activeMenu = $event" />
|
||||
<SubMenu
|
||||
:data="menus"
|
||||
:initial-active-menu="activeMenu"
|
||||
@change-menu="activeMenu = $event"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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,138 @@
|
||||
<script setup lang="ts">
|
||||
import { z } from 'zod'
|
||||
import Entry from '~/components/app/initial-nursing/entry-form.vue'
|
||||
import Action from '~/components/pub/my-ui/nav-footer/ba-dr-su.vue'
|
||||
import ActionDialog from '~/components/pub/my-ui/nav-footer/ba-su.vue'
|
||||
import Dialog from '~/components/pub/my-ui/modal/dialog.vue'
|
||||
import { InitialNursingSchema } 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 procedures = ref([])
|
||||
const diagnoses = ref([])
|
||||
const selectedProcedure = ref<any>(null)
|
||||
const selectedDiagnose = ref<any>(null)
|
||||
const schema = InitialNursingSchema
|
||||
const payload = ref({
|
||||
encounter_id: 0,
|
||||
time: '',
|
||||
typeCode: 'early-nursery',
|
||||
value: '',
|
||||
})
|
||||
const listProblem = ref([])
|
||||
|
||||
const model = ref({
|
||||
'pri-complain': '',
|
||||
'med-type': '',
|
||||
'med-name': '',
|
||||
'med-reaction': '',
|
||||
'food-type': '',
|
||||
'food-name': '',
|
||||
'food-reaction': '',
|
||||
'other-type': '',
|
||||
'other-name': '',
|
||||
'other-reaction': '',
|
||||
'pain-asst': '',
|
||||
'pain-scale': '',
|
||||
'pain-time': '',
|
||||
'pain-duration': '',
|
||||
'pain-freq': '',
|
||||
'pain-loc': '',
|
||||
'nut-screening': '',
|
||||
'spiritual-asst': '',
|
||||
'general-condition': '',
|
||||
'support-exam': '',
|
||||
'risk-fall': '',
|
||||
bracelet: '',
|
||||
'bracelet-alg': '',
|
||||
})
|
||||
|
||||
const isLoading = reactive<DataTableLoader>({
|
||||
isTableLoading: false,
|
||||
})
|
||||
|
||||
function handleOpen(event: any) {
|
||||
console.log('handleOpen', event.type)
|
||||
const type = event.type
|
||||
if (type === 'add-problem') {
|
||||
listProblem.value = event.data
|
||||
}
|
||||
}
|
||||
|
||||
const entryRehabRef = ref()
|
||||
async function actionHandler(type: string) {
|
||||
if (type === 'back') {
|
||||
backToList()
|
||||
return
|
||||
}
|
||||
const result = await entryRehabRef.value?.validate()
|
||||
if (result?.valid) {
|
||||
if (listProblem.value?.length > 0) {
|
||||
result.data.listProblem = listProblem.value || []
|
||||
}
|
||||
console.log('data', result.data)
|
||||
handleActionSave(
|
||||
{
|
||||
...payload.value,
|
||||
value: JSON.stringify(result.data),
|
||||
encounter_id: +route.params.id,
|
||||
time: new Date().toISOString(),
|
||||
},
|
||||
() => {},
|
||||
() => {},
|
||||
toast,
|
||||
)
|
||||
|
||||
backToList()
|
||||
} 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 || []
|
||||
}
|
||||
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="early-rehab"
|
||||
@click="handleOpen"
|
||||
/>
|
||||
<div class="my-2 flex justify-end py-2">
|
||||
<Action @click="actionHandler" />
|
||||
</div>
|
||||
<Dialog
|
||||
v-model:open="isOpenDiagnose"
|
||||
title="Pilih Fungsional"
|
||||
size="xl"
|
||||
prevent-outside
|
||||
>
|
||||
<AppIcdMultiselectPicker
|
||||
v-model:model-value="selectedDiagnose"
|
||||
:data="diagnoses"
|
||||
/>
|
||||
<div class="my-2 flex justify-end py-2">
|
||||
<ActionDialog @click="actionDialogHandler" />
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,210 @@
|
||||
<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/initial-nursing/list.vue'
|
||||
import Preview from '~/components/app/initial-nursing/preview.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'
|
||||
|
||||
// Handlers
|
||||
import {
|
||||
recId,
|
||||
recAction,
|
||||
recItem,
|
||||
isReadonly,
|
||||
isProcessing,
|
||||
isFormEntryDialogOpen,
|
||||
isRecordConfirmationOpen,
|
||||
onResetState,
|
||||
handleActionSave,
|
||||
handleActionEdit,
|
||||
handleActionRemove,
|
||||
handleCancelForm,
|
||||
} from '~/handlers/soapi-early.handler'
|
||||
|
||||
// Services
|
||||
import { getList, getDetail } from '~/services/soapi-early.service'
|
||||
|
||||
// Models
|
||||
import type { Encounter } from '~/models/encounter'
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
encounter: Encounter
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
const emits = defineEmits(['add', 'edit'])
|
||||
const route = useRoute()
|
||||
|
||||
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 id = route.params.id
|
||||
const descData = ref({})
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
paginationMeta,
|
||||
searchInput,
|
||||
handlePageChange,
|
||||
handleSearch,
|
||||
fetchData: getMyList,
|
||||
} = usePaginatedList({
|
||||
fetchFn: async ({ page, search }) => {
|
||||
const result = await getList({
|
||||
'encounter-id': id,
|
||||
'type-code': 'early-nursery',
|
||||
includes: 'encounter',
|
||||
search,
|
||||
page,
|
||||
})
|
||||
console.log('masukkk', result)
|
||||
if (result.success) {
|
||||
data.value = result.body.data
|
||||
}
|
||||
return { success: result.success || false, body: result.body || {} }
|
||||
},
|
||||
entityName: 'initial-nursing',
|
||||
})
|
||||
|
||||
const headerPrep: HeaderPrep = {
|
||||
title: 'Kajian Awal Keperawatan',
|
||||
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')
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
const mappedData = computed(() => {
|
||||
if (!data.value || data.value.length === 0) return []
|
||||
|
||||
const raw = data.value[0]
|
||||
|
||||
// Pastikan raw.value adalah string JSON
|
||||
let parsed: any = {}
|
||||
try {
|
||||
parsed = JSON.parse(raw.value || '{}')
|
||||
} catch (err) {
|
||||
console.error('JSON parse error:', err)
|
||||
return []
|
||||
}
|
||||
|
||||
// Ambil listProblem
|
||||
const list = parsed.listProblem || []
|
||||
const textData = parsed
|
||||
|
||||
// Untuk keamanan: pastikan selalu array
|
||||
if (!Array.isArray(list)) return []
|
||||
|
||||
return { list, textData }
|
||||
})
|
||||
|
||||
// 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:
|
||||
emits('edit')
|
||||
recordId.value = recId.value
|
||||
console.log('recordId', recId.value)
|
||||
break
|
||||
case ActionEvents.showConfirmDelete:
|
||||
isRecordConfirmationOpen.value = true
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await getMyList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Header
|
||||
v-model="searchInput"
|
||||
:prep="headerPrep"
|
||||
:ref-search-nav="headerPrep.refSearchNav"
|
||||
@search="handleSearch"
|
||||
class="mb-4 xl:mb-5"
|
||||
/>
|
||||
|
||||
<Preview :preview="mappedData.textData" />
|
||||
|
||||
<h2 class="my-3 p-1 font-semibold">C. Daftar Masalah Keperawatan</h2>
|
||||
<List :data="mappedData.list || []" />
|
||||
<!-- :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>
|
||||
@@ -54,7 +54,7 @@ const {
|
||||
page,
|
||||
'scope-code': props.scopeCode,
|
||||
'encounter-id': encounter_id,
|
||||
includes: 'doctor,doctor-employee,doctor-employee-person',
|
||||
includes: 'doctor,doctor-employee,doctor-employee-person,items,items-mcuSrc',
|
||||
})
|
||||
return { success: result.success || false, body: result.body || {} }
|
||||
},
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import Entry from '~/components/content/mcu-order/entry.vue'
|
||||
|
||||
defineProps<{
|
||||
encounter_id: number
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Entry :encounter_id="encounter_id" scope-code="micro-lab" />
|
||||
</template>
|
||||
@@ -0,0 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import List from '~/components/content/mcu-order/list.vue'
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<List scope-code="micro-lab" />
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
//
|
||||
import List from './list.vue'
|
||||
import Entry from './entry.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
encounter_id: number
|
||||
}>()
|
||||
|
||||
const { mode } = useQueryCRUDMode()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<List v-if="mode === 'list'" :encounter_id="encounter_id" />
|
||||
<Entry v-else :encounter_id="encounter_id" />
|
||||
</template>
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
recAction,
|
||||
recItem,
|
||||
isReadonly,
|
||||
isFormEntryDialogOpen,
|
||||
isRecordConfirmationOpen,
|
||||
handleActionSave,
|
||||
handleActionRemove,
|
||||
@@ -28,6 +27,7 @@ const route = useRoute()
|
||||
const { setQueryParams } = useQueryParam()
|
||||
|
||||
const title = ref('')
|
||||
const addTrigger = ref(false)
|
||||
|
||||
const plainEid = route.params.id
|
||||
const encounter_id = (plainEid && typeof plainEid == 'string') ? parseInt(plainEid) : 0 // here the
|
||||
@@ -74,7 +74,7 @@ const headerPrep: HeaderPrep = {
|
||||
onClick: () => {
|
||||
recItem.value = null
|
||||
recId.value = 0
|
||||
isFormEntryDialogOpen.value = true
|
||||
addTrigger.value = true
|
||||
isReadonly.value = false
|
||||
},
|
||||
},
|
||||
@@ -90,7 +90,7 @@ const getMyDetail = async (id: number | string) => {
|
||||
if (result.success) {
|
||||
const currentValue = result.body?.data || {}
|
||||
recItem.value = currentValue
|
||||
isFormEntryDialogOpen.value = true
|
||||
addTrigger.value = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,10 +113,10 @@ watch([recId, recAction], () => {
|
||||
}
|
||||
})
|
||||
|
||||
watch([isFormEntryDialogOpen], async () => {
|
||||
if (isFormEntryDialogOpen.value) {
|
||||
isFormEntryDialogOpen.value = false;
|
||||
const saveResp = await handleActionSave({ encounter_id }, getMyList, () =>{}, toast)
|
||||
watch([addTrigger], async () => {
|
||||
if (addTrigger.value) {
|
||||
addTrigger.value = false;
|
||||
const saveResp = await handleActionSave({ encounter_id, "scope_code": "rad" }, getMyList, () =>{}, toast)
|
||||
if (saveResp.success) {
|
||||
setQueryParams({
|
||||
'mode': 'entry',
|
||||
|
||||
@@ -8,7 +8,8 @@ import AppViewHistory from '~/components/app/sep/view-history.vue'
|
||||
import AppViewLetter from '~/components/app/sep/view-letter.vue'
|
||||
|
||||
// Handler
|
||||
import useIntegrationSepEntry from '~/handlers/integration-sep-entry.handler'
|
||||
import { useIntegrationSepEntry } from '~/handlers/integration-sep-entry.handler'
|
||||
import { useIntegrationSepDetail } from '~/handlers/integration-sep-detail.handler'
|
||||
|
||||
const {
|
||||
histories,
|
||||
@@ -55,8 +56,18 @@ const {
|
||||
handleInit,
|
||||
} = useIntegrationSepEntry()
|
||||
|
||||
const { valueObjects, getSepDetail } = useIntegrationSepDetail()
|
||||
|
||||
const props = defineProps<{
|
||||
mode: 'add' | 'edit' | 'detail' | 'link'
|
||||
}>()
|
||||
|
||||
onMounted(async () => {
|
||||
await handleInit()
|
||||
if (['detail', 'link'].includes(props.mode)) {
|
||||
await getSepDetail()
|
||||
selectedObjects.value = { ...selectedObjects.value, ...valueObjects.value }
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -66,12 +77,13 @@ onMounted(async () => {
|
||||
name="i-lucide-panel-bottom"
|
||||
class="me-2"
|
||||
/>
|
||||
<span class="font-semibold">Tambah</span>
|
||||
SEP
|
||||
<span class="font-semibold">{{ ['detail', 'link'].includes(props.mode) ? 'Detail' : 'Tambah' }} SEP</span>
|
||||
</div>
|
||||
<AppSepEntryForm
|
||||
:mode="props.mode"
|
||||
:is-save-loading="isSaveLoading"
|
||||
:is-service="isServiceHidden"
|
||||
:is-readonly="['detail', 'link'].includes(props.mode)"
|
||||
:doctors="doctors"
|
||||
:diagnoses="diagnoses"
|
||||
:facilities-from="facilitiesFrom"
|
||||
|
||||
@@ -13,8 +13,11 @@ import RangeCalendar from '~/components/pub/ui/range-calendar/RangeCalendar.vue'
|
||||
// Icons
|
||||
import { X, Check } from 'lucide-vue-next'
|
||||
|
||||
// Libraries
|
||||
import useIntegrationSepList from '~/handlers/integration-sep-list.handler'
|
||||
// Handlers
|
||||
import { useIntegrationSepList } from '~/handlers/integration-sep-list.handler'
|
||||
|
||||
// Helpers
|
||||
import { refDebounced } from '@vueuse/core'
|
||||
|
||||
// use handler to provide state and functions
|
||||
const {
|
||||
@@ -42,26 +45,42 @@ const {
|
||||
handleRemove,
|
||||
} = useIntegrationSepList()
|
||||
|
||||
const dataFiltered = ref<any>([])
|
||||
const debouncedSearch = refDebounced(search, 500)
|
||||
|
||||
// expose provides so component can also use provide/inject if needed
|
||||
provide('rec_id', recId)
|
||||
provide('rec_action', recAction)
|
||||
provide('rec_item', recItem)
|
||||
provide('table_data_loader', isLoading)
|
||||
|
||||
watch(
|
||||
[recId, recAction],
|
||||
() => {
|
||||
if (recAction.value === 'showConfirmDel') {
|
||||
open.value = true
|
||||
}
|
||||
},
|
||||
)
|
||||
watch([recId, recAction], () => {
|
||||
if (recAction.value === 'showConfirmDel') {
|
||||
open.value = true
|
||||
}
|
||||
if (recAction.value === 'showDetail') {
|
||||
navigateTo(`/integration/bpjs-vclaim/sep/${recItem.value?.letterNumber}/detail`)
|
||||
}
|
||||
})
|
||||
|
||||
watch(debouncedSearch, (newValue) => {
|
||||
if (newValue && newValue !== '-' && newValue.length >= 3) {
|
||||
dataFiltered.value = data.value.filter(
|
||||
(item: any) =>
|
||||
item.patientName.toLowerCase().includes(newValue.toLowerCase()) ||
|
||||
item.letterNumber.toLowerCase().includes(newValue.toLowerCase()) ||
|
||||
item.cardNumber.toLowerCase().includes(newValue.toLowerCase()),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => dateSelection.value,
|
||||
(val) => {
|
||||
async (val) => {
|
||||
if (!val) return
|
||||
setDateRange()
|
||||
await getSepList()
|
||||
dataFiltered.value = [...data.value]
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
@@ -73,9 +92,10 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
setServiceTypes()
|
||||
getSepList()
|
||||
await getSepList()
|
||||
dataFiltered.value = [...data.value]
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -85,20 +105,35 @@ onMounted(() => {
|
||||
<!-- Filter Bar -->
|
||||
<div class="my-2 flex flex-wrap items-center gap-2">
|
||||
<!-- Search -->
|
||||
<Input v-model="search" placeholder="Cari No. SEP / No. Kartu BPJS..." class="w-72" />
|
||||
<Input
|
||||
v-model="search"
|
||||
placeholder="Cari No. SEP / No. Kartu BPJS..."
|
||||
class="w-72"
|
||||
/>
|
||||
|
||||
<!-- Filter -->
|
||||
<div class="w-72">
|
||||
<Select id="serviceType" icon-name="i-lucide-chevron-down" v-model="serviceType" :items="serviceTypesList"
|
||||
placeholder="Pilih Pelayanan" />
|
||||
<Select
|
||||
id="serviceType"
|
||||
icon-name="i-lucide-chevron-down"
|
||||
v-model="serviceType"
|
||||
:items="serviceTypesList"
|
||||
placeholder="Pilih Pelayanan"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Date Range -->
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="outline" class="h-[40px] w-72 border-gray-400 bg-white text-right font-normal">
|
||||
<Button
|
||||
variant="outline"
|
||||
class="h-[40px] w-72 border-gray-400 bg-white text-right font-normal"
|
||||
>
|
||||
{{ dateRange }}
|
||||
<Icon name="i-lucide-calendar" class="h-5 w-5" />
|
||||
<Icon
|
||||
name="i-lucide-calendar"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="p-2">
|
||||
@@ -109,9 +144,14 @@ onMounted(() => {
|
||||
<!-- Export -->
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="outline"
|
||||
class="ml-auto h-[40px] w-[120px] rounded-md border-green-600 text-green-600 hover:bg-green-50">
|
||||
<Icon name="i-lucide-download" class="h-5 w-5" />
|
||||
<Button
|
||||
variant="outline"
|
||||
class="ml-auto h-[40px] w-[120px] rounded-md border-green-600 text-green-600 hover:bg-green-50"
|
||||
>
|
||||
<Icon
|
||||
name="i-lucide-download"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
Ekspor
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -123,13 +163,20 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<AppSepList v-if="!isLoading.dataListLoading" :data="data" @update:modelValue="handleRowSelected" />
|
||||
<AppSepList
|
||||
v-if="!isLoading.dataListLoading"
|
||||
:data="dataFiltered"
|
||||
@update:modelValue="handleRowSelected"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<template v-if="paginationMeta">
|
||||
<div v-if="paginationMeta.totalPage > 1">
|
||||
<PubMyUiPagination :pagination-meta="paginationMeta" @page-change="handlePageChange" />
|
||||
<PubMyUiPagination
|
||||
:pagination-meta="paginationMeta"
|
||||
@page-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -142,20 +189,39 @@ onMounted(() => {
|
||||
</DialogHeader>
|
||||
<DialogDescription class="text-gray-700">Apakah anda yakin ingin menghapus SEP dengan data:</DialogDescription>
|
||||
<div class="mt-4 space-y-2 text-sm">
|
||||
<p><strong>No. SEP:</strong> {{ sepData.sepNumber }}</p>
|
||||
<p><strong>No. Kartu BPJS:</strong> {{ sepData.cardNumber }}</p>
|
||||
<p><strong>Nama Pasien:</strong> {{ sepData.patientName }}</p>
|
||||
<p>
|
||||
<strong>No. SEP:</strong>
|
||||
{{ sepData.sepNumber }}
|
||||
</p>
|
||||
<p>
|
||||
<strong>No. Kartu BPJS:</strong>
|
||||
{{ sepData.cardNumber }}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Nama Pasien:</strong>
|
||||
{{ sepData.patientName }}
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter class="mt-6 flex justify-end gap-3">
|
||||
<Button variant="outline" class="border-green-600 text-green-600 hover:bg-green-50" @click="() => {
|
||||
recId = 0
|
||||
recAction = ''
|
||||
open = false
|
||||
}">
|
||||
<Button
|
||||
variant="outline"
|
||||
class="border-green-600 text-green-600 hover:bg-green-50"
|
||||
@click="
|
||||
() => {
|
||||
recId = 0
|
||||
recAction = ''
|
||||
open = false
|
||||
}
|
||||
"
|
||||
>
|
||||
<X class="mr-1 h-4 w-4" />
|
||||
Tidak
|
||||
</Button>
|
||||
<Button variant="destructive" class="bg-red-600 hover:bg-red-700" @click="handleRemove">
|
||||
<Button
|
||||
variant="destructive"
|
||||
class="bg-red-600 hover:bg-red-700"
|
||||
@click="handleRemove"
|
||||
>
|
||||
<Check class="mr-1 h-4 w-4" />
|
||||
Ya
|
||||
</Button>
|
||||
|
||||
@@ -71,7 +71,7 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
async function getMyList() {
|
||||
const url = `/api/v1/soapi?typeCode=${typeCode.value}&includes=encounter,employee&encounter-id=${route.params.id}`
|
||||
const url = `/api/v1/soapi?type-code=${typeCode.value}&includes=encounter,employee&encounter-id=${route.params.id}`
|
||||
const resp = await xfetch(url)
|
||||
if (resp.success) {
|
||||
data.value = (resp.body as Record<string, any>).data
|
||||
|
||||
Reference in New Issue
Block a user