feat: update sidebar button styles and add value updater utility function
- Adjusted sidebar menu button styles for improved icon size and padding. - Increased default button height and text size for better accessibility. - Introduced a new utility function `valueUpdater` to simplify state updates in components.
This commit is contained in:
parent
84ae9aff68
commit
2f79c83a17
136
app/components/data-table/DataTable.vue
Normal file
136
app/components/data-table/DataTable.vue
Normal file
@ -0,0 +1,136 @@
|
||||
<script setup lang="ts" generic="TData, TValue">
|
||||
import type {
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
ExpandedState,
|
||||
SortingState,
|
||||
} from '@tanstack/vue-table'
|
||||
import {
|
||||
FlexRender,
|
||||
getCoreRowModel,
|
||||
getExpandedRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useVueTable,
|
||||
} from '@tanstack/vue-table'
|
||||
import { ArrowUpDown } from '@lucide/vue'
|
||||
import { computed, h, ref } from 'vue'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { valueUpdater } from '@/lib/utils'
|
||||
import DataTablePagination from './DataTablePagination.vue'
|
||||
import {
|
||||
Empty, EmptyDescription,
|
||||
EmptyHeader, EmptyTitle
|
||||
} from '@/components/ui/empty'
|
||||
|
||||
const props = defineProps<{
|
||||
columns: ColumnDef<TData, TValue>[]
|
||||
data: TData[]
|
||||
}>()
|
||||
|
||||
const sorting = ref<SortingState>([])
|
||||
const columnFilters = ref<ColumnFiltersState>([])
|
||||
const expanded = ref<ExpandedState>([])
|
||||
|
||||
const numberColumn: ColumnDef<TData> = {
|
||||
id: 'number',
|
||||
accessorFn: (_, index) => index,
|
||||
header: ({ column }) => {
|
||||
return h(Button, {
|
||||
variant: 'ghost',
|
||||
class: 'justify-center',
|
||||
onClick: () => column.toggleSorting(column.getIsSorted() === 'asc'),
|
||||
}, () => ['#', h(ArrowUpDown, { class: 'ml-2 h-4 w-4' })])
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return h('div', { class: 'text-center' }, `${row.index + 1}`)
|
||||
},
|
||||
enableHiding: false,
|
||||
}
|
||||
|
||||
const allColumns = computed<ColumnDef<TData>[]>(() => [
|
||||
numberColumn,
|
||||
...props.columns,
|
||||
])
|
||||
|
||||
const table = useVueTable({
|
||||
get data() { return props.data },
|
||||
get columns() { return allColumns.value },
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getExpandedRowModel: getExpandedRowModel(),
|
||||
onSortingChange: updaterOrValue => valueUpdater(updaterOrValue, sorting),
|
||||
onColumnFiltersChange: updaterOrValue => valueUpdater(updaterOrValue, columnFilters),
|
||||
onExpandedChange: updaterOrValue => valueUpdater(updaterOrValue, expanded),
|
||||
state: {
|
||||
get sorting() { return sorting.value },
|
||||
get columnFilters() { return columnFilters.value },
|
||||
get expanded() { return expanded.value },
|
||||
},
|
||||
})
|
||||
|
||||
defineExpose({ table })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<slot name="filters" :table="table" />
|
||||
<div class="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
|
||||
<TableHead v-for="header in headerGroup.headers" :key="header.id"
|
||||
:class="['actions', 'number'].includes(header.column.id) ? 'w-0' : ''">
|
||||
<FlexRender v-if="!header.isPlaceholder" :render="header.column.columnDef.header"
|
||||
:props="header.getContext()" />
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<template v-if="table.getRowModel().rows?.length">
|
||||
<template v-for="row in table.getRowModel().rows" :key="row.id">
|
||||
<TableRow>
|
||||
<TableCell v-for="cell in row.getVisibleCells()" :key="cell.id"
|
||||
:class="['actions', 'number'].includes(cell.column.id) ? 'w-0' : ''">
|
||||
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow v-if="row.getIsExpanded()">
|
||||
<TableCell :colspan="row.getAllCells().length">
|
||||
{{ JSON.stringify(row.original) }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<TableRow v-else>
|
||||
<TableCell :colspan="allColumns.length" class="h-24 text-center">
|
||||
<Empty class="from-muted/50 to-background h-full bg-linear-to-b from-30%">
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>Tidak ada data ditemukan</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Sesuaikan filter atau kata kunci pencarian, lalu coba lagi.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<DataTablePagination :table="table" />
|
||||
</div>
|
||||
</template>
|
||||
64
app/components/data-table/DataTablePagination.vue
Normal file
64
app/components/data-table/DataTablePagination.vue
Normal file
@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import type { Table } from '@tanstack/vue-table';
|
||||
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from '@lucide/vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
const props = defineProps<{
|
||||
table: Table<any>
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-between px-2 py-4">
|
||||
<div />
|
||||
<div class="flex items-center gap-6 lg:gap-8">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-sm font-medium">
|
||||
Baris per halaman
|
||||
</p>
|
||||
<Select :model-value="`${table.getState().pagination.pageSize}`" @update:model-value="table.setPageSize">
|
||||
<SelectTrigger class="h-8 w-17.5">
|
||||
<SelectValue :placeholder="`${table.getState().pagination.pageSize}`" />
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top">
|
||||
<SelectItem v-for="pageSize in [10, 20, 30, 40, 50]" :key="pageSize" :value="`${pageSize}`">
|
||||
{{ pageSize }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="flex items-center justify-center text-sm font-medium">
|
||||
Halaman {{ table.getState().pagination.pageIndex + 1 }} dari
|
||||
{{ table.getPageCount() }}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="outline" class="hidden h-8 w-8 p-0 lg:flex" :disabled="!table.getCanPreviousPage()"
|
||||
@click="table.setPageIndex(0)">
|
||||
<span class="sr-only">Halaman pertama</span>
|
||||
<ChevronsLeft class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" class="h-8 w-8 p-0" :disabled="!table.getCanPreviousPage()"
|
||||
@click="table.previousPage()">
|
||||
<span class="sr-only">Halaman sebelumnya</span>
|
||||
<ChevronLeft class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" class="h-8 w-8 p-0" :disabled="!table.getCanNextPage()" @click="table.nextPage()">
|
||||
<span class="sr-only">Halaman berikutnya</span>
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" class="hidden h-8 w-8 p-0 lg:flex" :disabled="!table.getCanNextPage()"
|
||||
@click="table.setPageIndex(table.getPageCount() - 1)">
|
||||
<span class="sr-only">Halaman terakhir</span>
|
||||
<ChevronsRight class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
26
app/components/ui/empty/index.ts
Normal file
26
app/components/ui/empty/index.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
export { default as Empty } from "./Empty.vue"
|
||||
export { default as EmptyContent } from "./EmptyContent.vue"
|
||||
export { default as EmptyDescription } from "./EmptyDescription.vue"
|
||||
export { default as EmptyHeader } from "./EmptyHeader.vue"
|
||||
export { default as EmptyMedia } from "./EmptyMedia.vue"
|
||||
export { default as EmptyTitle } from "./EmptyTitle.vue"
|
||||
|
||||
export const emptyMediaVariants = cva(
|
||||
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type EmptyMediaVariants = VariantProps<typeof emptyMediaVariants>
|
||||
@ -36,7 +36,7 @@ export { default as SidebarTrigger } from "./SidebarTrigger.vue"
|
||||
export { useSidebar } from "./utils"
|
||||
|
||||
export const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-10! group-data-[collapsible=icon]:p-2.5! [&>span:last-child]:truncate [&>svg]:size-5 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@ -45,7 +45,7 @@ export const sidebarMenuButtonVariants = cva(
|
||||
"bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 text-sm",
|
||||
default: "h-10 text-base",
|
||||
sm: "h-7 text-xs",
|
||||
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
|
||||
},
|
||||
|
||||
@ -5,3 +5,9 @@ import { twMerge } from "tailwind-merge"
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function valueUpdater<T>(updaterOrValue: T | ((old: T) => T), ref: { value: T }) {
|
||||
ref.value = typeof updaterOrValue === 'function'
|
||||
? (updaterOrValue as (old: T) => T)(ref.value)
|
||||
: updaterOrValue
|
||||
}
|
||||
|
||||
118
app/pages/admin/business-types/columns.ts
Normal file
118
app/pages/admin/business-types/columns.ts
Normal file
@ -0,0 +1,118 @@
|
||||
import type { ColumnDef, FilterFn } from '@tanstack/vue-table'
|
||||
import { ArrowUpDown, Pencil, Trash2 } from '@lucide/vue'
|
||||
import { h } from 'vue'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
|
||||
export interface BusinessType {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
description: string | null
|
||||
is_active: boolean | number
|
||||
}
|
||||
|
||||
export function copy(id: string) {
|
||||
navigator.clipboard.writeText(id)
|
||||
}
|
||||
|
||||
const nameAndCodeFilter: FilterFn<BusinessType> = (row, _columnId, filterValue) => {
|
||||
const search = (filterValue as string).toLowerCase()
|
||||
const name = (row.original.name as string).toLowerCase()
|
||||
const code = (row.original.code as string).toLowerCase()
|
||||
return name.includes(search) || code.includes(search)
|
||||
}
|
||||
|
||||
const booleanFilter: FilterFn<BusinessType> = (row, columnId, filterValue) => {
|
||||
if (!filterValue || filterValue === '') return true
|
||||
const raw = row.original[columnId as keyof BusinessType]
|
||||
const cellValue = raw === true || raw === 1 || raw === '1'
|
||||
const filterBool = filterValue === 'true'
|
||||
return cellValue === filterBool
|
||||
}
|
||||
|
||||
export const columns: ColumnDef<BusinessType>[] = [
|
||||
{
|
||||
accessorKey: 'code',
|
||||
header: ({ column }) => {
|
||||
return h(Button, {
|
||||
variant: 'ghost',
|
||||
onClick: () => column.toggleSorting(column.getIsSorted() === 'asc'),
|
||||
}, () => ['Kode', h(ArrowUpDown, { class: 'ml-2 h-4 w-4' })])
|
||||
},
|
||||
cell: ({ row }) => h('div', { class: 'font-medium' }, row.getValue('code')),
|
||||
},
|
||||
{
|
||||
id: 'name_search',
|
||||
accessorFn: row => `${row.name} ${row.code}`,
|
||||
header: ({ column }) => {
|
||||
return h(Button, {
|
||||
variant: 'ghost',
|
||||
onClick: () => column.toggleSorting(column.getIsSorted() === 'asc'),
|
||||
}, () => ['Nama', h(ArrowUpDown, { class: 'ml-2 h-4 w-4' })])
|
||||
},
|
||||
cell: ({ row }) => h('div', {}, row.original.name),
|
||||
filterFn: nameAndCodeFilter,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: 'description',
|
||||
header: 'Deskripsi',
|
||||
cell: ({ row }) => {
|
||||
const desc = row.getValue('description') as string | null
|
||||
return h('div', { class: 'text-muted-foreground truncate max-w-[300px]' }, desc ?? '-')
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'is_active',
|
||||
header: 'Status',
|
||||
filterFn: booleanFilter,
|
||||
cell: ({ row }) => {
|
||||
const raw = row.getValue('is_active')
|
||||
const isActive = raw === true || raw === 1 || raw === '1'
|
||||
return h(Badge, {
|
||||
variant: 'outline',
|
||||
class: isActive
|
||||
? 'border-emerald-500 text-emerald-600 bg-emerald-50'
|
||||
: 'border-red-500 text-red-600 bg-red-50',
|
||||
}, () => isActive ? 'Aktif' : 'Nonaktif')
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: ' ',
|
||||
size: 0,
|
||||
minSize: 0,
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => {
|
||||
const businessType = row.original
|
||||
|
||||
return h(TooltipProvider, null, () =>
|
||||
h('div', { class: 'flex items-center gap-1' }, [
|
||||
h(Tooltip, null, () => [
|
||||
h(TooltipTrigger, { asChild: true }, () =>
|
||||
h(Button, { variant: 'ghost', size: 'icon', class: 'h-8 w-8 text-yellow-500' }, () =>
|
||||
h(Pencil, { class: 'h-4 w-4' })
|
||||
)
|
||||
),
|
||||
h(TooltipContent, null, () => 'Edit'),
|
||||
]),
|
||||
h(Tooltip, null, () => [
|
||||
h(TooltipTrigger, { asChild: true }, () =>
|
||||
h(Button, { variant: 'ghost', size: 'icon', class: 'h-8 w-8 text-destructive' }, () =>
|
||||
h(Trash2, { class: 'h-4 w-4' })
|
||||
)
|
||||
),
|
||||
h(TooltipContent, null, () => 'Hapus'),
|
||||
]),
|
||||
])
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
130
app/pages/admin/business-types/index.vue
Normal file
130
app/pages/admin/business-types/index.vue
Normal file
@ -0,0 +1,130 @@
|
||||
<script setup lang="ts">
|
||||
import AppSidebar from '@/components/AppSidebar.vue'
|
||||
import SiteHeader from '@/components/SiteHeader.vue'
|
||||
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'
|
||||
|
||||
import { ChevronDown, X } from '@lucide/vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import DataTable from '@/components/data-table/DataTable.vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { columns } from './columns'
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const { data: businessTypes, status } = useFetch<BusinessType[]>(`${config.public.apiBase}/business-types`)
|
||||
|
||||
const tableRef = ref<InstanceType<typeof DataTable>>()
|
||||
|
||||
function getStatusFilter(table: any) {
|
||||
const val = table.getColumn('is_active')?.getFilterValue()
|
||||
if (val === true || val === 'true' || val === 1) return 'true'
|
||||
if (val === false || val === 'false' || val === 0) return 'false'
|
||||
return 'all'
|
||||
}
|
||||
|
||||
function hasActiveFilter(table: any) {
|
||||
const search = table.getColumn('name_search')?.getFilterValue()
|
||||
const status = table.getColumn('is_active')?.getFilterValue()
|
||||
return (search && search !== '') || (status !== '' && status !== undefined && status !== null)
|
||||
}
|
||||
|
||||
function resetFilters(table: any) {
|
||||
table.getColumn('name_search')?.setFilterValue('')
|
||||
table.getColumn('is_active')?.setFilterValue('')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SidebarProvider :style="{
|
||||
'--sidebar-width': 'calc(var(--spacing) * 72)',
|
||||
'--header-height': 'calc(var(--spacing) * 12)',
|
||||
}">
|
||||
<AppSidebar variant="inset" />
|
||||
<SidebarInset>
|
||||
<SiteHeader />
|
||||
<div class="flex flex-1 flex-col">
|
||||
<div class="@container/main flex flex-1 flex-col gap-2">
|
||||
<div class="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||
<div class="px-4 lg:px-6">
|
||||
<h1 class="text-2xl font-bold">Jenis Bisnis</h1>
|
||||
</div>
|
||||
<div class="px-4 lg:px-6">
|
||||
<div v-if="status === 'pending'" class="flex items-center justify-center h-24">
|
||||
<p class="text-sm text-muted-foreground">Memuat data...</p>
|
||||
</div>
|
||||
<DataTable v-else ref="tableRef" :columns="columns" :data="businessTypes ?? []">
|
||||
<template #filters="{ table }">
|
||||
<div class="flex items-center gap-2 py-4">
|
||||
<Input class="max-w-sm" placeholder="Cari nama atau kode..."
|
||||
:model-value="(table.getColumn('name_search')?.getFilterValue() as string) ?? ''"
|
||||
@update:model-value="table.getColumn('name_search')?.setFilterValue($event)" />
|
||||
<Select :model-value="getStatusFilter(table)"
|
||||
@update:model-value="table.getColumn('is_active')?.setFilterValue($event === 'all' ? '' : $event)">
|
||||
<SelectTrigger class="hidden h-8 w-auto md:flex">
|
||||
<SelectValue placeholder="Semua Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Semua Status</SelectItem>
|
||||
<SelectItem value="true">Aktif</SelectItem>
|
||||
<SelectItem value="false">Nonaktif</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button v-if="hasActiveFilter(table)" variant="ghost" size="sm" class="h-8"
|
||||
@click="resetFilters(table)">
|
||||
<X class="h-4 w-4 mr-1" />
|
||||
Reset
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="outline" class="ml-auto md:hidden">
|
||||
Filter
|
||||
<ChevronDown class="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Status</DropdownMenuLabel>
|
||||
<div class="px-2 pb-2">
|
||||
<Select :model-value="getStatusFilter(table)"
|
||||
@update:model-value="table.getColumn('is_active')?.setFilterValue($event === 'all' ? '' : $event)">
|
||||
<SelectTrigger class="h-8 w-37.5">
|
||||
<SelectValue placeholder="Semua Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Semua Status</SelectItem>
|
||||
<SelectItem value="true">Aktif</SelectItem>
|
||||
<SelectItem value="false">Nonaktif</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem :disabled="!hasActiveFilter(table)" @click="resetFilters(table)">
|
||||
Reset Filter
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</template>
|
||||
111
docs/history/2026-07-24-data-table-jenis-bisnis.md
Normal file
111
docs/history/2026-07-24-data-table-jenis-bisnis.md
Normal file
@ -0,0 +1,111 @@
|
||||
# 2026-07-24 — DataTable Admin Jenis Bisnis
|
||||
|
||||
## Goal
|
||||
Build reusable DataTable with pagination, sorting, filtering, and actions for admin "Jenis Bisnis" (Business Types) page using Nuxt 4 + shadcn-vue.
|
||||
|
||||
## Installed Components
|
||||
- `@shadcn-vue/select`, `@shadcn-vue/tooltip`, `@shadcn-vue/badge`
|
||||
|
||||
## Current Files
|
||||
| File | Path | Role |
|
||||
|---|---|---|
|
||||
| **columns.ts** | `app/pages/admin/business-types/columns.ts` | BusinessType interface, column defs, filter functions, action buttons (Edit/Delete) — co-located with page |
|
||||
| **index.vue** | `app/pages/admin/business-types/index.vue` | Page: fetches data, passes columns to DataTable, filter UI via `#filters` slot |
|
||||
| **DataTable.vue** | `app/components/data-table/DataTable.vue` | Reusable generic DataTable — accepts `columns` + `data` props, auto-prepends `#` row-number column, `filters` slot, includes pagination |
|
||||
| **DataTablePagination.vue** | `app/components/data-table/DataTablePagination.vue` | Pagination sub-component (page size selector, prev/next/first/last buttons) |
|
||||
| **DataTable.vue** (old) | `app/components/DataTable.vue` | Legacy DataTable with hardcoded columns, drag-and-drop, tabs — NOT used by business-types |
|
||||
|
||||
## Refactoring Done
|
||||
- `columns.ts` moved from `app/components/business-types/columns.ts` → `app/pages/admin/business-types/columns.ts` (co-located with page)
|
||||
- `index.vue` imports columns via relative path `./columns`
|
||||
- Actions column simplified: removed Copy (ID) and Eye (Lihat) buttons → now only Edit (Pencil, yellow) and Delete (Trash2, destructive) with Tooltip — no click handlers wired yet
|
||||
|
||||
## Bugs Fixed
|
||||
|
||||
### 1. Numbering salah di page 2 (page 1: 1-10, page 2: 21-30)
|
||||
**Root cause:** Formula `pageIndex * pageSize + row.index + 1` double-counts. TanStack Table's `getPaginationRowModel` does NOT reset `row.index` — it stays as the global index from sorted/filtered model. So `row.index` is already `10-19` on page 2, and `1*10 + 10 = 20` gives 21-30.
|
||||
|
||||
**Fix:** Use `row.index + 1` directly in `DataTable.vue`:
|
||||
```ts
|
||||
cell: ({ row }) => {
|
||||
return h('div', { class: 'text-center' }, `${row.index + 1}`)
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Search filter tidak berfungsi
|
||||
**Root cause:** `nameAndCodeFilter` menggunakan `row.getValue('name')` dan `row.getValue('code')`, tapi column ID-nya `name_search` (virtual column dengan `accessorFn`), jadi `row.getValue('name')` return `undefined`.
|
||||
|
||||
**Fix:** Gunakan `row.original.name` dan `row.original.code` di `columns.ts`:
|
||||
```ts
|
||||
const nameAndCodeFilter: FilterFn<BusinessType> = (row, _columnId, filterValue) => {
|
||||
const search = (filterValue as string).toLowerCase()
|
||||
const name = (row.original.name as string).toLowerCase()
|
||||
const code = (row.original.code as string).toLowerCase()
|
||||
return name.includes(search) || code.includes(search)
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Data inactive (is_active: 0) tidak tampil / semua tampil "Aktif"
|
||||
**Root cause:** API return `is_active` sebagai number `0`/`1`, tapi cell dan filter pakai `!!row.getValue('is_active')`. Operator `!!` pada string `"0"` menghasilkan `true` (non-empty string).
|
||||
|
||||
**Fix:** Explicit check untuk number/string/boolean:
|
||||
```ts
|
||||
// Cell display
|
||||
const raw = row.getValue('is_active')
|
||||
const isActive = raw === true || raw === 1 || raw === '1'
|
||||
|
||||
// booleanFilter
|
||||
const raw = row.original[columnId as keyof BusinessType]
|
||||
const cellValue = raw === true || raw === 1 || raw === '1'
|
||||
```
|
||||
|
||||
### 4. Select status tidak reflect selection
|
||||
**Root cause:** `getStatusFilter()` hanya handle boolean dan number, tidak handle string `'true'`/`'false'` dari Select value.
|
||||
|
||||
**Fix:**
|
||||
```ts
|
||||
function getStatusFilter(table: any) {
|
||||
const val = table.getColumn('is_active')?.getFilterValue()
|
||||
if (val === true || val === 'true' || val === 1) return 'true'
|
||||
if (val === false || val === 'false' || val === 0) return 'false'
|
||||
return 'all'
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Model order salah
|
||||
**Root cause:** `getPaginationRowModel` di-register SEBELUM `getFilteredRowModel`, jadi pagination jalan duluan sebelum filter.
|
||||
|
||||
**Fix:** Reorder di `DataTable.vue`:
|
||||
```ts
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getExpandedRowModel: getExpandedRowModel(),
|
||||
```
|
||||
|
||||
## Pending
|
||||
- [ ] Wire `@click` handlers on Edit/Delete action buttons in `columns.ts`
|
||||
- [ ] Backend: ubah `is_active` default dari `True` → `None` supaya admin page bisa lihat semua data
|
||||
- [ ] Missing type import: `index.vue` uses `useFetch<BusinessType[]>` tapi `BusinessType` belum di-import (perlu `import { type BusinessType } from './columns'`)
|
||||
|
||||
## Backend Note
|
||||
API `GET /v1/business-types` default `is_active=True` — hanya return data aktif. Perlu ubah backend:
|
||||
```python
|
||||
def list_business_types(is_active: bool = None, db: Session = Depends(get_db)):
|
||||
return get_business_type_list(db, is_active=is_active)
|
||||
|
||||
def get_business_type_list(db: Session, is_active: bool = None):
|
||||
query = db.query(BusinessType).filter(BusinessType.deleted_at.is_(None))
|
||||
if is_active is not None:
|
||||
query = query.filter(BusinessType.is_active == is_active)
|
||||
return query.all()
|
||||
```
|
||||
Admin page perlu lihat semua data (active + inactive), frontend TanStack Table handle filtering.
|
||||
|
||||
## Tech Details
|
||||
- `useFetch` runs server-side (SSR), so API calls won't appear in browser Network tab
|
||||
- TanStack Table `row.index` is global index (not page-relative) after pagination
|
||||
- Filter values stored as strings (`'true'`/`'false'`/`''`), mapped to/from boolean via `getStatusFilter()`
|
||||
- `name_search` is virtual column with `accessorFn: row => ${row.name} ${row.code}` for combined search
|
||||
- `app/components/DataTable.vue` is legacy/unused — do not confuse with `app/components/data-table/DataTable.vue`
|
||||
Loading…
Reference in New Issue
Block a user