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:
Yoga Pangestu 2026-07-24 10:45:35 +07:00
parent 84ae9aff68
commit 2f79c83a17
8 changed files with 593 additions and 2 deletions

View 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>

View 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>

View 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>

View File

@ -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!",
},

View File

@ -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
}

View 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'),
]),
])
)
},
},
]

View 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>

View 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`