From 2f79c83a175bb007bafe227c684b597388f612fa Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Fri, 24 Jul 2026 10:45:35 +0700 Subject: [PATCH] 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. --- app/components/data-table/DataTable.vue | 136 ++++++++++++++++++ .../data-table/DataTablePagination.vue | 64 +++++++++ app/components/ui/empty/index.ts | 26 ++++ app/components/ui/sidebar/index.ts | 4 +- app/lib/utils.ts | 6 + app/pages/admin/business-types/columns.ts | 118 +++++++++++++++ app/pages/admin/business-types/index.vue | 130 +++++++++++++++++ .../2026-07-24-data-table-jenis-bisnis.md | 111 ++++++++++++++ 8 files changed, 593 insertions(+), 2 deletions(-) create mode 100644 app/components/data-table/DataTable.vue create mode 100644 app/components/data-table/DataTablePagination.vue create mode 100644 app/components/ui/empty/index.ts create mode 100644 app/pages/admin/business-types/columns.ts create mode 100644 app/pages/admin/business-types/index.vue create mode 100644 docs/history/2026-07-24-data-table-jenis-bisnis.md diff --git a/app/components/data-table/DataTable.vue b/app/components/data-table/DataTable.vue new file mode 100644 index 0000000..83dfb67 --- /dev/null +++ b/app/components/data-table/DataTable.vue @@ -0,0 +1,136 @@ + + + diff --git a/app/components/data-table/DataTablePagination.vue b/app/components/data-table/DataTablePagination.vue new file mode 100644 index 0000000..86ef88d --- /dev/null +++ b/app/components/data-table/DataTablePagination.vue @@ -0,0 +1,64 @@ + + + diff --git a/app/components/ui/empty/index.ts b/app/components/ui/empty/index.ts new file mode 100644 index 0000000..ce0c489 --- /dev/null +++ b/app/components/ui/empty/index.ts @@ -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 diff --git a/app/components/ui/sidebar/index.ts b/app/components/ui/sidebar/index.ts index c865573..c54731d 100644 --- a/app/components/ui/sidebar/index.ts +++ b/app/components/ui/sidebar/index.ts @@ -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!", }, diff --git a/app/lib/utils.ts b/app/lib/utils.ts index c66a9d9..1457e53 100644 --- a/app/lib/utils.ts +++ b/app/lib/utils.ts @@ -5,3 +5,9 @@ import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } + +export function valueUpdater(updaterOrValue: T | ((old: T) => T), ref: { value: T }) { + ref.value = typeof updaterOrValue === 'function' + ? (updaterOrValue as (old: T) => T)(ref.value) + : updaterOrValue +} diff --git a/app/pages/admin/business-types/columns.ts b/app/pages/admin/business-types/columns.ts new file mode 100644 index 0000000..6a5dfd9 --- /dev/null +++ b/app/pages/admin/business-types/columns.ts @@ -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 = (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 = (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[] = [ + { + 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'), + ]), + ]) + ) + }, + }, +] diff --git a/app/pages/admin/business-types/index.vue b/app/pages/admin/business-types/index.vue new file mode 100644 index 0000000..2f1fc21 --- /dev/null +++ b/app/pages/admin/business-types/index.vue @@ -0,0 +1,130 @@ + + + diff --git a/docs/history/2026-07-24-data-table-jenis-bisnis.md b/docs/history/2026-07-24-data-table-jenis-bisnis.md new file mode 100644 index 0000000..9d9a261 --- /dev/null +++ b/docs/history/2026-07-24-data-table-jenis-bisnis.md @@ -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 = (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` 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`