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>
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
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(
|
||||
pinnedColumn.side === 'left' ? 'left-0' : 'right-0',
|
||||
edgeClassName,
|
||||
kind === 'header'
|
||||
? 'bg-background z-30'
|
||||
: 'bg-background z-10 group-hover:bg-muted group-data-[state=selected]:bg-muted',
|
||||
? '[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:[background-color:color-mix(in_oklch,var(--muted)_50%,var(--background))] group-data-[state=selected]:bg-muted',
|
||||
pinnedColumn.className,
|
||||
kind === 'header'
|
||||
? pinnedColumn.headerClassName
|
||||
|
||||
@@ -23,10 +23,21 @@ export function DataTableColgroup<TData>({
|
||||
}: {
|
||||
table: TanstackTable<TData>
|
||||
}) {
|
||||
const columns = table.getVisibleLeafColumns()
|
||||
const totalSize = columns.reduce((sum, col) => sum + col.getSize(), 0)
|
||||
|
||||
return (
|
||||
<colgroup>
|
||||
{table.getVisibleLeafColumns().map((column) => (
|
||||
<col key={column.id} style={{ width: column.getSize() }} />
|
||||
{columns.map((column) => (
|
||||
<col
|
||||
key={column.id}
|
||||
style={{
|
||||
width:
|
||||
totalSize > 0
|
||||
? `${(column.getSize() / totalSize) * 100}%`
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</colgroup>
|
||||
)
|
||||
|
||||
@@ -16,8 +16,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
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 { DataTableColumnHeader } from './column-header'
|
||||
import type { DataTableColumnClassName } from './types'
|
||||
|
||||
type DataTableHeaderProps<TData> = {
|
||||
@@ -46,12 +47,7 @@ export function DataTableHeader<TData>({
|
||||
className={getColumnClassName?.(header.column.id, 'header')}
|
||||
style={applyHeaderSize ? { width: header.getSize() } : undefined}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
{renderHeaderContent(header)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
@@ -59,3 +55,19 @@ export function DataTableHeader<TData>({
|
||||
</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/>.
|
||||
|
||||
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 { TableCell, TableRow } from '@/components/ui/table'
|
||||
import type { DataTableColumnClassName } from './types'
|
||||
@@ -27,7 +27,7 @@ type DataTableRowProps<TData> = {
|
||||
getColumnClassName?: DataTableColumnClassName
|
||||
} & Omit<React.ComponentProps<typeof TableRow>, 'children'>
|
||||
|
||||
export function DataTableRow<TData>({
|
||||
function DataTableRowInner<TData>({
|
||||
row,
|
||||
className,
|
||||
getColumnClassName,
|
||||
@@ -50,3 +50,14 @@ export function DataTableRow<TData>({
|
||||
</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
|
||||
|
||||
+70
-55
@@ -17,7 +17,7 @@ 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 { type Row } from '@tanstack/react-table'
|
||||
import { type Row, type Table as TanstackTable } from '@tanstack/react-table'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Table, TableBody, TableCell, TableRow } from '@/components/ui/table'
|
||||
import {
|
||||
@@ -46,8 +46,12 @@ export { DataTableRow } from './data-table-row'
|
||||
|
||||
export function DataTableView<TData>(props: DataTableViewProps<TData>) {
|
||||
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(
|
||||
props.table,
|
||||
props.getColumnClassName,
|
||||
props.pinnedColumns
|
||||
)
|
||||
@@ -120,32 +124,8 @@ function SplitHeaderTableView<TData>({
|
||||
colSpan: number
|
||||
getColumnClassName: DataTableColumnClassName
|
||||
}) {
|
||||
const headerHostRef = React.useRef<HTMLDivElement>(null)
|
||||
const bodyHostRef = React.useRef<HTMLDivElement>(null)
|
||||
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 (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -155,49 +135,49 @@ function SplitHeaderTableView<TData>({
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-h-0 flex-1 flex-col overflow-hidden',
|
||||
props.splitHeaderScrollClassName
|
||||
'min-h-0 flex-1 overflow-auto',
|
||||
'[&_[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
|
||||
ref={headerHostRef}
|
||||
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}
|
||||
<table
|
||||
data-slot='table'
|
||||
className={cn(
|
||||
'min-h-0 flex-1 [scrollbar-gutter:stable] overflow-y-auto',
|
||||
props.bodyContainerClassName
|
||||
'w-full caption-bottom text-sm tabular-nums [&_td]:text-sm [&_td_*]:text-sm [&_th]:text-sm [&_th_*]:text-sm',
|
||||
props.tableClassName
|
||||
)}
|
||||
style={tableSizing.style}
|
||||
>
|
||||
<Table className={props.tableClassName} style={tableSizing.style}>
|
||||
{tableSizing.colgroup}
|
||||
{renderTableBody(props, rows, colSpan, getColumnClassName)}
|
||||
</Table>
|
||||
</div>
|
||||
{tableSizing.colgroup}
|
||||
<DataTableHeader
|
||||
table={props.table}
|
||||
applyHeaderSize={props.applyHeaderSize}
|
||||
className={cn('sticky top-0 z-10', props.tableHeaderClassName)}
|
||||
rowClassName={props.tableHeaderRowClassName}
|
||||
getColumnClassName={getColumnClassName}
|
||||
/>
|
||||
{renderTableBody(props, rows, colSpan, getColumnClassName)}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function useResolvedColumnClassName(
|
||||
function useResolvedColumnClassName<TData>(
|
||||
table: TanstackTable<TData>,
|
||||
getColumnClassName?: DataTableColumnClassName,
|
||||
pinnedColumns?: DataTablePinnedColumn[]
|
||||
) {
|
||||
const allPinnedColumns = React.useMemo(() => {
|
||||
const metaPinnedColumns = getMetaPinnedColumns(table)
|
||||
return mergePinnedColumns(pinnedColumns, metaPinnedColumns)
|
||||
}, [table, pinnedColumns])
|
||||
|
||||
const pinnedColumnById = React.useMemo(
|
||||
() => getPinnedColumnMap(pinnedColumns),
|
||||
[pinnedColumns]
|
||||
() => getPinnedColumnMap(allPinnedColumns),
|
||||
[allPinnedColumns]
|
||||
)
|
||||
|
||||
return React.useMemo(
|
||||
@@ -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>): {
|
||||
colgroup?: React.ReactNode
|
||||
style?: React.CSSProperties
|
||||
|
||||
@@ -41,6 +41,8 @@ export function useDebouncedColumnFilter({
|
||||
const [pendingValue, setPendingValue] = React.useState(value)
|
||||
const isComposingRef = React.useRef(false)
|
||||
const debouncedValue = useDebounce(pendingValue, delay)
|
||||
const onColumnFiltersChangeRef = React.useRef(onColumnFiltersChange)
|
||||
onColumnFiltersChangeRef.current = onColumnFiltersChange
|
||||
|
||||
React.useEffect(() => {
|
||||
// Keep the input aligned when URL state changes outside the local field.
|
||||
@@ -55,13 +57,13 @@ export function useDebouncedColumnFilter({
|
||||
React.useEffect(() => {
|
||||
if (debouncedValue === value) return
|
||||
|
||||
onColumnFiltersChange((previous) => {
|
||||
onColumnFiltersChangeRef.current((previous) => {
|
||||
const filters = previous.filter((filter) => filter.id !== columnId)
|
||||
return debouncedValue
|
||||
? [...filters, { id: columnId, value: debouncedValue }]
|
||||
: filters
|
||||
})
|
||||
}, [columnId, debouncedValue, onColumnFiltersChange, value])
|
||||
}, [columnId, debouncedValue, value])
|
||||
|
||||
const updateInputValue = React.useCallback((nextValue: string) => {
|
||||
setInputValue(nextValue)
|
||||
|
||||
@@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
export { DataTablePagination } from './core/pagination'
|
||||
export { DataTableColumnHeader } from './core/column-header'
|
||||
export { BadgeListCell } from './core/badge-list-cell'
|
||||
export { DataTableViewOptions } from './toolbar/view-options'
|
||||
export { DataTableToolbar } from './toolbar/toolbar'
|
||||
export { DataTableBulkActions } from './toolbar/bulk-actions'
|
||||
|
||||
@@ -344,7 +344,7 @@ function renderDesktop<TData>(
|
||||
splitHeader={fixedHeight}
|
||||
tableContainerClassName={fixedHeight ? 'h-full min-h-0' : undefined}
|
||||
tableHeaderClassName={cn(
|
||||
fixedHeight && 'bg-muted/30',
|
||||
fixedHeight && '[background-color:color-mix(in_oklch,var(--muted)_30%,var(--background))]',
|
||||
props.tableHeaderClassName
|
||||
)}
|
||||
getColumnClassName={props.getColumnClassName}
|
||||
|
||||
@@ -16,6 +16,7 @@ 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 {
|
||||
flexRender,
|
||||
type Cell,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
} from '@tanstack/react-table'
|
||||
import { Database } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { StatusBadgeTypeContext } from '@/components/status-badge'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Empty,
|
||||
@@ -43,24 +45,11 @@ interface MobileCardListProps<TData> {
|
||||
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 {
|
||||
const meta = getCellMeta(cell)
|
||||
const { header, meta } = cell.column.columnDef
|
||||
if (typeof header === 'string') return header
|
||||
if (meta?.label) return meta.label
|
||||
const header = cell.column.columnDef.header
|
||||
return typeof header === 'string' ? header : null
|
||||
return null
|
||||
}
|
||||
|
||||
function renderCellContent<TData>(cell: Cell<TData, unknown>): React.ReactNode {
|
||||
@@ -128,16 +117,22 @@ function CompactRow<TData>({ row }: { row: Row<TData> }) {
|
||||
.getVisibleCells()
|
||||
.filter((cell) => cell.column.id !== 'select')
|
||||
|
||||
const titleCell = allCells.find((c) => getCellMeta(c)?.mobileTitle)
|
||||
const badgeCell = allCells.find((c) => getCellMeta(c)?.mobileBadge)
|
||||
const actionsCell = allCells.find((c) => c.column.id === 'actions')
|
||||
// Read each cell's meta once, then reuse for all categorisation checks.
|
||||
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 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(
|
||||
(c) =>
|
||||
(c, i) =>
|
||||
c !== titleCell &&
|
||||
c !== badgeCell &&
|
||||
c !== actionsCell &&
|
||||
!getCellMeta(c)?.mobileHidden
|
||||
!cellMetas[i]?.mobileHidden
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -145,12 +140,14 @@ function CompactRow<TData>({ row }: { row: Row<TData> }) {
|
||||
{/* Row 1: Title + Badge */}
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
{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)}
|
||||
</div>
|
||||
)}
|
||||
{badgeCell && (
|
||||
<div className='shrink-0'>{renderCellContent(badgeCell)}</div>
|
||||
<div className='flex-none [&_[data-slot=status-badge]]:max-w-none'>
|
||||
{renderCellContent(badgeCell)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -166,8 +163,10 @@ function CompactRow<TData>({ row }: { row: Row<TData> }) {
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
<div className='min-w-0 overflow-hidden text-xs'>
|
||||
{renderCellContent(cell) ?? '-'}
|
||||
<div className='min-w-0 overflow-hidden text-xs [&_[data-slot=provider-badge]]:ml-0 [&_[data-slot=status-badge]]:ml-0'>
|
||||
<StatusBadgeTypeContext.Provider value='text'>
|
||||
{renderCellContent(cell) ?? '-'}
|
||||
</StatusBadgeTypeContext.Provider>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -194,21 +193,28 @@ function FallbackRow<TData>({ row }: { row: Row<TData> }) {
|
||||
.getVisibleCells()
|
||||
.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 contentCells = allCells.filter(
|
||||
(c) => c.column.id !== 'actions' && !getCellMeta(c)?.mobileHidden
|
||||
(c, i) => c.column.id !== 'actions' && !cellMetas[i]?.mobileHidden
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{contentCells.map((cell) => {
|
||||
const label = getCellLabel(cell)
|
||||
const content = renderCellContent(cell)
|
||||
|
||||
if (!label) {
|
||||
return (
|
||||
<div key={cell.id} className='flex justify-end overflow-hidden'>
|
||||
{content}
|
||||
<div key={cell.id} className='flex justify-end overflow-hidden [&_[data-slot=provider-badge]]:ml-0 [&_[data-slot=status-badge]]:ml-0'>
|
||||
<StatusBadgeTypeContext.Provider value='text'>
|
||||
{renderCellContent(cell)}
|
||||
</StatusBadgeTypeContext.Provider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -221,8 +227,10 @@ function FallbackRow<TData>({ row }: { row: Row<TData> }) {
|
||||
<span className='text-muted-foreground shrink-0 text-[10px] font-medium select-none'>
|
||||
{label}
|
||||
</span>
|
||||
<div className='flex min-w-0 flex-1 items-center justify-end overflow-hidden text-xs'>
|
||||
{content ?? '-'}
|
||||
<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'>
|
||||
<StatusBadgeTypeContext.Provider value='text'>
|
||||
{renderCellContent(cell) ?? '-'}
|
||||
</StatusBadgeTypeContext.Provider>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -265,10 +273,15 @@ export function MobileCardList<TData>(props: MobileCardListProps<TData>) {
|
||||
const resolvedEmptyTitle = emptyTitle ?? t('No Data')
|
||||
const resolvedEmptyDescription = emptyDescription ?? t('No data available')
|
||||
|
||||
const hasCompactMeta = table.getVisibleLeafColumns().some((col) => {
|
||||
const meta = col.columnDef.meta as MobileColumnMeta | undefined
|
||||
return meta?.mobileTitle || meta?.mobileBadge
|
||||
})
|
||||
const visibleColumns = table.getVisibleLeafColumns()
|
||||
const hasCompactMeta = React.useMemo(
|
||||
() =>
|
||||
visibleColumns.some((col) => {
|
||||
const meta = col.columnDef.meta
|
||||
return meta?.mobileTitle || meta?.mobileBadge
|
||||
}),
|
||||
[visibleColumns]
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return hasCompactMeta ? <ListSkeleton /> : <FallbackListSkeleton />
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ export const staticDataTableClassNames = {
|
||||
embeddedContainer: 'rounded-none border-0',
|
||||
compactTable: 'text-sm',
|
||||
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:
|
||||
'text-muted-foreground py-2 text-[10px] font-medium tracking-wider uppercase',
|
||||
compactHeaderCellRight:
|
||||
|
||||
@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
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 { X } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -55,8 +55,13 @@ export function DataTableBulkActions<TData>({
|
||||
const selectedRows = table.getFilteredSelectedRowModel().rows
|
||||
const selectedCount = selectedRows.length
|
||||
const toolbarRef = useRef<HTMLDivElement>(null)
|
||||
const buttonsRef = useRef<NodeListOf<HTMLButtonElement> | null>(null)
|
||||
const [announcement, setAnnouncement] = useState('')
|
||||
|
||||
useLayoutEffect(() => {
|
||||
buttonsRef.current = toolbarRef.current?.querySelectorAll('button') ?? null
|
||||
})
|
||||
|
||||
// Announce selection changes to screen readers
|
||||
useEffect(() => {
|
||||
if (selectedCount > 0) {
|
||||
@@ -75,7 +80,7 @@ export function DataTableBulkActions<TData>({
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
const buttons = toolbarRef.current?.querySelectorAll('button')
|
||||
const buttons = buttonsRef.current
|
||||
if (!buttons) return
|
||||
|
||||
const currentIndex = Array.from(buttons).findIndex(
|
||||
|
||||
@@ -53,7 +53,7 @@ type DataTableFacetedFilterProps<TData, TValue> = {
|
||||
singleSelect?: boolean
|
||||
}
|
||||
|
||||
export function DataTableFacetedFilter<TData, TValue>({
|
||||
function DataTableFacetedFilterInner<TData, TValue>({
|
||||
column,
|
||||
title,
|
||||
options,
|
||||
@@ -64,6 +64,18 @@ export function DataTableFacetedFilter<TData, TValue>({
|
||||
const filterValue = column?.getFilterValue() as string[] | undefined
|
||||
const selectedValues = new Set(filterValue)
|
||||
|
||||
const handleOptionSelect = (optionValue: string) => {
|
||||
const nextSelectedValues = getNextSelectedValues(
|
||||
selectedValues,
|
||||
optionValue,
|
||||
singleSelect
|
||||
)
|
||||
|
||||
column?.setFilterValue(
|
||||
nextSelectedValues.length ? nextSelectedValues : undefined
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
@@ -118,29 +130,7 @@ export function DataTableFacetedFilter<TData, TValue>({
|
||||
return (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
onSelect={() => {
|
||||
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
|
||||
)
|
||||
}
|
||||
}}
|
||||
onSelect={() => handleOptionSelect(option.value)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
@@ -197,3 +187,26 @@ export function DataTableFacetedFilter<TData, TValue>({
|
||||
</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)
|
||||
}
|
||||
|
||||
+47
-59
@@ -19,9 +19,9 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import * as React from 'react'
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
import { useDebounce } from '@/hooks'
|
||||
import { ChevronDown, Loader2, X as Cross2Icon } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDebounce } from '@/hooks'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -41,6 +41,11 @@ type FilterDef = {
|
||||
singleSelect?: boolean
|
||||
}
|
||||
|
||||
type SearchDraft = {
|
||||
baseValue: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export type DataTableToolbarProps<TData> = {
|
||||
table: Table<TData>
|
||||
/**
|
||||
@@ -141,8 +146,7 @@ export type DataTableToolbarProps<TData> = {
|
||||
export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
|
||||
const { t } = useTranslation()
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const isSearchComposingRef = React.useRef(false)
|
||||
const lastCommittedSearchValueRef = React.useRef('')
|
||||
const [isSearchComposing, setIsSearchComposing] = useState(false)
|
||||
|
||||
const filters = props.filters ?? []
|
||||
const hasExpandable = props.expandable != null
|
||||
@@ -159,31 +163,22 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
|
||||
'')
|
||||
: ((props.table.getState().globalFilter as string | undefined) ?? '')
|
||||
|
||||
const [searchValue, setSearchValue] = useState(currentSearchValue)
|
||||
const [pendingSearchValue, setPendingSearchValue] =
|
||||
useState(currentSearchValue)
|
||||
const [searchDraft, setSearchDraft] = useState<SearchDraft | null>(null)
|
||||
const activeSearchDraft =
|
||||
searchDraft &&
|
||||
(isSearchComposing || searchDraft.baseValue === currentSearchValue)
|
||||
? searchDraft
|
||||
: null
|
||||
const searchValue = activeSearchDraft?.value ?? currentSearchValue
|
||||
const searchDebounceMs = Math.max(0, props.searchDebounceMs ?? 0)
|
||||
const debouncedSearchValue = useDebounce(
|
||||
pendingSearchValue,
|
||||
searchDebounceMs
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
lastCommittedSearchValueRef.current = currentSearchValue
|
||||
if (!isSearchComposingRef.current) {
|
||||
setSearchValue(currentSearchValue)
|
||||
}
|
||||
setPendingSearchValue(currentSearchValue)
|
||||
}, [currentSearchValue])
|
||||
const debouncedSearchValue = useDebounce(searchValue, searchDebounceMs)
|
||||
|
||||
const commitSearchValue = React.useCallback(
|
||||
(value: string) => {
|
||||
if (value === lastCommittedSearchValueRef.current) {
|
||||
if (value === currentSearchValue) {
|
||||
return
|
||||
}
|
||||
|
||||
lastCommittedSearchValueRef.current = value
|
||||
|
||||
if (props.searchKey) {
|
||||
props.table.getColumn(props.searchKey)?.setFilterValue(value)
|
||||
return
|
||||
@@ -191,14 +186,14 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
|
||||
|
||||
props.table.setGlobalFilter(value)
|
||||
},
|
||||
[props.searchKey, props.table]
|
||||
[currentSearchValue, props.searchKey, props.table]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
searchDebounceMs <= 0 ||
|
||||
isSearchComposingRef.current ||
|
||||
debouncedSearchValue !== pendingSearchValue
|
||||
isSearchComposing ||
|
||||
debouncedSearchValue !== searchValue
|
||||
) {
|
||||
return
|
||||
}
|
||||
@@ -207,13 +202,12 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
|
||||
}, [
|
||||
commitSearchValue,
|
||||
debouncedSearchValue,
|
||||
pendingSearchValue,
|
||||
isSearchComposing,
|
||||
searchDebounceMs,
|
||||
searchValue,
|
||||
])
|
||||
|
||||
const queueSearchValue = (value: string) => {
|
||||
setPendingSearchValue(value)
|
||||
|
||||
if (searchDebounceMs <= 0) {
|
||||
commitSearchValue(value)
|
||||
}
|
||||
@@ -221,36 +215,27 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
|
||||
|
||||
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.target.value
|
||||
setSearchValue(value)
|
||||
setSearchDraft({ baseValue: currentSearchValue, value })
|
||||
|
||||
if (!isSearchComposingRef.current) {
|
||||
if (!isSearchComposing) {
|
||||
queueSearchValue(value)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearchCompositionStart = () => {
|
||||
isSearchComposingRef.current = true
|
||||
setIsSearchComposing(true)
|
||||
}
|
||||
|
||||
const handleSearchCompositionEnd = (
|
||||
event: React.CompositionEvent<HTMLInputElement>
|
||||
) => {
|
||||
isSearchComposingRef.current = false
|
||||
setIsSearchComposing(false)
|
||||
const value = event.currentTarget.value
|
||||
setSearchValue(value)
|
||||
setSearchDraft({ baseValue: currentSearchValue, value })
|
||||
queueSearchValue(value)
|
||||
}
|
||||
|
||||
const searchInput = props.searchKey ? (
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
value={searchValue}
|
||||
onChange={handleSearchChange}
|
||||
onCompositionStart={handleSearchCompositionStart}
|
||||
onCompositionEnd={handleSearchCompositionEnd}
|
||||
className='w-full sm:w-[200px] lg:w-[240px]'
|
||||
/>
|
||||
) : (
|
||||
const searchInput = (
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
value={searchValue}
|
||||
@@ -261,25 +246,28 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
|
||||
/>
|
||||
)
|
||||
|
||||
const filterChips = filters.map((filter) => {
|
||||
const column = props.table.getColumn(filter.columnId)
|
||||
if (!column) return null
|
||||
return (
|
||||
<DataTableFacetedFilter
|
||||
key={filter.columnId}
|
||||
column={column}
|
||||
title={filter.title}
|
||||
options={filter.options}
|
||||
singleSelect={filter.singleSelect}
|
||||
/>
|
||||
)
|
||||
})
|
||||
const filterChips = React.useMemo(
|
||||
() =>
|
||||
filters.map((filter) => {
|
||||
const column = props.table.getColumn(filter.columnId)
|
||||
if (!column) return null
|
||||
return (
|
||||
<DataTableFacetedFilter
|
||||
key={filter.columnId}
|
||||
column={column}
|
||||
title={filter.title}
|
||||
options={filter.options}
|
||||
singleSelect={filter.singleSelect}
|
||||
/>
|
||||
)
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[props.filters, props.table]
|
||||
)
|
||||
|
||||
const handleReset = () => {
|
||||
isSearchComposingRef.current = false
|
||||
setSearchValue('')
|
||||
setPendingSearchValue('')
|
||||
lastCommittedSearchValueRef.current = ''
|
||||
setIsSearchComposing(false)
|
||||
setSearchDraft(null)
|
||||
props.table.resetColumnFilters()
|
||||
props.table.setGlobalFilter('')
|
||||
props.onReset?.()
|
||||
|
||||
+26
-17
@@ -16,6 +16,7 @@ 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 { type Table } from '@tanstack/react-table'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -36,6 +37,18 @@ export function DataTableViewOptions<TData>({
|
||||
table,
|
||||
}: DataTableViewOptionsProps<TData>) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const hideableColumns = React.useMemo(
|
||||
() =>
|
||||
table
|
||||
.getAllColumns()
|
||||
.filter(
|
||||
(column) =>
|
||||
typeof column.accessorFn !== 'undefined' && column.getCanHide()
|
||||
),
|
||||
[table]
|
||||
)
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
@@ -52,24 +65,20 @@ export function DataTableViewOptions<TData>({
|
||||
<DropdownMenuContent align='end' className='w-[150px]'>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>{t('Toggle columns')}</DropdownMenuLabel>
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter(
|
||||
(column) =>
|
||||
typeof column.accessorFn !== 'undefined' && column.getCanHide()
|
||||
{hideableColumns.map((column) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
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>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
+8
-2
@@ -36,9 +36,15 @@ export function ProviderBadge({
|
||||
const icon = iconKey ? getLobeIcon(iconKey, iconSize) : null
|
||||
|
||||
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}
|
||||
<StatusBadge label={label} autoColor={label} size='sm' {...badgeProps} />
|
||||
<StatusBadge
|
||||
label={label}
|
||||
autoColor={label}
|
||||
size='sm'
|
||||
className={!icon ? 'pl-0' : undefined}
|
||||
{...badgeProps}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+32
-4
@@ -22,7 +22,6 @@ import { type LucideIcon } from 'lucide-react'
|
||||
import { stringToColor } from '@/lib/colors'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
|
||||
export const dotColorMap = {
|
||||
success: 'bg-success',
|
||||
warning: 'bg-warning',
|
||||
@@ -73,12 +72,29 @@ export const textColorMap = {
|
||||
|
||||
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 = {
|
||||
sm: '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',
|
||||
} 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<
|
||||
React.HTMLAttributes<HTMLSpanElement>,
|
||||
'children'
|
||||
@@ -94,6 +110,8 @@ export interface StatusBadgeProps extends Omit<
|
||||
copyable?: boolean
|
||||
copyText?: string
|
||||
autoColor?: string
|
||||
/** Visual style. Defaults to 'badge'. Can be overridden via StatusBadgeTypeContext. */
|
||||
type?: StatusBadgeType
|
||||
}
|
||||
|
||||
export function StatusBadge({
|
||||
@@ -107,11 +125,14 @@ export function StatusBadge({
|
||||
copyable = true,
|
||||
copyText,
|
||||
autoColor,
|
||||
type: typeProp,
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: StatusBadgeProps) {
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const contextType = React.useContext(StatusBadgeTypeContext)
|
||||
const type = typeProp ?? contextType
|
||||
|
||||
const computedVariant: StatusVariant = autoColor
|
||||
? (stringToColor(autoColor) as StatusVariant)
|
||||
@@ -126,14 +147,21 @@ export function StatusBadge({
|
||||
}
|
||||
|
||||
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 (
|
||||
<span
|
||||
data-slot='status-badge'
|
||||
className={cn(
|
||||
'inline-flex w-fit max-w-full shrink-0 items-center rounded-4xl font-medium tracking-normal whitespace-nowrap transition-colors',
|
||||
sizeMap[size ?? 'sm'],
|
||||
'inline-flex w-fit max-w-full shrink-0 items-center font-medium tracking-normal whitespace-nowrap transition-colors',
|
||||
isBadge
|
||||
? cn('rounded-4xl', sizeMap[size ?? 'sm'])
|
||||
: cn(textSizeMap[size ?? 'sm'], type === 'underline' && 'border-b border-current pb-px'),
|
||||
textColorMap[computedVariant],
|
||||
pulse && 'animate-pulse',
|
||||
copyable &&
|
||||
|
||||
+2
-2
@@ -77,7 +77,7 @@ function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
|
||||
<tr
|
||||
data-slot='table-row'
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
@@ -103,7 +103,7 @@ function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
|
||||
<td
|
||||
data-slot='table-cell'
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
|
||||
+44
-100
@@ -45,10 +45,10 @@ import {
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { DataTableColumnHeader } from '@/components/data-table'
|
||||
import { BadgeListCell } from '@/components/data-table'
|
||||
import { GroupBadge } from '@/components/group-badge'
|
||||
import { ProviderBadge } from '@/components/provider-badge'
|
||||
import { StatusBadge, StatusBadgeList } from '@/components/status-badge'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { TableId } from '@/components/table-id'
|
||||
import { TruncatedText } from '@/components/truncated-text'
|
||||
import { getCodexUsage } from '../api'
|
||||
@@ -98,22 +98,6 @@ function parseIonetMeta(otherInfo: string | null | undefined): null | {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Render limited items with "and X more" indicator
|
||||
*/
|
||||
function renderLimitedItems(
|
||||
items: React.ReactNode[],
|
||||
maxDisplay: number = 2
|
||||
): React.ReactNode {
|
||||
return (
|
||||
<StatusBadgeList
|
||||
items={items}
|
||||
max={maxDisplay}
|
||||
renderItem={(item) => item}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Upstream update tags (+N / -N) shown on channel name for model-fetchable channels
|
||||
*/
|
||||
@@ -314,6 +298,7 @@ function BalanceCell({ channel }: { channel: Channel }) {
|
||||
size='sm'
|
||||
copyable={false}
|
||||
showDot={false}
|
||||
className='-ml-1.5'
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -349,7 +334,7 @@ function BalanceCell({ channel }: { channel: Channel }) {
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className='flex items-center gap-1'>
|
||||
<div className='-ml-1.5 flex items-center gap-1'>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
@@ -476,10 +461,8 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
// ID column
|
||||
{
|
||||
accessorKey: 'id',
|
||||
meta: { label: t('ID'), mobileHidden: true },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title='ID' />
|
||||
),
|
||||
header: t('ID'),
|
||||
meta: { mobileHidden: true },
|
||||
cell: ({ row }) => {
|
||||
const id = row.getValue('id') as number
|
||||
return <TableId value={id} />
|
||||
@@ -490,10 +473,8 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
// Name column
|
||||
{
|
||||
accessorKey: 'name',
|
||||
meta: { label: t('Name'), mobileTitle: true },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Name')} />
|
||||
),
|
||||
header: t('Name'),
|
||||
meta: { mobileTitle: true },
|
||||
cell: ({ row }) => {
|
||||
const isTagRow = isTagAggregateRow(row.original)
|
||||
const name = row.getValue('name') as string
|
||||
@@ -603,7 +584,6 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
// Type column
|
||||
{
|
||||
accessorKey: 'type',
|
||||
meta: { label: t('Type') },
|
||||
header: t('Type'),
|
||||
cell: ({ row }) => {
|
||||
const isTagRow = isTagAggregateRow(row.original)
|
||||
@@ -615,6 +595,7 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
variant='blue'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
className='-ml-1.5'
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -718,8 +699,8 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
// Status column
|
||||
{
|
||||
accessorKey: 'status',
|
||||
meta: { label: t('Status'), mobileBadge: true },
|
||||
header: t('Status'),
|
||||
meta: { mobileBadge: true },
|
||||
cell: ({ row }) => {
|
||||
const isTagRow = isTagAggregateRow(row.original)
|
||||
const status = row.getValue('status') as number
|
||||
@@ -737,6 +718,7 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
variant='success'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
className='-ml-1.5'
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
@@ -746,6 +728,7 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
variant='neutral'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
className='-ml-1.5'
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -823,6 +806,7 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
variant={config.variant}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
className='-ml-1.5'
|
||||
/>
|
||||
)
|
||||
},
|
||||
@@ -840,42 +824,23 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
// Models column
|
||||
{
|
||||
accessorKey: 'models',
|
||||
meta: { label: t('Models'), mobileHidden: true },
|
||||
header: t('Models'),
|
||||
meta: { mobileHidden: true },
|
||||
cell: ({ row }) => {
|
||||
const models = row.getValue('models') as string
|
||||
const modelArray = parseModelsList(models)
|
||||
|
||||
if (modelArray.length === 0) {
|
||||
return <span className='text-muted-foreground text-xs'>-</span>
|
||||
}
|
||||
|
||||
const modelBadges = modelArray.map((model, idx) => (
|
||||
<StatusBadge
|
||||
key={idx}
|
||||
label={model}
|
||||
autoColor={model}
|
||||
size='sm'
|
||||
className='font-mono'
|
||||
/>
|
||||
))
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<div />}>
|
||||
{renderLimitedItems(modelBadges, 2)}
|
||||
</TooltipTrigger>
|
||||
{modelArray.length > 2 && (
|
||||
<TooltipContent
|
||||
side='top'
|
||||
className='border-border bg-popover max-h-48 max-w-[320px] overflow-y-auto p-2'
|
||||
>
|
||||
<div className='flex flex-wrap gap-1'>{modelBadges}</div>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<BadgeListCell
|
||||
items={modelArray.map((model, idx) => (
|
||||
<StatusBadge
|
||||
key={idx}
|
||||
label={model}
|
||||
autoColor={model}
|
||||
size='sm'
|
||||
className='font-mono'
|
||||
/>
|
||||
))}
|
||||
/>
|
||||
)
|
||||
},
|
||||
size: 200,
|
||||
@@ -885,32 +850,17 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
// Group column
|
||||
{
|
||||
accessorKey: 'group',
|
||||
meta: { label: t('Groups'), mobileHidden: true },
|
||||
header: t('Groups'),
|
||||
meta: { mobileHidden: true },
|
||||
cell: ({ row }) => {
|
||||
const group = row.getValue('group') as string
|
||||
const groupArray = parseGroupsList(group)
|
||||
|
||||
const groupBadges = groupArray.map((g) => (
|
||||
<GroupBadge key={g} group={g} size='sm' />
|
||||
))
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<div />}>
|
||||
{renderLimitedItems(groupBadges, 2)}
|
||||
</TooltipTrigger>
|
||||
{groupArray.length > 2 && (
|
||||
<TooltipContent
|
||||
side='top'
|
||||
className='border-border bg-popover max-h-48 max-w-[320px] overflow-y-auto p-2'
|
||||
>
|
||||
<div className='flex flex-wrap gap-1'>{groupBadges}</div>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<BadgeListCell
|
||||
items={groupArray.map((g) => (
|
||||
<GroupBadge key={g} group={g} size='sm' />
|
||||
))}
|
||||
/>
|
||||
)
|
||||
},
|
||||
filterFn: (row, id, value) => {
|
||||
@@ -926,14 +876,14 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
// Tag column
|
||||
{
|
||||
accessorKey: 'tag',
|
||||
meta: { label: t('Tag'), mobileHidden: true },
|
||||
header: t('Tag'),
|
||||
meta: { mobileHidden: true },
|
||||
cell: ({ row }) => {
|
||||
const tag = row.getValue('tag') as string | null
|
||||
if (!tag)
|
||||
return <span className='text-muted-foreground text-xs'>-</span>
|
||||
|
||||
return <StatusBadge label={tag} autoColor={tag} size='sm' />
|
||||
return <StatusBadge label={tag} autoColor={tag} size='sm' className='-ml-1.5' />
|
||||
},
|
||||
size: 120,
|
||||
enableSorting: false,
|
||||
@@ -942,10 +892,8 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
// Priority column
|
||||
{
|
||||
accessorKey: 'priority',
|
||||
meta: { label: t('Priority'), mobileHidden: true },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Priority')} />
|
||||
),
|
||||
header: t('Priority'),
|
||||
meta: { mobileHidden: true },
|
||||
cell: ({ row }) => <PriorityCell channel={row.original} />,
|
||||
size: 100,
|
||||
},
|
||||
@@ -953,8 +901,8 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
// Weight column
|
||||
{
|
||||
accessorKey: 'weight',
|
||||
meta: { label: t('Weight'), mobileHidden: true },
|
||||
header: t('Weight'),
|
||||
meta: { mobileHidden: true },
|
||||
cell: ({ row }) => <WeightCell channel={row.original} />,
|
||||
size: 90,
|
||||
enableSorting: false,
|
||||
@@ -963,10 +911,7 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
// Balance column (Used/Remaining)
|
||||
{
|
||||
accessorKey: 'balance',
|
||||
meta: { label: t('Used / Remaining') },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Used / Remaining')} />
|
||||
),
|
||||
header: t('Used / Remaining'),
|
||||
cell: ({ row }) => <BalanceCell channel={row.original} />,
|
||||
size: 180,
|
||||
},
|
||||
@@ -974,10 +919,8 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
// Response Time column
|
||||
{
|
||||
accessorKey: 'response_time',
|
||||
meta: { label: t('Response'), mobileHidden: true },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Response')} />
|
||||
),
|
||||
header: t('Response'),
|
||||
meta: { mobileHidden: true },
|
||||
cell: ({ row }) => {
|
||||
const responseTime = row.getValue('response_time') as number
|
||||
const config = getResponseTimeConfig(responseTime)
|
||||
@@ -988,6 +931,7 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
variant={config.variant}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
className='-ml-1.5'
|
||||
/>
|
||||
)
|
||||
},
|
||||
@@ -997,10 +941,8 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
// Test Time column
|
||||
{
|
||||
accessorKey: 'test_time',
|
||||
meta: { label: t('Last Tested'), mobileHidden: true },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Last Tested')} />
|
||||
),
|
||||
header: t('Last Tested'),
|
||||
meta: { mobileHidden: true },
|
||||
cell: ({ row }) => {
|
||||
const testTime = row.getValue('test_time') as number
|
||||
|
||||
@@ -1037,6 +979,7 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
// Actions column
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => t('Actions'),
|
||||
cell: ({ row }) => {
|
||||
// Check if this is a tag row (has children)
|
||||
const isTagRow = isTagAggregateRow(row.original)
|
||||
@@ -1055,6 +998,7 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
size: 132,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
meta: { pinned: 'right' as const },
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex items-center justify-end gap-1'>
|
||||
<div className='-ml-1.5 flex items-center gap-1'>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
|
||||
+22
-40
@@ -29,7 +29,6 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { DataTableColumnHeader } from '@/components/data-table'
|
||||
import { GroupBadge } from '@/components/group-badge'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { API_KEY_STATUSES } from '../constants'
|
||||
@@ -93,26 +92,21 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
size: 40,
|
||||
meta: { label: t('Select') },
|
||||
},
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Name')} />
|
||||
),
|
||||
header: t('Name'),
|
||||
cell: ({ row }) => (
|
||||
<div className='max-w-[200px] truncate font-medium'>
|
||||
{row.getValue('name')}
|
||||
</div>
|
||||
),
|
||||
size: 180,
|
||||
meta: { label: t('Name'), mobileTitle: true },
|
||||
meta: { mobileTitle: true },
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Status')} />
|
||||
),
|
||||
header: t('Status'),
|
||||
cell: ({ row }) => {
|
||||
const statusConfig = API_KEY_STATUSES[row.getValue('status') as number]
|
||||
if (!statusConfig) return null
|
||||
@@ -121,12 +115,13 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
||||
label={t(statusConfig.label)}
|
||||
variant={statusConfig.variant}
|
||||
copyable={false}
|
||||
className='-ml-1.5'
|
||||
/>
|
||||
)
|
||||
},
|
||||
filterFn: (row, id, value) => value.includes(String(row.getValue(id))),
|
||||
size: 120,
|
||||
meta: { label: t('Status'), mobileBadge: true },
|
||||
meta: { mobileBadge: true },
|
||||
},
|
||||
{
|
||||
id: 'key',
|
||||
@@ -135,14 +130,11 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
||||
cell: ({ row }) => <ApiKeyCell apiKey={row.original} />,
|
||||
enableSorting: false,
|
||||
size: 260,
|
||||
meta: { label: t('API Key') },
|
||||
},
|
||||
{
|
||||
id: 'quota',
|
||||
accessorKey: 'remain_quota',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Quota')} />
|
||||
),
|
||||
header: t('Quota'),
|
||||
cell: ({ row }) => {
|
||||
const apiKey = row.original
|
||||
if (apiKey.unlimited_quota) {
|
||||
@@ -151,6 +143,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
||||
label={t('Unlimited')}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
className='-ml-1.5'
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -194,13 +187,10 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
||||
)
|
||||
},
|
||||
size: 170,
|
||||
meta: { label: t('Quota') },
|
||||
},
|
||||
{
|
||||
accessorKey: 'group',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Group')} />
|
||||
),
|
||||
header: t('Group'),
|
||||
cell: ({ row }) => {
|
||||
const apiKey = row.original
|
||||
const group = row.getValue('group') as string
|
||||
@@ -236,48 +226,40 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
||||
return <GroupBadge group={group} ratio={ratio} />
|
||||
},
|
||||
size: 160,
|
||||
meta: { label: t('Group'), mobileHidden: true },
|
||||
meta: { mobileHidden: true },
|
||||
},
|
||||
{
|
||||
id: 'model_limits',
|
||||
accessorKey: 'model_limits',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Models')} />
|
||||
),
|
||||
header: t('Models'),
|
||||
cell: ({ row }) => <ModelLimitsCell apiKey={row.original} />,
|
||||
enableSorting: false,
|
||||
size: 160,
|
||||
meta: { label: t('Models'), mobileHidden: true },
|
||||
meta: { mobileHidden: true },
|
||||
},
|
||||
{
|
||||
id: 'allow_ips',
|
||||
accessorKey: 'allow_ips',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('IP Restriction')} />
|
||||
),
|
||||
header: t('IP Restriction'),
|
||||
cell: ({ row }) => <IpRestrictionsCell apiKey={row.original} />,
|
||||
enableSorting: false,
|
||||
size: 160,
|
||||
meta: { label: t('IP Restriction'), mobileHidden: true },
|
||||
meta: { mobileHidden: true },
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_time',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Created')} />
|
||||
),
|
||||
header: t('Created'),
|
||||
cell: ({ row }) => (
|
||||
<span className='text-muted-foreground block truncate font-mono text-xs tabular-nums'>
|
||||
{formatTimestampToDate(row.getValue('created_time'))}
|
||||
</span>
|
||||
),
|
||||
size: 180,
|
||||
meta: { label: t('Created'), mobileHidden: true },
|
||||
meta: { mobileHidden: true },
|
||||
},
|
||||
{
|
||||
accessorKey: 'accessed_time',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Last Used')} />
|
||||
),
|
||||
header: t('Last Used'),
|
||||
cell: ({ row }) => {
|
||||
const accessedTime = row.getValue('accessed_time') as number
|
||||
if (!accessedTime) {
|
||||
@@ -290,13 +272,11 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
||||
)
|
||||
},
|
||||
size: 180,
|
||||
meta: { label: t('Last Used'), mobileHidden: true },
|
||||
meta: { mobileHidden: true },
|
||||
},
|
||||
{
|
||||
accessorKey: 'expired_time',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Expires')} />
|
||||
),
|
||||
header: t('Expires'),
|
||||
cell: ({ row }) => {
|
||||
const expiredTime = row.getValue('expired_time') as number
|
||||
if (expiredTime === -1) {
|
||||
@@ -305,6 +285,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
||||
label={t('Never')}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
className='-ml-1.5'
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -321,12 +302,13 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
||||
)
|
||||
},
|
||||
size: 180,
|
||||
meta: { label: t('Expires'), mobileHidden: true },
|
||||
meta: { mobileHidden: true },
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => t('Actions'),
|
||||
cell: ({ row }) => <DataTableRowActions row={row} />,
|
||||
meta: { label: t('Actions') },
|
||||
meta: { pinned: 'right' as const },
|
||||
size: 88,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -190,7 +190,7 @@ export function DataTableRowActions<TData>({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex items-center justify-end gap-1'>
|
||||
<div className='-ml-1.5 flex items-center gap-1'>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
|
||||
@@ -62,7 +62,8 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<div className='-ml-2'>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
@@ -134,5 +135,6 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
}}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ import { Eye, Info, Pencil, Settings2, Timer, Trash2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { formatTimestampToDate } from '@/lib/format'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DataTableColumnHeader } from '@/components/data-table'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { TableId } from '@/components/table-id'
|
||||
import { getDeploymentStatusConfig } from '../constants'
|
||||
@@ -45,10 +44,8 @@ export function useDeploymentsColumns(opts: {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'id',
|
||||
meta: { label: t('ID'), mobileHidden: true },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('ID')} />
|
||||
),
|
||||
header: t('ID'),
|
||||
meta: { mobileHidden: true },
|
||||
cell: ({ row }) => {
|
||||
const id = row.original.id
|
||||
return <TableId value={id} />
|
||||
@@ -59,10 +56,8 @@ export function useDeploymentsColumns(opts: {
|
||||
id: 'name',
|
||||
accessorFn: (row) =>
|
||||
row.container_name || row.deployment_name || row.name || '',
|
||||
meta: { label: t('Name'), mobileTitle: true },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Name')} />
|
||||
),
|
||||
header: t('Name'),
|
||||
meta: { mobileTitle: true },
|
||||
cell: ({ getValue }) => {
|
||||
const name = String(getValue() || '-') || '-'
|
||||
return (
|
||||
@@ -71,7 +66,7 @@ export function useDeploymentsColumns(opts: {
|
||||
variant='neutral'
|
||||
copyText={name}
|
||||
size='sm'
|
||||
className='font-mono'
|
||||
className='-ml-1.5 font-mono'
|
||||
/>
|
||||
)
|
||||
},
|
||||
@@ -79,8 +74,8 @@ export function useDeploymentsColumns(opts: {
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
meta: { label: t('Status'), mobileBadge: true },
|
||||
header: t('Status'),
|
||||
meta: { mobileBadge: true },
|
||||
cell: ({ row }) => {
|
||||
const raw = row.original.status
|
||||
const key = normalizeDeploymentStatus(raw)
|
||||
@@ -95,6 +90,7 @@ export function useDeploymentsColumns(opts: {
|
||||
variant={config.variant}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
className='-ml-1.5'
|
||||
/>
|
||||
)
|
||||
},
|
||||
@@ -114,7 +110,6 @@ export function useDeploymentsColumns(opts: {
|
||||
},
|
||||
{
|
||||
accessorKey: 'provider',
|
||||
meta: { label: t('Provider') },
|
||||
header: t('Provider'),
|
||||
cell: ({ row }) => {
|
||||
const provider = row.original.provider
|
||||
@@ -126,6 +121,7 @@ export function useDeploymentsColumns(opts: {
|
||||
autoColor={String(provider)}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
className='-ml-1.5'
|
||||
/>
|
||||
)
|
||||
},
|
||||
@@ -134,7 +130,6 @@ export function useDeploymentsColumns(opts: {
|
||||
},
|
||||
{
|
||||
accessorKey: 'time_remaining',
|
||||
meta: { label: t('Time remaining') },
|
||||
header: t('Time remaining'),
|
||||
cell: ({ row }) => {
|
||||
const status = normalizeDeploymentStatus(row.original.status)
|
||||
@@ -185,8 +180,8 @@ export function useDeploymentsColumns(opts: {
|
||||
},
|
||||
{
|
||||
id: 'hardware',
|
||||
meta: { label: t('Hardware'), mobileHidden: true },
|
||||
header: t('Hardware'),
|
||||
meta: { mobileHidden: true },
|
||||
accessorFn: (row) =>
|
||||
row.hardware_info || row.hardware_name || row.brand_name || '',
|
||||
cell: ({ row }) => {
|
||||
@@ -220,10 +215,8 @@ export function useDeploymentsColumns(opts: {
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
meta: { label: t('Created'), mobileHidden: true },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Created')} />
|
||||
),
|
||||
header: t('Created'),
|
||||
meta: { mobileHidden: true },
|
||||
cell: ({ row }) => {
|
||||
const ts =
|
||||
typeof row.original.created_at === 'number'
|
||||
@@ -241,6 +234,7 @@ export function useDeploymentsColumns(opts: {
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => t('Actions'),
|
||||
enableHiding: false,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
@@ -252,7 +246,7 @@ export function useDeploymentsColumns(opts: {
|
||||
''
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-1'>
|
||||
<div className='-ml-2.5 flex items-center gap-1'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
@@ -305,6 +299,7 @@ export function useDeploymentsColumns(opts: {
|
||||
)
|
||||
},
|
||||
size: 180,
|
||||
meta: { pinned: 'right' as const },
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
+73
-193
@@ -27,10 +27,10 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { DataTableColumnHeader } from '@/components/data-table'
|
||||
import { BadgeListCell } from '@/components/data-table'
|
||||
import { GroupBadge } from '@/components/group-badge'
|
||||
import { ProviderBadge } from '@/components/provider-badge'
|
||||
import { StatusBadge, StatusBadgeList } from '@/components/status-badge'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { TableId } from '@/components/table-id'
|
||||
import {
|
||||
getModelStatusConfig,
|
||||
@@ -48,22 +48,6 @@ function getCompactModelIcon(iconKey: string) {
|
||||
return getLobeIcon(`${baseIconKey}.Avatar.type={'platform'}`, 20)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render limited items with "and X more" indicator
|
||||
*/
|
||||
function renderLimitedItems(
|
||||
items: React.ReactNode[],
|
||||
maxDisplay: number = 2
|
||||
): React.ReactNode {
|
||||
return (
|
||||
<StatusBadgeList
|
||||
items={items}
|
||||
max={maxDisplay}
|
||||
renderItem={(item) => item}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate models columns configuration
|
||||
*/
|
||||
@@ -107,10 +91,8 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
|
||||
// ID column
|
||||
{
|
||||
accessorKey: 'id',
|
||||
meta: { label: t('ID'), mobileHidden: true },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title='ID' />
|
||||
),
|
||||
header: t('ID'),
|
||||
meta: { mobileHidden: true },
|
||||
cell: ({ row }) => {
|
||||
const id = row.getValue('id') as number
|
||||
return <TableId value={id} />
|
||||
@@ -121,8 +103,8 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
|
||||
// Icon column
|
||||
{
|
||||
accessorKey: 'icon',
|
||||
meta: { label: t('Icon'), mobileHidden: true },
|
||||
header: t('Icon'),
|
||||
meta: { mobileHidden: true },
|
||||
cell: ({ row }) => {
|
||||
const model = row.original
|
||||
const iconKey =
|
||||
@@ -145,10 +127,8 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
|
||||
// Model Name column
|
||||
{
|
||||
accessorKey: 'model_name',
|
||||
meta: { label: t('Model Name'), mobileTitle: true },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Model Name')} />
|
||||
),
|
||||
header: t('Model Name'),
|
||||
meta: { mobileTitle: true },
|
||||
cell: ({ row }) => {
|
||||
const name = row.getValue('model_name') as string
|
||||
return (
|
||||
@@ -157,7 +137,7 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
|
||||
variant='neutral'
|
||||
copyText={name}
|
||||
size='sm'
|
||||
className='font-mono'
|
||||
className='-ml-1.5 font-mono'
|
||||
/>
|
||||
)
|
||||
},
|
||||
@@ -167,10 +147,7 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
|
||||
// Name Rule column
|
||||
{
|
||||
accessorKey: 'name_rule',
|
||||
meta: { label: t('Match Type') },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Match Type')} />
|
||||
),
|
||||
header: t('Match Type'),
|
||||
cell: ({ row }) => {
|
||||
const rule = row.getValue('name_rule') as 0 | 1 | 2 | 3
|
||||
const model = row.original
|
||||
@@ -193,6 +170,7 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
|
||||
| 'info'
|
||||
}
|
||||
size='sm'
|
||||
className='-ml-1.5'
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -209,7 +187,7 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<div />}>{badge}</TooltipTrigger>
|
||||
<TooltipTrigger render={<div className='-ml-1.5' />}>{badge}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side='top'
|
||||
className='border-border bg-popover max-h-48 max-w-[320px] overflow-y-auto p-2'
|
||||
@@ -230,8 +208,8 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
|
||||
// Status column
|
||||
{
|
||||
accessorKey: 'status',
|
||||
meta: { label: t('Status'), mobileBadge: true },
|
||||
header: t('Status'),
|
||||
meta: { mobileBadge: true },
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue('status') as number
|
||||
const config =
|
||||
@@ -243,6 +221,7 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
|
||||
variant={config.variant}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
className='-ml-1.5'
|
||||
/>
|
||||
)
|
||||
},
|
||||
@@ -260,7 +239,6 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
|
||||
// Vendor column
|
||||
{
|
||||
accessorKey: 'vendor_id',
|
||||
meta: { label: t('Vendor') },
|
||||
header: t('Vendor'),
|
||||
cell: ({ row }) => {
|
||||
const vendorId = row.getValue('vendor_id') as number
|
||||
@@ -283,8 +261,8 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
|
||||
// Description column
|
||||
{
|
||||
accessorKey: 'description',
|
||||
meta: { label: t('Description'), mobileHidden: true },
|
||||
header: t('Description'),
|
||||
meta: { mobileHidden: true },
|
||||
cell: ({ row }) => {
|
||||
const description = row.getValue('description') as string
|
||||
const modelName = row.getValue('model_name') as string
|
||||
@@ -300,36 +278,17 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
|
||||
// Tags column
|
||||
{
|
||||
accessorKey: 'tags',
|
||||
meta: { label: t('Tags'), mobileHidden: true },
|
||||
header: t('Tags'),
|
||||
meta: { mobileHidden: true },
|
||||
cell: ({ row }) => {
|
||||
const tags = row.getValue('tags') as string
|
||||
const tagArray = parseModelTags(tags)
|
||||
|
||||
if (tagArray.length === 0) {
|
||||
return <span className='text-muted-foreground text-xs'>-</span>
|
||||
}
|
||||
|
||||
const tagBadges = tagArray.map((tag, idx) => (
|
||||
<StatusBadge key={idx} label={tag} autoColor={tag} size='sm' />
|
||||
))
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<div />}>
|
||||
{renderLimitedItems(tagBadges, 2)}
|
||||
</TooltipTrigger>
|
||||
{tagArray.length > 2 && (
|
||||
<TooltipContent
|
||||
side='top'
|
||||
className='border-border bg-popover max-h-48 max-w-[320px] overflow-y-auto p-2'
|
||||
>
|
||||
<div className='flex flex-wrap gap-1'>{tagBadges}</div>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<BadgeListCell
|
||||
items={tagArray.map((tag, idx) => (
|
||||
<StatusBadge key={idx} label={tag} autoColor={tag} size='sm' />
|
||||
))}
|
||||
/>
|
||||
)
|
||||
},
|
||||
size: 150,
|
||||
@@ -339,36 +298,17 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
|
||||
// Endpoints column
|
||||
{
|
||||
accessorKey: 'endpoints',
|
||||
meta: { label: t('Endpoints'), mobileHidden: true },
|
||||
header: t('Endpoints'),
|
||||
meta: { mobileHidden: true },
|
||||
cell: ({ row }) => {
|
||||
const endpoints = row.getValue('endpoints') as string
|
||||
const endpointArray = formatEndpointsDisplay(endpoints)
|
||||
|
||||
if (endpointArray.length === 0) {
|
||||
return <span className='text-muted-foreground text-xs'>-</span>
|
||||
}
|
||||
|
||||
const endpointBadges = endpointArray.map((ep, idx) => (
|
||||
<StatusBadge key={idx} label={ep} autoColor={ep} size='sm' />
|
||||
))
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<div />}>
|
||||
{renderLimitedItems(endpointBadges, 2)}
|
||||
</TooltipTrigger>
|
||||
{endpointArray.length > 2 && (
|
||||
<TooltipContent
|
||||
side='top'
|
||||
className='border-border bg-popover max-h-48 max-w-[320px] overflow-y-auto p-2'
|
||||
>
|
||||
<div className='flex flex-wrap gap-1'>{endpointBadges}</div>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<BadgeListCell
|
||||
items={endpointArray.map((ep, idx) => (
|
||||
<StatusBadge key={idx} label={ep} autoColor={ep} size='sm' />
|
||||
))}
|
||||
/>
|
||||
)
|
||||
},
|
||||
size: 150,
|
||||
@@ -378,8 +318,8 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
|
||||
// Bound Channels column
|
||||
{
|
||||
accessorKey: 'bound_channels',
|
||||
meta: { label: t('Bound Channels'), mobileHidden: true },
|
||||
header: t('Bound Channels'),
|
||||
meta: { mobileHidden: true },
|
||||
cell: ({ row }) => {
|
||||
const channels = row.getValue('bound_channels') as Array<{
|
||||
id: number
|
||||
@@ -387,36 +327,17 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
|
||||
type?: number
|
||||
status?: number
|
||||
}>
|
||||
|
||||
if (!channels || channels.length === 0) {
|
||||
return <span className='text-muted-foreground text-xs'>-</span>
|
||||
}
|
||||
|
||||
const channelBadges = channels.map((c, idx) => (
|
||||
<StatusBadge
|
||||
key={idx}
|
||||
label={`${c.name} (${c.type})`}
|
||||
autoColor={c.name}
|
||||
size='sm'
|
||||
/>
|
||||
))
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<div />}>
|
||||
{renderLimitedItems(channelBadges, 2)}
|
||||
</TooltipTrigger>
|
||||
{channels.length > 2 && (
|
||||
<TooltipContent
|
||||
side='top'
|
||||
className='border-border bg-popover max-h-48 max-w-[320px] overflow-y-auto p-2'
|
||||
>
|
||||
<div className='flex flex-wrap gap-1'>{channelBadges}</div>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<BadgeListCell
|
||||
items={(channels ?? []).map((c, idx) => (
|
||||
<StatusBadge
|
||||
key={idx}
|
||||
label={`${c.name} (${c.type})`}
|
||||
autoColor={c.name}
|
||||
size='sm'
|
||||
/>
|
||||
))}
|
||||
/>
|
||||
)
|
||||
},
|
||||
size: 150,
|
||||
@@ -426,37 +347,16 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
|
||||
// Enable Groups column
|
||||
{
|
||||
accessorKey: 'enable_groups',
|
||||
meta: { label: t('Enable Groups'), mobileHidden: true },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Enable Groups')} />
|
||||
),
|
||||
header: t('Enable Groups'),
|
||||
meta: { mobileHidden: true },
|
||||
cell: ({ row }) => {
|
||||
const groups = row.getValue('enable_groups') as string[]
|
||||
|
||||
if (!groups || groups.length === 0) {
|
||||
return <span className='text-muted-foreground text-xs'>-</span>
|
||||
}
|
||||
|
||||
const groupBadges = groups.map((g) => (
|
||||
<GroupBadge key={g} group={g} size='sm' />
|
||||
))
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<div />}>
|
||||
{renderLimitedItems(groupBadges, 2)}
|
||||
</TooltipTrigger>
|
||||
{groups.length > 2 && (
|
||||
<TooltipContent
|
||||
side='top'
|
||||
className='border-border bg-popover max-h-48 max-w-[320px] overflow-y-auto p-2'
|
||||
>
|
||||
<div className='flex flex-wrap gap-1'>{groupBadges}</div>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<BadgeListCell
|
||||
items={(groups ?? []).map((g) => (
|
||||
<GroupBadge key={g} group={g} size='sm' />
|
||||
))}
|
||||
/>
|
||||
)
|
||||
},
|
||||
size: 150,
|
||||
@@ -466,50 +366,31 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
|
||||
// Quota Types column
|
||||
{
|
||||
accessorKey: 'quota_types',
|
||||
meta: { label: t('Quota Types'), mobileHidden: true },
|
||||
header: t('Quota Types'),
|
||||
meta: { mobileHidden: true },
|
||||
cell: ({ row }) => {
|
||||
const quotaTypes = row.getValue('quota_types') as number[]
|
||||
|
||||
if (!quotaTypes || quotaTypes.length === 0) {
|
||||
return <span className='text-muted-foreground text-xs'>-</span>
|
||||
}
|
||||
|
||||
const quotaBadges = quotaTypes.map((qt, idx) => {
|
||||
const config = QUOTA_TYPE_CONFIG[qt]
|
||||
return (
|
||||
<StatusBadge
|
||||
key={idx}
|
||||
label={config?.label || String(qt)}
|
||||
variant={
|
||||
(config?.color === 'error' ? 'danger' : config?.color) as
|
||||
| 'neutral'
|
||||
| 'success'
|
||||
| 'warning'
|
||||
| 'danger'
|
||||
| 'info'
|
||||
}
|
||||
size='sm'
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<div />}>
|
||||
{renderLimitedItems(quotaBadges, 2)}
|
||||
</TooltipTrigger>
|
||||
{quotaTypes.length > 2 && (
|
||||
<TooltipContent
|
||||
side='top'
|
||||
className='border-border bg-popover max-h-48 max-w-[320px] overflow-y-auto p-2'
|
||||
>
|
||||
<div className='flex flex-wrap gap-1'>{quotaBadges}</div>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<BadgeListCell
|
||||
items={(quotaTypes ?? []).map((qt, idx) => {
|
||||
const config = QUOTA_TYPE_CONFIG[qt]
|
||||
return (
|
||||
<StatusBadge
|
||||
key={idx}
|
||||
label={config?.label || String(qt)}
|
||||
variant={
|
||||
(config?.color === 'error' ? 'danger' : config?.color) as
|
||||
| 'neutral'
|
||||
| 'success'
|
||||
| 'warning'
|
||||
| 'danger'
|
||||
| 'info'
|
||||
}
|
||||
size='sm'
|
||||
/>
|
||||
)
|
||||
})}
|
||||
/>
|
||||
)
|
||||
},
|
||||
size: 150,
|
||||
@@ -519,8 +400,8 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
|
||||
// Sync Official column
|
||||
{
|
||||
accessorKey: 'sync_official',
|
||||
meta: { label: t('Official Sync'), mobileHidden: true },
|
||||
header: t('Official Sync'),
|
||||
meta: { mobileHidden: true },
|
||||
cell: ({ row }) => {
|
||||
const syncOfficial = row.getValue('sync_official') as number
|
||||
return (
|
||||
@@ -529,6 +410,7 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
|
||||
variant={syncOfficial === 1 ? 'success' : 'warning'}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
className='-ml-1.5'
|
||||
/>
|
||||
)
|
||||
},
|
||||
@@ -546,10 +428,8 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
|
||||
// Created Time column
|
||||
{
|
||||
accessorKey: 'created_time',
|
||||
meta: { label: t('Created'), mobileHidden: true },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Created')} />
|
||||
),
|
||||
header: t('Created'),
|
||||
meta: { mobileHidden: true },
|
||||
cell: ({ row }) => {
|
||||
const timestamp = row.getValue('created_time') as number
|
||||
return (
|
||||
@@ -564,10 +444,8 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
|
||||
// Updated Time column
|
||||
{
|
||||
accessorKey: 'updated_time',
|
||||
meta: { label: t('Updated'), mobileHidden: true },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Updated')} />
|
||||
),
|
||||
header: t('Updated'),
|
||||
meta: { mobileHidden: true },
|
||||
cell: ({ row }) => {
|
||||
const timestamp = row.getValue('updated_time') as number
|
||||
return (
|
||||
@@ -582,12 +460,14 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
|
||||
// Actions column
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => t('Actions'),
|
||||
cell: ({ row }) => {
|
||||
return <DataTableRowActions row={row} />
|
||||
},
|
||||
size: 100,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
meta: { pinned: 'right' as const },
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
+31
-96
@@ -19,15 +19,9 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import { type ColumnDef } from '@tanstack/react-table'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getLobeIcon } from '@/lib/lobe-icon'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { DataTableColumnHeader } from '@/components/data-table'
|
||||
import { DataTableColumnHeader, BadgeListCell } from '@/components/data-table'
|
||||
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 {
|
||||
getDynamicDisplayGroupRatio,
|
||||
@@ -53,36 +47,6 @@ export interface PricingColumnsOptions {
|
||||
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(
|
||||
options: PricingColumnsOptions = {}
|
||||
): ColumnDef<PricingModel>[] {
|
||||
@@ -124,7 +88,6 @@ export function usePricingColumns(
|
||||
// Type column
|
||||
{
|
||||
accessorKey: 'quota_type',
|
||||
meta: { label: t('Type') },
|
||||
header: t('Type'),
|
||||
cell: ({ row }) => {
|
||||
const isTokenBased = row.original.quota_type === QUOTA_TYPE_VALUES.TOKEN
|
||||
@@ -133,6 +96,7 @@ export function usePricingColumns(
|
||||
label={isTokenBased ? t('Token') : t('Request')}
|
||||
variant={isTokenBased ? 'info' : 'neutral'}
|
||||
copyable={false}
|
||||
className='-ml-1.5'
|
||||
/>
|
||||
)
|
||||
},
|
||||
@@ -269,7 +233,6 @@ export function usePricingColumns(
|
||||
// Cached price column (Vercel AI Gateway style)
|
||||
{
|
||||
id: 'cached_price',
|
||||
meta: { label: t('Cached') },
|
||||
header: t('Cached'),
|
||||
cell: ({ row }) => {
|
||||
const model = row.original
|
||||
@@ -344,7 +307,6 @@ export function usePricingColumns(
|
||||
// Vendor column
|
||||
{
|
||||
accessorKey: 'vendor_name',
|
||||
meta: { label: t('Vendor') },
|
||||
header: t('Vendor'),
|
||||
cell: ({ row }) => {
|
||||
const model = row.original
|
||||
@@ -373,27 +335,21 @@ export function usePricingColumns(
|
||||
// Tags column
|
||||
{
|
||||
accessorKey: 'tags',
|
||||
meta: { label: t('Tags') },
|
||||
header: t('Tags'),
|
||||
cell: ({ row }) => {
|
||||
const tags = parseTags(row.original.tags)
|
||||
if (tags.length === 0) {
|
||||
return <span className='text-muted-foreground/50 text-xs'>—</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<div />}>
|
||||
{renderLimitedTags(tags, 2)}
|
||||
</TooltipTrigger>
|
||||
{tags.length > 2 && (
|
||||
<TooltipContent side='top' className='max-w-[280px] p-2'>
|
||||
<span className='text-xs'>{tags.join(', ')}</span>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<BadgeListCell
|
||||
items={tags.map((tag) => (
|
||||
<StatusBadge
|
||||
key={tag}
|
||||
label={tag}
|
||||
autoColor={tag}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
))}
|
||||
/>
|
||||
)
|
||||
},
|
||||
size: 140,
|
||||
@@ -403,27 +359,21 @@ export function usePricingColumns(
|
||||
// Endpoints column
|
||||
{
|
||||
accessorKey: 'supported_endpoint_types',
|
||||
meta: { label: t('Endpoints') },
|
||||
header: t('Endpoints'),
|
||||
cell: ({ row }) => {
|
||||
const endpoints = row.original.supported_endpoint_types || []
|
||||
if (endpoints.length === 0) {
|
||||
return <span className='text-muted-foreground/50 text-xs'>—</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<div />}>
|
||||
{renderLimitedTags(endpoints, 2)}
|
||||
</TooltipTrigger>
|
||||
{endpoints.length > 2 && (
|
||||
<TooltipContent side='top' className='max-w-[280px] p-2'>
|
||||
<span className='text-xs'>{endpoints.join(', ')}</span>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<BadgeListCell
|
||||
items={endpoints.map((ep) => (
|
||||
<StatusBadge
|
||||
key={ep}
|
||||
label={ep}
|
||||
autoColor={ep}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
))}
|
||||
/>
|
||||
)
|
||||
},
|
||||
size: 130,
|
||||
@@ -433,31 +383,16 @@ export function usePricingColumns(
|
||||
// Enable Groups column
|
||||
{
|
||||
accessorKey: 'enable_groups',
|
||||
meta: { label: t('Groups') },
|
||||
header: t('Groups'),
|
||||
cell: ({ row }) => {
|
||||
const groups = row.original.enable_groups || []
|
||||
if (groups.length === 0) {
|
||||
return <span className='text-muted-foreground/50 text-xs'>—</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<div />}>
|
||||
{renderLimitedGroupBadges(groups, 2)}
|
||||
</TooltipTrigger>
|
||||
{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>
|
||||
<BadgeListCell
|
||||
items={groups.map((group) => (
|
||||
<GroupBadge key={group} group={group} size='sm' />
|
||||
))}
|
||||
tooltipClassName='max-w-[280px] p-2'
|
||||
/>
|
||||
)
|
||||
},
|
||||
size: 130,
|
||||
|
||||
+3
-1
@@ -77,7 +77,8 @@ export function DataTableRowActions<TData>({
|
||||
const canToggle = !isUsed && !isExpired
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<div className='-ml-2'>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
@@ -136,5 +137,6 @@ export function DataTableRowActions<TData>({
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+20
-34
@@ -25,7 +25,6 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { DataTableColumnHeader } from '@/components/data-table'
|
||||
import { MaskedValueDisplay } from '@/components/masked-value-display'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { TableId } from '@/components/table-id'
|
||||
@@ -39,7 +38,6 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
|
||||
return [
|
||||
{
|
||||
id: 'select',
|
||||
meta: { label: t('Select') },
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
@@ -63,10 +61,8 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
|
||||
},
|
||||
{
|
||||
accessorKey: 'id',
|
||||
meta: { label: t('ID'), mobileHidden: true },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('ID')} />
|
||||
),
|
||||
header: t('ID'),
|
||||
meta: { mobileHidden: true },
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<TableId value={row.getValue('id') as number} className='w-[60px]' />
|
||||
@@ -76,10 +72,8 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
|
||||
},
|
||||
{
|
||||
accessorKey: 'name',
|
||||
meta: { label: t('Name'), mobileTitle: true },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Name')} />
|
||||
),
|
||||
header: t('Name'),
|
||||
meta: { mobileTitle: true },
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className='max-w-[150px] truncate font-medium'>
|
||||
@@ -91,10 +85,8 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
meta: { label: t('Status'), mobileBadge: true },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Status')} />
|
||||
),
|
||||
header: t('Status'),
|
||||
meta: { mobileBadge: true },
|
||||
cell: ({ row }) => {
|
||||
const redemption = row.original
|
||||
const statusValue = row.getValue('status') as number
|
||||
@@ -106,6 +98,7 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
|
||||
label={t('Expired')}
|
||||
variant='warning'
|
||||
copyable={false}
|
||||
className='-ml-1.5'
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -121,6 +114,7 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
|
||||
label={t(statusConfig.labelKey)}
|
||||
variant={statusConfig.variant}
|
||||
copyable={false}
|
||||
className='-ml-1.5'
|
||||
/>
|
||||
)
|
||||
},
|
||||
@@ -143,10 +137,7 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
|
||||
{
|
||||
id: 'code',
|
||||
accessorKey: 'key',
|
||||
meta: { label: t('Code') },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Code')} />
|
||||
),
|
||||
header: t('Code'),
|
||||
cell: function CodeCell({ row }) {
|
||||
const redemption = row.original
|
||||
const key = redemption.key
|
||||
@@ -167,10 +158,7 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
|
||||
},
|
||||
{
|
||||
accessorKey: 'quota',
|
||||
meta: { label: t('Quota') },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Quota')} />
|
||||
),
|
||||
header: t('Quota'),
|
||||
cell: ({ row }) => {
|
||||
const quota = row.getValue('quota') as number
|
||||
return (
|
||||
@@ -178,6 +166,7 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
|
||||
label={formatQuota(quota)}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
className='-ml-1.5'
|
||||
/>
|
||||
)
|
||||
},
|
||||
@@ -185,10 +174,8 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_time',
|
||||
meta: { label: t('Created'), mobileHidden: true },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Created')} />
|
||||
),
|
||||
header: t('Created'),
|
||||
meta: { mobileHidden: true },
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className='min-w-[160px] font-mono text-sm'>
|
||||
@@ -200,10 +187,8 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
|
||||
},
|
||||
{
|
||||
accessorKey: 'expired_time',
|
||||
meta: { label: t('Expires'), mobileHidden: true },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Expires')} />
|
||||
),
|
||||
header: t('Expires'),
|
||||
meta: { mobileHidden: true },
|
||||
cell: ({ row }) => {
|
||||
const expiredTime = row.getValue('expired_time') as number
|
||||
if (expiredTime === 0) {
|
||||
@@ -212,6 +197,7 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
|
||||
label={t('Never')}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
className='-ml-1.5'
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -228,10 +214,8 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
|
||||
},
|
||||
{
|
||||
accessorKey: 'used_user_id',
|
||||
meta: { label: t('Redeemed By'), mobileHidden: true },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Redeemed By')} />
|
||||
),
|
||||
header: t('Redeemed By'),
|
||||
meta: { mobileHidden: true },
|
||||
cell: ({ row }) => {
|
||||
const userId = row.getValue('used_user_id') as number
|
||||
const redemption = row.original
|
||||
@@ -272,7 +256,9 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => t('Actions'),
|
||||
cell: ({ row }) => <DataTableRowActions row={row} />,
|
||||
meta: { pinned: 'right' as const },
|
||||
size: 88,
|
||||
},
|
||||
]
|
||||
|
||||
+3
-1
@@ -38,7 +38,8 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
const { setOpen, setCurrentRow, complianceConfirmed } = useSubscriptions()
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<div className='-ml-2'>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={<Button variant='ghost' className='h-8 w-8 p-0' />}
|
||||
>
|
||||
@@ -76,5 +77,6 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+22
-41
@@ -20,7 +20,6 @@ import { useMemo } from 'react'
|
||||
import { type ColumnDef } from '@tanstack/react-table'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { formatQuota } from '@/lib/format'
|
||||
import { DataTableColumnHeader } from '@/components/data-table'
|
||||
import { GroupBadge } from '@/components/group-badge'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { TableId } from '@/components/table-id'
|
||||
@@ -36,20 +35,16 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] {
|
||||
{
|
||||
accessorFn: (row) => row.plan.id,
|
||||
id: 'id',
|
||||
meta: { label: t('ID'), mobileHidden: true },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('ID')} />
|
||||
),
|
||||
header: t('ID'),
|
||||
meta: { mobileHidden: true },
|
||||
cell: ({ row }) => <TableId value={row.original.plan.id} />,
|
||||
size: 60,
|
||||
},
|
||||
{
|
||||
accessorFn: (row) => row.plan.title,
|
||||
id: 'title',
|
||||
meta: { label: t('Plan'), mobileTitle: true },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Plan')} />
|
||||
),
|
||||
header: t('Plan'),
|
||||
meta: { mobileTitle: true },
|
||||
cell: ({ row }) => {
|
||||
const plan = row.original.plan
|
||||
return (
|
||||
@@ -68,10 +63,7 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] {
|
||||
{
|
||||
accessorFn: (row) => row.plan.price_amount,
|
||||
id: 'price',
|
||||
meta: { label: t('Price') },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Price')} />
|
||||
),
|
||||
header: t('Price'),
|
||||
cell: ({ row }) => (
|
||||
<span className='font-semibold text-emerald-600'>
|
||||
${Number(row.original.plan.price_amount || 0).toFixed(2)}
|
||||
@@ -81,10 +73,7 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] {
|
||||
},
|
||||
{
|
||||
id: 'duration',
|
||||
meta: { label: t('Validity') },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Validity')} />
|
||||
),
|
||||
header: t('Validity'),
|
||||
cell: ({ row }) => (
|
||||
<span className='text-muted-foreground'>
|
||||
{formatDuration(row.original.plan, t)}
|
||||
@@ -94,10 +83,8 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] {
|
||||
},
|
||||
{
|
||||
id: 'reset',
|
||||
meta: { label: t('Quota Reset'), mobileHidden: true },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Quota Reset')} />
|
||||
),
|
||||
header: t('Quota Reset'),
|
||||
meta: { mobileHidden: true },
|
||||
cell: ({ row }) => (
|
||||
<span className='text-muted-foreground'>
|
||||
{formatResetPeriod(row.original.plan, t)}
|
||||
@@ -108,10 +95,8 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] {
|
||||
{
|
||||
accessorFn: (row) => row.plan.sort_order,
|
||||
id: 'sort_order',
|
||||
meta: { label: t('Priority'), mobileHidden: true },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Priority')} />
|
||||
),
|
||||
header: t('Priority'),
|
||||
meta: { mobileHidden: true },
|
||||
cell: ({ row }) => (
|
||||
<span className='text-muted-foreground'>
|
||||
{row.original.plan.sort_order}
|
||||
@@ -122,32 +107,30 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] {
|
||||
{
|
||||
accessorFn: (row) => row.plan.enabled,
|
||||
id: 'enabled',
|
||||
meta: { label: t('Status'), mobileBadge: true },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Status')} />
|
||||
),
|
||||
header: t('Status'),
|
||||
meta: { mobileBadge: true },
|
||||
cell: ({ row }) =>
|
||||
row.original.plan.enabled ? (
|
||||
<StatusBadge
|
||||
label={t('Enable')}
|
||||
variant='success'
|
||||
copyable={false}
|
||||
className='-ml-1.5'
|
||||
/>
|
||||
) : (
|
||||
<StatusBadge
|
||||
label={t('Disable')}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
className='-ml-1.5'
|
||||
/>
|
||||
),
|
||||
size: 80,
|
||||
},
|
||||
{
|
||||
id: 'payment',
|
||||
meta: { label: t('Payment Channel'), mobileHidden: true },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Payment Channel')} />
|
||||
),
|
||||
header: t('Payment Channel'),
|
||||
meta: { mobileHidden: true },
|
||||
cell: ({ row }) => {
|
||||
const plan = row.original.plan
|
||||
return (
|
||||
@@ -176,10 +159,8 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] {
|
||||
},
|
||||
{
|
||||
id: 'total_amount',
|
||||
meta: { label: t('Received amount'), mobileHidden: true },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Received amount')} />
|
||||
),
|
||||
header: t('Received amount'),
|
||||
meta: { mobileHidden: true },
|
||||
cell: ({ row }) => {
|
||||
const total = Number(row.original.plan.total_amount || 0)
|
||||
return (
|
||||
@@ -192,10 +173,8 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] {
|
||||
},
|
||||
{
|
||||
id: 'upgrade_group',
|
||||
meta: { label: t('Upgrade Group'), mobileHidden: true },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Upgrade Group')} />
|
||||
),
|
||||
header: t('Upgrade Group'),
|
||||
meta: { mobileHidden: true },
|
||||
cell: ({ row }) => {
|
||||
const group = row.original.plan.upgrade_group
|
||||
if (!group) {
|
||||
@@ -209,7 +188,9 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] {
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => t('Actions'),
|
||||
cell: ({ row }) => <DataTableRowActions row={row} />,
|
||||
meta: { pinned: 'right' as const },
|
||||
size: 80,
|
||||
},
|
||||
],
|
||||
|
||||
+1
-2
@@ -72,7 +72,6 @@ export function buildModelRatioColumns({
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
size: 40,
|
||||
meta: { label: t('Select') },
|
||||
},
|
||||
{
|
||||
accessorKey: 'name',
|
||||
@@ -113,7 +112,7 @@ export function buildModelRatioColumns({
|
||||
variant={getModeVariant(row.original.billingMode)}
|
||||
copyable={false}
|
||||
showDot={false}
|
||||
className='px-0'
|
||||
className='-ml-1.5 px-0'
|
||||
/>
|
||||
),
|
||||
filterFn: (row, id, value) =>
|
||||
|
||||
+9
-36
@@ -35,7 +35,6 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { DataTableColumnHeader } from '@/components/data-table'
|
||||
import { StatusBadge, type StatusBadgeProps } from '@/components/status-badge'
|
||||
import { LOG_TYPE_ALL_VALUE } from '../../constants'
|
||||
import type { UsageLog } from '../../data/schema'
|
||||
@@ -265,9 +264,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
const columns: ColumnDef<UsageLog>[] = [
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Time')} />
|
||||
),
|
||||
header: t('Time'),
|
||||
cell: ({ row }) => {
|
||||
const log = row.original
|
||||
const timestamp = row.getValue('created_at') as number
|
||||
@@ -295,7 +292,6 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
},
|
||||
enableHiding: false,
|
||||
size: 180,
|
||||
meta: { label: t('Time') },
|
||||
},
|
||||
]
|
||||
|
||||
@@ -303,10 +299,8 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
columns.push(
|
||||
{
|
||||
id: 'channel',
|
||||
header: t('Channel'),
|
||||
accessorFn: (row) => row.channel,
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Channel')} />
|
||||
),
|
||||
cell: function ChannelCell({ row }) {
|
||||
const { sensitiveVisible, setAffinityTarget, setAffinityDialogOpen } =
|
||||
useUsageLogsContext()
|
||||
@@ -404,14 +398,11 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
</TooltipProvider>
|
||||
)
|
||||
},
|
||||
meta: { label: t('Channel') },
|
||||
},
|
||||
{
|
||||
id: 'user',
|
||||
header: t('User'),
|
||||
accessorFn: (row) => row.username,
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('User')} />
|
||||
),
|
||||
cell: function UserCell({ row }) {
|
||||
const { sensitiveVisible, setSelectedUserId, setUserInfoDialogOpen } =
|
||||
useUsageLogsContext()
|
||||
@@ -461,16 +452,13 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
</button>
|
||||
)
|
||||
},
|
||||
meta: { label: t('User') },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
columns.push({
|
||||
accessorKey: 'token_name',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Token')} />
|
||||
),
|
||||
header: t('Token'),
|
||||
cell: function TokenNameCell({ row }) {
|
||||
const { sensitiveVisible } = useUsageLogsContext()
|
||||
const log = row.original
|
||||
@@ -520,16 +508,12 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { label: t('Token') },
|
||||
size: 160,
|
||||
})
|
||||
|
||||
columns.push(
|
||||
{
|
||||
accessorKey: 'model_name',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Model')} />
|
||||
),
|
||||
header: t('Model'),
|
||||
cell: function ModelCell({ row }) {
|
||||
const log = row.original
|
||||
if (!isDisplayableLogType(log.type)) return null
|
||||
@@ -545,14 +529,11 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { label: t('Model'), mobileTitle: true },
|
||||
meta: { mobileTitle: true },
|
||||
},
|
||||
|
||||
{
|
||||
accessorKey: 'use_time',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Timing')} />
|
||||
),
|
||||
header: t('Timing'),
|
||||
cell: ({ row }) => {
|
||||
const log = row.original
|
||||
if (!isTimingLogType(log.type)) return null
|
||||
@@ -656,14 +637,11 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { label: t('Timing') },
|
||||
},
|
||||
|
||||
{
|
||||
accessorKey: 'prompt_tokens',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title='Tokens' />
|
||||
),
|
||||
header: 'Tokens',
|
||||
cell: ({ row }) => {
|
||||
const log = row.original
|
||||
if (!isDisplayableLogType(log.type)) return null
|
||||
@@ -707,14 +685,11 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { label: 'Tokens' },
|
||||
},
|
||||
|
||||
{
|
||||
accessorKey: 'quota',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Cost')} />
|
||||
),
|
||||
header: t('Cost'),
|
||||
cell: ({ row }) => {
|
||||
const log = row.original
|
||||
if (!isDisplayableLogType(log.type)) return null
|
||||
@@ -762,7 +737,6 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { label: t('Cost') },
|
||||
},
|
||||
|
||||
{
|
||||
@@ -820,7 +794,6 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
</>
|
||||
)
|
||||
},
|
||||
meta: { label: t('Details') },
|
||||
size: 180,
|
||||
maxSize: 200,
|
||||
}
|
||||
|
||||
+9
-25
@@ -38,7 +38,6 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { formatTimestampToDate } from '@/lib/format'
|
||||
import { DataTableColumnHeader } from '@/components/data-table'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { MJ_TASK_TYPES } from '../../constants'
|
||||
import {
|
||||
@@ -87,9 +86,7 @@ export function useDrawingLogsColumns(
|
||||
const columns: ColumnDef<MidjourneyLog>[] = [
|
||||
{
|
||||
accessorKey: 'submit_time',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Submit Time')} />
|
||||
),
|
||||
header: t('Submit Time'),
|
||||
cell: ({ row }) => {
|
||||
const log = row.original
|
||||
const submitTime = row.getValue('submit_time') as number
|
||||
@@ -109,7 +106,6 @@ export function useDrawingLogsColumns(
|
||||
)
|
||||
},
|
||||
size: 180,
|
||||
meta: { label: t('Submit Time') },
|
||||
},
|
||||
]
|
||||
|
||||
@@ -121,9 +117,7 @@ export function useDrawingLogsColumns(
|
||||
|
||||
columns.push({
|
||||
accessorKey: 'action',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Type')} />
|
||||
),
|
||||
header: t('Type'),
|
||||
cell: ({ row }) => {
|
||||
const action = row.getValue('action') as string
|
||||
return (
|
||||
@@ -133,17 +127,15 @@ export function useDrawingLogsColumns(
|
||||
icon={getDrawingTypeIcon(action)}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
className='-ml-1.5'
|
||||
/>
|
||||
)
|
||||
},
|
||||
meta: { label: t('Type') },
|
||||
})
|
||||
|
||||
columns.push({
|
||||
accessorKey: 'mj_id',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Task ID')} />
|
||||
),
|
||||
header: t('Task ID'),
|
||||
cell: ({ row }) => {
|
||||
const mjId = row.getValue('mj_id') as string
|
||||
|
||||
@@ -162,7 +154,7 @@ export function useDrawingLogsColumns(
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { label: t('Task ID'), mobileTitle: true },
|
||||
meta: { mobileTitle: true },
|
||||
})
|
||||
|
||||
columns.push(
|
||||
@@ -176,9 +168,7 @@ export function useDrawingLogsColumns(
|
||||
if (isAdmin) {
|
||||
columns.push({
|
||||
accessorKey: 'code',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Submit Result')} />
|
||||
),
|
||||
header: t('Submit Result'),
|
||||
cell: ({ row }) => {
|
||||
const code = row.getValue('code') as number
|
||||
|
||||
@@ -188,10 +178,10 @@ export function useDrawingLogsColumns(
|
||||
variant={mjSubmitResultMapper.getVariant(String(code))}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
className='-ml-1.5'
|
||||
/>
|
||||
)
|
||||
},
|
||||
meta: { label: t('Submit Result') },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -199,9 +189,7 @@ export function useDrawingLogsColumns(
|
||||
createProgressColumn<MidjourneyLog>({ headerLabel: t('Progress') }),
|
||||
{
|
||||
accessorKey: 'image_url',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Image')} />
|
||||
),
|
||||
header: t('Image'),
|
||||
cell: function ImageCell({ row }) {
|
||||
const log = row.original
|
||||
const imageUrl = row.getValue('image_url') as string
|
||||
@@ -232,13 +220,10 @@ export function useDrawingLogsColumns(
|
||||
</>
|
||||
)
|
||||
},
|
||||
meta: { label: t('Image') },
|
||||
},
|
||||
{
|
||||
accessorKey: 'prompt',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Prompt')} />
|
||||
),
|
||||
header: t('Prompt'),
|
||||
cell: function PromptCell({ row }) {
|
||||
const log = row.original
|
||||
const prompt = row.getValue('prompt') as string
|
||||
@@ -269,7 +254,6 @@ export function useDrawingLogsColumns(
|
||||
</>
|
||||
)
|
||||
},
|
||||
meta: { label: t('Prompt') },
|
||||
size: 200,
|
||||
maxSize: 220,
|
||||
},
|
||||
|
||||
+7
-21
@@ -25,7 +25,6 @@ import { getUserAvatarFallback, getUserAvatarStyle } from '@/lib/avatar'
|
||||
import { formatTimestampToDate } from '@/lib/format'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||
import { DataTableColumnHeader } from '@/components/data-table'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { TASK_ACTIONS, TASK_STATUS } from '../../constants'
|
||||
import { taskActionMapper, taskStatusMapper } from '../../lib/mappers'
|
||||
@@ -94,9 +93,7 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
|
||||
const columns: ColumnDef<TaskLog>[] = [
|
||||
{
|
||||
accessorKey: 'submit_time',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Submit Time')} />
|
||||
),
|
||||
header: t('Submit Time'),
|
||||
cell: ({ row }) => {
|
||||
const log = row.original
|
||||
const submitTime = row.getValue('submit_time') as number
|
||||
@@ -117,17 +114,14 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
|
||||
)
|
||||
},
|
||||
size: 180,
|
||||
meta: { label: t('Submit Time') },
|
||||
},
|
||||
]
|
||||
|
||||
if (isAdmin) {
|
||||
columns.push(createChannelColumn<TaskLog>({ headerLabel: t('Channel') }), {
|
||||
id: 'user',
|
||||
header: t('User'),
|
||||
accessorFn: (row) => row.username || row.user_id,
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('User')} />
|
||||
),
|
||||
cell: function UserCell({ row }) {
|
||||
const { sensitiveVisible, setSelectedUserId, setUserInfoDialogOpen } =
|
||||
useUsageLogsContext()
|
||||
@@ -163,16 +157,13 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
|
||||
</button>
|
||||
)
|
||||
},
|
||||
meta: { label: t('User') },
|
||||
})
|
||||
}
|
||||
|
||||
columns.push(
|
||||
{
|
||||
accessorKey: 'task_id',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Task ID')} />
|
||||
),
|
||||
header: t('Task ID'),
|
||||
cell: ({ row }) => {
|
||||
const log = row.original
|
||||
const taskId = row.getValue('task_id') as string
|
||||
@@ -193,7 +184,7 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { label: t('Task ID'), mobileTitle: true },
|
||||
meta: { mobileTitle: true },
|
||||
},
|
||||
createDurationColumn<TaskLog>({
|
||||
submitTimeKey: 'submit_time',
|
||||
@@ -204,9 +195,7 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
|
||||
}),
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Status')} />
|
||||
),
|
||||
header: t('Status'),
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue('status') as string
|
||||
return (
|
||||
@@ -215,17 +204,15 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
|
||||
variant={taskStatusMapper.getVariant(status)}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
className='-ml-1.5'
|
||||
/>
|
||||
)
|
||||
},
|
||||
meta: { label: t('Status') },
|
||||
},
|
||||
createProgressColumn<TaskLog>({ headerLabel: t('Progress') }),
|
||||
{
|
||||
accessorKey: 'fail_reason',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Details')} />
|
||||
),
|
||||
header: t('Details'),
|
||||
cell: function DetailsCell({ row }) {
|
||||
const log = row.original
|
||||
const failReason = row.getValue('fail_reason') as string
|
||||
@@ -295,7 +282,6 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
|
||||
</>
|
||||
)
|
||||
},
|
||||
meta: { label: t('Details') },
|
||||
size: 200,
|
||||
maxSize: 220,
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='-ml-2'>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
@@ -294,6 +294,6 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
user={{ id: user.id, username: user.username }}
|
||||
onSuccess={triggerRefresh}
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+19
-40
@@ -27,7 +27,6 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { DataTableColumnHeader } from '@/components/data-table'
|
||||
import { GroupBadge } from '@/components/group-badge'
|
||||
import { LongText } from '@/components/long-text'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
@@ -67,26 +66,21 @@ export function useUsersColumns(): ColumnDef<User>[] {
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
size: 40,
|
||||
meta: { label: t('Select') },
|
||||
},
|
||||
{
|
||||
accessorKey: 'id',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title='ID' />
|
||||
),
|
||||
header: t('ID'),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<TableId value={row.getValue('id') as number} className='w-[60px]' />
|
||||
)
|
||||
},
|
||||
size: 80,
|
||||
meta: { label: t('ID'), mobileHidden: true },
|
||||
meta: { mobileHidden: true },
|
||||
},
|
||||
{
|
||||
accessorKey: 'username',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Username')} />
|
||||
),
|
||||
header: t('Username'),
|
||||
cell: ({ row }) => {
|
||||
const username = row.getValue('username') as string
|
||||
const displayName = row.original.display_name
|
||||
@@ -121,13 +115,11 @@ export function useUsersColumns(): ColumnDef<User>[] {
|
||||
},
|
||||
enableHiding: false,
|
||||
size: 220,
|
||||
meta: { label: t('Username'), mobileTitle: true },
|
||||
meta: { mobileTitle: true },
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Status')} />
|
||||
),
|
||||
header: t('Status'),
|
||||
cell: ({ row }) => {
|
||||
const user = row.original
|
||||
const requestCount = user.request_count
|
||||
@@ -142,7 +134,7 @@ export function useUsersColumns(): ColumnDef<User>[] {
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<div className='cursor-help' />}>
|
||||
<TooltipTrigger render={<div className='-ml-1.5 cursor-help' />}>
|
||||
<StatusBadge
|
||||
label={t(statusConfig.labelKey)}
|
||||
variant={statusConfig.variant}
|
||||
@@ -162,14 +154,12 @@ export function useUsersColumns(): ColumnDef<User>[] {
|
||||
},
|
||||
enableSorting: false,
|
||||
size: 120,
|
||||
meta: { label: t('Status'), mobileBadge: true },
|
||||
meta: { mobileBadge: true },
|
||||
},
|
||||
{
|
||||
id: 'quota',
|
||||
accessorKey: 'quota',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Quota')} />
|
||||
),
|
||||
header: t('Quota'),
|
||||
cell: ({ row }) => {
|
||||
const user = row.original
|
||||
const used = user.used_quota
|
||||
@@ -183,6 +173,7 @@ export function useUsersColumns(): ColumnDef<User>[] {
|
||||
label={t('No Quota')}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
className='-ml-1.5'
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -225,13 +216,10 @@ export function useUsersColumns(): ColumnDef<User>[] {
|
||||
)
|
||||
},
|
||||
size: 170,
|
||||
meta: { label: t('Quota') },
|
||||
},
|
||||
{
|
||||
accessorKey: 'group',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Group')} />
|
||||
),
|
||||
header: t('Group'),
|
||||
cell: ({ row }) => {
|
||||
const group = row.getValue('group') as string
|
||||
return <GroupBadge group={group} />
|
||||
@@ -242,13 +230,10 @@ export function useUsersColumns(): ColumnDef<User>[] {
|
||||
return group.includes(searchValue)
|
||||
},
|
||||
size: 140,
|
||||
meta: { label: t('Group') },
|
||||
},
|
||||
{
|
||||
accessorKey: 'role',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Role')} />
|
||||
),
|
||||
header: t('Role'),
|
||||
cell: ({ row }) => {
|
||||
const roleValue = row.getValue('role') as number
|
||||
const roleConfig = USER_ROLES[roleValue as keyof typeof USER_ROLES]
|
||||
@@ -271,13 +256,10 @@ export function useUsersColumns(): ColumnDef<User>[] {
|
||||
},
|
||||
enableSorting: false,
|
||||
size: 120,
|
||||
meta: { label: t('Role') },
|
||||
},
|
||||
{
|
||||
id: 'invite_info',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Invite Info')} />
|
||||
),
|
||||
header: t('Invite Info'),
|
||||
cell: ({ row }) => {
|
||||
const user = row.original
|
||||
const affCount = user.aff_count || 0
|
||||
@@ -347,13 +329,11 @@ export function useUsersColumns(): ColumnDef<User>[] {
|
||||
},
|
||||
size: 240,
|
||||
enableSorting: false,
|
||||
meta: { label: t('Invite Info'), mobileHidden: true },
|
||||
meta: { mobileHidden: true },
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Created At')} />
|
||||
),
|
||||
header: t('Created At'),
|
||||
cell: ({ row }) => {
|
||||
const ts = row.getValue('created_at') as number | undefined
|
||||
return (
|
||||
@@ -363,13 +343,11 @@ export function useUsersColumns(): ColumnDef<User>[] {
|
||||
)
|
||||
},
|
||||
size: 180,
|
||||
meta: { label: t('Created At'), mobileHidden: true },
|
||||
meta: { mobileHidden: true },
|
||||
},
|
||||
{
|
||||
accessorKey: 'last_login_at',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Last Login')} />
|
||||
),
|
||||
header: t('Last Login'),
|
||||
cell: ({ row }) => {
|
||||
const ts = row.getValue('last_login_at') as number | undefined
|
||||
return (
|
||||
@@ -379,12 +357,13 @@ export function useUsersColumns(): ColumnDef<User>[] {
|
||||
)
|
||||
},
|
||||
size: 180,
|
||||
meta: { label: t('Last Login'), mobileHidden: true },
|
||||
meta: { mobileHidden: true },
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => t('Actions'),
|
||||
cell: ({ row }) => <DataTableRowActions row={row} />,
|
||||
meta: { label: t('Actions') },
|
||||
meta: { pinned: 'right' as const },
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
Vendored
+5
-6
@@ -19,15 +19,14 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import '@tanstack/react-table'
|
||||
|
||||
declare module '@tanstack/react-table' {
|
||||
// Extended column metadata for enhanced table functionality
|
||||
interface ColumnMeta<_TData, _TValue> {
|
||||
// Human-readable label for the column
|
||||
label?: string
|
||||
// Optional description shown in tooltips or help text
|
||||
description?: string
|
||||
// Whether this column can be sorted (overrides default behavior)
|
||||
sortable?: boolean
|
||||
// Custom CSS classes to apply to the column cells
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user