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:
@@ -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>
|
||||
Reference in New Issue
Block a user