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:
Khafid Prayoga
2025-11-27 11:15:09 +07:00
parent 71c2833bf2
commit 1fbd20d9ae
8 changed files with 208 additions and 71 deletions
@@ -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
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'
+41 -26
View File
@@ -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,