done: form entry laporan tindakan
todo: manage state readonly ke komponen app prosedur pager-nav: scroll x on small screen form-schema: catatan opsional feat(treatment-report): add datetime validation and duration calculation - Change operator team fields from IDs to names in schema and form - Modify blood input schema to use type-based amount selection - Update form fields to match new schema structure - Simplify radio bloods component logic and styling - Add validation for ISO datetime format in treatment report schema - Implement duration calculation for operation and anesthesia times - Update input fields to use datetime-local type - Add disabled state for radio bloods component
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
<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 TreatmentReportFormData, TreatmentReportSchema } from '~/schemas/treatment-report.schema'
|
||||
@@ -9,9 +10,9 @@ import { type TreatmentReportFormData, TreatmentReportSchema } from '~/schemas/t
|
||||
import type { Doctor } from '~/models/doctor'
|
||||
|
||||
// components
|
||||
import { Form } from '~/components/pub/ui/form'
|
||||
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'
|
||||
|
||||
// form field components
|
||||
import {
|
||||
@@ -32,7 +33,8 @@ import { SelectDoctor } from '~/components/app/doctor/fields'
|
||||
|
||||
// #region Props & Emits
|
||||
interface FormData extends TreatmentReportFormData {
|
||||
hiddenNyc: number
|
||||
_operationDuration: string
|
||||
_anesthesiaDuration: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -66,7 +68,7 @@ const isReadonly = computed(() => {
|
||||
|
||||
const formSchema = toTypedSchema(TreatmentReportSchema)
|
||||
|
||||
const { handleSubmit, values, resetForm, setFieldValue, setValues, validate } = useForm<FormData>({
|
||||
const { errors, handleSubmit, values, meta, resetForm, setFieldValue, setValues, validate } = useForm<FormData>({
|
||||
name: 'treatmentReportForm',
|
||||
validationSchema: formSchema,
|
||||
initialValues: props.initialValues ? props.initialValues : {},
|
||||
@@ -94,13 +96,59 @@ defineExpose({
|
||||
// const onSubmit = handleSubmit((formValues: FormData) => emit('submit', formValues))
|
||||
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))
|
||||
},
|
||||
)
|
||||
|
||||
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))
|
||||
},
|
||||
)
|
||||
// #endregion
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -123,29 +171,29 @@ const onSubmit = handleSubmit(
|
||||
:is-disabled="isReadonly"
|
||||
/>
|
||||
<InputBase
|
||||
field-name="operatorTeam.operatorId"
|
||||
field-name="operatorTeam.operatorName"
|
||||
label="Operator"
|
||||
placeholder="Masukkan operator"
|
||||
:is-disabled="isReadonly"
|
||||
/>
|
||||
<InputBase
|
||||
field-name="assistantOperatorId"
|
||||
field-name="operatorTeam.assistantOperatorName"
|
||||
label="Asisten Operator"
|
||||
placeholder="Masukkan asisten operator"
|
||||
:is-disabled="isReadonly"
|
||||
/>
|
||||
<InputBase
|
||||
field-name="instrumentNurseId"
|
||||
field-name="operatorTeam.instrumentNurseName"
|
||||
label="Instrumentir"
|
||||
placeholder="Masukkan instrumentir"
|
||||
:is-disabled="isReadonly"
|
||||
/>
|
||||
<InputBase
|
||||
field-name="surgeryDate"
|
||||
field-name="operatorTeam.surgeryDate"
|
||||
label="Tanggal Pembedahan"
|
||||
placeholder="Pilih Tanggal"
|
||||
icon-name="i-lucide-calendar"
|
||||
input-type="datetime-local"
|
||||
:is-disabled="isReadonly"
|
||||
placeholder=""
|
||||
/>
|
||||
</DE.Block>
|
||||
<DE.Block
|
||||
@@ -153,7 +201,7 @@ const onSubmit = handleSubmit(
|
||||
:cell-flex="false"
|
||||
>
|
||||
<TextAreaInput
|
||||
field-name="actionDiagnosis"
|
||||
field-name="operatorTeam.actionDiagnosis"
|
||||
label="Diagnosa Tindakan"
|
||||
placeholder="Masukkan diagnosa tindakan"
|
||||
:col-span="2"
|
||||
@@ -161,7 +209,7 @@ const onSubmit = handleSubmit(
|
||||
:is-disabled="isReadonly"
|
||||
/>
|
||||
<InputBase
|
||||
field-name="postSurgeryNurseId"
|
||||
field-name="operatorTeam.postSurgeryNurseId"
|
||||
label="Perawat Pasca Bedah"
|
||||
placeholder="Masukkan perawat pasca bedah"
|
||||
:is-disabled="isReadonly"
|
||||
@@ -180,7 +228,14 @@ const onSubmit = handleSubmit(
|
||||
:col-count="2"
|
||||
:cell-flex="false"
|
||||
>
|
||||
<slot name="procedures" />
|
||||
<DE.Cell>
|
||||
<slot name="procedures" />
|
||||
<ArrayMessage
|
||||
class="mt-1"
|
||||
v-if="meta.touched"
|
||||
name="procedures"
|
||||
/>
|
||||
</DE.Cell>
|
||||
</DE.Block>
|
||||
</Fragment>
|
||||
|
||||
@@ -224,20 +279,20 @@ const onSubmit = handleSubmit(
|
||||
field-name="operationExecution.operationStartAt"
|
||||
label="Operasi Mulai"
|
||||
placeholder="Pilih Tanggal"
|
||||
icon-name="i-lucide-calendar"
|
||||
input-type="datetime-local"
|
||||
:is-disabled="isReadonly"
|
||||
/>
|
||||
<InputBase
|
||||
field-name="operationExecution.operationEndAt"
|
||||
label="Operasi Selesai"
|
||||
placeholder="Pilih Tanggal"
|
||||
icon-name="i-lucide-calendar"
|
||||
input-type="datetime-local"
|
||||
:is-disabled="isReadonly"
|
||||
/>
|
||||
<InputBase
|
||||
field-name="_operationDuration"
|
||||
label="Lama Operasi"
|
||||
placeholder="03 Jam 20 Menit"
|
||||
placeholder="-"
|
||||
is-disabled
|
||||
/>
|
||||
</DE.Block>
|
||||
@@ -250,20 +305,20 @@ const onSubmit = handleSubmit(
|
||||
field-name="operationExecution.anesthesiaStartAt"
|
||||
label="Pembiusan Mulai"
|
||||
placeholder="Pilih Tanggal"
|
||||
icon-name="i-lucide-calendar"
|
||||
input-type="datetime-local"
|
||||
:is-disabled="isReadonly"
|
||||
/>
|
||||
<InputBase
|
||||
field-name="operationExecution.anesthesiaEndAt"
|
||||
label="Pembiusan Selesai"
|
||||
placeholder="Pilih Tanggal"
|
||||
icon-name="i-lucide-calendar"
|
||||
input-type="datetime-local"
|
||||
:is-disabled="isReadonly"
|
||||
/>
|
||||
<InputBase
|
||||
field-name="_anesthesiaDuration"
|
||||
label="Lama Pembiusan"
|
||||
placeholder="03 Jam 20 Menit"
|
||||
placeholder="-"
|
||||
is-disabled
|
||||
/>
|
||||
</DE.Block>
|
||||
@@ -339,9 +394,9 @@ const onSubmit = handleSubmit(
|
||||
:cell-flex="false"
|
||||
>
|
||||
<RadioBloods
|
||||
field-name="isNewBorn"
|
||||
field-name="bloodInput"
|
||||
label="Jenis & Jumlah Darah Masuk"
|
||||
is-required
|
||||
:is-disabled="isReadonly"
|
||||
/>
|
||||
</DE.Block>
|
||||
|
||||
|
||||
@@ -37,55 +37,54 @@ const opts = [
|
||||
<DE.Field :id="fieldName">
|
||||
<FormField
|
||||
v-slot="{ componentField: radioField }"
|
||||
:name="fieldName"
|
||||
:name="`${fieldName}.type`"
|
||||
>
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
v-bind="radioField"
|
||||
: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('flex items-center gap-3', radioItemClass)"
|
||||
: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="`${fieldName}-${index}`"
|
||||
:id="`type-${index}`"
|
||||
:value="option.value"
|
||||
class="h-5 w-5 border-muted-foreground data-[state=checked]:border-primary data-[state=checked]:text-primary"
|
||||
:disabled="isDisabled"
|
||||
/>
|
||||
<RadioLabel
|
||||
:for="`${fieldName}-${index}`"
|
||||
:class="cn('min-w-[30px] cursor-pointer text-sm font-medium leading-none', labelClass)"
|
||||
>
|
||||
<RadioLabel :for="`type-${index}`">
|
||||
{{ option.label }}
|
||||
</RadioLabel>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
v-slot="{ componentField }"
|
||||
:name="`amount_${option.value}`"
|
||||
v-slot="{ componentField: amountField }"
|
||||
:name="`${fieldName}.amount.${option.value}`"
|
||||
>
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div class="relative w-[140px]">
|
||||
<Input
|
||||
v-bind="componentField"
|
||||
v-bind="amountField"
|
||||
placeholder="00"
|
||||
class="pr-10"
|
||||
:disabled="radioField.modelValue !== option.value || isDisabled"
|
||||
@input="(e: Event) => {
|
||||
const target = e.target as HTMLInputElement
|
||||
const value = target.value.replace(/\D/g, '')
|
||||
if (target.value !== value) {
|
||||
target.value = value
|
||||
componentField['onInput'](e)
|
||||
@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">
|
||||
<span
|
||||
class="absolute inset-y-0 end-0 flex items-center justify-center px-2 text-sm text-muted-foreground"
|
||||
>
|
||||
CC
|
||||
</span>
|
||||
</div>
|
||||
@@ -95,7 +94,6 @@ const opts = [
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage class="ml-0 mt-1" />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</DE.Field>
|
||||
|
||||
@@ -33,10 +33,54 @@ const doctors = ref<Doctor[]>([])
|
||||
:initialValues="
|
||||
{
|
||||
operatorTeam: {
|
||||
// dpjpId: -1,
|
||||
dpjpId: -1,
|
||||
operatorName: 'Julian',
|
||||
assistantOperatorName: 'Amar',
|
||||
instrumentNurseName: 'Anang',
|
||||
surgeryDate: '2025-11-13T14:29',
|
||||
actionDiagnosis: 'Omon Omon Saja',
|
||||
},
|
||||
// procedures: [{ id: 5, code: 'ROC0100', name: 'Accute Appendictis' }],
|
||||
} as TreatmentReportFormData
|
||||
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',
|
||||
},
|
||||
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',
|
||||
},
|
||||
],
|
||||
} as unknown as TreatmentReportFormData
|
||||
"
|
||||
>
|
||||
<template #procedures>
|
||||
|
||||
@@ -7,6 +7,8 @@ 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
|
||||
@@ -22,6 +24,7 @@ const props = defineProps<{
|
||||
bottomLabel?: string
|
||||
suffixMsg?: string
|
||||
iconName?: string
|
||||
inputType?: InputType
|
||||
}>()
|
||||
|
||||
const { class: containerClass } = props
|
||||
@@ -85,6 +88,7 @@ const hasError = computed(() => !!fieldError.value)
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
@input="handleInput"
|
||||
:type="inputType"
|
||||
/>
|
||||
<span
|
||||
v-if="suffixMsg"
|
||||
|
||||
@@ -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,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,24 +1,34 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const isoDateTime = z.string().min(1, 'Tanggal / waktu wajib diisi')
|
||||
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')
|
||||
.transform((val) => new Date(val).toISOString())
|
||||
|
||||
const positiveInt = z.coerce.number().int().nonnegative()
|
||||
|
||||
const OperatorTeamSchema = z.object({
|
||||
dpjpId: z.coerce
|
||||
.number({
|
||||
invalid_type_error: 'Dokter Pemeriksa wajib diisi',
|
||||
invalid_type_error: 'Silahkan pilih dpjp terlebih dahulu',
|
||||
})
|
||||
.int(),
|
||||
operatorId: z.coerce
|
||||
.number({
|
||||
invalid_type_error: 'Operator wajib diisi',
|
||||
})
|
||||
.int(),
|
||||
assistantOperatorId: z.coerce.number().int().optional().nullable(),
|
||||
instrumentNurseId: z.coerce.number().int().optional().nullable(),
|
||||
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().min(1),
|
||||
actionDiagnosis: z.string(),
|
||||
|
||||
postSurgeryNurseId: z.number().int().optional().nullable(),
|
||||
})
|
||||
@@ -58,17 +68,22 @@ const OperationExecutionSchema = z.object({
|
||||
birthRemark: z.enum(['lahir_hidup', 'lahir_mati']).optional(),
|
||||
})
|
||||
|
||||
const BloodComponentSchema = z.object({
|
||||
used: z.boolean().default(false),
|
||||
volumeCc: positiveInt.optional(),
|
||||
})
|
||||
|
||||
const BloodInputSchema = z.object({
|
||||
prc: BloodComponentSchema,
|
||||
ffp: BloodComponentSchema,
|
||||
wb: BloodComponentSchema,
|
||||
tc: BloodComponentSchema,
|
||||
})
|
||||
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(),
|
||||
@@ -85,15 +100,15 @@ const SpecimenSchema = z.object({
|
||||
|
||||
const TissueNoteSchema = z.object({
|
||||
note: z
|
||||
.string({
|
||||
required_error: 'Masukkan deskripsi catatan',
|
||||
})
|
||||
.min(1, { message: 'Setidaknya diperlukan 1 catatan' }),
|
||||
.string()
|
||||
.trim()
|
||||
.transform((val) => (val === '' ? undefined : val))
|
||||
.optional(),
|
||||
})
|
||||
|
||||
export const TreatmentReportSchema = z.object({
|
||||
operatorTeam: OperatorTeamSchema,
|
||||
procedures: z.array(ProcedureSchema).min(1),
|
||||
procedures: z.array(ProcedureSchema).min(1, { message: 'Silahkan pilih prosedur' }),
|
||||
|
||||
operationExecution: OperationExecutionSchema,
|
||||
|
||||
|
||||
Reference in New Issue
Block a user