feat(division): add form dialog for creating new division

- Implement dialog form with validation schema for division creation
- Add combobox component for parent division selection
- Include form submission handling with reset and error states
- a11y
This commit is contained in:
Khafid Prayoga
2025-09-02 14:48:43 +07:00
parent 132068fcde
commit 664849e15b
6 changed files with 198 additions and 107 deletions
+10 -7
View File
@@ -10,29 +10,32 @@ import { defineAsyncComponent } from 'vue'
type SmallDetailDto = any
const action = defineAsyncComponent(() => import('~/components/pub/custom-ui/data/dropdown-action-dud.vue'))
const action = defineAsyncComponent(() => import('~/components/pub/custom-ui/data/dropdown-action-ud.vue'))
export const cols: Col[] = [
{},
{},
{},
{},
{ width: 100 },
{ },
{ },
{ },
{ width: 50 },
]
export const header: Th[][] = [
[
{ label: 'Id' },
{ label: 'Kode' },
{ label: 'Nama' },
{ label: 'Kode' },
{ label: 'Kelompok' },
{ label: '' },
],
]
export const keys = [
'id',
'cellphone',
'firstName',
'cellphone',
'birth_place',
'action',
]
export const delKeyNames: KeyLabel[] = [
+58
View File
@@ -0,0 +1,58 @@
import * as z from 'zod'
export const division = {
msg: {
placeholder: '---pilih divisi utama',
search: 'kode, nama divisi',
empty: 'divisi tidak ditemukan',
},
items: [
{ value: '1', label: 'Medical', code: 'MED' },
{ value: '2', label: 'Nursing', code: 'NUR' },
{ value: '3', label: 'Admin', code: 'ADM' },
{ value: '4', label: 'Support', code: 'SUP' },
{ value: '5', label: 'Education', code: 'EDU' },
{ value: '6', label: 'Pharmacy', code: 'PHA' },
{ value: '7', label: 'Radiology', code: 'RAD' },
{ value: '8', label: 'Laboratory', code: 'LAB' },
{ value: '9', label: 'Finance', code: 'FIN' },
{ value: '10', label: 'Human Resources', code: 'HR' },
{ value: '11', label: 'IT Services', code: 'ITS' },
{ value: '12', label: 'Maintenance', code: 'MNT' },
{ value: '13', label: 'Catering', code: 'CAT' },
{ value: '14', label: 'Security', code: 'SEC' },
{ value: '15', label: 'Emergency', code: 'EMR' },
{ value: '16', label: 'Surgery', code: 'SUR' },
{ value: '17', label: 'Outpatient', code: 'OUT' },
{ value: '18', label: 'Inpatient', code: 'INP' },
{ value: '19', label: 'Rehabilitation', code: 'REB' },
{ value: '20', label: 'Research', code: 'RSH' },
],
}
export const schema = z.object({
name: z.string({
required_error: 'Nama wajib diisi',
}).min(1, 'Nama divisi wajib diisi'),
code: z.string({
required_error: 'Kode wajib diisi',
}).min(1, 'Kode divisi wajib diisi'),
parentId: z.preprocess(
(input: unknown) => {
if (typeof input === 'string') {
// Handle empty string case
if (input.trim() === '') {
return undefined
}
return Number(input)
}
return input
},
z.number({
required_error: 'Kelompok wajib dipilih',
}).min(1, 'Kelompok wajib dipilih'),
),
})
-98
View File
@@ -1,98 +0,0 @@
<script setup lang="ts">
import * as z from 'zod'
import Action from '~/components/pub/custom-ui/nav-footer/ba-su.vue'
const { errors, setFromZodError, clearErrors } = useFormErrors()
const data = ref({
code: '',
name: '',
parentId: '',
})
const division = {
msg: {
placeholder: '---pilih divisi utama',
search: 'kode, nama divisi',
empty: 'divisi tidak ditemukan',
},
items: [
{ value: '1', label: 'Medical', code: 'MED' },
{ value: '2', label: 'Nursing', code: 'NUR' },
{ value: '3', label: 'Admin', code: 'ADM' },
{ value: '4', label: 'Support', code: 'SUP' },
{ value: '5', label: 'Education', code: 'EDU' },
{ value: '6', label: 'Pharmacy', code: 'PHA' },
{ value: '7', label: 'Radiology', code: 'RAD' },
{ value: '8', label: 'Laboratory', code: 'LAB' },
{ value: '9', label: 'Finance', code: 'FIN' },
{ value: '10', label: 'Human Resources', code: 'HR' },
{ value: '11', label: 'IT Services', code: 'ITS' },
{ value: '12', label: 'Maintenance', code: 'MNT' },
{ value: '13', label: 'Catering', code: 'CAT' },
{ value: '14', label: 'Security', code: 'SEC' },
{ value: '15', label: 'Emergency', code: 'EMR' },
{ value: '16', label: 'Surgery', code: 'SUR' },
{ value: '17', label: 'Outpatient', code: 'OUT' },
{ value: '18', label: 'Inpatient', code: 'INP' },
{ value: '19', label: 'Rehabilitation', code: 'REB' },
{ value: '20', label: 'Research', code: 'RSH' },
],
}
const schema = z.object({
name: z.string().min(1, 'Nama divisi harus diisi'),
code: z.string().min(1, 'Kode divisi harus diisi'),
parentId: z.preprocess(
(input: unknown) => {
if (typeof input === 'string') {
// Handle empty string case
if (input.trim() === '') {
return 0
}
return Number(input)
}
return input
},
z.number().refine((num) => num > 0, 'Divisi harus dipilih'),
),
})
function onClick(type: string) {
if (type === 'cancel') {
navigateTo('/org-src/division')
} else if (type === 'draft') {
// do something
} else if (type === 'submit') {
// Clear previous errors
clearErrors()
const requestData = schema.safeParse(data.value)
if (!requestData.success) {
// Set errors menggunakan composable
setFromZodError(requestData.error)
// Optional: tampilkan toast notification untuk error general
console.warn('Form validation failed:', requestData.error)
return
}
console.log('Form data valid:', requestData.data)
// do something with valid data
}
}
</script>
<template>
<div class="mb-5 border-b border-b-slate-300 pb-3 text-lg xl:text-xl">
<Icon name="i-lucide-user" class="me-2" />
<span class="font-semibold">Tambah</span> Divisi
</div>
<div>
<AppDivisonEntryForm v-model="data" :errors="errors" :division="division" />
</div>
<div class="my-2 flex justify-end py-2">
<Action @click="onClick" />
</div>
</template>
+126 -2
View File
@@ -2,8 +2,11 @@
import type { DataTableLoader } from '~/components/pub/base/data-table/type'
import type { HeaderPrep } from '~/components/pub/custom-ui/data/types'
import type { PaginationMeta } from '~/components/pub/custom-ui/pagination/pagination.type'
import { toTypedSchema } from '@vee-validate/zod'
import { refDebounced, useUrlSearchParams } from '@vueuse/core'
import Combobox from '~/components/pub/custom-ui/form/combobox.vue'
import Header from '~/components/pub/custom-ui/nav-header/header.vue'
import { division as divisionConf, schema as schemaConf } from './entry'
import { defaultQuery, querySchema } from './schema.query'
// #region State & Computed
@@ -11,6 +14,7 @@ const data = ref([])
const isLoading = reactive<DataTableLoader>({
isTableLoading: false,
})
const isDialogOpen = ref(false)
// URL state management
const queryParams = useUrlSearchParams('history', {
@@ -33,6 +37,11 @@ const paginationMeta = reactive<PaginationMeta>({
hasPrev: false,
})
// Table action rowId provider
const recId = ref<number>(0)
const recAction = ref<string>('')
const recItem = ref<any>(null)
// Search model with debounce
const searchInput = ref(params.value.q || '')
const debouncedSearch = refDebounced(searchInput, 500) // 500ms debounce
@@ -58,11 +67,17 @@ const headerPrep: HeaderPrep = {
addNav: {
label: 'Tambah Divisi',
icon: 'i-lucide-send',
// todo: open modal form
onClick: () => navigateTo('/org-src/division/add'),
onClick: () => {
isDialogOpen.value = true
},
},
}
const formSchema = toTypedSchema(schemaConf)
provide('rec_id', recId)
provide('rec_action', recAction)
provide('rec_item', recItem)
provide('table_data_loader', isLoading)
// #endregion
@@ -127,6 +142,54 @@ function handlePageChange(page: number) {
// #endregion region
// #region Utilities & event handlers
function clearForm(setValues: (values: Record<string, any>) => void) {
// Manually clear all form fields
setValues({
name: '',
code: '',
parentId: '',
})
}
function onCancelForm(setValues: (values: Record<string, any>) => void) {
isDialogOpen.value = false
setTimeout(() => {
clearForm(setValues)
}, 500)
}
async function onSubmitForm(values: any, setValues: (values: Record<string, any>) => void) {
let isSuccess = false
try {
// TODO: Implement form submission logic
console.log('Form submitted:', values)
// Simulate API call
// const response = await xfetch('/api/v1/division', {
// method: 'POST',
// body: JSON.stringify(values)
// })
// If successful, mark as success and close dialog
isDialogOpen.value = false
isSuccess = true
// TODO: Show success message
console.log('Division created successfully')
} catch (error: unknown) {
console.warn('Error submitting form:', error)
isSuccess = false
// Don't close dialog or reset form on error
// TODO: Show error message to user
} finally {
if (isSuccess) {
setTimeout(() => {
clearForm(setValues)
}, 500)
}
}
}
// #endregion
// #region Watchers
@@ -161,6 +224,67 @@ watch(debouncedSearch, (newValue) => {
<div class="rounded-md border p-4">
<Header v-model="searchInput" :prep="headerPrep" @search="handleSearch" />
<AppDivisonList :data="data" :pagination-meta="paginationMeta" @page-change="handlePageChange" />
<Form v-slot="{ handleSubmit, setValues }" as="" keep-values :validation-schema="formSchema">
<Dialog v-model:open="isDialogOpen">
<DialogContent
class="sm:max-w-[425px]" @interact-outside="(e) => e.preventDefault()"
@pointer-down-outside="(e) => e.preventDefault()"
>
<DialogHeader>
<DialogTitle>Tambah Divisi</DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<form id="dialogForm" @submit="handleSubmit($event, (values) => onSubmitForm(values, setValues))">
<FormField v-slot="{ componentField }" name="name">
<FormItem>
<FormLabel>Nama</FormLabel>
<FormControl>
<Input
type="text" placeholder="Masukkan nama divisi" autocomplete="organization"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="code">
<FormItem>
<FormLabel>Kode</FormLabel>
<FormControl>
<Input type="text" placeholder="Masukkan kode divisi" autocomplete="off" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="parentId">
<FormItem>
<FormLabel>Kelompok</FormLabel>
<FormControl>
<Combobox
v-bind="componentField" :items="divisionConf.items"
:placeholder="divisionConf.msg.placeholder" :search-placeholder="divisionConf.msg.search"
:empty-message="divisionConf.msg.empty"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</form>
<DialogFooter>
<Button variant="outline" @click="onCancelForm(setValues)">
Batal
</Button>
<Button type="submit" form="dialogForm">
Simpan
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Form>
</div>
</template>
@@ -8,6 +8,7 @@ interface Item {
}
const props = defineProps<{
id: string
modelValue?: string
items: Item[]
placeholder?: string
@@ -61,6 +62,7 @@ function onSelect(item: Item) {
<Popover v-model:open="open">
<PopoverTrigger as-child>
<Button
:id="props.id"
variant="outline" role="combobox" :aria-expanded="open" :class="cn(
'w-full justify-between border-black bg-white hover:bg-gray-50 text-sm font-normal',
!modelValue && 'text-muted-foreground',
@@ -64,7 +64,9 @@ function clearSearch() {
<div v-if="props.prep.refSearchNav" class="ml-3 text-lg text-gray-900 relative">
<div class="relative">
<Input
id="search-table"
v-model="searchModel"
name="search-table"
type="text"
class="w-full rounded-md border bg-white px-4 py-2 text-gray-900 sm:text-sm"
:class="[