Merge pull request #197 from dikstub-rssa/feat/laporan-tindakan-185

feat(treatment-report): ui & crud done
This commit is contained in:
Munawwirul Jamal
2025-12-08 11:29:30 +07:00
committed by GitHub
52 changed files with 3034 additions and 146 deletions
@@ -0,0 +1,468 @@
<script setup lang="ts">
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { type Duration, intervalToDuration } from 'date-fns'
// schema
import { type ActionReportFormData, ActionReportSchema } from '~/schemas/action-report.schema'
// type
import type { Doctor } from '~/models/doctor'
import { type ClickType as ActionClickType } from '~/components/pub/my-ui/nav-footer/index'
// components
import * as DE from '~/components/pub/my-ui/doc-entry'
import Separator from '~/components/pub/ui/separator/Separator.vue'
import { ArrayMessage } from '~/components/pub/ui/form'
import ActionForm from '~/components/pub/my-ui/nav-footer/ba-dr-su.vue'
// form field components
import {
FillNotes,
RadioBloods,
SelectBilling,
SelectBirthPlace,
SelectBirthType,
SelectOperationSystem,
SelectOperationType,
SelectSpecimen,
SelectSurgeryCounter,
SelectSurgeryType,
} from './fields'
import { ButtonAction, Fragment, InputBase, TextAreaInput } from '~/components/pub/my-ui/form/'
import { SelectDoctor } from '~/components/app/doctor/fields'
// Helpers
// #region Props & Emits
interface FormData extends ActionReportFormData {
_operationDuration: string
_anesthesiaDuration: string
}
interface Props {
isLoading: boolean
mode: 'add' | 'edit' | 'view'
initialValues?: Partial<FormData>
// form related
doctors: Doctor[]
}
const props = defineProps<Props>()
const emit = defineEmits<{
(e: 'submit', payload: FormData): void
(e: 'back'): void
(e: 'error', errors: Error): void
}>()
const tissueNotesLimit = 5
const mode = toRef(props, 'mode')
const isLoading = toRef(props, 'isLoading')
const isReadonly = computed(() => {
if (isLoading.value === true) {
return true
}
if (mode.value === 'view') {
return true
}
return false
})
const formSchema = toTypedSchema(ActionReportSchema)
const { errors, handleSubmit, values, meta, resetForm, setFieldValue, setValues, validate } = useForm<FormData>({
name: 'encounterActionReportForm',
validationSchema: formSchema,
initialValues: props.initialValues ? props.initialValues : {},
validateOnMount: false,
})
defineExpose({
validate,
resetForm,
setValues,
values,
})
// #endregion
// #region State & Computed
// #endregion
// #region Lifecycle Hooks
// #endregion
// #region Functions
// #endregion region
// #region Utilities & event handlers
const onFormActionClicked = (action: ActionClickType) => {
if (action === 'back') {
emit('back')
return
}
if (action === 'submit') {
onSubmit()
return
}
}
const onSubmit = handleSubmit(
(values) => {
console.log(JSON.stringify(values))
emit('submit', values)
},
(errors) => {
console.error(errors)
emit('error', new Error('Silahkan lengkapi form terlebih dahulu'))
},
)
// #endregion
// #region Watcher
watch(
() => [values.operationExecution.operationStartAt, values.operationExecution.operationEndAt],
([start, end]) => {
if (!start || !end) return
const pStart = new Date(start)
const pEnd = new Date(end)
const formatTime = (r: Duration) =>
[r.hours && `${r.hours} jam`, r.minutes && `${r.minutes} menit`, r.seconds && `${r.seconds} detik`]
.filter(Boolean)
.join(' ')
const res = intervalToDuration({
start: pStart,
end: pEnd,
})
setFieldValue('_operationDuration', formatTime(res))
},
{ immediate: true },
)
watch(
() => [values.operationExecution.anesthesiaStartAt, values.operationExecution.anesthesiaEndAt],
([start, end]) => {
if (!start || !end) return
const pStart = new Date(start)
const pEnd = new Date(end)
const formatTime = (r: Duration) =>
[r.hours && `${r.hours} jam`, r.minutes && `${r.minutes} menit`, r.seconds && `${r.seconds} detik`]
.filter(Boolean)
.join(' ')
const res = intervalToDuration({
start: pStart,
end: pEnd,
})
setFieldValue('_anesthesiaDuration', formatTime(res))
},
{ immediate: true },
)
// #endregion
</script>
<template>
<form @submit.prevent="onSubmit">
<Fragment
v-slot="{ section }"
title="Tim Pelaksana Tindakan"
>
<p class="text-lg font-semibold">{{ section }}</p>
<DE.Block
:col-count="4"
:cell-flex="false"
>
<SelectDoctor
fieldName="operatorTeam.dpjpId"
label="Dokter Pemeriksa"
placeholder="Pilih dokter"
:doctors="doctors"
:is-disabled="isReadonly"
/>
<InputBase
field-name="operatorTeam.operatorName"
label="Operator"
placeholder="Masukkan operator"
:is-disabled="isReadonly"
/>
<InputBase
field-name="operatorTeam.assistantOperatorName"
label="Asisten Operator"
placeholder="Masukkan asisten operator"
:is-disabled="isReadonly"
/>
<InputBase
field-name="operatorTeam.instrumentNurseName"
label="Instrumentir"
placeholder="Masukkan instrumentir"
:is-disabled="isReadonly"
/>
<InputBase
field-name="operatorTeam.surgeryDate"
label="Tanggal Pembedahan"
input-type="datetime-local"
:is-disabled="isReadonly"
placeholder=""
/>
</DE.Block>
<DE.Block
:col-count="4"
:cell-flex="false"
>
<TextAreaInput
field-name="operatorTeam.actionDiagnosis"
label="Diagnosa Tindakan"
placeholder="Masukkan diagnosa tindakan"
:col-span="2"
:rows="5"
:is-disabled="isReadonly"
/>
<InputBase
field-name="operatorTeam.postSurgeryNurseId"
label="Perawat Pasca Bedah"
placeholder="Masukkan perawat pasca bedah"
:is-disabled="isReadonly"
/>
</DE.Block>
</Fragment>
<Separator class="my-4" />
<Fragment
v-slot="{ section }"
title="Tindakan Operatif/Non Operatif Lain"
>
<!-- <p class="text-lg font-semibold">{{ section }}</p> -->
<DE.Block
:col-count="2"
:cell-flex="false"
>
<DE.Cell>
<slot name="procedures" />
<ArrayMessage
class="mt-1"
v-if="meta.touched"
name="procedures"
/>
</DE.Cell>
</DE.Block>
</Fragment>
<Separator class="my-4" />
<Fragment
v-slot="{ section }"
title="Data Pelaksanaan Operasi"
>
<p class="text-lg font-semibold">{{ section }}</p>
<DE.Block
:col-count="3"
:cell-flex="false"
>
<SelectSurgeryType
field-name="operationExecution.surgeryType"
label="Jenis Operasi"
placeholder="Pilih jenis operasi"
:is-disabled="isReadonly"
/>
<SelectBilling
field-name="operationExecution.billingCode"
label="Kode Billing"
placeholder="Pilih kode billing"
:is-disabled="isReadonly"
/>
<SelectOperationSystem
field-name="operationExecution.operationSystem"
label="Sistem Operasi"
placeholder="Pilih sistem operasi"
:is-disabled="isReadonly"
/>
</DE.Block>
<DE.Block
:col-count="3"
:cell-flex="false"
>
<InputBase
field-name="operationExecution.operationStartAt"
label="Operasi Mulai"
placeholder="Pilih Tanggal"
input-type="datetime-local"
:is-disabled="isReadonly"
/>
<InputBase
field-name="operationExecution.operationEndAt"
label="Operasi Selesai"
placeholder="Pilih Tanggal"
input-type="datetime-local"
:is-disabled="isReadonly"
/>
<InputBase
field-name="_operationDuration"
label="Lama Operasi"
placeholder="-"
is-disabled
/>
</DE.Block>
<DE.Block
:col-count="3"
:cell-flex="false"
>
<InputBase
field-name="operationExecution.anesthesiaStartAt"
label="Pembiusan Mulai"
placeholder="Pilih Tanggal"
input-type="datetime-local"
:is-disabled="isReadonly"
/>
<InputBase
field-name="operationExecution.anesthesiaEndAt"
label="Pembiusan Selesai"
placeholder="Pilih Tanggal"
input-type="datetime-local"
:is-disabled="isReadonly"
/>
<InputBase
field-name="_anesthesiaDuration"
label="Lama Pembiusan"
placeholder="-"
is-disabled
/>
</DE.Block>
<DE.Block
:col-count="3"
:cell-flex="false"
>
<SelectOperationType
field-name="operationExecution.surgeryCleanType"
label="Jenis Pembedahan"
placeholder="Pilih jenis pembedahan"
:is-disabled="isReadonly"
/>
<SelectSurgeryCounter
field-name="operationExecution.surgeryNumber"
label="Operasi Ke"
placeholder="Pilih"
:is-disabled="isReadonly"
/>
<SelectBirthType
field-name="operationExecution.birthRemark"
label="Keterangan Lahir"
placeholder="Pilih"
:is-disabled="isReadonly"
/>
<SelectBirthPlace
field-name="operationExecution.birthPlaceNote"
label="Ket. Tempat Lahir"
placeholder="Pilih keterangan tempat lahir"
:is-disabled="isReadonly"
/>
<InputBase
field-name="operationExecution.personWeight"
label="Berat Badan"
placeholder="Masukkan berat badan"
numeric-only
suffix-msg="Gram"
:is-disabled="isReadonly"
/>
<InputBase
field-name="operationExecution.birthCondition"
label="Ket. Saat Lahir"
placeholder="Tambah catatan"
:is-disabled="isReadonly"
/>
</DE.Block>
<DE.Block
:col-count="4"
:cell-flex="false"
>
<TextAreaInput
field-name="operationExecution.operationDescription"
label="Uraian Operasi"
placeholder="Masukkan uraian"
:rows="3"
:col-span="2"
:is-disabled="isReadonly"
/>
<InputBase
field-name="operationExecution.bleedingAmountCc"
label="Jumlah Pendarahan"
placeholder="Masukkan jumlah pendarahan"
suffix-msg="CC"
numeric-only
:is-disabled="isReadonly"
/>
</DE.Block>
<DE.Block
:col-count="3"
:cell-flex="false"
>
<RadioBloods
field-name="bloodInput"
label="Jenis & Jumlah Darah Masuk"
:is-disabled="isReadonly"
/>
</DE.Block>
<DE.Block
:col-count="3"
:cell-flex="false"
>
<InputBase
field-name="implant.brand"
label="Merk"
placeholder="Masukkan merk"
:is-disabled="isReadonly"
/>
<InputBase
field-name="implant.name"
label="Nama Implant"
placeholder="Masukkan nama implant"
:is-disabled="isReadonly"
/>
<InputBase
field-name="implant.sticker"
label="Sticker/Nomer Register Implant"
placeholder="Masukkan sticker/nomor register implant"
:is-disabled="isReadonly"
/>
<InputBase
field-name="implant.companionName"
label="Nama Pendamping Implant"
placeholder="Masukkan nama pendamping implant"
:is-disabled="isReadonly"
/>
<SelectSpecimen
field-name="specimen.destination"
label="Specimen/Jaringan dikirim ke"
placeholder="Pilih"
:is-disabled="isReadonly"
/>
</DE.Block>
<FillNotes
title="Keterangan Jaringan"
:limit="tissueNotesLimit"
:is-disabled="isReadonly"
/>
</Fragment>
<div class="mt-4 flex justify-end">
<ActionForm @click="onFormActionClicked" />
</div>
</form>
</template>
@@ -0,0 +1,65 @@
<script setup lang="ts">
// components
import { FieldArray } from 'vee-validate'
import * as DE from '~/components/pub/my-ui/doc-entry'
// form field components
import { ButtonAction, InputBase } from '~/components/pub/my-ui/form'
interface Props {
isDisabled: boolean
limit: number
title: string
}
const props = defineProps<Props>()
const isReadonly = computed(() => props.isDisabled)
</script>
<template>
<FieldArray
v-slot="{ fields, push, remove }"
name="tissueNotes"
>
<template v-if="fields.length === 0">
{{ push({ note: '' }) }}
</template>
<div class="space-y-4">
<DE.Block
v-for="(field, idx) in fields"
:key="field.key"
:col-count="3"
:cell-flex="false"
>
<InputBase
:label="idx === 0 ? 'Keterangan Jaringan' : undefined"
:field-name="`tissueNotes[${idx}].note`"
placeholder="Masukkan catatan"
:is-disabled="isReadonly"
/>
<DE.Cell class="flex items-start justify-start">
<DE.Field :class="idx === 0 ? 'mt-[30px]' : 'mt-0'">
<ButtonAction
v-if="idx !== 0"
:disabled="isReadonly"
preset="delete"
:title="`Hapus Kontak ${idx + 1}`"
icon-only
@click="remove(idx)"
/>
</DE.Field>
</DE.Cell>
</DE.Block>
</div>
<ButtonAction
preset="add"
label="Tambah Catatan"
title="Tambah Catatan Keterangan Jaringan"
:disabled="fields.length >= limit || isReadonly"
:full-width-mobile="true"
class="mt-4"
@click="push({ name: '', dose: '', unit: '' })"
/>
</FieldArray>
</template>
@@ -0,0 +1,10 @@
export { default as FillNotes } from './fill-notes.vue'
export { default as RadioBloods } from './radio-bloods.vue'
export { default as SelectBilling } from './select-billing.vue'
export { default as SelectBirthPlace } from './select-birth-place.vue'
export { default as SelectBirthType } from './select-birth-type.vue'
export { default as SelectOperationSystem } from './select-operation-system.vue'
export { default as SelectOperationType } from './select-operation-type.vue'
export { default as SelectSpecimen } from './select-specimen.vue'
export { default as SelectSurgeryCounter } from './select-surgery-counter.vue'
export { default as SelectSurgeryType } from './select-surgery-type.vue'
@@ -0,0 +1,101 @@
<script setup lang="ts">
import { cn } from '~/lib/utils'
import * as DE from '~/components/pub/my-ui/doc-entry'
import { RadioGroup, RadioGroupItem } from '~/components/pub/ui/radio-group'
import { Label as RadioLabel } from '~/components/pub/ui/label'
import { Input } from '~/components/pub/ui/input'
const props = defineProps<{
fieldName: string
label: string
class?: string
radioGroupClass?: string
radioItemClass?: string
labelClass?: string
isDisabled?: boolean
}>()
const { class: containerClass, radioGroupClass, radioItemClass, labelClass } = props
const opts = [
{ label: 'PRC', value: 'prc' },
{ label: 'WB', value: 'wb' },
{ label: 'FFP', value: 'ffp' },
{ label: 'TC', value: 'tc' },
]
</script>
<template>
<DE.Cell
:class="cn('radio-group-field', containerClass)"
:col-span="2"
>
<DE.Label :label-for="fieldName">
{{ label }}
</DE.Label>
<DE.Field :id="fieldName">
<FormField
v-slot="{ componentField: radioField }"
:name="`${fieldName}.type`"
>
<FormItem>
<FormControl>
<RadioGroup
:model-value="radioField.modelValue"
@update:model-value="radioField.onChange"
:class="cn('grid grid-cols-1 items-start gap-4 sm:grid-cols-2 ', radioGroupClass)"
>
<div
v-for="(option, index) in opts"
:key="option.value"
:class="cn('grid grid-cols-1 items-start gap-3 sm:grid-cols-[auto,1fr]', radioItemClass)"
>
<div class="flex min-w-fit items-center gap-2">
<RadioGroupItem
:id="`type-${index}`"
:value="option.value"
:disabled="isDisabled"
/>
<RadioLabel :for="`type-${index}`">
{{ option.label }}
</RadioLabel>
</div>
<FormField
v-slot="{ componentField: amountField }"
:name="`${fieldName}.amount.${option.value}`"
>
<FormItem>
<FormControl>
<div class="relative w-[140px]">
<Input
v-bind="amountField"
placeholder="00"
class="pr-10"
:disabled="radioField.modelValue !== option.value || isDisabled"
@input="
(e: InputEvent) => {
const target = e.target as HTMLInputElement
const v = target.value.replace(/\D/g, '')
amountField.onChange(v)
}
"
/>
<span
class="absolute inset-y-0 end-0 flex items-center justify-center px-2 text-sm text-muted-foreground"
>
CC
</span>
</div>
</FormControl>
</FormItem>
</FormField>
</div>
</RadioGroup>
</FormControl>
</FormItem>
</FormField>
</DE.Field>
</DE.Cell>
</template>
@@ -0,0 +1,68 @@
<script setup lang="ts">
import type { FormErrors } from '~/types/error'
import Select from '~/components/pub/my-ui/form/select.vue'
import { cn } from '~/lib/utils'
import * as DE from '~/components/pub/my-ui/doc-entry'
const props = defineProps<{
fieldName: string
label: string
isDisabled?: boolean
isRequired?: boolean
placeholder?: string
errors?: FormErrors
class?: string
selectClass?: string
fieldGroupClass?: string
}>()
const { errors, class: containerClass, selectClass, fieldGroupClass } = props
const opts = [
{ label: 'General', value: 'general' },
{ label: 'Regional', value: 'regional' },
{ label: 'Local', value: 'local' },
]
</script>
<template>
<DE.Cell :class="cn('select-field-group', fieldGroupClass, containerClass)">
<DE.Label
label-for="fieldName"
:is-required="isRequired"
>
{{ label }}
</DE.Label>
<DE.Field
:id="fieldName"
:errors="errors"
:class="cn('select-field-wrapper')"
>
<FormField
v-slot="{ componentField }"
:name="fieldName"
>
<FormItem>
<FormControl>
<Select
:id="fieldName"
:is-disabled="isDisabled"
v-bind="componentField"
:items="opts"
:placeholder="placeholder"
:preserve-order="false"
:class="
cn(
'text-sm transition-all duration-200 focus:outline-none focus:ring-1 focus:ring-black focus:ring-offset-0',
selectClass,
)
"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</DE.Field>
</DE.Cell>
</template>
@@ -0,0 +1,71 @@
<script setup lang="ts">
import type { FormErrors } from '~/types/error'
import Select from '~/components/pub/my-ui/form/select.vue'
import { cn } from '~/lib/utils'
import * as DE from '~/components/pub/my-ui/doc-entry'
const props = defineProps<{
fieldName: string
label: string
isDisabled?: boolean
isRequired?: boolean
placeholder?: string
errors?: FormErrors
class?: string
selectClass?: string
fieldGroupClass?: string
}>()
const { errors, class: containerClass, selectClass, fieldGroupClass } = props
const opts = [
{ label: 'RSSA', value: 'rssa' },
{ label: 'Bidan Luar', value: 'out1' },
{ label: 'Dokter Luar', value: 'out2' },
{ label: 'Dukun Bayi', value: 'out3' },
{ label: 'Puskesmas', value: 'out4' },
{ label: 'Paramedis Luar', value: 'out5' },
]
</script>
<template>
<DE.Cell :class="cn('select-field-group', fieldGroupClass, containerClass)">
<DE.Label
label-for="fieldName"
:is-required="isRequired"
>
{{ label }}
</DE.Label>
<DE.Field
:id="fieldName"
:errors="errors"
:class="cn('select-field-wrapper')"
>
<FormField
v-slot="{ componentField }"
:name="fieldName"
>
<FormItem>
<FormControl>
<Select
:id="fieldName"
:is-disabled="isDisabled"
v-bind="componentField"
:items="opts"
:placeholder="placeholder"
:preserve-order="false"
:class="
cn(
'text-sm transition-all duration-200 focus:outline-none focus:ring-1 focus:ring-black focus:ring-offset-0',
selectClass,
)
"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</DE.Field>
</DE.Cell>
</template>
@@ -0,0 +1,67 @@
<script setup lang="ts">
import type { FormErrors } from '~/types/error'
import Select from '~/components/pub/my-ui/form/select.vue'
import { cn } from '~/lib/utils'
import * as DE from '~/components/pub/my-ui/doc-entry'
const props = defineProps<{
fieldName: string
label: string
isDisabled?: boolean
isRequired?: boolean
placeholder?: string
errors?: FormErrors
class?: string
selectClass?: string
fieldGroupClass?: string
}>()
const { errors, class: containerClass, selectClass, fieldGroupClass } = props
const opts = [
{ label: 'Lahir Hidup', value: 'lahir_hidup' },
{ label: 'Lahir Mati', value: 'lahir_mati' },
]
</script>
<template>
<DE.Cell :class="cn('select-field-group', fieldGroupClass, containerClass)">
<DE.Label
label-for="fieldName"
:is-required="isRequired"
>
{{ label }}
</DE.Label>
<DE.Field
:id="fieldName"
:errors="errors"
:class="cn('select-field-wrapper')"
>
<FormField
v-slot="{ componentField }"
:name="fieldName"
>
<FormItem>
<FormControl>
<Select
:id="fieldName"
:is-disabled="isDisabled"
v-bind="componentField"
:items="opts"
:placeholder="placeholder"
:preserve-order="false"
:class="
cn(
'text-sm transition-all duration-200 focus:outline-none focus:ring-1 focus:ring-black focus:ring-offset-0',
selectClass,
)
"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</DE.Field>
</DE.Cell>
</template>
@@ -0,0 +1,69 @@
<script setup lang="ts">
import type { FormErrors } from '~/types/error'
import Select from '~/components/pub/my-ui/form/select.vue'
import { cn } from '~/lib/utils'
import * as DE from '~/components/pub/my-ui/doc-entry'
const props = defineProps<{
fieldName: string
label: string
isDisabled?: boolean
isRequired?: boolean
placeholder?: string
errors?: FormErrors
class?: string
selectClass?: string
fieldGroupClass?: string
}>()
const { errors, class: containerClass, selectClass, fieldGroupClass } = props
const opts = [
{ label: 'Cito', value: 'cito' },
{ label: 'Urgent', value: 'urgent' },
{ label: 'Efektif', value: 'efektif' },
{ label: 'Khusus', value: 'khusus' },
]
</script>
<template>
<DE.Cell :class="cn('select-field-group', fieldGroupClass, containerClass)">
<DE.Label
label-for="fieldName"
:is-required="isRequired"
>
{{ label }}
</DE.Label>
<DE.Field
:id="fieldName"
:errors="errors"
:class="cn('select-field-wrapper')"
>
<FormField
v-slot="{ componentField }"
:name="fieldName"
>
<FormItem>
<FormControl>
<Select
:id="fieldName"
:is-disabled="isDisabled"
v-bind="componentField"
:items="opts"
:placeholder="placeholder"
:preserve-order="false"
:class="
cn(
'text-sm transition-all duration-200 focus:outline-none focus:ring-1 focus:ring-black focus:ring-offset-0',
selectClass,
)
"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</DE.Field>
</DE.Cell>
</template>
@@ -0,0 +1,69 @@
<script setup lang="ts">
import type { FormErrors } from '~/types/error'
import Select from '~/components/pub/my-ui/form/select.vue'
import { cn } from '~/lib/utils'
import * as DE from '~/components/pub/my-ui/doc-entry'
const props = defineProps<{
fieldName: string
label: string
isDisabled?: boolean
isRequired?: boolean
placeholder?: string
errors?: FormErrors
class?: string
selectClass?: string
fieldGroupClass?: string
}>()
const { errors, class: containerClass, selectClass, fieldGroupClass } = props
const opts = [
{ label: 'Bersih', value: 'bersih' },
{ label: 'Bersih Terkontaminasi', value: 'bersih_terkontaminasi' },
{ label: 'Terkontaminasi Kotor', value: 'terkontaminasi' },
{ label: 'Kotor', value: 'kotor' },
]
</script>
<template>
<DE.Cell :class="cn('select-field-group', fieldGroupClass, containerClass)">
<DE.Label
label-for="fieldName"
:is-required="isRequired"
>
{{ label }}
</DE.Label>
<DE.Field
:id="fieldName"
:errors="errors"
:class="cn('select-field-wrapper')"
>
<FormField
v-slot="{ componentField }"
:name="fieldName"
>
<FormItem>
<FormControl>
<Select
:id="fieldName"
:is-disabled="isDisabled"
v-bind="componentField"
:items="opts"
:placeholder="placeholder"
:preserve-order="false"
:class="
cn(
'text-sm transition-all duration-200 focus:outline-none focus:ring-1 focus:ring-black focus:ring-offset-0',
selectClass,
)
"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</DE.Field>
</DE.Cell>
</template>
@@ -0,0 +1,69 @@
<script setup lang="ts">
import type { FormErrors } from '~/types/error'
import Select from '~/components/pub/my-ui/form/select.vue'
import { cn } from '~/lib/utils'
import * as DE from '~/components/pub/my-ui/doc-entry'
const props = defineProps<{
fieldName: string
label: string
isDisabled?: boolean
isRequired?: boolean
placeholder?: string
errors?: FormErrors
class?: string
selectClass?: string
fieldGroupClass?: string
}>()
const { errors, class: containerClass, selectClass, fieldGroupClass } = props
const opts = [
{ label: 'PA', value: 'pa' },
{ label: 'Mikrobiologi', value: 'microbiology' },
{ label: 'Laborat', value: 'laboratory' },
{ label: 'Tidak Perlu', value: 'none' },
]
</script>
<template>
<DE.Cell :class="cn('select-field-group', fieldGroupClass, containerClass)">
<DE.Label
label-for="fieldName"
:is-required="isRequired"
>
{{ label }}
</DE.Label>
<DE.Field
:id="fieldName"
:errors="errors"
:class="cn('select-field-wrapper')"
>
<FormField
v-slot="{ componentField }"
:name="fieldName"
>
<FormItem>
<FormControl>
<Select
:id="fieldName"
:is-disabled="isDisabled"
v-bind="componentField"
:items="opts"
:placeholder="placeholder"
:preserve-order="false"
:class="
cn(
'text-sm transition-all duration-200 focus:outline-none focus:ring-1 focus:ring-black focus:ring-offset-0',
selectClass,
)
"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</DE.Field>
</DE.Cell>
</template>
@@ -0,0 +1,67 @@
<script setup lang="ts">
import type { FormErrors } from '~/types/error'
import Select from '~/components/pub/my-ui/form/select.vue'
import { cn } from '~/lib/utils'
import * as DE from '~/components/pub/my-ui/doc-entry'
const props = defineProps<{
fieldName: string
label: string
isDisabled?: boolean
isRequired?: boolean
placeholder?: string
errors?: FormErrors
class?: string
selectClass?: string
fieldGroupClass?: string
}>()
const { errors, class: containerClass, selectClass, fieldGroupClass } = props
const opts = [
{ label: '1 (Satu)', value: 'first' },
{ label: 'Ulangan', value: 'retry' },
]
</script>
<template>
<DE.Cell :class="cn('select-field-group', fieldGroupClass, containerClass)">
<DE.Label
label-for="fieldName"
:is-required="isRequired"
>
{{ label }}
</DE.Label>
<DE.Field
:id="fieldName"
:errors="errors"
:class="cn('select-field-wrapper')"
>
<FormField
v-slot="{ componentField }"
:name="fieldName"
>
<FormItem>
<FormControl>
<Select
:id="fieldName"
:is-disabled="isDisabled"
v-bind="componentField"
:items="opts"
:placeholder="placeholder"
:preserve-order="false"
:class="
cn(
'text-sm transition-all duration-200 focus:outline-none focus:ring-1 focus:ring-black focus:ring-offset-0',
selectClass,
)
"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</DE.Field>
</DE.Cell>
</template>
@@ -0,0 +1,69 @@
<script setup lang="ts">
import type { FormErrors } from '~/types/error'
import Select from '~/components/pub/my-ui/form/select.vue'
import { cn } from '~/lib/utils'
import * as DE from '~/components/pub/my-ui/doc-entry'
const props = defineProps<{
fieldName: string
label: string
isDisabled?: boolean
isRequired?: boolean
placeholder?: string
errors?: FormErrors
class?: string
selectClass?: string
fieldGroupClass?: string
}>()
const { errors, class: containerClass, selectClass, fieldGroupClass } = props
const opts = [
{ label: 'Kecil', value: 'kecil' },
{ label: 'Sedang', value: 'sedang' },
{ label: 'Besar', value: 'besar' },
{ label: 'Khusus', value: 'khusus' },
]
</script>
<template>
<DE.Cell :class="cn('select-field-group', fieldGroupClass, containerClass)">
<DE.Label
label-for="fieldName"
:is-required="isRequired"
>
{{ label }}
</DE.Label>
<DE.Field
:id="fieldName"
:errors="errors"
:class="cn('select-field-wrapper')"
>
<FormField
v-slot="{ componentField }"
:name="fieldName"
>
<FormItem>
<FormControl>
<Select
:id="fieldName"
:is-disabled="isDisabled"
v-bind="componentField"
:items="opts"
:placeholder="placeholder"
:preserve-order="false"
:class="
cn(
'text-sm transition-all duration-200 focus:outline-none focus:ring-1 focus:ring-black focus:ring-offset-0',
selectClass,
)
"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</DE.Field>
</DE.Cell>
</template>
@@ -0,0 +1,88 @@
import { defineAsyncComponent } from 'vue'
import { format } from 'date-fns'
import { id } from 'date-fns/locale'
import type { Config, RecComponent } from '~/components/pub/my-ui/data-table'
import type { ActionReportData } from '~/components/app/action-report/sample'
const action = defineAsyncComponent(() => import('~/components/pub/my-ui/data/dropdown-action-d.vue'))
export const config: Config = {
cols: [
{ width: 120 },
{ width: 120 },
{ width: 120 },
{ width: 120 },
{ width: 120 },
{ width: 120 },
{ width: 120 },
{ width: 50 },
],
headers: [
[
{ label: 'TANGGAL LAPORAN' },
{ label: 'DPJP' },
{ label: 'OPERATOR' },
{ label: 'TANGGAL PEMBEDAHAN' },
{ label: 'JENIS OPERASI' },
{ label: 'KODE BILLING' },
{ label: 'SISTEM OPERASI' },
{ label: 'AKSI' },
],
],
keys: ['reportAt', 'dpjp', 'operator', 'operationAt', 'operationType', 'billing', 'system', 'action'],
delKeyNames: [
{ key: 'code', label: 'Kode' },
{ key: 'name', label: 'Nama' },
],
parses: {
reportAt: (rec: unknown): unknown => {
const attr = (rec as ActionReportData).reportAt
const result = format(new Date(attr), 'd MMMM yyyy, HH:mm', { locale: id })
return result
},
operationAt: (rec: unknown): unknown => {
const attr = (rec as ActionReportData).operationAt
const result = format(new Date(attr), 'd MMMM yyyy', { locale: id })
return result
},
system: (rec: unknown): unknown => {
return 'Cito'
},
operator: (rec: unknown): unknown => {
return 'dr. Dewi Arum Sawitri, Sp.An'
},
billing: (rec: unknown): unknown => {
return 'General'
},
operationType: (rec: unknown): unknown => {
return 'Besar'
},
dpjp: (rec: unknown): unknown => {
return 'dr. Irwansyah Kurniawan Sp.Bo'
},
parent: (rec: unknown): unknown => {
const recX = rec as any
return recX.parent?.name || '-'
},
},
components: {
action(rec, idx) {
const res: RecComponent = {
idx,
rec: rec as object,
component: action,
}
return res
},
},
htmls: {},
}
@@ -0,0 +1,39 @@
<script setup lang="ts">
// Components
import PaginationView from '~/components/pub/my-ui/pagination/pagination-view.vue'
// Types
import type { PaginationMeta } from '~/components/pub/my-ui/pagination/pagination.type'
// Configs
import { config } from './list.cfg'
interface Props {
data: any[]
paginationMeta: PaginationMeta
}
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"
/>
<PaginationView
:pagination-meta="paginationMeta"
@page-change="handlePageChange"
/>
</div>
</template>
@@ -0,0 +1,89 @@
import { defineAsyncComponent } from 'vue'
import { format } from 'date-fns'
import { id } from 'date-fns/locale'
import type { Config, RecComponent } from '~/components/pub/my-ui/data-table'
import type { ActionReportData } from '~/components/app/action-report/sample'
const action = defineAsyncComponent(() => import('~/components/pub/my-ui/data/dropdown-action-dud.vue'))
export const config: Config = {
cols: [
{ width: 120 },
{ width: 120 },
{ width: 120 },
{ width: 120 },
{ width: 120 },
{ width: 120 },
{ width: 120 },
{ width: 50 },
],
headers: [
[
{ label: 'TANGGAL LAPORAN' },
{ label: 'DPJP' },
{ label: 'OPERATOR' },
{ label: 'TANGGAL PEMBEDAHAN' },
{ label: 'JENIS OPERASI' },
{ label: 'KODE BILLING' },
{ label: 'SISTEM OPERASI' },
{ label: 'AKSI' },
],
],
keys: ['reportAt', 'dpjp', 'operator', 'operationAt', 'operationType', 'billing', 'system', 'action'],
delKeyNames: [
{ key: 'id', label: 'ID' },
{ key: 'dokter', label: 'Dokter' },
{ key: 'reportAt', label: 'Tanggal Laporan' },
],
parses: {
reportAt: (rec: unknown): unknown => {
const attr = (rec as ActionReportData).reportAt
const result = format(new Date(attr), 'd MMMM yyyy, HH:mm', { locale: id })
return result
},
operationAt: (rec: unknown): unknown => {
const attr = (rec as ActionReportData).operationAt
const result = format(new Date(attr), 'd MMMM yyyy', { locale: id })
return result
},
system: (rec: unknown): unknown => {
return 'Cito'
},
operator: (rec: unknown): unknown => {
return 'dr. Dewi Arum Sawitri, Sp.An'
},
billing: (rec: unknown): unknown => {
return 'General'
},
operationType: (rec: unknown): unknown => {
return 'Besar'
},
dpjp: (rec: unknown): unknown => {
return 'dr. Irwansyah Kurniawan Sp.Bo'
},
parent: (rec: unknown): unknown => {
const recX = rec as any
return recX.parent?.name || '-'
},
},
components: {
action(rec, idx) {
const res: RecComponent = {
idx,
rec: rec as object,
component: action,
}
return res
},
},
htmls: {},
}
+39
View File
@@ -0,0 +1,39 @@
<script setup lang="ts">
// Components
import PaginationView from '~/components/pub/my-ui/pagination/pagination-view.vue'
// Types
import type { PaginationMeta } from '~/components/pub/my-ui/pagination/pagination.type'
// Configs
import { config } from './list.cfg'
interface Props {
data: any[]
paginationMeta: PaginationMeta
}
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"
/>
<PaginationView
:pagination-meta="paginationMeta"
@page-change="handlePageChange"
/>
</div>
</template>
@@ -0,0 +1,166 @@
<script setup lang="ts">
import { format } from 'date-fns'
import { id } from 'date-fns/locale'
// type
import { type ProcedureSrc } from '~/models/procedure-src'
import { type ActionReportFormData } from '~/schemas/action-report.schema'
// componenets
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '~/components/pub/ui/accordion'
import * as DE from '~/components/pub/my-ui/doc-entry'
import DetailRow from '~/components/pub/my-ui/form/view/detail-row.vue'
import ArrangementProcedurePicker from '~/components/app/therapy-protocol/picker-dialog/arrangement-procedure/procedure-picker.vue'
// #region Props & Emits
const props = defineProps<{
data: ActionReportFormData
}>()
const emit = defineEmits<{
(e: 'back'): void
(e: 'edit'): void
}>()
// #endregion
// #region State & Computed
const { operatorTeam, procedures, operationExecution, bloodInput, implant, specimen, tissueNotes = [] } = props.data
const procedureSampleData = procedures as unknown as ProcedureSrc[]
// #region Lifecycle Hooks
// #endregion
// #region Functions
// #endregion region
// #region Utilities & event handlers
function onNavigate(type: string) {
if (type == 'back') emit('back')
if (type == 'edit') emit('edit')
}
// #endregion
// #region Watchers
// #endregion
</script>
<template>
<DetailRow label="Tanggal Laporan">
{{ format(new Date(), 'd MMMM yyyy, HH:mm', { locale: id }) }}
</DetailRow>
<Accordion
type="multiple"
class="w-full"
collapsible
:default-value="['section-1', 'section-2', 'section-3']"
>
<AccordionItem value="section-1">
<AccordionTrigger>Tim Pelaksanaan Tindakan</AccordionTrigger>
<AccordionContent>
<DE.Block
:cell-flex="false"
:col-count="2"
>
<DE.Cell>
<DetailRow label="DPJP">dr. Marcell Galliard Sp.Gr</DetailRow>
<DetailRow label="Operator">Sumitro</DetailRow>
<DetailRow label="Asisten Operator">Alexis Lewis Carol</DetailRow>
<DetailRow label="Instrumentir">Mikel Arteta</DetailRow>
<DetailRow label="Tanggal Pembedahan">
{{ format(new Date(), 'd MMMM yyyy', { locale: id }) }}
</DetailRow>
<DetailRow label="Diagnosa Tindakan">{{ operatorTeam?.actionDiagnosis || '-' }}</DetailRow>
<DetailRow label="Perawat Pasca Bedah">Cak Armuji</DetailRow>
</DE.Cell>
</DE.Block>
</AccordionContent>
</AccordionItem>
<AccordionItem value="section-2">
<AccordionTrigger>Tindakan Operatif / Non Operatif Lain</AccordionTrigger>
<AccordionContent>
<DE.Block
:cell-flex="false"
:col-count="2"
>
<DE.Cell>
<ArrangementProcedurePicker
field-name="procedures"
title="List Prosedur"
sub-title="Pilih Prosedur"
:mode="'preview'"
:sample-items="procedureSampleData"
/>
</DE.Cell>
</DE.Block>
</AccordionContent>
</AccordionItem>
<AccordionItem value="section-3">
<AccordionTrigger>Data Pelaksanaan Tindakan</AccordionTrigger>
<AccordionContent>
<DE.Block
:cell-flex="false"
:col-count="2"
>
<DE.Cell>
<DetailRow label="Jenis Operasi">dr. Marcell Galliard Sp.Gr</DetailRow>
<DetailRow label="Kode Billing">GCASH1128190</DetailRow>
<DetailRow label="Sistem Operasi">Alexis Lewis Carol</DetailRow>
<DetailRow label="Operasi Mulai">
{{ format(new Date(), 'd MMMM yyyy, HH:mm', { locale: id }) }}
</DetailRow>
<DetailRow label="Operasi Selesai">
{{ format(new Date(), 'd MMMM yyyy, HH:mm', { locale: id }) }}
</DetailRow>
<DetailRow label="Lama Operasi">5 menit</DetailRow>
<DetailRow label="Pembiusan Mulai">
{{ format(new Date(), 'd MMMM yyyy, HH:mm', { locale: id }) }}
</DetailRow>
<DetailRow label="Pembiusan Selesai">
{{ format(new Date(), 'd MMMM yyyy, HH:mm', { locale: id }) }}
</DetailRow>
<DetailRow label="Lama Pembiusan">5 menit</DetailRow>
<DetailRow label="PRC">300 CC</DetailRow>
<DetailRow label="FPP">-</DetailRow>
<DetailRow label="WB">-</DetailRow>
<DetailRow label="TC">-</DetailRow>
<DetailRow label="Merk">-</DetailRow>
<DetailRow label="Nama Implant">-</DetailRow>
<DetailRow label="Sticker / Nomor Register Implant">-</DetailRow>
<DetailRow label="Nama Pendamping Implant">-</DetailRow>
</DE.Cell>
<DE.Cell>
<DetailRow label="Jenis Pembedahan">Bersih</DetailRow>
<DetailRow label="Operasi ke">1 (Satu)</DetailRow>
<DetailRow label="Keterangan Lahir">Lahir Hidup</DetailRow>
<DetailRow label="Ket. Tempat Lahir">RSSA</DetailRow>
<DetailRow label="Berat Badan">18 gram</DetailRow>
<DetailRow label="Ket. Saat Lahir">Normal dan sehat</DetailRow>
<DetailRow label="Uraian Operasi">-</DetailRow>
<DetailRow label="Jumlah Pendarahan">300 CC</DetailRow>
<DetailRow label="Specimen / Jaringan dikirim ke">PA</DetailRow>
<DetailRow label="Keterangan Jaringan">
<ul
class="list-disc space-y-1 pl-5 text-sm"
v-if="tissueNotes.length > 0"
v-for="item in tissueNotes"
>
<li>{{ item.note }}</li>
</ul>
<span v-else>-</span>
</DetailRow>
</DE.Cell>
</DE.Block>
</AccordionContent>
</AccordionItem>
</Accordion>
<div class="my-2 flex justify-end py-2">
<PubMyUiNavFooterBaEd @click="onNavigate" />
</div>
</template>
<style scoped></style>
@@ -0,0 +1,54 @@
import { addWeeks, formatISO } from 'date-fns'
export type ActionReportData = {
id: number
reportAt: string
operationAt: string
noRm: string
noBill: string
nama: string
jk: string
alamat: string
klinik: string
dokter: string
caraBayar: string
rujukan: string
ketRujukan: string
asal: string
}
export const sampleRows: ActionReportData[] = [
{
id: 1,
reportAt: formatISO(addWeeks(new Date(), -1)),
operationAt: formatISO(addWeeks(new Date(), 1)),
noRm: 'RM23311224',
noBill: '-',
nama: 'Ahmad Baidowi',
jk: 'L',
alamat: 'Jl Jaksa Agung S. No. 9',
klinik: 'Penyakit dalam',
dokter: 'Dr. Andreas Sutaji',
caraBayar: 'JKN',
rujukan: 'Faskes BPJS',
ketRujukan: 'RUMAH SAKIT - RS Lawang Medika - Malang',
asal: 'Rawat Jalan Reguler',
},
{
id: 2,
reportAt: new Date().toISOString(),
operationAt: formatISO(addWeeks(new Date(), 2)),
noRm: 'RM23455667',
noBill: '-',
nama: 'Abraham Sulaiman',
jk: 'L',
alamat: 'Purwantoro, Blimbing',
klinik: 'Penyakit dalam',
dokter: 'Dr. Andreas Sutaji',
caraBayar: 'JKN',
rujukan: 'Faskes BPJS',
ketRujukan: 'RUMAH SAKIT - RS Lawang Medika - Malang',
asal: 'Rawat Jalan Reguler',
},
// tambahkan lebih banyak baris contoh jika perlu
]
@@ -0,0 +1 @@
export { default as SelectDoctor } from './select-doctor.vue'
@@ -0,0 +1,72 @@
<script setup lang="ts">
import { cn } from '~/lib/utils'
// type
import type { Doctor } from '~/models/doctor'
import { type Person, parseName } from '~/models/person'
// componenets
import * as DE from '~/components/pub/my-ui/doc-entry'
import Combobox from '~/components/pub/my-ui/combobox/combobox.vue'
const props = defineProps<{
fieldName: string
label: string
placeholder: string
doctors?: Doctor[]
class?: string
selectClass?: string
fieldGroupClass?: string
labelClass?: string
isRequired?: boolean
isDisabled?: boolean
colSpan?: number
}>()
const { class: containerClass, labelClass, colSpan = 1, doctors = [] } = props
const opts = computed(() => {
return doctors.map((doc) => ({
value: doc.id,
label: parseName(doc.employee.person as Person),
}))
})
</script>
<template>
<DE.Cell
:col-span="colSpan"
:class="cn('select-field-group', fieldGroupClass, containerClass)"
>
<DE.Label
:label-for="fieldName"
:is-required="isRequired"
>
{{ label }}
</DE.Label>
<DE.Field
:id="fieldName"
:class="cn('select-field-wrapper')"
>
<FormField
v-slot="{ componentField }"
:name="fieldName"
>
<FormItem>
<FormControl>
<Combobox
:id="fieldName"
v-bind="componentField"
:items="opts"
:placeholder="placeholder"
:is-disabled="isDisabled"
search-placeholder="Cari dokter..."
empty-message="Dokter tidak ditemukan"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</DE.Field>
</DE.Cell>
</template>
@@ -45,6 +45,7 @@ const {
handleSearch,
fetchData: getItemList,
} = usePaginatedList({
syncToUrl: false,
fetchFn: async (params: any) => {
const result = await getList({
search: params.search,
@@ -102,19 +103,23 @@ onMounted(async () => {
</script>
<template>
<Dialog v-model:open="isModalOpen" title="" size="xl">
<Header
v-model="searchInput"
:prep="headerPrep"
:ref-search-nav="headerPrep.refSearchNav"
@search="handleSearch"
class=""
/>
<AppProcedureSrcList
:table-config="config"
:data="data"
:pagination-meta="paginationMeta"
@page-change="handlePageChange"
/>
<Dialog
v-model:open="isModalOpen"
title=""
size="xl"
>
<Header
v-model="searchInput"
:prep="headerPrep"
:ref-search-nav="headerPrep.refSearchNav"
@search="handleSearch"
class=""
/>
<AppProcedureSrcList
:table-config="config"
:data="data"
:pagination-meta="paginationMeta"
@page-change="handlePageChange"
/>
</Dialog>
</template>
@@ -1,62 +1,143 @@
<script setup lang="ts">
import ProcedureListDialog from './procedure-list.vue'
import type { FormErrors } from '~/types/error'
import { toTypedSchema } from '@vee-validate/zod'
import { Form } from '~/components/pub/ui/form'
import { FieldArray } from 'vee-validate'
import InputBase from '~/components/pub/my-ui/form/input-base.vue'
import * as DE from '~/components/pub/my-ui/doc-entry'
import TextAreaInput from '~/components/pub/my-ui/form/text-area-input.vue'
// Types
import { type ProcedureSrc } from '~/models/procedure-src'
// Helper
import { cn } from '~/lib/utils'
// Components
import { FieldArray } from 'vee-validate'
import { ButtonAction } from '~/components/pub/my-ui/form'
import TableHeader from '~/components/pub/ui/table/TableHeader.vue'
import { is } from 'date-fns/locale'
import ProcedureListDialog from './procedure-list.vue'
interface Props {
fieldName: string
title: string
subTitle?: string
// State UI (Loading / Disabled)
isReadonly?: boolean
// Data Architecture Switch
// 'form' = Pakai Vee-Validate (Parent wajib useForm)
// 'preview' = Pakai Props sampleItems (Parent bebas)
mode?: 'form' | 'preview'
// Data Source untuk mode 'preview' (atau initial data)
sampleItems?: ProcedureSrc[]
}
const props = defineProps<Props>()
// Set default mode ke 'form' agar backward compatible
const props = withDefaults(defineProps<Props>(), {
mode: 'form',
isReadonly: false,
sampleItems: () => [],
})
const isProcedurePickerDialogOpen = ref<boolean>(false)
provide(`isProcedurePickerDialogOpen`, isProcedurePickerDialogOpen)
</script>
<template>
<div class="">
<div class="mb-2 flex items-center justify-between">
<h1 class="mb-2 font-medium">{{ title }}</h1>
<Button @click="isProcedurePickerDialogOpen = true" size="xs" variant="outline"
class="text-orange-400 border-orange-400 bg-transparent">
<Icon name="i-lucide-search" class="h-4 w-4 align-middle transition-colors" />
Pilih Diagnosis
</Button>
<div class="mb-2 flex items-center justify-between">
<p class="mb-2 font-medium">{{ title }}</p>
<ButtonAction
v-if="mode === 'form' && !isReadonly"
preset="add"
title="Tambah Item"
icon="i-lucide-search"
:label="subTitle || 'Pilih Diagnosis'"
:full-width-mobile="true"
@click="isProcedurePickerDialogOpen = true"
/>
</div>
<FieldArray
v-if="mode === 'form'"
v-slot="{ fields, push, remove }"
:name="props.fieldName"
>
<ProcedureListDialog :process-fn="push" />
<div class="overflow-hidden rounded-lg border border-gray-200">
<Table>
<TableHeader class="bg-gray-100">
<TableRow>
<TableHead class="w-1/2">Prosedur</TableHead>
<TableHead class="w-1/2">ICD-X</TableHead>
<TableHead class="w-[50px]">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-if="fields.length === 0">
<TableCell
colspan="3"
class="py-4 text-center text-muted-foreground"
>
Belum ada data dipilih.
</TableCell>
</TableRow>
<TableRow
v-for="(field, idx) in fields"
:key="field.key"
>
<TableCell :class="cn(isReadonly && 'opacity-50')">
{{ (field.value as ProcedureSrc)?.name }}
</TableCell>
<TableCell :class="cn(isReadonly && 'opacity-50')">
{{ (field.value as ProcedureSrc)?.code }}
</TableCell>
<TableCell>
<ButtonAction
v-if="!isReadonly"
preset="delete"
icon-only
:title="`Hapus ${(field.value as ProcedureSrc)?.name}`"
@click="remove(idx)"
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</FieldArray>
<FieldArray v-slot="{ fields, push, remove }" :name="props.fieldName">
<ProcedureListDialog :process-fn="push" />
<div
v-else
class="overflow-hidden rounded-lg border border-gray-200"
>
<Table>
<TableHeader class="bg-gray-100">
<TableRow>
<TableHead class="w-1/2">Prosedur</TableHead>
<TableHead class="w-1/2">ICD-X</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-if="sampleItems.length === 0">
<TableCell
colspan="2"
class="py-4 text-center text-muted-foreground"
>
Tidak ada data.
</TableCell>
</TableRow>
<div class="border border-gray-200 rounded-lg overflow-hidden">
<Table>
<TableHeader class="bg-gray-100">
<TableRow>
<TableHead class="w-1/2">Prosedur</TableHead>
<TableHead class="w-1/2">ICD-X</TableHead>
<TableHead class="w-1/2">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="(field, idx) in fields" :key="idx">
<TableCell class="">{{ field.value?.name }}</TableCell>
<TableCell class="">{{ field.value?.code }}</TableCell>
<TableCell class="">
<Button type="button" variant="destructive" size="sm" @click="remove(idx)">
<Icon name="i-lucide-trash-2" class="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</FieldArray>
<TableRow
v-for="item in sampleItems"
:key="item.code || item.id"
>
<TableCell class="text-muted-foreground">
{{ item.name }}
</TableCell>
<TableCell class="text-muted-foreground">
{{ item.code }}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</template>
@@ -0,0 +1,34 @@
<script setup lang="ts">
import List from './list.vue'
import Form from './form.vue'
import View from './view.vue'
// Models
import type { Encounter } from '~/models/encounter'
// Props
interface Props {
encounter: Encounter
}
const props = defineProps<Props>()
const { mode, goToEntry, goToView } = useQueryCRUDMode('mode')
</script>
<template>
<div>
<List
v-if="mode === 'list'"
:encounter="props.encounter"
@add="goToEntry"
@edit="goToEntry({ fromView: false })"
@view="goToView"
/>
<View
v-else-if="mode === 'view'"
:encounter="props.encounter"
/>
<Form v-else />
</div>
</template>
@@ -0,0 +1,82 @@
<script setup lang="ts">
import mockData from './sample'
// type
import { genDoctor, type Doctor } from '~/models/doctor'
import type { ActionReportFormData } from '~/schemas/action-report.schema'
// components
import { toast } from '~/components/pub/ui/toast'
import AppActionReportEntry from '~/components/app/action-report/entry-form.vue'
import ArrangementProcedurePicker from '~/components/app/therapy-protocol/picker-dialog/arrangement-procedure/procedure-picker.vue'
// states
const route = useRoute()
const { mode, goBack } = useQueryCRUDMode('mode')
const { recordId } = useQueryCRUDRecordId('record-id')
const reportData = ref<ActionReportFormData>({} as unknown as ActionReportFormData)
const doctors = ref<Doctor[]>([])
const isLoading = ref<boolean>(false)
// TODO: dummy data
;(() => {
doctors.value = [genDoctor()]
})()
const entryMode = ref<'add' | 'edit' | 'view'>('add')
const isDataReady = ref(false)
onMounted(async () => {
if (mode.value === 'entry' && recordId.value) {
entryMode.value = 'edit'
await loadEntryForEdit(+recordId.value)
} else {
// Untuk mode 'add', langsung set ready
isDataReady.value = true
}
})
// TODO: map data
async function loadEntryForEdit(id: number | string) {
isLoading.value = true
const result = mockData
reportData.value = result as ActionReportFormData
isLoading.value = false
isDataReady.value = true
}
</script>
<template>
<AppActionReportEntry
v-if="isDataReady"
:isLoading="isLoading"
:mode="entryMode"
@submit="(val) => console.log(val)"
@back="goBack"
@error="
(err: Error) => {
toast({
title: 'Terjadi Kesalahan',
description: err.message,
variant: 'destructive',
})
}
"
:doctors="doctors"
:initialValues="reportData"
>
<template #procedures>
<ArrangementProcedurePicker
field-name="procedures"
title="Tindakan Operatif/Non-Operatif Lain"
sub-title="Pilih Prosedur"
/>
</template>
</AppActionReportEntry>
<div
v-else
class="flex items-center justify-center p-8"
>
<p class="text-muted-foreground">Memuat data...</p>
</div>
</template>
@@ -0,0 +1,276 @@
<script setup lang="ts">
// Components
import Dialog from '~/components/pub/my-ui/modal/dialog.vue'
import AppActionReportList from '~/components/app/action-report/list.vue'
import AppActionReportListHistory from '~/components/app/action-report/list-history.vue'
import RecordConfirmation from '~/components/pub/my-ui/confirmation/record-confirmation.vue'
import { ButtonAction } from '~/components/pub/my-ui/form'
// config
import { config } from '~/components/app/action-report/list.cfg'
// types
import { ActionEvents } from '~/components/pub/my-ui/data/types'
import type { Encounter } from '~/models/encounter'
// Samples
import { sampleRows, type ActionReportData } from '~/components/app/action-report/sample'
import sampleReport from './sample'
// helpers
import { toast } from '~/components/pub/ui/toast'
// Props
interface Props {
encounter: Encounter
}
const props = defineProps<Props>()
const emits = defineEmits<{
(e: 'add'): void
(e: 'edit', id: number | string): void
(e: 'view', id: number | string): void
}>()
// states
const router = useRouter()
const route = useRoute()
const { goToEntry, backToList } = useQueryCRUDMode('mode')
const title = ref('')
const search = ref('')
const dateFrom = ref('')
const dateTo = ref('')
const isDialogOpen = ref<boolean>(false)
const isLoading = ref<boolean>(false)
// #region mock
// Handlers
import {
recId,
recAction,
recItem,
isReadonly,
isProcessing,
isFormEntryDialogOpen,
isRecordConfirmationOpen,
onResetState,
handleActionSave,
handleActionEdit,
handleActionRemove,
handleCancelForm,
} from '~/handlers/consultation.handler'
// #endregion
// filter + pencarian sederhana (client-side)
const filtered = computed(() => {
const q = search.value.trim().toLowerCase()
return sampleRows.filter((r: ActionReportData) => {
if (q) {
return r.nama.toLowerCase().includes(q) || r.noRm.toLowerCase().includes(q) || r.dokter.toLowerCase().includes(q)
}
return true
})
})
const goEdit = (id: number | string) => {
router.replace({
path: route.path,
query: {
...route.query,
mode: 'entry',
'record-id': id,
},
})
}
const goView = (id: number | string) => {
router.replace({
path: route.path,
query: {
...route.query,
mode: 'view',
'record-id': id,
},
})
}
provide('rec_id', recId)
provide('rec_action', recAction)
provide('rec_item', recItem)
provide('table_data_loader', isLoading)
async function onGetDetail(id: number | string) {
isLoading.value = true
const res = sampleReport
recItem.value = res
console.log(res)
isLoading.value = false
}
// #region watcher
watch([recId, recAction], (newVal) => {
const [id, action] = newVal
// Guard: jangan proses jika id = 0 atau action kosong
if (!id || !action) return
switch (action) {
case ActionEvents.showDetail:
// onGetDetail(recId.value)
goView(id)
title.value = 'Detail Konsultasi'
break
case ActionEvents.showEdit:
goEdit(id)
title.value = 'Edit Konsultasi'
break
case ActionEvents.showConfirmDelete:
isRecordConfirmationOpen.value = true
break
}
// Reset KEDUANYA menggunakan nextTick agar tidak trigger watcher lagi
nextTick(() => {
recId.value = 0
recAction.value = ''
})
})
// #endregion
</script>
<template>
<div class="mx-auto max-w-full">
<div class="border-b p-6">
<h1 class="text-2xl font-semibold">Laporan Tindakan</h1>
<p class="mt-1 text-sm text-gray-500">Infomasi laporan tindakan pasien</p>
</div>
<div class="flex flex-wrap items-center gap-3 border-b p-4">
<div class="flex items-center gap-2">
<input
v-model="search"
placeholder="Cari Nama / No.RM"
class="w-64 rounded border px-3 py-2"
/>
</div>
<div class="flex items-center gap-2">
<input
v-model="dateFrom"
type="date"
class="rounded border px-3 py-2"
/>
<span class="text-sm text-gray-500">-</span>
<input
v-model="dateTo"
type="date"
class="rounded border px-3 py-2"
/>
<ButtonAction
preset="custom"
title="Filter List Laporan Tindakan"
label="Filter"
icon="i-lucide-filter"
@click="
() => {
isDialogOpen = true
}
"
/>
</div>
<div class="ml-auto flex items-center gap-2">
<ButtonAction
preset="custom"
title="Riwayat Laporan Tindakan"
icon="i-lucide-history"
label="Riwayat Laporan Tindakan"
@click="
() => {
isDialogOpen = true
}
"
/>
<ButtonAction
preset="add"
title="Tambah Data Laporan Tindakan"
icon="i-lucide-plus"
label="Tambah Data"
@click="
() => {
goToEntry()
}
"
/>
</div>
</div>
<div class="overflow-x-auto p-4">
<AppActionReportList
:data="filtered"
:pagination-meta="{
recordCount: 2,
page: 1,
pageSize: 10,
totalPage: 1,
hasPrev: false,
hasNext: false,
}"
/>
</div>
</div>
<Dialog
v-model:open="isDialogOpen"
title="Arsip Riwayat Laporan Tindakan"
size="2xl"
prevent-outside
@update:open="
(value: any) => {
isDialogOpen = value
}
"
>
<AppActionReportListHistory
:data="filtered"
:pagination-meta="{
recordCount: 2,
page: 1,
pageSize: 10,
totalPage: 1,
hasPrev: false,
hasNext: false,
}"
/>
</Dialog>
<RecordConfirmation
v-model:open="isRecordConfirmationOpen"
action="delete"
:record="recItem"
@confirm="
() =>
handleActionRemove(
recItem.id,
() => {
router.go(0)
},
toast,
)
"
@cancel=""
>
<template #default="{ record }">
{{ console.log(JSON.stringify(record)) }}
<div class="space-y-1 text-sm">
<p
v-for="field in config.delKeyNames"
:key="field.key"
:v-if="record?.[field.key]"
>
<span class="font-semibold">{{ field.label }}:</span>
{{ record[field.key] }}
</p>
</div>
</template>
</RecordConfirmation>
</template>
@@ -0,0 +1,68 @@
export default {
operatorTeam: {
dpjpId: -1,
operatorName: 'Julian Alvarez',
assistantOperatorName: 'Arda Guller',
instrumentNurseName: 'Kenan Yildiz',
surgeryDate: '2025-11-13T14:29:00',
actionDiagnosis: 'Sprei gratisnya mana',
},
procedures: [
{
id: -1,
name: 'Ndase mumet',
code: 'CX1',
},
],
operationExecution: {
surgeryType: 'khusus',
billingCode: 'local',
operationSystem: 'cito',
surgeryCleanType: 'kotor',
surgeryNumber: 'retry',
birthPlaceNote: 'out3',
personWeight: 100,
operationDescription: 'asdsadsa1',
birthRemark: 'lahir_hidup',
operationStartAt: '2025-11-13T14:29:00',
operationEndAt: '2025-11-13T17:29:00',
anesthesiaStartAt: '2025-11-13T11:29:00',
anesthesiaEndAt: '2025-11-13T18:29:00',
},
bloodInput: {
type: 'tc',
amount: {
prc: null,
wb: null,
ffp: null,
tc: 3243324,
},
},
implant: {
brand: 'Samsung',
name: 'S.Komedi',
companionName: 'When ya',
},
specimen: {
destination: 'pa',
},
tissueNotes: [
{
note: 'Anjai',
},
{
note: 'Ciee Kaget',
},
{
note: 'Baper',
},
{
note: 'Saltink weeh',
},
{
note: 'Kaburrr',
},
],
}
@@ -0,0 +1,67 @@
<script setup lang="ts">
import mockData from './sample'
// types
import { type ActionReportFormData } from '~/schemas/action-report.schema'
import { type Encounter } from '~/models/encounter'
// Components
import AppActionReportPreview from '~/components/app/action-report/preview.vue'
import type { HeaderPrep } from '~/components/pub/my-ui/data/types'
// #region Props & Emits
const router = useRouter()
const { backToList, goToEntry } = useQueryCRUDMode('mode')
const { recordId } = useQueryCRUDRecordId('record-id')
function onEditFromView() {
goToEntry({ fromView: true })
}
const props = defineProps<{
encounter: Encounter
}>()
// #endregion
// #region State & Computed
const reportData = ref<ActionReportFormData | null>(null)
const headerPrep: HeaderPrep = {
title: 'Detail Laporan Tindakan',
icon: 'i-lucide-stethoscope',
}
// #endregion
// #region Lifecycle Hooks
onMounted(async () => {
reportData.value = mockData as unknown as ActionReportFormData
})
// #endregion
// #region Functions
// #endregion region
// #region Utilities & event handlers
function onEdit() {
router.push({
name: 'action-report-id-edit',
params: { id: 100 },
})
}
function onBack() {}
// #endregion
// #region Watchers
// #endregion
</script>
<template>
<AppActionReportPreview
v-if="reportData"
:data="reportData"
@back="backToList"
@edit="onEditFromView"
/>
</template>
+9 -1
View File
@@ -29,6 +29,7 @@ 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 ActionReport from '~/components/content/action-report/entry.vue'
import DocUploadList from '~/components/content/document-upload/list.vue'
import GeneralConsentList from '~/components/content/general-consent/entry.vue'
import SummaryMedic from '~/components/content/summary-medic/entry.vue'
@@ -57,7 +58,7 @@ const router = useRouter()
const { user, userActiveRole, getActiveRole } = useUserStore()
const activeRole = getActiveRole()
const activePosition = ref(getServicePosition(activeRole))
const menus = ref([] as any)
const menus = shallowRef([] as any)
const activeMenu = computed({
get: () => (route.query?.menu && typeof route.query.menu === 'string' ? route.query.menu : 'status'),
set: (value: string) => {
@@ -124,6 +125,13 @@ const protocolRows = [
{ 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: 'report',
label: 'Laporan Tindakan',
groups: ['ambulatory', 'rehabilitation', 'chemotherapy'],
component: ActionReport,
props: { encounter: data },
},
{
value: 'supporting-document',
label: 'Upload Dokumen Pendukung',
@@ -4,7 +4,7 @@ import { type Item } from './index'
const props = defineProps<{
id?: string
modelValue?: string
modelValue?: string | number
items: Item[]
placeholder?: string
searchPlaceholder?: string
@@ -16,8 +16,8 @@ const props = defineProps<{
const model = defineModel()
const emit = defineEmits<{
'update:modelValue': [value: string]
'update:searchText': [value: string]
'update:modelValue': [value: string | number]
'update:searchText': [value: string | number]
}>()
const open = ref(false)
+4 -4
View File
@@ -1,5 +1,5 @@
export interface Item {
value: string
value: string | number
label: string
code?: string
priority?: number
@@ -7,12 +7,12 @@ export interface Item {
export function recStrToItem(input: Record<string, string>): Item[] {
const items: Item[] = []
let idx = 0;
let idx = 0
for (const key in input) {
if (input.hasOwnProperty(key)) {
items.push({
value: key || ('unknown-' + idx),
label: input[key] || ('unknown-' + idx),
value: key || 'unknown-' + idx,
label: input[key] || 'unknown-' + idx,
})
}
idx++
@@ -0,0 +1,66 @@
<script setup lang="ts">
import type { LinkItem, ListItemDto } from './types'
import { ActionEvents } from './types'
const props = defineProps<{
rec: ListItemDto
}>()
const recId = inject<Ref<number>>('rec_id')!
const recAction = inject<Ref<string>>('rec_action')!
const recItem = inject<Ref<any>>('rec_item')!
const timestamp = inject<Ref<number>>('timestamp')!
const activeKey = ref<string | null>(null)
const linkItems: LinkItem[] = [
{
label: 'Detail',
onClick: () => {
detail()
},
icon: 'i-lucide-eye',
},
]
function detail() {
recId.value = props.rec.id || 0
recAction.value = ActionEvents.showDetail
recItem.value = props.rec
timestamp.value = Date.now()
}
</script>
<template>
<div>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<SidebarMenuButton
size="lg"
class="data-[state=open]:text-sidebar-accent-foreground data-[state=open]:bg-white dark:data-[state=open]:bg-slate-800"
>
<Icon
name="i-lucide-chevrons-up-down"
class="ml-auto size-4"
/>
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
class="w-[--radix-dropdown-menu-trigger-width] min-w-40 rounded-lg border border-slate-200 bg-white text-black dark:border-slate-700 dark:bg-slate-800 dark:text-white"
align="end"
>
<DropdownMenuGroup>
<DropdownMenuItem
v-for="item in linkItems"
:key="item.label"
class="hover:bg-gray-100 dark:hover:bg-slate-700"
@click="item.onClick"
@mouseenter="activeKey = item.label"
@mouseleave="activeKey = null"
>
<Icon :name="item.icon ?? ''" />
<span :class="activeKey === item.label ? 'text-sidebar-accent-foreground' : ''">{{ item.label }}</span>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</template>
@@ -0,0 +1,165 @@
<script setup lang="ts">
import { Button } from '~/components/pub/ui/button'
import { cn } from '~/lib/utils'
/**
* Button Action Component untuk form
* Support preset: add, delete, save, cancel
*/
interface Props {
/**
* Preset button type dengan styling bawaan
* - add: Button tambah (outline primary)
* - delete: Button hapus (ghost)
* - save: Button simpan (primary)
* - cancel: Button batal (secondary)
* - custom: Custom styling
*/
preset?: 'add' | 'delete' | 'save' | 'cancel' | 'custom'
/**
* Icon name (UnoCSS/Lucide)
* Default akan diset berdasarkan preset
*/
icon?: string
/**
* Button text
* Set ke empty string ('') untuk icon-only mode
*/
label?: string
/**
* Button title (tooltip)
* Wajib untuk icon-only buttons (accessibility)
*/
title?: string
/**
* Icon only mode (no label)
* Otomatis true jika label kosong
*/
iconOnly?: boolean
/**
* Button type
*/
type?: 'button' | 'submit' | 'reset'
/**
* Disabled state
*/
disabled?: boolean
/**
* Custom class untuk override styling
*/
class?: string
/**
* Responsive width (full width on mobile)
*/
fullWidthMobile?: boolean
/**
* Button size
*/
size?: 'default' | 'sm' | 'lg' | 'icon'
/**
* Button variant (override preset variant)
*/
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link'
}
const props = withDefaults(defineProps<Props>(), {
preset: 'custom',
type: 'button',
disabled: false,
fullWidthMobile: false,
iconOnly: false,
})
// Preset configurations
const presetConfig = {
add: {
variant: 'outline' as const,
icon: 'i-lucide-plus',
label: 'Tambah',
classes:
'border-primary bg-white text-primary hover:bg-primary hover:text-white dark:bg-slate-800 dark:border-primary dark:text-primary dark:hover:bg-primary dark:hover:text-white',
},
delete: {
variant: 'ghost' as const,
icon: 'i-lucide-trash-2',
label: '', // Default kosong untuk icon-only
classes:
'hover:bg-destructive hover:text-white hover:border-destructive dark:hover:bg-destructive dark:hover:text-white',
},
save: {
variant: 'default' as const,
icon: 'i-lucide-save',
label: 'Simpan',
classes: 'bg-primary text-primary-foreground hover:bg-primary/90 dark:bg-primary dark:hover:bg-primary/90',
},
cancel: {
variant: 'secondary' as const,
icon: 'i-lucide-x',
label: 'Batal',
classes: 'bg-secondary text-secondary-foreground hover:bg-secondary/80 dark:bg-slate-700 dark:hover:bg-slate-600',
},
custom: {
variant: 'default' as const,
icon: '',
label: '',
classes: '',
},
}
const currentPreset = computed(() => presetConfig[props.preset])
const buttonVariant = computed(() => props.variant || currentPreset.value.variant)
const buttonIcon = computed(() => props.icon || currentPreset.value.icon)
// Label handling: gunakan prop label jika ada, fallback ke preset, atau undefined jika iconOnly
const buttonLabel = computed(() => {
if (props.label !== undefined) return props.label
return currentPreset.value.label
})
const buttonTitle = computed(() => props.title || buttonLabel.value)
// Deteksi icon-only mode
const isIconOnly = computed(() => {
return props.iconOnly || buttonLabel.value === '' || !buttonLabel.value
})
const buttonClasses = computed(() => {
// Base classes berbeda untuk icon-only vs with-label
const baseClasses = isIconOnly.value
? 'rounded-md p-2 w-9 h-9 transition-colors flex items-center justify-center' // Icon only: square button
: 'rounded-md px-4 py-2 transition-colors sm:text-sm' // With label: padding horizontal lebih besar
const widthClasses = props.fullWidthMobile && !isIconOnly.value ? 'w-full sm:w-auto' : ''
const presetClasses = currentPreset.value.classes
return cn(baseClasses, widthClasses, presetClasses, props.class)
})
</script>
<template>
<Button
:title="buttonTitle"
:type="type"
:variant="buttonVariant"
:size="size"
:disabled="disabled"
:class="buttonClasses"
>
<Icon
v-if="buttonIcon"
:name="buttonIcon"
:class="cn('h-4 w-4 align-middle transition-colors', !isIconOnly ? 'mr-2' : '')"
/>
<slot v-if="!isIconOnly">{{ buttonLabel }}</slot>
</Button>
</template>
@@ -1,8 +1,5 @@
<script setup lang="ts">
import type { FormErrors } from '~/types/error'
import FieldGroup from '~/components/pub/my-ui/form/field-group.vue'
import Field from '~/components/pub/my-ui/form/field.vue'
import Label from '~/components/pub/my-ui/form/label.vue'
import { Input } from '~/components/pub/ui/input'
import { cn } from '~/lib/utils'
@@ -0,0 +1,26 @@
<script setup lang="ts">
/**
* A "Ghost" wrapper component designed to group related form fields
* without adding unnecessary depth to the DOM tree.
* * Unlike a standard <div> wrapper, this component leverages Vue 3's
* multi-root feature to render the title and slot content as direct siblings.
* This ensures the parent Form's grid or flex layout remains intact.
* * @property {string} [title] for compiler marker.
*
* Example Usage:
*
<Fragment
v-slot="{ section }"
title="Tim Pelaksana Tindakan"
>
<p class="text-lg font-semibold">{{ section }}</p>
</Fragment>
*/
defineProps<{
title?: string
}>()
</script>
<template>
<slot :section="title" />
</template>
+11
View File
@@ -0,0 +1,11 @@
export { default as Block } from './block.vue'
export { default as ButtonAction } from './button-action.vue'
export { default as FieldGroup } from './field-group.vue'
export { default as Field } from './field.vue'
export { default as FileField } from './file-field.vue'
export { default as Fragment } from './fragment.vue'
export { default as InputBase } from './input-base.vue'
export { default as Label } from './label.vue'
export { default as Select } from './select.vue'
export { default as TextAreaInput } from './text-area-input.vue'
export { default as TextCaptcha } from './text-captcha.vue'
+56 -22
View File
@@ -1,17 +1,18 @@
<script setup lang="ts">
import type { FormErrors } from '~/types/error'
import FieldGroup from '~/components/pub/my-ui/form/field-group.vue'
import Field from '~/components/pub/my-ui/form/field.vue'
import Label from '~/components/pub/my-ui/form/label.vue'
import { Input } from '~/components/pub/ui/input'
import { cn } from '~/lib/utils'
import { computed } from 'vue'
import { useFieldError } from 'vee-validate'
import * as DE from '~/components/pub/my-ui/doc-entry'
type InputType = 'text' | 'number' | 'password' | 'email' | 'date' | 'time' | 'datetime-local' | 'search' | 'tel'
const props = defineProps<{
fieldName: string
placeholder: string
label: string
label?: string
errors?: FormErrors
class?: string
colSpan?: number
@@ -21,8 +22,13 @@ const props = defineProps<{
isDisabled?: boolean
rightLabel?: string
bottomLabel?: string
suffixMsg?: string
iconName?: string
inputType?: InputType
}>()
const { class: containerClass } = props
function handleInput(event: Event) {
const target = event.target as HTMLInputElement
let value = target.value
@@ -44,12 +50,16 @@ function handleInput(event: Event) {
target.dispatchEvent(new Event('input', { bubbles: true }))
}
}
// Get error state from vee-validate
const fieldError = useFieldError(() => props.fieldName)
const hasError = computed(() => !!fieldError.value)
</script>
<template>
<DE.Cell :col-span="colSpan || 1">
<DE.Label
v-if="label !== ''"
v-if="label"
:label-for="fieldName"
:is-required="isRequired && !isDisabled"
>
@@ -63,27 +73,51 @@ function handleInput(event: Event) {
v-slot="{ componentField }"
:name="fieldName"
>
<FormItem :class="cn(`relative`,)">
<FormControl>
<Input
:disabled="isDisabled"
v-bind="componentField"
:placeholder="placeholder"
:maxlength="maxLength"
:class="cn('focus:border-primary focus:ring-2 focus:ring-primary focus:ring-offset-0', props.class)"
autocomplete="off"
aria-autocomplete="none"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
@input="handleInput"
/>
<p v-show="rightLabel" class="text-gray-400 absolute top-0 right-3">{{ rightLabel }}</p>
<FormItem>
<FormControl :class="cn('relative', containerClass)">
<div class="relative w-full max-w-sm items-center">
<Input
:disabled="isDisabled"
v-bind="componentField"
:placeholder="placeholder"
:maxlength="maxLength"
:class="cn(hasError && 'border-red-500 focus-visible:border-red-500 focus-visible:ring-red-500')"
autocomplete="off"
aria-autocomplete="none"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
@input="handleInput"
:type="inputType"
/>
<span
v-if="suffixMsg"
class="absolute inset-y-0 end-0 flex items-center justify-center px-2 text-muted-foreground"
>
{{ suffixMsg }}
</span>
<Icon
v-if="iconName"
:name="iconName"
class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400"
/>
<p
v-show="rightLabel"
class="absolute right-3 top-0 text-gray-400"
>
{{ rightLabel }}
</p>
</div>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</DE.Field>
<p v-show="bottomLabel" class="text-gray-400 mt-1">{{ bottomLabel }}</p>
<p
v-show="bottomLabel"
class="text-gray-400"
>
{{ bottomLabel }}
</p>
</DE.Cell>
</template>
+8 -1
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { SelectRoot } from 'radix-vue'
import { watch } from 'vue'
import { watch, computed } from 'vue'
import { useFieldError } from 'vee-validate'
import SelectContent from '~/components/pub/ui/select/SelectContent.vue'
import SelectGroup from '~/components/pub/ui/select/SelectGroup.vue'
import SelectItem from '~/components/pub/ui/select/SelectItem.vue'
@@ -29,9 +30,14 @@ const props = defineProps<{
isDisabled?: boolean
autoWidth?: boolean
autoFill?: boolean
id?: string
// otherPlacement sudah tidak digunakan, diganti dengan priority system di Item interface
}>()
// Get error state from vee-validate if id is provided
const fieldError = props.id ? useFieldError(() => props.id!) : ref(null)
const hasError = computed(() => !!fieldError.value)
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
@@ -121,6 +127,7 @@ watch(
'cursor-not-allowed bg-gray-100 opacity-50': isDisabled,
'bg-white text-black dark:bg-gray-800 dark:text-white': !isDisabled,
'w-full': !autoWidth,
'border-red-500 focus:ring-red-500': hasError,
},
props.class,
)
@@ -1,9 +1,5 @@
<script setup lang="ts">
import type { FormErrors } from '~/types/error'
import FieldGroup from '~/components/pub/my-ui/form/field-group.vue'
import Field from '~/components/pub/my-ui/form/field.vue'
import Label from '~/components/pub/my-ui/form/label.vue'
import { Input } from '~/components/pub/ui/input'
import { cn } from '~/lib/utils'
import * as DE from '~/components/pub/my-ui/doc-entry'
@@ -19,8 +15,11 @@ const props = defineProps<{
maxLength?: number
isRequired?: boolean
isDisabled?: boolean
resize?: 'none' | 'y' | 'x'
rows?: number
}>()
const { resize = 'none', class: className } = props
function handleInput(event: Event) {
const target = event.target as HTMLInputElement
let value = target.value
@@ -47,7 +46,7 @@ function handleInput(event: Event) {
<template>
<DE.Cell :col-span="colSpan || 1">
<DE.Label
class="font-medium mb-1"
:class="className"
v-if="label !== ''"
:label-for="fieldName"
:is-required="isRequired && !isDisabled"
@@ -65,11 +64,12 @@ function handleInput(event: Event) {
<FormItem>
<FormControl>
<Textarea
:rows="rows"
:disabled="isDisabled"
v-bind="componentField"
:placeholder="placeholder"
:maxlength="maxLength"
:class="cn('focus:border-primary focus:ring-2 focus:ring-primary focus:ring-offset-0', props.class)"
:class="cn('focus:border-primary focus:ring-2 focus:ring-primary focus:ring-offset-0')"
autocomplete="off"
aria-autocomplete="none"
autocorrect="off"
@@ -83,4 +83,4 @@ function handleInput(event: Event) {
</FormField>
</DE.Field>
</DE.Cell>
</template>
</template>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { cn } from '~/lib/utils';
import { cn } from '~/lib/utils'
const props = defineProps<{
label: string
@@ -9,15 +9,17 @@ const props = defineProps<{
</script>
<template>
<div :class="cn(`flex flex-col gap-1 lg:grid lg:grid-cols-[180px_minmax(0,1fr)] lg:gap-x-3`, props.class)">
<div :class="cn(`flex flex-col gap-0.5 lg:grid lg:grid-cols-[180px_auto_minmax(0,1fr)] lg:gap-x-3 lg:gap-y-1`, props.class)">
<!-- Label -->
<span :class="cn(`text-md font-normal text-muted-foreground`, props.labelClass)">
{{ label }}
</span>
<!-- Colon (hidden on mobile) -->
<span class="hidden text-md tracking-wide text-muted-foreground lg:block">:</span>
<!-- Value -->
<span class="truncate lg:whitespace-normal">
<span class="me-3 hidden lg:inline-block">:</span>
<span class="text-md font-sans tracking-wide">
<slot />
</span>
</div>
@@ -0,0 +1 @@
export type ClickType = 'back' | 'draft' | 'submit'
@@ -93,7 +93,7 @@ function getButtonClass(pageNumber: number) {
</script>
<template>
<div class="flex w-full min-w-0 items-center justify-between px-2 py-2">
<div class="flex w-full min-w-0 items-center justify-between overflow-x-scroll px-2 py-2 md:overflow-hidden">
<!-- Info text -->
<div
v-if="showInfo && endRecord > 0"
@@ -0,0 +1,20 @@
<script setup lang="ts">
import { ErrorMessage } from 'vee-validate'
import { cn } from '~/lib/utils'
const props = defineProps<{
name: string
class?: string
}>()
</script>
<template>
<ErrorMessage
:name="props.name"
v-slot="{ message }"
>
<p :class="cn('font-sans text-[0.8rem] text-destructive', props.class)">
{{ message }}
</p>
</ErrorMessage>
</template>
+1
View File
@@ -1,3 +1,4 @@
export { default as ArrayMessage } from './array-message.vue'
export { default as FormControl } from './FormControl.vue'
export { default as FormDescription } from './FormDescription.vue'
export { default as FormItem } from './FormItem.vue'
+1 -1
View File
@@ -24,7 +24,7 @@ const modelValue = useVModel(props, 'modelValue', emits, {
v-model="modelValue"
:class="
cn(
'border-input dark:bg-slate-950 ring-offset-background placeholder:text-muted-foreground flex h-9 md:h-8 2xl:h-9 w-full rounded-md border border-gray-400 px-3 py-2 md:text-xs 2xl:text-sm file:border-0 file:bg-transparent md:file:!text-xs xl:file:!text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50',
'border-input dark:bg-slate-950 ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary focus-visible:ring-offset-0 focus-visible:border-primary flex h-9 md:h-8 2xl:h-9 w-full rounded-md border border-gray-400 px-3 py-2 md:text-xs 2xl:text-sm file:border-0 file:bg-transparent md:file:!text-xs xl:file:!text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)
"
+28 -8
View File
@@ -2,7 +2,6 @@ import type { DataTableLoader } from '~/components/pub/my-ui/data-table/type'
import type { PaginationMeta } from '~/components/pub/my-ui/pagination/pagination.type'
import { refDebounced, useUrlSearchParams } from '@vueuse/core'
import * as z from 'zod'
import { is } from "date-fns/locale"
// Default query schema yang bisa digunakan semua list
export const defaultQuerySchema = z.object({
@@ -38,10 +37,23 @@ interface UsePaginatedListOptions<T = any> {
}>
// Nama endpoint untuk logging error
entityName: string
/**
* Apakah state harus disinkronkan ke URL Browser?
* Set `false` jika digunakan di dalam Modal, Drawer, atau Nested Component
* agar tidak menimpa URL halaman induk.
* @default true
*/
syncToUrl?: boolean
}
export function usePaginatedList<T = any>(options: UsePaginatedListOptions<T>) {
const { querySchema = defaultQuerySchema, defaultQuery = defaultQueryParams, fetchFn, entityName } = options
const {
querySchema = defaultQuerySchema,
defaultQuery = defaultQueryParams,
fetchFn,
entityName,
syncToUrl = true, // Default true agar behavior lama tetap jalan
} = options
// State management
const data = ref<T[]>([])
@@ -49,11 +61,19 @@ export function usePaginatedList<T = any>(options: UsePaginatedListOptions<T>) {
isTableLoading: false,
})
// URL state management
const queryParams = useUrlSearchParams('history', {
initialValue: defaultQuery,
removeFalsyValues: true,
})
let queryParams: any
if (syncToUrl) {
// Mode Halaman Utama: Sync ke URL
queryParams = useUrlSearchParams('history', {
initialValue: defaultQuery,
removeFalsyValues: true,
write: false,
})
} else {
// Mode Nested/Modal: Local Reactive State
queryParams = reactive({ ...defaultQuery })
}
const params = computed(() => {
const result = querySchema.safeParse(queryParams)
@@ -168,7 +188,7 @@ export function usePaginatedList<T = any>(options: UsePaginatedListOptions<T>) {
}
}
export function transform(endpoint: string ,params: any): string {
export function transform(endpoint: string, params: any): string {
const urlParams = new URLSearchParams()
Object.entries(params).forEach(([key, value]) => {
+49 -11
View File
@@ -3,18 +3,18 @@ import { useRoute, useRouter } from 'vue-router'
export function useQueryCRUD(modeKey: string = 'mode', recordIdKey: string = 'record-id') {
type params = {
mode: string,
mode: string
recordId: any
}
const route = useRoute()
const router = useRouter()
const crudQueryParams = computed<params> ({
const crudQueryParams = computed<params>({
get: () => {
return {
mode: route.query[modeKey] && route.query[modeKey] === 'entry' ? 'entry' : 'list',
recordId: route.query[recordIdKey]
recordId: route.query[recordIdKey],
}
},
set: (val) => {
@@ -59,8 +59,15 @@ export function useQueryCRUDMode(key: string = 'mode') {
const route = useRoute()
const router = useRouter()
const mode = computed<'list' | 'entry'>({
get: () => (route.query[key] && route.query[key] === 'entry' ? 'entry' : 'list'),
const mode = computed<'list' | 'entry' | 'view'>({
get: () => {
const q = route.query[key]
if (q === 'entry') return 'entry'
if (q === 'view') return 'view'
return 'list'
},
set: (val) => {
router.push({
path: route.path,
@@ -72,9 +79,22 @@ export function useQueryCRUDMode(key: string = 'mode') {
},
})
const goToEntry = (myRecord_id?: any) => {
mode.value = 'entry'
if(myRecord_id) {
const fromView = computed(() => route.query['from'] === 'view')
const goToEntry = (options?: { fromView?: boolean }) => {
router.push({
path: route.path,
query: {
...route.query,
[key]: 'entry',
from: options?.fromView ? 'view' : undefined,
},
})
}
const goToView = (myRecord_id?: any) => {
mode.value = 'view'
if (myRecord_id) {
myRecord_id.value = myRecord_id
}
}
@@ -85,13 +105,32 @@ export function useQueryCRUDMode(key: string = 'mode') {
query: {
...route.query,
mode: 'list',
// HAPUS record-id
recordIdKey: undefined,
from: undefined,
},
})
}
return { mode, goToEntry, backToList }
const backToView = () => {
router.push({
path: route.path,
query: {
...route.query,
mode: 'view',
from: undefined,
},
})
}
const goBack = () => {
if (fromView.value) {
backToView()
} else {
backToList()
}
}
return { mode, fromView, goToEntry, goToView, backToList, backToView, goBack }
}
export function useQueryCRUDRecordId(key: string = 'record-id') {
@@ -114,4 +153,3 @@ export function useQueryCRUDRecordId(key: string = 'record-id') {
return { recordId }
}
+15 -7
View File
@@ -55,6 +55,7 @@ const SurgeryReportListAsync = defineAsyncComponent(() => import('~/components/c
const VaccineDataListAsync = defineAsyncComponent(() => import('~/components/content/vaccine-data/main.vue'))
const InitialNursingStudyAsync = defineAsyncComponent(() => import('~/components/content/initial-nursing/entry.vue'))
const SummaryMedicAsync = defineAsyncComponent(() => import('~/components/content/summary-medic/entry.vue'))
const ActionReportEntryAsync = defineAsyncComponent(() => import('~/components/content/action-report/entry.vue'))
const defaultKeys: Record<string, any> = {
status: {
@@ -457,6 +458,19 @@ export function injectComponents(id: string | number, data: EncounterListData, m
currentKeys.initialNursingStudy['props'] = { encounter: data?.encounter }
}
if (currentKeys?.initialNursingStudy) {
currentKeys.initialNursingStudy['component'] = InitialNursingStudyAsync
currentKeys.initialNursingStudy['props'] = { encounter: data?.encounter }
}
if (currentKeys?.actionReport) {
currentKeys.actionReport['component'] = ActionReportEntryAsync
currentKeys.actionReport['props'] = {
encounter: data?.encounter,
type: 'action-report',
label: currentKeys.actionReport['title'],
}
}
return currentKeys
}
@@ -539,13 +553,7 @@ export function mapResponseToEncounter(result: any): any {
return mapped
}
export function getMenuItems(
id: string | number,
props: any,
user: any,
data: EncounterListData,
meta: any,
) {
export function getMenuItems(id: string | number, props: any, user: any, data: EncounterListData, meta: any) {
// const normalClassCode = props.classCode === 'ambulatory' ? 'outpatient' : props.classCode
const normalClassCode = props.classCode === 'ambulatory' ? 'ambulatory' : props.classCode
const currentKeys = injectComponents(id, data, meta)
+3 -4
View File
@@ -3,11 +3,10 @@ import CardContent from '~/components/pub/ui/card/CardContent.vue'
const route = useRoute()
const contentFrame = computed(() => route.meta.contentFrame)
const contentPadding = computed(() => route.meta.contentPadding || 'p-4 2xl:p-5')
const contentUseCard = computed(() => route.meta.contentUseCard === false ? false : true)
console.log(route.meta.contentUseCard,contentUseCard)
const contentUseCard = computed(() => (route.meta.contentUseCard === false ? false : true))
console.log(route.meta.contentUseCard, contentUseCard)
const contentFrameClass = computed(() => {
switch (contentFrame.value) {
case 'cf-container-2xl':
@@ -33,7 +32,7 @@ const contentFrameClass = computed(() => {
<LayoutAppSidebar />
<SidebarInset>
<LayoutHeader />
<div :class="`w-full flex justify-center ${contentPadding} ${contentFrameClass}`">
<div :class="`flex w-full justify-center ${contentPadding} ${contentFrameClass}`">
<div v-if="contentFrame !== 'cf-no-frame'">
<Card v-if="contentUseCard">
<CardContent>
+3 -2
View File
@@ -1,4 +1,3 @@
export interface Base {
id: number
createdAt: string | null
@@ -20,7 +19,9 @@ export interface TreeItem {
export function genBase(): Base {
return {
id: 0,
// -1 buat mock data
// backend harusnya non-negative/ > 0 (untuk auto increment constraint) jadi harusnya aman ya
id: -1,
createdAt: '',
updatedAt: '',
}
+5 -5
View File
@@ -1,8 +1,8 @@
import { type Base, genBase } from "./_base"
import { type Employee, genEmployee } from "./employee"
import type { Unit } from "./unit"
import type { Specialist } from "./specialist"
import type { Subspecialist } from "./subspecialist"
import { type Base, genBase } from './_base'
import { type Employee, genEmployee } from './employee'
import type { Unit } from './unit'
import type { Specialist } from './specialist'
import type { Subspecialist } from './subspecialist'
export interface Doctor extends Base {
employee_id: number
+14 -5
View File
@@ -1,7 +1,7 @@
import { type Base, genBase } from "./_base"
import type { PersonAddress } from "./person-address"
import type { PersonContact } from "./person-contact"
import type { PersonRelative } from "./person-relative"
import { type Base, genBase } from './_base'
import type { PersonAddress } from './person-address'
import type { PersonContact } from './person-contact'
import type { PersonRelative } from './person-relative'
import type { Ethnic } from './ethnic'
import type { Language } from './language'
import type { Regency } from './regency'
@@ -43,6 +43,15 @@ export interface Person extends Base {
export function genPerson(): Person {
return {
...genBase(),
name: '',
frontTitle: '[MOCK] dr. ',
name: 'Agus Iwan Setiawan',
endTitle: 'Sp.Bo',
}
}
export function parseName(person: Person): string {
if (!person) return ''
const fullName = [person.frontTitle, person.name, person.endTitle].filter(Boolean).join(' ').trim()
return fullName
}
+121
View File
@@ -0,0 +1,121 @@
import { z } from 'zod'
const isoDateTime = z
.string()
.min(1, 'Tanggal / waktu wajib diisi')
.refine((val) => {
const date = new Date(val)
return !isNaN(date.getTime())
}, 'Format tanggal / waktu tidak valid')
const positiveInt = z.coerce.number().int().nonnegative()
const OperatorTeamSchema = z.object({
dpjpId: z.coerce
.number({
invalid_type_error: 'Silahkan pilih dpjp terlebih dahulu',
})
.int(),
operatorName: z.string({
required_error: 'Masukkan nama operator',
}),
assistantOperatorName: z.string({
required_error: 'Masukkan nama asisten operator',
}),
instrumentNurseName: z.string({
required_error: 'Masukkan nama instrumentir',
}),
surgeryDate: isoDateTime,
actionDiagnosis: z.string(),
postSurgeryNurseId: z.number().int().optional().nullable(),
})
const ProcedureSchema = z.object({
id: z.number().int(),
name: z.string().min(1),
code: z.string().min(1),
})
const OperationExecutionSchema = z.object({
surgeryType: z.enum(['kecil', 'sedang', 'besar', 'khusus'], { required_error: 'Silahkan pilih jenis operasi' }),
billingCode: z.string({
required_error: 'Silahkan pilih kode billing',
}),
operationSystem: z.enum(['khusus', 'cito', 'efektif', 'urgent'], { required_error: 'Silahkan pilih sistem operasi' }),
operationStartAt: isoDateTime,
operationEndAt: isoDateTime,
anesthesiaStartAt: isoDateTime,
anesthesiaEndAt: isoDateTime,
surgeryCleanType: z.enum(['bersih', 'bersih_terkontaminasi', 'terkontaminasi', 'kotor']).optional(),
surgeryNumber: z.enum(['first', 'retry']).optional(),
birthPlaceNote: z.string().optional(),
personWeight: positiveInt.optional(),
birthCondition: z.string().optional(),
operationDescription: z.string({
required_error: 'Mohon lengkapi uraian operasi',
}),
bleedingAmountCc: positiveInt.optional(),
birthRemark: z.enum(['lahir_hidup', 'lahir_mati']).optional(),
})
const BloodInputSchema = z
.object({
type: z.enum(['prc', 'wb', 'ffp', 'tc']),
amount: z.object({
prc: z.coerce.number().optional(),
wb: z.coerce.number().optional(),
ffp: z.coerce.number().optional(),
tc: z.coerce.number().optional(),
}),
})
.transform((val) => ({
type: val.type,
amount: Object.fromEntries(
['prc', 'wb', 'ffp', 'tc'].map((k) => [k, val.type === k ? (val.amount[k] ?? null) : null]),
),
}))
const ImplantSchema = z.object({
brand: z.string().optional(),
name: z.string().optional(),
stickerNumber: z.string().optional(),
companionName: z.string().optional(),
})
const SpecimenSchema = z.object({
destination: z.string({
required_error: 'Silahkan pilih specimen',
}),
})
const TissueNoteSchema = z.object({
note: z
.string()
.trim()
.transform((val) => (val === '' ? undefined : val))
.optional(),
})
export const ActionReportSchema = z.object({
operatorTeam: OperatorTeamSchema,
procedures: z.array(ProcedureSchema).min(1, { message: 'Silahkan pilih prosedur' }),
operationExecution: OperationExecutionSchema,
bloodInput: BloodInputSchema.optional(),
implant: ImplantSchema.optional(),
specimen: SpecimenSchema.optional(),
tissueNotes: z.array(TissueNoteSchema).optional(),
})
export type ActionReportFormData = z.infer<typeof ActionReportSchema>