Commit 27b2b2c4 by QuentinHsu Committed by GitHub

perf(data-table): improve data table layout and badge display (#5460)

* perf(table): use percentage-based column widths

- compute each column's width as a percentage of total column size instead of a fixed pixel value, letting the colgroup scale fluidly with the table container.

* perf(data-table): reduce unnecessary re-renders across table components

- stabilize commitSearchValue in toolbar by reading table/searchKey via refs, eliminating recreation on every parent render
- store onColumnFiltersChange in a ref so debounce effect is not reset when the caller passes a new function reference each render
- wrap DataTableRow in React.memo with a custom comparator that ignores getColumnClassName reference churn
- memoize selectedValues Set in DataTableFacetedFilter and wrap with React.memo to prevent rerenders on unrelated state changes
- cache cell meta reads in CompactRow and FallbackRow to a single pass per row; memoize hasCompactMeta in MobileCardList
- memoize hideable columns list in DataTableViewOptions and colSpan in DataTableView
- remove tableClassName and colgroup from scroll-sync effect deps; cache toolbar button NodeList via useLayoutEffect to avoid per-keydown DOM queries

* perf(data-table): replace scroll-sync split header with CSS sticky

- remove JS scroll-sync effect and event listener between split header and body containers.
- merge separate header/body tables into a single scrollable table element, reducing DOM complexity.
- apply CSS sticky positioning to the header for a simpler, hardware-accelerated freeze effect.

* fix(data-table): replace opacity muted colors with color-mix

- switch from bg-muted/50 and bg-muted/30 to color-mix(in oklch) to produce opaque blended backgrounds that prevent scroll content from showing through pinned cells.
- expose --table-header-bg CSS variable so pinned header cells inherit the exact same computed color as the thead background.
- add group class to TableRow to enable group-hover selectors on pinned cell styles.

* feat(data-table): support column pinning via meta.pinned

- add pinned?: 'left' | 'right' to ColumnMeta so pinning is declared once in the column definition and applies to both header and body automatically
- DataTableView derives pinnedColumns from meta.pinned at runtime, merged with any explicit pinnedColumns prop; explicit entries take precedence
- add header and meta.pinned: 'right' to all actions columns across channels, users, api-keys, redemption-codes, models, deployments, and subscriptions tables

* style(row-actions): align action buttons to leading edge of column

* refactor(data-table): extract BadgeListCell and centralize badge alignment

- add BadgeListCell component to data-table for badge lists with overflow tooltip, replacing duplicated renderLimitedItems helpers in channels, models, and pricing columns
- move StatusBadge -ml-1.5 alignment into the component itself via a table-cell context selector, so callers no longer need manual offset wrappers
- remove the table-cell-level -ml-1.5 selector from TableCell now that alignment is handled by StatusBadge directly

* style(row-actions): offset action buttons to align with column header text

* refactor(data-table): consolidate mobile meta into ColumnMeta declaration

- move mobileTitle, mobileBadge, mobileHidden into the global ColumnMeta augmentation so the type is shared across the project
- remove the local MobileColumnMeta interface and getCellMeta helper from mobile-card-list.tsx
- direct col.columnDef.meta access is now type-safe without explicit casting

* refactor(data-table): simplify column header and meta config

- auto-render string `header` values via DataTableColumnHeader so sortable/non-sortable columns work without boilerplate function wrappers
- promote mobile layout hints (mobileTitle, mobileBadge, mobileHidden) into the global ColumnMeta type, removing the local MobileColumnMeta cast in mobile-card-list
- migrate all column files from `meta: { label }` to top-level `header: t('...')`, cutting ~180 lines of repetitive template code
- ViewOptions and MobileCardList label resolution now reads string header first, then meta.label as fallback

* feat(status-badge): add text and underline display types

- introduce StatusBadgeType ('badge' | 'text' | 'underline') and StatusBadgeTypeContext so ancestors can override rendering without touching call sites
- mobile card field rows now use the text type via context, showing badges as plain colored text instead of pills
- ProviderBadge gains data-slot='provider-badge' to enable targeted CSS resets in compact layouts
- replace the implicit [[data-slot=table-cell]>&]:-ml-1.5 rule with explicit -ml-1.5 at each column call site

* refactor(data-table): simplify table filtering internals

- derive toolbar search state from the active table filter to avoid render-time ref writes.
- extract faceted filter selection updates into a pure helper for clearer single and multi-select behavior.
- split pinned column resolution into focused helpers so explicit and meta pins merge predictably.

Co-authored-by: t0ng7u <dev@aiass.cc>
parent 6f415428
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import * as React from 'react'
import { StatusBadgeList } from '@/components/status-badge'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
interface BadgeListCellProps {
items: React.ReactNode[]
max?: number
tooltipClassName?: string
}
/**
* Table cell renderer for a list of badges with overflow tooltip.
* Displays up to `max` badges inline; remaining items appear in a tooltip.
* Applies -ml-1.5 to compensate for badge px-1.5 and align with column header.
*/
export function BadgeListCell({
items,
max = 2,
tooltipClassName,
}: BadgeListCellProps) {
if (items.length === 0) {
return <span className='text-muted-foreground text-xs'>-</span>
}
const showTooltip = items.length > max
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={<div className='-ml-1.5' />}>
<StatusBadgeList
items={items}
max={max}
renderItem={(item) => item}
/>
</TooltipTrigger>
{showTooltip && (
<TooltipContent
side='top'
className={
tooltipClassName ??
'border-border bg-popover max-h-48 max-w-[320px] overflow-y-auto p-2'
}
>
<div className='flex flex-wrap gap-1'>{items}</div>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)
}
...@@ -63,8 +63,8 @@ function getPinnedColumnClassName( ...@@ -63,8 +63,8 @@ function getPinnedColumnClassName(
pinnedColumn.side === 'left' ? 'left-0' : 'right-0', pinnedColumn.side === 'left' ? 'left-0' : 'right-0',
edgeClassName, edgeClassName,
kind === 'header' kind === 'header'
? 'bg-background z-30' ? '[background-color:var(--table-header-bg,var(--background))] group-hover:[background-color:color-mix(in_oklch,var(--muted)_50%,var(--background))] z-30'
: 'bg-background z-10 group-hover:bg-muted group-data-[state=selected]:bg-muted', : 'bg-background z-10 group-hover:[background-color:color-mix(in_oklch,var(--muted)_50%,var(--background))] group-data-[state=selected]:bg-muted',
pinnedColumn.className, pinnedColumn.className,
kind === 'header' kind === 'header'
? pinnedColumn.headerClassName ? pinnedColumn.headerClassName
......
...@@ -23,10 +23,21 @@ export function DataTableColgroup<TData>({ ...@@ -23,10 +23,21 @@ export function DataTableColgroup<TData>({
}: { }: {
table: TanstackTable<TData> table: TanstackTable<TData>
}) { }) {
const columns = table.getVisibleLeafColumns()
const totalSize = columns.reduce((sum, col) => sum + col.getSize(), 0)
return ( return (
<colgroup> <colgroup>
{table.getVisibleLeafColumns().map((column) => ( {columns.map((column) => (
<col key={column.id} style={{ width: column.getSize() }} /> <col
key={column.id}
style={{
width:
totalSize > 0
? `${(column.getSize() / totalSize) * 100}%`
: undefined,
}}
/>
))} ))}
</colgroup> </colgroup>
) )
......
...@@ -16,8 +16,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,8 +16,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { flexRender, type Table as TanstackTable } from '@tanstack/react-table' import { flexRender, type Header, type Table as TanstackTable } from '@tanstack/react-table'
import { TableHead, TableHeader, TableRow } from '@/components/ui/table' import { TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { DataTableColumnHeader } from './column-header'
import type { DataTableColumnClassName } from './types' import type { DataTableColumnClassName } from './types'
type DataTableHeaderProps<TData> = { type DataTableHeaderProps<TData> = {
...@@ -46,12 +47,7 @@ export function DataTableHeader<TData>({ ...@@ -46,12 +47,7 @@ export function DataTableHeader<TData>({
className={getColumnClassName?.(header.column.id, 'header')} className={getColumnClassName?.(header.column.id, 'header')}
style={applyHeaderSize ? { width: header.getSize() } : undefined} style={applyHeaderSize ? { width: header.getSize() } : undefined}
> >
{header.isPlaceholder {renderHeaderContent(header)}
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead> </TableHead>
))} ))}
</TableRow> </TableRow>
...@@ -59,3 +55,19 @@ export function DataTableHeader<TData>({ ...@@ -59,3 +55,19 @@ export function DataTableHeader<TData>({
</TableHeader> </TableHeader>
) )
} }
function renderHeaderContent<TData>(header: Header<TData, unknown>) {
if (header.isPlaceholder) return null
const { header: headerDef, meta } = header.column.columnDef
// A string header means the user wrote e.g. `header: t('Name')` — auto-render
// with DataTableColumnHeader so sorting works without boilerplate.
// A function (including TanStack's default accessor-key fallback) is passed
// through as-is. meta.label is kept as a fallback for legacy columns.
if (typeof headerDef === 'string') {
return <DataTableColumnHeader column={header.column} title={headerDef} />
}
if (meta?.label) {
return <DataTableColumnHeader column={header.column} title={meta.label} />
}
return flexRender(headerDef, header.getContext())
}
...@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import type * as React from 'react' import * as React from 'react'
import { flexRender, type Row } from '@tanstack/react-table' import { flexRender, type Row } from '@tanstack/react-table'
import { TableCell, TableRow } from '@/components/ui/table' import { TableCell, TableRow } from '@/components/ui/table'
import type { DataTableColumnClassName } from './types' import type { DataTableColumnClassName } from './types'
...@@ -27,7 +27,7 @@ type DataTableRowProps<TData> = { ...@@ -27,7 +27,7 @@ type DataTableRowProps<TData> = {
getColumnClassName?: DataTableColumnClassName getColumnClassName?: DataTableColumnClassName
} & Omit<React.ComponentProps<typeof TableRow>, 'children'> } & Omit<React.ComponentProps<typeof TableRow>, 'children'>
export function DataTableRow<TData>({ function DataTableRowInner<TData>({
row, row,
className, className,
getColumnClassName, getColumnClassName,
...@@ -50,3 +50,14 @@ export function DataTableRow<TData>({ ...@@ -50,3 +50,14 @@ export function DataTableRow<TData>({
</TableRow> </TableRow>
) )
} }
export const DataTableRow = React.memo(DataTableRowInner, (prev, next) => {
// Skip re-render when only the getColumnClassName reference changed but the
// row identity and selection state are the same — callers rarely stabilize
// this callback, so excluding it from comparison avoids unnecessary renders.
return (
prev.row === next.row &&
prev.className === next.className &&
prev.row.getIsSelected() === next.row.getIsSelected()
)
}) as typeof DataTableRowInner
...@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import * as React from 'react' import * as React from 'react'
import { type Row } from '@tanstack/react-table' import { type Row, type Table as TanstackTable } from '@tanstack/react-table'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Table, TableBody, TableCell, TableRow } from '@/components/ui/table' import { Table, TableBody, TableCell, TableRow } from '@/components/ui/table'
import { import {
...@@ -46,8 +46,12 @@ export { DataTableRow } from './data-table-row' ...@@ -46,8 +46,12 @@ export { DataTableRow } from './data-table-row'
export function DataTableView<TData>(props: DataTableViewProps<TData>) { export function DataTableView<TData>(props: DataTableViewProps<TData>) {
const rows = props.rows ?? props.table.getRowModel().rows const rows = props.rows ?? props.table.getRowModel().rows
const colSpan = props.table.getVisibleLeafColumns().length const colSpan = React.useMemo(
() => props.table.getVisibleLeafColumns().length,
[props.table]
)
const columnClassName = useResolvedColumnClassName( const columnClassName = useResolvedColumnClassName(
props.table,
props.getColumnClassName, props.getColumnClassName,
props.pinnedColumns props.pinnedColumns
) )
...@@ -120,32 +124,8 @@ function SplitHeaderTableView<TData>({ ...@@ -120,32 +124,8 @@ function SplitHeaderTableView<TData>({
colSpan: number colSpan: number
getColumnClassName: DataTableColumnClassName getColumnClassName: DataTableColumnClassName
}) { }) {
const headerHostRef = React.useRef<HTMLDivElement>(null)
const bodyHostRef = React.useRef<HTMLDivElement>(null)
const tableSizing = getTableSizing(props) const tableSizing = getTableSizing(props)
React.useEffect(() => {
const headerScroller = headerHostRef.current?.querySelector<HTMLElement>(
'[data-slot=table-container]'
)
const bodyScroller = bodyHostRef.current?.querySelector<HTMLElement>(
'[data-slot=table-container]'
)
if (!headerScroller || !bodyScroller) return
const syncHeaderScroll = () => {
headerScroller.scrollLeft = bodyScroller.scrollLeft
}
syncHeaderScroll()
bodyScroller.addEventListener('scroll', syncHeaderScroll, { passive: true })
return () => {
bodyScroller.removeEventListener('scroll', syncHeaderScroll)
}
}, [rows.length, props.tableClassName, props.colgroup])
return ( return (
<div <div
className={cn( className={cn(
...@@ -155,49 +135,49 @@ function SplitHeaderTableView<TData>({ ...@@ -155,49 +135,49 @@ function SplitHeaderTableView<TData>({
> >
<div <div
className={cn( className={cn(
'flex min-h-0 flex-1 flex-col overflow-hidden', 'min-h-0 flex-1 overflow-auto',
props.splitHeaderScrollClassName '[&_[data-slot=table-header]]:[--table-header-bg:color-mix(in_oklch,var(--muted)_30%,var(--background))]',
'[&_[data-slot=table-header]]:[background-color:var(--table-header-bg)]',
props.splitHeaderScrollClassName,
props.bodyContainerClassName
)} )}
> >
<div <table
ref={headerHostRef} data-slot='table'
className='[scrollbar-gutter:stable] overflow-hidden [&_[data-slot=table-container]]:overflow-x-hidden'
>
<Table className={props.tableClassName} style={tableSizing.style}>
{tableSizing.colgroup}
<DataTableHeader
table={props.table}
applyHeaderSize={props.applyHeaderSize}
className={props.tableHeaderClassName}
rowClassName={props.tableHeaderRowClassName}
getColumnClassName={getColumnClassName}
/>
</Table>
</div>
<div
ref={bodyHostRef}
className={cn( className={cn(
'min-h-0 flex-1 [scrollbar-gutter:stable] overflow-y-auto', 'w-full caption-bottom text-sm tabular-nums [&_td]:text-sm [&_td_*]:text-sm [&_th]:text-sm [&_th_*]:text-sm',
props.bodyContainerClassName props.tableClassName
)} )}
style={tableSizing.style}
> >
<Table className={props.tableClassName} style={tableSizing.style}> {tableSizing.colgroup}
{tableSizing.colgroup} <DataTableHeader
{renderTableBody(props, rows, colSpan, getColumnClassName)} table={props.table}
</Table> applyHeaderSize={props.applyHeaderSize}
</div> className={cn('sticky top-0 z-10', props.tableHeaderClassName)}
rowClassName={props.tableHeaderRowClassName}
getColumnClassName={getColumnClassName}
/>
{renderTableBody(props, rows, colSpan, getColumnClassName)}
</table>
</div> </div>
</div> </div>
) )
} }
function useResolvedColumnClassName( function useResolvedColumnClassName<TData>(
table: TanstackTable<TData>,
getColumnClassName?: DataTableColumnClassName, getColumnClassName?: DataTableColumnClassName,
pinnedColumns?: DataTablePinnedColumn[] pinnedColumns?: DataTablePinnedColumn[]
) { ) {
const allPinnedColumns = React.useMemo(() => {
const metaPinnedColumns = getMetaPinnedColumns(table)
return mergePinnedColumns(pinnedColumns, metaPinnedColumns)
}, [table, pinnedColumns])
const pinnedColumnById = React.useMemo( const pinnedColumnById = React.useMemo(
() => getPinnedColumnMap(pinnedColumns), () => getPinnedColumnMap(allPinnedColumns),
[pinnedColumns] [allPinnedColumns]
) )
return React.useMemo( return React.useMemo(
...@@ -207,6 +187,41 @@ function useResolvedColumnClassName( ...@@ -207,6 +187,41 @@ function useResolvedColumnClassName(
) )
} }
function getMetaPinnedColumns<TData>(
table: TanstackTable<TData>
): DataTablePinnedColumn[] {
return table.getAllColumns().flatMap((column) => {
const side = column.columnDef.meta?.pinned
if (!side) return []
return [{ columnId: column.id, side }]
})
}
function mergePinnedColumns(
explicitPinnedColumns: DataTablePinnedColumn[] | undefined,
metaPinnedColumns: DataTablePinnedColumn[]
): DataTablePinnedColumn[] | undefined {
if (!metaPinnedColumns.length) {
return explicitPinnedColumns
}
if (!explicitPinnedColumns?.length) {
return metaPinnedColumns
}
const explicitColumnIds = new Set(
explicitPinnedColumns.map((column) => column.columnId)
)
return [
...explicitPinnedColumns,
...metaPinnedColumns.filter(
(column) => !explicitColumnIds.has(column.columnId)
),
]
}
function getTableSizing<TData>(props: DataTableViewProps<TData>): { function getTableSizing<TData>(props: DataTableViewProps<TData>): {
colgroup?: React.ReactNode colgroup?: React.ReactNode
style?: React.CSSProperties style?: React.CSSProperties
......
...@@ -41,6 +41,8 @@ export function useDebouncedColumnFilter({ ...@@ -41,6 +41,8 @@ export function useDebouncedColumnFilter({
const [pendingValue, setPendingValue] = React.useState(value) const [pendingValue, setPendingValue] = React.useState(value)
const isComposingRef = React.useRef(false) const isComposingRef = React.useRef(false)
const debouncedValue = useDebounce(pendingValue, delay) const debouncedValue = useDebounce(pendingValue, delay)
const onColumnFiltersChangeRef = React.useRef(onColumnFiltersChange)
onColumnFiltersChangeRef.current = onColumnFiltersChange
React.useEffect(() => { React.useEffect(() => {
// Keep the input aligned when URL state changes outside the local field. // Keep the input aligned when URL state changes outside the local field.
...@@ -55,13 +57,13 @@ export function useDebouncedColumnFilter({ ...@@ -55,13 +57,13 @@ export function useDebouncedColumnFilter({
React.useEffect(() => { React.useEffect(() => {
if (debouncedValue === value) return if (debouncedValue === value) return
onColumnFiltersChange((previous) => { onColumnFiltersChangeRef.current((previous) => {
const filters = previous.filter((filter) => filter.id !== columnId) const filters = previous.filter((filter) => filter.id !== columnId)
return debouncedValue return debouncedValue
? [...filters, { id: columnId, value: debouncedValue }] ? [...filters, { id: columnId, value: debouncedValue }]
: filters : filters
}) })
}, [columnId, debouncedValue, onColumnFiltersChange, value]) }, [columnId, debouncedValue, value])
const updateInputValue = React.useCallback((nextValue: string) => { const updateInputValue = React.useCallback((nextValue: string) => {
setInputValue(nextValue) setInputValue(nextValue)
......
...@@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
export { DataTablePagination } from './core/pagination' export { DataTablePagination } from './core/pagination'
export { DataTableColumnHeader } from './core/column-header' export { DataTableColumnHeader } from './core/column-header'
export { BadgeListCell } from './core/badge-list-cell'
export { DataTableViewOptions } from './toolbar/view-options' export { DataTableViewOptions } from './toolbar/view-options'
export { DataTableToolbar } from './toolbar/toolbar' export { DataTableToolbar } from './toolbar/toolbar'
export { DataTableBulkActions } from './toolbar/bulk-actions' export { DataTableBulkActions } from './toolbar/bulk-actions'
......
...@@ -344,7 +344,7 @@ function renderDesktop<TData>( ...@@ -344,7 +344,7 @@ function renderDesktop<TData>(
splitHeader={fixedHeight} splitHeader={fixedHeight}
tableContainerClassName={fixedHeight ? 'h-full min-h-0' : undefined} tableContainerClassName={fixedHeight ? 'h-full min-h-0' : undefined}
tableHeaderClassName={cn( tableHeaderClassName={cn(
fixedHeight && 'bg-muted/30', fixedHeight && '[background-color:color-mix(in_oklch,var(--muted)_30%,var(--background))]',
props.tableHeaderClassName props.tableHeaderClassName
)} )}
getColumnClassName={props.getColumnClassName} getColumnClassName={props.getColumnClassName}
......
...@@ -16,6 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,6 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import * as React from 'react'
import { import {
flexRender, flexRender,
type Cell, type Cell,
...@@ -24,6 +25,7 @@ import { ...@@ -24,6 +25,7 @@ import {
} from '@tanstack/react-table' } from '@tanstack/react-table'
import { Database } from 'lucide-react' import { Database } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { StatusBadgeTypeContext } from '@/components/status-badge'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { import {
Empty, Empty,
...@@ -43,24 +45,11 @@ interface MobileCardListProps<TData> { ...@@ -43,24 +45,11 @@ interface MobileCardListProps<TData> {
getRowClassName?: (row: Row<TData>) => string | undefined getRowClassName?: (row: Row<TData>) => string | undefined
} }
interface MobileColumnMeta {
label?: string
mobileTitle?: boolean
mobileBadge?: boolean
mobileHidden?: boolean
}
function getCellMeta<TData>(
cell: Cell<TData, unknown>
): MobileColumnMeta | undefined {
return cell.column.columnDef.meta as MobileColumnMeta | undefined
}
function getCellLabel<TData>(cell: Cell<TData, unknown>): string | null { function getCellLabel<TData>(cell: Cell<TData, unknown>): string | null {
const meta = getCellMeta(cell) const { header, meta } = cell.column.columnDef
if (typeof header === 'string') return header
if (meta?.label) return meta.label if (meta?.label) return meta.label
const header = cell.column.columnDef.header return null
return typeof header === 'string' ? header : null
} }
function renderCellContent<TData>(cell: Cell<TData, unknown>): React.ReactNode { function renderCellContent<TData>(cell: Cell<TData, unknown>): React.ReactNode {
...@@ -128,16 +117,22 @@ function CompactRow<TData>({ row }: { row: Row<TData> }) { ...@@ -128,16 +117,22 @@ function CompactRow<TData>({ row }: { row: Row<TData> }) {
.getVisibleCells() .getVisibleCells()
.filter((cell) => cell.column.id !== 'select') .filter((cell) => cell.column.id !== 'select')
const titleCell = allCells.find((c) => getCellMeta(c)?.mobileTitle) // Read each cell's meta once, then reuse for all categorisation checks.
const badgeCell = allCells.find((c) => getCellMeta(c)?.mobileBadge) const cellMetas = React.useMemo(
const actionsCell = allCells.find((c) => c.column.id === 'actions') () => allCells.map((c) => c.column.columnDef.meta),
// eslint-disable-next-line react-hooks/exhaustive-deps
[allCells.map((c) => c.id).join(',')]
)
const titleCell = allCells.find((_, i) => cellMetas[i]?.mobileTitle)
const badgeCell = allCells.find((_, i) => cellMetas[i]?.mobileBadge)
const actionsCell = allCells.find((c) => c.column.id === 'actions')
const fieldCells = allCells.filter( const fieldCells = allCells.filter(
(c) => (c, i) =>
c !== titleCell && c !== titleCell &&
c !== badgeCell && c !== badgeCell &&
c !== actionsCell && c !== actionsCell &&
!getCellMeta(c)?.mobileHidden !cellMetas[i]?.mobileHidden
) )
return ( return (
...@@ -145,12 +140,14 @@ function CompactRow<TData>({ row }: { row: Row<TData> }) { ...@@ -145,12 +140,14 @@ function CompactRow<TData>({ row }: { row: Row<TData> }) {
{/* Row 1: Title + Badge */} {/* Row 1: Title + Badge */}
<div className='flex items-center justify-between gap-2'> <div className='flex items-center justify-between gap-2'>
{titleCell && ( {titleCell && (
<div className='min-w-0 flex-1 overflow-hidden text-sm font-medium'> <div className='min-w-0 flex-1 text-sm font-medium [&_[data-slot=status-badge]]:max-w-full [&_[data-slot=status-badge]]:whitespace-normal'>
{renderCellContent(titleCell)} {renderCellContent(titleCell)}
</div> </div>
)} )}
{badgeCell && ( {badgeCell && (
<div className='shrink-0'>{renderCellContent(badgeCell)}</div> <div className='flex-none [&_[data-slot=status-badge]]:max-w-none'>
{renderCellContent(badgeCell)}
</div>
)} )}
</div> </div>
...@@ -166,8 +163,10 @@ function CompactRow<TData>({ row }: { row: Row<TData> }) { ...@@ -166,8 +163,10 @@ function CompactRow<TData>({ row }: { row: Row<TData> }) {
{label} {label}
</div> </div>
)} )}
<div className='min-w-0 overflow-hidden text-xs'> <div className='min-w-0 overflow-hidden text-xs [&_[data-slot=provider-badge]]:ml-0 [&_[data-slot=status-badge]]:ml-0'>
{renderCellContent(cell) ?? '-'} <StatusBadgeTypeContext.Provider value='text'>
{renderCellContent(cell) ?? '-'}
</StatusBadgeTypeContext.Provider>
</div> </div>
</div> </div>
) )
...@@ -194,21 +193,28 @@ function FallbackRow<TData>({ row }: { row: Row<TData> }) { ...@@ -194,21 +193,28 @@ function FallbackRow<TData>({ row }: { row: Row<TData> }) {
.getVisibleCells() .getVisibleCells()
.filter((cell) => cell.column.id !== 'select') .filter((cell) => cell.column.id !== 'select')
const cellMetas = React.useMemo(
() => allCells.map((c) => c.column.columnDef.meta),
// eslint-disable-next-line react-hooks/exhaustive-deps
[allCells.map((c) => c.id).join(',')]
)
const actionsCell = allCells.find((c) => c.column.id === 'actions') const actionsCell = allCells.find((c) => c.column.id === 'actions')
const contentCells = allCells.filter( const contentCells = allCells.filter(
(c) => c.column.id !== 'actions' && !getCellMeta(c)?.mobileHidden (c, i) => c.column.id !== 'actions' && !cellMetas[i]?.mobileHidden
) )
return ( return (
<> <>
{contentCells.map((cell) => { {contentCells.map((cell) => {
const label = getCellLabel(cell) const label = getCellLabel(cell)
const content = renderCellContent(cell)
if (!label) { if (!label) {
return ( return (
<div key={cell.id} className='flex justify-end overflow-hidden'> <div key={cell.id} className='flex justify-end overflow-hidden [&_[data-slot=provider-badge]]:ml-0 [&_[data-slot=status-badge]]:ml-0'>
{content} <StatusBadgeTypeContext.Provider value='text'>
{renderCellContent(cell)}
</StatusBadgeTypeContext.Provider>
</div> </div>
) )
} }
...@@ -221,8 +227,10 @@ function FallbackRow<TData>({ row }: { row: Row<TData> }) { ...@@ -221,8 +227,10 @@ function FallbackRow<TData>({ row }: { row: Row<TData> }) {
<span className='text-muted-foreground shrink-0 text-[10px] font-medium select-none'> <span className='text-muted-foreground shrink-0 text-[10px] font-medium select-none'>
{label} {label}
</span> </span>
<div className='flex min-w-0 flex-1 items-center justify-end overflow-hidden text-xs'> <div className='flex min-w-0 flex-1 items-center justify-end overflow-hidden text-xs [&_[data-slot=provider-badge]]:ml-0 [&_[data-slot=status-badge]]:ml-0'>
{content ?? '-'} <StatusBadgeTypeContext.Provider value='text'>
{renderCellContent(cell) ?? '-'}
</StatusBadgeTypeContext.Provider>
</div> </div>
</div> </div>
) )
...@@ -265,10 +273,15 @@ export function MobileCardList<TData>(props: MobileCardListProps<TData>) { ...@@ -265,10 +273,15 @@ export function MobileCardList<TData>(props: MobileCardListProps<TData>) {
const resolvedEmptyTitle = emptyTitle ?? t('No Data') const resolvedEmptyTitle = emptyTitle ?? t('No Data')
const resolvedEmptyDescription = emptyDescription ?? t('No data available') const resolvedEmptyDescription = emptyDescription ?? t('No data available')
const hasCompactMeta = table.getVisibleLeafColumns().some((col) => { const visibleColumns = table.getVisibleLeafColumns()
const meta = col.columnDef.meta as MobileColumnMeta | undefined const hasCompactMeta = React.useMemo(
return meta?.mobileTitle || meta?.mobileBadge () =>
}) visibleColumns.some((col) => {
const meta = col.columnDef.meta
return meta?.mobileTitle || meta?.mobileBadge
}),
[visibleColumns]
)
if (isLoading) { if (isLoading) {
return hasCompactMeta ? <ListSkeleton /> : <FallbackListSkeleton /> return hasCompactMeta ? <ListSkeleton /> : <FallbackListSkeleton />
......
...@@ -22,7 +22,7 @@ export const staticDataTableClassNames = { ...@@ -22,7 +22,7 @@ export const staticDataTableClassNames = {
embeddedContainer: 'rounded-none border-0', embeddedContainer: 'rounded-none border-0',
compactTable: 'text-sm', compactTable: 'text-sm',
compactHeaderRow: 'hover:bg-transparent', compactHeaderRow: 'hover:bg-transparent',
mutedHeaderRow: 'bg-muted/30 hover:bg-muted/30', mutedHeaderRow: '[background-color:color-mix(in_oklch,var(--muted)_30%,var(--background))] hover:[background-color:color-mix(in_oklch,var(--muted)_30%,var(--background))]',
compactHeaderCell: compactHeaderCell:
'text-muted-foreground py-2 text-[10px] font-medium tracking-wider uppercase', 'text-muted-foreground py-2 text-[10px] font-medium tracking-wider uppercase',
compactHeaderCellRight: compactHeaderCellRight:
......
...@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useLayoutEffect, useRef } from 'react'
import { type Table } from '@tanstack/react-table' import { type Table } from '@tanstack/react-table'
import { X } from 'lucide-react' import { X } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
...@@ -55,8 +55,13 @@ export function DataTableBulkActions<TData>({ ...@@ -55,8 +55,13 @@ export function DataTableBulkActions<TData>({
const selectedRows = table.getFilteredSelectedRowModel().rows const selectedRows = table.getFilteredSelectedRowModel().rows
const selectedCount = selectedRows.length const selectedCount = selectedRows.length
const toolbarRef = useRef<HTMLDivElement>(null) const toolbarRef = useRef<HTMLDivElement>(null)
const buttonsRef = useRef<NodeListOf<HTMLButtonElement> | null>(null)
const [announcement, setAnnouncement] = useState('') const [announcement, setAnnouncement] = useState('')
useLayoutEffect(() => {
buttonsRef.current = toolbarRef.current?.querySelectorAll('button') ?? null
})
// Announce selection changes to screen readers // Announce selection changes to screen readers
useEffect(() => { useEffect(() => {
if (selectedCount > 0) { if (selectedCount > 0) {
...@@ -75,7 +80,7 @@ export function DataTableBulkActions<TData>({ ...@@ -75,7 +80,7 @@ export function DataTableBulkActions<TData>({
} }
const handleKeyDown = (event: React.KeyboardEvent) => { const handleKeyDown = (event: React.KeyboardEvent) => {
const buttons = toolbarRef.current?.querySelectorAll('button') const buttons = buttonsRef.current
if (!buttons) return if (!buttons) return
const currentIndex = Array.from(buttons).findIndex( const currentIndex = Array.from(buttons).findIndex(
......
...@@ -53,7 +53,7 @@ type DataTableFacetedFilterProps<TData, TValue> = { ...@@ -53,7 +53,7 @@ type DataTableFacetedFilterProps<TData, TValue> = {
singleSelect?: boolean singleSelect?: boolean
} }
export function DataTableFacetedFilter<TData, TValue>({ function DataTableFacetedFilterInner<TData, TValue>({
column, column,
title, title,
options, options,
...@@ -64,6 +64,18 @@ export function DataTableFacetedFilter<TData, TValue>({ ...@@ -64,6 +64,18 @@ export function DataTableFacetedFilter<TData, TValue>({
const filterValue = column?.getFilterValue() as string[] | undefined const filterValue = column?.getFilterValue() as string[] | undefined
const selectedValues = new Set(filterValue) const selectedValues = new Set(filterValue)
const handleOptionSelect = (optionValue: string) => {
const nextSelectedValues = getNextSelectedValues(
selectedValues,
optionValue,
singleSelect
)
column?.setFilterValue(
nextSelectedValues.length ? nextSelectedValues : undefined
)
}
return ( return (
<Popover> <Popover>
<PopoverTrigger <PopoverTrigger
...@@ -118,29 +130,7 @@ export function DataTableFacetedFilter<TData, TValue>({ ...@@ -118,29 +130,7 @@ export function DataTableFacetedFilter<TData, TValue>({
return ( return (
<CommandItem <CommandItem
key={option.value} key={option.value}
onSelect={() => { onSelect={() => handleOptionSelect(option.value)}
if (singleSelect) {
// Single select mode: toggle or switch selection
if (isSelected) {
// Deselect if clicking the same option
column?.setFilterValue(undefined)
} else {
// Select only this option
column?.setFilterValue([option.value])
}
} else {
// Multi-select mode: original behavior
if (isSelected) {
selectedValues.delete(option.value)
} else {
selectedValues.add(option.value)
}
const filterValues = Array.from(selectedValues)
column?.setFilterValue(
filterValues.length ? filterValues : undefined
)
}
}}
> >
<div <div
className={cn( className={cn(
...@@ -197,3 +187,26 @@ export function DataTableFacetedFilter<TData, TValue>({ ...@@ -197,3 +187,26 @@ export function DataTableFacetedFilter<TData, TValue>({
</Popover> </Popover>
) )
} }
export const DataTableFacetedFilter = React.memo(
DataTableFacetedFilterInner
) as typeof DataTableFacetedFilterInner
function getNextSelectedValues(
selectedValues: Set<string>,
optionValue: string,
singleSelect: boolean
): string[] {
if (singleSelect) {
return selectedValues.has(optionValue) ? [] : [optionValue]
}
const nextSelectedValues = new Set(selectedValues)
if (nextSelectedValues.has(optionValue)) {
nextSelectedValues.delete(optionValue)
} else {
nextSelectedValues.add(optionValue)
}
return Array.from(nextSelectedValues)
}
...@@ -19,9 +19,9 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -19,9 +19,9 @@ For commercial licensing, please contact support@quantumnous.com
import * as React from 'react' import * as React from 'react'
import { useState, type ReactNode } from 'react' import { useState, type ReactNode } from 'react'
import { type Table } from '@tanstack/react-table' import { type Table } from '@tanstack/react-table'
import { useDebounce } from '@/hooks'
import { ChevronDown, Loader2, X as Cross2Icon } from 'lucide-react' import { ChevronDown, Loader2, X as Cross2Icon } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useDebounce } from '@/hooks'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
...@@ -41,6 +41,11 @@ type FilterDef = { ...@@ -41,6 +41,11 @@ type FilterDef = {
singleSelect?: boolean singleSelect?: boolean
} }
type SearchDraft = {
baseValue: string
value: string
}
export type DataTableToolbarProps<TData> = { export type DataTableToolbarProps<TData> = {
table: Table<TData> table: Table<TData>
/** /**
...@@ -141,8 +146,7 @@ export type DataTableToolbarProps<TData> = { ...@@ -141,8 +146,7 @@ export type DataTableToolbarProps<TData> = {
export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) { export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
const { t } = useTranslation() const { t } = useTranslation()
const [expanded, setExpanded] = useState(false) const [expanded, setExpanded] = useState(false)
const isSearchComposingRef = React.useRef(false) const [isSearchComposing, setIsSearchComposing] = useState(false)
const lastCommittedSearchValueRef = React.useRef('')
const filters = props.filters ?? [] const filters = props.filters ?? []
const hasExpandable = props.expandable != null const hasExpandable = props.expandable != null
...@@ -159,31 +163,22 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) { ...@@ -159,31 +163,22 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
'') '')
: ((props.table.getState().globalFilter as string | undefined) ?? '') : ((props.table.getState().globalFilter as string | undefined) ?? '')
const [searchValue, setSearchValue] = useState(currentSearchValue) const [searchDraft, setSearchDraft] = useState<SearchDraft | null>(null)
const [pendingSearchValue, setPendingSearchValue] = const activeSearchDraft =
useState(currentSearchValue) searchDraft &&
(isSearchComposing || searchDraft.baseValue === currentSearchValue)
? searchDraft
: null
const searchValue = activeSearchDraft?.value ?? currentSearchValue
const searchDebounceMs = Math.max(0, props.searchDebounceMs ?? 0) const searchDebounceMs = Math.max(0, props.searchDebounceMs ?? 0)
const debouncedSearchValue = useDebounce( const debouncedSearchValue = useDebounce(searchValue, searchDebounceMs)
pendingSearchValue,
searchDebounceMs
)
React.useEffect(() => {
lastCommittedSearchValueRef.current = currentSearchValue
if (!isSearchComposingRef.current) {
setSearchValue(currentSearchValue)
}
setPendingSearchValue(currentSearchValue)
}, [currentSearchValue])
const commitSearchValue = React.useCallback( const commitSearchValue = React.useCallback(
(value: string) => { (value: string) => {
if (value === lastCommittedSearchValueRef.current) { if (value === currentSearchValue) {
return return
} }
lastCommittedSearchValueRef.current = value
if (props.searchKey) { if (props.searchKey) {
props.table.getColumn(props.searchKey)?.setFilterValue(value) props.table.getColumn(props.searchKey)?.setFilterValue(value)
return return
...@@ -191,14 +186,14 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) { ...@@ -191,14 +186,14 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
props.table.setGlobalFilter(value) props.table.setGlobalFilter(value)
}, },
[props.searchKey, props.table] [currentSearchValue, props.searchKey, props.table]
) )
React.useEffect(() => { React.useEffect(() => {
if ( if (
searchDebounceMs <= 0 || searchDebounceMs <= 0 ||
isSearchComposingRef.current || isSearchComposing ||
debouncedSearchValue !== pendingSearchValue debouncedSearchValue !== searchValue
) { ) {
return return
} }
...@@ -207,13 +202,12 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) { ...@@ -207,13 +202,12 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
}, [ }, [
commitSearchValue, commitSearchValue,
debouncedSearchValue, debouncedSearchValue,
pendingSearchValue, isSearchComposing,
searchDebounceMs, searchDebounceMs,
searchValue,
]) ])
const queueSearchValue = (value: string) => { const queueSearchValue = (value: string) => {
setPendingSearchValue(value)
if (searchDebounceMs <= 0) { if (searchDebounceMs <= 0) {
commitSearchValue(value) commitSearchValue(value)
} }
...@@ -221,36 +215,27 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) { ...@@ -221,36 +215,27 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => { const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value const value = event.target.value
setSearchValue(value) setSearchDraft({ baseValue: currentSearchValue, value })
if (!isSearchComposingRef.current) { if (!isSearchComposing) {
queueSearchValue(value) queueSearchValue(value)
} }
} }
const handleSearchCompositionStart = () => { const handleSearchCompositionStart = () => {
isSearchComposingRef.current = true setIsSearchComposing(true)
} }
const handleSearchCompositionEnd = ( const handleSearchCompositionEnd = (
event: React.CompositionEvent<HTMLInputElement> event: React.CompositionEvent<HTMLInputElement>
) => { ) => {
isSearchComposingRef.current = false setIsSearchComposing(false)
const value = event.currentTarget.value const value = event.currentTarget.value
setSearchValue(value) setSearchDraft({ baseValue: currentSearchValue, value })
queueSearchValue(value) queueSearchValue(value)
} }
const searchInput = props.searchKey ? ( const searchInput = (
<Input
placeholder={placeholder}
value={searchValue}
onChange={handleSearchChange}
onCompositionStart={handleSearchCompositionStart}
onCompositionEnd={handleSearchCompositionEnd}
className='w-full sm:w-[200px] lg:w-[240px]'
/>
) : (
<Input <Input
placeholder={placeholder} placeholder={placeholder}
value={searchValue} value={searchValue}
...@@ -261,25 +246,28 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) { ...@@ -261,25 +246,28 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
/> />
) )
const filterChips = filters.map((filter) => { const filterChips = React.useMemo(
const column = props.table.getColumn(filter.columnId) () =>
if (!column) return null filters.map((filter) => {
return ( const column = props.table.getColumn(filter.columnId)
<DataTableFacetedFilter if (!column) return null
key={filter.columnId} return (
column={column} <DataTableFacetedFilter
title={filter.title} key={filter.columnId}
options={filter.options} column={column}
singleSelect={filter.singleSelect} title={filter.title}
/> options={filter.options}
) singleSelect={filter.singleSelect}
}) />
)
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[props.filters, props.table]
)
const handleReset = () => { const handleReset = () => {
isSearchComposingRef.current = false setIsSearchComposing(false)
setSearchValue('') setSearchDraft(null)
setPendingSearchValue('')
lastCommittedSearchValueRef.current = ''
props.table.resetColumnFilters() props.table.resetColumnFilters()
props.table.setGlobalFilter('') props.table.setGlobalFilter('')
props.onReset?.() props.onReset?.()
......
...@@ -16,6 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,6 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import * as React from 'react'
import { type Table } from '@tanstack/react-table' import { type Table } from '@tanstack/react-table'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
...@@ -36,6 +37,18 @@ export function DataTableViewOptions<TData>({ ...@@ -36,6 +37,18 @@ export function DataTableViewOptions<TData>({
table, table,
}: DataTableViewOptionsProps<TData>) { }: DataTableViewOptionsProps<TData>) {
const { t } = useTranslation() const { t } = useTranslation()
const hideableColumns = React.useMemo(
() =>
table
.getAllColumns()
.filter(
(column) =>
typeof column.accessorFn !== 'undefined' && column.getCanHide()
),
[table]
)
return ( return (
<DropdownMenu modal={false}> <DropdownMenu modal={false}>
<DropdownMenuTrigger <DropdownMenuTrigger
...@@ -52,24 +65,20 @@ export function DataTableViewOptions<TData>({ ...@@ -52,24 +65,20 @@ export function DataTableViewOptions<TData>({
<DropdownMenuContent align='end' className='w-[150px]'> <DropdownMenuContent align='end' className='w-[150px]'>
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownMenuLabel>{t('Toggle columns')}</DropdownMenuLabel> <DropdownMenuLabel>{t('Toggle columns')}</DropdownMenuLabel>
{table {hideableColumns.map((column) => {
.getAllColumns() return (
.filter( <DropdownMenuCheckboxItem
(column) => key={column.id}
typeof column.accessorFn !== 'undefined' && column.getCanHide() className='capitalize'
checked={column.getIsVisible()}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
>
{typeof column.columnDef.header === 'string'
? column.columnDef.header
: (column.columnDef.meta?.label ?? column.id)}
</DropdownMenuCheckboxItem>
) )
.map((column) => { })}
return (
<DropdownMenuCheckboxItem
key={column.id}
className='capitalize'
checked={column.getIsVisible()}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
>
{column.columnDef.meta?.label ?? column.id}
</DropdownMenuCheckboxItem>
)
})}
</DropdownMenuGroup> </DropdownMenuGroup>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
......
...@@ -36,9 +36,15 @@ export function ProviderBadge({ ...@@ -36,9 +36,15 @@ export function ProviderBadge({
const icon = iconKey ? getLobeIcon(iconKey, iconSize) : null const icon = iconKey ? getLobeIcon(iconKey, iconSize) : null
return ( return (
<div className={cn('flex items-center gap-1.5', className)}> <div data-slot='provider-badge' className={cn('flex items-center gap-1.5', className)}>
{icon} {icon}
<StatusBadge label={label} autoColor={label} size='sm' {...badgeProps} /> <StatusBadge
label={label}
autoColor={label}
size='sm'
className={!icon ? 'pl-0' : undefined}
{...badgeProps}
/>
</div> </div>
) )
} }
...@@ -22,7 +22,6 @@ import { type LucideIcon } from 'lucide-react' ...@@ -22,7 +22,6 @@ import { type LucideIcon } from 'lucide-react'
import { stringToColor } from '@/lib/colors' import { stringToColor } from '@/lib/colors'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
export const dotColorMap = { export const dotColorMap = {
success: 'bg-success', success: 'bg-success',
warning: 'bg-warning', warning: 'bg-warning',
...@@ -73,12 +72,29 @@ export const textColorMap = { ...@@ -73,12 +72,29 @@ export const textColorMap = {
export type StatusVariant = keyof typeof dotColorMap export type StatusVariant = keyof typeof dotColorMap
/** Controls the visual style of the badge.
* - `badge` — default pill with background and padding (default)
* - `text` — plain text, no background or padding, only color
* - `underline`— plain text with a bottom border underline
*/
export type StatusBadgeType = 'badge' | 'text' | 'underline'
/** Context that lets ancestor components (e.g. MobileCardList field area)
* override the badge type without modifying every call site. */
export const StatusBadgeTypeContext = React.createContext<StatusBadgeType>('badge')
const sizeMap = { const sizeMap = {
sm: 'h-5 gap-1 px-1.5 text-xs leading-none', sm: 'h-5 gap-1 px-1.5 text-xs leading-none',
md: 'h-5 gap-1 px-1.5 text-xs leading-none', md: 'h-5 gap-1 px-1.5 text-xs leading-none',
lg: 'h-6 gap-1.5 px-2 text-xs leading-none', lg: 'h-6 gap-1.5 px-2 text-xs leading-none',
} as const } as const
const textSizeMap = {
sm: 'gap-1 text-xs leading-none',
md: 'gap-1 text-xs leading-none',
lg: 'gap-1.5 text-xs leading-none',
} as const
export interface StatusBadgeProps extends Omit< export interface StatusBadgeProps extends Omit<
React.HTMLAttributes<HTMLSpanElement>, React.HTMLAttributes<HTMLSpanElement>,
'children' 'children'
...@@ -94,6 +110,8 @@ export interface StatusBadgeProps extends Omit< ...@@ -94,6 +110,8 @@ export interface StatusBadgeProps extends Omit<
copyable?: boolean copyable?: boolean
copyText?: string copyText?: string
autoColor?: string autoColor?: string
/** Visual style. Defaults to 'badge'. Can be overridden via StatusBadgeTypeContext. */
type?: StatusBadgeType
} }
export function StatusBadge({ export function StatusBadge({
...@@ -107,11 +125,14 @@ export function StatusBadge({ ...@@ -107,11 +125,14 @@ export function StatusBadge({
copyable = true, copyable = true,
copyText, copyText,
autoColor, autoColor,
type: typeProp,
className, className,
onClick, onClick,
...props ...props
}: StatusBadgeProps) { }: StatusBadgeProps) {
const { copyToClipboard } = useCopyToClipboard() const { copyToClipboard } = useCopyToClipboard()
const contextType = React.useContext(StatusBadgeTypeContext)
const type = typeProp ?? contextType
const computedVariant: StatusVariant = autoColor const computedVariant: StatusVariant = autoColor
? (stringToColor(autoColor) as StatusVariant) ? (stringToColor(autoColor) as StatusVariant)
...@@ -126,14 +147,21 @@ export function StatusBadge({ ...@@ -126,14 +147,21 @@ export function StatusBadge({
} }
const content = const content =
children ?? (label ? <span className='truncate'>{label}</span> : null) children ??
(label ? (
<span className='min-w-0 truncate leading-normal'>{label}</span>
) : null)
const isBadge = type === 'badge'
return ( return (
<span <span
data-slot='status-badge' data-slot='status-badge'
className={cn( className={cn(
'inline-flex w-fit max-w-full shrink-0 items-center rounded-4xl font-medium tracking-normal whitespace-nowrap transition-colors', 'inline-flex w-fit max-w-full shrink-0 items-center font-medium tracking-normal whitespace-nowrap transition-colors',
sizeMap[size ?? 'sm'], isBadge
? cn('rounded-4xl', sizeMap[size ?? 'sm'])
: cn(textSizeMap[size ?? 'sm'], type === 'underline' && 'border-b border-current pb-px'),
textColorMap[computedVariant], textColorMap[computedVariant],
pulse && 'animate-pulse', pulse && 'animate-pulse',
copyable && copyable &&
......
...@@ -77,7 +77,7 @@ function TableRow({ className, ...props }: React.ComponentProps<'tr'>) { ...@@ -77,7 +77,7 @@ function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
<tr <tr
data-slot='table-row' data-slot='table-row'
className={cn( className={cn(
'hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors', 'group hover:[background-color:color-mix(in_oklch,var(--muted)_50%,var(--background))] has-aria-expanded:[background-color:color-mix(in_oklch,var(--muted)_50%,var(--background))] data-[state=selected]:bg-muted border-b transition-colors',
className className
)} )}
{...props} {...props}
...@@ -103,7 +103,7 @@ function TableCell({ className, ...props }: React.ComponentProps<'td'>) { ...@@ -103,7 +103,7 @@ function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
<td <td
data-slot='table-cell' data-slot='table-cell'
className={cn( className={cn(
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>*:has(>[data-slot=status-badge]:first-child):first-child]:-ml-1.5 [&>[data-slot=status-badge]:first-child]:-ml-1.5', 'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0',
className className
)} )}
{...props} {...props}
......
...@@ -140,7 +140,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) { ...@@ -140,7 +140,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
} }
return ( return (
<div className='flex items-center justify-end gap-1'> <div className='-ml-1.5 flex items-center gap-1'>
<Tooltip> <Tooltip>
<TooltipTrigger <TooltipTrigger
render={ render={
......
...@@ -29,7 +29,6 @@ import { ...@@ -29,7 +29,6 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip' } from '@/components/ui/tooltip'
import { DataTableColumnHeader } from '@/components/data-table'
import { GroupBadge } from '@/components/group-badge' import { GroupBadge } from '@/components/group-badge'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { API_KEY_STATUSES } from '../constants' import { API_KEY_STATUSES } from '../constants'
...@@ -93,26 +92,21 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] { ...@@ -93,26 +92,21 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
enableSorting: false, enableSorting: false,
enableHiding: false, enableHiding: false,
size: 40, size: 40,
meta: { label: t('Select') },
}, },
{ {
accessorKey: 'name', accessorKey: 'name',
header: ({ column }) => ( header: t('Name'),
<DataTableColumnHeader column={column} title={t('Name')} />
),
cell: ({ row }) => ( cell: ({ row }) => (
<div className='max-w-[200px] truncate font-medium'> <div className='max-w-[200px] truncate font-medium'>
{row.getValue('name')} {row.getValue('name')}
</div> </div>
), ),
size: 180, size: 180,
meta: { label: t('Name'), mobileTitle: true }, meta: { mobileTitle: true },
}, },
{ {
accessorKey: 'status', accessorKey: 'status',
header: ({ column }) => ( header: t('Status'),
<DataTableColumnHeader column={column} title={t('Status')} />
),
cell: ({ row }) => { cell: ({ row }) => {
const statusConfig = API_KEY_STATUSES[row.getValue('status') as number] const statusConfig = API_KEY_STATUSES[row.getValue('status') as number]
if (!statusConfig) return null if (!statusConfig) return null
...@@ -121,12 +115,13 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] { ...@@ -121,12 +115,13 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
label={t(statusConfig.label)} label={t(statusConfig.label)}
variant={statusConfig.variant} variant={statusConfig.variant}
copyable={false} copyable={false}
className='-ml-1.5'
/> />
) )
}, },
filterFn: (row, id, value) => value.includes(String(row.getValue(id))), filterFn: (row, id, value) => value.includes(String(row.getValue(id))),
size: 120, size: 120,
meta: { label: t('Status'), mobileBadge: true }, meta: { mobileBadge: true },
}, },
{ {
id: 'key', id: 'key',
...@@ -135,14 +130,11 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] { ...@@ -135,14 +130,11 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
cell: ({ row }) => <ApiKeyCell apiKey={row.original} />, cell: ({ row }) => <ApiKeyCell apiKey={row.original} />,
enableSorting: false, enableSorting: false,
size: 260, size: 260,
meta: { label: t('API Key') },
}, },
{ {
id: 'quota', id: 'quota',
accessorKey: 'remain_quota', accessorKey: 'remain_quota',
header: ({ column }) => ( header: t('Quota'),
<DataTableColumnHeader column={column} title={t('Quota')} />
),
cell: ({ row }) => { cell: ({ row }) => {
const apiKey = row.original const apiKey = row.original
if (apiKey.unlimited_quota) { if (apiKey.unlimited_quota) {
...@@ -151,6 +143,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] { ...@@ -151,6 +143,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
label={t('Unlimited')} label={t('Unlimited')}
variant='neutral' variant='neutral'
copyable={false} copyable={false}
className='-ml-1.5'
/> />
) )
} }
...@@ -194,13 +187,10 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] { ...@@ -194,13 +187,10 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
) )
}, },
size: 170, size: 170,
meta: { label: t('Quota') },
}, },
{ {
accessorKey: 'group', accessorKey: 'group',
header: ({ column }) => ( header: t('Group'),
<DataTableColumnHeader column={column} title={t('Group')} />
),
cell: ({ row }) => { cell: ({ row }) => {
const apiKey = row.original const apiKey = row.original
const group = row.getValue('group') as string const group = row.getValue('group') as string
...@@ -236,48 +226,40 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] { ...@@ -236,48 +226,40 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
return <GroupBadge group={group} ratio={ratio} /> return <GroupBadge group={group} ratio={ratio} />
}, },
size: 160, size: 160,
meta: { label: t('Group'), mobileHidden: true }, meta: { mobileHidden: true },
}, },
{ {
id: 'model_limits', id: 'model_limits',
accessorKey: 'model_limits', accessorKey: 'model_limits',
header: ({ column }) => ( header: t('Models'),
<DataTableColumnHeader column={column} title={t('Models')} />
),
cell: ({ row }) => <ModelLimitsCell apiKey={row.original} />, cell: ({ row }) => <ModelLimitsCell apiKey={row.original} />,
enableSorting: false, enableSorting: false,
size: 160, size: 160,
meta: { label: t('Models'), mobileHidden: true }, meta: { mobileHidden: true },
}, },
{ {
id: 'allow_ips', id: 'allow_ips',
accessorKey: 'allow_ips', accessorKey: 'allow_ips',
header: ({ column }) => ( header: t('IP Restriction'),
<DataTableColumnHeader column={column} title={t('IP Restriction')} />
),
cell: ({ row }) => <IpRestrictionsCell apiKey={row.original} />, cell: ({ row }) => <IpRestrictionsCell apiKey={row.original} />,
enableSorting: false, enableSorting: false,
size: 160, size: 160,
meta: { label: t('IP Restriction'), mobileHidden: true }, meta: { mobileHidden: true },
}, },
{ {
accessorKey: 'created_time', accessorKey: 'created_time',
header: ({ column }) => ( header: t('Created'),
<DataTableColumnHeader column={column} title={t('Created')} />
),
cell: ({ row }) => ( cell: ({ row }) => (
<span className='text-muted-foreground block truncate font-mono text-xs tabular-nums'> <span className='text-muted-foreground block truncate font-mono text-xs tabular-nums'>
{formatTimestampToDate(row.getValue('created_time'))} {formatTimestampToDate(row.getValue('created_time'))}
</span> </span>
), ),
size: 180, size: 180,
meta: { label: t('Created'), mobileHidden: true }, meta: { mobileHidden: true },
}, },
{ {
accessorKey: 'accessed_time', accessorKey: 'accessed_time',
header: ({ column }) => ( header: t('Last Used'),
<DataTableColumnHeader column={column} title={t('Last Used')} />
),
cell: ({ row }) => { cell: ({ row }) => {
const accessedTime = row.getValue('accessed_time') as number const accessedTime = row.getValue('accessed_time') as number
if (!accessedTime) { if (!accessedTime) {
...@@ -290,13 +272,11 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] { ...@@ -290,13 +272,11 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
) )
}, },
size: 180, size: 180,
meta: { label: t('Last Used'), mobileHidden: true }, meta: { mobileHidden: true },
}, },
{ {
accessorKey: 'expired_time', accessorKey: 'expired_time',
header: ({ column }) => ( header: t('Expires'),
<DataTableColumnHeader column={column} title={t('Expires')} />
),
cell: ({ row }) => { cell: ({ row }) => {
const expiredTime = row.getValue('expired_time') as number const expiredTime = row.getValue('expired_time') as number
if (expiredTime === -1) { if (expiredTime === -1) {
...@@ -305,6 +285,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] { ...@@ -305,6 +285,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
label={t('Never')} label={t('Never')}
variant='neutral' variant='neutral'
copyable={false} copyable={false}
className='-ml-1.5'
/> />
) )
} }
...@@ -321,12 +302,13 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] { ...@@ -321,12 +302,13 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
) )
}, },
size: 180, size: 180,
meta: { label: t('Expires'), mobileHidden: true }, meta: { mobileHidden: true },
}, },
{ {
id: 'actions', id: 'actions',
header: () => t('Actions'),
cell: ({ row }) => <DataTableRowActions row={row} />, cell: ({ row }) => <DataTableRowActions row={row} />,
meta: { label: t('Actions') }, meta: { pinned: 'right' as const },
size: 88, size: 88,
}, },
] ]
......
...@@ -190,7 +190,7 @@ export function DataTableRowActions<TData>({ ...@@ -190,7 +190,7 @@ export function DataTableRowActions<TData>({
} }
return ( return (
<div className='flex items-center justify-end gap-1'> <div className='-ml-1.5 flex items-center gap-1'>
<Tooltip> <Tooltip>
<TooltipTrigger <TooltipTrigger
render={ render={
......
...@@ -62,7 +62,8 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) { ...@@ -62,7 +62,8 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
} }
return ( return (
<DropdownMenu> <div className='-ml-2'>
<DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
render={ render={
<Button <Button
...@@ -134,5 +135,6 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) { ...@@ -134,5 +135,6 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
}} }}
/> />
</DropdownMenu> </DropdownMenu>
</div>
) )
} }
...@@ -21,7 +21,6 @@ import { Eye, Info, Pencil, Settings2, Timer, Trash2 } from 'lucide-react' ...@@ -21,7 +21,6 @@ import { Eye, Info, Pencil, Settings2, Timer, Trash2 } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { formatTimestampToDate } from '@/lib/format' import { formatTimestampToDate } from '@/lib/format'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { DataTableColumnHeader } from '@/components/data-table'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { TableId } from '@/components/table-id' import { TableId } from '@/components/table-id'
import { getDeploymentStatusConfig } from '../constants' import { getDeploymentStatusConfig } from '../constants'
...@@ -45,10 +44,8 @@ export function useDeploymentsColumns(opts: { ...@@ -45,10 +44,8 @@ export function useDeploymentsColumns(opts: {
return [ return [
{ {
accessorKey: 'id', accessorKey: 'id',
meta: { label: t('ID'), mobileHidden: true }, header: t('ID'),
header: ({ column }) => ( meta: { mobileHidden: true },
<DataTableColumnHeader column={column} title={t('ID')} />
),
cell: ({ row }) => { cell: ({ row }) => {
const id = row.original.id const id = row.original.id
return <TableId value={id} /> return <TableId value={id} />
...@@ -59,10 +56,8 @@ export function useDeploymentsColumns(opts: { ...@@ -59,10 +56,8 @@ export function useDeploymentsColumns(opts: {
id: 'name', id: 'name',
accessorFn: (row) => accessorFn: (row) =>
row.container_name || row.deployment_name || row.name || '', row.container_name || row.deployment_name || row.name || '',
meta: { label: t('Name'), mobileTitle: true }, header: t('Name'),
header: ({ column }) => ( meta: { mobileTitle: true },
<DataTableColumnHeader column={column} title={t('Name')} />
),
cell: ({ getValue }) => { cell: ({ getValue }) => {
const name = String(getValue() || '-') || '-' const name = String(getValue() || '-') || '-'
return ( return (
...@@ -71,7 +66,7 @@ export function useDeploymentsColumns(opts: { ...@@ -71,7 +66,7 @@ export function useDeploymentsColumns(opts: {
variant='neutral' variant='neutral'
copyText={name} copyText={name}
size='sm' size='sm'
className='font-mono' className='-ml-1.5 font-mono'
/> />
) )
}, },
...@@ -79,8 +74,8 @@ export function useDeploymentsColumns(opts: { ...@@ -79,8 +74,8 @@ export function useDeploymentsColumns(opts: {
}, },
{ {
accessorKey: 'status', accessorKey: 'status',
meta: { label: t('Status'), mobileBadge: true },
header: t('Status'), header: t('Status'),
meta: { mobileBadge: true },
cell: ({ row }) => { cell: ({ row }) => {
const raw = row.original.status const raw = row.original.status
const key = normalizeDeploymentStatus(raw) const key = normalizeDeploymentStatus(raw)
...@@ -95,6 +90,7 @@ export function useDeploymentsColumns(opts: { ...@@ -95,6 +90,7 @@ export function useDeploymentsColumns(opts: {
variant={config.variant} variant={config.variant}
size='sm' size='sm'
copyable={false} copyable={false}
className='-ml-1.5'
/> />
) )
}, },
...@@ -114,7 +110,6 @@ export function useDeploymentsColumns(opts: { ...@@ -114,7 +110,6 @@ export function useDeploymentsColumns(opts: {
}, },
{ {
accessorKey: 'provider', accessorKey: 'provider',
meta: { label: t('Provider') },
header: t('Provider'), header: t('Provider'),
cell: ({ row }) => { cell: ({ row }) => {
const provider = row.original.provider const provider = row.original.provider
...@@ -126,6 +121,7 @@ export function useDeploymentsColumns(opts: { ...@@ -126,6 +121,7 @@ export function useDeploymentsColumns(opts: {
autoColor={String(provider)} autoColor={String(provider)}
size='sm' size='sm'
copyable={false} copyable={false}
className='-ml-1.5'
/> />
) )
}, },
...@@ -134,7 +130,6 @@ export function useDeploymentsColumns(opts: { ...@@ -134,7 +130,6 @@ export function useDeploymentsColumns(opts: {
}, },
{ {
accessorKey: 'time_remaining', accessorKey: 'time_remaining',
meta: { label: t('Time remaining') },
header: t('Time remaining'), header: t('Time remaining'),
cell: ({ row }) => { cell: ({ row }) => {
const status = normalizeDeploymentStatus(row.original.status) const status = normalizeDeploymentStatus(row.original.status)
...@@ -185,8 +180,8 @@ export function useDeploymentsColumns(opts: { ...@@ -185,8 +180,8 @@ export function useDeploymentsColumns(opts: {
}, },
{ {
id: 'hardware', id: 'hardware',
meta: { label: t('Hardware'), mobileHidden: true },
header: t('Hardware'), header: t('Hardware'),
meta: { mobileHidden: true },
accessorFn: (row) => accessorFn: (row) =>
row.hardware_info || row.hardware_name || row.brand_name || '', row.hardware_info || row.hardware_name || row.brand_name || '',
cell: ({ row }) => { cell: ({ row }) => {
...@@ -220,10 +215,8 @@ export function useDeploymentsColumns(opts: { ...@@ -220,10 +215,8 @@ export function useDeploymentsColumns(opts: {
}, },
{ {
accessorKey: 'created_at', accessorKey: 'created_at',
meta: { label: t('Created'), mobileHidden: true }, header: t('Created'),
header: ({ column }) => ( meta: { mobileHidden: true },
<DataTableColumnHeader column={column} title={t('Created')} />
),
cell: ({ row }) => { cell: ({ row }) => {
const ts = const ts =
typeof row.original.created_at === 'number' typeof row.original.created_at === 'number'
...@@ -241,6 +234,7 @@ export function useDeploymentsColumns(opts: { ...@@ -241,6 +234,7 @@ export function useDeploymentsColumns(opts: {
}, },
{ {
id: 'actions', id: 'actions',
header: () => t('Actions'),
enableHiding: false, enableHiding: false,
enableSorting: false, enableSorting: false,
cell: ({ row }) => { cell: ({ row }) => {
...@@ -252,7 +246,7 @@ export function useDeploymentsColumns(opts: { ...@@ -252,7 +246,7 @@ export function useDeploymentsColumns(opts: {
'' ''
return ( return (
<div className='flex items-center gap-1'> <div className='-ml-2.5 flex items-center gap-1'>
<Button <Button
variant='ghost' variant='ghost'
size='sm' size='sm'
...@@ -305,6 +299,7 @@ export function useDeploymentsColumns(opts: { ...@@ -305,6 +299,7 @@ export function useDeploymentsColumns(opts: {
) )
}, },
size: 180, size: 180,
meta: { pinned: 'right' as const },
}, },
] ]
} }
...@@ -19,15 +19,9 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -19,15 +19,9 @@ For commercial licensing, please contact support@quantumnous.com
import { type ColumnDef } from '@tanstack/react-table' import { type ColumnDef } from '@tanstack/react-table'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { getLobeIcon } from '@/lib/lobe-icon' import { getLobeIcon } from '@/lib/lobe-icon'
import { import { DataTableColumnHeader, BadgeListCell } from '@/components/data-table'
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { DataTableColumnHeader } from '@/components/data-table'
import { GroupBadge } from '@/components/group-badge' import { GroupBadge } from '@/components/group-badge'
import { StatusBadge, StatusBadgeList } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { DEFAULT_TOKEN_UNIT, QUOTA_TYPE_VALUES } from '../constants' import { DEFAULT_TOKEN_UNIT, QUOTA_TYPE_VALUES } from '../constants'
import { import {
getDynamicDisplayGroupRatio, getDynamicDisplayGroupRatio,
...@@ -53,36 +47,6 @@ export interface PricingColumnsOptions { ...@@ -53,36 +47,6 @@ export interface PricingColumnsOptions {
showRechargePrice?: boolean showRechargePrice?: boolean
} }
function renderLimitedTags(
items: string[],
maxDisplay: number = 3
): React.ReactNode {
return (
<StatusBadgeList
items={items}
max={maxDisplay}
getKey={(item) => item}
renderItem={(item) => (
<StatusBadge label={item} autoColor={item} size='sm' copyable={false} />
)}
/>
)
}
function renderLimitedGroupBadges(
groups: string[],
maxDisplay: number = 2
): React.ReactNode {
return (
<StatusBadgeList
items={groups}
max={maxDisplay}
getKey={(group) => group}
renderItem={(group) => <GroupBadge group={group} size='sm' />}
/>
)
}
export function usePricingColumns( export function usePricingColumns(
options: PricingColumnsOptions = {} options: PricingColumnsOptions = {}
): ColumnDef<PricingModel>[] { ): ColumnDef<PricingModel>[] {
...@@ -124,7 +88,6 @@ export function usePricingColumns( ...@@ -124,7 +88,6 @@ export function usePricingColumns(
// Type column // Type column
{ {
accessorKey: 'quota_type', accessorKey: 'quota_type',
meta: { label: t('Type') },
header: t('Type'), header: t('Type'),
cell: ({ row }) => { cell: ({ row }) => {
const isTokenBased = row.original.quota_type === QUOTA_TYPE_VALUES.TOKEN const isTokenBased = row.original.quota_type === QUOTA_TYPE_VALUES.TOKEN
...@@ -133,6 +96,7 @@ export function usePricingColumns( ...@@ -133,6 +96,7 @@ export function usePricingColumns(
label={isTokenBased ? t('Token') : t('Request')} label={isTokenBased ? t('Token') : t('Request')}
variant={isTokenBased ? 'info' : 'neutral'} variant={isTokenBased ? 'info' : 'neutral'}
copyable={false} copyable={false}
className='-ml-1.5'
/> />
) )
}, },
...@@ -269,7 +233,6 @@ export function usePricingColumns( ...@@ -269,7 +233,6 @@ export function usePricingColumns(
// Cached price column (Vercel AI Gateway style) // Cached price column (Vercel AI Gateway style)
{ {
id: 'cached_price', id: 'cached_price',
meta: { label: t('Cached') },
header: t('Cached'), header: t('Cached'),
cell: ({ row }) => { cell: ({ row }) => {
const model = row.original const model = row.original
...@@ -344,7 +307,6 @@ export function usePricingColumns( ...@@ -344,7 +307,6 @@ export function usePricingColumns(
// Vendor column // Vendor column
{ {
accessorKey: 'vendor_name', accessorKey: 'vendor_name',
meta: { label: t('Vendor') },
header: t('Vendor'), header: t('Vendor'),
cell: ({ row }) => { cell: ({ row }) => {
const model = row.original const model = row.original
...@@ -373,27 +335,21 @@ export function usePricingColumns( ...@@ -373,27 +335,21 @@ export function usePricingColumns(
// Tags column // Tags column
{ {
accessorKey: 'tags', accessorKey: 'tags',
meta: { label: t('Tags') },
header: t('Tags'), header: t('Tags'),
cell: ({ row }) => { cell: ({ row }) => {
const tags = parseTags(row.original.tags) const tags = parseTags(row.original.tags)
if (tags.length === 0) {
return <span className='text-muted-foreground/50 text-xs'></span>
}
return ( return (
<TooltipProvider> <BadgeListCell
<Tooltip> items={tags.map((tag) => (
<TooltipTrigger render={<div />}> <StatusBadge
{renderLimitedTags(tags, 2)} key={tag}
</TooltipTrigger> label={tag}
{tags.length > 2 && ( autoColor={tag}
<TooltipContent side='top' className='max-w-[280px] p-2'> size='sm'
<span className='text-xs'>{tags.join(', ')}</span> copyable={false}
</TooltipContent> />
)} ))}
</Tooltip> />
</TooltipProvider>
) )
}, },
size: 140, size: 140,
...@@ -403,27 +359,21 @@ export function usePricingColumns( ...@@ -403,27 +359,21 @@ export function usePricingColumns(
// Endpoints column // Endpoints column
{ {
accessorKey: 'supported_endpoint_types', accessorKey: 'supported_endpoint_types',
meta: { label: t('Endpoints') },
header: t('Endpoints'), header: t('Endpoints'),
cell: ({ row }) => { cell: ({ row }) => {
const endpoints = row.original.supported_endpoint_types || [] const endpoints = row.original.supported_endpoint_types || []
if (endpoints.length === 0) {
return <span className='text-muted-foreground/50 text-xs'></span>
}
return ( return (
<TooltipProvider> <BadgeListCell
<Tooltip> items={endpoints.map((ep) => (
<TooltipTrigger render={<div />}> <StatusBadge
{renderLimitedTags(endpoints, 2)} key={ep}
</TooltipTrigger> label={ep}
{endpoints.length > 2 && ( autoColor={ep}
<TooltipContent side='top' className='max-w-[280px] p-2'> size='sm'
<span className='text-xs'>{endpoints.join(', ')}</span> copyable={false}
</TooltipContent> />
)} ))}
</Tooltip> />
</TooltipProvider>
) )
}, },
size: 130, size: 130,
...@@ -433,31 +383,16 @@ export function usePricingColumns( ...@@ -433,31 +383,16 @@ export function usePricingColumns(
// Enable Groups column // Enable Groups column
{ {
accessorKey: 'enable_groups', accessorKey: 'enable_groups',
meta: { label: t('Groups') },
header: t('Groups'), header: t('Groups'),
cell: ({ row }) => { cell: ({ row }) => {
const groups = row.original.enable_groups || [] const groups = row.original.enable_groups || []
if (groups.length === 0) {
return <span className='text-muted-foreground/50 text-xs'></span>
}
return ( return (
<TooltipProvider> <BadgeListCell
<Tooltip> items={groups.map((group) => (
<TooltipTrigger render={<div />}> <GroupBadge key={group} group={group} size='sm' />
{renderLimitedGroupBadges(groups, 2)} ))}
</TooltipTrigger> tooltipClassName='max-w-[280px] p-2'
{groups.length > 2 && ( />
<TooltipContent side='top' className='max-w-[280px] p-2'>
<div className='flex flex-wrap gap-1'>
{groups.map((group) => (
<GroupBadge key={group} group={group} size='sm' />
))}
</div>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
) )
}, },
size: 130, size: 130,
......
...@@ -77,7 +77,8 @@ export function DataTableRowActions<TData>({ ...@@ -77,7 +77,8 @@ export function DataTableRowActions<TData>({
const canToggle = !isUsed && !isExpired const canToggle = !isUsed && !isExpired
return ( return (
<DropdownMenu modal={false}> <div className='-ml-2'>
<DropdownMenu modal={false}>
<DropdownMenuTrigger <DropdownMenuTrigger
render={ render={
<Button <Button
...@@ -136,5 +137,6 @@ export function DataTableRowActions<TData>({ ...@@ -136,5 +137,6 @@ export function DataTableRowActions<TData>({
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div>
) )
} }
...@@ -25,7 +25,6 @@ import { ...@@ -25,7 +25,6 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip' } from '@/components/ui/tooltip'
import { DataTableColumnHeader } from '@/components/data-table'
import { MaskedValueDisplay } from '@/components/masked-value-display' import { MaskedValueDisplay } from '@/components/masked-value-display'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { TableId } from '@/components/table-id' import { TableId } from '@/components/table-id'
...@@ -39,7 +38,6 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] { ...@@ -39,7 +38,6 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
return [ return [
{ {
id: 'select', id: 'select',
meta: { label: t('Select') },
header: ({ table }) => ( header: ({ table }) => (
<Checkbox <Checkbox
checked={table.getIsAllPageRowsSelected()} checked={table.getIsAllPageRowsSelected()}
...@@ -63,10 +61,8 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] { ...@@ -63,10 +61,8 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
}, },
{ {
accessorKey: 'id', accessorKey: 'id',
meta: { label: t('ID'), mobileHidden: true }, header: t('ID'),
header: ({ column }) => ( meta: { mobileHidden: true },
<DataTableColumnHeader column={column} title={t('ID')} />
),
cell: ({ row }) => { cell: ({ row }) => {
return ( return (
<TableId value={row.getValue('id') as number} className='w-[60px]' /> <TableId value={row.getValue('id') as number} className='w-[60px]' />
...@@ -76,10 +72,8 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] { ...@@ -76,10 +72,8 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
}, },
{ {
accessorKey: 'name', accessorKey: 'name',
meta: { label: t('Name'), mobileTitle: true }, header: t('Name'),
header: ({ column }) => ( meta: { mobileTitle: true },
<DataTableColumnHeader column={column} title={t('Name')} />
),
cell: ({ row }) => { cell: ({ row }) => {
return ( return (
<div className='max-w-[150px] truncate font-medium'> <div className='max-w-[150px] truncate font-medium'>
...@@ -91,10 +85,8 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] { ...@@ -91,10 +85,8 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
}, },
{ {
accessorKey: 'status', accessorKey: 'status',
meta: { label: t('Status'), mobileBadge: true }, header: t('Status'),
header: ({ column }) => ( meta: { mobileBadge: true },
<DataTableColumnHeader column={column} title={t('Status')} />
),
cell: ({ row }) => { cell: ({ row }) => {
const redemption = row.original const redemption = row.original
const statusValue = row.getValue('status') as number const statusValue = row.getValue('status') as number
...@@ -106,6 +98,7 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] { ...@@ -106,6 +98,7 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
label={t('Expired')} label={t('Expired')}
variant='warning' variant='warning'
copyable={false} copyable={false}
className='-ml-1.5'
/> />
) )
} }
...@@ -121,6 +114,7 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] { ...@@ -121,6 +114,7 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
label={t(statusConfig.labelKey)} label={t(statusConfig.labelKey)}
variant={statusConfig.variant} variant={statusConfig.variant}
copyable={false} copyable={false}
className='-ml-1.5'
/> />
) )
}, },
...@@ -143,10 +137,7 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] { ...@@ -143,10 +137,7 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
{ {
id: 'code', id: 'code',
accessorKey: 'key', accessorKey: 'key',
meta: { label: t('Code') }, header: t('Code'),
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Code')} />
),
cell: function CodeCell({ row }) { cell: function CodeCell({ row }) {
const redemption = row.original const redemption = row.original
const key = redemption.key const key = redemption.key
...@@ -167,10 +158,7 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] { ...@@ -167,10 +158,7 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
}, },
{ {
accessorKey: 'quota', accessorKey: 'quota',
meta: { label: t('Quota') }, header: t('Quota'),
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Quota')} />
),
cell: ({ row }) => { cell: ({ row }) => {
const quota = row.getValue('quota') as number const quota = row.getValue('quota') as number
return ( return (
...@@ -178,6 +166,7 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] { ...@@ -178,6 +166,7 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
label={formatQuota(quota)} label={formatQuota(quota)}
variant='neutral' variant='neutral'
copyable={false} copyable={false}
className='-ml-1.5'
/> />
) )
}, },
...@@ -185,10 +174,8 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] { ...@@ -185,10 +174,8 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
}, },
{ {
accessorKey: 'created_time', accessorKey: 'created_time',
meta: { label: t('Created'), mobileHidden: true }, header: t('Created'),
header: ({ column }) => ( meta: { mobileHidden: true },
<DataTableColumnHeader column={column} title={t('Created')} />
),
cell: ({ row }) => { cell: ({ row }) => {
return ( return (
<div className='min-w-[160px] font-mono text-sm'> <div className='min-w-[160px] font-mono text-sm'>
...@@ -200,10 +187,8 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] { ...@@ -200,10 +187,8 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
}, },
{ {
accessorKey: 'expired_time', accessorKey: 'expired_time',
meta: { label: t('Expires'), mobileHidden: true }, header: t('Expires'),
header: ({ column }) => ( meta: { mobileHidden: true },
<DataTableColumnHeader column={column} title={t('Expires')} />
),
cell: ({ row }) => { cell: ({ row }) => {
const expiredTime = row.getValue('expired_time') as number const expiredTime = row.getValue('expired_time') as number
if (expiredTime === 0) { if (expiredTime === 0) {
...@@ -212,6 +197,7 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] { ...@@ -212,6 +197,7 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
label={t('Never')} label={t('Never')}
variant='neutral' variant='neutral'
copyable={false} copyable={false}
className='-ml-1.5'
/> />
) )
} }
...@@ -228,10 +214,8 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] { ...@@ -228,10 +214,8 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
}, },
{ {
accessorKey: 'used_user_id', accessorKey: 'used_user_id',
meta: { label: t('Redeemed By'), mobileHidden: true }, header: t('Redeemed By'),
header: ({ column }) => ( meta: { mobileHidden: true },
<DataTableColumnHeader column={column} title={t('Redeemed By')} />
),
cell: ({ row }) => { cell: ({ row }) => {
const userId = row.getValue('used_user_id') as number const userId = row.getValue('used_user_id') as number
const redemption = row.original const redemption = row.original
...@@ -272,7 +256,9 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] { ...@@ -272,7 +256,9 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
}, },
{ {
id: 'actions', id: 'actions',
header: () => t('Actions'),
cell: ({ row }) => <DataTableRowActions row={row} />, cell: ({ row }) => <DataTableRowActions row={row} />,
meta: { pinned: 'right' as const },
size: 88, size: 88,
}, },
] ]
......
...@@ -38,7 +38,8 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) { ...@@ -38,7 +38,8 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
const { setOpen, setCurrentRow, complianceConfirmed } = useSubscriptions() const { setOpen, setCurrentRow, complianceConfirmed } = useSubscriptions()
return ( return (
<DropdownMenu> <div className='-ml-2'>
<DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
render={<Button variant='ghost' className='h-8 w-8 p-0' />} render={<Button variant='ghost' className='h-8 w-8 p-0' />}
> >
...@@ -76,5 +77,6 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) { ...@@ -76,5 +77,6 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div>
) )
} }
...@@ -20,7 +20,6 @@ import { useMemo } from 'react' ...@@ -20,7 +20,6 @@ import { useMemo } from 'react'
import { type ColumnDef } from '@tanstack/react-table' import { type ColumnDef } from '@tanstack/react-table'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { formatQuota } from '@/lib/format' import { formatQuota } from '@/lib/format'
import { DataTableColumnHeader } from '@/components/data-table'
import { GroupBadge } from '@/components/group-badge' import { GroupBadge } from '@/components/group-badge'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { TableId } from '@/components/table-id' import { TableId } from '@/components/table-id'
...@@ -36,20 +35,16 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] { ...@@ -36,20 +35,16 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] {
{ {
accessorFn: (row) => row.plan.id, accessorFn: (row) => row.plan.id,
id: 'id', id: 'id',
meta: { label: t('ID'), mobileHidden: true }, header: t('ID'),
header: ({ column }) => ( meta: { mobileHidden: true },
<DataTableColumnHeader column={column} title={t('ID')} />
),
cell: ({ row }) => <TableId value={row.original.plan.id} />, cell: ({ row }) => <TableId value={row.original.plan.id} />,
size: 60, size: 60,
}, },
{ {
accessorFn: (row) => row.plan.title, accessorFn: (row) => row.plan.title,
id: 'title', id: 'title',
meta: { label: t('Plan'), mobileTitle: true }, header: t('Plan'),
header: ({ column }) => ( meta: { mobileTitle: true },
<DataTableColumnHeader column={column} title={t('Plan')} />
),
cell: ({ row }) => { cell: ({ row }) => {
const plan = row.original.plan const plan = row.original.plan
return ( return (
...@@ -68,10 +63,7 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] { ...@@ -68,10 +63,7 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] {
{ {
accessorFn: (row) => row.plan.price_amount, accessorFn: (row) => row.plan.price_amount,
id: 'price', id: 'price',
meta: { label: t('Price') }, header: t('Price'),
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Price')} />
),
cell: ({ row }) => ( cell: ({ row }) => (
<span className='font-semibold text-emerald-600'> <span className='font-semibold text-emerald-600'>
${Number(row.original.plan.price_amount || 0).toFixed(2)} ${Number(row.original.plan.price_amount || 0).toFixed(2)}
...@@ -81,10 +73,7 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] { ...@@ -81,10 +73,7 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] {
}, },
{ {
id: 'duration', id: 'duration',
meta: { label: t('Validity') }, header: t('Validity'),
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Validity')} />
),
cell: ({ row }) => ( cell: ({ row }) => (
<span className='text-muted-foreground'> <span className='text-muted-foreground'>
{formatDuration(row.original.plan, t)} {formatDuration(row.original.plan, t)}
...@@ -94,10 +83,8 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] { ...@@ -94,10 +83,8 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] {
}, },
{ {
id: 'reset', id: 'reset',
meta: { label: t('Quota Reset'), mobileHidden: true }, header: t('Quota Reset'),
header: ({ column }) => ( meta: { mobileHidden: true },
<DataTableColumnHeader column={column} title={t('Quota Reset')} />
),
cell: ({ row }) => ( cell: ({ row }) => (
<span className='text-muted-foreground'> <span className='text-muted-foreground'>
{formatResetPeriod(row.original.plan, t)} {formatResetPeriod(row.original.plan, t)}
...@@ -108,10 +95,8 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] { ...@@ -108,10 +95,8 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] {
{ {
accessorFn: (row) => row.plan.sort_order, accessorFn: (row) => row.plan.sort_order,
id: 'sort_order', id: 'sort_order',
meta: { label: t('Priority'), mobileHidden: true }, header: t('Priority'),
header: ({ column }) => ( meta: { mobileHidden: true },
<DataTableColumnHeader column={column} title={t('Priority')} />
),
cell: ({ row }) => ( cell: ({ row }) => (
<span className='text-muted-foreground'> <span className='text-muted-foreground'>
{row.original.plan.sort_order} {row.original.plan.sort_order}
...@@ -122,32 +107,30 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] { ...@@ -122,32 +107,30 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] {
{ {
accessorFn: (row) => row.plan.enabled, accessorFn: (row) => row.plan.enabled,
id: 'enabled', id: 'enabled',
meta: { label: t('Status'), mobileBadge: true }, header: t('Status'),
header: ({ column }) => ( meta: { mobileBadge: true },
<DataTableColumnHeader column={column} title={t('Status')} />
),
cell: ({ row }) => cell: ({ row }) =>
row.original.plan.enabled ? ( row.original.plan.enabled ? (
<StatusBadge <StatusBadge
label={t('Enable')} label={t('Enable')}
variant='success' variant='success'
copyable={false} copyable={false}
className='-ml-1.5'
/> />
) : ( ) : (
<StatusBadge <StatusBadge
label={t('Disable')} label={t('Disable')}
variant='neutral' variant='neutral'
copyable={false} copyable={false}
className='-ml-1.5'
/> />
), ),
size: 80, size: 80,
}, },
{ {
id: 'payment', id: 'payment',
meta: { label: t('Payment Channel'), mobileHidden: true }, header: t('Payment Channel'),
header: ({ column }) => ( meta: { mobileHidden: true },
<DataTableColumnHeader column={column} title={t('Payment Channel')} />
),
cell: ({ row }) => { cell: ({ row }) => {
const plan = row.original.plan const plan = row.original.plan
return ( return (
...@@ -176,10 +159,8 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] { ...@@ -176,10 +159,8 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] {
}, },
{ {
id: 'total_amount', id: 'total_amount',
meta: { label: t('Received amount'), mobileHidden: true }, header: t('Received amount'),
header: ({ column }) => ( meta: { mobileHidden: true },
<DataTableColumnHeader column={column} title={t('Received amount')} />
),
cell: ({ row }) => { cell: ({ row }) => {
const total = Number(row.original.plan.total_amount || 0) const total = Number(row.original.plan.total_amount || 0)
return ( return (
...@@ -192,10 +173,8 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] { ...@@ -192,10 +173,8 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] {
}, },
{ {
id: 'upgrade_group', id: 'upgrade_group',
meta: { label: t('Upgrade Group'), mobileHidden: true }, header: t('Upgrade Group'),
header: ({ column }) => ( meta: { mobileHidden: true },
<DataTableColumnHeader column={column} title={t('Upgrade Group')} />
),
cell: ({ row }) => { cell: ({ row }) => {
const group = row.original.plan.upgrade_group const group = row.original.plan.upgrade_group
if (!group) { if (!group) {
...@@ -209,7 +188,9 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] { ...@@ -209,7 +188,9 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] {
}, },
{ {
id: 'actions', id: 'actions',
header: () => t('Actions'),
cell: ({ row }) => <DataTableRowActions row={row} />, cell: ({ row }) => <DataTableRowActions row={row} />,
meta: { pinned: 'right' as const },
size: 80, size: 80,
}, },
], ],
......
...@@ -72,7 +72,6 @@ export function buildModelRatioColumns({ ...@@ -72,7 +72,6 @@ export function buildModelRatioColumns({
enableSorting: false, enableSorting: false,
enableHiding: false, enableHiding: false,
size: 40, size: 40,
meta: { label: t('Select') },
}, },
{ {
accessorKey: 'name', accessorKey: 'name',
...@@ -113,7 +112,7 @@ export function buildModelRatioColumns({ ...@@ -113,7 +112,7 @@ export function buildModelRatioColumns({
variant={getModeVariant(row.original.billingMode)} variant={getModeVariant(row.original.billingMode)}
copyable={false} copyable={false}
showDot={false} showDot={false}
className='px-0' className='-ml-1.5 px-0'
/> />
), ),
filterFn: (row, id, value) => filterFn: (row, id, value) =>
......
...@@ -35,7 +35,6 @@ import { ...@@ -35,7 +35,6 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip' } from '@/components/ui/tooltip'
import { DataTableColumnHeader } from '@/components/data-table'
import { StatusBadge, type StatusBadgeProps } from '@/components/status-badge' import { StatusBadge, type StatusBadgeProps } from '@/components/status-badge'
import { LOG_TYPE_ALL_VALUE } from '../../constants' import { LOG_TYPE_ALL_VALUE } from '../../constants'
import type { UsageLog } from '../../data/schema' import type { UsageLog } from '../../data/schema'
...@@ -265,9 +264,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] { ...@@ -265,9 +264,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
const columns: ColumnDef<UsageLog>[] = [ const columns: ColumnDef<UsageLog>[] = [
{ {
accessorKey: 'created_at', accessorKey: 'created_at',
header: ({ column }) => ( header: t('Time'),
<DataTableColumnHeader column={column} title={t('Time')} />
),
cell: ({ row }) => { cell: ({ row }) => {
const log = row.original const log = row.original
const timestamp = row.getValue('created_at') as number const timestamp = row.getValue('created_at') as number
...@@ -295,7 +292,6 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] { ...@@ -295,7 +292,6 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
}, },
enableHiding: false, enableHiding: false,
size: 180, size: 180,
meta: { label: t('Time') },
}, },
] ]
...@@ -303,10 +299,8 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] { ...@@ -303,10 +299,8 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
columns.push( columns.push(
{ {
id: 'channel', id: 'channel',
header: t('Channel'),
accessorFn: (row) => row.channel, accessorFn: (row) => row.channel,
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Channel')} />
),
cell: function ChannelCell({ row }) { cell: function ChannelCell({ row }) {
const { sensitiveVisible, setAffinityTarget, setAffinityDialogOpen } = const { sensitiveVisible, setAffinityTarget, setAffinityDialogOpen } =
useUsageLogsContext() useUsageLogsContext()
...@@ -404,14 +398,11 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] { ...@@ -404,14 +398,11 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
</TooltipProvider> </TooltipProvider>
) )
}, },
meta: { label: t('Channel') },
}, },
{ {
id: 'user', id: 'user',
header: t('User'),
accessorFn: (row) => row.username, accessorFn: (row) => row.username,
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('User')} />
),
cell: function UserCell({ row }) { cell: function UserCell({ row }) {
const { sensitiveVisible, setSelectedUserId, setUserInfoDialogOpen } = const { sensitiveVisible, setSelectedUserId, setUserInfoDialogOpen } =
useUsageLogsContext() useUsageLogsContext()
...@@ -461,16 +452,13 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] { ...@@ -461,16 +452,13 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
</button> </button>
) )
}, },
meta: { label: t('User') },
} }
) )
} }
columns.push({ columns.push({
accessorKey: 'token_name', accessorKey: 'token_name',
header: ({ column }) => ( header: t('Token'),
<DataTableColumnHeader column={column} title={t('Token')} />
),
cell: function TokenNameCell({ row }) { cell: function TokenNameCell({ row }) {
const { sensitiveVisible } = useUsageLogsContext() const { sensitiveVisible } = useUsageLogsContext()
const log = row.original const log = row.original
...@@ -520,16 +508,12 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] { ...@@ -520,16 +508,12 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
</div> </div>
) )
}, },
meta: { label: t('Token') },
size: 160, size: 160,
}) })
columns.push( columns.push(
{ {
accessorKey: 'model_name', accessorKey: 'model_name',
header: ({ column }) => ( header: t('Model'),
<DataTableColumnHeader column={column} title={t('Model')} />
),
cell: function ModelCell({ row }) { cell: function ModelCell({ row }) {
const log = row.original const log = row.original
if (!isDisplayableLogType(log.type)) return null if (!isDisplayableLogType(log.type)) return null
...@@ -545,14 +529,11 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] { ...@@ -545,14 +529,11 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
</div> </div>
) )
}, },
meta: { label: t('Model'), mobileTitle: true }, meta: { mobileTitle: true },
}, },
{ {
accessorKey: 'use_time', accessorKey: 'use_time',
header: ({ column }) => ( header: t('Timing'),
<DataTableColumnHeader column={column} title={t('Timing')} />
),
cell: ({ row }) => { cell: ({ row }) => {
const log = row.original const log = row.original
if (!isTimingLogType(log.type)) return null if (!isTimingLogType(log.type)) return null
...@@ -656,14 +637,11 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] { ...@@ -656,14 +637,11 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
</div> </div>
) )
}, },
meta: { label: t('Timing') },
}, },
{ {
accessorKey: 'prompt_tokens', accessorKey: 'prompt_tokens',
header: ({ column }) => ( header: 'Tokens',
<DataTableColumnHeader column={column} title='Tokens' />
),
cell: ({ row }) => { cell: ({ row }) => {
const log = row.original const log = row.original
if (!isDisplayableLogType(log.type)) return null if (!isDisplayableLogType(log.type)) return null
...@@ -707,14 +685,11 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] { ...@@ -707,14 +685,11 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
</div> </div>
) )
}, },
meta: { label: 'Tokens' },
}, },
{ {
accessorKey: 'quota', accessorKey: 'quota',
header: ({ column }) => ( header: t('Cost'),
<DataTableColumnHeader column={column} title={t('Cost')} />
),
cell: ({ row }) => { cell: ({ row }) => {
const log = row.original const log = row.original
if (!isDisplayableLogType(log.type)) return null if (!isDisplayableLogType(log.type)) return null
...@@ -762,7 +737,6 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] { ...@@ -762,7 +737,6 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
</div> </div>
) )
}, },
meta: { label: t('Cost') },
}, },
{ {
...@@ -820,7 +794,6 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] { ...@@ -820,7 +794,6 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
</> </>
) )
}, },
meta: { label: t('Details') },
size: 180, size: 180,
maxSize: 200, maxSize: 200,
} }
......
...@@ -38,7 +38,6 @@ import { ...@@ -38,7 +38,6 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { formatTimestampToDate } from '@/lib/format' import { formatTimestampToDate } from '@/lib/format'
import { DataTableColumnHeader } from '@/components/data-table'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { MJ_TASK_TYPES } from '../../constants' import { MJ_TASK_TYPES } from '../../constants'
import { import {
...@@ -87,9 +86,7 @@ export function useDrawingLogsColumns( ...@@ -87,9 +86,7 @@ export function useDrawingLogsColumns(
const columns: ColumnDef<MidjourneyLog>[] = [ const columns: ColumnDef<MidjourneyLog>[] = [
{ {
accessorKey: 'submit_time', accessorKey: 'submit_time',
header: ({ column }) => ( header: t('Submit Time'),
<DataTableColumnHeader column={column} title={t('Submit Time')} />
),
cell: ({ row }) => { cell: ({ row }) => {
const log = row.original const log = row.original
const submitTime = row.getValue('submit_time') as number const submitTime = row.getValue('submit_time') as number
...@@ -109,7 +106,6 @@ export function useDrawingLogsColumns( ...@@ -109,7 +106,6 @@ export function useDrawingLogsColumns(
) )
}, },
size: 180, size: 180,
meta: { label: t('Submit Time') },
}, },
] ]
...@@ -121,9 +117,7 @@ export function useDrawingLogsColumns( ...@@ -121,9 +117,7 @@ export function useDrawingLogsColumns(
columns.push({ columns.push({
accessorKey: 'action', accessorKey: 'action',
header: ({ column }) => ( header: t('Type'),
<DataTableColumnHeader column={column} title={t('Type')} />
),
cell: ({ row }) => { cell: ({ row }) => {
const action = row.getValue('action') as string const action = row.getValue('action') as string
return ( return (
...@@ -133,17 +127,15 @@ export function useDrawingLogsColumns( ...@@ -133,17 +127,15 @@ export function useDrawingLogsColumns(
icon={getDrawingTypeIcon(action)} icon={getDrawingTypeIcon(action)}
size='sm' size='sm'
copyable={false} copyable={false}
className='-ml-1.5'
/> />
) )
}, },
meta: { label: t('Type') },
}) })
columns.push({ columns.push({
accessorKey: 'mj_id', accessorKey: 'mj_id',
header: ({ column }) => ( header: t('Task ID'),
<DataTableColumnHeader column={column} title={t('Task ID')} />
),
cell: ({ row }) => { cell: ({ row }) => {
const mjId = row.getValue('mj_id') as string const mjId = row.getValue('mj_id') as string
...@@ -162,7 +154,7 @@ export function useDrawingLogsColumns( ...@@ -162,7 +154,7 @@ export function useDrawingLogsColumns(
</div> </div>
) )
}, },
meta: { label: t('Task ID'), mobileTitle: true }, meta: { mobileTitle: true },
}) })
columns.push( columns.push(
...@@ -176,9 +168,7 @@ export function useDrawingLogsColumns( ...@@ -176,9 +168,7 @@ export function useDrawingLogsColumns(
if (isAdmin) { if (isAdmin) {
columns.push({ columns.push({
accessorKey: 'code', accessorKey: 'code',
header: ({ column }) => ( header: t('Submit Result'),
<DataTableColumnHeader column={column} title={t('Submit Result')} />
),
cell: ({ row }) => { cell: ({ row }) => {
const code = row.getValue('code') as number const code = row.getValue('code') as number
...@@ -188,10 +178,10 @@ export function useDrawingLogsColumns( ...@@ -188,10 +178,10 @@ export function useDrawingLogsColumns(
variant={mjSubmitResultMapper.getVariant(String(code))} variant={mjSubmitResultMapper.getVariant(String(code))}
size='sm' size='sm'
copyable={false} copyable={false}
className='-ml-1.5'
/> />
) )
}, },
meta: { label: t('Submit Result') },
}) })
} }
...@@ -199,9 +189,7 @@ export function useDrawingLogsColumns( ...@@ -199,9 +189,7 @@ export function useDrawingLogsColumns(
createProgressColumn<MidjourneyLog>({ headerLabel: t('Progress') }), createProgressColumn<MidjourneyLog>({ headerLabel: t('Progress') }),
{ {
accessorKey: 'image_url', accessorKey: 'image_url',
header: ({ column }) => ( header: t('Image'),
<DataTableColumnHeader column={column} title={t('Image')} />
),
cell: function ImageCell({ row }) { cell: function ImageCell({ row }) {
const log = row.original const log = row.original
const imageUrl = row.getValue('image_url') as string const imageUrl = row.getValue('image_url') as string
...@@ -232,13 +220,10 @@ export function useDrawingLogsColumns( ...@@ -232,13 +220,10 @@ export function useDrawingLogsColumns(
</> </>
) )
}, },
meta: { label: t('Image') },
}, },
{ {
accessorKey: 'prompt', accessorKey: 'prompt',
header: ({ column }) => ( header: t('Prompt'),
<DataTableColumnHeader column={column} title={t('Prompt')} />
),
cell: function PromptCell({ row }) { cell: function PromptCell({ row }) {
const log = row.original const log = row.original
const prompt = row.getValue('prompt') as string const prompt = row.getValue('prompt') as string
...@@ -269,7 +254,6 @@ export function useDrawingLogsColumns( ...@@ -269,7 +254,6 @@ export function useDrawingLogsColumns(
</> </>
) )
}, },
meta: { label: t('Prompt') },
size: 200, size: 200,
maxSize: 220, maxSize: 220,
}, },
......
...@@ -25,7 +25,6 @@ import { getUserAvatarFallback, getUserAvatarStyle } from '@/lib/avatar' ...@@ -25,7 +25,6 @@ import { getUserAvatarFallback, getUserAvatarStyle } from '@/lib/avatar'
import { formatTimestampToDate } from '@/lib/format' import { formatTimestampToDate } from '@/lib/format'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Avatar, AvatarFallback } from '@/components/ui/avatar' import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { DataTableColumnHeader } from '@/components/data-table'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { TASK_ACTIONS, TASK_STATUS } from '../../constants' import { TASK_ACTIONS, TASK_STATUS } from '../../constants'
import { taskActionMapper, taskStatusMapper } from '../../lib/mappers' import { taskActionMapper, taskStatusMapper } from '../../lib/mappers'
...@@ -94,9 +93,7 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] { ...@@ -94,9 +93,7 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
const columns: ColumnDef<TaskLog>[] = [ const columns: ColumnDef<TaskLog>[] = [
{ {
accessorKey: 'submit_time', accessorKey: 'submit_time',
header: ({ column }) => ( header: t('Submit Time'),
<DataTableColumnHeader column={column} title={t('Submit Time')} />
),
cell: ({ row }) => { cell: ({ row }) => {
const log = row.original const log = row.original
const submitTime = row.getValue('submit_time') as number const submitTime = row.getValue('submit_time') as number
...@@ -117,17 +114,14 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] { ...@@ -117,17 +114,14 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
) )
}, },
size: 180, size: 180,
meta: { label: t('Submit Time') },
}, },
] ]
if (isAdmin) { if (isAdmin) {
columns.push(createChannelColumn<TaskLog>({ headerLabel: t('Channel') }), { columns.push(createChannelColumn<TaskLog>({ headerLabel: t('Channel') }), {
id: 'user', id: 'user',
header: t('User'),
accessorFn: (row) => row.username || row.user_id, accessorFn: (row) => row.username || row.user_id,
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('User')} />
),
cell: function UserCell({ row }) { cell: function UserCell({ row }) {
const { sensitiveVisible, setSelectedUserId, setUserInfoDialogOpen } = const { sensitiveVisible, setSelectedUserId, setUserInfoDialogOpen } =
useUsageLogsContext() useUsageLogsContext()
...@@ -163,16 +157,13 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] { ...@@ -163,16 +157,13 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
</button> </button>
) )
}, },
meta: { label: t('User') },
}) })
} }
columns.push( columns.push(
{ {
accessorKey: 'task_id', accessorKey: 'task_id',
header: ({ column }) => ( header: t('Task ID'),
<DataTableColumnHeader column={column} title={t('Task ID')} />
),
cell: ({ row }) => { cell: ({ row }) => {
const log = row.original const log = row.original
const taskId = row.getValue('task_id') as string const taskId = row.getValue('task_id') as string
...@@ -193,7 +184,7 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] { ...@@ -193,7 +184,7 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
</div> </div>
) )
}, },
meta: { label: t('Task ID'), mobileTitle: true }, meta: { mobileTitle: true },
}, },
createDurationColumn<TaskLog>({ createDurationColumn<TaskLog>({
submitTimeKey: 'submit_time', submitTimeKey: 'submit_time',
...@@ -204,9 +195,7 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] { ...@@ -204,9 +195,7 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
}), }),
{ {
accessorKey: 'status', accessorKey: 'status',
header: ({ column }) => ( header: t('Status'),
<DataTableColumnHeader column={column} title={t('Status')} />
),
cell: ({ row }) => { cell: ({ row }) => {
const status = row.getValue('status') as string const status = row.getValue('status') as string
return ( return (
...@@ -215,17 +204,15 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] { ...@@ -215,17 +204,15 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
variant={taskStatusMapper.getVariant(status)} variant={taskStatusMapper.getVariant(status)}
size='sm' size='sm'
copyable={false} copyable={false}
className='-ml-1.5'
/> />
) )
}, },
meta: { label: t('Status') },
}, },
createProgressColumn<TaskLog>({ headerLabel: t('Progress') }), createProgressColumn<TaskLog>({ headerLabel: t('Progress') }),
{ {
accessorKey: 'fail_reason', accessorKey: 'fail_reason',
header: ({ column }) => ( header: t('Details'),
<DataTableColumnHeader column={column} title={t('Details')} />
),
cell: function DetailsCell({ row }) { cell: function DetailsCell({ row }) {
const log = row.original const log = row.original
const failReason = row.getValue('fail_reason') as string const failReason = row.getValue('fail_reason') as string
...@@ -295,7 +282,6 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] { ...@@ -295,7 +282,6 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
</> </>
) )
}, },
meta: { label: t('Details') },
size: 200, size: 200,
maxSize: 220, maxSize: 220,
} }
......
...@@ -136,7 +136,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) { ...@@ -136,7 +136,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
} }
return ( return (
<> <div className='-ml-2'>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
render={ render={
...@@ -294,6 +294,6 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) { ...@@ -294,6 +294,6 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
user={{ id: user.id, username: user.username }} user={{ id: user.id, username: user.username }}
onSuccess={triggerRefresh} onSuccess={triggerRefresh}
/> />
</> </div>
) )
} }
...@@ -27,7 +27,6 @@ import { ...@@ -27,7 +27,6 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip' } from '@/components/ui/tooltip'
import { DataTableColumnHeader } from '@/components/data-table'
import { GroupBadge } from '@/components/group-badge' import { GroupBadge } from '@/components/group-badge'
import { LongText } from '@/components/long-text' import { LongText } from '@/components/long-text'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
...@@ -67,26 +66,21 @@ export function useUsersColumns(): ColumnDef<User>[] { ...@@ -67,26 +66,21 @@ export function useUsersColumns(): ColumnDef<User>[] {
enableSorting: false, enableSorting: false,
enableHiding: false, enableHiding: false,
size: 40, size: 40,
meta: { label: t('Select') },
}, },
{ {
accessorKey: 'id', accessorKey: 'id',
header: ({ column }) => ( header: t('ID'),
<DataTableColumnHeader column={column} title='ID' />
),
cell: ({ row }) => { cell: ({ row }) => {
return ( return (
<TableId value={row.getValue('id') as number} className='w-[60px]' /> <TableId value={row.getValue('id') as number} className='w-[60px]' />
) )
}, },
size: 80, size: 80,
meta: { label: t('ID'), mobileHidden: true }, meta: { mobileHidden: true },
}, },
{ {
accessorKey: 'username', accessorKey: 'username',
header: ({ column }) => ( header: t('Username'),
<DataTableColumnHeader column={column} title={t('Username')} />
),
cell: ({ row }) => { cell: ({ row }) => {
const username = row.getValue('username') as string const username = row.getValue('username') as string
const displayName = row.original.display_name const displayName = row.original.display_name
...@@ -121,13 +115,11 @@ export function useUsersColumns(): ColumnDef<User>[] { ...@@ -121,13 +115,11 @@ export function useUsersColumns(): ColumnDef<User>[] {
}, },
enableHiding: false, enableHiding: false,
size: 220, size: 220,
meta: { label: t('Username'), mobileTitle: true }, meta: { mobileTitle: true },
}, },
{ {
accessorKey: 'status', accessorKey: 'status',
header: ({ column }) => ( header: t('Status'),
<DataTableColumnHeader column={column} title={t('Status')} />
),
cell: ({ row }) => { cell: ({ row }) => {
const user = row.original const user = row.original
const requestCount = user.request_count const requestCount = user.request_count
...@@ -142,7 +134,7 @@ export function useUsersColumns(): ColumnDef<User>[] { ...@@ -142,7 +134,7 @@ export function useUsersColumns(): ColumnDef<User>[] {
return ( return (
<Tooltip> <Tooltip>
<TooltipTrigger render={<div className='cursor-help' />}> <TooltipTrigger render={<div className='-ml-1.5 cursor-help' />}>
<StatusBadge <StatusBadge
label={t(statusConfig.labelKey)} label={t(statusConfig.labelKey)}
variant={statusConfig.variant} variant={statusConfig.variant}
...@@ -162,14 +154,12 @@ export function useUsersColumns(): ColumnDef<User>[] { ...@@ -162,14 +154,12 @@ export function useUsersColumns(): ColumnDef<User>[] {
}, },
enableSorting: false, enableSorting: false,
size: 120, size: 120,
meta: { label: t('Status'), mobileBadge: true }, meta: { mobileBadge: true },
}, },
{ {
id: 'quota', id: 'quota',
accessorKey: 'quota', accessorKey: 'quota',
header: ({ column }) => ( header: t('Quota'),
<DataTableColumnHeader column={column} title={t('Quota')} />
),
cell: ({ row }) => { cell: ({ row }) => {
const user = row.original const user = row.original
const used = user.used_quota const used = user.used_quota
...@@ -183,6 +173,7 @@ export function useUsersColumns(): ColumnDef<User>[] { ...@@ -183,6 +173,7 @@ export function useUsersColumns(): ColumnDef<User>[] {
label={t('No Quota')} label={t('No Quota')}
variant='neutral' variant='neutral'
copyable={false} copyable={false}
className='-ml-1.5'
/> />
) )
} }
...@@ -225,13 +216,10 @@ export function useUsersColumns(): ColumnDef<User>[] { ...@@ -225,13 +216,10 @@ export function useUsersColumns(): ColumnDef<User>[] {
) )
}, },
size: 170, size: 170,
meta: { label: t('Quota') },
}, },
{ {
accessorKey: 'group', accessorKey: 'group',
header: ({ column }) => ( header: t('Group'),
<DataTableColumnHeader column={column} title={t('Group')} />
),
cell: ({ row }) => { cell: ({ row }) => {
const group = row.getValue('group') as string const group = row.getValue('group') as string
return <GroupBadge group={group} /> return <GroupBadge group={group} />
...@@ -242,13 +230,10 @@ export function useUsersColumns(): ColumnDef<User>[] { ...@@ -242,13 +230,10 @@ export function useUsersColumns(): ColumnDef<User>[] {
return group.includes(searchValue) return group.includes(searchValue)
}, },
size: 140, size: 140,
meta: { label: t('Group') },
}, },
{ {
accessorKey: 'role', accessorKey: 'role',
header: ({ column }) => ( header: t('Role'),
<DataTableColumnHeader column={column} title={t('Role')} />
),
cell: ({ row }) => { cell: ({ row }) => {
const roleValue = row.getValue('role') as number const roleValue = row.getValue('role') as number
const roleConfig = USER_ROLES[roleValue as keyof typeof USER_ROLES] const roleConfig = USER_ROLES[roleValue as keyof typeof USER_ROLES]
...@@ -271,13 +256,10 @@ export function useUsersColumns(): ColumnDef<User>[] { ...@@ -271,13 +256,10 @@ export function useUsersColumns(): ColumnDef<User>[] {
}, },
enableSorting: false, enableSorting: false,
size: 120, size: 120,
meta: { label: t('Role') },
}, },
{ {
id: 'invite_info', id: 'invite_info',
header: ({ column }) => ( header: t('Invite Info'),
<DataTableColumnHeader column={column} title={t('Invite Info')} />
),
cell: ({ row }) => { cell: ({ row }) => {
const user = row.original const user = row.original
const affCount = user.aff_count || 0 const affCount = user.aff_count || 0
...@@ -347,13 +329,11 @@ export function useUsersColumns(): ColumnDef<User>[] { ...@@ -347,13 +329,11 @@ export function useUsersColumns(): ColumnDef<User>[] {
}, },
size: 240, size: 240,
enableSorting: false, enableSorting: false,
meta: { label: t('Invite Info'), mobileHidden: true }, meta: { mobileHidden: true },
}, },
{ {
accessorKey: 'created_at', accessorKey: 'created_at',
header: ({ column }) => ( header: t('Created At'),
<DataTableColumnHeader column={column} title={t('Created At')} />
),
cell: ({ row }) => { cell: ({ row }) => {
const ts = row.getValue('created_at') as number | undefined const ts = row.getValue('created_at') as number | undefined
return ( return (
...@@ -363,13 +343,11 @@ export function useUsersColumns(): ColumnDef<User>[] { ...@@ -363,13 +343,11 @@ export function useUsersColumns(): ColumnDef<User>[] {
) )
}, },
size: 180, size: 180,
meta: { label: t('Created At'), mobileHidden: true }, meta: { mobileHidden: true },
}, },
{ {
accessorKey: 'last_login_at', accessorKey: 'last_login_at',
header: ({ column }) => ( header: t('Last Login'),
<DataTableColumnHeader column={column} title={t('Last Login')} />
),
cell: ({ row }) => { cell: ({ row }) => {
const ts = row.getValue('last_login_at') as number | undefined const ts = row.getValue('last_login_at') as number | undefined
return ( return (
...@@ -379,12 +357,13 @@ export function useUsersColumns(): ColumnDef<User>[] { ...@@ -379,12 +357,13 @@ export function useUsersColumns(): ColumnDef<User>[] {
) )
}, },
size: 180, size: 180,
meta: { label: t('Last Login'), mobileHidden: true }, meta: { mobileHidden: true },
}, },
{ {
id: 'actions', id: 'actions',
header: () => t('Actions'),
cell: ({ row }) => <DataTableRowActions row={row} />, cell: ({ row }) => <DataTableRowActions row={row} />,
meta: { label: t('Actions') }, meta: { pinned: 'right' as const },
}, },
] ]
} }
...@@ -19,15 +19,14 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -19,15 +19,14 @@ For commercial licensing, please contact support@quantumnous.com
import '@tanstack/react-table' import '@tanstack/react-table'
declare module '@tanstack/react-table' { declare module '@tanstack/react-table' {
// Extended column metadata for enhanced table functionality
interface ColumnMeta<_TData, _TValue> { interface ColumnMeta<_TData, _TValue> {
// Human-readable label for the column
label?: string label?: string
// Optional description shown in tooltips or help text
description?: string description?: string
// Whether this column can be sorted (overrides default behavior)
sortable?: boolean
// Custom CSS classes to apply to the column cells
className?: string className?: string
pinned?: 'left' | 'right'
// Mobile card list layout hints (used by MobileCardList)
mobileTitle?: boolean // card title area (left, larger text)
mobileBadge?: boolean // status badge alongside title (right)
mobileHidden?: boolean // hide this column on mobile entirely
} }
} }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment