feat(division): implement division list with pagination and search

- Add schema validation for query parameters
- Create pagination component with type definitions
- Implement division list view with search functionality
- Add data table configuration for division listing
- Handle pagination state and URL synchronization
This commit is contained in:
Khafid Prayoga
2025-09-01 14:45:32 +07:00
parent b1b324e688
commit 132068fcde
9 changed files with 556 additions and 75 deletions
+80
View File
@@ -0,0 +1,80 @@
import type {
Col,
KeyLabel,
RecComponent,
RecStrFuncComponent,
RecStrFuncUnknown,
Th,
} from '~/components/pub/custom-ui/data/types'
import { defineAsyncComponent } from 'vue'
type SmallDetailDto = any
const action = defineAsyncComponent(() => import('~/components/pub/custom-ui/data/dropdown-action-dud.vue'))
export const cols: Col[] = [
{},
{},
{},
{},
]
export const header: Th[][] = [
[
{ label: 'Id' },
{ label: 'Kode' },
{ label: 'Nama' },
{ label: 'Kelompok' },
],
]
export const keys = [
'id',
'cellphone',
'firstName',
'birth_place',
]
export const delKeyNames: KeyLabel[] = [
{ key: 'code', label: 'Kode' },
{ key: 'name', label: 'Nama' },
]
export const funcParsed: RecStrFuncUnknown = {
name: (rec: unknown): unknown => {
const recX = rec as SmallDetailDto
return `${recX.frontTitle} ${recX.name} ${recX.endTitle}`.trim()
},
identity_number: (rec: unknown): unknown => {
const recX = rec as SmallDetailDto
if (recX.identity_number?.substring(0, 5) === 'BLANK') {
return '(TANPA NIK)'
}
return recX.identity_number
},
inPatient_itemPrice: (rec: unknown): unknown => {
const recX = rec as SmallDetailDto
return Number(recX.inPatient_itemPrice.price).toLocaleString('id-ID')
},
outPatient_itemPrice: (rec: unknown): unknown => {
const recX = rec as SmallDetailDto
return Number(recX.outPatient_itemPrice.price).toLocaleString('id-ID')
},
}
export const funcComponent: RecStrFuncComponent = {
action(rec, idx) {
const res: RecComponent = {
idx,
rec: rec as object,
component: action,
}
return res
},
}
export const funcHtml: RecStrFuncUnknown = {
patient_address(_rec) {
return '-'
},
}
+59 -41
View File
@@ -1,54 +1,72 @@
<script setup lang="ts">
// #region Props & Emits
const props = defineProps<{
title: string
}>()
import type { PaginationMeta } from '~/components/pub/custom-ui/pagination/pagination.type'
import { cols, funcComponent, funcHtml, funcParsed, header, keys } from './list-cfg'
interface Props {
data: any[]
paginationMeta?: PaginationMeta
}
const props = defineProps<Props>()
const emit = defineEmits<{
(e: 'update', value: string): void
pageChange: [page: number]
}>()
// #endregion
// #region State & Computed
// =============================
const count = ref(0)
const double = computed(() => count.value * 2)
// #endregion
// #region Lifecycle Hooks
onMounted(() => {
// init code
fetchData()
})
// #endregion
// #region Functions
async function fetchData() {
console.log('fetched')
function handlePageChange(page: number) {
emit('pageChange', page)
}
// #endregion region
// #region Utilities & event handlers
function increment() {
count.value++
}
// #endregion
// #region Watchers
watch(count, (newVal, oldVal) => {
console.log('count changed:', oldVal, '->', newVal)
// Computed properties for formatted display
const formattedRecordCount = computed(() => {
if (!props.paginationMeta) return '0'
const count = props.paginationMeta.recordCount
if (count == null || count === undefined) return '0'
return Number(count).toLocaleString('id-ID')
})
const startRecord = computed(() => {
if (!props.paginationMeta) return 1
return ((props.paginationMeta.page - 1) * props.paginationMeta.pageSize) + 1
})
const endRecord = computed(() => {
if (!props.paginationMeta) return 0
return Math.min(props.paginationMeta.page * props.paginationMeta.pageSize, props.paginationMeta.recordCount)
})
// #endregion
</script>
<template>
<div>
<p>Count: {{ count }}</p>
<p>Double: {{ double }}</p>
<button @click="increment">+1</button>
<div class="space-y-4">
<PubBaseDataTable
:rows="data"
:cols="cols"
:header="header"
:keys="keys"
:func-parsed="funcParsed"
:func-html="funcHtml"
:func-component="funcComponent"
/>
<!-- Data info and pagination -->
<div v-if="paginationMeta" class="flex align-center justify-between">
<!-- Data info - always show -->
<div class="flex items-center justify-between px-2 mb-4">
<div class="flex-1 text-sm text-muted-foreground">
Menampilkan {{ startRecord }}
hingga {{ endRecord }}
dari {{ formattedRecordCount }} data
</div>
</div>
<!-- Pagination controls - only show when multiple pages -->
<div v-if="paginationMeta.totalPage > 1">
<PubCustomUiPagination
:pagination-meta="paginationMeta"
:show-info="false"
@page-change="handlePageChange"
/>
</div>
</div>
</div>
</template>
<style scoped>
/* component style */
</style>
+140 -25
View File
@@ -1,51 +1,166 @@
<script setup lang="ts">
// #region Props & Emits
const props = defineProps<{
title: string
}>()
const emit = defineEmits<{
(e: 'update', value: string): void
}>()
// #endregion
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 { refDebounced, useUrlSearchParams } from '@vueuse/core'
import Header from '~/components/pub/custom-ui/nav-header/header.vue'
import { defaultQuery, querySchema } from './schema.query'
// #region State & Computed
// =============================
const count = ref(0)
const double = computed(() => count.value * 2)
const data = ref([])
const isLoading = reactive<DataTableLoader>({
isTableLoading: false,
})
// URL state management
const queryParams = useUrlSearchParams('history', {
initialValue: defaultQuery,
removeFalsyValues: true,
})
const params = computed(() => {
const result = querySchema.safeParse(queryParams)
return result.data || defaultQuery
})
// Pagination state - computed from URL params
const paginationMeta = reactive<PaginationMeta>({
recordCount: 0,
page: params.value.page,
pageSize: params.value.pageSize,
totalPage: 0,
hasNext: false,
hasPrev: false,
})
// Search model with debounce
const searchInput = ref(params.value.q || '')
const debouncedSearch = refDebounced(searchInput, 500) // 500ms debounce
const headerPrep: HeaderPrep = {
title: 'Divisi',
icon: 'i-lucide-box',
refSearchNav: {
placeholder: 'Cari (min. 3 karakter)...',
minLength: 3,
debounceMs: 500,
showValidationFeedback: true,
onInput: (_val: string) => {
// Handle search input - this will be triggered by the header component
},
onClick: () => {
// Handle search button click if needed
},
onClear: () => {
// Handle search clear
},
},
addNav: {
label: 'Tambah Divisi',
icon: 'i-lucide-send',
// todo: open modal form
onClick: () => navigateTo('/org-src/division/add'),
},
}
provide('table_data_loader', isLoading)
// #endregion
// #region Lifecycle Hooks
onMounted(() => {
// init code
fetchData()
getDivisionList()
})
// #endregion
// #region Functions
async function fetchData() {
console.log('fetched')
async function getDivisionList() {
isLoading.isTableLoading = true
try {
// Use current params from URL state
const currentParams = params.value
// Prepare query parameters for pagination and search
const urlParams = new URLSearchParams({
'page-number': currentParams.page.toString(),
'page-size': currentParams.pageSize.toString(),
})
if (currentParams.q) {
urlParams.append('search', currentParams.q)
}
const resp = await xfetch(`/api/v1/patient?${urlParams.toString()}`)
if (resp.success) {
const responseBody = resp.body as Record<string, any>
data.value = responseBody.data || []
const pager = responseBody.meta
// Update pagination meta from response
// Fallback if meta is not provided by API
paginationMeta.recordCount = pager.record_totalCount
paginationMeta.page = currentParams.page
paginationMeta.pageSize = currentParams.pageSize
paginationMeta.totalPage = Math.ceil(pager.record_totalCount / paginationMeta.pageSize)
paginationMeta.hasNext = paginationMeta.page < paginationMeta.totalPage
paginationMeta.hasPrev = paginationMeta.page > 1
}
} catch (error) {
console.error('Error fetching division list:', error)
data.value = []
paginationMeta.recordCount = 0
paginationMeta.totalPage = 0
paginationMeta.hasNext = false
paginationMeta.hasPrev = false
} finally {
isLoading.isTableLoading = false
}
}
// Handle pagination page change
function handlePageChange(page: number) {
// Update URL params - this will trigger watcher
queryParams.page = page
}
// #endregion region
// #region Utilities & event handlers
function increment() {
count.value++
}
// #endregion
// #region Watchers
watch(count, (newVal, oldVal) => {
console.log('count changed:', oldVal, '->', newVal)
// Watch for URL param changes and trigger refetch
watch(params, (newParams) => {
// Sync search input with URL params (for back/forward navigation)
if (newParams.q !== searchInput.value) {
searchInput.value = newParams.q || ''
}
getDivisionList()
}, { deep: true })
// Handle search from header component
function handleSearch(searchValue: string) {
// Update URL params - this will trigger watcher and refetch data
queryParams.q = searchValue
queryParams.page = 1 // Reset to first page when searching
}
// Watch debounced search and update URL params (keeping for backward compatibility)
watch(debouncedSearch, (newValue) => {
// Only search if 3+ characters or empty (to clear search)
if (newValue.length === 0 || newValue.length >= 3) {
queryParams.q = newValue
queryParams.page = 1 // Reset to first page when searching
}
})
// #endregion
</script>
<template>
<div>
<p>Count: {{ count }}</p>
<p>Double: {{ double }}</p>
<button @click="increment">+1</button>
<div class="rounded-md border p-4">
<Header v-model="searchInput" :prep="headerPrep" @search="handleSearch" />
<AppDivisonList :data="data" :pagination-meta="paginationMeta" @page-change="handlePageChange" />
</div>
</template>
@@ -0,0 +1,15 @@
import * as z from 'zod'
export const querySchema = z.object({
q: z.union([z.literal(''), z.string().min(3)]).optional().catch(''),
page: z.coerce.number().int().min(1).default(1).catch(1),
pageSize: z.coerce.number().int().min(5).max(20).default(10).catch(10),
})
export const defaultQuery = {
q: '',
page: 1,
pageSize: 10,
}
export type QueryParams = z.infer<typeof querySchema>
+7 -1
View File
@@ -36,7 +36,7 @@ export interface Th {
}
export interface ButtonNav {
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'negative' | 'ghost' | 'link'
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link'
classVal?: string
classValExt?: string
icon?: string
@@ -56,6 +56,12 @@ export interface QuickSearchNav {
}
export interface RefSearchNav {
modelValue?: string
placeholder?: string
minLength?: number
debounceMs?: number
inputClass?: string
showValidationFeedback?: boolean
onInput: (val: string) => void
onClick: () => void
onClear: () => void
@@ -1,18 +1,140 @@
<script setup lang="ts">
import type { HeaderPrep, RefSearchNav } from '~/components/pub/custom-ui/data/types'
import type { HeaderPrep } from '~/components/pub/custom-ui/data/types'
import { refDebounced } from '@vueuse/core'
const props = defineProps<{
prep: HeaderPrep
refSearchNav: RefSearchNav
modelValue?: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
'search': [value: string]
}>()
// Internal search state
const searchInput = ref(props.modelValue || '')
const debouncedSearch = refDebounced(searchInput, props.prep.refSearchNav?.debounceMs || 500)
// Computed search model for v-model
const searchModel = computed({
get: () => searchInput.value,
set: (value: string) => {
searchInput.value = value
emit('update:modelValue', value)
},
})
// Watch for external changes to modelValue
watch(() => props.modelValue, (newValue) => {
if (newValue !== searchInput.value) {
searchInput.value = newValue || ''
}
})
// Watch debounced search and emit search event
watch(debouncedSearch, (newValue) => {
const minLength = props.prep.refSearchNav?.minLength || 3
// Only search if meets minimum length or empty (to clear search)
if (newValue.length === 0 || newValue.length >= minLength) {
emit('search', newValue)
props.prep.refSearchNav?.onInput(newValue)
}
})
// Handle clear search
function clearSearch() {
searchModel.value = ''
props.prep.refSearchNav?.onClear()
}
</script>
<template>
<header>
<div class="flex items-center">
<div class="ml-3 text-lg font-bold text-gray-900">
<Icon :name="props.prep.icon!" class="mr-2 h-4 w-4 align-middle" />
{{ props.prep.title }}
<div class="flex items-center justify-between">
<div class="flex items-center">
<div class="ml-3 text-lg font-bold text-gray-900">
<Icon :name="props.prep.icon!" class="mr-2 size-4 md:size-6 align-middle" />
{{ props.prep.title }}
</div>
</div>
<div class="flex items-center">
<!-- Search Section -->
<div v-if="props.prep.refSearchNav" class="ml-3 text-lg text-gray-900 relative">
<div class="relative">
<Input
v-model="searchModel"
type="text"
class="w-full rounded-md border bg-white px-4 py-2 text-gray-900 sm:text-sm"
:class="[
props.prep.refSearchNav.inputClass,
{
'border-amber-300 bg-amber-50': searchInput.length > 0 && searchInput.length < (props.prep.refSearchNav.minLength || 3),
'border-green-300 bg-green-50': searchInput.length >= (props.prep.refSearchNav.minLength || 3),
},
]"
:placeholder="props.prep.refSearchNav.placeholder || 'Cari (min. 3 karakter)...'"
/>
<!-- Clear button -->
<button
v-if="searchInput.length > 0"
class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
type="button"
@click="clearSearch"
>
<Icon name="i-lucide-x" class="h-4 w-4" />
</button>
<!-- Validation feedback -->
<div
v-if="props.prep.refSearchNav.showValidationFeedback !== false && searchInput.length > 0 && searchInput.length < (props.prep.refSearchNav.minLength || 3)"
class="absolute -bottom-6 left-0 text-xs text-amber-600 whitespace-nowrap"
>
Minimal {{ props.prep.refSearchNav.minLength || 3 }} karakter untuk mencari
</div>
</div>
</div>
<!-- Add Button -->
<div v-if="props.prep.addNav" class="m-2 flex items-center">
<Button
class="rounded-md border border-gray-300 px-4 py-2 text-white sm:text-sm"
:class="props.prep.addNav.classVal"
:variant="(props.prep.addNav.variant as any) || 'default'"
@click="props.prep.addNav?.onClick"
>
<Icon :name="props.prep.addNav.icon || 'i-lucide-plus'" class="mr-2 h-4 w-4 align-middle" />
{{ props.prep.addNav.label }}
</Button>
</div>
<!-- Filter Button -->
<div v-if="props.prep.filterNav" class="m-2 flex items-center">
<Button
class="rounded-md border border-gray-300 px-4 py-2 sm:text-sm"
:class="props.prep.filterNav.classVal"
:variant="(props.prep.filterNav.variant as any) || 'default'"
@click="props.prep.filterNav?.onClick"
>
<Icon :name="props.prep.filterNav.icon || 'i-lucide-filter'" class="mr-2 h-4 w-4 align-middle" />
{{ props.prep.filterNav.label }}
</Button>
</div>
<!-- Print Button -->
<div v-if="props.prep.printNav" class="m-2 flex items-center">
<Button
class="rounded-md border border-gray-300 px-4 py-2 sm:text-sm"
:class="props.prep.printNav.classVal"
:variant="(props.prep.printNav.variant as any) || 'default'"
@click="props.prep.printNav?.onClick"
>
<Icon :name="props.prep.printNav.icon || 'i-lucide-printer'" class="mr-2 h-4 w-4 align-middle" />
{{ props.prep.printNav.label }}
</Button>
</div>
</div>
</div>
</header>
@@ -0,0 +1,13 @@
export interface PaginationMeta {
recordCount: number
// page : current pointer for viewing data
page: number
// pageSize: limit each page request
pageSize: number
// totalPage: recourdCount / pageSize
totalPage: number
// hasNext: check if there is next page
hasNext: boolean
// hasPrev: check if there is previous page
hasPrev: boolean
}
@@ -0,0 +1,112 @@
<script setup lang="ts">
import type { PaginationMeta } from './pagination.type'
import {
Pagination,
PaginationEllipsis,
PaginationFirst,
PaginationLast,
PaginationList,
PaginationListItem,
PaginationNext,
PaginationPrev,
} from '~/components/pub/ui/pagination'
interface Props {
paginationMeta: PaginationMeta
onPageChange?: (page: number) => void
showInfo?: boolean
}
const props = withDefaults(defineProps<Props>(), {
onPageChange: undefined,
showInfo: true,
})
const emit = defineEmits<{
pageChange: [page: number]
}>()
function handlePageChange(page: number) {
if (props.onPageChange) {
props.onPageChange(page)
}
emit('pageChange', page)
}
function handleFirst() {
if (props.paginationMeta.hasPrev) {
handlePageChange(1)
}
}
function handlePrev() {
if (props.paginationMeta.hasPrev) {
handlePageChange(props.paginationMeta.page - 1)
}
}
function handleNext() {
if (props.paginationMeta.hasNext) {
handlePageChange(props.paginationMeta.page + 1)
}
}
function handleLast() {
if (props.paginationMeta.hasNext) {
handlePageChange(props.paginationMeta.totalPage)
}
}
// Computed properties for formatted numbers
const formattedRecordCount = computed(() => {
const count = props.paginationMeta.recordCount
if (count == null || count === undefined) return '0'
return Number(count).toLocaleString('id-ID')
})
const startRecord = computed(() => {
return ((props.paginationMeta.page - 1) * props.paginationMeta.pageSize) + 1
})
const endRecord = computed(() => {
return Math.min(props.paginationMeta.page * props.paginationMeta.pageSize, props.paginationMeta.recordCount)
})
</script>
<template>
<div class="flex items-center justify-between px-2">
<!-- Info text -->
<div v-if="showInfo" class="flex-1 text-sm text-muted-foreground">
Menampilkan {{ startRecord }}
hingga {{ endRecord }}
dari {{ formattedRecordCount }} data
</div>
<div v-else class="flex-1"></div>
<!-- Pagination controls -->
<Pagination
v-slot="{ page }" :total="paginationMeta.recordCount" :sibling-count="1" :page="paginationMeta.page"
:items-per-page="paginationMeta.pageSize" show-edges
>
<PaginationList v-slot="{ items }" class="flex items-center gap-1">
<PaginationFirst :disabled="!paginationMeta.hasPrev" @click="handleFirst" />
<PaginationPrev :disabled="!paginationMeta.hasPrev" @click="handlePrev" />
<template v-for="(item, index) in items">
<PaginationListItem
v-if="item.type === 'page'" :key="index" :value="item.value" as-child
@click="handlePageChange(item.value)"
>
<Button class="w-9 h-9 p-0" :variant="item.value === page ? 'default' : 'outline'">
{{ item.value }}
</Button>
</PaginationListItem>
<PaginationEllipsis v-else :key="item.type" :index="index" />
</template>
<PaginationNext :disabled="!paginationMeta.hasNext" @click="handleNext" />
<PaginationLast :disabled="!paginationMeta.hasNext" @click="handleLast" />
</PaginationList>
</Pagination>
</div>
</template>
@@ -6,7 +6,7 @@ import Error from '~/components/pub/base/error/error.vue'
definePageMeta({
// middleware: ['rbac'],
roles: ['doctor', 'nurse', 'admisi', 'pharmacy', 'billing', 'management'],
title: 'List Division',
title: 'Daftar Divisi',
contentFrame: 'cf-full-width',
})
@@ -34,7 +34,7 @@ const canRead = true
<template>
<div>
<div v-if="canRead">
route division list
<FlowDivisionList />
</div>
<Error v-else :status-code="403" />
</div>