refactor(web): centralize data table implementation
- route all TanStack table setup through a shared data-table hook to remove repeated state and row model wiring. - move table rendering, static table wrappers, empty states, and primitive exports behind the data-table module. - update feature tables and configuration editors to share the same table UX while preserving their existing workflows.
This commit is contained in:
+42
-94
@@ -18,26 +18,21 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import * as React from 'react'
|
||||
import {
|
||||
flexRender,
|
||||
type ColumnDef,
|
||||
type Row,
|
||||
type Table as TanstackTable,
|
||||
} from '@tanstack/react-table'
|
||||
import { useMediaQuery } from '@/hooks'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { PageFooterPortal } from '@/components/layout'
|
||||
import {
|
||||
DataTableView,
|
||||
type DataTableColumnClassName,
|
||||
type DataTablePinnedColumn,
|
||||
type DataTableRenderRowHelpers,
|
||||
} from './data-table-view'
|
||||
import { MobileCardList } from './mobile-card-list'
|
||||
import { DataTablePagination } from './pagination'
|
||||
import { TableEmpty } from './table-empty'
|
||||
import { TableSkeleton } from './table-skeleton'
|
||||
import { DataTableToolbar } from './toolbar'
|
||||
|
||||
/**
|
||||
@@ -145,7 +140,22 @@ export type DataTablePageProps<TData> = {
|
||||
* Custom desktop row renderer — replaces the default `<TableRow>`/`<TableCell>` mapping.
|
||||
* Use for expanded rows, aggregate rows, click-on-row navigation, etc.
|
||||
*/
|
||||
renderRow?: (row: Row<TData>) => React.ReactNode
|
||||
renderRow?: (
|
||||
row: Row<TData>,
|
||||
helpers: DataTableRenderRowHelpers
|
||||
) => React.ReactNode
|
||||
|
||||
/**
|
||||
* Desktop column className resolver. Use for semantic alignment/spacing only;
|
||||
* fixed-column behavior should be configured with `pinnedColumns`.
|
||||
*/
|
||||
getColumnClassName?: DataTableColumnClassName
|
||||
|
||||
/**
|
||||
* Fixed desktop columns. The shared table component owns sticky position,
|
||||
* layering, shadows, and row-state backgrounds.
|
||||
*/
|
||||
pinnedColumns?: DataTablePinnedColumn[]
|
||||
|
||||
/**
|
||||
* Apply explicit column widths from `header.getSize()` to `<TableHead>`.
|
||||
@@ -301,90 +311,28 @@ function renderDesktop<TData>(
|
||||
const isFetchingOnly = props.isFetching && !props.isLoading
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden rounded-lg border transition-opacity duration-150',
|
||||
<DataTableView
|
||||
table={props.table}
|
||||
rows={rows}
|
||||
isLoading={props.isLoading}
|
||||
emptyTitle={props.emptyTitle}
|
||||
emptyDescription={props.emptyDescription}
|
||||
emptyIcon={props.emptyIcon}
|
||||
emptyAction={props.emptyAction}
|
||||
skeletonKeyPrefix={props.skeletonKeyPrefix}
|
||||
renderRow={props.renderRow}
|
||||
applyHeaderSize={props.applyHeaderSize}
|
||||
tableHeaderClassName={props.tableHeaderClassName}
|
||||
getColumnClassName={props.getColumnClassName}
|
||||
pinnedColumns={props.pinnedColumns}
|
||||
containerClassName={cn(
|
||||
'transition-opacity duration-150',
|
||||
isFetchingOnly && 'pointer-events-none opacity-60',
|
||||
props.tableClassName
|
||||
)}
|
||||
>
|
||||
<Table>
|
||||
<TableHeader className={props.tableHeaderClassName}>
|
||||
{props.table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
colSpan={header.colSpan}
|
||||
style={
|
||||
props.applyHeaderSize
|
||||
? { width: header.getSize() }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{props.isLoading ? (
|
||||
<TableSkeleton
|
||||
table={props.table}
|
||||
keyPrefix={props.skeletonKeyPrefix}
|
||||
/>
|
||||
) : rows.length === 0 ? (
|
||||
<TableEmpty
|
||||
colSpan={props.columns.length}
|
||||
title={props.emptyTitle}
|
||||
description={props.emptyDescription}
|
||||
icon={props.emptyIcon}
|
||||
>
|
||||
{props.emptyAction}
|
||||
</TableEmpty>
|
||||
) : (
|
||||
rows.map((row) => {
|
||||
if (props.renderRow) {
|
||||
return props.renderRow(row)
|
||||
}
|
||||
return (
|
||||
<DefaultRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
className={props.getRowClassName?.(row, { isMobile: false })}
|
||||
/>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DefaultRow<TData>({
|
||||
row,
|
||||
className,
|
||||
}: {
|
||||
row: Row<TData>
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<TableRow
|
||||
data-state={row.getIsSelected() && 'selected'}
|
||||
className={className}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
getRowClassName={(row) =>
|
||||
props.getRowClassName?.(row, { isMobile: false })
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
/*
|
||||
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 {
|
||||
flexRender,
|
||||
type Row,
|
||||
type Table as TanstackTable,
|
||||
} from '@tanstack/react-table'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { TableEmpty } from './table-empty'
|
||||
import { TableSkeleton } from './table-skeleton'
|
||||
|
||||
export type DataTableColumnClassName = (
|
||||
columnId: string,
|
||||
kind: 'header' | 'cell'
|
||||
) => string | undefined
|
||||
|
||||
export type DataTablePinnedColumn = {
|
||||
columnId: string
|
||||
side: 'left' | 'right'
|
||||
className?: string
|
||||
headerClassName?: string
|
||||
cellClassName?: string
|
||||
}
|
||||
|
||||
export type DataTableRenderRowHelpers = {
|
||||
getCellClassName: (columnId: string, className?: string) => string | undefined
|
||||
}
|
||||
|
||||
export type DataTableViewProps<TData> = {
|
||||
table: TanstackTable<TData>
|
||||
isLoading?: boolean
|
||||
rows?: Row<TData>[]
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
emptyIcon?: React.ReactNode
|
||||
emptyAction?: React.ReactNode
|
||||
emptyContent?: React.ReactNode
|
||||
emptyCellClassName?: string
|
||||
skeletonKeyPrefix?: string
|
||||
skeletonRowHeight?: string
|
||||
renderRow?: (
|
||||
row: Row<TData>,
|
||||
helpers: DataTableRenderRowHelpers
|
||||
) => React.ReactNode
|
||||
getRowClassName?: (row: Row<TData>) => string | undefined
|
||||
getColumnClassName?: DataTableColumnClassName
|
||||
pinnedColumns?: DataTablePinnedColumn[]
|
||||
applyHeaderSize?: boolean
|
||||
tableClassName?: string
|
||||
tableHeaderClassName?: string
|
||||
tableHeaderRowClassName?: string
|
||||
tableBodyClassName?: string
|
||||
tableBodyRowClassName?: string
|
||||
splitHeader?: boolean
|
||||
splitHeaderScrollClassName?: string
|
||||
bodyContainerClassName?: string
|
||||
containerClassName?: string
|
||||
containerProps?: Omit<React.ComponentProps<'div'>, 'className' | 'children'>
|
||||
tableContainerClassName?: string
|
||||
colgroup?: React.ReactNode
|
||||
}
|
||||
|
||||
export function DataTableView<TData>(props: DataTableViewProps<TData>) {
|
||||
const rows = props.rows ?? props.table.getRowModel().rows
|
||||
const colSpan = props.table.getVisibleLeafColumns().length
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden rounded-lg border',
|
||||
props.containerClassName
|
||||
)}
|
||||
{...props.containerProps}
|
||||
>
|
||||
{props.splitHeader ? (
|
||||
<SplitHeaderTableView props={props} rows={rows} colSpan={colSpan} />
|
||||
) : (
|
||||
<UnifiedTableView props={props} rows={rows} colSpan={colSpan} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function UnifiedTableView<TData>({
|
||||
props,
|
||||
rows,
|
||||
colSpan,
|
||||
}: {
|
||||
props: DataTableViewProps<TData>
|
||||
rows: Row<TData>[]
|
||||
colSpan: number
|
||||
}) {
|
||||
const getColumnClassName = getResolvedColumnClassName(
|
||||
props.getColumnClassName,
|
||||
props.pinnedColumns
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={props.tableContainerClassName}>
|
||||
<Table className={props.tableClassName}>
|
||||
{props.colgroup}
|
||||
<DataTableHeader
|
||||
table={props.table}
|
||||
applyHeaderSize={props.applyHeaderSize}
|
||||
className={props.tableHeaderClassName}
|
||||
rowClassName={props.tableHeaderRowClassName}
|
||||
getColumnClassName={getColumnClassName}
|
||||
/>
|
||||
{renderTableBody(props, rows, colSpan, getColumnClassName)}
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SplitHeaderTableView<TData>({
|
||||
props,
|
||||
rows,
|
||||
colSpan,
|
||||
}: {
|
||||
props: DataTableViewProps<TData>
|
||||
rows: Row<TData>[]
|
||||
colSpan: number
|
||||
}) {
|
||||
const headerHostRef = React.useRef<HTMLDivElement>(null)
|
||||
const bodyHostRef = React.useRef<HTMLDivElement>(null)
|
||||
const getColumnClassName = getResolvedColumnClassName(
|
||||
props.getColumnClassName,
|
||||
props.pinnedColumns
|
||||
)
|
||||
|
||||
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(
|
||||
'flex h-full min-h-0 flex-col',
|
||||
props.tableContainerClassName
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-h-0 flex-1 flex-col overflow-hidden',
|
||||
props.splitHeaderScrollClassName
|
||||
)}
|
||||
>
|
||||
<div
|
||||
ref={headerHostRef}
|
||||
className='[scrollbar-gutter:stable] overflow-hidden'
|
||||
>
|
||||
<Table className={props.tableClassName}>
|
||||
{renderColgroup(props.colgroup)}
|
||||
<DataTableHeader
|
||||
table={props.table}
|
||||
applyHeaderSize={props.applyHeaderSize}
|
||||
className={props.tableHeaderClassName}
|
||||
rowClassName={props.tableHeaderRowClassName}
|
||||
getColumnClassName={getColumnClassName}
|
||||
/>
|
||||
</Table>
|
||||
</div>
|
||||
<div
|
||||
ref={bodyHostRef}
|
||||
className={cn(
|
||||
'min-h-0 flex-1 overflow-y-auto',
|
||||
props.bodyContainerClassName
|
||||
)}
|
||||
>
|
||||
<Table className={props.tableClassName}>
|
||||
{renderColgroup(props.colgroup)}
|
||||
{renderTableBody(props, rows, colSpan, getColumnClassName)}
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function renderColgroup(colgroup: React.ReactNode) {
|
||||
return colgroup
|
||||
}
|
||||
|
||||
function renderTableBody<TData>(
|
||||
props: DataTableViewProps<TData>,
|
||||
rows: Row<TData>[],
|
||||
colSpan: number,
|
||||
getColumnClassName: DataTableColumnClassName
|
||||
) {
|
||||
return (
|
||||
<TableBody className={props.tableBodyClassName}>
|
||||
{props.isLoading ? (
|
||||
<TableSkeleton
|
||||
table={props.table}
|
||||
keyPrefix={props.skeletonKeyPrefix}
|
||||
rowHeight={props.skeletonRowHeight}
|
||||
/>
|
||||
) : rows.length === 0 ? (
|
||||
props.emptyContent ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={colSpan} className={props.emptyCellClassName}>
|
||||
{props.emptyContent}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
<TableEmpty
|
||||
colSpan={colSpan}
|
||||
title={props.emptyTitle}
|
||||
description={props.emptyDescription}
|
||||
icon={props.emptyIcon}
|
||||
>
|
||||
{props.emptyAction}
|
||||
</TableEmpty>
|
||||
)
|
||||
) : (
|
||||
rows.map((row) =>
|
||||
props.renderRow ? (
|
||||
props.renderRow(row, {
|
||||
getCellClassName: (columnId, className) =>
|
||||
cn(getColumnClassName(columnId, 'cell'), className),
|
||||
})
|
||||
) : (
|
||||
<DataTableRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
className={cn(
|
||||
props.tableBodyRowClassName,
|
||||
props.getRowClassName?.(row)
|
||||
)}
|
||||
getColumnClassName={getColumnClassName}
|
||||
/>
|
||||
)
|
||||
)
|
||||
)}
|
||||
</TableBody>
|
||||
)
|
||||
}
|
||||
|
||||
function getResolvedColumnClassName(
|
||||
getColumnClassName?: DataTableColumnClassName,
|
||||
pinnedColumns?: DataTablePinnedColumn[]
|
||||
): DataTableColumnClassName {
|
||||
if (!pinnedColumns?.length) {
|
||||
return (columnId, kind) => getColumnClassName?.(columnId, kind)
|
||||
}
|
||||
|
||||
const pinnedColumnById = new Map(
|
||||
pinnedColumns.map((column) => [column.columnId, column])
|
||||
)
|
||||
|
||||
return (columnId, kind) => {
|
||||
const pinnedColumn = pinnedColumnById.get(columnId)
|
||||
const customClassName = getColumnClassName?.(columnId, kind)
|
||||
|
||||
if (!pinnedColumn) return customClassName
|
||||
|
||||
return cn(getPinnedColumnClassName(pinnedColumn, kind), customClassName)
|
||||
}
|
||||
}
|
||||
|
||||
function getPinnedColumnClassName(
|
||||
pinnedColumn: DataTablePinnedColumn,
|
||||
kind: 'header' | 'cell'
|
||||
) {
|
||||
const edgeClassName =
|
||||
pinnedColumn.side === 'left'
|
||||
? 'border-r shadow-[8px_0_10px_-10px_hsl(var(--foreground))]'
|
||||
: 'border-l shadow-[-8px_0_10px_-10px_hsl(var(--foreground))]'
|
||||
|
||||
return cn(
|
||||
'sticky whitespace-nowrap',
|
||||
pinnedColumn.side === 'left' ? 'left-0' : 'right-0',
|
||||
edgeClassName,
|
||||
kind === 'header'
|
||||
? 'bg-background z-30'
|
||||
: 'bg-background z-10 group-hover:bg-muted/50 group-data-[state=selected]:bg-muted',
|
||||
pinnedColumn.className,
|
||||
kind === 'header' ? pinnedColumn.headerClassName : pinnedColumn.cellClassName
|
||||
)
|
||||
}
|
||||
|
||||
export function DataTableHeader<TData>({
|
||||
table,
|
||||
applyHeaderSize,
|
||||
className,
|
||||
rowClassName,
|
||||
getColumnClassName,
|
||||
}: {
|
||||
table: TanstackTable<TData>
|
||||
applyHeaderSize?: boolean
|
||||
className?: string
|
||||
rowClassName?: string
|
||||
getColumnClassName?: DataTableColumnClassName
|
||||
}) {
|
||||
return (
|
||||
<TableHeader className={className}>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} className={rowClassName}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
colSpan={header.colSpan}
|
||||
className={getColumnClassName?.(header.column.id, 'header')}
|
||||
style={applyHeaderSize ? { width: header.getSize() } : undefined}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
)
|
||||
}
|
||||
|
||||
export function DataTableRow<TData>({
|
||||
row,
|
||||
className,
|
||||
getColumnClassName,
|
||||
...rowProps
|
||||
}: {
|
||||
row: Row<TData>
|
||||
className?: string
|
||||
getColumnClassName?: DataTableColumnClassName
|
||||
} & Omit<React.ComponentProps<typeof TableRow>, 'children'>) {
|
||||
return (
|
||||
<TableRow
|
||||
data-state={row.getIsSelected() ? 'selected' : undefined}
|
||||
className={className}
|
||||
{...rowProps}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={getColumnClassName?.(cell.column.id, 'cell')}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
+31
@@ -22,10 +22,41 @@ export { DataTableFacetedFilter } from './faceted-filter'
|
||||
export { DataTableViewOptions } from './view-options'
|
||||
export { DataTableToolbar } from './toolbar'
|
||||
export { DataTableBulkActions } from './bulk-actions'
|
||||
export {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCaption,
|
||||
TableCell,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
export {
|
||||
StaticDataTable,
|
||||
StaticDataTableEmptyState,
|
||||
StaticDataTableEmptyRow,
|
||||
staticDataTableClassNames,
|
||||
type StaticDataTableElement,
|
||||
} from './static-data-table'
|
||||
export {
|
||||
DataTableHeader,
|
||||
DataTableRow,
|
||||
DataTableView,
|
||||
type DataTableColumnClassName,
|
||||
type DataTablePinnedColumn,
|
||||
type DataTableRenderRowHelpers,
|
||||
type DataTableViewProps,
|
||||
} from './data-table-view'
|
||||
export { TableSkeleton } from './table-skeleton'
|
||||
export { TableEmpty } from './table-empty'
|
||||
export { MobileCardList } from './mobile-card-list'
|
||||
export { DataTablePage, type DataTablePageProps } from './data-table-page'
|
||||
export {
|
||||
useDataTable,
|
||||
type DataTableRowSelectionPredicate,
|
||||
type UseDataTableOptions,
|
||||
} from './use-data-table'
|
||||
|
||||
export const DISABLED_ROW_DESKTOP =
|
||||
'bg-muted/85 hover:bg-muted [&>td:first-child]:border-l-muted-foreground/35 [&>td:first-child]:border-l-4 [&>td:first-child]:pl-1'
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
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 { cn } from '@/lib/utils'
|
||||
import { Table, TableCell, TableRow } from '@/components/ui/table'
|
||||
import { TableEmpty } from './table-empty'
|
||||
|
||||
export const staticDataTableClassNames = {
|
||||
container: 'overflow-hidden rounded-md border',
|
||||
sectionContainer: 'border-border/60 rounded-lg',
|
||||
embeddedContainer: 'rounded-none border-0',
|
||||
compactTable: 'text-sm',
|
||||
compactHeaderRow: 'hover:bg-transparent',
|
||||
mutedHeaderRow: 'bg-muted/30 hover:bg-muted/30',
|
||||
compactHeaderCell:
|
||||
'text-muted-foreground py-2 text-[10px] font-medium tracking-wider uppercase',
|
||||
compactHeaderCellRight:
|
||||
'text-muted-foreground py-2 text-right text-[10px] font-medium tracking-wider uppercase',
|
||||
compactCell: 'py-2.5',
|
||||
compactTopCell: 'py-2.5 align-top',
|
||||
compactTopNumericCell: 'py-2.5 text-right align-top font-mono',
|
||||
compactMutedCell: 'text-muted-foreground py-2.5',
|
||||
compactMutedCodeCell: 'text-muted-foreground py-2.5 font-mono',
|
||||
compactNumericCell: 'py-2.5 text-right font-mono',
|
||||
compactMutedNumericCell: 'text-muted-foreground py-2.5 text-right font-mono',
|
||||
topCell: 'py-2 align-top',
|
||||
topMutedCell: 'text-muted-foreground py-2 align-top',
|
||||
codeCell: 'font-mono text-sm',
|
||||
mutedCell: 'text-muted-foreground text-sm',
|
||||
mutedCodeCell: 'text-muted-foreground font-mono text-sm',
|
||||
topNumericCell: 'py-2 text-right font-mono',
|
||||
mediumCell: 'font-medium',
|
||||
actionHeaderCell: 'text-right',
|
||||
actionCell: 'text-right',
|
||||
} as const
|
||||
|
||||
type StaticDataTableProps = {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
tableClassName?: string
|
||||
containerProps?: Omit<React.ComponentProps<'div'>, 'className' | 'children'>
|
||||
tableProps?: Omit<React.ComponentProps<typeof Table>, 'className' | 'children'>
|
||||
}
|
||||
|
||||
export function StaticDataTable({
|
||||
children,
|
||||
className,
|
||||
tableClassName,
|
||||
containerProps,
|
||||
tableProps,
|
||||
}: StaticDataTableProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(staticDataTableClassNames.container, className)}
|
||||
{...containerProps}
|
||||
>
|
||||
<Table className={tableClassName} {...tableProps}>
|
||||
{children}
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type StaticDataTableEmptyRowProps = {
|
||||
colSpan: number
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function StaticDataTableEmptyRow({
|
||||
colSpan,
|
||||
children,
|
||||
className,
|
||||
}: StaticDataTableEmptyRowProps) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={colSpan}
|
||||
className={cn('h-24 text-center', className)}
|
||||
>
|
||||
{children}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
|
||||
type StaticDataTableEmptyStateProps = {
|
||||
colSpan: number
|
||||
title?: string
|
||||
description?: string
|
||||
icon?: React.ReactNode
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export function StaticDataTableEmptyState({
|
||||
colSpan,
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
children,
|
||||
}: StaticDataTableEmptyStateProps) {
|
||||
return (
|
||||
<TableEmpty
|
||||
colSpan={colSpan}
|
||||
title={title}
|
||||
description={description}
|
||||
icon={icon}
|
||||
>
|
||||
{children}
|
||||
</TableEmpty>
|
||||
)
|
||||
}
|
||||
|
||||
export type StaticDataTableElement = React.ComponentProps<typeof Table>
|
||||
@@ -0,0 +1,243 @@
|
||||
/*
|
||||
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 {
|
||||
type ColumnDef,
|
||||
type ColumnFiltersState,
|
||||
type ExpandedState,
|
||||
type OnChangeFn,
|
||||
type PaginationState,
|
||||
type Row,
|
||||
type RowSelectionState,
|
||||
type SortingState,
|
||||
type TableOptions,
|
||||
type Updater,
|
||||
type VisibilityState,
|
||||
getCoreRowModel,
|
||||
getExpandedRowModel,
|
||||
getFacetedRowModel,
|
||||
getFacetedUniqueValues,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table'
|
||||
|
||||
type DataTableFeatureOptions<TData> = Pick<
|
||||
TableOptions<TData>,
|
||||
| 'enableRowSelection'
|
||||
| 'getRowId'
|
||||
| 'getSubRows'
|
||||
| 'globalFilterFn'
|
||||
| 'autoResetPageIndex'
|
||||
| 'manualFiltering'
|
||||
| 'manualPagination'
|
||||
| 'manualSorting'
|
||||
>
|
||||
|
||||
type DataTableStateOptions = {
|
||||
initialSorting?: SortingState
|
||||
sorting?: SortingState
|
||||
onSortingChange?: OnChangeFn<SortingState>
|
||||
initialColumnVisibility?: VisibilityState
|
||||
columnVisibility?: VisibilityState
|
||||
onColumnVisibilityChange?: OnChangeFn<VisibilityState>
|
||||
initialRowSelection?: RowSelectionState
|
||||
rowSelection?: RowSelectionState
|
||||
onRowSelectionChange?: OnChangeFn<RowSelectionState>
|
||||
initialExpanded?: ExpandedState
|
||||
expanded?: ExpandedState
|
||||
onExpandedChange?: OnChangeFn<ExpandedState>
|
||||
columnFilters?: ColumnFiltersState
|
||||
onColumnFiltersChange?: OnChangeFn<ColumnFiltersState>
|
||||
globalFilter?: string
|
||||
onGlobalFilterChange?: OnChangeFn<string>
|
||||
initialPagination?: PaginationState
|
||||
pagination?: PaginationState
|
||||
onPaginationChange?: OnChangeFn<PaginationState>
|
||||
}
|
||||
|
||||
type DataTableRowModelOptions = {
|
||||
withFilteredRowModel?: boolean
|
||||
withPaginationRowModel?: boolean
|
||||
withSortedRowModel?: boolean
|
||||
withFacetedRowModel?: boolean
|
||||
withExpandedRowModel?: boolean
|
||||
}
|
||||
|
||||
export type UseDataTableOptions<TData> = DataTableFeatureOptions<TData> &
|
||||
DataTableStateOptions &
|
||||
DataTableRowModelOptions & {
|
||||
data: TData[]
|
||||
columns: ColumnDef<TData, unknown>[]
|
||||
totalCount?: number
|
||||
pageCount?: number
|
||||
ensurePageInRange?: (pageCount: number) => void
|
||||
}
|
||||
|
||||
function resolveUpdater<TValue>(
|
||||
updater: Updater<TValue>,
|
||||
previous: TValue
|
||||
): TValue {
|
||||
return typeof updater === 'function'
|
||||
? (updater as (old: TValue) => TValue)(previous)
|
||||
: updater
|
||||
}
|
||||
|
||||
function useControllableTableState<TValue>(
|
||||
controlledValue: TValue | undefined,
|
||||
defaultValue: TValue,
|
||||
onChange: OnChangeFn<TValue> | undefined
|
||||
): [TValue, OnChangeFn<TValue>] {
|
||||
const [uncontrolledValue, setUncontrolledValue] =
|
||||
React.useState<TValue>(defaultValue)
|
||||
|
||||
const value = controlledValue ?? uncontrolledValue
|
||||
|
||||
const setValue = React.useCallback<OnChangeFn<TValue>>(
|
||||
(updater) => {
|
||||
if (controlledValue === undefined) {
|
||||
setUncontrolledValue((previous) => resolveUpdater(updater, previous))
|
||||
}
|
||||
onChange?.(updater)
|
||||
},
|
||||
[controlledValue, onChange]
|
||||
)
|
||||
|
||||
return [value, setValue]
|
||||
}
|
||||
|
||||
export function useDataTable<TData>(options: UseDataTableOptions<TData>) {
|
||||
const {
|
||||
data,
|
||||
columns,
|
||||
totalCount,
|
||||
pageCount: explicitPageCount,
|
||||
ensurePageInRange,
|
||||
initialSorting = [],
|
||||
initialColumnVisibility = {},
|
||||
initialRowSelection = {},
|
||||
initialExpanded = {},
|
||||
initialPagination = { pageIndex: 0, pageSize: 20 },
|
||||
withFilteredRowModel = true,
|
||||
withPaginationRowModel = true,
|
||||
withSortedRowModel = true,
|
||||
withFacetedRowModel = true,
|
||||
withExpandedRowModel = false,
|
||||
} = options
|
||||
|
||||
const [sorting, onSortingChange] = useControllableTableState(
|
||||
options.sorting,
|
||||
initialSorting,
|
||||
options.onSortingChange
|
||||
)
|
||||
const [columnVisibility, onColumnVisibilityChange] =
|
||||
useControllableTableState(
|
||||
options.columnVisibility,
|
||||
initialColumnVisibility,
|
||||
options.onColumnVisibilityChange
|
||||
)
|
||||
const [rowSelection, onRowSelectionChange] = useControllableTableState(
|
||||
options.rowSelection,
|
||||
initialRowSelection,
|
||||
options.onRowSelectionChange
|
||||
)
|
||||
const [expanded, onExpandedChange] = useControllableTableState(
|
||||
options.expanded,
|
||||
initialExpanded,
|
||||
options.onExpandedChange
|
||||
)
|
||||
const [pagination, onPaginationChange] = useControllableTableState(
|
||||
options.pagination,
|
||||
initialPagination,
|
||||
options.onPaginationChange
|
||||
)
|
||||
|
||||
const resolvedPageCount =
|
||||
explicitPageCount ??
|
||||
(totalCount !== undefined
|
||||
? Math.ceil(totalCount / pagination.pageSize)
|
||||
: undefined)
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
pageCount: resolvedPageCount,
|
||||
state: {
|
||||
sorting,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
expanded,
|
||||
columnFilters: options.columnFilters,
|
||||
globalFilter: options.globalFilter,
|
||||
pagination,
|
||||
},
|
||||
enableRowSelection: options.enableRowSelection,
|
||||
getRowId: options.getRowId,
|
||||
getSubRows: options.getSubRows,
|
||||
globalFilterFn: options.globalFilterFn,
|
||||
autoResetPageIndex: options.autoResetPageIndex,
|
||||
manualFiltering: options.manualFiltering,
|
||||
manualPagination: options.manualPagination,
|
||||
manualSorting: options.manualSorting,
|
||||
onSortingChange,
|
||||
onColumnVisibilityChange,
|
||||
onRowSelectionChange,
|
||||
onExpandedChange,
|
||||
onColumnFiltersChange: options.onColumnFiltersChange,
|
||||
onGlobalFilterChange: options.onGlobalFilterChange,
|
||||
onPaginationChange,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: withFilteredRowModel
|
||||
? getFilteredRowModel()
|
||||
: undefined,
|
||||
getPaginationRowModel: withPaginationRowModel
|
||||
? getPaginationRowModel()
|
||||
: undefined,
|
||||
getSortedRowModel: withSortedRowModel ? getSortedRowModel() : undefined,
|
||||
getFacetedRowModel: withFacetedRowModel ? getFacetedRowModel() : undefined,
|
||||
getFacetedUniqueValues: withFacetedRowModel
|
||||
? getFacetedUniqueValues()
|
||||
: undefined,
|
||||
getExpandedRowModel: withExpandedRowModel
|
||||
? getExpandedRowModel()
|
||||
: undefined,
|
||||
})
|
||||
|
||||
const actualPageCount = table.getPageCount()
|
||||
React.useEffect(() => {
|
||||
ensurePageInRange?.(actualPageCount)
|
||||
}, [actualPageCount, ensurePageInRange])
|
||||
|
||||
return {
|
||||
table,
|
||||
sorting,
|
||||
onSortingChange,
|
||||
columnVisibility,
|
||||
onColumnVisibilityChange,
|
||||
rowSelection,
|
||||
onRowSelectionChange,
|
||||
expanded,
|
||||
onExpandedChange,
|
||||
pagination,
|
||||
onPaginationChange,
|
||||
}
|
||||
}
|
||||
|
||||
export type DataTableRowSelectionPredicate<TData> = (row: Row<TData>) => boolean
|
||||
+16
-32
@@ -20,13 +20,8 @@ import { useState, useMemo, useEffect } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getRouteApi } from '@tanstack/react-router'
|
||||
import {
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
getExpandedRowModel,
|
||||
type OnChangeFn,
|
||||
type SortingState,
|
||||
type VisibilityState,
|
||||
type ExpandedState,
|
||||
type Row,
|
||||
} from '@tanstack/react-table'
|
||||
import { useDebounce, useMediaQuery } from '@/hooks'
|
||||
@@ -38,6 +33,7 @@ import {
|
||||
DISABLED_ROW_DESKTOP,
|
||||
DISABLED_ROW_MOBILE,
|
||||
DataTablePage,
|
||||
useDataTable,
|
||||
} from '@/components/data-table'
|
||||
import { getChannels, searchChannels, getGroups } from '../api'
|
||||
import {
|
||||
@@ -81,12 +77,6 @@ export function ChannelsTable() {
|
||||
|
||||
// Table state
|
||||
const [sorting, setSorting] = useState<SortingState>([])
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
|
||||
models: false,
|
||||
tag: false,
|
||||
})
|
||||
const [rowSelection, setRowSelection] = useState({})
|
||||
const [expanded, setExpanded] = useState<ExpandedState>({})
|
||||
|
||||
// URL state management
|
||||
const {
|
||||
@@ -279,41 +269,35 @@ export function ChannelsTable() {
|
||||
const columns = useChannelsColumns()
|
||||
|
||||
// React Table instance
|
||||
const table = useReactTable({
|
||||
const { table } = useDataTable({
|
||||
data: channels,
|
||||
columns,
|
||||
pageCount: Math.ceil(totalCount / pagination.pageSize),
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
pagination,
|
||||
expanded,
|
||||
globalFilter,
|
||||
totalCount,
|
||||
sorting,
|
||||
initialColumnVisibility: {
|
||||
models: false,
|
||||
tag: false,
|
||||
},
|
||||
columnFilters,
|
||||
pagination,
|
||||
globalFilter,
|
||||
enableRowSelection: (row: Row<Channel>) => !isTagAggregateRow(row.original),
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: handleSortingChange,
|
||||
onColumnFiltersChange,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onPaginationChange,
|
||||
onExpandedChange: setExpanded,
|
||||
onGlobalFilterChange,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getExpandedRowModel: getExpandedRowModel(),
|
||||
getSubRows: (row: Channel & { children?: Channel[] }) => row.children,
|
||||
manualPagination: true,
|
||||
manualSorting: true,
|
||||
manualFiltering: true,
|
||||
withFilteredRowModel: false,
|
||||
withPaginationRowModel: false,
|
||||
withSortedRowModel: false,
|
||||
withFacetedRowModel: false,
|
||||
withExpandedRowModel: true,
|
||||
ensurePageInRange,
|
||||
})
|
||||
|
||||
// Ensure page is in range when total count changes
|
||||
const pageCount = table.getPageCount()
|
||||
useEffect(() => {
|
||||
ensurePageInRange(pageCount)
|
||||
}, [pageCount, ensurePageInRange])
|
||||
|
||||
// Prepare filter options from existing channel types only.
|
||||
const typeFilterOptions = useMemo(() => {
|
||||
const counts = typeCounts || {}
|
||||
|
||||
+47
-95
@@ -21,10 +21,6 @@ import {
|
||||
type ColumnDef,
|
||||
type RowSelectionState,
|
||||
type Table as TanStackTable,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table'
|
||||
import { Check, Copy, Info, Loader2, Settings } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -52,20 +48,16 @@ import {
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { DataTableBulkActions as BulkActionsToolbar } from '@/components/data-table'
|
||||
import {
|
||||
DataTableBulkActions as BulkActionsToolbar,
|
||||
DataTableView,
|
||||
useDataTable,
|
||||
} from '@/components/data-table'
|
||||
import { DataTablePagination } from '@/components/data-table/pagination'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import {
|
||||
@@ -200,7 +192,7 @@ function getTestTableColumnClass(columnId: string) {
|
||||
case 'status':
|
||||
return 'w-70 min-w-70 max-w-70 whitespace-normal'
|
||||
case 'actions':
|
||||
return 'bg-popover sticky right-0 z-20 w-24 min-w-24 border-l shadow-[-8px_0_8px_-8px_rgb(0_0_0_/_0.2)] whitespace-nowrap sm:w-28 sm:min-w-28'
|
||||
return 'bg-popover w-24 min-w-24 whitespace-nowrap sm:w-28 sm:min-w-28'
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
@@ -502,18 +494,17 @@ export function ChannelTestDialog({
|
||||
]
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
const { table } = useDataTable({
|
||||
data: tableData,
|
||||
columns,
|
||||
state: {
|
||||
rowSelection,
|
||||
pagination,
|
||||
},
|
||||
rowSelection,
|
||||
pagination,
|
||||
enableRowSelection: true,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onPaginationChange: setPagination,
|
||||
withFilteredRowModel: false,
|
||||
withSortedRowModel: false,
|
||||
withFacetedRowModel: false,
|
||||
})
|
||||
|
||||
if (!currentRow) {
|
||||
@@ -615,80 +606,41 @@ export function ChannelTestDialog({
|
||||
</div>
|
||||
|
||||
<div className='space-y-3'>
|
||||
<div
|
||||
className='overflow-hidden rounded-md border'
|
||||
role='region'
|
||||
aria-label={t('Channel models')}
|
||||
>
|
||||
<div className='max-h-90 overflow-auto **:data-[slot=table-container]:overflow-visible'>
|
||||
<Table className='w-max min-w-full table-auto'>
|
||||
<colgroup>
|
||||
<col className='w-10 min-w-10' />
|
||||
<col className='w-auto' />
|
||||
<col className='w-70' />
|
||||
<col className='w-24 sm:w-28' />
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className={getTestTableColumnClass(
|
||||
header.column.id
|
||||
)}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={
|
||||
row.getIsSelected() ? 'selected' : undefined
|
||||
}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={getTestTableColumnClass(
|
||||
cell.column.id
|
||||
)}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={table.getVisibleLeafColumns().length}
|
||||
className='text-muted-foreground h-16 text-center text-sm'
|
||||
>
|
||||
{models.length
|
||||
? 'No models matched your search.'
|
||||
: 'This channel has no configured models.'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
<DataTableView
|
||||
table={table}
|
||||
containerClassName='rounded-md'
|
||||
containerProps={{
|
||||
role: 'region',
|
||||
'aria-label': t('Channel models'),
|
||||
}}
|
||||
tableContainerClassName='max-h-90 overflow-auto **:data-[slot=table-container]:overflow-visible'
|
||||
tableClassName='w-max min-w-full table-auto'
|
||||
pinnedColumns={[
|
||||
{
|
||||
columnId: 'actions',
|
||||
side: 'right',
|
||||
className: 'w-24 min-w-24 sm:w-28 sm:min-w-28',
|
||||
cellClassName: 'bg-popover',
|
||||
},
|
||||
]}
|
||||
colgroup={
|
||||
<colgroup>
|
||||
<col className='w-10 min-w-10' />
|
||||
<col className='w-auto' />
|
||||
<col className='w-70' />
|
||||
<col className='w-24 sm:w-28' />
|
||||
</colgroup>
|
||||
}
|
||||
getColumnClassName={(columnId) =>
|
||||
getTestTableColumnClass(columnId)
|
||||
}
|
||||
emptyContent={
|
||||
models.length
|
||||
? t('No models matched your search.')
|
||||
: t('This channel has no configured models.')
|
||||
}
|
||||
emptyCellClassName='text-muted-foreground h-16 text-center text-sm'
|
||||
/>
|
||||
|
||||
<DataTablePagination table={table} />
|
||||
</div>
|
||||
|
||||
+43
-44
@@ -31,15 +31,15 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
} from '@/components/data-table'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import {
|
||||
@@ -358,48 +358,47 @@ export function MultiKeyManageDialog({
|
||||
{t('No keys found')}
|
||||
</div>
|
||||
) : (
|
||||
<div className='min-w-[800px]'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className='w-20'>{t('Index')}</TableHead>
|
||||
<TableHead className='w-32'>{t('Status')}</TableHead>
|
||||
<TableHead className='min-w-[200px]'>
|
||||
{t('Disabled Reason')}
|
||||
</TableHead>
|
||||
<TableHead className='w-44'>
|
||||
{t('Disabled Time')}
|
||||
</TableHead>
|
||||
<TableHead className='w-44 text-right'>
|
||||
{t('Actions')}
|
||||
</TableHead>
|
||||
<StaticDataTable
|
||||
className='rounded-none border-0'
|
||||
tableClassName='min-w-[800px]'
|
||||
>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className='w-20'>{t('Index')}</TableHead>
|
||||
<TableHead className='w-32'>{t('Status')}</TableHead>
|
||||
<TableHead className='min-w-[200px]'>
|
||||
{t('Disabled Reason')}
|
||||
</TableHead>
|
||||
<TableHead className='w-44'>{t('Disabled Time')}</TableHead>
|
||||
<TableHead className='w-44 text-right'>
|
||||
{t('Actions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{keys.map((key) => (
|
||||
<TableRow key={key.index}>
|
||||
<TableCell className='font-mono text-sm'>
|
||||
#{key.index + 1}
|
||||
</TableCell>
|
||||
<TableCell>{renderStatusBadge(key.status)}</TableCell>
|
||||
<TableCell className='max-w-xs truncate text-sm'>
|
||||
{key.reason || '-'}
|
||||
</TableCell>
|
||||
<TableCell className='text-muted-foreground text-sm'>
|
||||
{formatKeyTimestamp(key.disabled_time)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MultiKeyTableRowActions
|
||||
keyIndex={key.index}
|
||||
status={key.status}
|
||||
onAction={setConfirmAction}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{keys.map((key) => (
|
||||
<TableRow key={key.index}>
|
||||
<TableCell className='font-mono text-sm'>
|
||||
#{key.index + 1}
|
||||
</TableCell>
|
||||
<TableCell>{renderStatusBadge(key.status)}</TableCell>
|
||||
<TableCell className='max-w-xs truncate text-sm'>
|
||||
{key.reason || '-'}
|
||||
</TableCell>
|
||||
<TableCell className='text-muted-foreground text-sm'>
|
||||
{formatKeyTimestamp(key.disabled_time)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MultiKeyTableRowActions
|
||||
keyIndex={key.index}
|
||||
status={key.status}
|
||||
onAction={setConfirmAction}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
))}
|
||||
</TableBody>
|
||||
</StaticDataTable>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
+9
-39
@@ -19,17 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getRouteApi } from '@tanstack/react-router'
|
||||
import {
|
||||
type SortingState,
|
||||
type VisibilityState,
|
||||
getCoreRowModel,
|
||||
getFacetedRowModel,
|
||||
getFacetedUniqueValues,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table'
|
||||
import { type Table as TanstackTable } from '@tanstack/react-table'
|
||||
import { useDebounce } from '@/hooks'
|
||||
import { Database } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -50,6 +40,7 @@ import {
|
||||
DISABLED_ROW_DESKTOP,
|
||||
DISABLED_ROW_MOBILE,
|
||||
DataTablePage,
|
||||
useDataTable,
|
||||
} from '@/components/data-table'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { getApiKeys, searchApiKeys } from '../api'
|
||||
@@ -99,7 +90,7 @@ function ApiKeysMobileList({
|
||||
table,
|
||||
isLoading,
|
||||
}: {
|
||||
table: ReturnType<typeof useReactTable<ApiKey>>
|
||||
table: TanstackTable<ApiKey>
|
||||
isLoading: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
@@ -192,9 +183,6 @@ export function ApiKeysTable() {
|
||||
const { t } = useTranslation()
|
||||
const { refreshTrigger } = useApiKeys()
|
||||
const columns = useApiKeysColumns()
|
||||
const [rowSelection, setRowSelection] = useState({})
|
||||
const [sorting, setSorting] = useState<SortingState>([])
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
|
||||
|
||||
const {
|
||||
globalFilter,
|
||||
@@ -284,40 +272,22 @@ export function ApiKeysTable() {
|
||||
|
||||
const apiKeys = data?.items || []
|
||||
|
||||
const table = useReactTable({
|
||||
const { table } = useDataTable({
|
||||
data: apiKeys,
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
columnFilters,
|
||||
globalFilter,
|
||||
pagination,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
columnFilters,
|
||||
globalFilter,
|
||||
pagination,
|
||||
globalFilterFn: () => true,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedRowModel: getFacetedRowModel(),
|
||||
getFacetedUniqueValues: getFacetedUniqueValues(),
|
||||
onPaginationChange,
|
||||
onGlobalFilterChange,
|
||||
onColumnFiltersChange,
|
||||
manualPagination: true,
|
||||
pageCount: Math.ceil((data?.total || 0) / pagination.pageSize),
|
||||
totalCount: data?.total || 0,
|
||||
ensurePageInRange,
|
||||
})
|
||||
|
||||
const pageCount = table.getPageCount()
|
||||
useEffect(() => {
|
||||
ensurePageInRange(pageCount)
|
||||
}, [pageCount, ensurePageInRange])
|
||||
|
||||
return (
|
||||
<DataTablePage
|
||||
table={table}
|
||||
|
||||
@@ -16,14 +16,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { getRouteApi } from '@tanstack/react-router'
|
||||
import {
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
type VisibilityState,
|
||||
} from '@tanstack/react-table'
|
||||
import { useMediaQuery } from '@/hooks'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
@@ -38,7 +33,7 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { DataTablePage } from '@/components/data-table'
|
||||
import { DataTablePage, useDataTable } from '@/components/data-table'
|
||||
import { deleteDeployment, listDeployments, searchDeployments } from '../api'
|
||||
import { getDeploymentStatusOptions } from '../constants'
|
||||
import { deploymentsQueryKeys } from '../lib'
|
||||
@@ -167,8 +162,6 @@ export function DeploymentsTable() {
|
||||
}
|
||||
}
|
||||
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
|
||||
|
||||
const columns = useDeploymentsColumns({
|
||||
onViewLogs: (id) => {
|
||||
setLogsDeploymentId(id)
|
||||
@@ -197,30 +190,25 @@ export function DeploymentsTable() {
|
||||
},
|
||||
})
|
||||
|
||||
const table = useReactTable({
|
||||
const { table } = useDataTable({
|
||||
data: deployments,
|
||||
columns,
|
||||
pageCount: Math.ceil(totalCount / pagination.pageSize),
|
||||
state: {
|
||||
columnFilters,
|
||||
columnVisibility,
|
||||
pagination,
|
||||
globalFilter,
|
||||
},
|
||||
totalCount,
|
||||
columnFilters,
|
||||
pagination,
|
||||
globalFilter,
|
||||
onColumnFiltersChange,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onPaginationChange,
|
||||
onGlobalFilterChange,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
manualFiltering: true,
|
||||
withFilteredRowModel: false,
|
||||
withPaginationRowModel: false,
|
||||
withSortedRowModel: false,
|
||||
withFacetedRowModel: false,
|
||||
ensurePageInRange,
|
||||
})
|
||||
|
||||
const pageCount = table.getPageCount()
|
||||
useEffect(() => {
|
||||
ensurePageInRange(pageCount)
|
||||
}, [ensurePageInRange, pageCount])
|
||||
|
||||
const statusFilterOptions = useMemo(() => {
|
||||
return [...getDeploymentStatusOptions(t)].map((opt) => ({
|
||||
label: opt.label,
|
||||
|
||||
+100
-106
@@ -46,15 +46,15 @@ import {
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/empty'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
} from '@/components/data-table'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { TableId } from '@/components/table-id'
|
||||
@@ -344,110 +344,104 @@ export function PrefillGroupManagementDialog({
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className='rounded-md border'>
|
||||
<div className='w-full overflow-x-auto'>
|
||||
<Table className='min-w-[680px]'>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Group')}</TableHead>
|
||||
<TableHead>{t('Type')}</TableHead>
|
||||
<TableHead className='min-w-[240px]'>
|
||||
{t('Items')}
|
||||
</TableHead>
|
||||
<TableHead className='w-[120px] text-right'>
|
||||
{t('Actions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{normalizedGroups.map(({ group, meta, parsedItems }) => (
|
||||
<TableRow key={group.id}>
|
||||
<TableCell className='align-top whitespace-normal'>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<span className='font-medium'>{group.name}</span>
|
||||
<TableId value={group.id} />
|
||||
</div>
|
||||
{group.description ? (
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{group.description}
|
||||
</p>
|
||||
) : (
|
||||
<p className='text-muted-foreground text-xs italic'>
|
||||
No description provided
|
||||
</p>
|
||||
<StaticDataTable tableClassName='min-w-[680px]'>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Group')}</TableHead>
|
||||
<TableHead>{t('Type')}</TableHead>
|
||||
<TableHead className='min-w-[240px]'>{t('Items')}</TableHead>
|
||||
<TableHead className='w-[120px] text-right'>
|
||||
{t('Actions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{normalizedGroups.map(({ group, meta, parsedItems }) => (
|
||||
<TableRow key={group.id}>
|
||||
<TableCell className='align-top whitespace-normal'>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<span className='font-medium'>{group.name}</span>
|
||||
<TableId value={group.id} />
|
||||
</div>
|
||||
{group.description ? (
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{group.description}
|
||||
</p>
|
||||
) : (
|
||||
<p className='text-muted-foreground text-xs italic'>
|
||||
No description provided
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='align-top'>
|
||||
<StatusBadge
|
||||
label={meta.label}
|
||||
variant={meta.badge}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className='align-top whitespace-normal'>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{parsedItems.length > 0 ? (
|
||||
<>
|
||||
{parsedItems.slice(0, 6).map((item) => (
|
||||
<StatusBadge
|
||||
key={item}
|
||||
label={item}
|
||||
autoColor={item}
|
||||
size='sm'
|
||||
/>
|
||||
))}
|
||||
{parsedItems.length > 6 && (
|
||||
<StatusBadge
|
||||
label={`+${parsedItems.length - 6} more`}
|
||||
variant='neutral'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='align-top'>
|
||||
<StatusBadge
|
||||
label={meta.label}
|
||||
variant={meta.badge}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className='align-top whitespace-normal'>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{parsedItems.length > 0 ? (
|
||||
<>
|
||||
{parsedItems.slice(0, 6).map((item) => (
|
||||
<StatusBadge
|
||||
key={item}
|
||||
label={item}
|
||||
autoColor={item}
|
||||
size='sm'
|
||||
/>
|
||||
))}
|
||||
{parsedItems.length > 6 && (
|
||||
<StatusBadge
|
||||
label={`+${parsedItems.length - 6} more`}
|
||||
variant='neutral'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{group.type === 'endpoint'
|
||||
? 'No endpoint mappings configured.'
|
||||
: 'No items configured yet.'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className='text-muted-foreground mt-2 text-xs font-medium tracking-wide uppercase'>
|
||||
{parsedItems.length} item
|
||||
{parsedItems.length === 1 ? '' : 's'}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='align-top'>
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button
|
||||
size='icon'
|
||||
variant='outline'
|
||||
onClick={() => onEditGroup(group)}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
<span className='sr-only'>Edit group</span>
|
||||
</Button>
|
||||
<Button
|
||||
size='icon'
|
||||
variant='ghost'
|
||||
className='text-destructive hover:text-destructive'
|
||||
onClick={() => handleDeleteClick(group)}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
<span className='sr-only'>Delete group</span>
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{group.type === 'endpoint'
|
||||
? 'No endpoint mappings configured.'
|
||||
: 'No items configured yet.'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className='text-muted-foreground mt-2 text-xs font-medium tracking-wide uppercase'>
|
||||
{parsedItems.length} item
|
||||
{parsedItems.length === 1 ? '' : 's'}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='align-top'>
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button
|
||||
size='icon'
|
||||
variant='outline'
|
||||
onClick={() => onEditGroup(group)}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
<span className='sr-only'>Edit group</span>
|
||||
</Button>
|
||||
<Button
|
||||
size='icon'
|
||||
variant='ghost'
|
||||
className='text-destructive hover:text-destructive'
|
||||
onClick={() => handleDeleteClick(group)}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
<span className='sr-only'>Delete group</span>
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</StaticDataTable>
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
+16
-57
@@ -18,13 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useEffect, useMemo, useState, useCallback } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
type RowSelectionState,
|
||||
} from '@tanstack/react-table'
|
||||
import { type ColumnDef, type RowSelectionState } from '@tanstack/react-table'
|
||||
import {
|
||||
Search,
|
||||
Info,
|
||||
@@ -51,14 +45,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { DataTableView, useDataTable } from '@/components/data-table'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { applyUpstreamOverwrite } from '../../api'
|
||||
@@ -341,16 +328,17 @@ export function UpstreamConflictDialog({
|
||||
]
|
||||
}, [isMobile])
|
||||
|
||||
const table = useReactTable({
|
||||
const { table } = useDataTable({
|
||||
data: conflictRows,
|
||||
columns,
|
||||
state: {
|
||||
rowSelection,
|
||||
},
|
||||
rowSelection,
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getRowId: (row) => row.id,
|
||||
withFilteredRowModel: false,
|
||||
withPaginationRowModel: false,
|
||||
withSortedRowModel: false,
|
||||
withFacetedRowModel: false,
|
||||
})
|
||||
|
||||
const totalSelectedFields = table.getSelectedRowModel().rows.length
|
||||
@@ -536,43 +524,14 @@ export function UpstreamConflictDialog({
|
||||
) : (
|
||||
<div className='flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border'>
|
||||
<div className='flex-1 overflow-auto'>
|
||||
<div className={isMobile ? 'min-w-full' : 'min-w-[720px]'}>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paginatedRows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && 'selected'}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<DataTableView
|
||||
table={table}
|
||||
rows={paginatedRows}
|
||||
containerClassName='border-0'
|
||||
tableContainerClassName={
|
||||
isMobile ? 'min-w-full' : 'min-w-[720px]'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='bg-muted/40 flex flex-col gap-2 border-t px-2 py-1.5 text-sm sm:flex-row sm:items-center sm:justify-between sm:gap-3 sm:px-3 sm:py-2'>
|
||||
|
||||
+16
-36
@@ -16,19 +16,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getRouteApi } from '@tanstack/react-router'
|
||||
import {
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
type SortingState,
|
||||
type VisibilityState,
|
||||
} from '@tanstack/react-table'
|
||||
import { useMediaQuery } from '@/hooks'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useTableUrlState } from '@/hooks/use-table-url-state'
|
||||
import { DataTablePage } from '@/components/data-table'
|
||||
import { DataTablePage, useDataTable } from '@/components/data-table'
|
||||
import { getModels, searchModels, getVendors } from '../api'
|
||||
import {
|
||||
DEFAULT_PAGE_SIZE,
|
||||
@@ -47,15 +41,6 @@ export function ModelsTable() {
|
||||
const { selectedVendor } = useModels()
|
||||
const isMobile = useMediaQuery('(max-width: 640px)')
|
||||
|
||||
// Table state
|
||||
const [sorting, setSorting] = useState<SortingState>([])
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
|
||||
description: false,
|
||||
bound_channels: false,
|
||||
quota_types: false,
|
||||
})
|
||||
const [rowSelection, setRowSelection] = useState({})
|
||||
|
||||
// URL state management
|
||||
const {
|
||||
globalFilter,
|
||||
@@ -176,37 +161,32 @@ export function ModelsTable() {
|
||||
const columns = useModelsColumns(vendors)
|
||||
|
||||
// React Table instance
|
||||
const table = useReactTable({
|
||||
const { table } = useDataTable({
|
||||
data: models,
|
||||
columns,
|
||||
pageCount: Math.ceil(totalCount / pagination.pageSize),
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
pagination,
|
||||
globalFilter,
|
||||
totalCount,
|
||||
initialColumnVisibility: {
|
||||
description: false,
|
||||
bound_channels: false,
|
||||
quota_types: false,
|
||||
},
|
||||
columnFilters,
|
||||
pagination,
|
||||
globalFilter,
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onPaginationChange,
|
||||
onGlobalFilterChange,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
manualSorting: true,
|
||||
manualFiltering: true,
|
||||
withFilteredRowModel: false,
|
||||
withPaginationRowModel: false,
|
||||
withSortedRowModel: false,
|
||||
withFacetedRowModel: false,
|
||||
ensurePageInRange,
|
||||
})
|
||||
|
||||
// Ensure page is in range when total count changes
|
||||
const pageCount = table.getPageCount()
|
||||
useEffect(() => {
|
||||
ensurePageInRange(pageCount)
|
||||
}, [pageCount, ensurePageInRange])
|
||||
|
||||
// Prepare filter options
|
||||
const vendorFilterOptions = [
|
||||
{
|
||||
|
||||
+76
-76
@@ -23,13 +23,13 @@ import { useSystemConfigStore } from '@/stores/system-config-store'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
} from '@/components/data-table'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import {
|
||||
BILLING_PRICING_VARS,
|
||||
MATCH_CONTAINS,
|
||||
@@ -307,86 +307,86 @@ export function DynamicPricingBreakdown({
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className='hidden overflow-x-auto sm:block'>
|
||||
<Table className='text-sm'>
|
||||
<TableHeader>
|
||||
<TableRow className='hover:bg-transparent'>
|
||||
<TableHead className='text-muted-foreground py-2 font-medium'>
|
||||
{t('Tier')}
|
||||
<StaticDataTable
|
||||
className='hidden rounded-none border-0 sm:block'
|
||||
tableClassName='text-sm'
|
||||
>
|
||||
<TableHeader>
|
||||
<TableRow className='hover:bg-transparent'>
|
||||
<TableHead className='text-muted-foreground py-2 font-medium'>
|
||||
{t('Tier')}
|
||||
</TableHead>
|
||||
{visiblePriceFields.map((v) => (
|
||||
<TableHead
|
||||
key={v.field}
|
||||
className='text-muted-foreground py-2 text-right font-medium'
|
||||
>
|
||||
{t(v.shortLabel)}
|
||||
</TableHead>
|
||||
{visiblePriceFields.map((v) => (
|
||||
<TableHead
|
||||
key={v.field}
|
||||
className='text-muted-foreground py-2 text-right font-medium'
|
||||
>
|
||||
{t(v.shortLabel)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tiers.map((tier, i) => {
|
||||
const condSummary = formatConditionSummary(tier.conditions, t)
|
||||
const isMatched =
|
||||
normalizedMatchedTierLabel !== '' &&
|
||||
normalizeTierLabel(tier.label) ===
|
||||
normalizedMatchedTierLabel
|
||||
return (
|
||||
<TableRow
|
||||
key={`tier-${i}`}
|
||||
className={cn(
|
||||
isMatched &&
|
||||
'bg-emerald-50/70 hover:bg-emerald-50/70 dark:bg-emerald-500/10 dark:hover:bg-emerald-500/10'
|
||||
)}
|
||||
>
|
||||
<TableCell className='py-2.5 align-top'>
|
||||
<div className='flex flex-wrap items-center gap-1.5'>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tiers.map((tier, i) => {
|
||||
const condSummary = formatConditionSummary(tier.conditions, t)
|
||||
const isMatched =
|
||||
normalizedMatchedTierLabel !== '' &&
|
||||
normalizeTierLabel(tier.label) === normalizedMatchedTierLabel
|
||||
return (
|
||||
<TableRow
|
||||
key={`tier-${i}`}
|
||||
className={cn(
|
||||
isMatched &&
|
||||
'bg-emerald-50/70 hover:bg-emerald-50/70 dark:bg-emerald-500/10 dark:hover:bg-emerald-500/10'
|
||||
)}
|
||||
>
|
||||
<TableCell className='py-2.5 align-top'>
|
||||
<div className='flex flex-wrap items-center gap-1.5'>
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300'
|
||||
>
|
||||
{tier.label || t('Default')}
|
||||
</Badge>
|
||||
{isMatched && (
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300'
|
||||
className='bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300'
|
||||
>
|
||||
{tier.label || t('Default')}
|
||||
{t('Matched')}
|
||||
</Badge>
|
||||
{isMatched && (
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300'
|
||||
>
|
||||
{t('Matched')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{condSummary && (
|
||||
<div className='text-muted-foreground mt-1 text-xs'>
|
||||
{condSummary}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
{visiblePriceFields.map((v) => {
|
||||
const value = Number(
|
||||
tier[v.field as string as keyof ParsedTier] || 0
|
||||
)
|
||||
return (
|
||||
<TableCell
|
||||
key={v.field}
|
||||
className='py-2.5 text-right align-top font-mono'
|
||||
>
|
||||
{value > 0 ? (
|
||||
<span className='font-semibold'>
|
||||
{`${symbol}${(value * rate).toFixed(4)}`}
|
||||
</span>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
{condSummary && (
|
||||
<div className='text-muted-foreground mt-1 text-xs'>
|
||||
{condSummary}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
{visiblePriceFields.map((v) => {
|
||||
const value = Number(
|
||||
tier[v.field as string as keyof ParsedTier] || 0
|
||||
)
|
||||
return (
|
||||
<TableCell
|
||||
key={v.field}
|
||||
className='py-2.5 text-right align-top font-mono'
|
||||
>
|
||||
{value > 0 ? (
|
||||
<span className='font-semibold'>
|
||||
{`${symbol}${(value * rate).toFixed(4)}`}
|
||||
</span>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</StaticDataTable>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -32,19 +32,22 @@ import type { BundledLanguage } from 'shiki/bundle/web'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useStatus } from '@/hooks/use-status'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
CodeBlock,
|
||||
CodeBlockCopyButton,
|
||||
} from '@/components/ai-elements/code-block'
|
||||
import {
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/data-table'
|
||||
import {
|
||||
StaticDataTable,
|
||||
staticDataTableClassNames as tableStyles,
|
||||
} from '@/components/data-table'
|
||||
import {
|
||||
buildRateLimits,
|
||||
buildSupportedParameters,
|
||||
@@ -570,53 +573,51 @@ function SupportedParametersSection(props: { model: PricingModel }) {
|
||||
return (
|
||||
<section>
|
||||
<SectionTitle icon={Sigma}>{t('Supported parameters')}</SectionTitle>
|
||||
<div className='border-border/60 overflow-hidden rounded-lg border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className='bg-muted/30 hover:bg-muted/30'>
|
||||
<TableHead className='h-9 w-44'>{t('Parameter')}</TableHead>
|
||||
<TableHead className='h-9 w-24'>{t('Type')}</TableHead>
|
||||
<TableHead className='h-9 w-32'>{t('Default / range')}</TableHead>
|
||||
<TableHead className='h-9'>{t('Description')}</TableHead>
|
||||
<StaticDataTable className={tableStyles.sectionContainer}>
|
||||
<TableHeader>
|
||||
<TableRow className={tableStyles.mutedHeaderRow}>
|
||||
<TableHead className='h-9 w-44'>{t('Parameter')}</TableHead>
|
||||
<TableHead className='h-9 w-24'>{t('Type')}</TableHead>
|
||||
<TableHead className='h-9 w-32'>{t('Default / range')}</TableHead>
|
||||
<TableHead className='h-9'>{t('Description')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{params.map((p) => (
|
||||
<TableRow key={p.name} className='hover:bg-muted/20'>
|
||||
<TableCell className={tableStyles.topCell}>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<code className='font-mono text-sm font-medium'>
|
||||
{p.name}
|
||||
</code>
|
||||
{p.required && (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='h-6 border-rose-500/40 px-2 text-sm text-rose-600 dark:text-rose-400'
|
||||
>
|
||||
{t('required')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className={tableStyles.topCell}>
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='h-7 rounded-full px-2.5 font-mono text-sm font-normal'
|
||||
>
|
||||
{p.type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className={tableStyles.topCell}>
|
||||
<ParamRangeCell param={p} />
|
||||
</TableCell>
|
||||
<TableCell className={tableStyles.topMutedCell}>
|
||||
{t(p.descriptionKey)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{params.map((p) => (
|
||||
<TableRow key={p.name} className='hover:bg-muted/20'>
|
||||
<TableCell className='py-2 align-top'>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<code className='font-mono text-sm font-medium'>
|
||||
{p.name}
|
||||
</code>
|
||||
{p.required && (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='h-6 border-rose-500/40 px-2 text-sm text-rose-600 dark:text-rose-400'
|
||||
>
|
||||
{t('required')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='py-2 align-top'>
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='h-7 rounded-full px-2.5 font-mono text-sm font-normal'
|
||||
>
|
||||
{p.type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className='py-2 align-top'>
|
||||
<ParamRangeCell param={p} />
|
||||
</TableCell>
|
||||
<TableCell className='text-muted-foreground py-2 align-top'>
|
||||
{t(p.descriptionKey)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
))}
|
||||
</TableBody>
|
||||
</StaticDataTable>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -671,34 +672,32 @@ function RateLimitsSection(props: { model: PricingModel }) {
|
||||
return (
|
||||
<section>
|
||||
<SectionTitle icon={Gauge}>{t('Rate limits')}</SectionTitle>
|
||||
<div className='border-border/60 overflow-hidden rounded-lg border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className='bg-muted/30 hover:bg-muted/30'>
|
||||
<TableHead className='h-9'>{t('Group')}</TableHead>
|
||||
<TableHead className='h-9 text-right'>RPM</TableHead>
|
||||
<TableHead className='h-9 text-right'>TPM</TableHead>
|
||||
<TableHead className='h-9 text-right'>RPD</TableHead>
|
||||
<StaticDataTable className={tableStyles.sectionContainer}>
|
||||
<TableHeader>
|
||||
<TableRow className={tableStyles.mutedHeaderRow}>
|
||||
<TableHead className='h-9'>{t('Group')}</TableHead>
|
||||
<TableHead className='h-9 text-right'>RPM</TableHead>
|
||||
<TableHead className='h-9 text-right'>TPM</TableHead>
|
||||
<TableHead className='h-9 text-right'>RPD</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{limits.map((l) => (
|
||||
<TableRow key={l.group} className='hover:bg-muted/20'>
|
||||
<TableCell className='py-2 font-mono'>{l.group}</TableCell>
|
||||
<TableCell className={tableStyles.topNumericCell}>
|
||||
{formatRateLimit(l.rpm)}
|
||||
</TableCell>
|
||||
<TableCell className={tableStyles.topNumericCell}>
|
||||
{formatRateLimit(l.tpm)}
|
||||
</TableCell>
|
||||
<TableCell className={tableStyles.topNumericCell}>
|
||||
{formatRateLimit(l.rpd)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{limits.map((l) => (
|
||||
<TableRow key={l.group} className='hover:bg-muted/20'>
|
||||
<TableCell className='py-2 font-mono'>{l.group}</TableCell>
|
||||
<TableCell className='py-2 text-right font-mono'>
|
||||
{formatRateLimit(l.rpm)}
|
||||
</TableCell>
|
||||
<TableCell className='py-2 text-right font-mono'>
|
||||
{formatRateLimit(l.tpm)}
|
||||
</TableCell>
|
||||
<TableCell className='py-2 text-right font-mono'>
|
||||
{formatRateLimit(l.rpd)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
))}
|
||||
</TableBody>
|
||||
</StaticDataTable>
|
||||
<p className='text-muted-foreground mt-2 text-[11px] leading-relaxed'>
|
||||
{t(
|
||||
'RPM = requests per minute, TPM = tokens per minute, RPD = requests per day. Limits apply per token group.'
|
||||
|
||||
@@ -26,13 +26,16 @@ import {
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
} from '@/components/data-table'
|
||||
import {
|
||||
StaticDataTable,
|
||||
staticDataTableClassNames as tableStyles,
|
||||
} from '@/components/data-table'
|
||||
import {
|
||||
buildAppRankings,
|
||||
formatTokenVolume,
|
||||
@@ -123,9 +126,6 @@ export function ModelDetailsApps(props: { model: PricingModel }) {
|
||||
|
||||
const totalMonthlyTokens = apps.reduce((s, a) => s + a.monthly_tokens, 0)
|
||||
const top = apps[0]
|
||||
const headerCellClass =
|
||||
'text-muted-foreground py-2 text-[10px] font-medium tracking-wider uppercase'
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='grid grid-cols-1 gap-2 sm:grid-cols-3'>
|
||||
@@ -165,60 +165,72 @@ export function ModelDetailsApps(props: { model: PricingModel }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='overflow-x-auto rounded-lg border'>
|
||||
<Table className='text-sm'>
|
||||
<TableHeader>
|
||||
<TableRow className='hover:bg-transparent'>
|
||||
<TableHead className={cn(headerCellClass, 'w-12')}>#</TableHead>
|
||||
<TableHead className={headerCellClass}>{t('App')}</TableHead>
|
||||
<TableHead
|
||||
className={cn(headerCellClass, 'hidden md:table-cell')}
|
||||
>
|
||||
{t('Category')}
|
||||
</TableHead>
|
||||
<TableHead className={`${headerCellClass} text-right`}>
|
||||
{t('Monthly tokens')}
|
||||
</TableHead>
|
||||
<TableHead className={`${headerCellClass} text-right`}>
|
||||
{t('30d change')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{apps.map((app) => (
|
||||
<TableRow key={`${app.rank}-${app.name}`}>
|
||||
<TableCell className='py-2.5'>
|
||||
<RankBadge rank={app.rank} />
|
||||
</TableCell>
|
||||
<TableCell className='py-2.5'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<span className='bg-muted text-muted-foreground inline-flex size-7 shrink-0 items-center justify-center rounded-md font-bold'>
|
||||
{app.initial}
|
||||
</span>
|
||||
<div className='min-w-0'>
|
||||
<div className='text-sm font-medium'>
|
||||
<AppLink app={app} />
|
||||
</div>
|
||||
<p className='text-muted-foreground line-clamp-1 text-sm'>
|
||||
{app.description}
|
||||
</p>
|
||||
<StaticDataTable className='rounded-lg' tableClassName='text-sm'>
|
||||
<TableHeader>
|
||||
<TableRow className={tableStyles.compactHeaderRow}>
|
||||
<TableHead className={cn(tableStyles.compactHeaderCell, 'w-12')}>
|
||||
#
|
||||
</TableHead>
|
||||
<TableHead className={tableStyles.compactHeaderCell}>
|
||||
{t('App')}
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className={cn(
|
||||
tableStyles.compactHeaderCell,
|
||||
'hidden md:table-cell'
|
||||
)}
|
||||
>
|
||||
{t('Category')}
|
||||
</TableHead>
|
||||
<TableHead className={tableStyles.compactHeaderCellRight}>
|
||||
{t('Monthly tokens')}
|
||||
</TableHead>
|
||||
<TableHead className={tableStyles.compactHeaderCellRight}>
|
||||
{t('30d change')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{apps.map((app) => (
|
||||
<TableRow key={`${app.rank}-${app.name}`}>
|
||||
<TableCell className={tableStyles.compactCell}>
|
||||
<RankBadge rank={app.rank} />
|
||||
</TableCell>
|
||||
<TableCell className={tableStyles.compactCell}>
|
||||
<div className='flex items-center gap-3'>
|
||||
<span className='bg-muted text-muted-foreground inline-flex size-7 shrink-0 items-center justify-center rounded-md font-bold'>
|
||||
{app.initial}
|
||||
</span>
|
||||
<div className='min-w-0'>
|
||||
<div className='text-sm font-medium'>
|
||||
<AppLink app={app} />
|
||||
</div>
|
||||
<p className='text-muted-foreground line-clamp-1 text-sm'>
|
||||
{app.description}
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='text-muted-foreground hidden py-2.5 md:table-cell'>
|
||||
{app.category}
|
||||
</TableCell>
|
||||
<TableCell className='py-2.5 text-right font-mono tabular-nums'>
|
||||
{formatTokenVolume(app.monthly_tokens)}
|
||||
</TableCell>
|
||||
<TableCell className='py-2.5 text-right'>
|
||||
<GrowthChip value={app.growth_pct} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={cn(
|
||||
tableStyles.compactMutedCell,
|
||||
'hidden md:table-cell'
|
||||
)}
|
||||
>
|
||||
{app.category}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={cn(tableStyles.compactNumericCell, 'tabular-nums')}
|
||||
>
|
||||
{formatTokenVolume(app.monthly_tokens)}
|
||||
</TableCell>
|
||||
<TableCell className={cn(tableStyles.compactCell, 'text-right')}>
|
||||
<GrowthChip value={app.growth_pct} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</StaticDataTable>
|
||||
|
||||
<p className='text-muted-foreground/60 text-[11px] leading-relaxed'>
|
||||
{t(
|
||||
|
||||
@@ -30,6 +30,14 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import {
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/data-table'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import type { Modality } from '../types'
|
||||
|
||||
type IconComponent = React.ComponentType<{ className?: string }>
|
||||
@@ -96,18 +104,18 @@ export function ModalitiesMatrix(props: {
|
||||
const outputSet = new Set(props.output)
|
||||
|
||||
const renderRow = (label: string, set: Set<Modality>) => (
|
||||
<tr>
|
||||
<th
|
||||
<TableRow>
|
||||
<TableHead
|
||||
scope='row'
|
||||
className='text-muted-foreground bg-muted/30 px-3 py-2 text-left text-[11px] font-medium tracking-wider uppercase'
|
||||
>
|
||||
{label}
|
||||
</th>
|
||||
</TableHead>
|
||||
{ALL_MODALITIES.map((modality) => {
|
||||
const enabled = set.has(modality)
|
||||
const Icon = MODALITY_META[modality].icon
|
||||
return (
|
||||
<td
|
||||
<TableCell
|
||||
key={modality}
|
||||
className={cn(
|
||||
'border-l px-3 py-2 text-center',
|
||||
@@ -135,39 +143,37 @@ export function ModalitiesMatrix(props: {
|
||||
>
|
||||
<Icon className='size-4' />
|
||||
</span>
|
||||
</td>
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
</TableRow>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='overflow-x-auto rounded-lg border'>
|
||||
<table className='w-full text-sm'>
|
||||
<thead>
|
||||
<tr className='bg-muted/40'>
|
||||
<th
|
||||
<StaticDataTable className='rounded-lg' tableClassName='text-sm'>
|
||||
<TableHeader>
|
||||
<TableRow className='bg-muted/40'>
|
||||
<TableHead
|
||||
scope='col'
|
||||
className='text-muted-foreground px-3 py-2 text-left text-[11px] font-medium tracking-wider uppercase'
|
||||
>
|
||||
{t('Modality')}
|
||||
</TableHead>
|
||||
{ALL_MODALITIES.map((modality) => (
|
||||
<TableHead
|
||||
key={modality}
|
||||
scope='col'
|
||||
className='text-muted-foreground px-3 py-2 text-left text-[11px] font-medium tracking-wider uppercase'
|
||||
className='text-muted-foreground border-l px-3 py-2 text-center text-[11px] font-medium tracking-wider uppercase'
|
||||
>
|
||||
{t('Modality')}
|
||||
</th>
|
||||
{ALL_MODALITIES.map((modality) => (
|
||||
<th
|
||||
key={modality}
|
||||
scope='col'
|
||||
className='text-muted-foreground border-l px-3 py-2 text-center text-[11px] font-medium tracking-wider uppercase'
|
||||
>
|
||||
{t(MODALITY_META[modality].labelKey)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{renderRow(t('Input'), inputSet)}
|
||||
{renderRow(t('Output'), outputSet)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{t(MODALITY_META[modality].labelKey)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{renderRow(t('Input'), inputSet)}
|
||||
{renderRow(t('Output'), outputSet)}
|
||||
</TableBody>
|
||||
</StaticDataTable>
|
||||
)
|
||||
}
|
||||
|
||||
+51
-51
@@ -22,13 +22,16 @@ import { AlertTriangle, HeartPulse, Timer } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
} from '@/components/data-table'
|
||||
import {
|
||||
StaticDataTable,
|
||||
staticDataTableClassNames as tableStyles,
|
||||
} from '@/components/data-table'
|
||||
import { GroupBadge } from '@/components/group-badge'
|
||||
import { getPerfMetrics } from '@/features/performance-metrics/api'
|
||||
import {
|
||||
@@ -218,9 +221,6 @@ export function ModelDetailsPerformance(props: { model: PricingModel }) {
|
||||
intent = 'default'
|
||||
}
|
||||
|
||||
const headerCellClass =
|
||||
'text-muted-foreground py-2 text-[10px] font-medium tracking-wider uppercase'
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='grid grid-cols-1 gap-2 sm:grid-cols-3'>
|
||||
@@ -256,53 +256,53 @@ export function ModelDetailsPerformance(props: { model: PricingModel }) {
|
||||
title={t('Per-group performance')}
|
||||
description={t('Average latency, TTFT, TPS, and success rate')}
|
||||
/>
|
||||
<div className='overflow-x-auto rounded-lg border'>
|
||||
<Table className='text-sm'>
|
||||
<TableHeader>
|
||||
<TableRow className='hover:bg-transparent'>
|
||||
<TableHead className={headerCellClass}>{t('Group')}</TableHead>
|
||||
<TableHead className={`${headerCellClass} text-right`}>
|
||||
TPS
|
||||
</TableHead>
|
||||
<TableHead className={`${headerCellClass} text-right`}>
|
||||
{t('Average TTFT')}
|
||||
</TableHead>
|
||||
<TableHead className={`${headerCellClass} text-right`}>
|
||||
{t('Average latency')}
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className={`${headerCellClass} min-w-[180px] text-left`}
|
||||
>
|
||||
{t('Success rate')}
|
||||
</TableHead>
|
||||
<StaticDataTable className='rounded-lg' tableClassName='text-sm'>
|
||||
<TableHeader>
|
||||
<TableRow className={tableStyles.compactHeaderRow}>
|
||||
<TableHead className={tableStyles.compactHeaderCell}>
|
||||
{t('Group')}
|
||||
</TableHead>
|
||||
<TableHead className={tableStyles.compactHeaderCellRight}>
|
||||
TPS
|
||||
</TableHead>
|
||||
<TableHead className={tableStyles.compactHeaderCellRight}>
|
||||
{t('Average TTFT')}
|
||||
</TableHead>
|
||||
<TableHead className={tableStyles.compactHeaderCellRight}>
|
||||
{t('Average latency')}
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className={cn(tableStyles.compactHeaderCell, 'min-w-[180px]')}
|
||||
>
|
||||
{t('Success rate')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{performances.map((perf) => (
|
||||
<TableRow key={perf.group}>
|
||||
<TableCell className={tableStyles.compactCell}>
|
||||
<GroupBadge group={perf.group} size='sm' />
|
||||
</TableCell>
|
||||
<TableCell className={tableStyles.compactNumericCell}>
|
||||
{formatThroughput(perf.avg_tps)}
|
||||
</TableCell>
|
||||
<TableCell className={tableStyles.compactNumericCell}>
|
||||
{formatLatency(perf.avg_ttft_ms)}
|
||||
</TableCell>
|
||||
<TableCell className={tableStyles.compactMutedNumericCell}>
|
||||
{formatLatency(perf.avg_latency_ms)}
|
||||
</TableCell>
|
||||
<TableCell className={tableStyles.compactCell}>
|
||||
<UptimeSparkline
|
||||
size='sm'
|
||||
series={uptimeByGroup[perf.group] ?? []}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{performances.map((perf) => (
|
||||
<TableRow key={perf.group}>
|
||||
<TableCell className='py-2.5'>
|
||||
<GroupBadge group={perf.group} size='sm' />
|
||||
</TableCell>
|
||||
<TableCell className='py-2.5 text-right font-mono'>
|
||||
{formatThroughput(perf.avg_tps)}
|
||||
</TableCell>
|
||||
<TableCell className='py-2.5 text-right font-mono'>
|
||||
{formatLatency(perf.avg_ttft_ms)}
|
||||
</TableCell>
|
||||
<TableCell className='text-muted-foreground py-2.5 text-right font-mono'>
|
||||
{formatLatency(perf.avg_latency_ms)}
|
||||
</TableCell>
|
||||
<TableCell className='py-2.5'>
|
||||
<UptimeSparkline
|
||||
size='sm'
|
||||
series={uptimeByGroup[perf.group] ?? []}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
))}
|
||||
</TableBody>
|
||||
</StaticDataTable>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
|
||||
+146
-147
@@ -32,16 +32,16 @@ import {
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { CopyButton } from '@/components/copy-button'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { CopyButton } from '@/components/copy-button'
|
||||
} from '@/components/data-table'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import { sideDrawerContentClassName } from '@/components/drawer-layout'
|
||||
import { GroupBadge } from '@/components/group-badge'
|
||||
import { PublicLayout } from '@/components/layout'
|
||||
@@ -269,9 +269,7 @@ function ModelHeader(props: { model: PricingModel }) {
|
||||
const { t } = useTranslation()
|
||||
const model = props.model
|
||||
const modelIconKey = model.icon || model.vendor_icon
|
||||
const modelIcon = modelIconKey
|
||||
? getLobeIcon(modelIconKey, 20)
|
||||
: null
|
||||
const modelIcon = modelIconKey ? getLobeIcon(modelIconKey, 20) : null
|
||||
const description = model.description || model.vendor_description || null
|
||||
const tags = parseTags(model.tags)
|
||||
const isSpecialExpression =
|
||||
@@ -707,56 +705,57 @@ function GroupPricingSection(props: {
|
||||
{ratio}x
|
||||
</span>
|
||||
</div>
|
||||
<div className='overflow-x-auto'>
|
||||
<Table className='text-sm'>
|
||||
<TableHeader>
|
||||
<TableRow className='hover:bg-transparent'>
|
||||
<TableHead className={thClass}>{t('Tier')}</TableHead>
|
||||
{priceFields.map((entry) => (
|
||||
<TableHead
|
||||
key={entry.field}
|
||||
className={`${thClass} text-right`}
|
||||
>
|
||||
{t(entry.shortLabel)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{dynamicTiers.map((tier, tierIndex) => {
|
||||
const entries = getDynamicPriceEntries(tier, {
|
||||
tokenUnit: props.tokenUnit,
|
||||
showRechargePrice,
|
||||
priceRate: props.priceRate,
|
||||
usdExchangeRate: props.usdExchangeRate,
|
||||
groupRatioMultiplier: ratio,
|
||||
})
|
||||
const entryMap = new Map(
|
||||
entries.map((entry) => [entry.field, entry])
|
||||
)
|
||||
<StaticDataTable
|
||||
className='rounded-none border-0'
|
||||
tableClassName='text-sm'
|
||||
>
|
||||
<TableHeader>
|
||||
<TableRow className='hover:bg-transparent'>
|
||||
<TableHead className={thClass}>{t('Tier')}</TableHead>
|
||||
{priceFields.map((entry) => (
|
||||
<TableHead
|
||||
key={entry.field}
|
||||
className={`${thClass} text-right`}
|
||||
>
|
||||
{t(entry.shortLabel)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{dynamicTiers.map((tier, tierIndex) => {
|
||||
const entries = getDynamicPriceEntries(tier, {
|
||||
tokenUnit: props.tokenUnit,
|
||||
showRechargePrice,
|
||||
priceRate: props.priceRate,
|
||||
usdExchangeRate: props.usdExchangeRate,
|
||||
groupRatioMultiplier: ratio,
|
||||
})
|
||||
const entryMap = new Map(
|
||||
entries.map((entry) => [entry.field, entry])
|
||||
)
|
||||
|
||||
return (
|
||||
<TableRow key={`${group}-${tier.label || tierIndex}`}>
|
||||
<TableCell className='text-muted-foreground py-2.5'>
|
||||
{tier.label || t('Default')}
|
||||
</TableCell>
|
||||
{priceFields.map((fieldEntry) => {
|
||||
const entry = entryMap.get(fieldEntry.field)
|
||||
return (
|
||||
<TableCell
|
||||
key={fieldEntry.field}
|
||||
className='py-2.5 text-right font-mono'
|
||||
>
|
||||
{entry?.formatted ?? '-'}
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
return (
|
||||
<TableRow key={`${group}-${tier.label || tierIndex}`}>
|
||||
<TableCell className='text-muted-foreground py-2.5'>
|
||||
{tier.label || t('Default')}
|
||||
</TableCell>
|
||||
{priceFields.map((fieldEntry) => {
|
||||
const entry = entryMap.get(fieldEntry.field)
|
||||
return (
|
||||
<TableCell
|
||||
key={fieldEntry.field}
|
||||
className='py-2.5 text-right font-mono'
|
||||
>
|
||||
{entry?.formatted ?? '-'}
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</StaticDataTable>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -772,108 +771,108 @@ function GroupPricingSection(props: {
|
||||
<section>
|
||||
<SectionTitle>{t('Pricing by Group')}</SectionTitle>
|
||||
<AutoGroupChain model={props.model} autoGroups={props.autoGroups} />
|
||||
<div className='-mx-4 overflow-x-auto sm:mx-0'>
|
||||
<Table className='text-sm'>
|
||||
<TableHeader>
|
||||
<TableRow className='hover:bg-transparent'>
|
||||
<TableHead className={thClass}>{t('Group')}</TableHead>
|
||||
<TableHead className={thClass}>{t('Ratio')}</TableHead>
|
||||
{isTokenBased ? (
|
||||
<>
|
||||
<TableHead className={`${thClass} text-right`}>
|
||||
{t('Input')}
|
||||
</TableHead>
|
||||
<TableHead className={`${thClass} text-right`}>
|
||||
{t('Output')}
|
||||
</TableHead>
|
||||
{extraPriceTypes.map((ep) => (
|
||||
<TableHead
|
||||
key={ep.type}
|
||||
className={`${thClass} text-right`}
|
||||
>
|
||||
{ep.label}
|
||||
</TableHead>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<StaticDataTable
|
||||
className='-mx-4 rounded-none border-0 sm:mx-0'
|
||||
tableClassName='text-sm'
|
||||
>
|
||||
<TableHeader>
|
||||
<TableRow className='hover:bg-transparent'>
|
||||
<TableHead className={thClass}>{t('Group')}</TableHead>
|
||||
<TableHead className={thClass}>{t('Ratio')}</TableHead>
|
||||
{isTokenBased ? (
|
||||
<>
|
||||
<TableHead className={`${thClass} text-right`}>
|
||||
{t('Price')}
|
||||
{t('Input')}
|
||||
</TableHead>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{availableGroups.map((group) => {
|
||||
const ratio = props.groupRatio[group] || 1
|
||||
return (
|
||||
<TableRow key={group}>
|
||||
<TableCell className='py-2.5'>
|
||||
<GroupBadge group={group} size='sm' />
|
||||
</TableCell>
|
||||
<TableCell className='text-muted-foreground py-2.5 font-mono'>
|
||||
{ratio}x
|
||||
</TableCell>
|
||||
{isTokenBased ? (
|
||||
<>
|
||||
<TableCell className='py-2.5 text-right font-mono'>
|
||||
{formatGroupPrice(
|
||||
props.model,
|
||||
group,
|
||||
'input',
|
||||
props.tokenUnit,
|
||||
showRechargePrice,
|
||||
props.priceRate,
|
||||
props.usdExchangeRate,
|
||||
props.groupRatio
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className='py-2.5 text-right font-mono'>
|
||||
{formatGroupPrice(
|
||||
props.model,
|
||||
group,
|
||||
'output',
|
||||
props.tokenUnit,
|
||||
showRechargePrice,
|
||||
props.priceRate,
|
||||
props.usdExchangeRate,
|
||||
props.groupRatio
|
||||
)}
|
||||
</TableCell>
|
||||
{extraPriceTypes.map((ep) => (
|
||||
<TableCell
|
||||
key={ep.type}
|
||||
className='py-2.5 text-right font-mono'
|
||||
>
|
||||
{formatGroupPrice(
|
||||
props.model,
|
||||
group,
|
||||
ep.type,
|
||||
props.tokenUnit,
|
||||
showRechargePrice,
|
||||
props.priceRate,
|
||||
props.usdExchangeRate,
|
||||
props.groupRatio
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<TableHead className={`${thClass} text-right`}>
|
||||
{t('Output')}
|
||||
</TableHead>
|
||||
{extraPriceTypes.map((ep) => (
|
||||
<TableHead key={ep.type} className={`${thClass} text-right`}>
|
||||
{ep.label}
|
||||
</TableHead>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<TableHead className={`${thClass} text-right`}>
|
||||
{t('Price')}
|
||||
</TableHead>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{availableGroups.map((group) => {
|
||||
const ratio = props.groupRatio[group] || 1
|
||||
return (
|
||||
<TableRow key={group}>
|
||||
<TableCell className='py-2.5'>
|
||||
<GroupBadge group={group} size='sm' />
|
||||
</TableCell>
|
||||
<TableCell className='text-muted-foreground py-2.5 font-mono'>
|
||||
{ratio}x
|
||||
</TableCell>
|
||||
{isTokenBased ? (
|
||||
<>
|
||||
<TableCell className='py-2.5 text-right font-mono'>
|
||||
{formatFixedPrice(
|
||||
{formatGroupPrice(
|
||||
props.model,
|
||||
group,
|
||||
'input',
|
||||
props.tokenUnit,
|
||||
showRechargePrice,
|
||||
props.priceRate,
|
||||
props.usdExchangeRate,
|
||||
props.groupRatio
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<TableCell className='py-2.5 text-right font-mono'>
|
||||
{formatGroupPrice(
|
||||
props.model,
|
||||
group,
|
||||
'output',
|
||||
props.tokenUnit,
|
||||
showRechargePrice,
|
||||
props.priceRate,
|
||||
props.usdExchangeRate,
|
||||
props.groupRatio
|
||||
)}
|
||||
</TableCell>
|
||||
{extraPriceTypes.map((ep) => (
|
||||
<TableCell
|
||||
key={ep.type}
|
||||
className='py-2.5 text-right font-mono'
|
||||
>
|
||||
{formatGroupPrice(
|
||||
props.model,
|
||||
group,
|
||||
ep.type,
|
||||
props.tokenUnit,
|
||||
showRechargePrice,
|
||||
props.priceRate,
|
||||
props.usdExchangeRate,
|
||||
props.groupRatio
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<TableCell className='py-2.5 text-right font-mono'>
|
||||
{formatFixedPrice(
|
||||
props.model,
|
||||
group,
|
||||
showRechargePrice,
|
||||
props.priceRate,
|
||||
props.usdExchangeRate,
|
||||
props.groupRatio
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</StaticDataTable>
|
||||
<div className='-mx-4 sm:mx-0'>
|
||||
{isTokenBased && (
|
||||
<p className='text-muted-foreground/40 mt-1.5 px-4 text-[10px] sm:px-0'>
|
||||
{t('Prices shown per')} {tokenUnitLabel} tokens
|
||||
|
||||
+29
-71
@@ -17,23 +17,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useState, useCallback } from 'react'
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
useReactTable,
|
||||
type PaginationState,
|
||||
} from '@tanstack/react-table'
|
||||
import { type Row, type PaginationState } from '@tanstack/react-table'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { TableSkeleton, TableEmpty } from '@/components/data-table'
|
||||
DataTableRow,
|
||||
DataTableView,
|
||||
useDataTable,
|
||||
} from '@/components/data-table'
|
||||
import { DataTablePagination } from '@/components/data-table/pagination'
|
||||
import { DEFAULT_PRICING_PAGE_SIZE, DEFAULT_TOKEN_UNIT } from '../constants'
|
||||
import type { PricingModel, TokenUnit } from '../types'
|
||||
@@ -73,15 +63,16 @@ export function PricingTable(props: PricingTableProps) {
|
||||
showRechargePrice,
|
||||
})
|
||||
|
||||
const table = useReactTable({
|
||||
const { table } = useDataTable({
|
||||
data: models,
|
||||
columns,
|
||||
pageCount: Math.ceil(models.length / pagination.pageSize),
|
||||
state: { pagination },
|
||||
pagination,
|
||||
onPaginationChange: setPagination,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
manualPagination: false,
|
||||
withFilteredRowModel: false,
|
||||
withSortedRowModel: false,
|
||||
withFacetedRowModel: false,
|
||||
})
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
@@ -93,58 +84,25 @@ export function PricingTable(props: PricingTableProps) {
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='overflow-hidden rounded-lg border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
style={{ width: header.getSize() }}
|
||||
className='text-muted-foreground font-medium'
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableSkeleton table={table} keyPrefix='pricing-skeleton' />
|
||||
) : table.getRowModel().rows.length === 0 ? (
|
||||
<TableEmpty
|
||||
colSpan={columns.length}
|
||||
title={t('No Models Found')}
|
||||
description={t('No models match your current filters.')}
|
||||
/>
|
||||
) : (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
onClick={() => handleRowClick(row.original)}
|
||||
className='hover:bg-muted/30 cursor-pointer transition-colors'
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<DataTableView
|
||||
table={table}
|
||||
isLoading={isLoading}
|
||||
emptyTitle={t('No Models Found')}
|
||||
emptyDescription={t('No models match your current filters.')}
|
||||
skeletonKeyPrefix='pricing-skeleton'
|
||||
applyHeaderSize
|
||||
getColumnClassName={(_columnId, kind) =>
|
||||
kind === 'header' ? 'text-muted-foreground font-medium' : undefined
|
||||
}
|
||||
renderRow={(row: Row<PricingModel>) => (
|
||||
<DataTableRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
className='hover:bg-muted/30 cursor-pointer transition-colors'
|
||||
onClick={() => handleRowClick(row.original)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!isLoading && models.length > 0 && <DataTablePagination table={table} />}
|
||||
</div>
|
||||
|
||||
+8
-39
@@ -16,20 +16,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getRouteApi } from '@tanstack/react-router'
|
||||
import {
|
||||
type SortingState,
|
||||
type VisibilityState,
|
||||
getCoreRowModel,
|
||||
getFacetedRowModel,
|
||||
getFacetedUniqueValues,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table'
|
||||
import { useMediaQuery } from '@/hooks'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useTableUrlState } from '@/hooks/use-table-url-state'
|
||||
@@ -37,6 +26,7 @@ import {
|
||||
DISABLED_ROW_DESKTOP,
|
||||
DISABLED_ROW_MOBILE,
|
||||
DataTablePage,
|
||||
useDataTable,
|
||||
} from '@/components/data-table'
|
||||
import { getRedemptions, searchRedemptions } from '../api'
|
||||
import { REDEMPTION_STATUS, getRedemptionStatusOptions } from '../constants'
|
||||
@@ -60,9 +50,6 @@ export function RedemptionsTable() {
|
||||
const columns = useRedemptionsColumns()
|
||||
const { refreshTrigger } = useRedemptions()
|
||||
const isMobile = useMediaQuery('(max-width: 640px)')
|
||||
const [rowSelection, setRowSelection] = useState({})
|
||||
const [sorting, setSorting] = useState<SortingState>([])
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
|
||||
|
||||
const {
|
||||
globalFilter,
|
||||
@@ -110,21 +97,13 @@ export function RedemptionsTable() {
|
||||
|
||||
const redemptions = data?.items || []
|
||||
|
||||
const table = useReactTable({
|
||||
const { table } = useDataTable({
|
||||
data: redemptions,
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
columnFilters,
|
||||
globalFilter,
|
||||
pagination,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
columnFilters,
|
||||
globalFilter,
|
||||
pagination,
|
||||
globalFilterFn: (row, _columnId, filterValue) => {
|
||||
const name = String(row.getValue('name')).toLowerCase()
|
||||
const id = String(row.getValue('id'))
|
||||
@@ -132,24 +111,14 @@ export function RedemptionsTable() {
|
||||
|
||||
return name.includes(searchValue) || id.includes(searchValue)
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedRowModel: getFacetedRowModel(),
|
||||
getFacetedUniqueValues: getFacetedUniqueValues(),
|
||||
onPaginationChange,
|
||||
onGlobalFilterChange,
|
||||
onColumnFiltersChange,
|
||||
manualPagination: !globalFilter,
|
||||
pageCount: Math.ceil((data?.total || 0) / pagination.pageSize),
|
||||
totalCount: data?.total || 0,
|
||||
ensurePageInRange,
|
||||
})
|
||||
|
||||
const pageCount = table.getPageCount()
|
||||
useEffect(() => {
|
||||
ensurePageInRange(pageCount)
|
||||
}, [pageCount, ensurePageInRange])
|
||||
|
||||
const redemptionStatusOptions = useMemo(
|
||||
() => getRedemptionStatusOptions(t),
|
||||
[t]
|
||||
|
||||
+102
-105
@@ -36,15 +36,18 @@ import {
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
} from '@/components/ui/sheet'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
} from '@/components/data-table'
|
||||
import {
|
||||
StaticDataTable,
|
||||
StaticDataTableEmptyRow,
|
||||
} from '@/components/data-table'
|
||||
import {
|
||||
sideDrawerContentClassName,
|
||||
sideDrawerFormClassName,
|
||||
@@ -245,112 +248,106 @@ export function UserSubscriptionsDialog(props: Props) {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='rounded-md border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>{t('Plan')}</TableHead>
|
||||
<TableHead>{t('Status')}</TableHead>
|
||||
<TableHead>{t('Validity')}</TableHead>
|
||||
<TableHead>{t('Total Quota')}</TableHead>
|
||||
<TableHead className='text-right'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className='py-8 text-center'>
|
||||
{t('Loading...')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : subs.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={6}
|
||||
className='text-muted-foreground py-8 text-center'
|
||||
>
|
||||
{t('No subscription records')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
subs.map((record) => {
|
||||
const sub = record.subscription
|
||||
const now = Date.now() / 1000
|
||||
const isExpired =
|
||||
(sub.end_time || 0) > 0 && sub.end_time < now
|
||||
const isActive = sub.status === 'active' && !isExpired
|
||||
const total = Number(sub.amount_total || 0)
|
||||
const used = Number(sub.amount_used || 0)
|
||||
<StaticDataTable>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>{t('Plan')}</TableHead>
|
||||
<TableHead>{t('Status')}</TableHead>
|
||||
<TableHead>{t('Validity')}</TableHead>
|
||||
<TableHead>{t('Total Quota')}</TableHead>
|
||||
<TableHead className='text-right'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<StaticDataTableEmptyRow colSpan={6} className='py-8'>
|
||||
{t('Loading...')}
|
||||
</StaticDataTableEmptyRow>
|
||||
) : subs.length === 0 ? (
|
||||
<StaticDataTableEmptyRow
|
||||
colSpan={6}
|
||||
className='text-muted-foreground py-8'
|
||||
>
|
||||
{t('No subscription records')}
|
||||
</StaticDataTableEmptyRow>
|
||||
) : (
|
||||
subs.map((record) => {
|
||||
const sub = record.subscription
|
||||
const now = Date.now() / 1000
|
||||
const isExpired =
|
||||
(sub.end_time || 0) > 0 && sub.end_time < now
|
||||
const isActive = sub.status === 'active' && !isExpired
|
||||
const total = Number(sub.amount_total || 0)
|
||||
const used = Number(sub.amount_used || 0)
|
||||
|
||||
return (
|
||||
<TableRow key={sub.id}>
|
||||
<TableCell>
|
||||
<TableId value={sub.id} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
return (
|
||||
<TableRow key={sub.id}>
|
||||
<TableCell>
|
||||
<TableId value={sub.id} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className='font-medium'>
|
||||
{planTitleMap.get(sub.plan_id) ||
|
||||
`#${sub.plan_id}`}
|
||||
</div>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
{t('Source')}: {sub.source || '-'}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<SubscriptionStatusBadge sub={sub} t={t} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='text-sm'>
|
||||
<div>
|
||||
<div className='font-medium'>
|
||||
{planTitleMap.get(sub.plan_id) ||
|
||||
`#${sub.plan_id}`}
|
||||
</div>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
{t('Source')}: {sub.source || '-'}
|
||||
</div>
|
||||
{t('Start')}: {formatTimestamp(sub.start_time)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<SubscriptionStatusBadge sub={sub} t={t} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='text-sm'>
|
||||
<div>
|
||||
{t('Start')}: {formatTimestamp(sub.start_time)}
|
||||
</div>
|
||||
<div>
|
||||
{t('End')}: {formatTimestamp(sub.end_time)}
|
||||
</div>
|
||||
<div>
|
||||
{t('End')}: {formatTimestamp(sub.end_time)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{total > 0 ? `${used}/${total}` : t('Unlimited')}
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<div className='flex justify-end gap-1'>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outline'
|
||||
disabled={!isActive}
|
||||
onClick={() =>
|
||||
setConfirmAction({
|
||||
type: 'invalidate',
|
||||
subId: sub.id,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('Invalidate')}
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='destructive'
|
||||
onClick={() =>
|
||||
setConfirmAction({
|
||||
type: 'delete',
|
||||
subId: sub.id,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('Delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{total > 0 ? `${used}/${total}` : t('Unlimited')}
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<div className='flex justify-end gap-1'>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outline'
|
||||
disabled={!isActive}
|
||||
onClick={() =>
|
||||
setConfirmAction({
|
||||
type: 'invalidate',
|
||||
subId: sub.id,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('Invalidate')}
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='destructive'
|
||||
onClick={() =>
|
||||
setConfirmAction({
|
||||
type: 'delete',
|
||||
subId: sub.id,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('Delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</StaticDataTable>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
@@ -16,18 +16,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
type SortingState,
|
||||
type VisibilityState,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { DataTablePage } from '@/components/data-table'
|
||||
import { DataTablePage, useDataTable } from '@/components/data-table'
|
||||
import { getAdminPlans } from '../api'
|
||||
import { useSubscriptionsColumns } from './subscriptions-columns'
|
||||
import { useSubscriptions } from './subscriptions-provider'
|
||||
@@ -36,8 +28,6 @@ export function SubscriptionsTable() {
|
||||
const { t } = useTranslation()
|
||||
const columns = useSubscriptionsColumns()
|
||||
const { refreshTrigger } = useSubscriptions()
|
||||
const [sorting, setSorting] = useState<SortingState>([])
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['admin-subscription-plans', refreshTrigger],
|
||||
@@ -50,15 +40,11 @@ export function SubscriptionsTable() {
|
||||
|
||||
const plans = useMemo(() => data || [], [data])
|
||||
|
||||
const table = useReactTable({
|
||||
const { table } = useDataTable({
|
||||
data: plans,
|
||||
columns,
|
||||
state: { sorting, columnVisibility },
|
||||
onSortingChange: setSorting,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
withFilteredRowModel: false,
|
||||
withFacetedRowModel: false,
|
||||
})
|
||||
|
||||
return (
|
||||
|
||||
+5
-5
@@ -20,15 +20,15 @@ import { useState } from 'react'
|
||||
import { Pencil, Trash2, Plus } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
} from '@/components/data-table'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { useDeleteProvider } from '../hooks/use-custom-oauth-mutations'
|
||||
import type { CustomOAuthProvider } from '../types'
|
||||
@@ -69,7 +69,7 @@ export function ProviderTable(props: ProviderTableProps) {
|
||||
{t('No custom OAuth providers configured yet.')}
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<StaticDataTable>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Icon')}</TableHead>
|
||||
@@ -129,7 +129,7 @@ export function ProviderTable(props: ProviderTableProps) {
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</StaticDataTable>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
|
||||
+103
-104
@@ -54,15 +54,18 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
} from '@/components/data-table'
|
||||
import {
|
||||
StaticDataTable,
|
||||
StaticDataTableEmptyRow,
|
||||
} from '@/components/data-table'
|
||||
import { DateTimePicker } from '@/components/datetime-picker'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
@@ -350,109 +353,105 @@ export function AnnouncementsSection({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='rounded-md border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className='w-12'>
|
||||
<Checkbox
|
||||
checked={
|
||||
selectedIds.length === announcements.length &&
|
||||
announcements.length > 0
|
||||
}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>{t('Content')}</TableHead>
|
||||
<TableHead>{t('Publish Date')}</TableHead>
|
||||
<TableHead>{t('Type')}</TableHead>
|
||||
<TableHead>{t('Extra')}</TableHead>
|
||||
<TableHead className='w-32'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedAnnouncements.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className='h-24 text-center'>
|
||||
{t(
|
||||
'No announcements yet. Click "Add Announcement" to create one.'
|
||||
)}
|
||||
<StaticDataTable>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className='w-12'>
|
||||
<Checkbox
|
||||
checked={
|
||||
selectedIds.length === announcements.length &&
|
||||
announcements.length > 0
|
||||
}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>{t('Content')}</TableHead>
|
||||
<TableHead>{t('Publish Date')}</TableHead>
|
||||
<TableHead>{t('Type')}</TableHead>
|
||||
<TableHead>{t('Extra')}</TableHead>
|
||||
<TableHead className='w-32'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedAnnouncements.length === 0 ? (
|
||||
<StaticDataTableEmptyRow colSpan={6}>
|
||||
{t(
|
||||
'No announcements yet. Click "Add Announcement" to create one.'
|
||||
)}
|
||||
</StaticDataTableEmptyRow>
|
||||
) : (
|
||||
sortedAnnouncements.map((announcement) => (
|
||||
<TableRow key={announcement.id}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(announcement.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleSelectOne(announcement.id, checked as boolean)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className='max-w-xs truncate'
|
||||
title={announcement.content}
|
||||
>
|
||||
{announcement.content}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<span className='text-sm font-medium'>
|
||||
{getRelativeTime(announcement.publishDate)}
|
||||
</span>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{dayjs(announcement.publishDate).format(
|
||||
'YYYY-MM-DD HH:mm:ss'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge
|
||||
label={
|
||||
typeOptions.find(
|
||||
(opt) => opt.value === announcement.type
|
||||
)?.label
|
||||
}
|
||||
variant={
|
||||
typeOptions.find(
|
||||
(opt) => opt.value === announcement.type
|
||||
)?.badgeVariant ?? 'neutral'
|
||||
}
|
||||
copyable={false}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className='text-muted-foreground max-w-xs truncate'
|
||||
title={announcement.extra}
|
||||
>
|
||||
{announcement.extra || '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
onClick={() => handleEdit(announcement)}
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
>
|
||||
<Edit className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleDelete(announcement)}
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
sortedAnnouncements.map((announcement) => (
|
||||
<TableRow key={announcement.id}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(announcement.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleSelectOne(announcement.id, checked as boolean)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className='max-w-xs truncate'
|
||||
title={announcement.content}
|
||||
>
|
||||
{announcement.content}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<span className='text-sm font-medium'>
|
||||
{getRelativeTime(announcement.publishDate)}
|
||||
</span>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{dayjs(announcement.publishDate).format(
|
||||
'YYYY-MM-DD HH:mm:ss'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge
|
||||
label={
|
||||
typeOptions.find(
|
||||
(opt) => opt.value === announcement.type
|
||||
)?.label
|
||||
}
|
||||
variant={
|
||||
typeOptions.find(
|
||||
(opt) => opt.value === announcement.type
|
||||
)?.badgeVariant ?? 'neutral'
|
||||
}
|
||||
copyable={false}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className='text-muted-foreground max-w-xs truncate'
|
||||
title={announcement.extra}
|
||||
>
|
||||
{announcement.extra || '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
onClick={() => handleEdit(announcement)}
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
>
|
||||
<Edit className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleDelete(announcement)}
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</StaticDataTable>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
|
||||
@@ -55,13 +55,16 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
} from '@/components/data-table'
|
||||
import {
|
||||
StaticDataTable,
|
||||
StaticDataTableEmptyRow,
|
||||
} from '@/components/data-table'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { SettingsSwitchField } from '../components/settings-form-layout'
|
||||
@@ -306,101 +309,97 @@ export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='rounded-md border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className='w-12'>
|
||||
<Checkbox
|
||||
checked={
|
||||
selectedIds.length === apiInfoList.length &&
|
||||
apiInfoList.length > 0
|
||||
}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>{t('URL')}</TableHead>
|
||||
<TableHead>{t('Route')}</TableHead>
|
||||
<TableHead>{t('Description')}</TableHead>
|
||||
<TableHead>{t('Color')}</TableHead>
|
||||
<TableHead className='w-32'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{apiInfoList.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className='h-24 text-center'>
|
||||
{t('No API Domains yet. Click "Add API" to create one.')}
|
||||
<StaticDataTable>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className='w-12'>
|
||||
<Checkbox
|
||||
checked={
|
||||
selectedIds.length === apiInfoList.length &&
|
||||
apiInfoList.length > 0
|
||||
}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>{t('URL')}</TableHead>
|
||||
<TableHead>{t('Route')}</TableHead>
|
||||
<TableHead>{t('Description')}</TableHead>
|
||||
<TableHead>{t('Color')}</TableHead>
|
||||
<TableHead className='w-32'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{apiInfoList.length === 0 ? (
|
||||
<StaticDataTableEmptyRow colSpan={6}>
|
||||
{t('No API Domains yet. Click "Add API" to create one.')}
|
||||
</StaticDataTableEmptyRow>
|
||||
) : (
|
||||
apiInfoList.map((apiInfo) => (
|
||||
<TableRow key={apiInfo.id}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(apiInfo.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleSelectOne(apiInfo.id, checked as boolean)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className='max-w-xs truncate font-mono text-sm'
|
||||
title={apiInfo.url}
|
||||
>
|
||||
<StatusBadge
|
||||
label={apiInfo.url}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge
|
||||
label={apiInfo.route}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className='max-w-xs truncate'
|
||||
title={apiInfo.description}
|
||||
>
|
||||
{apiInfo.description}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div
|
||||
className={`h-4 w-4 rounded-full ${getColorClass(apiInfo.color)}`}
|
||||
/>
|
||||
<span className='text-sm capitalize'>
|
||||
{apiInfo.color}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
onClick={() => handleEdit(apiInfo)}
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
>
|
||||
<Edit className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleDelete(apiInfo)}
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
apiInfoList.map((apiInfo) => (
|
||||
<TableRow key={apiInfo.id}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(apiInfo.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleSelectOne(apiInfo.id, checked as boolean)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className='max-w-xs truncate font-mono text-sm'
|
||||
title={apiInfo.url}
|
||||
>
|
||||
<StatusBadge
|
||||
label={apiInfo.url}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge
|
||||
label={apiInfo.route}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className='max-w-xs truncate'
|
||||
title={apiInfo.description}
|
||||
>
|
||||
{apiInfo.description}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div
|
||||
className={`h-4 w-4 rounded-full ${getColorClass(apiInfo.color)}`}
|
||||
/>
|
||||
<span className='text-sm capitalize'>
|
||||
{apiInfo.color}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
onClick={() => handleEdit(apiInfo)}
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
>
|
||||
<Edit className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleDelete(apiInfo)}
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</StaticDataTable>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
|
||||
+38
-40
@@ -22,13 +22,13 @@ import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
} from '@/components/data-table'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import { safeJsonParseWithValidation } from '../utils/json-parser'
|
||||
import { isArray } from '../utils/json-validators'
|
||||
import { ChatDialog, type ChatEntryData } from './chat-dialog'
|
||||
@@ -158,45 +158,43 @@ export function ChatSettingsVisualEditor({
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className='rounded-md border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Chat Client Name')}</TableHead>
|
||||
<TableHead>{t('URL')}</TableHead>
|
||||
<TableHead className='text-right'>{t('Actions')}</TableHead>
|
||||
<StaticDataTable>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Chat Client Name')}</TableHead>
|
||||
<TableHead>{t('URL')}</TableHead>
|
||||
<TableHead className='text-right'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredChats.map((chat) => (
|
||||
<TableRow key={chat.name}>
|
||||
<TableCell className='font-medium'>{chat.name}</TableCell>
|
||||
<TableCell className='max-w-md truncate font-mono text-sm'>
|
||||
{chat.url}
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => handleEdit(chat)}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => handleDelete(chat.name)}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredChats.map((chat) => (
|
||||
<TableRow key={chat.name}>
|
||||
<TableCell className='font-medium'>{chat.name}</TableCell>
|
||||
<TableCell className='max-w-md truncate font-mono text-sm'>
|
||||
{chat.url}
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => handleEdit(chat)}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => handleDelete(chat.name)}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
))}
|
||||
</TableBody>
|
||||
</StaticDataTable>
|
||||
)}
|
||||
|
||||
<ChatDialog
|
||||
|
||||
@@ -45,15 +45,18 @@ import {
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
} from '@/components/data-table'
|
||||
import {
|
||||
StaticDataTable,
|
||||
StaticDataTableEmptyRow,
|
||||
} from '@/components/data-table'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { SettingsSwitchField } from '../components/settings-form-layout'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
@@ -269,78 +272,73 @@ export function FAQSection({ enabled, data }: FAQSectionProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='rounded-md border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className='w-12'>
|
||||
<Checkbox
|
||||
checked={
|
||||
selectedIds.length === faqList.length &&
|
||||
faqList.length > 0
|
||||
}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>{t('Question')}</TableHead>
|
||||
<TableHead>{t('Answer')}</TableHead>
|
||||
<TableHead className='w-32'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{faqList.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className='h-24 text-center'>
|
||||
{t('No FAQ entries yet. Click "Add FAQ" to create one.')}
|
||||
<StaticDataTable>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className='w-12'>
|
||||
<Checkbox
|
||||
checked={
|
||||
selectedIds.length === faqList.length && faqList.length > 0
|
||||
}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>{t('Question')}</TableHead>
|
||||
<TableHead>{t('Answer')}</TableHead>
|
||||
<TableHead className='w-32'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{faqList.length === 0 ? (
|
||||
<StaticDataTableEmptyRow colSpan={4}>
|
||||
{t('No FAQ entries yet. Click "Add FAQ" to create one.')}
|
||||
</StaticDataTableEmptyRow>
|
||||
) : (
|
||||
faqList.map((faq) => (
|
||||
<TableRow key={faq.id}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(faq.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleSelectOne(faq.id, checked as boolean)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className='max-w-xs truncate font-medium'
|
||||
title={faq.question}
|
||||
>
|
||||
{faq.question}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className='text-muted-foreground max-w-md truncate'
|
||||
title={faq.answer}
|
||||
>
|
||||
{faq.answer}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
onClick={() => handleEdit(faq)}
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
>
|
||||
<Edit className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleDelete(faq)}
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
faqList.map((faq) => (
|
||||
<TableRow key={faq.id}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(faq.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleSelectOne(faq.id, checked as boolean)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className='max-w-xs truncate font-medium'
|
||||
title={faq.question}
|
||||
>
|
||||
{faq.question}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className='text-muted-foreground max-w-md truncate'
|
||||
title={faq.answer}
|
||||
>
|
||||
{faq.answer}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
onClick={() => handleEdit(faq)}
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
>
|
||||
<Edit className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleDelete(faq)}
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</StaticDataTable>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
|
||||
@@ -46,13 +46,16 @@ import {
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
} from '@/components/data-table'
|
||||
import {
|
||||
StaticDataTable,
|
||||
StaticDataTableEmptyRow,
|
||||
} from '@/components/data-table'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { SettingsSwitchField } from '../components/settings-form-layout'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
@@ -278,80 +281,76 @@ export function UptimeKumaSection({ enabled, data }: UptimeKumaSectionProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='rounded-md border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className='w-12'>
|
||||
<Checkbox
|
||||
checked={
|
||||
selectedIds.length === groups.length && groups.length > 0
|
||||
}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>{t('Category Name')}</TableHead>
|
||||
<TableHead>{t('Uptime Kuma URL')}</TableHead>
|
||||
<TableHead>{t('Status Page Slug')}</TableHead>
|
||||
<TableHead className='w-32'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{groups.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className='h-24 text-center'>
|
||||
{t(
|
||||
'No Uptime Kuma groups yet. Click "Add Group" to create one.'
|
||||
)}
|
||||
<StaticDataTable>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className='w-12'>
|
||||
<Checkbox
|
||||
checked={
|
||||
selectedIds.length === groups.length && groups.length > 0
|
||||
}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>{t('Category Name')}</TableHead>
|
||||
<TableHead>{t('Uptime Kuma URL')}</TableHead>
|
||||
<TableHead>{t('Status Page Slug')}</TableHead>
|
||||
<TableHead className='w-32'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{groups.length === 0 ? (
|
||||
<StaticDataTableEmptyRow colSpan={5}>
|
||||
{t(
|
||||
'No Uptime Kuma groups yet. Click "Add Group" to create one.'
|
||||
)}
|
||||
</StaticDataTableEmptyRow>
|
||||
) : (
|
||||
groups.map((group) => (
|
||||
<TableRow key={group.id}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(group.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleSelectOne(group.id, checked as boolean)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className='font-medium'>
|
||||
{group.categoryName}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className='text-primary max-w-xs truncate font-mono text-sm'
|
||||
title={group.url}
|
||||
>
|
||||
{group.url}
|
||||
</TableCell>
|
||||
<TableCell className='text-muted-foreground font-mono text-sm'>
|
||||
{group.slug}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
onClick={() => handleEdit(group)}
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
>
|
||||
<Edit className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleDelete(group)}
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
groups.map((group) => (
|
||||
<TableRow key={group.id}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(group.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleSelectOne(group.id, checked as boolean)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className='font-medium'>
|
||||
{group.categoryName}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className='text-primary max-w-xs truncate font-mono text-sm'
|
||||
title={group.url}
|
||||
>
|
||||
{group.url}
|
||||
</TableCell>
|
||||
<TableCell className='text-muted-foreground font-mono text-sm'>
|
||||
{group.slug}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
onClick={() => handleEdit(group)}
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
>
|
||||
<Edit className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleDelete(group)}
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</StaticDataTable>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
|
||||
+112
-113
@@ -31,15 +31,18 @@ import {
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
} from '@/components/data-table'
|
||||
import {
|
||||
StaticDataTable,
|
||||
StaticDataTableEmptyRow,
|
||||
} from '@/components/data-table'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { StatusBadge, StatusBadgeList } from '@/components/status-badge'
|
||||
import { SettingsSwitchField } from '../../components/settings-form-layout'
|
||||
@@ -546,118 +549,114 @@ export function ChannelAffinitySection(props: Props) {
|
||||
|
||||
{/* Rules Table or JSON Editor */}
|
||||
{editMode === 'visual' ? (
|
||||
<div className='overflow-x-auto rounded-md border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Name')}</TableHead>
|
||||
<TableHead>{t('Model Regex')}</TableHead>
|
||||
<TableHead>{t('Key Sources')}</TableHead>
|
||||
<TableHead>{t('TTL')}</TableHead>
|
||||
<TableHead>{t('Retry')}</TableHead>
|
||||
<TableHead>{t('Scope')}</TableHead>
|
||||
<TableHead>{t('Cache')}</TableHead>
|
||||
<TableHead className='text-right'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rules.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={8}
|
||||
className='text-muted-foreground py-8 text-center'
|
||||
>
|
||||
{t('No rules yet')}
|
||||
<StaticDataTable tableClassName='min-w-max'>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Name')}</TableHead>
|
||||
<TableHead>{t('Model Regex')}</TableHead>
|
||||
<TableHead>{t('Key Sources')}</TableHead>
|
||||
<TableHead>{t('TTL')}</TableHead>
|
||||
<TableHead>{t('Retry')}</TableHead>
|
||||
<TableHead>{t('Scope')}</TableHead>
|
||||
<TableHead>{t('Cache')}</TableHead>
|
||||
<TableHead className='text-right'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rules.length === 0 ? (
|
||||
<StaticDataTableEmptyRow
|
||||
colSpan={8}
|
||||
className='text-muted-foreground py-8'
|
||||
>
|
||||
{t('No rules yet')}
|
||||
</StaticDataTableEmptyRow>
|
||||
) : (
|
||||
rules.map((rule, idx) => (
|
||||
<TableRow key={idx}>
|
||||
<TableCell className='font-medium'>
|
||||
{rule.name || '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<RuleBadgeList items={rule.model_regex || []} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<RuleBadgeList
|
||||
items={(rule.key_sources || []).map(
|
||||
(src) =>
|
||||
`${src.type}:${src.type === 'gjson' ? src.path : src.key}`
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{rule.ttl_seconds || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge
|
||||
label={
|
||||
rule.skip_retry_on_failure
|
||||
? t('No Retry')
|
||||
: t('Retry')
|
||||
}
|
||||
variant={
|
||||
rule.skip_retry_on_failure ? 'danger' : 'neutral'
|
||||
}
|
||||
copyable={false}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{(() => {
|
||||
const scopeItems = [
|
||||
rule.include_using_group && t('Group'),
|
||||
rule.include_model_name && t('Model'),
|
||||
rule.include_rule_name && t('Rule'),
|
||||
].filter(Boolean) as string[]
|
||||
if (scopeItems.length === 0) return '-'
|
||||
return <RuleBadgeList items={scopeItems} />
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{rule.include_rule_name && cacheStats?.by_rule_name
|
||||
? cacheStats.by_rule_name[rule.name] || 0
|
||||
: 'N/A'}
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<div className='flex justify-end gap-1'>
|
||||
{rule.include_rule_name && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-7 w-7'
|
||||
onClick={() => setClearRuleName(rule.name)}
|
||||
title={t('Clear cache for this rule')}
|
||||
>
|
||||
<X className='h-3 w-3' />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-7 w-7'
|
||||
onClick={() => {
|
||||
setEditingRule(rule)
|
||||
setRuleTemplateKey(null)
|
||||
setRuleEditorOpen(true)
|
||||
}}
|
||||
>
|
||||
<Edit className='h-3 w-3' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-7 w-7'
|
||||
onClick={() => handleDeleteRule(idx)}
|
||||
>
|
||||
<Trash2 className='h-3 w-3' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
rules.map((rule, idx) => (
|
||||
<TableRow key={idx}>
|
||||
<TableCell className='font-medium'>
|
||||
{rule.name || '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<RuleBadgeList items={rule.model_regex || []} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<RuleBadgeList
|
||||
items={(rule.key_sources || []).map(
|
||||
(src) =>
|
||||
`${src.type}:${src.type === 'gjson' ? src.path : src.key}`
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{rule.ttl_seconds || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge
|
||||
label={
|
||||
rule.skip_retry_on_failure
|
||||
? t('No Retry')
|
||||
: t('Retry')
|
||||
}
|
||||
variant={
|
||||
rule.skip_retry_on_failure ? 'danger' : 'neutral'
|
||||
}
|
||||
copyable={false}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{(() => {
|
||||
const scopeItems = [
|
||||
rule.include_using_group && t('Group'),
|
||||
rule.include_model_name && t('Model'),
|
||||
rule.include_rule_name && t('Rule'),
|
||||
].filter(Boolean) as string[]
|
||||
if (scopeItems.length === 0) return '-'
|
||||
return <RuleBadgeList items={scopeItems} />
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{rule.include_rule_name && cacheStats?.by_rule_name
|
||||
? cacheStats.by_rule_name[rule.name] || 0
|
||||
: 'N/A'}
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<div className='flex justify-end gap-1'>
|
||||
{rule.include_rule_name && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-7 w-7'
|
||||
onClick={() => setClearRuleName(rule.name)}
|
||||
title={t('Clear cache for this rule')}
|
||||
>
|
||||
<X className='h-3 w-3' />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-7 w-7'
|
||||
onClick={() => {
|
||||
setEditingRule(rule)
|
||||
setRuleTemplateKey(null)
|
||||
setRuleEditorOpen(true)
|
||||
}}
|
||||
>
|
||||
<Edit className='h-3 w-3' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-7 w-7'
|
||||
onClick={() => handleDeleteRule(idx)}
|
||||
>
|
||||
<Trash2 className='h-3 w-3' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</StaticDataTable>
|
||||
) : (
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('Rules JSON')}</Label>
|
||||
|
||||
+64
-66
@@ -21,13 +21,13 @@ import { Pencil, Plus, Trash2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
} from '@/components/data-table'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { safeJsonParseWithValidation } from '../utils/json-parser'
|
||||
import { isObjectRecord } from '../utils/json-validators'
|
||||
@@ -147,71 +147,69 @@ export function AmountDiscountVisualEditor({
|
||||
) : (
|
||||
<div className='rounded-md border'>
|
||||
{/* Desktop table view */}
|
||||
<div className='hidden sm:block'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Recharge Amount')}</TableHead>
|
||||
<TableHead>{t('Discount Rate')}</TableHead>
|
||||
<TableHead>{t('Discount')}</TableHead>
|
||||
<TableHead className='text-right'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{discounts.map((discount) => (
|
||||
<TableRow key={discount.amount}>
|
||||
<TableCell>
|
||||
<span className='font-mono text-sm'>
|
||||
${discount.amount}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className='bg-muted rounded px-1.5 py-0.5 text-sm'>
|
||||
{discount.discountRate.toFixed(2)}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge
|
||||
variant={discount.discountRate < 1 ? 'info' : 'neutral'}
|
||||
className='font-mono'
|
||||
copyable={false}
|
||||
<StaticDataTable className='hidden rounded-none border-0 sm:block'>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Recharge Amount')}</TableHead>
|
||||
<TableHead>{t('Discount Rate')}</TableHead>
|
||||
<TableHead>{t('Discount')}</TableHead>
|
||||
<TableHead className='text-right'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{discounts.map((discount) => (
|
||||
<TableRow key={discount.amount}>
|
||||
<TableCell>
|
||||
<span className='font-mono text-sm'>
|
||||
${discount.amount}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className='bg-muted rounded px-1.5 py-0.5 text-sm'>
|
||||
{discount.discountRate.toFixed(2)}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge
|
||||
variant={discount.discountRate < 1 ? 'info' : 'neutral'}
|
||||
className='font-mono'
|
||||
copyable={false}
|
||||
>
|
||||
{formatPercentage(discount.discountRate)} {t('off')}
|
||||
</StatusBadge>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleEdit(discount)
|
||||
}}
|
||||
>
|
||||
{formatPercentage(discount.discountRate)} {t('off')}
|
||||
</StatusBadge>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleEdit(discount)
|
||||
}}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleDelete(discount.amount)
|
||||
}}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<Pencil className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleDelete(discount.amount)
|
||||
}}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</StaticDataTable>
|
||||
|
||||
{/* Mobile card view */}
|
||||
<div className='divide-y sm:hidden'>
|
||||
|
||||
+62
-66
@@ -22,13 +22,13 @@ import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
} from '@/components/data-table'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import {
|
||||
formatCreemPrice,
|
||||
formatQuotaShort,
|
||||
@@ -183,71 +183,67 @@ export function CreemProductsVisualEditor({
|
||||
) : (
|
||||
<div className='rounded-md border'>
|
||||
{/* Desktop table view */}
|
||||
<div className='hidden md:block'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Name')}</TableHead>
|
||||
<TableHead>{t('Product ID')}</TableHead>
|
||||
<TableHead>{t('Price')}</TableHead>
|
||||
<TableHead>{t('Quota')}</TableHead>
|
||||
<TableHead className='text-right'>{t('Actions')}</TableHead>
|
||||
<StaticDataTable className='hidden rounded-none border-0 md:block'>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Name')}</TableHead>
|
||||
<TableHead>{t('Product ID')}</TableHead>
|
||||
<TableHead>{t('Price')}</TableHead>
|
||||
<TableHead>{t('Quota')}</TableHead>
|
||||
<TableHead className='text-right'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredProducts.map((product) => (
|
||||
<TableRow key={product.productId}>
|
||||
<TableCell className='font-medium'>{product.name}</TableCell>
|
||||
<TableCell>
|
||||
<code className='bg-muted rounded px-1.5 py-0.5 text-sm'>
|
||||
{product.productId}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className='font-mono text-sm'>
|
||||
{formatCreemPrice(product.price, product.currency)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className='font-mono text-sm'>
|
||||
{formatQuotaShort(product.quota)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleEdit(product)
|
||||
}}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleDelete(product)
|
||||
}}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredProducts.map((product) => (
|
||||
<TableRow key={product.productId}>
|
||||
<TableCell className='font-medium'>
|
||||
{product.name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className='bg-muted rounded px-1.5 py-0.5 text-sm'>
|
||||
{product.productId}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className='font-mono text-sm'>
|
||||
{formatCreemPrice(product.price, product.currency)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className='font-mono text-sm'>
|
||||
{formatQuotaShort(product.quota)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleEdit(product)
|
||||
}}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleDelete(product)
|
||||
}}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
))}
|
||||
</TableBody>
|
||||
</StaticDataTable>
|
||||
|
||||
{/* Mobile card view */}
|
||||
<div className='divide-y md:hidden'>
|
||||
|
||||
+77
-83
@@ -27,13 +27,13 @@ import {
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
} from '@/components/data-table'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import { safeJsonParseWithValidation } from '../utils/json-parser'
|
||||
import { isArray } from '../utils/json-validators'
|
||||
import {
|
||||
@@ -291,88 +291,82 @@ export function PaymentMethodsVisualEditor({
|
||||
) : (
|
||||
<div className='rounded-md border'>
|
||||
{/* Desktop table view */}
|
||||
<div className='hidden md:block'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Name')}</TableHead>
|
||||
<TableHead>{t('Type')}</TableHead>
|
||||
<TableHead>{t('Color')}</TableHead>
|
||||
<TableHead>{t('Min Top-up')}</TableHead>
|
||||
<TableHead className='text-right'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredMethods.map((method, index) => {
|
||||
const colorPreview = getColorPreview(method.color)
|
||||
return (
|
||||
<TableRow key={`${method.type}-${index}`}>
|
||||
<TableCell className='font-medium'>
|
||||
{method.name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className='bg-muted rounded px-1.5 py-0.5 text-sm'>
|
||||
{method.type}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='flex items-center gap-2'>
|
||||
{colorPreview && (
|
||||
<div
|
||||
className='size-5 shrink-0 rounded border'
|
||||
style={{ backgroundColor: colorPreview }}
|
||||
/>
|
||||
)}
|
||||
<span className='text-muted-foreground truncate font-mono text-sm'>
|
||||
{method.color}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{method.min_topup ? (
|
||||
<span className='font-mono text-sm'>
|
||||
{method.min_topup}
|
||||
</span>
|
||||
) : (
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
—
|
||||
</span>
|
||||
<StaticDataTable className='hidden rounded-none border-0 md:block'>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Name')}</TableHead>
|
||||
<TableHead>{t('Type')}</TableHead>
|
||||
<TableHead>{t('Color')}</TableHead>
|
||||
<TableHead>{t('Min Top-up')}</TableHead>
|
||||
<TableHead className='text-right'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredMethods.map((method, index) => {
|
||||
const colorPreview = getColorPreview(method.color)
|
||||
return (
|
||||
<TableRow key={`${method.type}-${index}`}>
|
||||
<TableCell className='font-medium'>{method.name}</TableCell>
|
||||
<TableCell>
|
||||
<code className='bg-muted rounded px-1.5 py-0.5 text-sm'>
|
||||
{method.type}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='flex items-center gap-2'>
|
||||
{colorPreview && (
|
||||
<div
|
||||
className='size-5 shrink-0 rounded border'
|
||||
style={{ backgroundColor: colorPreview }}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleEdit(method)
|
||||
}}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleDelete(method)
|
||||
}}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<span className='text-muted-foreground truncate font-mono text-sm'>
|
||||
{method.color}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{method.min_topup ? (
|
||||
<span className='font-mono text-sm'>
|
||||
{method.min_topup}
|
||||
</span>
|
||||
) : (
|
||||
<span className='text-muted-foreground text-sm'>—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleEdit(method)
|
||||
}}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleDelete(method)
|
||||
}}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</StaticDataTable>
|
||||
|
||||
{/* Mobile card view */}
|
||||
<div className='divide-y md:hidden'>
|
||||
|
||||
+70
-71
@@ -25,15 +25,18 @@ import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
} from '@/components/data-table'
|
||||
import {
|
||||
StaticDataTable,
|
||||
StaticDataTableEmptyRow,
|
||||
} from '@/components/data-table'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { SettingsSwitchField } from '../components/settings-form-layout'
|
||||
|
||||
@@ -333,76 +336,72 @@ export function WaffoSettingsSection({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='rounded-md border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Display name')}</TableHead>
|
||||
<TableHead>{t('Icon')}</TableHead>
|
||||
<TableHead>{t('Payment method type')}</TableHead>
|
||||
<TableHead>{t('Payment method name')}</TableHead>
|
||||
<TableHead className='text-right'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{payMethods.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={5}
|
||||
className='text-muted-foreground py-8 text-center'
|
||||
>
|
||||
{t('No payment methods configured')}
|
||||
<StaticDataTable>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Display name')}</TableHead>
|
||||
<TableHead>{t('Icon')}</TableHead>
|
||||
<TableHead>{t('Payment method type')}</TableHead>
|
||||
<TableHead>{t('Payment method name')}</TableHead>
|
||||
<TableHead className='text-right'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{payMethods.length === 0 ? (
|
||||
<StaticDataTableEmptyRow
|
||||
colSpan={5}
|
||||
className='text-muted-foreground py-8'
|
||||
>
|
||||
{t('No payment methods configured')}
|
||||
</StaticDataTableEmptyRow>
|
||||
) : (
|
||||
payMethods.map((m, idx) => (
|
||||
<TableRow key={idx}>
|
||||
<TableCell>{m.name}</TableCell>
|
||||
<TableCell>
|
||||
{m.icon ? (
|
||||
<img
|
||||
src={m.icon}
|
||||
alt={m.name}
|
||||
className='h-6 w-6 rounded object-contain'
|
||||
/>
|
||||
) : (
|
||||
<span className='text-muted-foreground'>-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{m.payMethodType || '-'}</TableCell>
|
||||
<TableCell>{m.payMethodName || '-'}</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<div className='flex justify-end gap-1'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-7 w-7'
|
||||
onClick={() => openEdit(idx)}
|
||||
>
|
||||
<Pencil className='h-3 w-3' />
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-7 w-7'
|
||||
onClick={() =>
|
||||
onPayMethodsChange((prev) =>
|
||||
prev.filter((_, i) => i !== idx)
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 className='h-3 w-3' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
payMethods.map((m, idx) => (
|
||||
<TableRow key={idx}>
|
||||
<TableCell>{m.name}</TableCell>
|
||||
<TableCell>
|
||||
{m.icon ? (
|
||||
<img
|
||||
src={m.icon}
|
||||
alt={m.name}
|
||||
className='h-6 w-6 rounded object-contain'
|
||||
/>
|
||||
) : (
|
||||
<span className='text-muted-foreground'>-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{m.payMethodType || '-'}</TableCell>
|
||||
<TableCell>{m.payMethodName || '-'}</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<div className='flex justify-end gap-1'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-7 w-7'
|
||||
onClick={() => openEdit(idx)}
|
||||
>
|
||||
<Pencil className='h-3 w-3' />
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-7 w-7'
|
||||
onClick={() =>
|
||||
onPayMethodsChange((prev) =>
|
||||
prev.filter((_, i) => i !== idx)
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 className='h-3 w-3' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</StaticDataTable>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
|
||||
+13
-77
@@ -17,15 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
type RowSelectionState,
|
||||
} from '@tanstack/react-table'
|
||||
import { type ColumnDef, type RowSelectionState } from '@tanstack/react-table'
|
||||
import { Search } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -39,14 +31,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { DataTableView, useDataTable } from '@/components/data-table'
|
||||
import { DataTablePagination } from '@/components/data-table/pagination'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
@@ -295,23 +280,16 @@ export function ChannelSelectorDialog({
|
||||
})
|
||||
}, [filteredChannels])
|
||||
|
||||
const table = useReactTable({
|
||||
const { table } = useDataTable({
|
||||
data: sortedChannels,
|
||||
columns,
|
||||
state: {
|
||||
rowSelection,
|
||||
},
|
||||
rowSelection,
|
||||
getRowId: (row) => row.id.toString(),
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
initialState: {
|
||||
pagination: {
|
||||
pageSize: 10,
|
||||
},
|
||||
},
|
||||
initialPagination: { pageIndex: 0, pageSize: 10 },
|
||||
withSortedRowModel: false,
|
||||
withFacetedRowModel: false,
|
||||
})
|
||||
|
||||
const handleConfirm = () => {
|
||||
@@ -355,54 +333,12 @@ export function ChannelSelectorDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex-1 overflow-auto rounded-md border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && 'selected'}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className='h-24 text-center'
|
||||
>
|
||||
{t('No channels found')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<DataTableView
|
||||
table={table}
|
||||
containerClassName='flex-1 overflow-auto rounded-md'
|
||||
emptyContent={t('No channels found')}
|
||||
emptyCellClassName='h-24 text-center'
|
||||
/>
|
||||
|
||||
<DataTablePagination table={table} />
|
||||
</div>
|
||||
|
||||
+33
-35
@@ -28,13 +28,13 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
} from '@/components/data-table'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
|
||||
export type ConflictItem = {
|
||||
channel: string
|
||||
@@ -71,40 +71,38 @@ export function ConflictConfirmDialog({
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<div className='max-h-96 overflow-y-auto rounded-md border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Channel')}</TableHead>
|
||||
<TableHead>{t('Model')}</TableHead>
|
||||
<TableHead>{t('Current Billing')}</TableHead>
|
||||
<TableHead>{t('Change To')}</TableHead>
|
||||
<StaticDataTable className='max-h-96 overflow-y-auto'>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Channel')}</TableHead>
|
||||
<TableHead>{t('Model')}</TableHead>
|
||||
<TableHead>{t('Current Billing')}</TableHead>
|
||||
<TableHead>{t('Change To')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{conflicts.map((conflict, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell className='font-medium'>
|
||||
{conflict.channel}
|
||||
</TableCell>
|
||||
<TableCell className='font-mono text-sm'>
|
||||
{conflict.model}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<pre className='text-sm whitespace-pre-wrap'>
|
||||
{conflict.current}
|
||||
</pre>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<pre className='text-sm whitespace-pre-wrap'>
|
||||
{conflict.newVal}
|
||||
</pre>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{conflicts.map((conflict, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell className='font-medium'>
|
||||
{conflict.channel}
|
||||
</TableCell>
|
||||
<TableCell className='font-mono text-sm'>
|
||||
{conflict.model}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<pre className='text-sm whitespace-pre-wrap'>
|
||||
{conflict.current}
|
||||
</pre>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<pre className='text-sm whitespace-pre-wrap'>
|
||||
{conflict.newVal}
|
||||
</pre>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
))}
|
||||
</TableBody>
|
||||
</StaticDataTable>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isLoading}>
|
||||
|
||||
+131
-141
@@ -36,13 +36,16 @@ import {
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
} from '@/components/data-table'
|
||||
import {
|
||||
StaticDataTable,
|
||||
StaticDataTableEmptyRow,
|
||||
} from '@/components/data-table'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { safeJsonParse } from '../utils/json-parser'
|
||||
|
||||
@@ -427,54 +430,47 @@ export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({
|
||||
{t('Add group')}
|
||||
</Button>
|
||||
{topupRatioList.length > 0 && (
|
||||
<div className='rounded-md border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Group name')}</TableHead>
|
||||
<TableHead>{t('Multiplier')}</TableHead>
|
||||
<TableHead className='text-right'>
|
||||
{t('Actions')}
|
||||
</TableHead>
|
||||
<StaticDataTable>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Group name')}</TableHead>
|
||||
<TableHead>{t('Multiplier')}</TableHead>
|
||||
<TableHead className='text-right'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{topupRatioList.map((group) => (
|
||||
<TableRow key={group.name}>
|
||||
<TableCell className='font-medium'>
|
||||
{group.name}
|
||||
</TableCell>
|
||||
<TableCell>{group.value}</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
handleSimpleEdit('topupGroupRatio', group)
|
||||
}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
handleSimpleDelete('topupGroupRatio', group.name)
|
||||
}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{topupRatioList.map((group) => (
|
||||
<TableRow key={group.name}>
|
||||
<TableCell className='font-medium'>
|
||||
{group.name}
|
||||
</TableCell>
|
||||
<TableCell>{group.value}</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
handleSimpleEdit('topupGroupRatio', group)
|
||||
}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
handleSimpleDelete(
|
||||
'topupGroupRatio',
|
||||
group.name
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
))}
|
||||
</TableBody>
|
||||
</StaticDataTable>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -541,7 +537,7 @@ export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({
|
||||
<CollapsibleContent>
|
||||
{userGroupData.overrides.length > 0 && (
|
||||
<div className='border-t'>
|
||||
<Table>
|
||||
<StaticDataTable className='rounded-none border-0'>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Target group')}</TableHead>
|
||||
@@ -589,7 +585,7 @@ export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</StaticDataTable>
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
@@ -858,106 +854,100 @@ function GroupPricingTable({
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='space-y-3'>
|
||||
<div className='overflow-hidden rounded-md border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className='min-w-40'>{t('Group name')}</TableHead>
|
||||
<TableHead className='w-28'>{t('Ratio')}</TableHead>
|
||||
<TableHead className='w-28 text-center'>
|
||||
{t('User selectable')}
|
||||
</TableHead>
|
||||
<TableHead className='min-w-56'>{t('Description')}</TableHead>
|
||||
<TableHead className='w-16 text-right'>
|
||||
{t('Actions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={5}
|
||||
className='text-muted-foreground h-20 text-center text-sm'
|
||||
>
|
||||
{t('No groups yet. Add a group to get started.')}
|
||||
<StaticDataTable>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className='min-w-40'>{t('Group name')}</TableHead>
|
||||
<TableHead className='w-28'>{t('Ratio')}</TableHead>
|
||||
<TableHead className='w-28 text-center'>
|
||||
{t('User selectable')}
|
||||
</TableHead>
|
||||
<TableHead className='min-w-56'>{t('Description')}</TableHead>
|
||||
<TableHead className='w-16 text-right'>
|
||||
{t('Actions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.length === 0 ? (
|
||||
<StaticDataTableEmptyRow
|
||||
colSpan={5}
|
||||
className='text-muted-foreground h-20 text-sm'
|
||||
>
|
||||
{t('No groups yet. Add a group to get started.')}
|
||||
</StaticDataTableEmptyRow>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<TableRow key={row._id}>
|
||||
<TableCell>
|
||||
<Input
|
||||
value={row.name}
|
||||
onChange={(event) =>
|
||||
updateRow(row._id, 'name', event.target.value)
|
||||
}
|
||||
aria-invalid={duplicateNames.includes(row.name.trim())}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<TableRow key={row._id}>
|
||||
<TableCell>
|
||||
<Input
|
||||
value={row.name}
|
||||
onChange={(event) =>
|
||||
updateRow(row._id, 'name', event.target.value)
|
||||
<TableCell>
|
||||
<Input
|
||||
type='number'
|
||||
min={0}
|
||||
step={0.1}
|
||||
value={String(row.ratio)}
|
||||
onChange={(event) =>
|
||||
updateRow(
|
||||
row._id,
|
||||
'ratio',
|
||||
normalizeRatio(event.target.value)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='flex justify-center'>
|
||||
<Checkbox
|
||||
checked={row.selectable}
|
||||
onCheckedChange={(checked) =>
|
||||
updateRow(row._id, 'selectable', checked === true)
|
||||
}
|
||||
aria-invalid={duplicateNames.includes(
|
||||
row.name.trim()
|
||||
)}
|
||||
aria-label={t('User selectable')}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{row.selectable ? (
|
||||
<Input
|
||||
type='number'
|
||||
min={0}
|
||||
step={0.1}
|
||||
value={String(row.ratio)}
|
||||
value={row.description}
|
||||
placeholder={t('Group description')}
|
||||
onChange={(event) =>
|
||||
updateRow(
|
||||
row._id,
|
||||
'ratio',
|
||||
normalizeRatio(event.target.value)
|
||||
'description',
|
||||
event.target.value
|
||||
)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='flex justify-center'>
|
||||
<Checkbox
|
||||
checked={row.selectable}
|
||||
onCheckedChange={(checked) =>
|
||||
updateRow(row._id, 'selectable', checked === true)
|
||||
}
|
||||
aria-label={t('User selectable')}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{row.selectable ? (
|
||||
<Input
|
||||
value={row.description}
|
||||
placeholder={t('Group description')}
|
||||
onChange={(event) =>
|
||||
updateRow(
|
||||
row._id,
|
||||
'description',
|
||||
event.target.value
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<span className='text-muted-foreground px-3 text-sm'>
|
||||
-
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => removeRow(row._id)}
|
||||
aria-label={t('Delete')}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<span className='text-muted-foreground px-3 text-sm'>
|
||||
-
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => removeRow(row._id)}
|
||||
aria-label={t('Delete')}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</StaticDataTable>
|
||||
|
||||
{duplicateNames.length > 0 && (
|
||||
<p className='text-destructive text-sm'>
|
||||
|
||||
+1
-1
@@ -136,7 +136,7 @@ export function buildModelRatioColumns({
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => <div className='text-right'>{t('Actions')}</div>,
|
||||
header: () => <div>{t('Actions')}</div>,
|
||||
cell: ({ row }) => (
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button
|
||||
|
||||
+58
-88
@@ -33,25 +33,19 @@ import {
|
||||
type RowSelectionState,
|
||||
type VisibilityState,
|
||||
type SortingState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFacetedRowModel,
|
||||
getFacetedUniqueValues,
|
||||
getFilteredRowModel,
|
||||
getSortedRowModel,
|
||||
getPaginationRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table'
|
||||
import { useMediaQuery } from '@/hooks'
|
||||
import { Copy, Plus } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DataTableBulkActions,
|
||||
DataTableToolbar,
|
||||
DataTablePagination,
|
||||
DataTableRow,
|
||||
DataTableView,
|
||||
useDataTable,
|
||||
} from '@/components/data-table'
|
||||
import { combineBillingExpr } from '@/features/pricing/lib/billing-expr'
|
||||
import { safeJsonParse } from '../utils/json-parser'
|
||||
@@ -424,17 +418,15 @@ const ModelRatioVisualEditorComponent = forwardRef<
|
||||
[handleEdit, handleDelete, t]
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
const { table } = useDataTable({
|
||||
data: models,
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
globalFilter,
|
||||
columnVisibility,
|
||||
pagination,
|
||||
rowSelection,
|
||||
},
|
||||
sorting,
|
||||
columnFilters,
|
||||
globalFilter,
|
||||
columnVisibility,
|
||||
pagination,
|
||||
rowSelection,
|
||||
enableRowSelection: true,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
@@ -443,12 +435,6 @@ const ModelRatioVisualEditorComponent = forwardRef<
|
||||
onPaginationChange: setPagination,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
autoResetPageIndex: false,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getFacetedRowModel: getFacetedRowModel(),
|
||||
getFacetedUniqueValues: getFacetedUniqueValues(),
|
||||
globalFilterFn: (row, _columnId, filterValue) => {
|
||||
const searchValue = String(filterValue).toLowerCase()
|
||||
return row.original.name.toLowerCase().includes(searchValue)
|
||||
@@ -674,70 +660,54 @@ const ModelRatioVisualEditorComponent = forwardRef<
|
||||
: t('No models configured. Use Add model to get started.')}
|
||||
</div>
|
||||
) : (
|
||||
<div className='min-h-0 flex-1 overflow-auto rounded-md border'>
|
||||
<table className='w-full caption-bottom text-sm tabular-nums'>
|
||||
<thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id} className='border-b'>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th
|
||||
key={header.id}
|
||||
colSpan={header.colSpan}
|
||||
className={cn(
|
||||
'bg-background text-foreground sticky top-0 z-10 h-10 px-2 text-left align-middle text-sm font-medium whitespace-nowrap',
|
||||
header.column.id === 'actions' &&
|
||||
'right-0 z-30 w-24 min-w-24 shadow-[-10px_0_14px_-14px_hsl(var(--foreground))]'
|
||||
)}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<tr
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() ? 'selected' : undefined}
|
||||
className={
|
||||
editData?.name === row.original.name
|
||||
? 'bg-muted/45 hover:bg-muted/50 data-[state=selected]:bg-muted group border-b transition-colors'
|
||||
: 'hover:bg-muted/50 data-[state=selected]:bg-muted group border-b transition-colors'
|
||||
}
|
||||
onClick={(event) => {
|
||||
const target = event.target as HTMLElement
|
||||
if (target.closest('button, [role="checkbox"]')) return
|
||||
handleEdit(row.original)
|
||||
}}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td
|
||||
key={cell.id}
|
||||
className={cn(
|
||||
'p-2 align-middle text-sm whitespace-nowrap',
|
||||
cell.column.id === 'actions' &&
|
||||
(editData?.name === row.original.name
|
||||
? 'bg-muted/45 group-hover:bg-muted/50 group-data-[state=selected]:bg-muted sticky right-0 z-10 w-24 min-w-24 shadow-[-10px_0_14px_-14px_hsl(var(--foreground))]'
|
||||
: 'bg-background group-hover:bg-muted/50 group-data-[state=selected]:bg-muted sticky right-0 z-10 w-24 min-w-24 shadow-[-10px_0_14px_-14px_hsl(var(--foreground))]')
|
||||
)}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<DataTableView
|
||||
table={table}
|
||||
containerClassName='min-h-0 flex-1 rounded-md'
|
||||
tableContainerClassName='h-full'
|
||||
tableClassName='min-w-[760px] table-fixed sm:min-w-full'
|
||||
tableHeaderClassName='[&_tr]:border-b-0'
|
||||
splitHeaderScrollClassName='h-full'
|
||||
bodyContainerClassName='[scrollbar-gutter:stable]'
|
||||
splitHeader
|
||||
pinnedColumns={[
|
||||
{
|
||||
columnId: 'actions',
|
||||
side: 'right',
|
||||
className: 'w-24 min-w-24',
|
||||
},
|
||||
]}
|
||||
colgroup={
|
||||
<colgroup>
|
||||
<col className='w-8' />
|
||||
<col className='w-[280px] sm:w-[38%]' />
|
||||
<col className='w-[150px] sm:w-[18%]' />
|
||||
<col className='w-[260px] sm:w-[32%]' />
|
||||
<col className='w-24' />
|
||||
</colgroup>
|
||||
}
|
||||
renderRow={(row, { getCellClassName }) => (
|
||||
<DataTableRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
className={
|
||||
editData?.name === row.original.name
|
||||
? 'bg-muted/45 hover:bg-muted/50 data-[state=selected]:bg-muted group'
|
||||
: 'group'
|
||||
}
|
||||
getColumnClassName={(columnId) =>
|
||||
columnId === 'actions' &&
|
||||
editData?.name === row.original.name
|
||||
? getCellClassName(columnId, 'bg-muted/45')
|
||||
: getCellClassName(columnId)
|
||||
}
|
||||
onClick={(event) => {
|
||||
const target = event.target as HTMLElement
|
||||
if (target.closest('button, [role="checkbox"]')) return
|
||||
handleEdit(row.original)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{table.getRowModel().rows.length > 0 && (
|
||||
|
||||
@@ -23,15 +23,18 @@ import { toast } from 'sonner'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
} from '@/components/data-table'
|
||||
import {
|
||||
StaticDataTable,
|
||||
StaticDataTableEmptyRow,
|
||||
} from '@/components/data-table'
|
||||
import { useUpdateOption } from '../hooks/use-update-option'
|
||||
|
||||
const OPTION_KEY = 'tool_price_setting.prices'
|
||||
@@ -109,7 +112,6 @@ export const ToolPriceSettings = memo(function ToolPriceSettings({
|
||||
useEffect(() => {
|
||||
const prices = parseInitialPrices(defaultValue)
|
||||
const initialRows = objectToRows(prices)
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setRows(initialRows)
|
||||
setJsonText(JSON.stringify(prices, null, 2))
|
||||
setJsonError('')
|
||||
@@ -261,72 +263,62 @@ export const ToolPriceSettings = memo(function ToolPriceSettings({
|
||||
</div>
|
||||
|
||||
{editMode === 'visual' ? (
|
||||
<div className='overflow-hidden rounded-md border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Tool identifier')}</TableHead>
|
||||
<TableHead className='w-[200px]'>
|
||||
{t('Price ($/1K calls)')}
|
||||
</TableHead>
|
||||
<TableHead className='w-[80px] text-right'>
|
||||
{t('Actions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={3}
|
||||
className='text-muted-foreground py-8 text-center'
|
||||
>
|
||||
{t('No tools configured')}
|
||||
<StaticDataTable>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Tool identifier')}</TableHead>
|
||||
<TableHead className='w-[200px]'>
|
||||
{t('Price ($/1K calls)')}
|
||||
</TableHead>
|
||||
<TableHead className='w-[80px] text-right'>
|
||||
{t('Actions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.length === 0 ? (
|
||||
<StaticDataTableEmptyRow
|
||||
colSpan={3}
|
||||
className='text-muted-foreground py-8'
|
||||
>
|
||||
{t('No tools configured')}
|
||||
</StaticDataTableEmptyRow>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell>
|
||||
<Input
|
||||
value={row.key}
|
||||
placeholder='web_search_preview:gpt-4o*'
|
||||
onChange={(e) => updateRow(row.id, 'key', e.target.value)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type='number'
|
||||
min={0}
|
||||
step={0.5}
|
||||
value={row.price}
|
||||
onChange={(e) =>
|
||||
updateRow(row.id, 'price', Number(e.target.value) || 0)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={() => removeRow(row.id)}
|
||||
aria-label={t('Delete')}
|
||||
>
|
||||
<Trash2 className='text-destructive h-4 w-4' />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell>
|
||||
<Input
|
||||
value={row.key}
|
||||
placeholder='web_search_preview:gpt-4o*'
|
||||
onChange={(e) =>
|
||||
updateRow(row.id, 'key', e.target.value)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type='number'
|
||||
min={0}
|
||||
step={0.5}
|
||||
value={row.price}
|
||||
onChange={(e) =>
|
||||
updateRow(
|
||||
row.id,
|
||||
'price',
|
||||
Number(e.target.value) || 0
|
||||
)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={() => removeRow(row.id)}
|
||||
aria-label={t('Delete')}
|
||||
>
|
||||
<Trash2 className='text-destructive h-4 w-4' />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</StaticDataTable>
|
||||
) : (
|
||||
<div className='space-y-2'>
|
||||
<Textarea
|
||||
|
||||
+15
-67
@@ -17,12 +17,6 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table'
|
||||
import { Loader2, Search } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -34,14 +28,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { DataTableView, useDataTable } from '@/components/data-table'
|
||||
import { DataTablePagination } from '@/components/data-table/pagination'
|
||||
import type { DifferencesMap, RatioType } from '../types'
|
||||
import { RATIO_TYPE_OPTIONS } from './constants'
|
||||
@@ -180,15 +167,14 @@ export function UpstreamRatioSyncTable({
|
||||
handleBulkUnselect
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
const { table } = useDataTable({
|
||||
data: filteredData,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getRowId: (row) => row.key,
|
||||
initialState: {
|
||||
pagination: { pageSize: 10 },
|
||||
},
|
||||
initialPagination: { pageIndex: 0, pageSize: 10 },
|
||||
withFilteredRowModel: false,
|
||||
withSortedRowModel: false,
|
||||
withFacetedRowModel: false,
|
||||
})
|
||||
|
||||
if (dataSource.length === 0) {
|
||||
@@ -258,53 +244,15 @@ export function UpstreamRatioSyncTable({
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className='overflow-hidden rounded-md border'>
|
||||
<div className='overflow-x-auto'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id} className='align-top'>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.length > 0 ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id} className='align-top'>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id} className='align-top'>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className='h-24 text-center'
|
||||
>
|
||||
{t('No results found')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
<DataTableView
|
||||
table={table}
|
||||
containerClassName='rounded-md'
|
||||
tableContainerClassName='overflow-x-auto'
|
||||
getColumnClassName={() => 'align-top'}
|
||||
getRowClassName={() => 'align-top'}
|
||||
emptyContent={t('No results found')}
|
||||
emptyCellClassName='h-24 text-center'
|
||||
/>
|
||||
|
||||
<DataTablePagination table={table} />
|
||||
</div>
|
||||
|
||||
+50
-54
@@ -22,13 +22,13 @@ import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
} from '@/components/data-table'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import { safeJsonParseWithValidation } from '../utils/json-parser'
|
||||
import { isObjectRecord } from '../utils/json-validators'
|
||||
import { RateLimitDialog, type RateLimitEntryData } from './rate-limit-dialog'
|
||||
@@ -151,59 +151,55 @@ export function RateLimitVisualEditor({
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className='rounded-md border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Group Name')}</TableHead>
|
||||
<TableHead className='text-right'>
|
||||
{t('Max Requests (incl. failures)')}
|
||||
</TableHead>
|
||||
<TableHead className='text-right'>{t('Max Success')}</TableHead>
|
||||
<TableHead className='text-right'>{t('Actions')}</TableHead>
|
||||
<StaticDataTable>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Group Name')}</TableHead>
|
||||
<TableHead className='text-right'>
|
||||
{t('Max Requests (incl. failures)')}
|
||||
</TableHead>
|
||||
<TableHead className='text-right'>{t('Max Success')}</TableHead>
|
||||
<TableHead className='text-right'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredRateLimits.map((limit) => (
|
||||
<TableRow key={limit.groupName}>
|
||||
<TableCell className='font-medium'>{limit.groupName}</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<span className='font-mono'>
|
||||
{limit.maxRequests === 0
|
||||
? t('Unlimited')
|
||||
: limit.maxRequests.toLocaleString()}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<span className='font-mono'>
|
||||
{limit.maxSuccess.toLocaleString()}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => handleEdit(limit)}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => handleDelete(limit.groupName)}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredRateLimits.map((limit) => (
|
||||
<TableRow key={limit.groupName}>
|
||||
<TableCell className='font-medium'>
|
||||
{limit.groupName}
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<span className='font-mono'>
|
||||
{limit.maxRequests === 0
|
||||
? t('Unlimited')
|
||||
: limit.maxRequests.toLocaleString()}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<span className='font-mono'>
|
||||
{limit.maxSuccess.toLocaleString()}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => handleEdit(limit)}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => handleDelete(limit.groupName)}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
))}
|
||||
</TableBody>
|
||||
</StaticDataTable>
|
||||
)}
|
||||
|
||||
<RateLimitDialog
|
||||
|
||||
+30
-53
@@ -16,11 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import {
|
||||
flexRender,
|
||||
type Cell,
|
||||
type Table,
|
||||
} from '@tanstack/react-table'
|
||||
import { flexRender, type Cell, type Table } from '@tanstack/react-table'
|
||||
import { Database } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { formatTimestampToDate } from '@/lib/format'
|
||||
@@ -33,14 +29,20 @@ import {
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/empty'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { dotColorMap, textColorMap, type StatusVariant } from '@/components/status-badge'
|
||||
import type { LogCategory } from '../types'
|
||||
import {
|
||||
dotColorMap,
|
||||
textColorMap,
|
||||
type StatusVariant,
|
||||
} from '@/components/status-badge'
|
||||
import { LOG_TYPE_ENUM } from '../constants'
|
||||
import { getLogTypeConfig } from '../lib/utils'
|
||||
import type { LogCategory } from '../types'
|
||||
|
||||
const logTypeRowTint: Record<number, string> = {
|
||||
[LOG_TYPE_ENUM.ERROR]: 'bg-rose-50/40 dark:bg-rose-950/20 border-rose-200/50 dark:border-rose-900/30',
|
||||
[LOG_TYPE_ENUM.REFUND]: 'bg-blue-50/30 dark:bg-blue-950/15 border-blue-200/50 dark:border-blue-900/30',
|
||||
[LOG_TYPE_ENUM.ERROR]:
|
||||
'bg-rose-50/40 dark:bg-rose-950/20 border-rose-200/50 dark:border-rose-900/30',
|
||||
[LOG_TYPE_ENUM.REFUND]:
|
||||
'bg-blue-50/30 dark:bg-blue-950/15 border-blue-200/50 dark:border-blue-900/30',
|
||||
}
|
||||
|
||||
interface UsageLogsMobileListProps<TData> {
|
||||
@@ -53,11 +55,11 @@ interface UsageLogsMobileListProps<TData> {
|
||||
|
||||
function UsageLogsMobileSkeleton() {
|
||||
return (
|
||||
<div className='overflow-hidden rounded-lg border border-border/50 bg-card'>
|
||||
<div className='border-border/50 bg-card overflow-hidden rounded-lg border'>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className='space-y-2.5 border-b border-border/40 p-3 last:border-b-0'
|
||||
className='border-border/40 space-y-2.5 border-b p-3 last:border-b-0'
|
||||
>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<Skeleton className='h-5 w-40 rounded-md' />
|
||||
@@ -93,7 +95,7 @@ function CompactCell<TData>({
|
||||
className={cn(
|
||||
'min-w-0 overflow-hidden leading-tight [&_button]:max-w-full [&_span]:max-w-full',
|
||||
primaryOnly &&
|
||||
'[&_.flex-col>*:not(:first-child)]:hidden [&_.flex-col]:min-w-0',
|
||||
'[&_.flex-col]:min-w-0 [&_.flex-col>*:not(:first-child)]:hidden',
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -123,10 +125,7 @@ function SummaryField<TData>({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0 rounded-md bg-muted/20 px-2 py-1.5',
|
||||
className
|
||||
)}
|
||||
className={cn('bg-muted/20 min-w-0 rounded-md px-2 py-1.5', className)}
|
||||
>
|
||||
<div className='text-muted-foreground mb-1 text-[11px] leading-none font-medium select-none'>
|
||||
{label}
|
||||
@@ -198,7 +197,7 @@ function CommonLogsCard<TData>({
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-[minmax(0,1.15fr)_minmax(0,0.85fr)] gap-1.5'>
|
||||
<div className='min-w-0 rounded-md bg-muted/20 px-2 py-1.5'>
|
||||
<div className='bg-muted/20 min-w-0 rounded-md px-2 py-1.5'>
|
||||
<div className='text-muted-foreground mb-1 text-[11px] leading-none font-medium select-none'>
|
||||
{t('Time')}
|
||||
</div>
|
||||
@@ -257,15 +256,8 @@ function TaskLogsCard<TData>({
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-2 gap-1.5'>
|
||||
<SummaryField
|
||||
label={t('Submit Time')}
|
||||
cell={submitTimeCell}
|
||||
/>
|
||||
<SummaryField
|
||||
label={t('User')}
|
||||
cell={cells.get('user')}
|
||||
primaryOnly
|
||||
/>
|
||||
<SummaryField label={t('Submit Time')} cell={submitTimeCell} />
|
||||
<SummaryField label={t('User')} cell={cells.get('user')} primaryOnly />
|
||||
<SummaryField
|
||||
label={t('Result')}
|
||||
cell={cells.get('fail_reason')}
|
||||
@@ -295,28 +287,19 @@ function DrawingLogsCard<TData>({
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-2 gap-1.5'>
|
||||
<SummaryField
|
||||
label={t('Submit Time')}
|
||||
cell={submitTimeCell}
|
||||
/>
|
||||
<SummaryField label={t('Submit Time')} cell={submitTimeCell} />
|
||||
<SummaryField
|
||||
label={t('Channel')}
|
||||
cell={cells.get('channel')}
|
||||
primaryOnly
|
||||
/>
|
||||
<SummaryField
|
||||
label={t('Task ID')}
|
||||
cell={cells.get('mj_id')}
|
||||
/>
|
||||
<SummaryField label={t('Task ID')} cell={cells.get('mj_id')} />
|
||||
<SummaryField
|
||||
label={t('Duration')}
|
||||
cell={cells.get('duration')}
|
||||
primaryOnly
|
||||
/>
|
||||
<SummaryField
|
||||
label={t('Image')}
|
||||
cell={cells.get('image_url')}
|
||||
/>
|
||||
<SummaryField label={t('Image')} cell={cells.get('image_url')} />
|
||||
<SummaryField
|
||||
label={t('Prompt')}
|
||||
cell={cells.get('prompt')}
|
||||
@@ -354,11 +337,11 @@ export function UsageLogsMobileList<TData>({
|
||||
|
||||
if (!rows || rows.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border p-6">
|
||||
<Empty className="border-none p-0">
|
||||
<div className='rounded-lg border p-6'>
|
||||
<Empty className='border-none p-0'>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<Database className="size-6" />
|
||||
<EmptyMedia variant='icon'>
|
||||
<Database className='size-6' />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>{resolvedEmptyTitle}</EmptyTitle>
|
||||
<EmptyDescription>{resolvedEmptyDescription}</EmptyDescription>
|
||||
@@ -369,7 +352,7 @@ export function UsageLogsMobileList<TData>({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='overflow-hidden rounded-lg border border-border/50 bg-card'>
|
||||
<div className='border-border/50 bg-card overflow-hidden rounded-lg border'>
|
||||
{rows.map((row) => {
|
||||
const cells = new Map(
|
||||
row.getVisibleCells().map((cell) => [cell.column.id, cell])
|
||||
@@ -384,19 +367,13 @@ export function UsageLogsMobileList<TData>({
|
||||
<div
|
||||
key={row.id}
|
||||
className={cn(
|
||||
'border-l-2 border-l-transparent border-b border-border/40 p-3 transition-colors last:border-b-0',
|
||||
'border-border/40 border-b border-l-2 border-l-transparent p-3 transition-colors last:border-b-0',
|
||||
tintClass
|
||||
)}
|
||||
>
|
||||
{logCategory === 'common' && (
|
||||
<CommonLogsCard cells={cells} />
|
||||
)}
|
||||
{logCategory === 'task' && (
|
||||
<TaskLogsCard cells={cells} />
|
||||
)}
|
||||
{logCategory === 'drawing' && (
|
||||
<DrawingLogsCard cells={cells} />
|
||||
)}
|
||||
{logCategory === 'common' && <CommonLogsCard cells={cells} />}
|
||||
{logCategory === 'task' && <TaskLogsCard cells={cells} />}
|
||||
{logCategory === 'drawing' && <DrawingLogsCard cells={cells} />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -16,27 +16,20 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useEffect } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getRouteApi } from '@tanstack/react-router'
|
||||
import {
|
||||
type ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFacetedRowModel,
|
||||
getFacetedUniqueValues,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table'
|
||||
import { type ColumnDef } from '@tanstack/react-table'
|
||||
import { useMediaQuery } from '@/hooks'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useIsAdmin } from '@/hooks/use-admin'
|
||||
import { useTableUrlState } from '@/hooks/use-table-url-state'
|
||||
import { TableCell, TableRow } from '@/components/ui/table'
|
||||
import { DataTablePage } from '@/components/data-table'
|
||||
import {
|
||||
DataTablePage,
|
||||
DataTableRow,
|
||||
useDataTable,
|
||||
} from '@/components/data-table'
|
||||
import {
|
||||
DEFAULT_LOGS_DATA,
|
||||
LOG_TYPE_ALL_VALUE,
|
||||
@@ -149,31 +142,20 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
|
||||
const columns = useColumnsByCategory(logCategory, isAdmin)
|
||||
const isLoadingData = isLoading || (isFetching && !data)
|
||||
|
||||
const table = useReactTable({
|
||||
const { table } = useDataTable({
|
||||
data: logs as Record<string, unknown>[],
|
||||
columns: columns as ColumnDef<Record<string, unknown>>[],
|
||||
state: {
|
||||
columnFilters,
|
||||
pagination,
|
||||
},
|
||||
columnFilters,
|
||||
pagination,
|
||||
enableRowSelection: false,
|
||||
onPaginationChange,
|
||||
onColumnFiltersChange,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getFacetedRowModel: getFacetedRowModel(),
|
||||
getFacetedUniqueValues: getFacetedUniqueValues(),
|
||||
manualPagination: true,
|
||||
manualFiltering: true,
|
||||
pageCount: Math.ceil((data?.total || 0) / pagination.pageSize),
|
||||
totalCount: data?.total || 0,
|
||||
ensurePageInRange,
|
||||
})
|
||||
|
||||
const pageCount = table.getPageCount()
|
||||
useEffect(() => {
|
||||
ensurePageInRange(pageCount)
|
||||
}, [pageCount, ensurePageInRange])
|
||||
|
||||
const isCommon = logCategory === 'common'
|
||||
|
||||
return (
|
||||
@@ -214,13 +196,12 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
|
||||
isCommon && logType != null ? (logTypeRowTint[logType] ?? '') : ''
|
||||
|
||||
return (
|
||||
<TableRow key={row.id} className={cn('transition-colors', tintClass)}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id} className={isCommon ? 'py-2' : 'py-3.5'}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
<DataTableRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
className={cn('transition-colors', tintClass)}
|
||||
getColumnClassName={() => (isCommon ? 'py-2' : 'py-3.5')}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
||||
+7
-39
@@ -16,20 +16,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getRouteApi } from '@tanstack/react-router'
|
||||
import {
|
||||
type SortingState,
|
||||
type VisibilityState,
|
||||
getCoreRowModel,
|
||||
getFacetedRowModel,
|
||||
getFacetedUniqueValues,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table'
|
||||
import { useMediaQuery } from '@/hooks'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
@@ -38,6 +26,7 @@ import {
|
||||
DISABLED_ROW_DESKTOP,
|
||||
DISABLED_ROW_MOBILE,
|
||||
DataTablePage,
|
||||
useDataTable,
|
||||
} from '@/components/data-table'
|
||||
import { getUsers, searchUsers } from '../api'
|
||||
import {
|
||||
@@ -62,9 +51,6 @@ export function UsersTable() {
|
||||
const columns = useUsersColumns()
|
||||
const { refreshTrigger } = useUsers()
|
||||
const isMobile = useMediaQuery('(max-width: 640px)')
|
||||
const [rowSelection, setRowSelection] = useState({})
|
||||
const [sorting, setSorting] = useState<SortingState>([])
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
|
||||
|
||||
const {
|
||||
globalFilter,
|
||||
@@ -146,21 +132,13 @@ export function UsersTable() {
|
||||
|
||||
const users = data?.items || []
|
||||
|
||||
const table = useReactTable({
|
||||
const { table } = useDataTable({
|
||||
data: users,
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
columnFilters,
|
||||
globalFilter,
|
||||
pagination,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
columnFilters,
|
||||
globalFilter,
|
||||
pagination,
|
||||
globalFilterFn: (row, _columnId, filterValue) => {
|
||||
const searchValue = String(filterValue).toLowerCase()
|
||||
const fields = [
|
||||
@@ -174,24 +152,14 @@ export function UsersTable() {
|
||||
.includes(searchValue)
|
||||
)
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedRowModel: getFacetedRowModel(),
|
||||
getFacetedUniqueValues: getFacetedUniqueValues(),
|
||||
onPaginationChange,
|
||||
onGlobalFilterChange,
|
||||
onColumnFiltersChange,
|
||||
manualPagination: true,
|
||||
pageCount: Math.ceil((data?.total || 0) / pagination.pageSize),
|
||||
totalCount: data?.total || 0,
|
||||
ensurePageInRange,
|
||||
})
|
||||
|
||||
const pageCount = table.getPageCount()
|
||||
useEffect(() => {
|
||||
ensurePageInRange(pageCount)
|
||||
}, [pageCount, ensurePageInRange])
|
||||
|
||||
return (
|
||||
<DataTablePage
|
||||
table={table}
|
||||
|
||||
Reference in New Issue
Block a user