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:
@@ -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[] = [
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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="[
|
||||
|
||||
Reference in New Issue
Block a user