Merge pull request #118 from dikstub-rssa/feat/patient-63

wip: Feat/patient 63
This commit is contained in:
Munawwirul Jamal
2025-10-18 06:56:08 +07:00
committed by GitHub
67 changed files with 3492 additions and 1329 deletions
+1 -1
View File
@@ -174,7 +174,7 @@ body {
}
body, table, label {
@apply md:!text-xs 2xl:!text-sm;
@apply md:!text-xs 2xl:!text-sm;
}
/* Container */
+239 -241
View File
@@ -108,248 +108,246 @@ function onAddSep() {
:initial-values="initialValues"
>
<form id="entry-form" @submit="handleSubmit($event, (values) => onSubmitForm(values, { resetForm }))">
<div class="mb-5 border-b border-b-slate-300 pb-3 text-lg xl:text-xl">
<div class="flex flex-col justify-between">
<div class="p-2">
<h2 class="text-md font-semibold">Data Pasien</h2>
</div>
<div class="my-2 flex gap-6 p-2 text-sm">
<span>
Sudah pernah terdaftar sebagai pasien?
<Button class="bg-primary" size="sm" @click.prevent="emit('click', 'search')">
<Icon name="i-lucide-search" class="mr-1" /> Cari Pasien
</Button>
</span>
<span>
Belum pernah terdaftar sebagai pasien?
<Button class="bg-primary" size="sm" @click.prevent="emit('click', 'add')">
<Icon name="i-lucide-plus" class="mr-1" /> Tambah Pasien Baru
</Button>
</span>
</div>
<Block :colCount="3">
<Cell>
<Label label-for="patient_name">Nama Pasien</Label>
<Field id="patient_name" :errors="errors">
<FormField v-slot="{ componentField }" name="patient_name">
<FormItem>
<FormControl>
<Input
id="patient_name"
v-bind="componentField"
disabled
placeholder="Tambah data pasien terlebih dahulu"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</Field>
</Cell>
<!-- NIK -->
<Cell :cosSpan="3">
<Label label-for="nik">NIK</Label>
<Field id="nik" :errors="errors">
<FormField v-slot="{ componentField }" name="nik">
<FormItem>
<FormControl>
<Input id="nik" v-bind="componentField" disabled placeholder="Otomatis" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</Field>
</Cell>
<Cell>
<Label label-for="rm">No. RM</Label>
<Field id="rm" :errors="errors">
<FormField v-slot="{ componentField }" name="rm">
<FormItem>
<FormControl>
<Input id="rm" v-bind="componentField" disabled placeholder="RM99222" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</Field>
</Cell>
</Block>
<Separator />
<div class="p-2">
<h2 class="text-md font-semibold">Data Kunjungan</h2>
</div>
<Block :colCount="3">
<!-- Dokter (Combobox) -->
<Cell :cosSpan="3">
<Label label-for="doctor_id">Dokter</Label>
<Field id="doctor_id" :errors="errors">
<FormField v-slot="{ componentField }" name="doctor_id">
<FormItem>
<FormControl>
<Combobox id="doctor_id" v-bind="componentField" :items="doctorOpts" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</Field>
</Cell>
<!-- Tanggal Daftar (DatePicker) -->
<Cell :cosSpan="3">
<Label label-for="register_date">Tanggal Daftar</Label>
<Field id="register_date" :errors="errors">
<FormField v-slot="{ componentField }" name="register_date">
<FormItem>
<FormControl>
<DatepickerSingle v-bind="componentField" placeholder="Pilih tanggal" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</Field>
</Cell>
<!-- Jenis Pembayaran (Combobox) -->
<Cell :cosSpan="3">
<Label label-for="payment_type">Jenis Pembayaran</Label>
<Field id="payment_type" :errors="errors">
<FormField v-slot="{ componentField }" name="payment_type">
<FormItem>
<FormControl>
<!-- <Combobox id="payment_type" v-bind="componentField" :items="paymentOpts" /> -->
<Select id="payment_type" v-bind="componentField" :items="paymentOpts" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</Field>
</Cell>
</Block>
<Block :colCount="3">
<Cell :cosSpan="3">
<Label label-for="bpjs_number">Kelompok Peserta</Label>
<Field id="bpjs_number" :errors="errors">
<FormField v-slot="{ componentField }" name="bpjs_number">
<FormItem>
<FormControl>
<Input
id="bpjs_number"
v-bind="componentField"
placeholder="Pilih jenis pembayaran terlebih dahulu"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</Field>
</Cell>
<!-- No. Kartu BPJS -->
<Cell :cosSpan="3">
<Label label-for="bpjs_number">No. Kartu BPJS</Label>
<Field id="bpjs_number" :errors="errors">
<FormField v-slot="{ componentField }" name="bpjs_number">
<FormItem>
<FormControl>
<Input
id="bpjs_number"
v-bind="componentField"
placeholder="Pilih jenis pembayaran terlebih dahulu"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</Field>
</Cell>
<!-- Jenis SEP -->
<Cell :cosSpan="3">
<Label label-for="sep_type">Jenis SEP</Label>
<Field id="sep_type" :errors="errors">
<FormField v-slot="{ componentField }" name="sep_type">
<FormItem>
<FormControl>
<Select id="sep_type" v-bind="componentField" :items="sepOpts" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</Field>
</Cell>
</Block>
<Block :colCount="3">
<!-- No. SEP (input + tombol +) -->
<Cell :cosSpan="3">
<Label label-for="sep_number">No. SEP</Label>
<Field id="sep_number" :errors="errors">
<FormField v-slot="{ componentField }" name="sep_number">
<FormItem>
<FormControl>
<div class="flex gap-2">
<Input
id="sep_number"
v-bind="componentField"
placeholder="Tambah SEP terlebih dahulu"
class="flex-1"
/>
<Button class="bg-primary" size="sm" variant="outline" @click.prevent="onAddSep">+</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</Field>
</Cell>
<!-- Dokumen SEP (file) -->
<Cell :cosSpan="3">
<Label label-for="sep_file">Dokumen SEP</Label>
<Field id="sep_file" :errors="errors">
<FormField v-slot="{ componentField }" name="sep_file">
<FormItem>
<FormControl>
<div class="flex items-center gap-2">
<input ref="sepFileInput" type="file" class="hidden" @change="onSepFileChange" />
<Button class="bg-primary" size="sm" variant="ghost" @click.prevent="pickSepFile"
>Pilih Berkas</Button
>
<Input readonly v-bind="componentField" placeholder="Unggah dokumen SEP" />
</div>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</Field>
</Cell>
<!-- Dokumen SIPP (file) -->
<Cell :cosSpan="3" labelSize="thin">
<Label label-for="sipp_file">Dokumen SIPP</Label>
<Field id="sipp_file" :errors="errors">
<FormField v-slot="{ componentField }" name="sipp_file">
<FormItem>
<FormControl>
<div class="flex items-center gap-2">
<input ref="sippFileInput" type="file" class="hidden" @change="onSippFileChange" />
<Button class="bg-primary" size="sm" variant="ghost" @click.prevent="pickSippFile"
>Pilih Berkas</Button
>
<Input readonly v-bind="componentField" placeholder="Unggah dokumen SIPP" />
</div>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</Field>
</Cell>
</Block>
<div class="flex flex-col justify-between">
<div class="mb-2 2xl:mb-3 text-sm 2xl:text-base font-semibold">
Data Pasien
</div>
<div class="flex gap-6 mb-2 2xl:mb-2">
<span>
Sudah pernah terdaftar sebagai pasien?
<Button class="bg-primary" size="sm" @click.prevent="emit('click', 'search')">
<Icon name="i-lucide-search" class="mr-1" /> Cari Pasien
</Button>
</span>
<span>
Belum pernah terdaftar sebagai pasien?
<Button class="bg-primary" size="sm" @click.prevent="emit('click', 'add')">
<Icon name="i-lucide-plus" class="mr-1" /> Tambah Pasien Baru
</Button>
</span>
</div>
<Block :colCount="3">
<Cell>
<Label label-for="patient_name">Nama Pasien</Label>
<Field id="patient_name" :errors="errors">
<FormField v-slot="{ componentField }" name="patient_name">
<FormItem>
<FormControl>
<Input
id="patient_name"
v-bind="componentField"
disabled
placeholder="Tambah data pasien terlebih dahulu"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</Field>
</Cell>
<!-- NIK -->
<Cell :cosSpan="3">
<Label label-for="nik">NIK</Label>
<Field id="nik" :errors="errors">
<FormField v-slot="{ componentField }" name="nik">
<FormItem>
<FormControl>
<Input id="nik" v-bind="componentField" disabled placeholder="Otomatis" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</Field>
</Cell>
<Cell>
<Label label-for="rm">No. RM</Label>
<Field id="rm" :errors="errors">
<FormField v-slot="{ componentField }" name="rm">
<FormItem>
<FormControl>
<Input id="rm" v-bind="componentField" disabled placeholder="RM99222" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</Field>
</Cell>
</Block>
<Separator class="my-4 2xl:my-5" />
<div class="mb-2 2xl:mb-3 text-sm 2xl:text-base font-semibold">
Data Kunjungan
</div>
<Block :colCount="3">
<!-- Dokter (Combobox) -->
<Cell :cosSpan="3">
<Label label-for="doctor_id">Dokter</Label>
<Field id="doctor_id" :errors="errors">
<FormField v-slot="{ componentField }" name="doctor_id">
<FormItem>
<FormControl>
<Combobox id="doctor_id" v-bind="componentField" :items="doctorOpts" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</Field>
</Cell>
<!-- Tanggal Daftar (DatePicker) -->
<Cell :cosSpan="3">
<Label label-for="register_date">Tanggal Daftar</Label>
<Field id="register_date" :errors="errors">
<FormField v-slot="{ componentField }" name="register_date">
<FormItem>
<FormControl>
<DatepickerSingle v-bind="componentField" placeholder="Pilih tanggal" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</Field>
</Cell>
<!-- Jenis Pembayaran (Combobox) -->
<Cell :cosSpan="3">
<Label label-for="payment_type">Jenis Pembayaran</Label>
<Field id="payment_type" :errors="errors">
<FormField v-slot="{ componentField }" name="payment_type">
<FormItem>
<FormControl>
<!-- <Combobox id="payment_type" v-bind="componentField" :items="paymentOpts" /> -->
<Select id="payment_type" v-bind="componentField" :items="paymentOpts" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</Field>
</Cell>
</Block>
<Block :colCount="3">
<Cell :cosSpan="3">
<Label label-for="bpjs_number">Kelompok Peserta</Label>
<Field id="bpjs_number" :errors="errors">
<FormField v-slot="{ componentField }" name="bpjs_number">
<FormItem>
<FormControl>
<Input
id="bpjs_number"
v-bind="componentField"
placeholder="Pilih jenis pembayaran terlebih dahulu"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</Field>
</Cell>
<!-- No. Kartu BPJS -->
<Cell :cosSpan="3">
<Label label-for="bpjs_number">No. Kartu BPJS</Label>
<Field id="bpjs_number" :errors="errors">
<FormField v-slot="{ componentField }" name="bpjs_number">
<FormItem>
<FormControl>
<Input
id="bpjs_number"
v-bind="componentField"
placeholder="Pilih jenis pembayaran terlebih dahulu"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</Field>
</Cell>
<!-- Jenis SEP -->
<Cell :cosSpan="3">
<Label label-for="sep_type">Jenis SEP</Label>
<Field id="sep_type" :errors="errors">
<FormField v-slot="{ componentField }" name="sep_type">
<FormItem>
<FormControl>
<Select id="sep_type" v-bind="componentField" :items="sepOpts" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</Field>
</Cell>
</Block>
<Block :colCount="3">
<!-- No. SEP (input + tombol +) -->
<Cell :cosSpan="3">
<Label label-for="sep_number">No. SEP</Label>
<Field id="sep_number" :errors="errors">
<FormField v-slot="{ componentField }" name="sep_number">
<FormItem>
<FormControl>
<div class="flex gap-2">
<Input
id="sep_number"
v-bind="componentField"
placeholder="Tambah SEP terlebih dahulu"
class="flex-1"
/>
<Button class="bg-primary" size="sm" variant="outline" @click.prevent="onAddSep">+</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</Field>
</Cell>
<!-- Dokumen SEP (file) -->
<Cell :cosSpan="3">
<Label label-for="sep_file">Dokumen SEP</Label>
<Field id="sep_file" :errors="errors">
<FormField v-slot="{ componentField }" name="sep_file">
<FormItem>
<FormControl>
<div class="flex items-center gap-2">
<input ref="sepFileInput" type="file" class="hidden" @change="onSepFileChange" />
<Button class="bg-primary" size="sm" variant="ghost" @click.prevent="pickSepFile"
>Pilih Berkas</Button
>
<Input readonly v-bind="componentField" placeholder="Unggah dokumen SEP" />
</div>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</Field>
</Cell>
<!-- Dokumen SIPP (file) -->
<Cell :cosSpan="3" labelSize="thin">
<Label label-for="sipp_file">Dokumen SIPP</Label>
<Field id="sipp_file" :errors="errors">
<FormField v-slot="{ componentField }" name="sipp_file">
<FormItem>
<FormControl>
<div class="flex items-center gap-2">
<input ref="sippFileInput" type="file" class="hidden" @change="onSippFileChange" />
<Button class="bg-primary" size="sm" variant="ghost" @click.prevent="pickSippFile"
>Pilih Berkas</Button
>
<Input readonly v-bind="componentField" placeholder="Unggah dokumen SIPP" />
</div>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</Field>
</Cell>
</Block>
</div>
</form>
</Form>
@@ -0,0 +1,54 @@
<script setup lang="ts">
import type { FormErrors } from '~/types/error'
import Select from '~/components/pub/my-ui/form/select.vue'
import { Input } from '~/components/pub/ui/input'
import { cn } from '~/lib/utils'
import * as DE from '~/components/pub/my-ui/doc-entry'
defineProps<{
fieldNameInput: string
placeholder: string
labelForInput: string
errors?: FormErrors
class?: string
selectClass?: string
fieldGroupClass?: string
labelClass?: string
maxLength?: number
isRequired?: boolean
}>()
</script>
<template>
<DE.Cell :col-span="2">
<DE.Label
:label-for="fieldNameInput"
:is-required="isRequired"
>
{{ labelForInput }}
</DE.Label>
<DE.Field
:id="fieldNameInput"
:errors="errors"
>
<FormField
v-slot="{ componentField }"
:name="fieldNameInput"
>
<FormItem>
<FormControl>
<Input
v-bind="componentField"
type="text"
:placeholder="placeholder"
:class="cn('focus:border-primary focus:ring-2 focus:ring-primary focus:ring-offset-0')"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</DE.Field>
</DE.Cell>
</template>
@@ -1,102 +0,0 @@
<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 Select from '~/components/pub/my-ui/form/select.vue'
import { Input } from '~/components/pub/ui/input'
import { cn } from '~/lib/utils'
defineProps<{
fieldNameAlias: string
fieldNameInput: string
placeholder: string
labelForAlias: string
labelForInput: string
errors?: FormErrors
class?: string
selectClass?: string
fieldGroupClass?: string
labelClass?: string
maxLength?: number
isRequired?: boolean
}>()
const aliasOptions = [
{ label: 'An', value: 'an' },
{ label: 'By.Ny', value: 'byny' },
{ label: 'Nn', value: 'nn' },
{ label: 'Ny', value: 'ny' },
{ label: 'Tn', value: 'tn' },
]
</script>
<template>
<FieldGroup>
<Label
:label-for="fieldNameAlias"
:is-required="isRequired"
>
{{ labelForAlias }}
</Label>
<Field
:id="fieldNameAlias"
:errors="errors"
>
<FormField
v-slot="{ componentField }"
:name="fieldNameAlias"
>
<FormItem>
<FormControl>
<Select
:id="fieldNameAlias"
:preserve-order="false"
v-bind="componentField"
:auto-width="true"
:items="aliasOptions"
: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>
</Field>
</FieldGroup>
<FieldGroup>
<Label
:label-for="fieldNameInput"
:is-required="isRequired"
>
{{ labelForInput }}
</Label>
<Field
:id="fieldNameInput"
:errors="errors"
>
<FormField
v-slot="{ componentField }"
:name="fieldNameInput"
>
<FormItem>
<FormControl>
<Input
v-bind="componentField"
type="text"
:placeholder="placeholder"
:class="cn('focus:border-primary focus:ring-2 focus:ring-primary focus:ring-offset-0')"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</Field>
</FieldGroup>
</template>
@@ -1,12 +1,11 @@
<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 { Label as RadioLabel } from '~/components/pub/ui/label'
import { RadioGroup, RadioGroupItem } from '~/components/pub/ui/radio-group'
import { cn } from '~/lib/utils'
import * as DE from '~/components/pub/my-ui/doc-entry'
const props = defineProps<{
fieldName?: string
label?: string
@@ -35,18 +34,19 @@ const genderOptions = [
</script>
<template>
<FieldGroup :class="cn('radio-group-field', containerClass)">
<Label
<DE.Cell :class="cn('radio-group-field', containerClass)">
<DE.Label
:label-for="fieldName"
:is-required="isRequired"
height="compact"
>
{{ label }}
</Label>
<Field
</DE.Label>
<DE.Field
:id="fieldName"
:errors="errors"
>
class="pt-0.5"
>
<FormField
v-slot="{ componentField }"
:name="fieldName"
@@ -67,7 +67,7 @@ const genderOptions = [
:value="option.value"
:class="
cn(
'relative h-4 w-4 rounded-full border-2 border-muted-foreground before:absolute before:inset-1 before:rounded-full before:bg-primary before:opacity-0 before:transition-opacity data-[state=checked]:border-primary data-[state=checked]:bg-white data-[state=checked]:before:opacity-100 sm:h-5 sm:w-5',
'relative h-4 w-4 rounded-full border-muted-foreground before:absolute before:inset-1 before:rounded-full before:bg-primary before:opacity-0 before:transition-opacity data-[state=checked]:border-primary data-[state=checked]:bg-white data-[state=checked]:before:opacity-100 sm:h-5 sm:w-5',
containerClass,
)
"
@@ -76,7 +76,7 @@ const genderOptions = [
:for="`${fieldName}-${index}`"
:class="
cn(
'cursor-pointer select-none text-xs font-medium leading-none transition-colors hover:text-primary peer-disabled:cursor-not-allowed peer-disabled:opacity-70 sm:text-sm',
'cursor-pointer select-none font-normal text-xs leading-none transition-colors hover:text-primary peer-disabled:cursor-not-allowed peer-disabled:opacity-70 sm:text-sm',
labelClass,
)
"
@@ -89,6 +89,6 @@ const genderOptions = [
<FormMessage class="ml-0 mt-1" />
</FormItem>
</FormField>
</Field>
</FieldGroup>
</DE.Field>
</DE.Cell>
</template>
@@ -1,12 +1,11 @@
<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 { Label as RadioLabel } from '~/components/pub/ui/label'
import { RadioGroup, RadioGroupItem } from '~/components/pub/ui/radio-group'
import { cn } from '~/lib/utils'
import * as DE from '~/components/pub/my-ui/doc-entry'
const props = defineProps<{
fieldName?: string
label?: string
@@ -35,17 +34,18 @@ const dissabilityOptions = [
</script>
<template>
<FieldGroup :class="cn('radio-group-field', containerClass)">
<Label
<DE.Cell :class="cn('radio-group-field', containerClass)">
<DE.Label
:label-for="fieldName"
:is-required="isRequired"
height="compact"
>
{{ label }}
</Label>
<Field
</DE.Label>
<DE.Field
:id="fieldName"
:errors="errors"
class="pt-0.5"
>
<FormField
v-slot="{ componentField }"
@@ -67,7 +67,7 @@ const dissabilityOptions = [
:value="option.value"
:class="
cn(
'relative h-4 w-4 rounded-full border-2 border-muted-foreground before:absolute before:inset-1 before:rounded-full before:bg-primary before:opacity-0 before:transition-opacity data-[state=checked]:border-primary data-[state=checked]:bg-white data-[state=checked]:before:opacity-100 sm:h-5 sm:w-5',
'relative h-4 w-4 rounded-full border-1 border-muted-foreground before:absolute before:inset-1 before:rounded-full before:bg-primary before:opacity-0 before:transition-opacity data-[state=checked]:border-primary data-[state=checked]:bg-white data-[state=checked]:before:opacity-100 sm:h-5 sm:w-5',
containerClass,
)
"
@@ -76,7 +76,7 @@ const dissabilityOptions = [
:for="`${fieldName}-${index}`"
:class="
cn(
'cursor-pointer select-none text-xs font-medium leading-none transition-colors hover:text-primary peer-disabled:cursor-not-allowed peer-disabled:opacity-70 sm:text-sm',
'cursor-pointer select-none text-xs !font-normal leading-none transition-colors hover:text-primary peer-disabled:cursor-not-allowed peer-disabled:opacity-70 sm:text-sm',
labelClass,
)
"
@@ -89,6 +89,6 @@ const dissabilityOptions = [
<FormMessage class="ml-0 mt-1" />
</FormItem>
</FormField>
</Field>
</FieldGroup>
</DE.Field>
</DE.Cell>
</template>
@@ -1,13 +1,12 @@
<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 { Label as RadioLabel } from '~/components/pub/ui/label'
import { RadioGroup, RadioGroupItem } from '~/components/pub/ui/radio-group'
import { genderCodes } from '~/lib/constants'
import { cn, mapToComboboxOptList } from '~/lib/utils'
import * as DE from '~/components/pub/my-ui/doc-entry'
const props = defineProps<{
fieldName?: string
label?: string
@@ -33,15 +32,15 @@ const genderOptions = mapToComboboxOptList(genderCodes)
</script>
<template>
<FieldGroup :class="cn('radio-group-field', containerClass)">
<Label
<DE.Cell :class="cn('radio-group-field', containerClass)" :col-span="4">
<DE.Label
:label-for="fieldName"
:is-required="isRequired"
height="compact"
>
{{ label }}
</Label>
<Field
</DE.Label>
<DE.Field
:id="fieldName"
:errors="errors"
>
@@ -65,7 +64,7 @@ const genderOptions = mapToComboboxOptList(genderCodes)
:value="option.value"
:class="
cn(
'relative h-4 w-4 rounded-full border-2 border-muted-foreground before:absolute before:inset-1 before:rounded-full before:bg-primary before:opacity-0 before:transition-opacity data-[state=checked]:border-primary data-[state=checked]:bg-white data-[state=checked]:before:opacity-100 sm:h-5 sm:w-5',
'relative h-4 w-4 rounded-full border-1 border-muted-foreground before:absolute before:inset-1 before:rounded-full before:bg-primary before:opacity-0 before:transition-opacity data-[state=checked]:border-primary data-[state=checked]:bg-white data-[state=checked]:before:opacity-100 sm:h-5 sm:w-5',
containerClass,
)
"
@@ -74,7 +73,7 @@ const genderOptions = mapToComboboxOptList(genderCodes)
:for="`${fieldName}-${index}`"
:class="
cn(
'cursor-pointer select-none text-xs font-medium leading-none transition-colors hover:text-primary peer-disabled:cursor-not-allowed peer-disabled:opacity-70 sm:text-sm',
'cursor-pointer select-none text-xs 2xl:text-sm leading-none transition-colors hover:text-primary peer-disabled:cursor-not-allowed peer-disabled:opacity-70 !font-normal',
labelClass,
)
"
@@ -87,6 +86,6 @@ const genderOptions = mapToComboboxOptList(genderCodes)
<FormMessage class="ml-0 mt-1" />
</FormItem>
</FormField>
</Field>
</FieldGroup>
</DE.Field>
</DE.Cell>
</template>
@@ -1,12 +1,11 @@
<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 { Label as RadioLabel } from '~/components/pub/ui/label'
import { RadioGroup, RadioGroupItem } from '~/components/pub/ui/radio-group'
import { cn } from '~/lib/utils'
import * as DE from '~/components/pub/my-ui/doc-entry'
const props = defineProps<{
fieldName?: string
label?: string
@@ -35,17 +34,18 @@ const nationalityOptions = [
</script>
<template>
<FieldGroup :class="cn('radio-group-field', containerClass)">
<Label
<DE.Cell :class="cn('radio-group-field', containerClass)">
<DE.Label
:label-for="fieldName"
:is-required="isRequired"
height="compact"
>
{{ label }}
</Label>
<Field
</DE.Label>
<DE.Field
:id="fieldName"
:errors="errors"
class="pt-0.5"
>
<FormField
v-slot="{ componentField }"
@@ -67,7 +67,7 @@ const nationalityOptions = [
:value="option.value"
:class="
cn(
'relative h-4 w-4 rounded-full border-2 border-muted-foreground before:absolute before:inset-1 before:rounded-full before:bg-primary before:opacity-0 before:transition-opacity data-[state=checked]:border-primary data-[state=checked]:bg-white data-[state=checked]:before:opacity-100 sm:h-5 sm:w-5',
'relative h-4 w-4 rounded-full border-1 border-muted-foreground before:absolute before:inset-1 before:rounded-full before:bg-primary before:opacity-0 before:transition-opacity data-[state=checked]:border-primary data-[state=checked]:bg-white data-[state=checked]:before:opacity-100 sm:h-5 sm:w-5',
containerClass,
)
"
@@ -76,7 +76,7 @@ const nationalityOptions = [
:for="`${fieldName}-${index}`"
:class="
cn(
'cursor-pointer select-none text-xs font-medium leading-none transition-colors hover:text-primary peer-disabled:cursor-not-allowed peer-disabled:opacity-70 sm:text-sm',
'cursor-pointer select-none text-xs !font-normal leading-none transition-colors hover:text-primary peer-disabled:cursor-not-allowed peer-disabled:opacity-70 sm:text-sm',
labelClass,
)
"
@@ -89,6 +89,6 @@ const nationalityOptions = [
<FormMessage class="ml-0 mt-1" />
</FormItem>
</FormField>
</Field>
</FieldGroup>
</DE.Field>
</DE.Cell>
</template>
@@ -0,0 +1,92 @@
<script setup lang="ts">
import type { FormErrors } from '~/types/error'
import { Label as RadioLabel } from '~/components/pub/ui/label'
import { RadioGroup, RadioGroupItem } from '~/components/pub/ui/radio-group'
import { cn } from '~/lib/utils'
import * as DE from '~/components/pub/my-ui/doc-entry'
const props = defineProps<{
fieldName?: string
label?: string
errors?: FormErrors
class?: string
radioGroupClass?: string
radioItemClass?: string
labelClass?: string
isRequired?: boolean
}>()
const {
fieldName = 'isNewBorn',
label = 'Status Pasien',
errors,
class: containerClass,
radioGroupClass,
radioItemClass,
labelClass,
} = props
const newbornOptions = [
{ label: 'Ya', value: 'YA' },
{ label: 'Tidak', value: 'TIDAK' },
]
</script>
<template>
<DE.Cell :class="cn('radio-group-field', containerClass)" :col-span="2">
<DE.Label
:label-for="fieldName"
:is-required="isRequired"
>
{{ label }}
</DE.Label>
<DE.Field
:id="fieldName"
:errors="errors"
>
<FormField
v-slot="{ componentField }"
:name="fieldName"
>
<FormItem>
<FormControl>
<RadioGroup
v-bind="componentField"
:class="cn('flex flex-row flex-wrap gap-4 sm:gap-6', radioGroupClass)"
>
<div
v-for="(option, index) in newbornOptions"
:key="option.value"
:class="cn('flex min-w-fit items-center space-x-2 pt-1', radioItemClass)"
>
<RadioGroupItem
:id="`${fieldName}-${index}`"
:value="option.value"
:class="
cn(
'relative h-4 w-4 rounded-full border-muted-foreground before:absolute before:inset-1 before:rounded-full before:bg-primary before:opacity-0 before:transition-opacity data-[state=checked]:border-primary data-[state=checked]:bg-white data-[state=checked]:before:opacity-100 sm:h-5 sm:w-5',
containerClass,
)
"
/>
<RadioLabel
:for="`${fieldName}-${index}`"
:class="
cn(
'cursor-pointer select-none text-xs font-normal leading-none transition-colors hover:text-primary peer-disabled:cursor-not-allowed peer-disabled:opacity-70 sm:text-sm',
labelClass,
)
"
>
{{ option.label }}
</RadioLabel>
</div>
</RadioGroup>
</FormControl>
<FormMessage class="ml-0 mt-1" />
</FormItem>
</FormField>
</DE.Field>
</DE.Cell>
</template>
@@ -1,11 +1,10 @@
<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 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
@@ -40,14 +39,14 @@ const disabilityOptions = [
</script>
<template>
<FieldGroup :class="cn('select-field-group', fieldGroupClass, containerClass)">
<Label
<DE.Cell :class="cn('select-field-group', fieldGroupClass, containerClass)">
<DE.Label
label-for="fieldName"
:is-required="isRequired"
>
{{ label }}
</Label>
<Field
</DE.Label>
<DE.Field
:id="fieldName"
:errors="errors"
:class="cn('select-field-wrapper')"
@@ -76,6 +75,6 @@ const disabilityOptions = [
<FormMessage />
</FormItem>
</FormField>
</Field>
</FieldGroup>
</DE.Field>
</DE.Cell>
</template>
@@ -1,12 +1,11 @@
<script setup lang="ts">
import type { FormErrors } from '~/types/error'
import { differenceInDays, differenceInMonths, differenceInYears, parseISO } from 'date-fns'
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'
const props = defineProps<{
fieldName?: string
label?: string
@@ -76,15 +75,15 @@ function calculateAge(birthDate: string | Date | undefined): string {
</script>
<template>
<FieldGroup :class="cn('select-field-group', fieldGroupClass, containerClass)">
<Label
<DE.Cell :class="cn('select-field-group', fieldGroupClass, containerClass)">
<DE.Label
:label-for="fieldName"
:class="cn('select-field-label', labelClass)"
:is-required="isRequired"
>
{{ label }}
</Label>
<Field
</DE.Label>
<DE.Field
:id="fieldName"
:errors="errors"
:class="cn('select-field-wrapper')"
@@ -113,12 +112,11 @@ function calculateAge(birthDate: string | Date | undefined): string {
<FormMessage />
</FormItem>
</FormField>
</Field>
</FieldGroup>
<FieldGroup>
<Label label-for="patientAge">Usia</Label>
<Field id="patientAge">
</DE.Field>
</DE.Cell>
<DE.Cell>
<DE.Label label-for="patientAge">Usia</DE.Label>
<DE.Field id="patientAge">
<FormField name="patientAge">
<FormItem>
<FormControl>
@@ -137,6 +135,6 @@ function calculateAge(birthDate: string | Date | undefined): string {
<FormMessage />
</FormItem>
</FormField>
</Field>
</FieldGroup>
</DE.Field>
</DE.Cell>
</template>
@@ -1,12 +1,11 @@
<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 Select from '~/components/pub/my-ui/form/select.vue'
import { educationCodes } from '~/lib/constants'
import { cn, mapToComboboxOptList } from '~/lib/utils'
import * as DE from '~/components/pub/my-ui/doc-entry'
const props = defineProps<{
fieldName?: string
label?: string
@@ -46,15 +45,15 @@ const educationOptions = [
</script>
<template>
<FieldGroup :class="cn('select-field-group', fieldGroupClass, containerClass)">
<Label
<DE.Cell :class="cn('select-field-group', fieldGroupClass, containerClass)">
<DE.Label
:label-for="fieldName"
:class="cn('select-field-label', labelClass)"
:is-required="isRequired && !isDisabled"
>
{{ label }}
</Label>
<Field
</DE.Label>
<DE.Field
:id="fieldName"
:errors="errors"
:class="cn('select-field-wrapper')"
@@ -83,6 +82,6 @@ const educationOptions = [
<FormMessage />
</FormItem>
</FormField>
</Field>
</FieldGroup>
</DE.Field>
</DE.Cell>
</template>
@@ -1,11 +1,10 @@
<script setup lang="ts">
import type { FormErrors } from '~/types/error'
import Combobox from '~/components/pub/my-ui/combobox/combobox.vue'
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 { cn } from '~/lib/utils'
import * as DE from '~/components/pub/my-ui/doc-entry'
const props = defineProps<{
fieldName?: string
label?: string
@@ -49,15 +48,15 @@ const ethnicOptions = [
</script>
<template>
<FieldGroup :class="cn('select-field-group', fieldGroupClass, containerClass)">
<Label
<DE.Cell :class="cn('select-field-group', fieldGroupClass, containerClass)">
<DE.Label
:label-for="fieldName"
:class="cn('select-field-label', labelClass)"
:is-required="isRequired && !isDisabled"
>
{{ label }}
</Label>
<Field
</DE.Label>
<DE.Field
:id="fieldName"
:errors="errors"
:class="cn('select-field-wrapper')"
@@ -81,6 +80,6 @@ const ethnicOptions = [
<FormMessage />
</FormItem>
</FormField>
</Field>
</FieldGroup>
</DE.Field>
</DE.Cell>
</template>
@@ -0,0 +1,76 @@
<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 {
fieldName = 'disabilityType',
placeholder = 'Pilih jenis disabilitas',
errors,
class: containerClass,
selectClass,
fieldGroupClass,
} = props
const disabilityOptions = [
{ label: 'Laki', value: 'male' },
{ label: 'Perempuan', value: 'female' },
{ label: 'Tidak Disebutkan', value: 'not-stated' },
{ label: 'Tidak Diketahui', value: 'unknown' },
]
</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="disabilityOptions"
: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>
@@ -1,10 +1,10 @@
<script setup lang="ts">
import type { FormErrors } from '~/types/error'
import Combobox from '~/components/pub/my-ui/combobox/combobox.vue'
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 { cn } from '~/lib/utils'
import { cn, mapToComboboxOptList } from '~/lib/utils'
import { occupationCodes } from '~/lib/constants'
import * as DE from '~/components/pub/my-ui/doc-entry'
const props = defineProps<{
fieldName?: string
@@ -28,106 +28,20 @@ const {
labelClass,
} = props
const jobOptions = [
{ label: 'Tidak diketahui', value: 'unknown', priority: 100 },
{ label: 'Belum/Tidak Bekerja', value: 'tidak_bekerja', priority: 99 },
{ label: 'Mengurus Rumah Tangga', value: 'mengurus_rumah_tangga' },
{ label: 'Pelajar/Mahasiswa', value: 'pelajar' },
{ label: 'Pensiunan', value: 'pensiunan' },
{ label: 'Pegawai Negeri Sipil', value: 'pns' },
{ label: 'Tentara Nasional Indonesia', value: 'tni' },
{ label: 'Kepolisian RI', value: 'polri' },
{ label: 'Perdagangan', value: 'perdagangan' },
{ label: 'Petani/Pekebun', value: 'petani' },
{ label: 'Peternak', value: 'peternak' },
{ label: 'Nelayan/Perikanan', value: 'nelayan' },
{ label: 'Industri', value: 'industri' },
{ label: 'Konstruksi', value: 'konstruksi' },
{ label: 'Transportasi', value: 'transportasi' },
{ label: 'Karyawan Swasta', value: 'karyawan_swasta' },
{ label: 'Karyawan BUMN', value: 'karyawan_bumn' },
{ label: 'Karyawan BUMD', value: 'karyawan_bumd' },
{ label: 'Karyawan Honorer', value: 'karyawan_honorer' },
{ label: 'Buruh Harian Lepas', value: 'buruh_harian' },
{ label: 'Buruh Tani/Perkebunan', value: 'buruh_tani' },
{ label: 'Buruh Nelayan/Perikanan', value: 'buruh_nelayan' },
{ label: 'Buruh Peternakan', value: 'buruh_peternakan' },
{ label: 'Pembantu Rumah Tangga', value: 'pembantu_rumah_tangga' },
{ label: 'Tukang Cukur', value: 'tukang_cukur' },
{ label: 'Tukang Listrik', value: 'tukang_listrik' },
{ label: 'Tukang Batu', value: 'tukang_batu' },
{ label: 'Tukang Kayu', value: 'tukang_kayu' },
{ label: 'Tukang Sol Sepatu', value: 'tukang_sol_sepatu' },
{ label: 'Tukang Jahit', value: 'tukang_jahit' },
{ label: 'Tukang Gigi', value: 'tukang_gigi' },
{ label: 'Penata Rias', value: 'penata_rias' },
{ label: 'Penata Busana', value: 'penata_busana' },
{ label: 'Penata Rambut', value: 'penata_rambut' },
{ label: 'Mekanik', value: 'mekanik' },
{ label: 'Seniman', value: 'seniman' },
{ label: 'Tabib', value: 'tabib' },
{ label: 'Paraji', value: 'paraji' },
{ label: 'Perancang Busana', value: 'perancang_busana' },
{ label: 'Penterjemah', value: 'penterjemah' },
{ label: 'Imam Mesjid', value: 'imam_mesjid' },
{ label: 'Pendeta', value: 'pendeta' },
{ label: 'Pastor', value: 'pastor' },
{ label: 'Wartawan', value: 'wartawan' },
{ label: 'Ustadz/Mubaligh', value: 'ustadz' },
{ label: 'Juru Masak', value: 'juru_masak' },
{ label: 'Promotor Acara', value: 'promotor' },
{ label: 'Anggota DPR-RI', value: 'dpr_ri' },
{ label: 'Anggota DPD', value: 'dpd' },
{ label: 'Anggota BPK', value: 'bpk' },
{ label: 'Presiden', value: 'presiden' },
{ label: 'Wakil Presiden', value: 'wakil_presiden' },
{ label: 'Anggota Mahkamah Konstitusi', value: 'mk' },
{ label: 'Anggota Kabinet/Kementrian', value: 'kabinet' },
{ label: 'Duta Besar', value: 'dubes' },
{ label: 'Gubernur', value: 'gubernur' },
{ label: 'Wakil Gubernur', value: 'wakil_gubernur' },
{ label: 'Bupati', value: 'bupati' },
{ label: 'Wakil Bupati', value: 'wakil_bupati' },
{ label: 'Walikota', value: 'walikota' },
{ label: 'Wakil Walikota', value: 'wakil_walikota' },
{ label: 'Anggota DPRD Provinsi', value: 'dprd_provinsi' },
{ label: 'Anggota DPRD Kabupaten/Kota', value: 'dprd_kabkota' },
{ label: 'Dosen', value: 'dosen' },
{ label: 'Guru', value: 'guru' },
{ label: 'Pilot', value: 'pilot' },
{ label: 'Pengacara', value: 'pengacara' },
{ label: 'Arsitek', value: 'arsitek' },
{ label: 'Akuntan', value: 'akuntan' },
{ label: 'Konsultan', value: 'konsultan' },
{ label: 'Dokter', value: 'dokter' },
{ label: 'Bidan', value: 'bidan' },
{ label: 'Apoteker', value: 'apoteker' },
{ label: 'Psikiater/Psikolog', value: 'psikolog' },
{ label: 'Penyiar Televisi', value: 'penyiar_tv' },
{ label: 'Penyiar Radio', value: 'penyiar_radio' },
{ label: 'Pelaut', value: 'pelaut' },
{ label: 'Sopir', value: 'sopir' },
{ label: 'Pialang', value: 'pialang' },
{ label: 'Paranormal', value: 'paranormal' },
{ label: 'Pedagang', value: 'pedagang' },
{ label: 'Perangkat Desa', value: 'perangkat_desa' },
{ label: 'Kepala Desa', value: 'kepala_desa' },
{ label: 'Biarawati', value: 'biarawati' },
{ label: 'Wiraswasta', value: 'wiraswasta' },
{ label: 'Lainnya', value: 'lainnya', priority: -100 },
]
// Generate job options from constants, sama seperti pola genderCodes
const jobOptions = mapToComboboxOptList(occupationCodes)
</script>
<template>
<FieldGroup :class="cn('select-field-group', fieldGroupClass, containerClass)">
<Label
<DE.Cell :class="cn('select-field-group', fieldGroupClass, containerClass)">
<DE.Label
:label-for="fieldName"
:class="cn('select-field-label', labelClass)"
:is-required="isRequired"
>
{{ label }}
</Label>
<Field
</DE.Label>
<DE.Field
:id="fieldName"
:errors="errors"
:class="cn('select-field-wrapper')"
@@ -150,6 +64,6 @@ const jobOptions = [
<FormMessage />
</FormItem>
</FormField>
</Field>
</FieldGroup>
</DE.Field>
</DE.Cell>
</template>
@@ -1,11 +1,10 @@
<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 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
@@ -41,15 +40,15 @@ const langOptions = [
</script>
<template>
<FieldGroup :class="cn('select-field-group', fieldGroupClass, containerClass)">
<Label
<DE.Cell :class="cn('select-field-group', fieldGroupClass, containerClass)">
<DE.Label
:label-for="fieldName"
:class="cn('select-field-label', labelClass)"
:is-required="isRequired"
>
{{ label }}
</Label>
<Field
</DE.Label>
<DE.Field
:id="fieldName"
:errors="errors"
:class="cn('select-field-wrapper')"
@@ -77,6 +76,6 @@ const langOptions = [
<FormMessage />
</FormItem>
</FormField>
</Field>
</FieldGroup>
</DE.Field>
</DE.Cell>
</template>
@@ -1,11 +1,10 @@
<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 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
@@ -37,15 +36,15 @@ const maritalStatusOptions = [
</script>
<template>
<FieldGroup :class="cn('select-field-group', fieldGroupClass, containerClass)">
<Label
<DE.Cell :class="cn('select-field-group', fieldGroupClass, containerClass)">
<DE.Label
:label-for="fieldName"
:class="cn('select-field-label', labelClass)"
:is-required="isRequired"
>
{{ label }}
</Label>
<Field
</DE.Label>
<DE.Field
:id="fieldName"
:errors="errors"
:class="cn('select-field-wrapper')"
@@ -73,6 +72,6 @@ const maritalStatusOptions = [
<FormMessage />
</FormItem>
</FormField>
</Field>
</FieldGroup>
</DE.Field>
</DE.Cell>
</template>
@@ -1,12 +1,11 @@
<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 Select from '~/components/pub/my-ui/form/select.vue'
import { religionCodes } from '~/lib/constants'
import { cn, mapToComboboxOptList } from '~/lib/utils'
import * as DE from '~/components/pub/my-ui/doc-entry'
const props = defineProps<{
fieldName?: string
label?: string
@@ -45,15 +44,15 @@ const religionOptions = [
</script>
<template>
<FieldGroup :class="cn('select-field-group', fieldGroupClass, containerClass)">
<Label
<DE.Cell :class="cn('select-field-group', fieldGroupClass, containerClass)">
<DE.Label
:label-for="fieldName"
:class="cn('select-field-label', labelClass)"
:is-required="isRequired"
>
{{ label }}
</Label>
<Field
</DE.Label>
<DE.Field
:id="fieldName"
:errors="errors"
:class="cn('select-field-wrapper')"
@@ -81,6 +80,6 @@ const religionOptions = [
<FormMessage />
</FormItem>
</FormField>
</Field>
</FieldGroup>
</DE.Field>
</DE.Cell>
</template>
+162 -176
View File
@@ -3,12 +3,14 @@ import type { FormErrors } from '~/types/error'
import { toTypedSchema } from '@vee-validate/zod'
import { Form } from '~/components/pub/ui/form'
import InputBase from '~/components/pub/my-ui/form/input-base.vue'
import InputFile from './_common/input-file.vue'
import InputPatientName from './_common/input-patient-name.vue'
import FileUpload from '~/components/pub/my-ui/form/file-field.vue'
import InputName from './_common/input-name.vue'
import RadioCommunicationBarrier from './_common/radio-communication-barrier.vue'
import RadioDisability from './_common/radio-disability.vue'
import RadioGender from './_common/radio-gender.vue'
import SelectGender from './_common/select-gender.vue'
import RadioNationality from './_common/radio-nationality.vue'
import RadioNewborn from './_common/radio-newborn.vue'
import SelectBirthPlace from '~/components/app/person/_common/select-birth-place.vue'
import SelectDisability from './_common/select-disability.vue'
import SelectDob from './_common/select-dob.vue'
import SelectEducation from './_common/select-education.vue'
@@ -17,6 +19,9 @@ import SelectJob from './_common/select-job.vue'
import SelectLanguage from './_common/select-lang.vue'
import SelectMaritalStatus from './_common/select-marital-status.vue'
import SelectReligion from './_common/select-religion.vue'
import Separator from '~/components/pub/ui/separator/Separator.vue'
import * as DE from '~/components/pub/my-ui/doc-entry'
const props = defineProps<{
schema: any
@@ -46,180 +51,161 @@ defineExpose({
validation-mode="onSubmit"
:initial-values="initialValues ? initialValues : {}"
>
<div class="mb-3 border-b border-b-slate-300">
<p class="text-md mt-1 font-semibold">Data Diri Pasien</p>
<div class="grid grid-cols-1 md:grid-cols-[150px_1fr]">
<InputPatientName
field-name-alias="alias"
field-name-input="fullName"
label-for-alias="Alias"
label-for-input="Nama Lengkap"
placeholder="Masukkan nama lengkap pasien"
:errors="errors"
is-required
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-3">
<InputBase
field-name="birthPlace"
label="Tempat Lahir"
placeholder="Malang"
:errors="errors"
is-required
/>
<SelectDob
label="Tanggal Lahir"
:errors="errors"
is-required
/>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3">
<RadioGender
field-name="gender"
label="Jenis Kelamin"
placeholder="Pilih jenis kelamin"
:errors="errors"
is-required
/>
<RadioNationality
field-name="nationality"
label="Kebangsaan"
placeholder="Pilih kebangsaan"
:errors="errors"
is-required
/>
</div>
</div>
<p class="text-sm 2xl:text-base mb-2 2xl:mb-3 font-semibold">Data Diri Pasien</p>
<DE.Block :col-count="4" :cell-flex="false">
<InputBase
field-name="identityNumber"
label="No. KTP"
placeholder="Masukkan NIK"
:errors="errors"
numeric-only
/>
<InputBase
field-name="drivingLicenseNumber"
label="No. SIM"
placeholder="Masukkan nomor SIM"
numeric-only
:max-length="20"
:errors="errors"
/>
<InputBase
field-name="passportNumber"
label="No. Paspor"
placeholder="Masukkan nomor paspor"
:max-length="20"
:errors="errors"
/>
<InputName
field-name-alias="alias"
field-name-input="fullName"
label-for-input="Nama Lengkap"
placeholder="Masukkan nama lengkap pasien"
:errors="errors"
is-required
/>
<RadioNewborn
field-name="isNewBorn"
label="Pasien Bayi"
placeholder="Pilih status pasien"
:errors="errors"
is-required
/>
<SelectGender
field-name="gender"
label="Jenis Kelamin"
placeholder="Pilih jenis kelamin"
:errors="errors"
is-required
/>
<SelectBirthPlace
field-name="birthPlace"
label="Tempat Lahir"
placeholder="Pilih tempat lahir"
:errors="errors"
is-required
/>
<SelectDob
label="Tanggal Lahir"
:errors="errors"
is-required
/>
<SelectEducation
field-name="education"
label="Pendidikan"
placeholder="Pilih pendidikan"
:errors="errors"
is-required
/>
<SelectJob
field-name="job"
label="Pekerjaan"
placeholder="Pilih pekerjaan"
:errors="errors"
is-required
/>
<SelectMaritalStatus
field-name="maritalStatus"
label="Status Perkawinan"
placeholder="Pilih status Perkawinan"
:errors="errors"
is-required
/>
<DE.Cell />
<RadioNationality
field-name="nationality"
label="Kebangsaan"
placeholder="Pilih kebangsaan"
:errors="errors"
is-required
/>
<SelectEthnicity
field-name="ethnicity"
label="Suku"
placeholder="Pilih suku bangsa"
:errors="errors"
:is-disabled="values.nationality !== 'WNI'"
/>
<SelectLanguage
field-name="language"
label="Bahasa"
placeholder="Pilih preferensi bahasa"
:errors="errors"
is-required
/>
<SelectReligion
field-name="religion"
label="Agama"
placeholder="Pilih agama"
:errors="errors"
is-required
/>
<RadioCommunicationBarrier
field-name="communicationBarrier"
label="Hambatan Berkomunikasi"
:errors="errors"
is-required
/>
<RadioDisability
field-name="disability"
label="Disabilitas"
:errors="errors"
is-required
/>
<SelectDisability
label="Jenis Disabilitas"
field-name="disabilityType"
:errors="errors"
:is-disabled="values.disability !== 'YA'"
:is-required="values.disability === 'YA'"
/>
<InputBase
field-name="note"
label="Kepercayaan"
placeholder="Contoh: tidak ingin diperiksa oleh dokter laki-laki"
:errors="errors"
/>
</DE.Block>
<div class="mb-3 border-b border-b-slate-300">
<p class="text-md mt-1 font-semibold">Dokumen Identitas</p>
<div class="h-6"></div>
<div class="grid grid-cols-1 md:grid-cols-3">
<InputBase
field-name="identityNumber"
label="No. KTP"
placeholder="Masukkan NIK"
:errors="errors"
numeric-only
:max-length="16"
is-required
/>
<InputBase
field-name="drivingLicenseNumber"
label="No. SIM"
placeholder="Masukkan nomor SIM"
numeric-only
:max-length="20"
:errors="errors"
/>
<InputBase
field-name="passportNumber"
label="No. Paspor"
placeholder="Masukkan nomor paspor"
:max-length="20"
:errors="errors"
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-2">
<InputFile
field-name="identityCardFile"
label="Dokumen KTP"
placeholder="Unggah scan dokumen KTP"
:errors="errors"
/>
<InputFile
field-name="familyCardFile"
label="Dokumen KK"
placeholder="Unggah scan dokumen KK"
:errors="errors"
/>
</div>
</div>
<div class="mb-3 border-b border-b-slate-300">
<p class="text-md mt-1 font-semibold">Data Demografis</p>
<div class="grid grid-cols-1 md:grid-cols-3">
<SelectReligion
field-name="religion"
label="Agama"
placeholder="Pilih agama"
:errors="errors"
is-required
/>
<SelectEthnicity
field-name="ethnicity"
label="Suku"
placeholder="Pilih suku bangsa"
:errors="errors"
:is-disabled="values.nationality !== 'WNI'"
/>
<SelectLanguage
field-name="language"
label="Bahasa"
placeholder="Pilih preferensi bahasa"
:errors="errors"
is-required
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-3">
<SelectMaritalStatus
field-name="maritalStatus"
label="Status Perkawinan"
placeholder="Pilih status Perkawinan"
:errors="errors"
is-required
/>
<SelectEducation
field-name="education"
label="Pendidikan"
placeholder="Pilih pendidikan"
:errors="errors"
is-required
/>
<SelectJob
field-name="job"
label="Pekerjaan"
placeholder="Pilih pekerjaan"
:errors="errors"
is-required
/>
</div>
</div>
<div class="mb-3 border-b border-b-slate-300">
<p class="text-md mt-1 font-semibold">Kondisi Khusus</p>
<div class="grid grid-cols-1 md:grid-cols-3">
<RadioCommunicationBarrier
field-name="communicationBarrier"
label="Hambatan Berkomunikasi"
:errors="errors"
is-required
/>
<div class="cols-span-1">
<RadioDisability
field-name="disability"
label="Disabilitas"
:errors="errors"
is-required
/>
<SelectDisability
label="Jenis Disabilitas"
field-name="disabilityType"
:errors="errors"
:is-disabled="values.disability !== 'YA'"
:is-required="values.disability === 'YA'"
/>
</div>
<InputBase
field-name="note"
label="Kepercayaan"
placeholder="Contoh: tidak ingin diperiksa oleh dokter laki-laki"
:errors="errors"
/>
</div>
</div>
<p class="text-sm 2xl:text-base mb-2 2xl:mb-3 font-semibold">Dokumen Identitas</p>
<DE.Block :col-count="2" :cell-flex="false">
<FileUpload
field-name="identityCardFile"
label="Dokumen KTP"
placeholder="Unggah scan dokumen KTP"
:errors="errors"
:accept="['pdf', 'jpg', 'png']"
:max-size-mb="1"
/>
<FileUpload
field-name="familyCardFile"
label="Dokumen KK"
placeholder="Unggah scan dokumen KK"
:errors="errors"
:accept="['pdf', 'jpg', 'png']"
:max-size-mb="1"
/>
</DE.Block>
</Form>
</template>
+27 -26
View File
@@ -1,5 +1,5 @@
import type { Config } from '~/components/pub/my-ui/data-table'
import type { PatientEntity } from '~/models/patient'
import type { Patient } from '~/models/patient'
import { defineAsyncComponent } from 'vue'
import { educationCodes, genderCodes } from '~/lib/constants'
import { calculateAge } from '~/lib/utils'
@@ -11,8 +11,9 @@ export const config: Config = {
headers: [
[
{ label: 'ID' },
{ label: 'Nama' },
{ label: 'NIK' },
{ label: 'NIK/No. Paspor' },
{ label: 'Tgl Lahir' },
{ label: 'Umur' },
{ label: 'Jenis Kelamin' },
@@ -21,15 +22,7 @@ export const config: Config = {
],
],
keys: [
'name',
'identity_number',
'birth_date',
'patient_age',
'gender',
'education',
'action',
],
keys: ['patientId', 'name', 'identity_number', 'birth_date', 'patient_age', 'gender', 'education', 'action'],
delKeyNames: [
{ key: 'code', label: 'Kode' },
@@ -37,33 +30,41 @@ export const config: Config = {
],
parses: {
patientId: (rec: unknown): unknown => {
const patient = rec as Patient
return patient.number
},
name: (rec: unknown): unknown => {
const { person } = rec as PatientEntity
const { person } = rec as Patient
return person.name.trim()
},
identity_number: (rec: unknown): unknown => {
const { person } = rec as PatientEntity
if (person?.residentIdentityNumber?.substring(0, 5) === 'BLANK') {
return '(TANPA NIK)'
const { person } = rec as Patient
if (person.nationality == 'WNA') {
return person.passportNumber
}
return person.residentIdentityNumber
return person.residentIdentityNumber || '-'
},
birth_date: (rec: unknown): unknown => {
const { person } = rec as PatientEntity
if (typeof person.birthDate === 'object' && person.birthDate) {
return (person.birthDate as Date).toLocaleDateString()
} else if (typeof person.birthDate === 'string') {
return person.birthDate.substring(0, 10)
const { person } = rec as Patient
if (typeof person.birthDate == 'object' && person.birthDate) {
return (person.birthDate as Date).toLocaleDateString('id-ID')
} else if (typeof person.birthDate == 'string') {
return (person.birthDate as string).substring(0, 10)
}
return person.birthDate
},
patient_age: (rec: unknown): unknown => {
const { person } = rec as PatientEntity
const { person } = rec as Patient
return calculateAge(person.birthDate)
},
gender: (rec: unknown): unknown => {
const { person } = rec as PatientEntity
if (typeof person.gender_code === 'number' && person.gender_code >= 0) {
const { person } = rec as Patient
if (typeof person.gender_code == 'number' && person.gender_code >= 0) {
return person.gender_code
} else if (typeof person.gender_code === 'string' && person.gender_code) {
return genderCodes[person.gender_code] || '-'
@@ -71,8 +72,8 @@ export const config: Config = {
return '-'
},
education: (rec: unknown): unknown => {
const { person } = rec as PatientEntity
if (typeof person.education_code === 'number' && person.education_code >= 0) {
const { person } = rec as Patient
if (typeof person.education_code == 'number' && person.education_code >= 0) {
return person.education_code
} else if (typeof person.education_code === 'string' && person.education_code) {
return educationCodes[person.education_code] || '-'
+96
View File
@@ -0,0 +1,96 @@
import type { Config } from '~/components/pub/my-ui/data-table'
import type { Patient } from '~/models/patient'
import { defineAsyncComponent } from 'vue'
import { educationCodes, genderCodes } from '~/lib/constants'
import { calculateAge } from '~/lib/utils'
const action = defineAsyncComponent(() => import('~/components/pub/my-ui/data/dropdown-action-dud.vue'))
export const config: Config = {
cols: [{}, {}, {}, {}, {}, {}, {}, { width: 5 }],
headers: [
[
{ label: 'No. RM' },
{ label: 'Nama' },
{ label: 'No. KTP/SIM/Passpor' },
{ label: 'Tgl Lahir' },
{ label: 'Umur' },
{ label: 'Kelamin' },
{ label: 'Pendidikan' },
{ label: '' },
],
],
keys: ['number', 'person.name', 'identity_number', 'birth_date', 'patient_age', 'gender', 'education', 'action'],
delKeyNames: [
{ key: 'code', label: 'Kode' },
{ key: 'name', label: 'Nama' },
],
parses: {
patientId: (rec: unknown): unknown => {
const patient = rec as Patient
return patient.number
},
identity_number: (rec: unknown): unknown => {
const { person } = rec as Patient
if (person.nationality == 'WNA') {
return person.passportNumber
}
return person.residentIdentityNumber || '-'
},
birth_date: (rec: unknown): unknown => {
const { person } = rec as Patient
if (typeof person.birthDate == 'object' && person.birthDate) {
return (person.birthDate as Date).toLocaleDateString('id-ID')
} else if (typeof person.birthDate == 'string') {
return (person.birthDate as string).substring(0, 10)
}
return person.birthDate
},
patient_age: (rec: unknown): unknown => {
const { person } = rec as Patient
return calculateAge(person.birthDate)
},
gender: (rec: unknown): unknown => {
const { person } = rec as Patient
if (typeof person.gender_code == 'number' && person.gender_code >= 0) {
return person.gender_code
} else if (typeof person.gender_code === 'string' && person.gender_code) {
return genderCodes[person.gender_code] || '-'
}
return '-'
},
education: (rec: unknown): unknown => {
const { person } = rec as Patient
if (typeof person.education_code == 'number' && person.education_code >= 0) {
return person.education_code
} else if (typeof person.education_code === 'string' && person.education_code) {
return educationCodes[person.education_code] || '-'
}
return '-'
},
},
components: {
action(rec, idx) {
return {
idx,
rec: rec as object,
component: action,
}
},
},
htmls: {
patient_address(_rec) {
return '-'
},
},
}
+1 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { PaginationMeta } from '~/components/pub/my-ui/pagination/pagination.type'
import PaginationView from '~/components/pub/my-ui/pagination/pagination-view.vue'
import { config } from './list-cfg'
import { config } from './list.cfg'
interface Props {
data: any[]
+1 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { config } from './list-cfg'
import { config } from './list.cfg'
defineProps<{ data: any[] }>()
const modelValue = defineModel<any | null>()
+87 -40
View File
@@ -1,21 +1,23 @@
<script setup lang="ts">
import type { Person } from '~/models/person'
import type { PersonAddress } from '~/models/person-address'
import type { PersonContact } from '~/models/person-contact'
import type { PersonRelative } from '~/models/person-relative'
import type { Patient } from '~/models/patient'
import DetailRow from '~/components/pub/my-ui/form/view/detail-row.vue'
import DetailSection from '~/components/pub/my-ui/form/view/detail-section.vue'
import { formatAddress } from '~/models/person-address'
import { educationCodes, genderCodes, personContactTypes, relationshipCodes, religionCodes } from '~/lib/constants'
import {
addressLocationTypeCode,
educationCodes,
genderCodes,
occupationCodes,
personContactTypes,
relationshipCodes,
religionCodes,
} from '~/lib/constants'
import { mapToComboboxOptList } from '~/lib/utils'
// #region Props & Emits
const props = defineProps<{
person: Person
personAddresses: PersonAddress[]
personContacts: PersonContact[]
personRelatives: PersonRelative[]
patient: Patient
}>()
const emit = defineEmits<{
@@ -28,17 +30,28 @@ const emit = defineEmits<{
const genderOptions = mapToComboboxOptList(genderCodes)
const religionOptions = mapToComboboxOptList(religionCodes)
const educationOptions = mapToComboboxOptList(educationCodes)
const occupationOptions = mapToComboboxOptList(occupationCodes)
const relationshipOptions = mapToComboboxOptList(relationshipCodes)
const personContactTypeOptions = mapToComboboxOptList(personContactTypes)
const residentAddress = 'Jl. Puncak Borobudur Blok M No. 321, Lowokwaru, Kota Malang, Jawa Timur'
const primaryAddress = 'Perumahan Araya Cluster B, No 22, Blimbing, Kota Malang, Jawa Timur'
// Computed addresses from nested data
const domicileAddress = computed(() => {
const addresses = props.patient.person.addresses
const resident = addresses?.find((addr) => addr.locationType_code === 'domicile')
return formatAddress(resident)
})
const identityAddress = computed(() => {
const addresses = props.patient.person.addresses
const primary = addresses?.find((addr) => addr.locationType_code === 'identity')
return formatAddress(primary)
})
const patientAge = computed(() => {
if (!props.person.birthDate) {
if (!props.patient.person.birthDate) {
return '-'
}
const birthDate = new Date(props.person.birthDate)
const birthDate = new Date(props.patient.person.birthDate)
const today = new Date()
let age = today.getFullYear() - birthDate.getFullYear()
const monthDiff = today.getMonth() - birthDate.getMonth()
@@ -53,6 +66,7 @@ const patientAge = computed(() => {
// #endregion
// #region Functions
// #endregion region
// #region Utilities & event handlers
@@ -67,48 +81,53 @@ function onClick(type: string) {
<template>
<DetailSection title="Data Pasien">
<DetailRow label="Nomor ID">{{ person.id || '-' }}</DetailRow>
<DetailRow label="Sapaan">{{ person.alias || '-' }}</DetailRow>
<DetailRow label="Nama Lengkap">{{ person.name || '-' }}</DetailRow>
<DetailRow label="Nomor">{{ patient.number || '-' }}</DetailRow>
<DetailRow label="Nama Lengkap">{{ patient.person.name || '-' }}</DetailRow>
<DetailRow label="Tempat, tanggal lahir">
{{ person.birthRegency_code || '-' }},
{{ person.birthDate ? new Date(person.birthDate).toLocaleDateString() : '-' }}
{{ patient.person.birthRegency?.name || '-' }},
{{ patient.person.birthDate ? new Date(patient.person.birthDate).toLocaleDateString('id-ID') : '-' }}
</DetailRow>
<DetailRow label="Usia">{{ patientAge || '-' }}</DetailRow>
<DetailRow label="Usia">{{ patientAge || '-' }} Tahun</DetailRow>
<DetailRow label="Tanggal Daftar">
{{ person.createdAt ? new Date(person.createdAt).toLocaleDateString() : '-' }}
{{ patient.person.createdAt ? new Date(patient.person.createdAt).toLocaleDateString('id-ID') : '-' }}
</DetailRow>
<DetailRow label="Jenis Kelamin">
{{ genderOptions.find((item) => item.code === person.gender_code)?.label || '-' }}
{{ genderOptions.find((item) => item.code === patient.person.gender_code)?.label || '-' }}
</DetailRow>
<DetailRow label="NIK">{{ person.residentIdentityNumber || '-' }}</DetailRow>
<DetailRow label="No. SIM">{{ person.drivingLicenseNumber || '-' }}</DetailRow>
<DetailRow label="No. Paspor">{{ person.passportNumber || '-' }}</DetailRow>
<DetailRow label="NIK">{{ patient.person.residentIdentityNumber || '-' }}</DetailRow>
<DetailRow label="No. SIM">{{ patient.person.drivingLicenseNumber || '-' }}</DetailRow>
<DetailRow label="No. Paspor">{{ patient.person.passportNumber || '-' }}</DetailRow>
<DetailRow label="Agama">
{{ religionOptions.find((item) => item.code === person.religion_code)?.label || '-' }}
{{ religionOptions.find((item) => item.code === patient.person.religion_code)?.label || '-' }}
</DetailRow>
<DetailRow label="Suku">{{ person.ethnic_code || '-' }}</DetailRow>
<DetailRow label="Bahasa">{{ person.language_code || '-' }}</DetailRow>
<DetailRow label="Suku">{{ patient.person.ethnic?.name || '-' }}</DetailRow>
<DetailRow label="Bahasa">{{ patient.person.language?.name || '-' }}</DetailRow>
<DetailRow label="Pendidikan">
{{ educationOptions.find((item) => item.code === person.education_code)?.label || '-' }}
{{ educationOptions.find((item) => item.code === patient.person.education_code)?.label || '-' }}
</DetailRow>
<DetailRow label="Pekerjaan">
{{
occupationOptions.find((item) => item.code === patient.person.occupation_code)?.label ||
patient.person.occupation_name ||
'-'
}}
</DetailRow>
<DetailRow label="Pekerjaan">{{ person.occupation_name || '-' }}</DetailRow>
</DetailSection>
<DetailSection title="Alamat">
<DetailRow label="Alamat Domisili">{{ residentAddress || '-' }}</DetailRow>
<DetailRow label="Alamat KTP">{{ primaryAddress || '-' }}</DetailRow>
<DetailRow :label="addressLocationTypeCode.domicile || 'Alamat Domisili'">{{ domicileAddress || '-' }}</DetailRow>
<DetailRow :label="addressLocationTypeCode.identity || 'Alamat KTP'">{{ identityAddress || '-' }}</DetailRow>
</DetailSection>
<DetailSection title="Kontak">
<template v-if="personContacts && personContacts.length > 0">
<template v-if="patient.person.contacts && patient.person.contacts.length > 0">
<template
v-for="contactType in personContactTypeOptions"
:key="contactType.code"
>
<DetailRow :label="contactType.label">
{{ personContacts.find((item) => item.type_code === contactType.code)?.value || '-' }}
{{ patient.person.contacts.find((item) => item.type_code === contactType.code)?.value || '-' }}
</DetailRow>
</template>
</template>
@@ -116,10 +135,10 @@ function onClick(type: string) {
<DetailRow label="Kontak">-</DetailRow>
</template>
</DetailSection>
<DetailSection title="Penanggung Jawab">
<template v-if="personRelatives && personRelatives.filter((rel) => rel.responsible).length > 0">
<DetailSection title="Orang Tua">
<template v-if="patient.person.relatives && patient.person.relatives.filter((rel) => !rel.responsible).length > 0">
<template
v-for="(relative, index) in personRelatives.filter((rel) => rel.responsible)"
v-for="(relative, index) in patient.person.relatives.filter((rel) => !rel.responsible)"
:key="relative.id"
>
<div
@@ -130,13 +149,41 @@ function onClick(type: string) {
<DetailRow label="Hubungan">
{{ relationshipOptions.find((item) => item.code === relative.relationship_code)?.label || '-' }}
</DetailRow>
<DetailRow label="Jenis Kelamin">
<!-- <DetailRow label="Jenis Kelamin">
{{ genderOptions.find((item) => item.code === relative.gender_code)?.label || '-' }}
</DetailRow>
</DetailRow> -->
<DetailRow label="Pendidikan">
{{ educationOptions.find((item) => item.code === relative.education_code)?.label || '-' }}
</DetailRow>
<DetailRow label="Pekerjaan">{{ relative.occupation_name || '-' }}</DetailRow>
<DetailRow label="Pekerjaan">
{{
occupationOptions.find((item) => item.code === relative.occupation_code)?.label ||
relative.occupation_name ||
'-'
}}
</DetailRow>
<!-- <DetailRow label="Alamat">{{ relative.address || '-' }}</DetailRow> -->
<!-- <DetailRow label="Nomor HP">{{ relative.phoneNumber || '-' }}</DetailRow> -->
</template>
</template>
<template v-else>
<DetailRow label="Orang Tua">-</DetailRow>
</template>
</DetailSection>
<DetailSection title="Penanggung Jawab">
<template v-if="patient.person.relatives && patient.person.relatives.filter((rel) => rel.responsible).length > 0">
<template
v-for="(relative, index) in patient.person.relatives.filter((rel) => rel.responsible)"
:key="relative.id"
>
<div
v-if="index > 0"
class="mt-3 border-t border-gray-200 pt-3"
></div>
<DetailRow label="Nama">{{ relative.name || '-' }}</DetailRow>
<DetailRow label="Hubungan">
{{ relationshipOptions.find((item) => item.code === relative.relationship_code)?.label || '-' }}
</DetailRow>
<DetailRow label="Alamat">{{ relative.address || '-' }}</DetailRow>
<DetailRow label="Nomor HP">{{ relative.phoneNumber || '-' }}</DetailRow>
</template>
@@ -6,8 +6,11 @@ import Field from '~/components/pub/my-ui/form/field.vue'
import Label from '~/components/pub/my-ui/form/label.vue'
import { cn } from '~/lib/utils'
import * as DE from '~/components/pub/my-ui/doc-entry'
const props = defineProps<{
fieldName: string
fieldName?: string
regencyCode?: string
isDisabled?: boolean
placeholder?: string
errors?: FormErrors
@@ -17,24 +20,41 @@ const props = defineProps<{
isRequired?: boolean
}>()
const { placeholder = 'Pilih Kecamatan', errors, class: containerClass, selectClass, fieldGroupClass } = props
const {
fieldName = 'districtId',
placeholder = 'Pilih kecamatan',
errors,
class: containerClass,
fieldGroupClass,
} = props
const districtOptions = [
{ label: 'Kecamatan Lowokwaru', value: '18' },
{ label: 'Kecamatan Pakis', value: '33' },
{ label: 'Kecamatan Blimbing', value: '35' },
]
// Gunakan composable untuk mengelola data districts
const regencyCodeRef = toRef(props, 'regencyCode')
const { districtOptions, isLoading, error } = useDistricts(regencyCodeRef)
// Computed untuk menentukan placeholder berdasarkan state
const dynamicPlaceholder = computed(() => {
if (!props.regencyCode) return 'Pilih kabupaten/kota dahulu'
if (isLoading.value) return 'Memuat data kecamatan...'
if (error.value) return 'Gagal memuat data'
return placeholder
})
// Computed untuk menentukan apakah field disabled
const isFieldDisabled = computed(() => {
return props.isDisabled || !props.regencyCode || isLoading.value || !!error.value
})
</script>
<template>
<FieldGroup :class="cn('select-field-group', fieldGroupClass, containerClass)">
<Label
<DE.Cell :class="cn('select-field-group', fieldGroupClass, containerClass)">
<DE.Label
:label-for="fieldName"
:is-required="isRequired"
>
Kecamatan
</Label>
<Field
</DE.Label>
<DE.Field
:id="fieldName"
:errors="errors"
:class="cn('select-field-wrapper')"
@@ -49,15 +69,15 @@ const districtOptions = [
:id="fieldName"
v-bind="componentField"
:items="districtOptions"
:placeholder="placeholder"
:is-disabled="isDisabled"
search-placeholder="Cari..."
:placeholder="dynamicPlaceholder"
:is-disabled="isFieldDisabled"
search-placeholder="Cari kecamatan..."
empty-message="Kecamatan tidak ditemukan"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</Field>
</FieldGroup>
</DE.Field>
</DE.Cell>
</template>
@@ -6,8 +6,11 @@ import Field from '~/components/pub/my-ui/form/field.vue'
import Label from '~/components/pub/my-ui/form/label.vue'
import { cn } from '~/lib/utils'
import * as DE from '~/components/pub/my-ui/doc-entry'
const props = defineProps<{
fieldName: string
villageCode?: string
isDisabled?: boolean
placeholder?: string
errors?: FormErrors
@@ -17,37 +20,32 @@ const props = defineProps<{
isRequired?: boolean
}>()
const {
fieldName = 'zipCode',
placeholder = 'Kode Pos',
errors,
class: containerClass,
selectClass,
fieldGroupClass,
} = props
const { fieldName = 'postalRegion', placeholder = 'Kode Pos', errors, class: containerClass, fieldGroupClass } = props
const postalCodeOptions = [
{ label: '65120', value: '65120' },
{ label: '65121', value: '65121' },
{ label: '65123', value: '65123' },
{ label: '65124', value: '65124' },
{ label: '65125', value: '65125' },
{ label: '65126', value: '65126' },
{ label: '65127', value: '65127' },
{ label: '65128', value: '65128' },
{ label: '65129', value: '65129' },
]
const villageCodeRef = toRef(props, 'villageCode')
const { postalRegionOptions, isLoading, error } = usePostalRegion(villageCodeRef)
const dynamicPlaceholder = computed(() => {
if (!props.villageCode) return 'Pilih kelurahan terlebih dahulu'
if (isLoading.value) return 'Memuat kode pos...'
if (error.value) return 'Gagal memuat data'
return placeholder
})
const isFieldDisabled = computed(() => {
return props.isDisabled || !props.villageCode || isLoading.value || !!error.value
})
</script>
<template>
<FieldGroup :class="cn('select-field-group', fieldGroupClass, containerClass)">
<Label
<DE.Cell :class="cn('select-field-group', fieldGroupClass, containerClass)">
<DE.Label
:label-for="fieldName"
:is-required="isRequired"
>
Kode Pos
</Label>
<Field
</DE.Label>
<DE.Field
:id="fieldName"
:errors="errors"
:class="cn('select-field-wrapper')"
@@ -61,9 +59,9 @@ const postalCodeOptions = [
<Combobox
:id="fieldName"
v-bind="componentField"
:items="postalCodeOptions"
:placeholder="placeholder"
:is-disabled="isDisabled"
:items="postalRegionOptions"
:placeholder="dynamicPlaceholder"
:is-disabled="isFieldDisabled"
search-placeholder="Cari..."
empty-message="Kode pos tidak ditemukan"
/>
@@ -71,6 +69,6 @@ const postalCodeOptions = [
<FormMessage />
</FormItem>
</FormField>
</Field>
</FieldGroup>
</DE.Field>
</DE.Cell>
</template>
@@ -6,8 +6,10 @@ import Field from '~/components/pub/my-ui/form/field.vue'
import Label from '~/components/pub/my-ui/form/label.vue'
import { cn } from '~/lib/utils'
import * as DE from '~/components/pub/my-ui/doc-entry'
const props = defineProps<{
fieldName: string
fieldName?: string
isDisabled?: boolean
placeholder?: string
errors?: FormErrors
@@ -18,29 +20,38 @@ const props = defineProps<{
}>()
const {
fieldName = 'provinceId',
fieldName = 'provinceCode',
placeholder = 'Pilih provinsi',
errors,
class: containerClass,
fieldGroupClass,
} = props
const provinceList = [
{ label: 'Jawa Barat', value: '18' },
{ label: 'Jawa Tengah', value: '33' },
{ label: 'Jawa Timur', value: '35' },
]
// Gunakan composable untuk mengelola data provinces
const { provinceOptions, isLoading, error } = useProvinces()
// Computed untuk menentukan placeholder berdasarkan state
const dynamicPlaceholder = computed(() => {
if (isLoading.value) return 'Memuat data provinsi...'
if (error.value) return 'Gagal memuat data'
return placeholder
})
// Computed untuk menentukan apakah field disabled
const isFieldDisabled = computed(() => {
return props.isDisabled || isLoading.value || !!error.value
})
</script>
<template>
<FieldGroup :class="cn('select-field-group', fieldGroupClass, containerClass)">
<Label
<DE.Cell :class="cn('select-field-group', fieldGroupClass, containerClass)">
<DE.Label
:label-for="fieldName"
:is-required="isRequired"
>
Provinsi
</Label>
<Field
</DE.Label>
<DE.Field
:id="fieldName"
:errors="errors"
:class="cn('select-field-wrapper')"
@@ -54,16 +65,16 @@ const provinceList = [
<Combobox
:id="fieldName"
v-bind="componentField"
:items="provinceList"
:placeholder="placeholder"
:is-disabled="isDisabled"
search-placeholder="Cari..."
empty-message="Kecamatan tidak ditemukan"
:items="provinceOptions"
:placeholder="dynamicPlaceholder"
:is-disabled="isFieldDisabled"
search-placeholder="Cari provinsi..."
empty-message="Provinsi tidak ditemukan"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</Field>
</FieldGroup>
</DE.Field>
</DE.Cell>
</template>
@@ -6,8 +6,11 @@ import Field from '~/components/pub/my-ui/form/field.vue'
import Label from '~/components/pub/my-ui/form/label.vue'
import { cn } from '~/lib/utils'
import * as DE from '~/components/pub/my-ui/doc-entry'
const props = defineProps<{
fieldName: string
fieldName?: string
provinceCode?: string
isDisabled?: boolean
placeholder?: string
errors?: FormErrors
@@ -17,26 +20,41 @@ const props = defineProps<{
isRequired?: boolean
}>()
const { placeholder = 'Pilih kabupaten/kota', errors, class: containerClass, selectClass, fieldGroupClass } = props
const {
fieldName = 'regencyId',
placeholder = 'Pilih kabupaten/kota',
errors,
class: containerClass,
fieldGroupClass,
} = props
const regencyOptions = [
{ label: 'Kab. Sidoarjo', value: '32' },
{ label: 'Kab. Malang', value: '35' },
{ label: 'Kab. Mojokerto', value: '31' },
{ label: 'Kab. Lamongan', value: '30' },
{ label: 'Kota Malang', value: '18' },
]
// Gunakan composable untuk mengelola data regencies
const provinceCodeRef = toRef(props, 'provinceCode')
const { regencyOptions, isLoading, error } = useRegencies({ provinceCode: provinceCodeRef, enablePagination: false })
// Computed untuk menentukan placeholder berdasarkan state
const dynamicPlaceholder = computed(() => {
if (!props.provinceCode) return 'Pilih provinsi dahulu'
if (isLoading.value) return 'Memuat data kabupaten/kota...'
if (error.value) return 'Gagal memuat data'
return placeholder
})
// Computed untuk menentukan apakah field disabled
const isFieldDisabled = computed(() => {
return props.isDisabled || !props.provinceCode || isLoading.value || !!error.value
})
</script>
<template>
<FieldGroup :class="cn('select-field-group', fieldGroupClass, containerClass)">
<Label
<DE.Cell :class="cn('select-field-group', fieldGroupClass, containerClass)">
<DE.Label
:label-for="fieldName"
:is-required="isRequired"
>
Kabupaten/Kota
</Label>
<Field
</DE.Label>
<DE.Field
:id="fieldName"
:errors="errors"
:class="cn('select-field-wrapper')"
@@ -51,15 +69,15 @@ const regencyOptions = [
:id="fieldName"
v-bind="componentField"
:items="regencyOptions"
:placeholder="placeholder"
:is-disabled="isDisabled"
search-placeholder="Cari..."
:placeholder="dynamicPlaceholder"
:is-disabled="isFieldDisabled"
search-placeholder="Cari kabupaten/kota..."
empty-message="Kabupaten/kota tidak ditemukan"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</Field>
</FieldGroup>
</DE.Field>
</DE.Cell>
</template>
@@ -6,8 +6,11 @@ import Field from '~/components/pub/my-ui/form/field.vue'
import Label from '~/components/pub/my-ui/form/label.vue'
import { cn } from '~/lib/utils'
import * as DE from '~/components/pub/my-ui/doc-entry'
const props = defineProps<{
fieldName: string
fieldName?: string
districtCode?: string
isDisabled?: boolean
placeholder?: string
errors?: FormErrors
@@ -17,25 +20,41 @@ const props = defineProps<{
isRequired?: boolean
}>()
const { placeholder = 'Pilih Kelurahan', errors, class: containerClass, selectClass, fieldGroupClass } = props
const {
fieldName = 'villageId',
placeholder = 'Pilih kelurahan',
errors,
class: containerClass,
fieldGroupClass,
} = props
const villageOptions = [
{ label: 'Lowokwaru', value: '18' },
{ label: 'Dinoyo', value: '33' },
{ label: 'Blimbing', value: '35' },
{ label: 'Sawojajar', value: '36' },
]
// Gunakan composable untuk mengelola data villages
const districtCodeRef = toRef(props, 'districtCode')
const { villageOptions, isLoading, error } = useVillages(districtCodeRef)
// Computed untuk menentukan placeholder berdasarkan state
const dynamicPlaceholder = computed(() => {
if (!props.districtCode) return 'Pilih kecamatan dahulu'
if (isLoading.value) return 'Memuat data kelurahan...'
if (error.value) return 'Gagal memuat data'
return placeholder
})
// Computed untuk menentukan apakah field disabled
const isFieldDisabled = computed(() => {
return props.isDisabled || !props.districtCode || isLoading.value || !!error.value
})
</script>
<template>
<FieldGroup :class="cn('select-field-group', fieldGroupClass, containerClass)">
<Label
<DE.Cell :class="cn('select-field-group', fieldGroupClass, containerClass)">
<DE.Label
:label-for="fieldName"
:is-required="isRequired"
>
Kelurahan
</Label>
<Field
</DE.Label>
<DE.Field
:id="fieldName"
:errors="errors"
:class="cn('select-field-wrapper')"
@@ -50,15 +69,15 @@ const villageOptions = [
:id="fieldName"
v-bind="componentField"
:items="villageOptions"
:placeholder="placeholder"
:is-disabled="isDisabled"
search-placeholder="Cari..."
:placeholder="dynamicPlaceholder"
:is-disabled="isFieldDisabled"
search-placeholder="Cari kelurahan..."
empty-message="Kelurahan tidak ditemukan"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</Field>
</FieldGroup>
</DE.Field>
</DE.Cell>
</template>
@@ -6,7 +6,8 @@ import FieldGroup from '~/components/pub/my-ui/form/field-group.vue'
import Field from '~/components/pub/my-ui/form/field.vue'
import InputBase from '~/components/pub/my-ui/form/input-base.vue'
import Label from '~/components/pub/my-ui/form/label.vue'
import RadioResidence from './_common/radio-residence.vue'
import { Label as RadioLabel } from '~/components/pub/ui/label'
import { RadioGroup, RadioGroupItem } from '~/components/pub/ui/radio-group'
import SelectDistrict from './_common/select-district.vue'
import SelectPostal from './_common/select-postal.vue'
import SelectProvince from './_common/select-province.vue'
@@ -14,6 +15,8 @@ import SelectRegency from './_common/select-regency.vue'
import SelectVillage from './_common/select-village.vue'
import { Form } from '~/components/pub/ui/form'
import * as DE from '~/components/pub/my-ui/doc-entry'
const props = defineProps<{
title: string
conf?: {
@@ -39,18 +42,27 @@ let isResetting = false
// Field dependency map for placeholder
const fieldStates: Record<string, { dependsOn?: string; placeholder: string }> = {
regencyId: { dependsOn: 'provinceId', placeholder: 'Pilih provinsi dahulu' },
districtId: { dependsOn: 'regencyId', placeholder: 'Pilih kabupaten/kota dahulu' },
villageId: { dependsOn: 'districtId', placeholder: 'Pilih kecamatan dahulu' },
zipCode: { dependsOn: 'villageId', placeholder: 'Pilih kelurahan dahulu' },
regency_code: { dependsOn: 'province_code', placeholder: 'Pilih provinsi dahulu' },
district_code: { dependsOn: 'regency_code', placeholder: 'Pilih kabupaten/kota dahulu' },
village_code: { dependsOn: 'district_code', placeholder: 'Pilih kecamatan dahulu' },
postalRegion_code: { dependsOn: 'village_code', placeholder: 'Pilih kelurahan dahulu' },
address: { placeholder: 'Masukkan alamat' },
rt: { placeholder: '001' },
rw: { placeholder: '002' },
}
// Computed untuk konversi boolean ke string untuk radio group
const isSameAddressString = computed(() => {
const value = formRef.value?.values?.isSameAddress
if (typeof value === 'boolean') {
return value ? '1' : '0'
}
return value || '1'
})
// #region Function Helper
function getFieldState(field: string) {
const state = fieldStates[field]
const isSame = formRef.value?.values?.isSameAddress === '1'
const isSame = formRef.value?.values?.isSameAddress === true || formRef.value?.values?.isSameAddress === '1'
// Jika alamat sama, semua field kecuali provinsi disabled
if (['address', 'rt', 'rw'].includes(field) && isSame) {
@@ -63,7 +75,7 @@ function getFieldState(field: string) {
const isDisabledByDependency = !dependencyValue
// Jika isSame, semua field location disabled
if (isSame && ['regencyId', 'districtId', 'villageId', 'zipCode'].includes(field)) {
if (isSame && ['regency_code', 'district_code', 'village_code', 'postalRegion_code'].includes(field)) {
return { placeholder: '-', disabled: true }
}
@@ -73,7 +85,7 @@ function getFieldState(field: string) {
}
// Jika isSame dan field location, disabled
if (isSame && ['regencyId', 'districtId', 'villageId', 'zipCode'].includes(field)) {
if (isSame && ['regency_code', 'district_code', 'village_code', 'postalRegion_code'].includes(field)) {
return { placeholder: '-', disabled: true }
}
@@ -84,9 +96,9 @@ function getFieldState(field: string) {
// #region watch
// Watch provinceId changes
// Watch province_code changes
watch(
() => formRef.value?.values?.provinceId,
() => formRef.value?.values?.province_code,
(newValue, oldValue) => {
if (isResetting || !formRef.value || newValue === oldValue) return
@@ -95,10 +107,10 @@ watch(
formRef.value.setValues(
{
regencyId: undefined,
districtId: undefined,
villageId: undefined,
zipCode: undefined,
regency_code: undefined,
district_code: undefined,
village_code: undefined,
postalRegion_code: undefined,
},
false,
)
@@ -110,9 +122,9 @@ watch(
},
)
// Watch regencyId changes
// Watch regency_code changes
watch(
() => formRef.value?.values?.regencyId,
() => formRef.value?.values?.regency_code,
(newValue, oldValue) => {
if (isResetting || !formRef.value || newValue === oldValue) return
@@ -121,9 +133,9 @@ watch(
formRef.value.setValues(
{
districtId: undefined,
villageId: undefined,
zipCode: undefined,
district_code: undefined,
village_code: undefined,
postalRegion_code: undefined,
},
false,
)
@@ -135,9 +147,9 @@ watch(
},
)
// Watch districtId changes
// Watch district_code changes
watch(
() => formRef.value?.values?.districtId,
() => formRef.value?.values?.district_code,
(newValue, oldValue) => {
if (isResetting || !formRef.value || newValue === oldValue) return
@@ -146,8 +158,8 @@ watch(
formRef.value.setValues(
{
villageId: undefined,
zipCode: undefined,
village_code: undefined,
postalRegion_code: undefined,
},
false,
)
@@ -159,9 +171,9 @@ watch(
},
)
// Watch villageId changes
// Watch village_code changes
watch(
() => formRef.value?.values?.villageId,
() => formRef.value?.values?.village_code,
(newValue, oldValue) => {
if (isResetting || !formRef.value || newValue === oldValue) return
@@ -170,7 +182,7 @@ watch(
formRef.value.setValues(
{
zipCode: undefined,
postalRegion_code: undefined,
},
false,
)
@@ -188,19 +200,23 @@ watch(
(newValue, oldValue) => {
if (!formRef.value || newValue === oldValue) return
// Ketika berubah dari '1' ke '0', clear empty strings dan trigger validasi
if (oldValue === '1' && newValue === '0') {
// Konversi ke boolean untuk perbandingan yang konsisten
const newBool = newValue === true || newValue === '1'
const oldBool = oldValue === true || oldValue === '1'
// Ketika berubah dari true ke false, clear empty strings dan trigger validasi
if (oldBool && !newBool) {
nextTick(() => {
// Set empty strings ke undefined untuk trigger required validation
const currentValues = formRef.value.values
const updatedValues = { ...currentValues }
// Convert empty strings to undefined untuk field yang sekarang required
if (updatedValues.provinceId === '') updatedValues.provinceId = undefined
if (updatedValues.regencyId === '') updatedValues.regencyId = undefined
if (updatedValues.districtId === '') updatedValues.districtId = undefined
if (updatedValues.villageId === '') updatedValues.villageId = undefined
if (updatedValues.zipCode === '') updatedValues.zipCode = undefined
if (updatedValues.province_code === '') updatedValues.province_code = undefined
if (updatedValues.regency_code === '') updatedValues.regency_code = undefined
if (updatedValues.district_code === '') updatedValues.district_code = undefined
if (updatedValues.village_code === '') updatedValues.village_code = undefined
if (updatedValues.postalRegion_code === '') updatedValues.postalRegion_code = undefined
if (updatedValues.address === '') updatedValues.address = undefined
// Update values dan trigger validasi
@@ -213,15 +229,15 @@ watch(
})
}
// Ketika berubah dari '0' ke '1', clear error messages
if (oldValue === '0' && newValue === '1') {
// Ketika berubah dari false ke true, clear error messages
if (!oldBool && newBool) {
nextTick(() => {
// Clear error messages untuk field yang tidak lagi required
formRef.value?.setFieldError('provinceId', undefined)
formRef.value?.setFieldError('regencyId', undefined)
formRef.value?.setFieldError('districtId', undefined)
formRef.value?.setFieldError('villageId', undefined)
formRef.value?.setFieldError('zipCode', undefined)
formRef.value?.setFieldError('province_code', undefined)
formRef.value?.setFieldError('regency_code', undefined)
formRef.value?.setFieldError('district_code', undefined)
formRef.value?.setFieldError('village_code', undefined)
formRef.value?.setFieldError('postalRegion_code', undefined)
formRef.value?.setFieldError('address', undefined)
formRef.value?.setFieldError('rt', undefined)
formRef.value?.setFieldError('rw', undefined)
@@ -241,129 +257,138 @@ watch(
:validation-schema="formSchema"
:validate-on-mount="false"
validation-mode="onSubmit"
:initial-values="initialValues ?? { isSameAddress: '1' }"
:initial-values="initialValues ?? { isSameAddress: '1', locationType_code: 'identity' }"
>
<div>
<p
v-if="props.title"
class="text-md mb-2 mt-1 font-semibold"
class="text-sm 2xl:text-base mb-2 2xl:mb-3 font-semibold"
>
{{ props.title }}
</p>
</div>
<div class="mb-5 border-b border-b-slate-300 pb-3 text-lg xl:text-xl">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-2">
<!-- LocationType -->
<FieldGroup v-if="conf?.withAddressName">
<Label label-for="locationType">Jenis Alamat</Label>
<Field
id="locationType"
:errors="errors"
>
<FormField
v-slot="{ componentField }"
name="locationType"
>
<FormItem>
<FormControl>
<Select
id="locationType"
v-bind="componentField"
:items="[
{ label: 'Rumah', value: 'rumah' },
{ label: 'Kantor', value: 'kantor' },
{ label: 'Lainnya', value: 'lainnya' },
]"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</Field>
</FieldGroup>
<RadioResidence field-name="isSameAddress" />
<Block></Block>
<div class="flex-row gap-2 md:flex">
<div class="min-w-0 flex-1">
<SelectProvince
field-name="provinceId"
placeholder="Pilih"
:is-disabled="values.isSameAddress === '1'"
:is-required="values.isSameAddress !== '1'"
/>
</div>
<div class="min-w-0 flex-1">
<SelectRegency
field-name="regencyId"
:placeholder="getFieldState('regencyId').placeholder"
:is-disabled="getFieldState('regencyId').disabled || !values.provinceId"
:is-required="values.isSameAddress !== '1'"
/>
</div>
</div>
<div class="flex-row gap-2 md:flex">
<div class="min-w-0 flex-1">
<SelectDistrict
field-name="districtId"
:placeholder="getFieldState('districtId').placeholder"
:is-disabled="getFieldState('districtId').disabled || !values.regencyId"
:is-required="values.isSameAddress !== '1'"
/>
</div>
<div class="min-w-0 flex-1">
<SelectVillage
field-name="villageId"
:placeholder="getFieldState('villageId').placeholder"
:is-disabled="getFieldState('villageId').disabled || !values.districtId"
:is-required="values.isSameAddress !== '1'"
/>
</div>
</div>
<InputBase
field-name="address"
label="Alamat"
:placeholder="getFieldState('address').placeholder"
:is-disabled="getFieldState('address').disabled"
<!-- locationType_code - Hidden field with default value 'identity' -->
<FormField
v-slot="{ componentField }"
name="locationType_code"
>
<input
type="hidden"
v-bind="componentField"
value="identity"
/>
</FormField>
<DE.Block :col-count="4" :cell-flex="false">
<DE.Cell :col-span="4">
<DE.Label
size="fit"
height="compact"
label-for="isSameAddress"
>
Apakah alamat KTP sama dengan alamat sekarang?
</DE.Label>
<DE.Field
id="isSameAddress"
:errors="errors"
:is-required="values.isSameAddress !== '1'"
>
<FormField
v-slot="{ componentField }"
name="isSameAddress"
>
<FormItem>
<FormControl>
<RadioGroup
:model-value="isSameAddressString"
@update:model-value="(value) => componentField.onChange(value)"
class="flex flex-row flex-wrap gap-4 sm:gap-6"
>
<div
v-for="(option, index) in [
{ label: 'Ya', value: '1' },
{ label: 'Tidak', value: '0' },
]"
:key="option.value"
class="flex min-w-fit items-center space-x-2"
>
<RadioGroupItem
:id="`isSameAddress-${index}`"
:value="option.value"
class="relative h-4 w-4 rounded-full border-2 border-muted-foreground before:absolute before:inset-1 before:rounded-full before:bg-primary before:opacity-0 before:transition-opacity data-[state=checked]:border-primary data-[state=checked]:bg-white data-[state=checked]:before:opacity-100 sm:h-5 sm:w-5"
/>
<RadioLabel
:for="`isSameAddress-${index}`"
class="cursor-pointer select-none text-xs font-medium leading-none transition-colors hover:text-primary peer-disabled:cursor-not-allowed peer-disabled:opacity-70 sm:text-sm"
>
{{ option.label }}
</RadioLabel>
</div>
</RadioGroup>
</FormControl>
<FormMessage class="ml-0 mt-1" />
</FormItem>
</FormField>
</DE.Field>
</DE.Cell>
<SelectProvince
field-name="province_code"
placeholder="Pilih"
:is-disabled="values.isSameAddress === true || values.isSameAddress === '1'"
:is-required="values.isSameAddress !== true && values.isSameAddress !== '1'"
/>
<SelectRegency
field-name="regency_code"
:province-code="values.province_code"
:is-disabled="getFieldState('regency_code').disabled"
:is-required="values.isSameAddress !== true && values.isSameAddress !== '1'"
/>
<SelectDistrict
field-name="district_code"
:regency-code="values.regency_code"
:is-disabled="getFieldState('district_code').disabled"
:is-required="values.isSameAddress !== true && values.isSameAddress !== '1'"
/>
<SelectVillage
field-name="village_code"
:district-code="values.district_code"
:is-disabled="getFieldState('village_code').disabled"
:is-required="values.isSameAddress !== true && values.isSameAddress !== '1'"
/>
<InputBase
field-name="address"
label="Alamat"
:placeholder="getFieldState('address').placeholder"
:is-disabled="getFieldState('address').disabled"
:errors="errors"
:is-required="values.isSameAddress !== true && values.isSameAddress !== '1'"
:col-span="2"
/>
<div class="grid grid-cols-2 gap-1">
<InputBase
field-name="rt"
label="RT"
:errors="errors"
numeric-only
:max-length="2"
:placeholder="getFieldState('rt').placeholder"
:is-disabled="getFieldState('rt').disabled"
/>
<InputBase
field-name="rw"
label="RW"
:placeholder="getFieldState('rw').placeholder"
:is-disabled="getFieldState('rw').disabled"
:errors="errors"
:max-length="2"
numeric-only
/>
<div class="flex-row gap-2 md:flex">
<div class="min-w-0 flex-1">
<InputBase
field-name="rt"
label="RT"
:errors="errors"
numeric-only
:max-length="3"
:placeholder="getFieldState('rt').placeholder"
:is-disabled="getFieldState('rt').disabled"
/>
</div>
<div class="min-w-0 flex-1">
<InputBase
field-name="rw"
label="RW"
:placeholder="getFieldState('rw').placeholder"
:is-disabled="getFieldState('rw').disabled"
:errors="errors"
:max-length="3"
numeric-only
/>
</div>
<div class="min-w-0 flex-[2]">
<SelectPostal
field-name="zipCode"
:placeholder="getFieldState('zipCode').placeholder"
:is-disabled="getFieldState('zipCode').disabled || !values.villageId"
/>
</div>
</div>
</div>
</div>
<SelectPostal
field-name="postalRegion_code"
:village-code="values.village_code"
:placeholder="getFieldState('postalRegion_code').placeholder"
:is-disabled="getFieldState('postalRegion_code').disabled || !values.village_code"
/>
</DE.Block>
</Form>
</template>
+119 -142
View File
@@ -12,6 +12,8 @@ import SelectRegency from './_common/select-regency.vue'
import SelectVillage from './_common/select-village.vue'
import { Form } from '~/components/pub/ui/form'
import * as DE from '~/components/pub/my-ui/doc-entry'
const props = defineProps<{
title: string
conf?: {
@@ -35,29 +37,34 @@ defineExpose({
// Watchers untuk cascading reset
let isResetting = false
// #region Watch provinceId changes
// #region Watch provinceCode changes
watch(
() => formRef.value?.values?.provinceId,
() => formRef.value?.values?.provinceCode,
(newValue, oldValue) => {
if (isResetting || !formRef.value || newValue === oldValue) return
if (oldValue && newValue !== oldValue) {
isResetting = true
formRef.value.setValues(
{
regencyId: undefined,
districtId: undefined,
villageId: undefined,
zipCode: undefined,
},
false,
)
// Delay reset untuk memberikan waktu composable menyelesaikan request
setTimeout(() => {
if (formRef.value) {
formRef.value.setValues(
{
regencyId: undefined,
districtId: undefined,
villageId: undefined,
zipCode: undefined,
},
false,
)
}
nextTick(() => {
isResetting = false
})
nextTick(() => {
isResetting = false
})
}, 150) // Delay 150ms, lebih dari debounce composable (100ms)
}
},
)
@@ -71,18 +78,23 @@ watch(
if (oldValue && newValue !== oldValue) {
isResetting = true
formRef.value.setValues(
{
districtId: undefined,
villageId: undefined,
zipCode: undefined,
},
false,
)
// Delay reset untuk memberikan waktu composable menyelesaikan request
setTimeout(() => {
if (formRef.value) {
formRef.value.setValues(
{
districtId: undefined,
villageId: undefined,
zipCode: undefined,
},
false,
)
}
nextTick(() => {
isResetting = false
})
nextTick(() => {
isResetting = false
})
}, 150)
}
},
)
@@ -96,17 +108,22 @@ watch(
if (oldValue && newValue !== oldValue) {
isResetting = true
formRef.value.setValues(
{
villageId: undefined,
zipCode: undefined,
},
false,
)
// Delay reset untuk memberikan waktu composable menyelesaikan request
setTimeout(() => {
if (formRef.value) {
formRef.value.setValues(
{
villageId: undefined,
zipCode: undefined,
},
false,
)
}
nextTick(() => {
isResetting = false
})
nextTick(() => {
isResetting = false
})
}, 150)
}
},
)
@@ -145,124 +162,84 @@ watch(
:validation-schema="formSchema"
:validate-on-mount="false"
validation-mode="onSubmit"
:initial-values="initialValues ? initialValues : {}"
:initial-values="
initialValues ? { locationType_code: 'domicile', ...initialValues } : { locationType_code: 'domicile' }
"
>
<div>
<p
v-if="props.title"
class="text-md mb-2 mt-1 font-semibold"
class="text-sm 2xl:text-base mb-2 2xl:mb-3 font-semibold"
>
{{ props.title }}
</p>
</div>
<div class="mb-5 border-b border-b-slate-300 pb-3 text-lg xl:text-xl">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-2">
<!-- LocationType -->
<FieldGroup v-if="conf?.withAddressName">
<Label label-for="locationType">Jenis Alamat</Label>
<Field
id="locationType"
<FormField
v-slot="{ componentField }"
name="locationType_code"
>
<input
type="hidden"
v-bind="componentField"
value="domicile"
/>
</FormField>
<DE.Block :col-count="4" :cell-flex="false">
<SelectProvince
field-name="province_code"
placeholder="Pilih"
is-required
/>
<SelectRegency
field-name="regency_code"
:province-code="values.province_code"
is-required
/>
<SelectDistrict
field-name="district_code"
:regency-code="values.regency_code"
is-required
/>
<SelectVillage
field-name="village_code"
:district-code="values.district_code"
is-required
/>
<InputBase
field-name="address"
label="Alamat"
placeholder="Masukkan alamat"
:errors="errors"
is-required
:col-span="2"
/>
<DE.Cell class="flex-row gap-2">
<div class="grid grid-cols-2 gap-1">
<InputBase
field-name="rt"
label="RT"
placeholder="01"
:errors="errors"
>
<FormField
v-slot="{ componentField }"
name="locationType"
>
<FormItem>
<FormControl>
<Select
id="locationType"
v-bind="componentField"
:items="[
{ label: 'Rumah', value: 'rumah' },
{ label: 'Kantor', value: 'kantor' },
{ label: 'Lainnya', value: 'lainnya' },
]"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</Field>
</FieldGroup>
<div class="flex-row gap-2 md:flex">
<div class="min-w-0 flex-1">
<SelectProvince
field-name="provinceId"
placeholder="Pilih"
is-required
numeric-only
:max-length="2"
/>
</div>
<div class="min-w-0 flex-1">
<SelectRegency
field-name="regencyId"
placeholder="Pilih provinsi dahulu"
:is-disabled="!values.provinceId"
is-required
/>
</div>
<InputBase
field-name="rw"
label="RW"
placeholder="02"
:errors="errors"
:max-length="2"
numeric-only
/>
</div>
<div class="flex-row gap-2 md:flex">
<div class="min-w-0 flex-1">
<SelectDistrict
field-name="districtId"
placeholder="Pilih kabupaten/kota dahulu"
:is-disabled="!values.regencyId"
is-required
/>
</div>
<div class="min-w-0 flex-1">
<SelectVillage
field-name="villageId"
placeholder="Pilih kecamatan dahulu"
:is-disabled="!values.districtId"
is-required
/>
</div>
</div>
<InputBase
field-name="address"
label="Alamat"
placeholder="Masukkan alamat"
:errors="errors"
is-required
</DE.Cell>
<SelectPostal
field-name="postalRegion_code"
placeholder="Pilih kelurahan dahulu"
:village-code="values.village_code"
:is-disabled="!values.village_code"
/>
<div class="flex-row gap-2 md:flex">
<div class="min-w-0 flex-1">
<InputBase
field-name="rt"
label="RT"
placeholder="001"
:errors="errors"
numeric-only
:max-length="3"
/>
</div>
<div class="min-w-0 flex-1">
<InputBase
field-name="rw"
label="RW"
placeholder="002"
:errors="errors"
:max-length="3"
numeric-only
/>
</div>
<div class="min-w-0 flex-[2]">
<SelectPostal
field-name="zipCode"
placeholder="Pilih kelurahan dahulu"
:is-disabled="!values.villageId"
/>
</div>
</div>
</div>
</div>
</DE.Block>
</Form>
</template>
@@ -39,7 +39,7 @@ defineExpose({
<div>
<p
v-if="props.title"
class="text-md mb-2 mt-1 font-semibold"
class="text-sm 2xl:text-base mb-2 2xl:mb-3 font-semibold"
>
{{ props.title || 'Kontak Pasien' }}
</p>
@@ -94,6 +94,7 @@ defineExpose({
:disabled="fields.length >= contactLimit"
type="button"
variant="outline"
size="sm"
@click="push({ contactType: '', contactNumber: '' })"
>
<Icon
@@ -4,6 +4,7 @@ import FieldGroup from '~/components/pub/my-ui/form/field-group.vue'
import Field from '~/components/pub/my-ui/form/field.vue'
import Select from '~/components/pub/my-ui/form/select.vue'
import { cn } from '~/lib/utils'
import { relationshipCodes } from '~/lib/constants'
const props = defineProps<{
fieldName: string
@@ -18,15 +19,11 @@ const props = defineProps<{
const { fieldName = 'phoneNumber', errors, class: containerClass, selectClass, fieldGroupClass } = props
const emergencyContactOptions = [
{ label: 'Diri sendiri', value: 'self' },
{ label: 'Orang Tua', value: 'parent' },
{ label: 'Anak', value: 'child' },
{ label: 'Keluarga lain', value: 'relative' },
{ label: 'Petugas instansi lainnya', value: 'institution_officer' },
{ label: 'Petugas kesehatan', value: 'health_officer' },
{ label: 'Lainnya', value: 'other', priority: -1 },
]
const emergencyContactOptions = Object.entries(relationshipCodes).map(([value, label]) => ({
label,
value,
...(value === 'other' && { priority: -1 })
}))
</script>
<template>
@@ -0,0 +1,77 @@
<script setup lang="ts">
import type { FormErrors } from '~/types/error'
import Combobox from '~/components/pub/my-ui/combobox/combobox.vue'
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 { cn } from '~/lib/utils'
import * as DE from '~/components/pub/my-ui/doc-entry'
const props = defineProps<{
fieldName?: string
isDisabled?: boolean
placeholder?: string
errors?: FormErrors
class?: string
selectClass?: string
fieldGroupClass?: string
isRequired?: boolean
}>()
const {
fieldName = 'birthPlace',
placeholder = 'Pilih tempat lahir',
errors,
class: containerClass,
fieldGroupClass,
} = props
// Gunakan composable untuk mengelola data regencies (tanpa pagination & tanpa province code)
const { fetchRegencies, regencyOptions, isLoading, error } = useRegencies({
enablePagination: false,
enableSearch: false,
})
// Computed untuk menentukan placeholder berdasarkan state
const dynamicPlaceholder = computed(() => {
if (isLoading.value) return 'Memuat data tempat lahir...'
if (error.value) return 'Gagal memuat data'
return placeholder
})
// Computed untuk menentukan apakah field disabled
const isFieldDisabled = computed(() => {
return props.isDisabled || isLoading.value || !!error.value
})
onMounted(() => {
fetchRegencies()
})
</script>
<template>
<DE.Cell :class="cn('select-field-group', fieldGroupClass, containerClass)">
<DE.Label :label-for="fieldName" :is-required="isRequired">
Tempat Lahir
</DE.Label>
<DE.Field :id="fieldName" :errors="errors" :class="cn('select-field-wrapper')">
<FormField v-slot="{ componentField }" :name="fieldName">
<FormItem>
<FormControl>
<Combobox
:id="fieldName"
v-bind="componentField"
:items="regencyOptions"
:placeholder="dynamicPlaceholder"
:is-disabled="isFieldDisabled"
search-placeholder="Cari tempat lahir..."
empty-message="Tempat lahir tidak ditemukan"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</DE.Field>
</DE.Cell>
</template>
@@ -65,7 +65,7 @@ defineExpose({
<div>
<p
v-if="props.title"
class="text-md mb-2 mt-1 font-semibold"
class="text-sm 2xl:text-base mb-2 2xl:mb-3 font-semibold"
>
{{ props.title }}
</p>
+3 -6
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { withBase } from '~/models/_base'
import type { HeaderPrep } from '~/components/pub/my-ui/data/types'
import type { PatientEntity } from '~/models/patient'
import type { Patient } from '~/models/patient'
import type { Person } from '~/models/person'
// Components
@@ -18,7 +18,7 @@ const props = defineProps<{
// #region State & Computed
const patient = ref(
withBase<PatientEntity>({
withBase<Patient>({
person: {} as Person,
personAddresses: [],
personContacts: [],
@@ -74,10 +74,7 @@ function handleAction(type: string) {
/>
<AppPatientPreview
:person="patient.person"
:person-addresses="patient.personAddresses"
:person-contacts="patient.personContacts"
:person-relatives="patient.personRelatives"
:patient="patient"
@click="handleAction"
/>
</template>
+172 -43
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import type { PatientEntity, genPatientProps } from '~/models/patient'
import type { Patient, genPatientProps } from '~/models/patient'
import type { ExposedForm } from '~/types/form'
import type { PatientBase } from '~/models/patient'
import Action from '~/components/pub/my-ui/nav-footer/ba-dr-su.vue'
import { genPatient } from '~/models/patient'
import { PatientSchema } from '~/schemas/patient.schema'
@@ -9,10 +10,28 @@ import { PersonAddressSchema } from '~/schemas/person-address.schema'
import { PersonContactListSchema } from '~/schemas/person-contact.schema'
import { PersonFamiliesSchema } from '~/schemas/person-family.schema'
import { ResponsiblePersonSchema } from '~/schemas/person-relative.schema'
import { postPatient } from '~/services/patient.service'
import { uploadAttachment } from '~/services/patient.service'
import {
// for form entry
isReadonly,
isProcessing,
isFormEntryDialogOpen,
isRecordConfirmationOpen,
onResetState,
handleActionSave,
handleCancelForm,
} from '~/handlers/patient.handler'
import { toast } from '~/components/pub/ui/toast'
// #region Props & Emits
const payload = ref<PatientEntity>()
const props = defineProps<{
callbackUrl?: string
}>()
const residentIdentityFile = ref<File>()
const familyCardFile = ref<File>()
// form related state
const personAddressForm = ref<ExposedForm<any> | null>(null)
@@ -28,13 +47,39 @@ const personPatientForm = ref<ExposedForm<any> | null>(null)
// #endregion
// #region Lifecycle Hooks
onMounted(() => {
// Initial synchronization when forms are mounted and isSameAddress is true by default
nextTick(() => {
const isSameAddress = personAddressRelativeForm.value?.values?.isSameAddress
if (
(isSameAddress === true || isSameAddress === '1') &&
personAddressForm.value?.values &&
personAddressRelativeForm.value
) {
const currentAddressValues = personAddressForm.value.values
if (Object.keys(currentAddressValues).length > 0) {
personAddressRelativeForm.value.setValues(
{
...personAddressRelativeForm.value.values,
province_code: currentAddressValues.province_code || undefined,
regency_code: currentAddressValues.regency_code || undefined,
district_code: currentAddressValues.district_code || undefined,
village_code: currentAddressValues.village_code || undefined,
postalRegion_code: currentAddressValues.postalRegion_code || undefined,
address: currentAddressValues.address || undefined,
rt: currentAddressValues.rt || undefined,
rw: currentAddressValues.rw || undefined,
},
false,
)
}
}
})
})
// #endregion
// #region Functions
// #endregion region
// #region Utilities & event handlers
async function submitAll() {
async function composeFormData(): Promise<Patient> {
const [patient, address, addressRelative, families, contacts, emergencyContact] = await Promise.all([
personPatientForm.value?.validate(),
personAddressForm.value?.validate(),
@@ -50,7 +95,7 @@ async function submitAll() {
// exit, if form errors happend during validation
// for example: dropdown not selected
if (!allValid) return
if (!allValid) return Promise.reject('Form validation failed')
const formDataRequest: genPatientProps = {
patient: patient?.values,
@@ -62,46 +107,122 @@ async function submitAll() {
}
const formData = genPatient(formDataRequest)
payload.value = formData
try {
const result = await postPatient(formData)
if (result.success) {
console.log('Patient created successfully:', result.body)
// Navigate to patient list or show success message
await navigateTo('/client/patient')
} else {
console.error('Failed to create patient:', result)
// Handle error - show error message to user
if (patient?.values.residentIdentityFile) {
residentIdentityFile.value = patient?.values.residentIdentityFile
}
if (patient?.values.familyIdentityFile) {
familyCardFile.value = patient?.values.familyIdentityFile
}
return new Promise((resolve) => resolve(formData))
}
// #endregion region
// #region Utilities & event handlers
async function handleActionClick(eventType: string) {
if (eventType === 'submit') {
const patient: Patient = await composeFormData()
let createdPatientId = 0
const response = await handleActionSave(
patient,
() => {},
() => {},
toast,
)
const data = (response?.body?.data ?? null) as PatientBase | null
if (!data) return
createdPatientId = data.id
if (residentIdentityFile.value) {
void uploadAttachment(residentIdentityFile.value, createdPatientId, 'ktp')
}
} catch (error) {
console.error('Error creating patient:', error)
// Handle error - show error message to user
if (familyCardFile.value) {
void uploadAttachment(familyCardFile.value, createdPatientId, 'kk')
}
// If has callback provided redirect to callback with patientData
if (props.callbackUrl) {
await navigateTo(props.callbackUrl + '?patient-id=' + patient.id)
return
}
// Navigate to patient list or show success message
await navigateTo('/client/patient')
return
}
if (eventType === 'cancel') {
if (props.callbackUrl) {
await navigateTo(props.callbackUrl)
return
}
await navigateTo({
name: 'client-patient',
})
// handleCancelForm()
}
}
// #endregion
// #region Watchers
// Watcher untuk sinkronisasi alamat ketika isSameAddress = '1'
// Watcher untuk sinkronisasi initial ketika kedua form sudah ready
watch(
[() => personAddressForm.value, () => personAddressRelativeForm.value],
([addressForm, relativeForm]) => {
if (addressForm && relativeForm) {
// Trigger initial sync jika isSameAddress adalah true
nextTick(() => {
const isSameAddress = relativeForm.values?.isSameAddress
if ((isSameAddress === true || isSameAddress === '1') && addressForm.values) {
const currentAddressValues = addressForm.values
if (Object.keys(currentAddressValues).length > 0) {
relativeForm.setValues(
{
...relativeForm.values,
province_code: currentAddressValues.province_code || undefined,
regency_code: currentAddressValues.regency_code || undefined,
district_code: currentAddressValues.district_code || undefined,
village_code: currentAddressValues.village_code || undefined,
postalRegion_code: currentAddressValues.postalRegion_code || undefined,
address: currentAddressValues.address || undefined,
rt: currentAddressValues.rt || undefined,
rw: currentAddressValues.rw || undefined,
},
false,
)
}
}
})
}
},
{ immediate: true },
)
// Watcher untuk sinkronisasi alamat ketika isSameAddress = true
watch(
() => personAddressForm.value?.values,
(newAddressValues) => {
// Cek apakah alamat KTP harus sama dengan alamat sekarang
const isSameAddress = personAddressRelativeForm.value?.values?.isSameAddress === '1'
const isSameAddress = personAddressRelativeForm.value?.values?.isSameAddress
if (isSameAddress && newAddressValues && personAddressRelativeForm.value) {
if ((isSameAddress === true || isSameAddress === '1') && newAddressValues && personAddressRelativeForm.value) {
// Sinkronkan semua field alamat dari alamat sekarang ke alamat KTP
personAddressRelativeForm.value.setValues(
{
...personAddressRelativeForm.value.values,
provinceId: newAddressValues.provinceId || '',
regencyId: newAddressValues.regencyId || '',
districtId: newAddressValues.districtId || '',
villageId: newAddressValues.villageId || '',
zipCode: newAddressValues.zipCode || '',
address: newAddressValues.address || '',
rt: newAddressValues.rt || '',
rw: newAddressValues.rw || '',
province_code: newAddressValues.province_code || undefined,
regency_code: newAddressValues.regency_code || undefined,
district_code: newAddressValues.district_code || undefined,
village_code: newAddressValues.village_code || undefined,
postalRegion_code: newAddressValues.postalRegion_code || undefined,
address: newAddressValues.address || undefined,
rt: newAddressValues.rt || undefined,
rw: newAddressValues.rw || undefined,
},
false,
)
@@ -114,20 +235,24 @@ watch(
watch(
() => personAddressRelativeForm.value?.values?.isSameAddress,
(isSameAddress) => {
if (isSameAddress === '1' && personAddressForm.value?.values && personAddressRelativeForm.value) {
// Ketika isSameAddress diubah menjadi '1', copy alamat sekarang ke alamat KTP
if (
(isSameAddress === true || isSameAddress === '1') &&
personAddressForm.value?.values &&
personAddressRelativeForm.value?.values
) {
// Ketika isSameAddress diubah menjadi true, copy alamat sekarang ke alamat KTP
const currentAddressValues = personAddressForm.value.values
personAddressRelativeForm.value.setValues(
{
...personAddressRelativeForm.value.values,
provinceId: currentAddressValues.provinceId || '',
regencyId: currentAddressValues.regencyId || '',
districtId: currentAddressValues.districtId || '',
villageId: currentAddressValues.villageId || '',
zipCode: currentAddressValues.zipCode || '',
address: currentAddressValues.address || '',
rt: currentAddressValues.rt || '',
rw: currentAddressValues.rw || '',
province_code: currentAddressValues.province_code || undefined,
regency_code: currentAddressValues.regency_code || undefined,
district_code: currentAddressValues.district_code || undefined,
village_code: currentAddressValues.village_code || undefined,
postalRegion_code: currentAddressValues.postalRegion_code || undefined,
address: currentAddressValues.address || undefined,
rt: currentAddressValues.rt || undefined,
rw: currentAddressValues.rw || undefined,
},
false,
)
@@ -143,21 +268,25 @@ watch(
ref="personPatientForm"
:schema="PatientSchema"
/>
<div class="h-6"></div>
<AppPersonAddressEntryForm
ref="personAddressForm"
title="Alamat Sekarang"
:schema="PersonAddressSchema"
/>
<div class="h-6"></div>
<AppPersonAddressEntryFormRelative
ref="personAddressRelativeForm"
title="Alamat KTP"
:schema="PersonAddressRelativeSchema"
/>
<div class="h-6"></div>
<AppPersonFamilyParentsForm
ref="personFamilyForm"
title="Identitas Orang Tua"
:schema="PersonFamiliesSchema"
/>
<div class="h-6"></div>
<AppPersonContactEntryForm
ref="personContactForm"
title="Kontak Pasien"
@@ -171,7 +300,7 @@ watch(
/>
<div class="my-2 flex justify-end py-2">
<Action @click="submitAll" />
<Action @click="handleActionClick" />
</div>
</template>
@@ -0,0 +1,208 @@
<script setup lang="ts">
import { cn } from '~/lib/utils'
import { type Item } from './index'
const props = defineProps<{
id?: string
modelValue?: string
items: Item[]
placeholder?: string
searchPlaceholder?: string
emptyMessage?: string
class?: string
isDisabled?: boolean
page?: number
totalPage?: number
hasNext?: boolean
hasPrev?: boolean
isLoading?: boolean
searchText?: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
'update:searchText': [value: string]
'page-change': [page: number]
next: []
prev: []
}>()
const open = ref(false)
const internalSearchText = ref(props.searchText || '')
// Keep internal search text synced with props
watch(
() => props.searchText,
(val) => {
if (val !== internalSearchText.value) internalSearchText.value = val || ''
},
)
const selectedItem = computed(() => props.items.find((item) => item.value === props.modelValue))
const displayText = computed(() => {
if (selectedItem.value?.label) return selectedItem.value.label
return props.placeholder || 'Pilih item'
})
const searchableItems = computed(() => {
const itemsWithSearch = props.items.map((item) => ({
...item,
searchValue: `${item.code || ''} ${item.label}`.trim(),
isSelected: item.value === props.modelValue,
}))
return itemsWithSearch.sort((a, b) => {
const aPriority = a.priority ?? 0
const bPriority = b.priority ?? 0
if (aPriority !== bPriority) return bPriority - aPriority
if (a.isSelected && !b.isSelected) return -1
if (!a.isSelected && b.isSelected) return 1
return a.label.localeCompare(b.label)
})
})
function onSelect(item: Item) {
emit('update:modelValue', item.value)
open.value = false
}
function onSearchInput(value: string) {
console.log('[ComboboxPaginated] emit update:searchText', value)
internalSearchText.value = value
emit('update:searchText', value)
}
function handlePrev() {
if (props.hasPrev) emit('prev')
}
function handleNext() {
if (props.hasNext) emit('next')
}
</script>
<template>
<Popover v-model:open="open">
<PopoverTrigger as-child>
<Button
:id="props.id"
:disabled="props.isDisabled"
variant="outline"
role="combobox"
:aria-expanded="open"
:aria-controls="`${props.id}-list`"
:aria-describedby="`${props.id}-search`"
:class="
cn(
'h-8 w-full justify-between rounded-md border px-3 font-normal focus:outline-none focus:ring-1 focus:ring-black dark:!border-slate-400 dark:focus:ring-white md:text-xs 2xl:h-9 2xl:text-sm',
{
'cursor-not-allowed border-gray-300 bg-gray-100 text-gray-500 opacity-50': props.isDisabled,
'border-gray-400 bg-white text-black hover:bg-gray-50 dark:border-gray-600 dark:bg-slate-950 dark:text-white dark:hover:bg-gray-700':
!props.isDisabled,
'text-gray-400 dark:text-gray-500': !props.modelValue && !props.isDisabled,
},
props.class,
)
"
>
{{ displayText }}
<Icon
name="i-lucide-chevrons-up-down"
:class="
cn('ml-2 h-4 w-4 shrink-0', {
'opacity-30': props.isDisabled,
'text-gray-500 opacity-50 dark:text-gray-300': !props.isDisabled,
})
"
/>
</Button>
</PopoverTrigger>
<PopoverContent
class="w-[var(--radix-popover-trigger-width)] border border-gray-200 bg-white p-0 dark:border-gray-700 dark:bg-gray-800"
>
<Command class="bg-white dark:bg-gray-800">
<CommandInput
:id="`${props.id}-search`"
class="h-9 border-0 border-b border-gray-200 bg-white text-black focus:ring-0 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
:placeholder="searchPlaceholder || 'Cari...'"
v-model="internalSearchText"
@input="onSearchInput(($event.target as HTMLInputElement).value)"
:aria-label="`Cari ${displayText}`"
/>
<CommandEmpty class="py-6 text-center text-sm text-gray-500 dark:text-gray-400">
{{ emptyMessage || 'Item tidak ditemukan.' }}
</CommandEmpty>
<CommandList
:id="`${props.id}-list`"
role="listbox"
class="max-h-60 overflow-auto"
>
<CommandGroup>
<CommandItem
v-for="item in searchableItems"
:key="item.value"
:value="item.value"
:class="
cn(
'flex w-full cursor-pointer items-center justify-between rounded-sm px-2 py-1.5 md:text-xs xl:text-sm',
'text-black focus:outline-none dark:text-white',
'hover:bg-primary hover:text-white focus:bg-primary focus:text-white',
'data-[highlighted]:bg-primary data-[highlighted]:text-white',
)
"
@select="onSelect(item)"
>
<div class="flex w-full items-center justify-between">
<span>{{ item.label }}</span>
<div class="flex items-center gap-2">
<span
v-if="item.code"
class="text-xs text-muted-foreground"
>
{{ item.code }}
</span>
<Icon
name="i-lucide-check"
:class="cn('h-4 w-4', props.modelValue === item.value ? 'opacity-100' : 'opacity-0')"
/>
</div>
</div>
</CommandItem>
</CommandGroup>
</CommandList>
<div class="flex items-center justify-between border-t border-gray-200 p-2 text-xs dark:border-gray-700">
<div class="flex items-center gap-2">
<Icon
v-if="props.isLoading"
name="i-lucide-loader-2"
class="h-3 w-3 animate-spin"
/>
<span v-else>{{ props.page || 1 }} / {{ props.totalPage || 1 }}</span>
</div>
<div class="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
:disabled="!props.hasPrev || props.isDisabled"
@click="handlePrev"
>
Prev
</Button>
<Button
size="sm"
variant="ghost"
:disabled="!props.hasNext || props.isDisabled"
@click="handleNext"
>
Next
</Button>
</div>
</div>
</Command>
</PopoverContent>
</Popover>
</template>
+18 -13
View File
@@ -23,7 +23,7 @@ const open = ref(false)
const selectedItem = computed(() => props.items.find((item) => item.value === props.modelValue))
const displayText = computed(() => {
console.log(selectedItem);
console.log(selectedItem)
if (selectedItem.value?.label) {
return selectedItem.value.label
}
@@ -31,7 +31,7 @@ const displayText = computed(() => {
})
watch(props, () => {
console.log(props.modelValue);
console.log(props.modelValue)
})
const searchableItems = computed(() => {
@@ -74,10 +74,11 @@ function onSelect(item: Item) {
:aria-describedby="`${props.id}-search`"
:class="
cn(
'w-full justify-between border dark:!border-slate-400 h-8 2xl:h-9 md:text-xs 2xl:text-sm font-normal rounded-md px-3 focus:outline-none focus:ring-1 focus:ring-black dark:focus:ring-white',
'h-8 w-full justify-between rounded-md border px-3 font-normal focus:outline-none focus:ring-1 focus:ring-black dark:!border-slate-400 dark:focus:ring-white md:text-xs 2xl:h-9 2xl:text-sm',
{
'cursor-not-allowed bg-gray-100 opacity-50 border-gray-300 text-gray-500': props.isDisabled,
'bg-white text-black dark:bg-slate-950 dark:text-white dark:border-gray-600 border-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700': !props.isDisabled,
'cursor-not-allowed border-gray-300 bg-gray-100 text-gray-500 opacity-50': props.isDisabled,
'border-gray-400 bg-white text-black hover:bg-gray-50 dark:border-gray-600 dark:bg-slate-950 dark:text-white dark:hover:bg-gray-700':
!props.isDisabled,
'text-gray-400 dark:text-gray-500': !modelValue && !props.isDisabled,
},
props.class,
@@ -87,22 +88,26 @@ function onSelect(item: Item) {
{{ displayText }}
<Icon
name="i-lucide-chevrons-up-down"
:class="cn('ml-2 h-4 w-4 shrink-0', {
'opacity-30': props.isDisabled,
'opacity-50 text-gray-500 dark:text-gray-300': !props.isDisabled
})"
:class="
cn('ml-2 h-4 w-4 shrink-0', {
'opacity-30': props.isDisabled,
'text-gray-500 opacity-50 dark:text-gray-300': !props.isDisabled,
})
"
/>
</Button>
</PopoverTrigger>
<PopoverContent class="w-[var(--radix-popover-trigger-width)] p-0 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
<PopoverContent
class="w-[var(--radix-popover-trigger-width)] border border-gray-200 bg-white p-0 dark:border-gray-700 dark:bg-gray-800"
>
<Command class="bg-white dark:bg-gray-800">
<CommandInput
:id="`${props.id}-search`"
class="h-9 bg-white dark:bg-gray-800 text-black dark:text-white border-0 border-b border-gray-200 dark:border-gray-700 focus:ring-0"
class="h-9 border-0 border-b border-gray-200 bg-white text-black focus:ring-0 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
:placeholder="searchPlaceholder || 'Cari...'"
:aria-label="`Cari ${displayText}`"
/>
<CommandEmpty class="text-gray-500 dark:text-gray-400 py-6 text-center text-sm">
<CommandEmpty class="py-6 text-center text-sm text-gray-500 dark:text-gray-400">
{{ emptyMessage || 'Item tidak ditemukan.' }}
</CommandEmpty>
<CommandList
@@ -118,7 +123,7 @@ function onSelect(item: Item) {
:class="
cn(
'flex w-full cursor-pointer items-center justify-between rounded-sm px-2 py-1.5 md:text-xs xl:text-sm',
'focus:outline-none text-black dark:text-white',
'text-black focus:outline-none dark:text-white',
'hover:bg-primary hover:text-white focus:bg-primary focus:text-white',
'data-[highlighted]:bg-primary data-[highlighted]:text-white',
)
+2 -2
View File
@@ -10,7 +10,7 @@ import SelectTrigger from '~/components/pub/ui/select/SelectTrigger.vue'
import SelectValue from '~/components/pub/ui/select/SelectValue.vue'
import { cn } from '~/lib/utils'
interface Item {
export interface SelectItem {
value: string
label: string
code?: string
@@ -19,7 +19,7 @@ interface Item {
const props = defineProps<{
modelValue?: string
items: Item[]
items: SelectItem[]
placeholder?: string
label?: string
separator?: boolean
+180
View File
@@ -0,0 +1,180 @@
import { ref, computed, watch } from 'vue'
import { refDebounced } from '@vueuse/core'
import type { District } from '~/models/district'
import type { SelectItem } from '~/components/pub/my-ui/form/select.vue'
import { toTitleCase } from '~/lib/utils'
import * as districtService from '~/services/district.service'
// Global cache untuk districts berdasarkan regency code
const districtsCache = ref<Map<string, District[]>>(new Map())
const loadingStates = ref<Map<string, boolean>>(new Map())
const errorStates = ref<Map<string, string | null>>(new Map())
export function useDistricts(regencyCode: Ref<string | undefined> | string | undefined) {
// Convert regencyCode ke ref jika bukan ref
const regencyCodeRef = typeof regencyCode === 'string' || regencyCode === undefined ? ref(regencyCode) : regencyCode
// Computed untuk mendapatkan districts berdasarkan regency code
const districts = computed(() => {
const code = regencyCodeRef.value
if (!code) return []
return districtsCache.value.get(code) || []
})
// Computed untuk loading state
const isLoading = computed(() => {
const code = regencyCodeRef.value
if (!code) return false
return loadingStates.value.get(code) || false
})
// Computed untuk error state
const error = computed(() => {
const code = regencyCodeRef.value
if (!code) return null
return errorStates.value.get(code) || null
})
// Computed untuk format SelectItem
const districtOptions = computed<SelectItem[]>(() => {
return districts.value.map((district) => ({
label: toTitleCase(district.name),
value: district.code,
searchValue: `${district.code} ${district.name}`.trim(),
}))
})
// Function untuk fetch districts berdasarkan regency code
async function fetchDistricts(regencyCodeParam?: string, forceRefresh = false, isUserAction = false) {
const code = regencyCodeParam || regencyCodeRef.value
if (!code) return
// Jika user action atau force refresh, selalu fetch
// Jika bukan user action dan sudah ada cache, skip
if (!isUserAction && !forceRefresh && districtsCache.value.has(code)) {
return
}
// Jika sedang loading, skip untuk mencegah duplicate calls
if (loadingStates.value.get(code)) {
return
}
// Tambahan: Cek apakah ada pending request untuk code yang sama
const pendingKey = `pending_${code}`
if (loadingStates.value.get(pendingKey)) {
return
}
loadingStates.value.set(pendingKey, true)
loadingStates.value.set(code, true)
errorStates.value.set(code, null)
try {
const response = await districtService.getList({
sort: 'name:asc',
'regency-code': code,
'page-no-limit': true,
})
if (response.success) {
const districtsData = response.body.data || []
districtsCache.value.set(code, districtsData)
} else {
errorStates.value.set(code, 'Gagal memuat data kecamatan')
console.error('Failed to fetch districts:', response)
}
} catch (err) {
errorStates.value.set(code, 'Terjadi kesalahan saat memuat data kecamatan')
console.error('Error fetching districts:', err)
} finally {
loadingStates.value.set(code, false)
loadingStates.value.delete(pendingKey)
}
}
// Function untuk mencari district berdasarkan code
function getDistrictByCode(code: string): District | undefined {
const regencyCode = regencyCodeRef.value
if (!regencyCode) return undefined
const districtsForRegency = districtsCache.value.get(regencyCode) || []
return districtsForRegency.find((district) => district.code === code)
}
// Function untuk mencari district berdasarkan name
function getDistrictByName(name: string): District | undefined {
const regencyCode = regencyCodeRef.value
if (!regencyCode) return undefined
const districtsForRegency = districtsCache.value.get(regencyCode) || []
return districtsForRegency.find((district) => district.name.toLowerCase() === name.toLowerCase())
}
// Function untuk clear cache regency tertentu
function clearCache(regencyCodeParam?: string) {
const code = regencyCodeParam || regencyCodeRef.value
if (code) {
districtsCache.value.delete(code)
loadingStates.value.delete(code)
errorStates.value.delete(code)
}
}
// Function untuk clear semua cache
function clearAllCache() {
districtsCache.value.clear()
loadingStates.value.clear()
errorStates.value.clear()
}
// Function untuk refresh data
function refreshDistricts(regencyCodeParam?: string) {
const code = regencyCodeParam || regencyCodeRef.value
if (code) {
return fetchDistricts(code, true)
}
}
// Debounced regency code untuk mencegah multiple calls
const debouncedRegencyCode = refDebounced(regencyCodeRef, 100)
// Watch perubahan regency code untuk auto fetch
watch(
debouncedRegencyCode,
(newCode, oldCode) => {
if (newCode && newCode !== oldCode) {
// Jika ada oldCode berarti user action (ganti pilihan)
const isUserAction = !!oldCode
fetchDistricts(newCode, false, isUserAction)
}
},
{ immediate: true },
)
return {
// Data
districts: readonly(districts),
districtOptions,
// State
isLoading: readonly(isLoading),
error: readonly(error),
// Methods
fetchDistricts,
refreshDistricts,
getDistrictByCode,
getDistrictByName,
clearCache,
clearAllCache,
}
}
// Export untuk direct access ke cached data (jika diperlukan)
export const useDistrictsCache = () => ({
districtsCache: readonly(districtsCache),
loadingStates: readonly(loadingStates),
errorStates: readonly(errorStates),
})
+169
View File
@@ -0,0 +1,169 @@
import { ref, computed, watch, readonly, type Ref } from 'vue'
import { refDebounced } from '@vueuse/core'
import type { PostalRegion } from '~/models/postal-region'
import type { SelectItem } from '~/components/pub/my-ui/form/select.vue'
import * as postalRegionService from '~/services/postal-region.service'
// Global cache untuk postal codes berdasarkan village code
const postalRegionCache = ref<Map<string, PostalRegion[]>>(new Map())
const loadingStates = ref<Map<string, boolean>>(new Map())
const errorStates = ref<Map<string, string | null>>(new Map())
export function usePostalRegion(villageCode: Ref<string | undefined> | string | undefined) {
// Convert villageCode ke ref jika bukan ref
const villageCodeRef = typeof villageCode === 'string' || villageCode === undefined ? ref(villageCode) : villageCode
// Computed untuk mendapatkan postalRegion berdasarkan village code
const postalRegion = computed(() => {
const code = villageCodeRef.value
if (!code) return []
return postalRegionCache.value.get(code) || []
})
// Computed untuk loading state
const isLoading = computed(() => {
const code = villageCodeRef.value
if (!code) return false
return loadingStates.value.get(code) || false
})
// Computed untuk error state
const error = computed(() => {
const code = villageCodeRef.value
if (!code) return null
return errorStates.value.get(code) || null
})
// Computed untuk format SelectItem
const postalRegionOptions = computed<SelectItem[]>(() => {
return postalRegion.value.map((postalRegion) => ({
label: postalRegion.code,
value: postalRegion.code,
searchValue: postalRegion.code,
}))
})
// Function untuk fetch postalRegion berdasarkan village code
async function fetchpostalRegion(villageCodeParam?: string, forceRefresh = false, isUserAction = false) {
const code = villageCodeParam || villageCodeRef.value
if (!code) return
// Jika user action atau force refresh, selalu fetch
// Jika bukan user action dan sudah ada cache, skip
if (!isUserAction && !forceRefresh && postalRegionCache.value.has(code)) {
return
}
// Jika sedang loading, skip untuk mencegah duplicate calls
if (loadingStates.value.get(code)) {
return
}
// Tambahan: Cek apakah ada pending request untuk code yang sama
const pendingKey = `pending_${code}`
if (loadingStates.value.get(pendingKey)) {
return
}
loadingStates.value.set(pendingKey, true)
loadingStates.value.set(code, true)
errorStates.value.set(code, null)
try {
const response = await postalRegionService.getList({
sort: 'code:asc',
'village-code': code,
'page-no-limit': true,
})
if (response.success) {
const postalRegionData = response.body.data || []
postalRegionCache.value.set(code, postalRegionData)
} else {
errorStates.value.set(code, 'Gagal memuat data kode pos')
console.error('Failed to fetch postal codes:', response)
}
} catch (err) {
errorStates.value.set(code, 'Terjadi kesalahan saat memuat data kode pos')
console.error('Error fetching postal codes:', err)
} finally {
loadingStates.value.set(code, false)
loadingStates.value.delete(pendingKey)
}
}
// Function untuk mencari postalRegion berdasarkan code
function getpostalRegionByCode(code: string): PostalRegion | undefined {
const villageCode = villageCodeRef.value
if (!villageCode) return undefined
const postalRegionForVillage = postalRegionCache.value.get(villageCode) || []
return postalRegionForVillage.find((postalRegion) => postalRegion.code === code)
}
// Function untuk clear cache village tertentu
function clearCache(villageCodeParam?: string) {
const code = villageCodeParam || villageCodeRef.value
if (code) {
postalRegionCache.value.delete(code)
loadingStates.value.delete(code)
errorStates.value.delete(code)
}
}
// Function untuk clear semua cache
function clearAllCache() {
postalRegionCache.value.clear()
loadingStates.value.clear()
errorStates.value.clear()
}
// Function untuk refresh data
function refreshpostalRegion(villageCodeParam?: string) {
const code = villageCodeParam || villageCodeRef.value
if (code) {
return fetchpostalRegion(code, true)
}
}
// Debounced village code untuk mencegah multiple calls
const debouncedVillageCode = refDebounced(villageCodeRef, 100)
// Watch perubahan village code untuk auto fetch
watch(
debouncedVillageCode,
(newCode, oldCode) => {
if (newCode && newCode !== oldCode) {
// Jika ada oldCode berarti user action (ganti pilihan)
const isUserAction = !!oldCode
fetchpostalRegion(newCode, false, isUserAction)
}
},
{ immediate: true },
)
return {
// Data
postalRegion: readonly(postalRegion),
postalRegionOptions,
// State
isLoading: readonly(isLoading),
error: readonly(error),
// Methods
fetchpostalRegion,
refreshpostalRegion,
getpostalRegionByCode,
clearCache,
clearAllCache,
}
}
// Export untuk direct access ke cached data (jika diperlukan)
export const usepostalRegionCache = () => ({
postalRegionCache: readonly(postalRegionCache),
loadingStates: readonly(loadingStates),
errorStates: readonly(errorStates),
})
+111
View File
@@ -0,0 +1,111 @@
import { ref, computed } from 'vue'
import type { Province } from '~/models/province'
import type { SelectItem } from '~/components/pub/my-ui/form/select.vue'
import { toTitleCase } from '~/lib/utils'
import * as provinceService from '~/services/province.service'
// Global state untuk caching
const provincesCache = ref<Province[]>([])
const isLoading = ref(false)
const isInitialized = ref(false)
const error = ref<string | null>(null)
export function useProvinces() {
// Computed untuk format SelectItem
const provinceOptions = computed<SelectItem[]>(() => {
return provincesCache.value.map((province) => ({
label: toTitleCase(province.name),
value: province.code,
// code: province.code,
searchValue: `${province.code} ${province.name}`.trim(), // Untuk search internal combobox
}))
})
// Function untuk fetch data provinces
async function fetchProvinces(forceRefresh = false) {
// Jika sudah ada data dan tidak force refresh, skip
if (isInitialized.value && !forceRefresh) {
return
}
// Jika sedang loading, skip untuk mencegah duplicate calls
if (isLoading.value) {
return
}
isLoading.value = true
error.value = null
try {
const response = await provinceService.getList({
'page-no-limit': true,
sort: 'name:asc',
})
if (response.success) {
provincesCache.value = response.body.data || []
isInitialized.value = true
} else {
error.value = 'Gagal memuat data provinsi'
console.error('Failed to fetch provinces:', response)
}
} catch (err) {
error.value = 'Terjadi kesalahan saat memuat data provinsi'
console.error('Error fetching provinces:', err)
} finally {
isLoading.value = false
}
}
// Function untuk mencari province berdasarkan code
function getProvinceByCode(code: string): Province | undefined {
return provincesCache.value.find((province) => province.code === code)
}
// Function untuk mencari province berdasarkan name
function getProvinceByName(name: string): Province | undefined {
return provincesCache.value.find((province) => province.name.toLowerCase() === name.toLowerCase())
}
// Function untuk clear cache (jika diperlukan)
function clearCache() {
provincesCache.value = []
isInitialized.value = false
error.value = null
}
// Function untuk refresh data
function refreshProvinces() {
return fetchProvinces(true)
}
// Auto fetch saat composable pertama kali digunakan
if (!isInitialized.value && !isLoading.value) {
fetchProvinces()
}
return {
// Data
provinces: readonly(provincesCache),
provinceOptions,
// State
isLoading: readonly(isLoading),
isInitialized: readonly(isInitialized),
error: readonly(error),
// Methods
fetchProvinces,
refreshProvinces,
getProvinceByCode,
getProvinceByName,
clearCache,
}
}
// Export untuk direct access ke cached data (jika diperlukan)
export const useProvincesCache = () => ({
provinces: readonly(provincesCache),
isLoading: readonly(isLoading),
isInitialized: readonly(isInitialized),
})
+386
View File
@@ -0,0 +1,386 @@
import { ref, computed, watch } from 'vue'
import { useDebounceFn } from '@vueuse/core'
import type { Regency } from '~/models/regency'
import type { Item } from '~/components/pub/my-ui/combobox'
import type { PaginationMeta } from '~/components/pub/my-ui/pagination/pagination.type'
import { toTitleCase } from '~/lib/utils'
import * as regencyService from '~/services/regency.service'
// Interface untuk query parameters
interface RegencyQueryParams {
'province-code'?: string
search?: string
'page-number'?: number
'page-size'?: number
sort?: string
}
// Interface untuk cached data dengan pagination
interface CachedRegencyData {
data: Regency[]
meta: PaginationMeta
queryKey: string // untuk tracking query yang berbeda
}
// Global cache untuk regencies berdasarkan query key (province-code + search + pagination)
const regenciesCache = reactive(new Map<string, CachedRegencyData>())
const loadingStates = reactive(new Map<string, boolean>())
const errorStates = reactive(new Map<string, string | null>())
interface UseRegenciesOptions {
provinceCode?: Ref<string | undefined> | string | undefined
pageSize?: number
enablePagination?: boolean
enableSearch?: boolean
}
export function useRegencies(options: UseRegenciesOptions | Ref<string | undefined> | string | undefined = {}) {
// Backward compatibility - jika parameter pertama adalah provinceCode
const normalizedOptions: UseRegenciesOptions =
typeof options === 'object' && 'value' in options
? { provinceCode: options }
: typeof options === 'string' || options === undefined
? { provinceCode: options }
: options
const { provinceCode, pageSize = 10, enablePagination = true, enableSearch = true } = normalizedOptions
// Convert provinceCode ke ref jika bukan ref
const provinceCodeRef =
typeof provinceCode === 'string' || provinceCode === undefined ? ref(provinceCode) : provinceCode || ref(undefined)
// State untuk pagination dan search
const currentPage = ref(1)
const currentPageSize = ref(pageSize)
const searchQuery = ref('')
// Function untuk generate query key
const generateQueryKey = (params: RegencyQueryParams) => {
const keyParts = [
params['province-code'] || '',
params.search || '',
params['page-number'] || 1,
params['page-size'] || pageSize,
params.sort || 'name:asc',
]
return keyParts.join('|')
}
// Current query key
const currentQueryKey = computed(() => {
return generateQueryKey({
'province-code': provinceCodeRef.value,
search: enableSearch ? searchQuery.value : undefined,
'page-number': enablePagination ? currentPage.value : undefined,
'page-size': enablePagination ? currentPageSize.value : undefined,
sort: 'name:asc',
})
})
// Computed untuk mendapatkan regencies berdasarkan current query
const regencies = computed(() => {
const queryKey = currentQueryKey.value
const cachedData = regenciesCache.get(queryKey)
return cachedData?.data || []
})
// Computed untuk pagination meta
const paginationMeta = computed(() => {
const queryKey = currentQueryKey.value
const cachedData = regenciesCache.get(queryKey)
return (
cachedData?.meta || {
recordCount: 0,
page: currentPage.value,
pageSize: currentPageSize.value,
totalPage: 0,
hasNext: false,
hasPrev: false,
}
)
})
// Computed untuk loading state
const isLoading = computed(() => {
const queryKey = currentQueryKey.value
return loadingStates.get(queryKey) || false
})
// Computed untuk error state
const error = computed(() => {
const queryKey = currentQueryKey.value
return errorStates.get(queryKey) || null
})
// Computed untuk format Item
const regencyOptions = computed<Item[]>(() => {
return regencies.value.map((regency) => ({
label: toTitleCase(regency.name),
value: regency.code,
searchValue: `${regency.code} ${regency.name}`.trim(),
}))
})
// Function untuk fetch regencies dengan pagination dan search
async function fetchRegencies(params?: Partial<RegencyQueryParams>, forceRefresh = false) {
const queryParams: RegencyQueryParams = {
'province-code': params?.['province-code'] || provinceCodeRef.value,
search: enableSearch ? params?.search || searchQuery.value : undefined,
'page-number': enablePagination ? params?.['page-number'] || currentPage.value : undefined,
'page-size': enablePagination ? params?.['page-size'] || currentPageSize.value : undefined,
sort: params?.sort || 'name:asc',
}
// Jika tidak ada province code, return
// if (!queryParams['province-code']) return // buat komponen select birth
const queryKey = generateQueryKey(queryParams)
// Jika tidak force refresh dan sudah ada cache, skip
if (!forceRefresh && regenciesCache.has(queryKey)) {
return
}
// Jika sedang loading, skip untuk mencegah duplicate calls
if (loadingStates.get(queryKey)) {
return
}
loadingStates.set(queryKey, true)
errorStates.set(queryKey, null)
try {
// Prepare API parameters
const apiParams: any = {
sort: queryParams.sort,
'province-code': queryParams['province-code'],
}
// Add pagination or no-limit based on enablePagination
if (enablePagination && queryParams['page-number'] && queryParams['page-size']) {
apiParams['page-number'] = queryParams['page-number']
apiParams['page-size'] = queryParams['page-size']
} else {
apiParams['page-no-limit'] = true
}
// Add search if enabled and has value
if (enableSearch && queryParams.search && queryParams.search.trim().length >= 3) {
apiParams.search = queryParams.search.trim()
}
const response = await regencyService.getList(apiParams)
if (response.success) {
const regenciesData = response.body.data || []
const meta = response.body.meta || { record_totalCount: regenciesData.length }
// Create pagination meta
const paginationMeta: PaginationMeta = {
recordCount: meta.record_totalCount,
page: queryParams['page-number'] || 1,
pageSize: queryParams['page-size'] || regenciesData.length,
totalPage:
enablePagination && queryParams['page-size']
? Math.ceil(meta.record_totalCount / queryParams['page-size'])
: 1,
hasNext: false,
hasPrev: false,
}
if (enablePagination && queryParams['page-size']) {
paginationMeta.hasNext = paginationMeta.page < paginationMeta.totalPage
paginationMeta.hasPrev = paginationMeta.page > 1
}
// Cache the result
regenciesCache.set(queryKey, {
data: [...regenciesData],
meta: { ...paginationMeta },
queryKey,
})
} else {
errorStates.set(queryKey, 'Gagal memuat data kabupaten/kota')
console.error('Failed to fetch regencies:', response)
}
} catch (err) {
errorStates.set(queryKey, 'Terjadi kesalahan saat memuat data kabupaten/kota')
console.error('Error fetching regencies:', err)
} finally {
loadingStates.set(queryKey, false)
}
}
// Function untuk mencari regency berdasarkan code (search di semua cached data)
function getRegencyByCode(code: string): Regency | undefined {
// Search di semua cached data
for (const cachedData of regenciesCache.values()) {
const found = cachedData.data.find((regency) => regency.code === code)
if (found) return found
}
return undefined
}
// Function untuk mencari regency berdasarkan name (search di semua cached data)
function getRegencyByName(name: string): Regency | undefined {
// Search di semua cached data
for (const cachedData of regenciesCache.values()) {
const found = cachedData.data.find((regency) => regency.name.toLowerCase() === name.toLowerCase())
if (found) return found
}
return undefined
}
// Function untuk pagination
function goToPage(page: number) {
if (page >= 1 && page <= paginationMeta.value.totalPage) {
currentPage.value = page
}
}
function nextPage() {
if (paginationMeta.value.hasNext) {
currentPage.value += 1
}
}
function prevPage() {
if (paginationMeta.value.hasPrev) {
currentPage.value -= 1
}
}
function changePageSize(size: number) {
currentPageSize.value = size
currentPage.value = 1 // Reset ke halaman pertama
}
// Function untuk search
function setSearch(query: string) {
searchQuery.value = query
currentPage.value = 1 // Reset ke halaman pertama saat search
}
function clearSearch() {
searchQuery.value = ''
currentPage.value = 1
}
// Function untuk clear cache berdasarkan query key pattern
function clearCache(provinceCodeParam?: string) {
const code = provinceCodeParam || provinceCodeRef.value
if (code) {
// Clear semua cache yang mengandung province code tersebut
const keysToDelete: string[] = []
for (const [key] of regenciesCache.entries()) {
if (key.startsWith(code + '|')) {
keysToDelete.push(key)
}
}
keysToDelete.forEach((key) => {
regenciesCache.delete(key)
loadingStates.delete(key)
errorStates.delete(key)
})
}
}
// Function untuk clear semua cache
function clearAllCache() {
regenciesCache.clear()
loadingStates.clear()
errorStates.clear()
}
// Function untuk refresh data
function refreshRegencies(params?: Partial<RegencyQueryParams>) {
return fetchRegencies(params, true)
}
// Debounced province code untuk mencegah multiple calls
const debouncedProvinceCode = refDebounced(provinceCodeRef, 100)
// Watch perubahan province code untuk auto fetch
watch(
debouncedProvinceCode,
(newCode, oldCode) => {
if (newCode && newCode !== oldCode) {
// Reset pagination dan search saat province code berubah
currentPage.value = 1
if (enableSearch) {
searchQuery.value = ''
}
fetchRegencies()
}
},
{ immediate: true },
)
const triggerFetchAfterIdle = useDebounceFn(() => {
if (enableSearch) {
currentPage.value = 1
fetchRegencies()
}
}, 1000)
// Watch perubahan search query untuk auto fetch dan reset halaman
watch(searchQuery, (newSearch, oldSearch) => {
if (newSearch !== oldSearch) {
triggerFetchAfterIdle()
}
})
// Watch perubahan pagination untuk auto fetch
watch([currentPage, currentPageSize], () => {
if (enablePagination) {
fetchRegencies()
}
})
watch(regencyOptions, (val) => {
console.log('[regencyOptions] updated', val.length)
})
return {
// Data
regencies: readonly(regencies),
regencyOptions,
// State
isLoading: readonly(isLoading),
error: readonly(error),
paginationMeta: readonly(paginationMeta),
// Search state
searchQuery: enableSearch ? searchQuery : readonly(ref('')),
// Pagination state
currentPage: enablePagination ? currentPage : readonly(ref(1)),
currentPageSize: enablePagination ? currentPageSize : readonly(ref(pageSize)),
// Methods
fetchRegencies,
refreshRegencies,
getRegencyByCode,
getRegencyByName,
clearCache,
clearAllCache,
// Pagination methods
goToPage: enablePagination ? goToPage : () => {},
nextPage: enablePagination ? nextPage : () => {},
prevPage: enablePagination ? prevPage : () => {},
changePageSize: enablePagination ? changePageSize : () => {},
// Search methods
setSearch: enableSearch ? setSearch : () => {},
clearSearch: enableSearch ? clearSearch : () => {},
}
}
// Export untuk direct access ke cached data (jika diperlukan)
export const useRegenciesCache = () => ({
regenciesCache: readonly(regenciesCache),
loadingStates: readonly(loadingStates),
errorStates: readonly(errorStates),
})
+181
View File
@@ -0,0 +1,181 @@
import { ref, computed, watch } from 'vue'
import { refDebounced } from '@vueuse/core'
import type { Village } from '~/models/village'
import type { SelectItem } from '~/components/pub/my-ui/form/select.vue'
import { toTitleCase } from '~/lib/utils'
import * as villageService from '~/services/village.service'
// Global cache untuk villages berdasarkan district code
const villagesCache = ref<Map<string, Village[]>>(new Map())
const loadingStates = ref<Map<string, boolean>>(new Map())
const errorStates = ref<Map<string, string | null>>(new Map())
export function useVillages(districtCode: Ref<string | undefined> | string | undefined) {
// Convert districtCode ke ref jika bukan ref
const districtCodeRef =
typeof districtCode === 'string' || districtCode === undefined ? ref(districtCode) : districtCode
// Computed untuk mendapatkan villages berdasarkan district code
const villages = computed(() => {
const code = districtCodeRef.value
if (!code) return []
return villagesCache.value.get(code) || []
})
// Computed untuk loading state
const isLoading = computed(() => {
const code = districtCodeRef.value
if (!code) return false
return loadingStates.value.get(code) || false
})
// Computed untuk error state
const error = computed(() => {
const code = districtCodeRef.value
if (!code) return null
return errorStates.value.get(code) || null
})
// Computed untuk format SelectItem
const villageOptions = computed<SelectItem[]>(() => {
return villages.value.map((village) => ({
label: toTitleCase(village.name),
value: village.code,
searchValue: `${village.code} ${village.name}`.trim(),
}))
})
// Function untuk fetch villages berdasarkan district code
async function fetchVillages(districtCodeParam?: string, forceRefresh = false, isUserAction = false) {
const code = districtCodeParam || districtCodeRef.value
if (!code) return
// Jika user action atau force refresh, selalu fetch
// Jika bukan user action dan sudah ada cache, skip
if (!isUserAction && !forceRefresh && villagesCache.value.has(code)) {
return
}
// Jika sedang loading, skip untuk mencegah duplicate calls
if (loadingStates.value.get(code)) {
return
}
// Tambahan: Cek apakah ada pending request untuk code yang sama
const pendingKey = `pending_${code}`
if (loadingStates.value.get(pendingKey)) {
return
}
loadingStates.value.set(pendingKey, true)
loadingStates.value.set(code, true)
errorStates.value.set(code, null)
try {
const response = await villageService.getList({
sort: 'name:asc',
'district-code': code,
'page-no-limit': true,
})
if (response.success) {
const villagesData = response.body.data || []
villagesCache.value.set(code, villagesData)
} else {
errorStates.value.set(code, 'Gagal memuat data kelurahan')
console.error('Failed to fetch villages:', response)
}
} catch (err) {
errorStates.value.set(code, 'Terjadi kesalahan saat memuat data kelurahan')
console.error('Error fetching villages:', err)
} finally {
loadingStates.value.set(code, false)
loadingStates.value.delete(pendingKey)
}
}
// Function untuk mencari village berdasarkan code
function getVillageByCode(code: string): Village | undefined {
const districtCode = districtCodeRef.value
if (!districtCode) return undefined
const villagesForDistrict = villagesCache.value.get(districtCode) || []
return villagesForDistrict.find((village) => village.code === code)
}
// Function untuk mencari village berdasarkan name
function getVillageByName(name: string): Village | undefined {
const districtCode = districtCodeRef.value
if (!districtCode) return undefined
const villagesForDistrict = villagesCache.value.get(districtCode) || []
return villagesForDistrict.find((village) => village.name.toLowerCase() === name.toLowerCase())
}
// Function untuk clear cache district tertentu
function clearCache(districtCodeParam?: string) {
const code = districtCodeParam || districtCodeRef.value
if (code) {
villagesCache.value.delete(code)
loadingStates.value.delete(code)
errorStates.value.delete(code)
}
}
// Function untuk clear semua cache
function clearAllCache() {
villagesCache.value.clear()
loadingStates.value.clear()
errorStates.value.clear()
}
// Function untuk refresh data
function refreshVillages(districtCodeParam?: string) {
const code = districtCodeParam || districtCodeRef.value
if (code) {
return fetchVillages(code, true)
}
}
// Debounced district code untuk mencegah multiple calls
const debouncedDistrictCode = refDebounced(districtCodeRef, 100)
// Watch perubahan district code untuk auto fetch
watch(
debouncedDistrictCode,
(newCode, oldCode) => {
if (newCode && newCode !== oldCode) {
// Jika ada oldCode berarti user action (ganti pilihan)
const isUserAction = !!oldCode
fetchVillages(newCode, false, isUserAction)
}
},
{ immediate: true },
)
return {
// Data
villages: readonly(villages),
villageOptions,
// State
isLoading: readonly(isLoading),
error: readonly(error),
// Methods
fetchVillages,
refreshVillages,
getVillageByCode,
getVillageByName,
clearCache,
clearAllCache,
}
}
// Export untuk direct access ke cached data (jika diperlukan)
export const useVillagesCache = () => ({
villagesCache: readonly(villagesCache),
loadingStates: readonly(loadingStates),
errorStates: readonly(errorStates),
})
+13 -2
View File
@@ -129,23 +129,34 @@ export function genCrudHandler<T = any>(crud: {
recItem.value = null
}
async function handleActionSave(values: any, refresh: () => void, reset: () => void, toast: ToastFn) {
async function handleActionSave(
values: any,
refresh: () => void,
reset: () => void,
toast: ToastFn,
): Promise<any | null> {
isProcessing.value = true
let successResponse: any = null
await handleAsyncAction<[any], any>({
action: crud.create,
args: [values],
toast,
successMessage: 'Data berhasil disimpan',
errorMessage: 'Gagal menyimpan data',
onSuccess: () => {
onSuccess: (result) => {
isFormEntryDialogOpen.value = false
if (refresh) refresh()
successResponse = result
},
onFinally: (isSuccess: boolean) => {
if (isSuccess) setTimeout(reset, 500)
isProcessing.value = false
},
})
return successResponse
}
async function handleActionEdit(
+24
View File
@@ -0,0 +1,24 @@
// Handlers
import { genCrudHandler } from '~/handlers/_handler'
// Services
import { postPatient as create, patchPatient as update, removePatient as remove } from '~/services/patient.service'
export const {
recId,
recAction,
recItem,
isReadonly,
isProcessing,
isFormEntryDialogOpen,
isRecordConfirmationOpen,
onResetState,
handleActionSave,
handleActionEdit,
handleActionRemove,
handleCancelForm,
} = genCrudHandler({
create,
update,
remove,
})
+93 -5
View File
@@ -85,6 +85,7 @@ export const religionCodes: Record<string, string> = {
hindu: 'Hindu',
buda: 'Buda',
konghucu: 'Konghucu',
other: 'Kepercayaan Lain',
}
export const educationCodes: Record<string, string> = {
@@ -102,13 +103,91 @@ export const educationCodes: Record<string, string> = {
}
export const occupationCodes: Record<string, string> = {
'tidak-bekerja': 'Tidak Bekerja',
unknown: 'Tidak diketahui',
tidak_bekerja: 'Belum/Tidak Bekerja',
mengurus_rumah_tangga: 'Mengurus Rumah Tangga',
pelajar: 'Pelajar/Mahasiswa',
pensiunan: 'Pensiunan',
pns: 'Pegawai Negeri Sipil',
polisi: 'Polisi',
tni: 'TNI',
tni: 'Tentara Nasional Indonesia',
polri: 'Kepolisian RI',
perdagangan: 'Perdagangan',
petani: 'Petani/Pekebun',
peternak: 'Peternak',
nelayan: 'Nelayan/Perikanan',
industri: 'Industri',
konstruksi: 'Konstruksi',
transportasi: 'Transportasi',
karyawan_swasta: 'Karyawan Swasta',
karyawan_bumn: 'Karyawan BUMN',
karyawan_bumd: 'Karyawan BUMD',
karyawan_honorer: 'Karyawan Honorer',
buruh_harian: 'Buruh Harian Lepas',
buruh_tani: 'Buruh Tani/Perkebunan',
buruh_nelayan: 'Buruh Nelayan/Perikanan',
buruh_peternakan: 'Buruh Peternakan',
pembantu_rumah_tangga: 'Pembantu Rumah Tangga',
tukang_cukur: 'Tukang Cukur',
tukang_listrik: 'Tukang Listrik',
tukang_batu: 'Tukang Batu',
tukang_kayu: 'Tukang Kayu',
tukang_sol_sepatu: 'Tukang Sol Sepatu',
tukang_jahit: 'Tukang Jahit',
tukang_gigi: 'Tukang Gigi',
penata_rias: 'Penata Rias',
penata_busana: 'Penata Busana',
penata_rambut: 'Penata Rambut',
mekanik: 'Mekanik',
seniman: 'Seniman',
tabib: 'Tabib',
paraji: 'Paraji',
perancang_busana: 'Perancang Busana',
penterjemah: 'Penterjemah',
imam_mesjid: 'Imam Mesjid',
pendeta: 'Pendeta',
pastor: 'Pastor',
wartawan: 'Wartawan',
ustadz: 'Ustadz/Mubaligh',
juru_masak: 'Juru Masak',
promotor: 'Promotor Acara',
dpr_ri: 'Anggota DPR-RI',
dpd: 'Anggota DPD',
bpk: 'Anggota BPK',
presiden: 'Presiden',
wakil_presiden: 'Wakil Presiden',
mk: 'Anggota Mahkamah Konstitusi',
kabinet: 'Anggota Kabinet/Kementrian',
dubes: 'Duta Besar',
gubernur: 'Gubernur',
wakil_gubernur: 'Wakil Gubernur',
bupati: 'Bupati',
wakil_bupati: 'Wakil Bupati',
walikota: 'Walikota',
wakil_walikota: 'Wakil Walikota',
dprd_provinsi: 'Anggota DPRD Provinsi',
dprd_kabkota: 'Anggota DPRD Kabupaten/Kota',
dosen: 'Dosen',
guru: 'Guru',
pilot: 'Pilot',
pengacara: 'Pengacara',
arsitek: 'Arsitek',
akuntan: 'Akuntan',
konsultan: 'Konsultan',
dokter: 'Dokter',
bidan: 'Bidan',
apoteker: 'Apoteker',
psikolog: 'Psikiater/Psikolog',
penyiar_tv: 'Penyiar Televisi',
penyiar_radio: 'Penyiar Radio',
pelaut: 'Pelaut',
sopir: 'Sopir',
pialang: 'Pialang',
paranormal: 'Paranormal',
pedagang: 'Pedagang',
perangkat_desa: 'Perangkat Desa',
kepala_desa: 'Kepala Desa',
biarawati: 'Biarawati',
wiraswasta: 'Wiraswasta',
'kary-swasta': 'Karyawan Swasta',
lainnya: 'Lainnya',
}
@@ -248,7 +327,10 @@ export const uploadCode: Record<string, string> = {
kk: 'person-family-card',
paspor: 'person-passport',
'mcu-report': 'mcu-item-result',
}
} as const
export type UploadCodeKey = keyof typeof uploadCode
export type UploadCodeValue = (typeof uploadCode)[UploadCodeKey]
export const infraGroupCodes: Record<string, string> = {
building: 'Bangunan',
@@ -269,6 +351,12 @@ export const infraGroupCodesKeys: Record<string, string> = Object.keys(infraGrou
{} as Record<string, string>,
)
export const addressLocationTypeCode: Record<string, string> = {
identity: 'Alamat KTP',
domicile: 'Alamat Domisili',
}
export type AddressLocationTypeCode = keyof typeof addressLocationTypeCode
export const medicalActionTypeCode: Record<string, string> = {
chemo: 'Chemo',
hemo: 'Hemo',
+10
View File
@@ -27,6 +27,16 @@ export function mapToComboboxOptList(items: Record<string, string>): SelectOptio
return result
}
/**
* Mengkonversi string menjadi title case (huruf pertama setiap kata kapital)
* @param str - String yang akan dikonversi
* @returns String dalam format title case
*/
export function toTitleCase(str: string): string {
return str.toLowerCase().replace(/\b\w/g, (char) => char.toUpperCase())
}
/**
* Menghitung umur berdasarkan tanggal lahir
* @param birthDate - Tanggal lahir dalam format Date atau string
+20
View File
@@ -0,0 +1,20 @@
import { type Base, genBase } from './_base'
import type { Regency } from './regency'
export interface District extends Base {
regency_code: string
code: string
name: string
// preload
regency?: Regency | null
}
export function genDistrict(): District {
return {
...genBase(),
regency_code: '',
name: '',
code: '',
}
}
+32 -9
View File
@@ -21,7 +21,7 @@ export interface PatientBase extends Base {
number?: string
}
export interface PatientEntity extends PatientBase {
export interface Patient extends PatientBase {
person: Person
personAddresses: PersonAddress[]
personContacts: PersonContact[]
@@ -37,16 +37,39 @@ export interface genPatientProps {
responsible: PersonRelativeFormData
}
export function genPatient(props: genPatientProps): PatientEntity {
export function genPatient(props: genPatientProps): Patient {
console.log(props)
const { patient, residentAddress, cardAddress, familyData, contacts, responsible } = props
const addresses: PersonAddress[] = [{ ...genBase(), person_id: 0, locationType: '', ...residentAddress }]
const addresses: PersonAddress[] = [{ ...genBase(), person_id: 0, ...residentAddress }]
const familiesContact: PersonRelative[] = []
const personContacts: PersonContact[] = []
// jika alamat ktp sama dengan domisili saat ini
if (cardAddress.isSameAddress === '1') {
addresses.push({ ...genBase(), person_id: 0, locationType: '', ...residentAddress })
if (cardAddress.isSameAddress) {
addresses.push({
...genBase(),
...residentAddress,
person_id: 0,
locationType_code: cardAddress.locationType_code || 'identity'
})
} else {
// jika alamat berbeda, tambahkan alamat relatif
// Pastikan semua field yang diperlukan ada
const relativeAddress = {
...genBase(),
person_id: 0,
locationType_code: cardAddress.locationType_code || 'identity',
address: cardAddress.address || '',
province_code: cardAddress.province_code || '',
regency_code: cardAddress.regency_code || '',
district_code: cardAddress.district_code || '',
village_code: cardAddress.village_code || '',
postalRegion_code: cardAddress.postalRegion_code || '',
rt: cardAddress.rt,
rw: cardAddress.rw,
}
addresses.push(relativeAddress)
}
// add data orang tua
@@ -97,7 +120,7 @@ export function genPatient(props: genPatientProps): PatientEntity {
person: {
id: 0,
name: patient.fullName,
alias: patient.alias,
// alias: patient.alias,
birthDate: patient.birthDate,
birthRegency_code: patient.birthPlace,
gender_code: patient.gender,
@@ -111,7 +134,7 @@ export function genPatient(props: genPatientProps): PatientEntity {
ethnic_code: patient.ethnicity,
language_code: patient.language,
communicationIssueStatus: patient.communicationBarrier,
disability: patient.disability,
disability: patient.disabilityType || '',
nationality: patient.nationality,
// residentIdentityFileUrl: patient.residentIdentityFileUrl,
// passportFileUrl: patient.passportFileUrl,
@@ -126,10 +149,10 @@ export function genPatient(props: genPatientProps): PatientEntity {
personRelatives: familiesContact,
registeredAt: new Date(),
status_code: 'active',
newBornStatus: false,
newBornStatus: patient.isNewBorn,
person_id: 0,
id: 0,
number: '0x000000000000000000000000000000',
number: '',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
+31 -5
View File
@@ -1,21 +1,47 @@
import { type Base, genBase } from "./_base"
import { type Base, genBase } from './_base'
import type { AddressLocationTypeCode } from '~/lib/constants'
import type { PostalRegion } from './postal-region'
import { toTitleCase } from '~/lib/utils'
export interface PersonAddress extends Base {
person_id: number
locationType: string
locationType_code: AddressLocationTypeCode
address: string
rt?: string
rw?: string
postalCode?: string
postalRegion_code?: string
village_code: string
// preload
postalRegion?: PostalRegion | null
locationType?: AddressLocationTypeCode
}
export function genPersonAddress(): PersonAddress {
return {
...genBase(),
person_id: 0,
locationType: '',
locationType_code: '',
address: '',
village_code: '',
}
}
export function formatAddress(builder?: PersonAddress) {
if (!builder) return ''
const village = builder?.postalRegion?.village
const district = village?.district
const regency = district?.regency
const province = regency?.province
const parts = [
builder?.address,
village?.name,
district?.name,
toTitleCase(regency?.name || ''),
toTitleCase(province?.name || ''),
builder?.postalRegion_code,
].filter(Boolean)
return parts.join(', ')
}
+1
View File
@@ -12,3 +12,4 @@ export interface PersonRelative {
occupation_code?: string
responsible?: boolean
}
+16 -4
View File
@@ -1,10 +1,14 @@
import { type Base, genBase } from "./_base"
import { type Base, genBase } from './_base'
import type { Ethnic } from './ethnic'
import type { Language } from './language'
import type { PersonAddress } from './person-address'
import type { PersonContact } from './person-contact'
import type { PersonRelative } from './person-relative'
import type { Regency } from './regency'
export interface Person extends Base {
// todo: awaiting approve from stake holder: buat field sapaan
// todo: adjust field ketika person Balita
name: string
alias?: string
// alias?: string
frontTitle?: string
endTitle?: string
birthDate?: Date | string
@@ -26,6 +30,14 @@ export interface Person extends Base {
passportFileUrl?: string
drivingLicenseFileUrl?: string
familyIdentityFileUrl?: string
// preload data for detail patient
birthRegency?: Regency | null
addresses?: PersonAddress[] | null
contacts?: PersonContact[] | null
relatives?: PersonRelative[] | null
ethnic?: Ethnic | null
language?: Language | null
}
export function genPerson(): Person {
+18
View File
@@ -0,0 +1,18 @@
import { type Base, genBase } from './_base'
import type { Village } from './village'
export interface PostalRegion extends Base {
code: string
village_code: string
// preload
village?: Village | null
}
export function genPostalRegion(): PostalRegion {
return {
...genBase(),
code: '',
village_code: '',
}
}
+4 -1
View File
@@ -1,9 +1,12 @@
import { type Base, genBase } from "./_base"
import { type Base, genBase } from './_base'
import type { Province } from './province'
export interface Regency extends Base {
code: string
name: string
province_code: string
province?: Province | null
}
export function genRegency(): Regency {
+20
View File
@@ -0,0 +1,20 @@
import { type Base, genBase } from './_base'
import type { District } from './district'
export interface Village extends Base {
district_code: string
code: string
name: string
// preload
district?: District | null
}
export function genVillage(): Village {
return {
...genBase(),
district_code: '',
code: '',
name: '',
}
}
+2 -1
View File
@@ -28,12 +28,13 @@ if (!hasAccess) {
// Define permission-based computed properties
const canRead = hasReadAccess(roleAccess)
const callbackUrl = route.query['return-path'] as string | undefined
</script>
<template>
<div>
<div v-if="canRead">
<ContentPatientEntry />
<ContentPatientEntry :callback-url="callbackUrl" />
</div>
<Error
v-else
+81 -17
View File
@@ -6,22 +6,46 @@ const CommunicationBarrierSchema = z
})
.transform((val) => val === 'YA')
const IsNewBornSchema = z
.enum(['YA', 'TIDAK'], {
required_error: 'Mohon lengkapi status pasien',
})
.transform((val) => val === 'YA')
const ACCEPTED_UPLOAD_TYPES = ['image/jpeg', 'image/png', 'application/pdf']
const PatientSchema = z
.object({
// Data Diri Pasien
identityNumber: z
.string({
required_error: 'Mohon lengkapi NIK',
identityNumber: z.string().optional(),
// .string({
// required_error: 'Mohon lengkapi NIK',
// })
// .min(16, 'NIK harus berupa angka 16 digit')
// .regex(/^\d+$/, 'NIK harus berupa angka 16 digit'),
residentIdentityFile: z
.any()
.optional()
.refine((f) => !f || f instanceof File, { message: 'Harus berupa file yang valid' })
.refine((f) => !f || ACCEPTED_UPLOAD_TYPES.includes(f.type), {
message: 'Format file harus JPG, PNG, atau PDF',
})
.min(16, 'NIK harus berupa angka 16 digit')
.regex(/^\d+$/, 'NIK harus berupa angka 16 digit'),
// identityCardFile: z.instanceof(File, { message: 'File KTP harus dipilih' }),
// familyCardFile: z.instanceof(File, { message: 'File KK harus dipilih' }),
.refine((f) => !f || f.size <= 1 * 1024 * 1024, { message: 'Maksimal 1MB' }),
familyIdentityFile: z
.any()
.optional()
.refine((f) => !f || f instanceof File, { message: 'Harus berupa file yang valid' })
.refine((f) => !f || ACCEPTED_UPLOAD_TYPES.includes(f.type), {
message: 'Format file harus JPG, PNG, atau PDF',
})
.refine((f) => !f || f.size <= 1 * 1024 * 1024, { message: 'Maksimal 1MB' }),
// .refine(f => ['image/jpeg', 'image/png'].includes(f.type), 'Hanya JPG/PNG')
// Informasi Dasar
alias: z.string({
required_error: 'Mohon pilih sapaan',
}),
// alias: z.string({
// required_error: 'Mohon pilih sapaan',
// }),
fullName: z.string({
required_error: 'Mohon lengkapi Nama',
}),
@@ -71,6 +95,7 @@ const PatientSchema = z
nationality: z.string({
required_error: 'Pilih Kebangsaan',
}),
isNewBorn: IsNewBornSchema,
language: z.string({
required_error: 'Mohon pilih Preferensi Bahasa',
}),
@@ -99,13 +124,52 @@ const PatientSchema = z
note: z.string().optional(),
drivingLicenseNumber: z.string().optional(),
})
.refine((data) => (data.disability === 'TIDAK' ? !data.disabilityType : true), {
message: "DisabilityType hanya boleh diisi jika disability = 'YA'",
path: ['disabilityType'],
})
.refine((data) => (data.disability === 'YA' ? !!data.disabilityType?.trim() : true), {
message: 'Mohon pilih Jenis Disabilitas',
path: ['disabilityType'],
.refine(
(data) => {
// Jika disability = 'TIDAK', maka disabilityType harus kosong atau undefined
if (data.disability === 'TIDAK') {
return !data.disabilityType || data.disabilityType.trim() === ''
}
return true
},
{
message: "Jenis Disabilitas harus kosong jika Status Disabilitas = 'TIDAK'",
path: ['disabilityType'],
},
)
.refine(
(data) => {
// Jika disability = 'YA', maka disabilityType wajib diisi
if (data.disability === 'YA') {
return !!data.disabilityType?.trim()
}
return true
},
{
message: 'Mohon pilih Jenis Disabilitas',
path: ['disabilityType'],
},
)
// .refine((data) => {
// // Jika nationality = 'WNA', maka passportNumber wajib diisi
// if (data.nationality === 'WNA') {
// return !!data.passportNumber?.trim()
// }
// return true
// }, {
// message: 'Nomor Paspor wajib diisi untuk Warga Negara Asing',
// path: ['passportNumber'],
// })
.transform((data) => {
// Transform untuk backend: hanya kirim disabilityType sesuai kondisi
return {
...data,
// Jika disability = 'YA', kirim disabilityType
// Jika disability = 'TIDAK', kirim null untuk disabilityType
disabilityType: data.disability === 'YA' ? data.disabilityType : null,
// Hapus field disability karena yang dikirim ke backend adalah disabilityType
disability: undefined,
}
})
type PatientFormData = z.infer<typeof PatientSchema>
+49 -16
View File
@@ -1,25 +1,58 @@
import { z } from 'zod'
import { PersonAddressSchema } from './person-address.schema'
// Schema untuk alamat opsional ketika isSameAddress = '1'
const OptionalAddressSchema = PersonAddressSchema.partial()
const PersonAddressRelativeSchema = z
.object({
isSameAddress: z
.union([z.literal('1'), z.literal('0'), z.boolean()])
.default('1')
.transform((val) => {
if (typeof val === 'boolean') return val ? '1' : '0'
return val
}),
})
.merge(PersonAddressSchema.partial())
.superRefine((data, ctx) => {
const isSameAddress = data.isSameAddress
// Schema untuk alamat required ketika isSameAddress = '0'
const RequiredAddressSchema = PersonAddressSchema
// Jika alamat tidak sama ('0'), maka semua field address wajib diisi
if (isSameAddress === '0') {
const requiredFields = [
'province_code',
'regency_code',
'district_code',
'village_code',
'postalRegion_code',
'address',
]
const PersonAddressRelativeSchema = z.discriminatedUnion('isSameAddress', [
z
.object({
isSameAddress: z.literal('1').default('1'),
})
.merge(OptionalAddressSchema),
requiredFields.forEach((field) => {
if (!data[field as keyof typeof data]) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: getRequiredMessage(field),
path: [field],
})
}
})
}
})
.transform((data) => ({
...data,
isSameAddress: data.isSameAddress === '1',
}))
z
.object({
isSameAddress: z.literal('0'),
})
.merge(RequiredAddressSchema),
])
function getRequiredMessage(field: string): string {
const messages: Record<string, string> = {
province_code: 'Mohon pilih provinsi',
regency_code: 'Mohon pilih kabupaten/kota',
district_code: 'Mohon pilih kecamatan',
village_code: 'Mohon pilih kelurahan',
postalRegion_code: 'Mohon lengkapi kode pos',
address: 'Mohon lengkapi alamat',
}
return messages[field] || `${field} wajib diisi`
}
type PersonAddressRelativeFormData = z.infer<typeof PersonAddressRelativeSchema>
+4 -2
View File
@@ -1,6 +1,9 @@
import { z } from 'zod'
const PersonAddressSchema = z.object({
locationType_code: z.string({
required_error: 'Mohon pilih jenis alamat',
}),
address: z.string({
required_error: 'Mohon lengkapi alamat',
}),
@@ -16,8 +19,7 @@ const PersonAddressSchema = z.object({
village_code: z.string({
required_error: 'Mohon pilih kelurahan',
}),
// diganti postalCode, zipCode hanya beberapa negara, kurang universal
postalCode: z.string({
postalRegion_code: z.string({
required_error: 'Mohon lengkapi kode pos',
}),
// .min(5, 'Kode pos harus berupa angka 5 digit')
+25
View File
@@ -0,0 +1,25 @@
// Base
import * as base from './_crud-base'
const path = '/api/v1/district'
const name = 'district'
export function create(data: any) {
return base.create(path, data, name)
}
export function getList(params: any = null) {
return base.getList(path, params, name)
}
export function getDetail(id: number | string) {
return base.getDetail(path, id, name)
}
export function update(id: number | string, data: any) {
return base.update(path, id, data, name)
}
export function remove(id: number | string) {
return base.remove(path, id, name)
}
+28
View File
@@ -1,4 +1,5 @@
import { xfetch } from '~/composables/useXfetch'
import { uploadCode, type UploadCodeKey } from '~/lib/constants'
const mainUrl = '/api/v1/patient'
@@ -77,3 +78,30 @@ export async function removePatient(id: number) {
throw new Error('Failed to delete patient')
}
}
export async function uploadAttachment(file: File, userId: number, key: UploadCodeKey) {
try {
const resolvedKey = uploadCode[key]
if (!resolvedKey) {
throw new Error(`Invalid upload code key: ${key}`)
}
// siapkan form-data body
const formData = new FormData()
formData.append('code', resolvedKey)
formData.append('content', file)
// kirim via xfetch
const resp = await xfetch(`${mainUrl}/${userId}/upload`, 'POST', formData)
// struktur hasil sama seperti patchPatient
const result: any = {}
result.success = resp.success
result.body = (resp.body as Record<string, any>) || {}
return result
} catch (error) {
console.error('Error uploading attachment:', error)
throw new Error('Failed to upload attachment')
}
}
+17
View File
@@ -0,0 +1,17 @@
// Base service for postal code operations
import * as base from './_crud-base'
const path = '/api/v1/postal-region'
const name = 'postal-region'
export function create(data: any) {
return base.create(path, data, name)
}
export function getList(params: any = null) {
return base.getList(path, params, name)
}
export function getDetail(id: number | string) {
return base.getDetail(path, id, name)
}
+25
View File
@@ -0,0 +1,25 @@
// Base
import * as base from './_crud-base'
const path = '/api/v1/province'
const name = 'province'
export function create(data: any) {
return base.create(path, data, name)
}
export function getList(params: any = null) {
return base.getList(path, params, name)
}
export function getDetail(id: number | string) {
return base.getDetail(path, id, name)
}
export function update(id: number | string, data: any) {
return base.update(path, id, data, name)
}
export function remove(id: number | string) {
return base.remove(path, id, name)
}
+25
View File
@@ -0,0 +1,25 @@
// Base
import * as base from './_crud-base'
const path = '/api/v1/regency'
const name = 'regency'
export function create(data: any) {
return base.create(path, data, name)
}
export function getList(params: any = null) {
return base.getList(path, params, name)
}
export function getDetail(id: number | string) {
return base.getDetail(path, id, name)
}
export function update(id: number | string, data: any) {
return base.update(path, id, data, name)
}
export function remove(id: number | string) {
return base.remove(path, id, name)
}
+25
View File
@@ -0,0 +1,25 @@
// Base
import * as base from './_crud-base'
const path = '/api/v1/village'
const name = 'village'
export function create(data: any) {
return base.create(path, data, name)
}
export function getList(params: any = null) {
return base.getList(path, params, name)
}
export function getDetail(id: number | string) {
return base.getDetail(path, id, name)
}
export function update(id: number | string, data: any) {
return base.update(path, id, data, name)
}
export function remove(id: number | string) {
return base.remove(path, id, name)
}