Files
2026-06-23 13:28:38 +07:00

481 lines
12 KiB
Vue

<script setup>
import Waveform from '@/components/Waveform.vue'
import axiosIns from '@/plugins/axios'
import { showToast, showToastCenter } from '@/plugins/toast'
import { useAuthStore } from '@/stores/auth'
import uDiagnose from '@/utils/diagnose'
import { requiredValidator } from '@validators'
import { Dropzone } from 'dropzone'
import { useRoute, useRouter } from 'vue-router'
import { VDataTable } from 'vuetify/labs/VDataTable'
const authStore = useAuthStore()
const route = useRoute()
const router = useRouter()
const search = ref('')
const patient = ref({})
const diagnoses = ref([])
const searchedDiagnoses = computed(() => {
return diagnoses.value.filter(diagnose => {
return Object.values(diagnose).some(value => {
return String(value).toLowerCase().includes(search.value.toLowerCase())
})
})
})
const editDialog = ref(false)
const defaultItem = ref({
id: '',
diagnoses: '',
notes: '',
is_verified: false,
file: '',
})
const editedItem = ref(defaultItem.value)
const editedIndex = ref(-1)
const refForm = ref()
const loadingEdit = ref(false)
const loadingPredict = ref(false)
const close = () => {
editDialog.value = false
editedIndex.value = -1
editedItem.value = { ...defaultItem.value }
refForm.value.reset()
}
const editItemShow = item => {
editedIndex.value = diagnoses.value.indexOf(item)
editedItem.value = {
...item,
}
editDialog.value = true
}
const save = async () => {
const validation = await refForm.value.validate()
if (!validation.valid) return
loadingEdit.value = true
if (editedIndex.value > -1) {
await axiosIns.put(`/api/v1/patient/diagnoses/${editedItem.value.id}`, {
diagnoses: editedItem.value.diagnoses,
notes: editedItem.value.notes,
is_verified: editedItem.value.is_verified,
})
.then (response => {
showToast(response.data.message, 'success')
})
.catch(error => {
showToast(error.response.data.error, 'error')
})
editedItem.value.updated_at = new Date()
Object.assign(diagnoses.value[editedIndex.value], editedItem.value)
loadDiagnoses()
}
loadingEdit.value = false
close()
}
const repredict = async () => {
loadingPredict.value = true
const formData = new FormData()
const fileData = await axiosIns.get('https://apicta-s3-bucket.s3.ap-southeast-1.amazonaws.com/' + editedItem.value.file, {
responseType: 'blob',
transformRequest: [(data, headers) => {
delete headers.Authorization
return data
}],
}).then(response => {
return new File([response.data], 'audio.wav', { type: 'audio/wav' })
}).catch(error => {
showToastCenter('Cannot get audio file', 'error')
})
if (!fileData) {
loadingPredict.value = false
return
}
formData.append('file', fileData)
formData.append('patient_id', patient.value.id)
await axiosIns.post('/api/v1/public/predict', formData)
.then (response => {
const newDiagnose = response.data.data.result == 0 ? 'normal' : 'mi'
if (newDiagnose == editedItem.value.diagnoses) {
showToastCenter('No changes', 'info')
} else {
showToastCenter('Diagnoses changed to ' + uDiagnose.text(newDiagnose), 'success')
}
editedItem.value.diagnoses = newDiagnose
editedItem.value.is_verified = false
})
.catch(error => {
showToastCenter(error.response.data.error, 'error')
})
loadingPredict.value = false
}
const details = [
{
icon: 'tabler-user',
title: 'Fullname',
key: 'fullname',
},
{
icon: 'tabler-address-book',
title: 'Address',
key: 'address',
},
{
icon: 'tabler-phone',
title: 'Phone',
key: 'phone',
},
{
icon: 'tabler-phone',
title: 'Emergency Phone',
key: 'emergency_phone',
},
{
icon: 'tabler-gender-bigender',
title: 'Gender',
key: 'gender',
},
{
icon: 'tabler-123',
title: 'Age',
key: 'age',
},
{
icon: 'tabler-activity-heartbeat',
title: 'Condition',
key: 'condition',
},
]
const headers = [
{ title: 'Updated', key: 'updated_at' },
{ title: 'Notes', key: 'notes' },
{ title: 'Diagnoses', key: 'diagnoses' },
{ title: 'Verification', key: 'is_verified' },
{ title: 'Actions', key: 'actions', sortable: false },
]
const sortBy = ref([{ key: 'updated_at', order: 'desc' }])
const loading = ref(false)
async function loadDiagnoses() {
loading.value = true
await axiosIns.get(`/api/v1/doctor/patients/${route.params.id}`)
.then(response => {
patient.value = response.data.data
diagnoses.value = response.data.data.diagnoses?.map(item => ({
...item,
is_verified: item.is_verified == 1,
}))
})
.catch(error => {
console.log(error)
})
loading.value = false
}
onMounted(async () => {
await loadDiagnoses()
if (route.query?.diagnoses_id) {
const diagnosesId = route.query.diagnoses_id
const item = diagnoses.value.find(item => item.id === diagnosesId)
if (item) {
editItemShow(item)
} else {
router.replace({ query: null })
}
}
})
function formatTimestamp(timestamp) {
const date = new Date(timestamp)
return date.toLocaleString(navigator.language)
}
// DROPZONE:START
const dropRef = ref(null)
Dropzone.autoDiscover = false
onMounted(() => {
if(dropRef.value !== null) {
new Dropzone(dropRef.value, {
url: '/api/v1/doctor/predict',
acceptedFiles: "audio/*",
headers: {
'Authorization': `Bearer ${authStore.access_token}`,
},
init: function() {
this.on('sending', function(file, xhr, formData) {
if (patient?.value?.id) {
formData.append('patient_id', patient?.value?.id)
} else {
xhr.abort()
showToast('Authenticating failed, please try again later', 'error')
}
})
this.on('success', function(file, response) {
this.removeFile(file)
loadDiagnoses()
})
this.on('error', function(file, response) {
this.removeFile(file)
showToast(response?.message, 'error')
})
},
})
}
})
// DROPZONE:END
</script>
<template>
<div>
<VRow class="match-height">
<VCol cols="3">
<VCard
title="Patient Details"
density="compact"
:loading="loading"
>
<VCardText>
<VList class="text-medium-emphasis">
<VListItem
v-for="detail in details"
:key="detail.key"
density="compact"
>
<template #prepend>
<VIcon
:icon="detail.icon"
size="32"
start
/>
</template>
<VListItemTitle>
<span class="font-weight-light me-1">{{ detail.title }}</span>
</VListItemTitle>
<VListItemMedia>
<span class="font-weight-medium me-1">{{ patient[detail.key] }}</span>
</VListItemMedia>
</VListItem>
</VList>
</VCardText>
<VCardActions>
<VBtn
color="primary"
variant="elevated"
block
@click="router.back"
>
Back
</VBtn>
</VCardActions>
</VCard>
</VCol>
<VCol cols="9">
<VCard
title="Diagnoses History"
density="compact"
:loading="loading"
>
<VCardText>
<AppTextField
v-model="search"
prepend-inner-icon="tabler-search"
:append-inner-icon="search.length > 0 ? 'tabler-x' : null"
placeholder="Search"
class="mb-2"
@click:append-inner="search=''"
/>
<VDataTable
v-model:sort-by="sortBy"
:headers="headers"
:items="searchedDiagnoses"
:items-per-page="10"
density="compact"
>
<template #item.updated_at="{ item }">
{{ formatTimestamp(item.raw.updated_at) }}
</template>
<template #item.diagnoses="{ item }">
<VChip
:color="uDiagnose.color(item.raw.diagnoses)"
label
variant="elevated"
>
{{ uDiagnose.text(item.raw.diagnoses) }}
</VChip>
</template>
<template #item.is_verified="{ item }">
<VChip
:color="item.raw.is_verified ? 'success' : 'error'"
label
variant="elevated"
>
{{ item.raw.is_verified ? 'Verified' : 'Not Verified' }}
</VChip>
</template>
<template #item.actions="{ item }">
<div class="d-flex gap-1">
<IconBtn @click="editItemShow(item.raw)">
<VIcon
icon="mdi-pencil-outline"
color="primary"
/>
</IconBtn>
</div>
</template>
</VDataTable>
</VCardText>
</VCard>
</VCol>
<VCol cols="12">
<VCard title="Upload Audio">
<VCardText density="compact">
<div
ref="dropRef"
class="dropzone custom-dropzone mt-2"
/>
</VCardText>
</VCard>
</VCol>
</VRow>
<!-- DIALOG EDIT -->
<VDialog
v-model="editDialog"
max-width="600px"
persistent
>
<VCard>
<VForm
ref="refForm"
@submit.prevent
>
<VCardTitle>
<span class="headline">
Edit Diagnoses
</span>
</VCardTitle>
<VCardText>
<VContainer>
<VRow>
<VCol cols="12">
<Waveform :url="'https://apicta-s3-bucket.s3.ap-southeast-1.amazonaws.com/' + editedItem.file" />
</VCol>
<VCol cols="12">
<AppTextarea
v-model="editedItem.notes"
label="Notes"
rows="2"
auto-grow
:rules="[requiredValidator]"
/>
</VCol>
<VCol cols="6">
<AppSelect
v-model="editedItem.diagnoses"
:items="['normal', 'mi']"
label="Diagnose"
density="compact"
:rules="[requiredValidator]"
/>
</VCol>
<VCol cols="6">
<VLabel class="text-body-2">
Verification
</VLabel>
<VSwitch
v-model="editedItem.is_verified"
color="success"
:inset="false"
:label="editedItem.is_verified ? 'Verified' : 'Not Verified'"
/>
</VCol>
</VRow>
</VContainer>
</VCardText>
<VCardActions>
<VBtn
color="primary"
variant="tonal"
:loading="loadingPredict"
:disabled="loadingPredict || loadingEdit"
@click="repredict"
>
Re-Predict
</VBtn>
<VSpacer />
<VBtn
color="primary"
variant="outlined"
:disabled="loadingPredict || loadingEdit"
@click="close"
>
Cancel
</VBtn>
<VBtn
color="success"
variant="elevated"
:loading="loadingEdit"
:disabled="loadingPredict || loadingEdit"
@click="save"
>
Save
</VBtn>
</VCardActions>
</VForm>
</VCard>
</VDialog>
</div>
</template>
<style>
.custom-dropzone {
border-style: dashed;
border-width: 2px;
padding: 16px;
border-radius: 4px;
color:rgb(114, 114, 114);
text-align: center;
}
</style>
<route lang="yaml">
meta:
requiresAuth: true
allowedRoles: ['doctor']
</route>