Compare commits
39 Commits
main
...
perf/ui-table
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a44183873 | |||
| b5d13a6fee | |||
| ac694fbc9f | |||
| 0a8fcb450e | |||
| c57009ffae | |||
| d58ddf2441 | |||
| 6799f27fe5 | |||
| 40d0d6a82f | |||
| 9691ca06d1 | |||
| 445a87c3f3 | |||
| 823418ba36 | |||
| 0cec454fc4 | |||
| 7efe325dc4 | |||
| 9b1fc293fa | |||
| d73f6b492f | |||
| 990ec72bda | |||
| 33d87e6ab1 | |||
| f8f7716be6 | |||
| 2d978cc314 | |||
| 503447103c | |||
| 6df10dcebb | |||
| 4835abfda8 | |||
| d64e09bb19 | |||
| d26f277e70 | |||
| f78a7973e2 | |||
| 2f6edabc97 | |||
| 9274edc409 | |||
| d380ed8ccd | |||
| e861caf2f0 | |||
| 767020d6e2 | |||
| 9190895708 | |||
| e6f910e329 | |||
| 895fae66ff | |||
| 5306e640f4 | |||
| 8fb8cacae8 | |||
| a1f7256a05 | |||
| 0863ddc3d9 | |||
| 04c0ae7aa8 | |||
| dc6aea065a |
+1
-2
@@ -27,7 +27,6 @@ import {
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react'
|
||||
import type { Element } from 'hast'
|
||||
import { CheckIcon, CopyIcon } from 'lucide-react'
|
||||
import {
|
||||
type BundledLanguage,
|
||||
@@ -53,7 +52,7 @@ const CodeBlockContext = createContext<CodeBlockContextType>({
|
||||
|
||||
const lineNumberTransformer: ShikiTransformer = {
|
||||
name: 'line-numbers',
|
||||
line(node: Element, line: number) {
|
||||
line(node, line) {
|
||||
node.children.unshift({
|
||||
type: 'element',
|
||||
tagName: 'span',
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
# Data Table Components
|
||||
|
||||
This package keeps a stable public API through `index.ts`; feature code should
|
||||
continue importing from `@/components/data-table`.
|
||||
|
||||
- `core/`: TanStack table rendering primitives, headers, rows, pagination,
|
||||
loading, empty states, and pinned-column behavior.
|
||||
- `layout/`: responsive page-level composition that combines toolbar, desktop
|
||||
table, mobile list, bulk actions, and pagination placement.
|
||||
- `toolbar/`: filter/search/view-option controls and selection action toolbar.
|
||||
- `static/`: lightweight table rendering for local/static arrays that do not
|
||||
need TanStack state.
|
||||
- `hooks/`: table state and filter hooks.
|
||||
|
||||
Keep feature-specific columns, actions, and dialogs inside their feature
|
||||
folders. Shared table code belongs here only when it is reusable across more
|
||||
than one feature.
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
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 { cn } from '@/lib/utils'
|
||||
import type { DataTableColumnClassName, DataTablePinnedColumn } from './types'
|
||||
|
||||
export function getResolvedColumnClassName(
|
||||
getColumnClassName?: DataTableColumnClassName,
|
||||
pinnedColumns?: DataTablePinnedColumn[]
|
||||
): DataTableColumnClassName {
|
||||
return getResolvedColumnClassNameFromMap(
|
||||
getColumnClassName,
|
||||
getPinnedColumnMap(pinnedColumns)
|
||||
)
|
||||
}
|
||||
|
||||
export function getResolvedColumnClassNameFromMap(
|
||||
getColumnClassName?: DataTableColumnClassName,
|
||||
pinnedColumnById?: Map<string, DataTablePinnedColumn>
|
||||
): DataTableColumnClassName {
|
||||
return (columnId, kind) => {
|
||||
const customClassName = getColumnClassName?.(columnId, kind)
|
||||
const pinnedColumn = pinnedColumnById?.get(columnId)
|
||||
|
||||
if (!pinnedColumn) return customClassName
|
||||
|
||||
return cn(customClassName, getPinnedColumnClassName(pinnedColumn, kind))
|
||||
}
|
||||
}
|
||||
|
||||
export function getPinnedColumnMap(pinnedColumns?: DataTablePinnedColumn[]) {
|
||||
if (!pinnedColumns?.length) return undefined
|
||||
|
||||
return new Map(pinnedColumns.map((column) => [column.columnId, column]))
|
||||
}
|
||||
|
||||
function getPinnedColumnClassName(
|
||||
pinnedColumn: DataTablePinnedColumn,
|
||||
kind: 'header' | 'cell'
|
||||
) {
|
||||
const edgeClassName =
|
||||
pinnedColumn.side === 'left'
|
||||
? 'shadow-[8px_0_10px_-10px_hsl(var(--foreground))]'
|
||||
: '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 group-data-[state=selected]:bg-muted',
|
||||
pinnedColumn.className,
|
||||
kind === 'header'
|
||||
? pinnedColumn.headerClassName
|
||||
: pinnedColumn.cellClassName
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
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 type { Table as TanstackTable } from '@tanstack/react-table'
|
||||
|
||||
export function DataTableColgroup<TData>({
|
||||
table,
|
||||
}: {
|
||||
table: TanstackTable<TData>
|
||||
}) {
|
||||
return (
|
||||
<colgroup>
|
||||
{table.getVisibleLeafColumns().map((column) => (
|
||||
<col key={column.id} style={{ width: column.getSize() }} />
|
||||
))}
|
||||
</colgroup>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
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 { flexRender, type Table as TanstackTable } from '@tanstack/react-table'
|
||||
import { TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import type { DataTableColumnClassName } from './types'
|
||||
|
||||
type DataTableHeaderProps<TData> = {
|
||||
table: TanstackTable<TData>
|
||||
applyHeaderSize?: boolean
|
||||
className?: string
|
||||
rowClassName?: string
|
||||
getColumnClassName?: DataTableColumnClassName
|
||||
}
|
||||
|
||||
export function DataTableHeader<TData>({
|
||||
table,
|
||||
applyHeaderSize,
|
||||
className,
|
||||
rowClassName,
|
||||
getColumnClassName,
|
||||
}: DataTableHeaderProps<TData>) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
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 type * as React from 'react'
|
||||
import { flexRender, type Row } from '@tanstack/react-table'
|
||||
import { TableCell, TableRow } from '@/components/ui/table'
|
||||
import type { DataTableColumnClassName } from './types'
|
||||
|
||||
type DataTableRowProps<TData> = {
|
||||
row: Row<TData>
|
||||
className?: string
|
||||
getColumnClassName?: DataTableColumnClassName
|
||||
} & Omit<React.ComponentProps<typeof TableRow>, 'children'>
|
||||
|
||||
export function DataTableRow<TData>({
|
||||
row,
|
||||
className,
|
||||
getColumnClassName,
|
||||
...rowProps
|
||||
}: DataTableRowProps<TData>) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
/*
|
||||
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 Row } from '@tanstack/react-table'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Table, TableBody, TableCell, TableRow } from '@/components/ui/table'
|
||||
import {
|
||||
getPinnedColumnMap,
|
||||
getResolvedColumnClassNameFromMap,
|
||||
} from './column-pinning'
|
||||
import { DataTableColgroup } from './data-table-colgroup'
|
||||
import { DataTableHeader } from './data-table-header'
|
||||
import { DataTableRow } from './data-table-row'
|
||||
import { TableEmpty } from './table-empty'
|
||||
import { getTableSizeStyle } from './table-sizing'
|
||||
import { TableSkeleton } from './table-skeleton'
|
||||
import type {
|
||||
DataTableColumnClassName,
|
||||
DataTablePinnedColumn,
|
||||
DataTableViewProps,
|
||||
} from './types'
|
||||
|
||||
export type {
|
||||
DataTableColumnClassName,
|
||||
DataTablePinnedColumn,
|
||||
DataTableRenderRowHelpers,
|
||||
DataTableViewProps,
|
||||
} from './types'
|
||||
export { DataTableRow } from './data-table-row'
|
||||
|
||||
export function DataTableView<TData>(props: DataTableViewProps<TData>) {
|
||||
const rows = props.rows ?? props.table.getRowModel().rows
|
||||
const colSpan = props.table.getVisibleLeafColumns().length
|
||||
const columnClassName = useResolvedColumnClassName(
|
||||
props.getColumnClassName,
|
||||
props.pinnedColumns
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden rounded-lg border',
|
||||
props.containerClassName
|
||||
)}
|
||||
{...props.containerProps}
|
||||
>
|
||||
{props.splitHeader ? (
|
||||
<SplitHeaderTableView
|
||||
props={props}
|
||||
rows={rows}
|
||||
colSpan={colSpan}
|
||||
getColumnClassName={columnClassName}
|
||||
/>
|
||||
) : (
|
||||
<UnifiedTableView
|
||||
props={props}
|
||||
rows={rows}
|
||||
colSpan={colSpan}
|
||||
getColumnClassName={columnClassName}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function UnifiedTableView<TData>({
|
||||
props,
|
||||
rows,
|
||||
colSpan,
|
||||
getColumnClassName,
|
||||
}: {
|
||||
props: DataTableViewProps<TData>
|
||||
rows: Row<TData>[]
|
||||
colSpan: number
|
||||
getColumnClassName: DataTableColumnClassName
|
||||
}) {
|
||||
const tableSizing = getTableSizing(props)
|
||||
|
||||
return (
|
||||
<div className={props.tableContainerClassName}>
|
||||
<Table className={props.tableClassName} style={tableSizing.style}>
|
||||
{tableSizing.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,
|
||||
getColumnClassName,
|
||||
}: {
|
||||
props: DataTableViewProps<TData>
|
||||
rows: Row<TData>[]
|
||||
colSpan: number
|
||||
getColumnClassName: DataTableColumnClassName
|
||||
}) {
|
||||
const headerHostRef = React.useRef<HTMLDivElement>(null)
|
||||
const bodyHostRef = React.useRef<HTMLDivElement>(null)
|
||||
const tableSizing = getTableSizing(props)
|
||||
|
||||
React.useEffect(() => {
|
||||
const headerScroller = headerHostRef.current?.querySelector<HTMLElement>(
|
||||
'[data-slot=table-container]'
|
||||
)
|
||||
const bodyScroller = bodyHostRef.current?.querySelector<HTMLElement>(
|
||||
'[data-slot=table-container]'
|
||||
)
|
||||
|
||||
if (!headerScroller || !bodyScroller) return
|
||||
|
||||
const syncHeaderScroll = () => {
|
||||
headerScroller.scrollLeft = bodyScroller.scrollLeft
|
||||
}
|
||||
|
||||
syncHeaderScroll()
|
||||
bodyScroller.addEventListener('scroll', syncHeaderScroll, { passive: true })
|
||||
|
||||
return () => {
|
||||
bodyScroller.removeEventListener('scroll', syncHeaderScroll)
|
||||
}
|
||||
}, [rows.length, props.tableClassName, props.colgroup])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'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 [&_[data-slot=table-container]]:overflow-x-hidden'
|
||||
>
|
||||
<Table className={props.tableClassName} style={tableSizing.style}>
|
||||
{tableSizing.colgroup}
|
||||
<DataTableHeader
|
||||
table={props.table}
|
||||
applyHeaderSize={props.applyHeaderSize}
|
||||
className={props.tableHeaderClassName}
|
||||
rowClassName={props.tableHeaderRowClassName}
|
||||
getColumnClassName={getColumnClassName}
|
||||
/>
|
||||
</Table>
|
||||
</div>
|
||||
<div
|
||||
ref={bodyHostRef}
|
||||
className={cn(
|
||||
'min-h-0 flex-1 [scrollbar-gutter:stable] overflow-y-auto',
|
||||
props.bodyContainerClassName
|
||||
)}
|
||||
>
|
||||
<Table className={props.tableClassName} style={tableSizing.style}>
|
||||
{tableSizing.colgroup}
|
||||
{renderTableBody(props, rows, colSpan, getColumnClassName)}
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function useResolvedColumnClassName(
|
||||
getColumnClassName?: DataTableColumnClassName,
|
||||
pinnedColumns?: DataTablePinnedColumn[]
|
||||
) {
|
||||
const pinnedColumnById = React.useMemo(
|
||||
() => getPinnedColumnMap(pinnedColumns),
|
||||
[pinnedColumns]
|
||||
)
|
||||
|
||||
return React.useMemo(
|
||||
() =>
|
||||
getResolvedColumnClassNameFromMap(getColumnClassName, pinnedColumnById),
|
||||
[getColumnClassName, pinnedColumnById]
|
||||
)
|
||||
}
|
||||
|
||||
function getTableSizing<TData>(props: DataTableViewProps<TData>): {
|
||||
colgroup?: React.ReactNode
|
||||
style?: React.CSSProperties
|
||||
} {
|
||||
if (props.colgroup) {
|
||||
return { colgroup: props.colgroup }
|
||||
}
|
||||
|
||||
if (!props.splitHeader && !props.applyHeaderSize) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return {
|
||||
colgroup: <DataTableColgroup table={props.table} />,
|
||||
style: getTableSizeStyle(props.table),
|
||||
}
|
||||
}
|
||||
|
||||
function renderTableBody<TData>(
|
||||
props: DataTableViewProps<TData>,
|
||||
rows: Row<TData>[],
|
||||
colSpan: number,
|
||||
getColumnClassName: DataTableColumnClassName
|
||||
) {
|
||||
return (
|
||||
<TableBody className={props.tableBodyClassName}>
|
||||
{renderTableBodyContent(props, rows, colSpan, getColumnClassName)}
|
||||
</TableBody>
|
||||
)
|
||||
}
|
||||
|
||||
function renderTableBodyContent<TData>(
|
||||
props: DataTableViewProps<TData>,
|
||||
rows: Row<TData>[],
|
||||
colSpan: number,
|
||||
getColumnClassName: DataTableColumnClassName
|
||||
) {
|
||||
if (props.isLoading) {
|
||||
return (
|
||||
<TableSkeleton
|
||||
table={props.table}
|
||||
keyPrefix={props.skeletonKeyPrefix}
|
||||
rowHeight={props.skeletonRowHeight}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return renderEmptyState(props, colSpan)
|
||||
}
|
||||
|
||||
return rows.map((row) =>
|
||||
props.renderRow
|
||||
? props.renderRow(row, {
|
||||
getCellClassName: (columnId, className) =>
|
||||
cn(getColumnClassName(columnId, 'cell'), className),
|
||||
})
|
||||
: renderDefaultRow(props, row, getColumnClassName)
|
||||
)
|
||||
}
|
||||
|
||||
function renderEmptyState<TData>(
|
||||
props: DataTableViewProps<TData>,
|
||||
colSpan: number
|
||||
) {
|
||||
if (props.emptyContent) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={colSpan} className={props.emptyCellClassName}>
|
||||
{props.emptyContent}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<TableEmpty
|
||||
colSpan={colSpan}
|
||||
title={props.emptyTitle}
|
||||
description={props.emptyDescription}
|
||||
icon={props.emptyIcon}
|
||||
>
|
||||
{props.emptyAction}
|
||||
</TableEmpty>
|
||||
)
|
||||
}
|
||||
|
||||
function renderDefaultRow<TData>(
|
||||
props: DataTableViewProps<TData>,
|
||||
row: Row<TData>,
|
||||
getColumnClassName: DataTableColumnClassName
|
||||
) {
|
||||
return (
|
||||
<DataTableRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
className={cn(props.tableBodyRowClassName, props.getRowClassName?.(row))}
|
||||
getColumnClassName={getColumnClassName}
|
||||
/>
|
||||
)
|
||||
}
|
||||
+44
-40
@@ -39,48 +39,55 @@ type DataTablePaginationProps<TData> = {
|
||||
table: Table<TData>
|
||||
}
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [10, 20, 30, 40, 50, 100] as const
|
||||
const PAGE_SIZE_SELECT_ITEMS = PAGE_SIZE_OPTIONS.map((pageSize) => ({
|
||||
value: `${pageSize}`,
|
||||
label: pageSize,
|
||||
}))
|
||||
|
||||
export function DataTablePagination<TData>({
|
||||
table,
|
||||
}: DataTablePaginationProps<TData>) {
|
||||
const { t } = useTranslation()
|
||||
const currentPage = table.getState().pagination.pageIndex + 1
|
||||
const pagination = table.getState().pagination
|
||||
const currentPage = pagination.pageIndex + 1
|
||||
const pageSize = pagination.pageSize
|
||||
const totalPages = table.getPageCount()
|
||||
const totalRows = table.getRowCount()
|
||||
const pageNumbers = getPageNumbers(currentPage, totalPages)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between overflow-clip',
|
||||
'@max-2xl/content:flex-col-reverse @max-2xl/content:gap-2 sm:@max-2xl/content:gap-4'
|
||||
'@container/pagination flex min-w-0 items-center justify-end overflow-clip'
|
||||
)}
|
||||
style={{ overflowClipMargin: 1 }}
|
||||
>
|
||||
<div className='flex w-full items-center justify-between gap-2'>
|
||||
<div className='flex min-w-0 items-center text-xs font-medium whitespace-nowrap sm:min-w-[130px] sm:text-sm @2xl/content:hidden'>
|
||||
{t('Page {{current}} of {{total}}', {
|
||||
current: currentPage,
|
||||
total: totalPages,
|
||||
})}
|
||||
<div className='flex min-w-0 shrink-0 items-center gap-2 @xl/pagination:gap-3'>
|
||||
<div className='flex shrink-0 items-baseline gap-1.5 text-xs font-medium whitespace-nowrap sm:text-sm'>
|
||||
<span className='text-muted-foreground/80'>{t('Total:')}</span>
|
||||
<span className='text-foreground tabular-nums'>
|
||||
{totalRows.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-2 @max-2xl/content:flex-row-reverse'>
|
||||
|
||||
<div className='flex shrink-0 items-center gap-1.5 @lg/pagination:gap-2'>
|
||||
<p className='text-muted-foreground/80 hidden text-sm font-medium whitespace-nowrap @2xl/pagination:block'>
|
||||
{t('Rows per page')}
|
||||
</p>
|
||||
<Select
|
||||
items={[
|
||||
...[10, 20, 30, 40, 50, 100].map((pageSize) => ({
|
||||
value: `${pageSize}`,
|
||||
label: pageSize,
|
||||
})),
|
||||
]}
|
||||
value={`${table.getState().pagination.pageSize}`}
|
||||
items={PAGE_SIZE_SELECT_ITEMS}
|
||||
value={`${pageSize}`}
|
||||
onValueChange={(value) => {
|
||||
table.setPageSize(Number(value))
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className='h-8 w-[64px] sm:w-[70px]'>
|
||||
<SelectValue placeholder={table.getState().pagination.pageSize} />
|
||||
<SelectTrigger className='text-foreground h-8 w-[64px] font-medium tabular-nums sm:w-[70px]'>
|
||||
<SelectValue placeholder={pageSize} />
|
||||
</SelectTrigger>
|
||||
<SelectContent side='top' alignItemWithTrigger={false}>
|
||||
<SelectGroup>
|
||||
{[10, 20, 30, 40, 50, 100].map((pageSize) => (
|
||||
{PAGE_SIZE_OPTIONS.map((pageSize) => (
|
||||
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||
{pageSize}
|
||||
</SelectItem>
|
||||
@@ -88,23 +95,12 @@ export function DataTablePagination<TData>({
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className='hidden text-sm font-medium sm:block'>
|
||||
{t('Rows per page')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center sm:space-x-6 lg:space-x-8'>
|
||||
<div className='flex min-w-[130px] items-center text-sm font-medium whitespace-nowrap @max-3xl/content:hidden'>
|
||||
{t('Page {{current}} of {{total}}', {
|
||||
current: currentPage,
|
||||
total: totalPages,
|
||||
})}
|
||||
</div>
|
||||
<div className='flex items-center space-x-1.5 sm:space-x-2'>
|
||||
<div className='flex min-w-0 shrink-0 items-center gap-1 @lg/pagination:gap-1.5 @xl/pagination:gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='size-8 p-0 @max-md/content:hidden'
|
||||
className='text-muted-foreground hover:text-foreground disabled:text-muted-foreground/50 size-8 p-0 @max-lg/pagination:hidden'
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
@@ -113,7 +109,7 @@ export function DataTablePagination<TData>({
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='size-8 p-0'
|
||||
className='text-muted-foreground hover:text-foreground disabled:text-muted-foreground/50 size-8 p-0'
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
@@ -121,18 +117,26 @@ export function DataTablePagination<TData>({
|
||||
<ChevronLeftIcon className='h-4 w-4' />
|
||||
</Button>
|
||||
|
||||
{/* Page number buttons */}
|
||||
{pageNumbers.map((pageNumber, index) => (
|
||||
<div key={`${pageNumber}-${index}`} className='flex items-center'>
|
||||
{pageNumber === '...' ? (
|
||||
<span className='text-muted-foreground px-1 text-sm'>...</span>
|
||||
<span className='text-muted-foreground/60 px-0.5 text-sm @lg/pagination:px-1'>
|
||||
...
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
variant={currentPage === pageNumber ? 'default' : 'outline'}
|
||||
className='h-8 min-w-8 px-2'
|
||||
className={cn(
|
||||
'h-8 min-w-8 px-2 tabular-nums',
|
||||
currentPage === pageNumber
|
||||
? 'font-semibold'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
onClick={() => table.setPageIndex((pageNumber as number) - 1)}
|
||||
>
|
||||
<span className='sr-only'>Go to page {pageNumber}</span>
|
||||
<span className='sr-only'>
|
||||
{t('Go to page {{page}}', { page: pageNumber })}
|
||||
</span>
|
||||
{pageNumber}
|
||||
</Button>
|
||||
)}
|
||||
@@ -141,7 +145,7 @@ export function DataTablePagination<TData>({
|
||||
|
||||
<Button
|
||||
variant='outline'
|
||||
className='size-8 p-0'
|
||||
className='text-muted-foreground hover:text-foreground disabled:text-muted-foreground/50 size-8 p-0'
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
@@ -150,7 +154,7 @@ export function DataTablePagination<TData>({
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='size-8 p-0 @max-md/content:hidden'
|
||||
className='text-muted-foreground hover:text-foreground disabled:text-muted-foreground/50 size-8 p-0 @max-lg/pagination:hidden'
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
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 type * as React from 'react'
|
||||
import type { Table as TanstackTable } from '@tanstack/react-table'
|
||||
|
||||
export function getTableSizeStyle<TData>(
|
||||
table: TanstackTable<TData>
|
||||
): React.CSSProperties {
|
||||
const width = table
|
||||
.getVisibleLeafColumns()
|
||||
.reduce((total, column) => total + column.getSize(), 0)
|
||||
|
||||
return { minWidth: width, tableLayout: 'fixed', width: '100%' }
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
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 type * as React from 'react'
|
||||
import type { Row, Table as TanstackTable } from '@tanstack/react-table'
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
/*
|
||||
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 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
|
||||
}
|
||||
|
||||
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,
|
||||
manualFiltering,
|
||||
manualPagination,
|
||||
manualSorting,
|
||||
initialSorting = [],
|
||||
initialColumnVisibility = {},
|
||||
initialRowSelection = {},
|
||||
initialExpanded = {},
|
||||
initialPagination = { pageIndex: 0, pageSize: 20 },
|
||||
withFilteredRowModel = !manualFiltering,
|
||||
withPaginationRowModel = !manualPagination,
|
||||
withSortedRowModel = !manualSorting,
|
||||
withFacetedRowModel = !manualFiltering,
|
||||
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,
|
||||
rowCount: totalCount,
|
||||
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,
|
||||
manualPagination,
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
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 { ColumnFiltersState, OnChangeFn } from '@tanstack/react-table'
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
|
||||
type UseDebouncedColumnFilterOptions = {
|
||||
columnFilters: ColumnFiltersState
|
||||
columnId: string
|
||||
onColumnFiltersChange: OnChangeFn<ColumnFiltersState>
|
||||
delay?: number
|
||||
}
|
||||
|
||||
export function useDebouncedColumnFilter({
|
||||
columnFilters,
|
||||
columnId,
|
||||
onColumnFiltersChange,
|
||||
delay = 500,
|
||||
}: UseDebouncedColumnFilterOptions) {
|
||||
const value =
|
||||
(columnFilters.find((filter) => filter.id === columnId)?.value as
|
||||
| string
|
||||
| undefined) ?? ''
|
||||
const [inputValue, setInputValue] = React.useState(value)
|
||||
const [pendingValue, setPendingValue] = React.useState(value)
|
||||
const isComposingRef = React.useRef(false)
|
||||
const debouncedValue = useDebounce(pendingValue, delay)
|
||||
|
||||
React.useEffect(() => {
|
||||
// Keep the input aligned when URL state changes outside the local field.
|
||||
if (!isComposingRef.current) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setInputValue(value)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setPendingValue(value)
|
||||
}, [value])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (debouncedValue === value) return
|
||||
|
||||
onColumnFiltersChange((previous) => {
|
||||
const filters = previous.filter((filter) => filter.id !== columnId)
|
||||
return debouncedValue
|
||||
? [...filters, { id: columnId, value: debouncedValue }]
|
||||
: filters
|
||||
})
|
||||
}, [columnId, debouncedValue, onColumnFiltersChange, value])
|
||||
|
||||
const updateInputValue = React.useCallback((nextValue: string) => {
|
||||
setInputValue(nextValue)
|
||||
|
||||
if (!isComposingRef.current) {
|
||||
setPendingValue(nextValue)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
updateInputValue(event.target.value)
|
||||
},
|
||||
[updateInputValue]
|
||||
)
|
||||
|
||||
const handleCompositionStart = React.useCallback(() => {
|
||||
isComposingRef.current = true
|
||||
}, [])
|
||||
|
||||
const handleCompositionEnd = React.useCallback(
|
||||
(event: React.CompositionEvent<HTMLInputElement>) => {
|
||||
isComposingRef.current = false
|
||||
const nextValue = event.currentTarget.value
|
||||
setInputValue(nextValue)
|
||||
setPendingValue(nextValue)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const resetInput = React.useCallback(() => {
|
||||
isComposingRef.current = false
|
||||
setInputValue('')
|
||||
setPendingValue('')
|
||||
}, [])
|
||||
|
||||
return {
|
||||
value,
|
||||
inputValue,
|
||||
setInputValue: updateInputValue,
|
||||
onChange: handleChange,
|
||||
onCompositionStart: handleCompositionStart,
|
||||
onCompositionEnd: handleCompositionEnd,
|
||||
resetInput,
|
||||
}
|
||||
}
|
||||
+24
-10
@@ -16,16 +16,30 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
export { DataTablePagination } from './pagination'
|
||||
export { DataTableColumnHeader } from './column-header'
|
||||
export { DataTableFacetedFilter } from './faceted-filter'
|
||||
export { DataTableViewOptions } from './view-options'
|
||||
export { DataTableToolbar } from './toolbar'
|
||||
export { DataTableBulkActions } from './bulk-actions'
|
||||
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 { DataTablePagination } from './core/pagination'
|
||||
export { DataTableColumnHeader } from './core/column-header'
|
||||
export { DataTableViewOptions } from './toolbar/view-options'
|
||||
export { DataTableToolbar } from './toolbar/toolbar'
|
||||
export { DataTableBulkActions } from './toolbar/bulk-actions'
|
||||
export {
|
||||
StaticDataTable,
|
||||
type StaticDataTableColumn,
|
||||
} from './static/static-data-table'
|
||||
export { staticDataTableClassNames } from './static/static-data-table-classnames'
|
||||
export {
|
||||
DataTableRow,
|
||||
DataTableView,
|
||||
type DataTableColumnClassName,
|
||||
type DataTablePinnedColumn,
|
||||
type DataTableRenderRowHelpers,
|
||||
} from './core/data-table-view'
|
||||
export { MobileCardList } from './layout/mobile-card-list'
|
||||
export {
|
||||
DataTablePage,
|
||||
type DataTablePageProps,
|
||||
} from './layout/data-table-page'
|
||||
export { useDataTable } from './hooks/use-data-table'
|
||||
export { useDebouncedColumnFilter } from './hooks/use-debounced-column-filter'
|
||||
|
||||
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'
|
||||
|
||||
+85
-112
@@ -18,27 +18,22 @@ 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 '../core/data-table-view'
|
||||
import { DataTablePagination } from '../core/pagination'
|
||||
import { DataTableToolbar } from '../toolbar/toolbar'
|
||||
import { MobileCardList } from './mobile-card-list'
|
||||
import { DataTablePagination } from './pagination'
|
||||
import { TableEmpty } from './table-empty'
|
||||
import { TableSkeleton } from './table-skeleton'
|
||||
import { DataTableToolbar } from './toolbar'
|
||||
|
||||
/**
|
||||
* Pass-through configuration for the default {@link DataTableToolbar}.
|
||||
@@ -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>`.
|
||||
@@ -182,6 +192,12 @@ export type DataTablePageProps<TData> = {
|
||||
*/
|
||||
className?: string
|
||||
|
||||
/**
|
||||
* Make the desktop table consume the available page height and scroll inside
|
||||
* the table body while keeping the header fixed. Defaults to `true`.
|
||||
*/
|
||||
fixedHeight?: boolean
|
||||
|
||||
/**
|
||||
* Desktop table container className (the bordered scroll wrapper).
|
||||
*/
|
||||
@@ -189,7 +205,8 @@ export type DataTablePageProps<TData> = {
|
||||
|
||||
/**
|
||||
* Desktop `<TableHeader>` className override.
|
||||
* Useful for sticky headers (`'sticky top-0 z-10 bg-muted/30'`) on long lists.
|
||||
* Use for header color/spacing overrides. Fixed-height pages keep the header
|
||||
* outside the scrollable body automatically.
|
||||
*/
|
||||
tableHeaderClassName?: string
|
||||
}
|
||||
@@ -222,10 +239,18 @@ export function DataTablePage<TData>(props: DataTablePageProps<TData>) {
|
||||
const toolbarNode = renderToolbar(props)
|
||||
const mobileNode = renderMobile(props, showMobile)
|
||||
const desktopNode = renderDesktop(props, showMobile)
|
||||
const paginationNode = renderPagination(props)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn('space-y-2.5 sm:space-y-3', props.className)}>
|
||||
<div
|
||||
className={cn(
|
||||
props.fixedHeight !== false
|
||||
? 'flex h-full min-h-0 flex-col gap-2.5 sm:gap-3'
|
||||
: 'space-y-2.5 sm:space-y-3',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{toolbarNode}
|
||||
{mobileNode}
|
||||
{desktopNode}
|
||||
@@ -236,16 +261,7 @@ export function DataTablePage<TData>(props: DataTablePageProps<TData>) {
|
||||
handle its own visibility, we just gate it to non-mobile. */}
|
||||
{!showMobile && props.bulkActions}
|
||||
|
||||
{props.showPagination !== false &&
|
||||
(props.paginationInFooter !== false ? (
|
||||
<PageFooterPortal>
|
||||
<DataTablePagination table={props.table} />
|
||||
</PageFooterPortal>
|
||||
) : (
|
||||
<div className='pt-2'>
|
||||
<DataTablePagination table={props.table} />
|
||||
</div>
|
||||
))}
|
||||
{paginationNode}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -265,12 +281,25 @@ function renderToolbar<TData>(
|
||||
return null
|
||||
}
|
||||
|
||||
function renderPagination<TData>(
|
||||
props: DataTablePageProps<TData>
|
||||
): React.ReactNode {
|
||||
if (props.showPagination === false) return null
|
||||
|
||||
const pagination = <DataTablePagination table={props.table} />
|
||||
|
||||
return props.paginationInFooter !== false ? (
|
||||
<PageFooterPortal>{pagination}</PageFooterPortal>
|
||||
) : (
|
||||
<div className='pt-2'>{pagination}</div>
|
||||
)
|
||||
}
|
||||
|
||||
function renderMobile<TData>(
|
||||
props: DataTablePageProps<TData>,
|
||||
showMobile: boolean
|
||||
): React.ReactNode {
|
||||
if (!showMobile) return null
|
||||
if (props.mobile !== undefined) return props.mobile
|
||||
|
||||
const ownGetRowClassName = props.getRowClassName
|
||||
const mobileGetRowClassName =
|
||||
@@ -278,8 +307,7 @@ function renderMobile<TData>(
|
||||
(ownGetRowClassName
|
||||
? (row: Row<TData>) => ownGetRowClassName(row, { isMobile: true })
|
||||
: undefined)
|
||||
|
||||
return (
|
||||
const mobileContent = props.mobile ?? (
|
||||
<MobileCardList
|
||||
table={props.table}
|
||||
isLoading={props.isLoading}
|
||||
@@ -289,6 +317,8 @@ function renderMobile<TData>(
|
||||
getRowClassName={mobileGetRowClassName}
|
||||
/>
|
||||
)
|
||||
|
||||
return <div className='min-h-0 flex-1 overflow-y-auto'>{mobileContent}</div>
|
||||
}
|
||||
|
||||
function renderDesktop<TData>(
|
||||
@@ -297,94 +327,37 @@ function renderDesktop<TData>(
|
||||
): React.ReactNode {
|
||||
if (showMobile) return null
|
||||
|
||||
const rows = props.table.getRowModel().rows
|
||||
const isFetchingOnly = props.isFetching && !props.isLoading
|
||||
const fixedHeight = props.fixedHeight !== false
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden rounded-lg border transition-opacity duration-150',
|
||||
<DataTableView
|
||||
table={props.table}
|
||||
isLoading={props.isLoading}
|
||||
emptyTitle={props.emptyTitle}
|
||||
emptyDescription={props.emptyDescription}
|
||||
emptyIcon={props.emptyIcon}
|
||||
emptyAction={props.emptyAction}
|
||||
skeletonKeyPrefix={props.skeletonKeyPrefix}
|
||||
renderRow={props.renderRow}
|
||||
applyHeaderSize={props.applyHeaderSize}
|
||||
splitHeader={fixedHeight}
|
||||
tableContainerClassName={fixedHeight ? 'h-full min-h-0' : undefined}
|
||||
tableHeaderClassName={cn(
|
||||
fixedHeight && 'bg-muted/30',
|
||||
props.tableHeaderClassName
|
||||
)}
|
||||
getColumnClassName={props.getColumnClassName}
|
||||
pinnedColumns={props.pinnedColumns}
|
||||
containerClassName={cn(
|
||||
fixedHeight && 'min-h-0 flex-1',
|
||||
'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,46 @@
|
||||
/*
|
||||
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
|
||||
*/
|
||||
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
|
||||
@@ -0,0 +1,206 @@
|
||||
/*
|
||||
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,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { staticDataTableClassNames } from './static-data-table-classnames'
|
||||
|
||||
type StaticDataTableBaseProps = {
|
||||
className?: string
|
||||
tableClassName?: string
|
||||
containerProps?: Omit<React.ComponentProps<'div'>, 'className' | 'children'>
|
||||
tableProps?: Omit<
|
||||
React.ComponentProps<typeof Table>,
|
||||
'className' | 'children'
|
||||
>
|
||||
}
|
||||
|
||||
type StaticDataTableDataProps<TData = unknown> = StaticDataTableBaseProps & {
|
||||
columns: StaticDataTableColumn<TData>[]
|
||||
data: TData[]
|
||||
getRowKey?: (row: TData, index: number) => React.Key
|
||||
getRowClassName?: (row: TData, index: number) => string | undefined
|
||||
renderRow?: (row: TData, index: number) => React.ReactNode
|
||||
empty?: boolean
|
||||
emptyContent?: React.ReactNode
|
||||
emptyClassName?: string
|
||||
headerRowClassName?: string
|
||||
}
|
||||
|
||||
type StaticDataTableChildrenProps = StaticDataTableBaseProps & {
|
||||
children: React.ReactNode
|
||||
columns?: never
|
||||
data?: never
|
||||
}
|
||||
|
||||
type StaticDataTableProps<TData = unknown> =
|
||||
| StaticDataTableDataProps<TData>
|
||||
| StaticDataTableChildrenProps
|
||||
|
||||
export type StaticDataTableColumn<TData = unknown> = {
|
||||
id: string
|
||||
header: React.ReactNode
|
||||
className?: string
|
||||
cellClassName?: string | ((row: TData, index: number) => string | undefined)
|
||||
cell?: (row: TData, index: number) => React.ReactNode
|
||||
}
|
||||
|
||||
export function StaticDataTable<TData = unknown>(
|
||||
props: StaticDataTableProps<TData>
|
||||
) {
|
||||
const { className, tableClassName, containerProps, tableProps } = props
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(staticDataTableClassNames.container, className)}
|
||||
{...containerProps}
|
||||
>
|
||||
<Table className={tableClassName} {...tableProps}>
|
||||
{props.columns !== undefined ? (
|
||||
<StaticDataTableWithColumns {...props} />
|
||||
) : (
|
||||
props.children
|
||||
)}
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StaticDataTableWithColumns<TData>({
|
||||
columns,
|
||||
data,
|
||||
getRowKey,
|
||||
getRowClassName,
|
||||
renderRow,
|
||||
empty,
|
||||
emptyContent,
|
||||
emptyClassName,
|
||||
headerRowClassName,
|
||||
}: StaticDataTableDataProps<TData>) {
|
||||
const isEmpty = empty ?? (data !== undefined && data.length === 0)
|
||||
const bodyRows = data.map((row, index) => (
|
||||
<StaticDataTableRow
|
||||
key={getRowKey?.(row, index) ?? index}
|
||||
row={row}
|
||||
index={index}
|
||||
columns={columns}
|
||||
getRowClassName={getRowClassName}
|
||||
renderRow={renderRow}
|
||||
/>
|
||||
))
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableHeader>
|
||||
<TableRow className={headerRowClassName}>
|
||||
{columns.map((column) => (
|
||||
<TableHead key={column.id} className={column.className}>
|
||||
{column.header}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isEmpty ? (
|
||||
<StaticDataTableEmptyRow
|
||||
colSpan={columns.length}
|
||||
className={emptyClassName}
|
||||
>
|
||||
{emptyContent}
|
||||
</StaticDataTableEmptyRow>
|
||||
) : (
|
||||
bodyRows
|
||||
)}
|
||||
</TableBody>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type StaticDataTableRowProps<TData> = Required<
|
||||
Pick<StaticDataTableDataProps<TData>, 'columns'>
|
||||
> &
|
||||
Pick<StaticDataTableDataProps<TData>, 'getRowClassName' | 'renderRow'> & {
|
||||
row: TData
|
||||
index: number
|
||||
}
|
||||
|
||||
function StaticDataTableRow<TData>({
|
||||
row,
|
||||
index,
|
||||
columns,
|
||||
getRowClassName,
|
||||
renderRow,
|
||||
}: StaticDataTableRowProps<TData>) {
|
||||
if (renderRow) {
|
||||
return <>{renderRow(row, index)}</>
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRow className={getRowClassName?.(row, index)}>
|
||||
{columns.map((column) => (
|
||||
<TableCell
|
||||
key={column.id}
|
||||
className={getStaticCellClassName(column, row, index)}
|
||||
>
|
||||
{column.cell?.(row, index)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
|
||||
function getStaticCellClassName<TData>(
|
||||
column: StaticDataTableColumn<TData>,
|
||||
row: TData,
|
||||
index: number
|
||||
) {
|
||||
return typeof column.cellClassName === 'function'
|
||||
? column.cellClassName(row, index)
|
||||
: column.cellClassName
|
||||
}
|
||||
|
||||
type StaticDataTableEmptyRowProps = {
|
||||
colSpan: number
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
function StaticDataTableEmptyRow({
|
||||
colSpan,
|
||||
children,
|
||||
className,
|
||||
}: StaticDataTableEmptyRowProps) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={colSpan}
|
||||
className={cn('h-24 text-center', className)}
|
||||
>
|
||||
{children}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
@@ -50,6 +50,7 @@ SectionPageLayoutBreadcrumb.displayName = 'SectionPageLayout.Breadcrumb'
|
||||
|
||||
export type SectionPageLayoutProps = {
|
||||
children: ReactNode
|
||||
fixedContent?: boolean
|
||||
}
|
||||
|
||||
export function SectionPageLayout(props: SectionPageLayoutProps) {
|
||||
@@ -95,7 +96,13 @@ export function SectionPageLayout(props: SectionPageLayoutProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='min-h-0 flex-1 overflow-auto px-3 pt-1 pb-3 sm:px-4 sm:pt-1.5 sm:pb-4'>
|
||||
<div
|
||||
className={
|
||||
props.fixedContent
|
||||
? 'min-h-0 flex-1 overflow-hidden px-3 pt-1 pb-3 sm:px-4 sm:pt-1.5 sm:pb-4'
|
||||
: 'min-h-0 flex-1 overflow-auto px-3 pt-1 pb-3 sm:px-4 sm:pt-1.5 sm:pb-4'
|
||||
}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
|
||||
|
||||
-1
@@ -46,7 +46,6 @@ export function LongText({
|
||||
|
||||
useEffect(() => {
|
||||
if (checkOverflow(ref.current)) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setIsOverflown(true)
|
||||
return
|
||||
}
|
||||
|
||||
+7
-3
@@ -42,14 +42,18 @@ interface MaskedValueDisplayProps {
|
||||
*/
|
||||
export function MaskedValueDisplay(props: MaskedValueDisplayProps) {
|
||||
return (
|
||||
<div className='flex items-center'>
|
||||
<div className='flex max-w-full min-w-0 items-center'>
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button variant='ghost' size='sm' className='h-7 font-mono' />
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-7 max-w-full min-w-0 justify-start truncate px-0 font-mono hover:bg-transparent aria-expanded:bg-transparent'
|
||||
/>
|
||||
}
|
||||
>
|
||||
{props.maskedValue}
|
||||
<span className='truncate'>{props.maskedValue}</span>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className='w-auto max-w-[min(90vw,28rem)]'
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
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 { getLobeIcon } from '@/lib/lobe-icon'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { StatusBadge, type StatusBadgeProps } from './status-badge'
|
||||
|
||||
type ProviderBadgeProps = Omit<StatusBadgeProps, 'children' | 'label'> & {
|
||||
iconKey?: string | null
|
||||
iconSize?: number
|
||||
label: string
|
||||
}
|
||||
|
||||
export function ProviderBadge({
|
||||
className,
|
||||
iconKey,
|
||||
iconSize = 14,
|
||||
label,
|
||||
...badgeProps
|
||||
}: ProviderBadgeProps) {
|
||||
const icon = iconKey ? getLobeIcon(iconKey, iconSize) : null
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-1.5', className)}>
|
||||
{icon}
|
||||
<StatusBadge label={label} autoColor={label} size='sm' {...badgeProps} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+2
-1
@@ -103,7 +103,7 @@ export function StatusBadge({
|
||||
variant,
|
||||
size = 'sm',
|
||||
pulse = false,
|
||||
showDot = true,
|
||||
showDot = false,
|
||||
copyable = true,
|
||||
copyText,
|
||||
autoColor,
|
||||
@@ -130,6 +130,7 @@ export function StatusBadge({
|
||||
|
||||
return (
|
||||
<span
|
||||
data-slot='status-badge'
|
||||
className={cn(
|
||||
'inline-flex w-fit max-w-full shrink-0 items-center rounded-4xl font-medium tracking-normal whitespace-nowrap transition-colors',
|
||||
sizeMap[size ?? 'sm'],
|
||||
|
||||
+1
-1
@@ -103,7 +103,7 @@ function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
|
||||
<td
|
||||
data-slot='table-cell'
|
||||
className={cn(
|
||||
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0',
|
||||
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>*:has(>[data-slot=status-badge]:first-child):first-child]:-ml-1.5 [&>[data-slot=status-badge]:first-child]:-ml-1.5',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -35,7 +35,6 @@ import {
|
||||
formatTimestampToDate,
|
||||
formatQuota as formatQuotaValue,
|
||||
} from '@/lib/format'
|
||||
import { getLobeIcon } from '@/lib/lobe-icon'
|
||||
import { truncateText } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
@@ -46,8 +45,9 @@ import {
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { DataTableColumnHeader } from '@/components/data-table/column-header'
|
||||
import { DataTableColumnHeader } from '@/components/data-table'
|
||||
import { GroupBadge } from '@/components/group-badge'
|
||||
import { ProviderBadge } from '@/components/provider-badge'
|
||||
import { StatusBadge, StatusBadgeList } from '@/components/status-badge'
|
||||
import { TableId } from '@/components/table-id'
|
||||
import { TruncatedText } from '@/components/truncated-text'
|
||||
@@ -623,7 +623,6 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
const typeNameKey = getChannelTypeLabel(type)
|
||||
const typeName = t(typeNameKey)
|
||||
const iconName = getChannelTypeIcon(type)
|
||||
const icon = getLobeIcon(`${iconName}.Color`, 14)
|
||||
const channel = row.original as Channel
|
||||
const isMultiKey = isMultiKeyChannel(channel)
|
||||
const multiKeyMode = channel.channel_info?.multi_key_mode ?? 'random'
|
||||
@@ -657,16 +656,12 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<StatusBadge
|
||||
autoColor={typeName}
|
||||
size='sm'
|
||||
<ProviderBadge
|
||||
iconKey={iconName}
|
||||
label={typeName}
|
||||
copyable={false}
|
||||
showDot={false}
|
||||
className='gap-1 pl-1'
|
||||
>
|
||||
{icon}
|
||||
<span className='truncate'>{typeName}</span>
|
||||
</StatusBadge>
|
||||
/>
|
||||
{isIonet && (
|
||||
<TooltipProvider delay={100}>
|
||||
<Tooltip>
|
||||
|
||||
+34
-109
@@ -16,27 +16,15 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import {
|
||||
useState,
|
||||
useMemo,
|
||||
useEffect,
|
||||
useRef,
|
||||
type ChangeEvent,
|
||||
type CompositionEvent,
|
||||
} from 'react'
|
||||
import { useState, useMemo } 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'
|
||||
import { useMediaQuery } from '@/hooks'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getLobeIcon } from '@/lib/lobe-icon'
|
||||
import { useTableUrlState } from '@/hooks/use-table-url-state'
|
||||
@@ -45,6 +33,8 @@ import {
|
||||
DISABLED_ROW_DESKTOP,
|
||||
DISABLED_ROW_MOBILE,
|
||||
DataTablePage,
|
||||
useDebouncedColumnFilter,
|
||||
useDataTable,
|
||||
} from '@/components/data-table'
|
||||
import { getChannels, searchChannels, getGroups } from '../api'
|
||||
import {
|
||||
@@ -88,12 +78,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 {
|
||||
@@ -123,71 +107,24 @@ export function ChannelsTable() {
|
||||
// Extract filters from column filters
|
||||
const statusFilter =
|
||||
(columnFilters.find((f) => f.id === 'status')?.value as string[]) || []
|
||||
const typeFilter =
|
||||
(columnFilters.find((f) => f.id === 'type')?.value as string[]) || []
|
||||
const typeFilter = useMemo(
|
||||
() => (columnFilters.find((f) => f.id === 'type')?.value as string[]) || [],
|
||||
[columnFilters]
|
||||
)
|
||||
const groupFilter =
|
||||
(columnFilters.find((f) => f.id === 'group')?.value as string[]) || []
|
||||
const modelFilterFromUrl =
|
||||
(columnFilters.find((f) => f.id === 'model')?.value as string) || ''
|
||||
|
||||
// Local state for immediate input feedback
|
||||
const isModelFilterComposingRef = useRef(false)
|
||||
const [modelFilterInput, setModelFilterInput] = useState(modelFilterFromUrl)
|
||||
const [modelFilterPendingValue, setModelFilterPendingValue] =
|
||||
useState(modelFilterFromUrl)
|
||||
const debouncedModelFilter = useDebounce(modelFilterPendingValue, 500)
|
||||
|
||||
// Sync local input with URL when URL changes (e.g., from back/forward navigation)
|
||||
useEffect(() => {
|
||||
if (!isModelFilterComposingRef.current) {
|
||||
setModelFilterInput(modelFilterFromUrl)
|
||||
}
|
||||
setModelFilterPendingValue(modelFilterFromUrl)
|
||||
}, [modelFilterFromUrl])
|
||||
|
||||
// Update URL when debounced value changes
|
||||
useEffect(() => {
|
||||
if (
|
||||
debouncedModelFilter === modelFilterPendingValue &&
|
||||
debouncedModelFilter !== modelFilterFromUrl
|
||||
) {
|
||||
onColumnFiltersChange((prev) => {
|
||||
const filtered = prev.filter((f) => f.id !== 'model')
|
||||
return debouncedModelFilter
|
||||
? [...filtered, { id: 'model', value: debouncedModelFilter }]
|
||||
: filtered
|
||||
})
|
||||
}
|
||||
}, [
|
||||
debouncedModelFilter,
|
||||
modelFilterFromUrl,
|
||||
modelFilterPendingValue,
|
||||
const {
|
||||
value: modelFilter,
|
||||
inputValue: modelFilterInput,
|
||||
onChange: onModelFilterInputChange,
|
||||
onCompositionStart: onModelFilterCompositionStart,
|
||||
onCompositionEnd: onModelFilterCompositionEnd,
|
||||
resetInput: resetModelFilterInput,
|
||||
} = useDebouncedColumnFilter({
|
||||
columnFilters,
|
||||
columnId: 'model',
|
||||
onColumnFiltersChange,
|
||||
])
|
||||
|
||||
const handleModelFilterChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.target.value
|
||||
setModelFilterInput(value)
|
||||
|
||||
if (!isModelFilterComposingRef.current) {
|
||||
setModelFilterPendingValue(value)
|
||||
}
|
||||
}
|
||||
|
||||
const handleModelFilterCompositionStart = () => {
|
||||
isModelFilterComposingRef.current = true
|
||||
}
|
||||
|
||||
const handleModelFilterCompositionEnd = (
|
||||
event: CompositionEvent<HTMLInputElement>
|
||||
) => {
|
||||
isModelFilterComposingRef.current = false
|
||||
const value = event.currentTarget.value
|
||||
setModelFilterInput(value)
|
||||
setModelFilterPendingValue(value)
|
||||
}
|
||||
|
||||
const modelFilter = modelFilterFromUrl
|
||||
})
|
||||
|
||||
// Determine whether to use search or regular list API
|
||||
const shouldSearch = Boolean(globalFilter?.trim() || modelFilter.trim())
|
||||
@@ -322,41 +259,31 @@ 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,
|
||||
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 || {}
|
||||
@@ -430,17 +357,15 @@ export function ChannelsTable() {
|
||||
searchPlaceholder: t('Filter by name, ID, or key...'),
|
||||
searchDebounceMs: 500,
|
||||
onReset: () => {
|
||||
isModelFilterComposingRef.current = false
|
||||
setModelFilterInput('')
|
||||
setModelFilterPendingValue('')
|
||||
resetModelFilterInput()
|
||||
},
|
||||
additionalSearch: (
|
||||
<Input
|
||||
placeholder={t('Filter by model...')}
|
||||
value={modelFilterInput}
|
||||
onChange={handleModelFilterChange}
|
||||
onCompositionStart={handleModelFilterCompositionStart}
|
||||
onCompositionEnd={handleModelFilterCompositionEnd}
|
||||
onChange={onModelFilterInputChange}
|
||||
onCompositionStart={onModelFilterCompositionStart}
|
||||
onCompositionEnd={onModelFilterCompositionEnd}
|
||||
className='w-full sm:w-[150px] lg:w-[180px]'
|
||||
/>
|
||||
),
|
||||
|
||||
+62
-110
@@ -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,21 +48,17 @@ 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 { DataTablePagination } from '@/components/data-table/pagination'
|
||||
import {
|
||||
DataTableBulkActions as BulkActionsToolbar,
|
||||
DataTablePagination,
|
||||
DataTableView,
|
||||
useDataTable,
|
||||
} from '@/components/data-table'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import {
|
||||
sideDrawerContentClassName,
|
||||
@@ -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
|
||||
}
|
||||
@@ -227,6 +219,14 @@ export function ChannelTestDialog({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
})
|
||||
const endpointSelectItems = useMemo(
|
||||
() =>
|
||||
endpointTypeOptions.map((option) => ({
|
||||
value: option.value,
|
||||
label: t(option.label),
|
||||
})),
|
||||
[t]
|
||||
)
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setEndpointType('auto')
|
||||
@@ -502,18 +502,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) {
|
||||
@@ -548,12 +547,7 @@ export function ChannelTestDialog({
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='endpoint-type'>{t('Endpoint Type')}</Label>
|
||||
<Select
|
||||
items={[
|
||||
...endpointTypeOptions.map((option) => {
|
||||
const itemValue = option.value
|
||||
return { value: itemValue, label: t(option.label) }
|
||||
}),
|
||||
]}
|
||||
items={endpointSelectItems}
|
||||
value={endpointType}
|
||||
onValueChange={(v) => v !== null && setEndpointType(v)}
|
||||
>
|
||||
@@ -562,14 +556,11 @@ export function ChannelTestDialog({
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectGroup>
|
||||
{endpointTypeOptions.map((option) => {
|
||||
const itemValue = option.value
|
||||
return (
|
||||
<SelectItem key={itemValue} value={itemValue}>
|
||||
{t(option.label)}
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
{endpointSelectItems.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -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>
|
||||
|
||||
+48
-50
@@ -31,15 +31,8 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import {
|
||||
@@ -358,48 +351,53 @@ 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>
|
||||
</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>
|
||||
<StaticDataTable
|
||||
className='rounded-none border-0'
|
||||
tableClassName='min-w-[800px]'
|
||||
data={keys}
|
||||
getRowKey={(key) => key.index}
|
||||
columns={[
|
||||
{
|
||||
id: 'index',
|
||||
header: t('Index'),
|
||||
className: 'w-20',
|
||||
cellClassName: 'font-mono text-sm',
|
||||
cell: (key) => `#${key.index + 1}`,
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
header: t('Status'),
|
||||
className: 'w-32',
|
||||
cell: (key) => renderStatusBadge(key.status),
|
||||
},
|
||||
{
|
||||
id: 'reason',
|
||||
header: t('Disabled Reason'),
|
||||
className: 'min-w-[200px]',
|
||||
cellClassName: 'max-w-xs truncate text-sm',
|
||||
cell: (key) => key.reason || '-',
|
||||
},
|
||||
{
|
||||
id: 'disabled-time',
|
||||
header: t('Disabled Time'),
|
||||
className: 'w-44',
|
||||
cellClassName: 'text-muted-foreground text-sm',
|
||||
cell: (key) => formatKeyTimestamp(key.disabled_time),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('Actions'),
|
||||
className: 'w-44 text-right',
|
||||
cell: (key) => (
|
||||
<MultiKeyTableRowActions
|
||||
keyIndex={key.index}
|
||||
status={key.status}
|
||||
onAction={setConfirmAction}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ export function Channels() {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<ChannelsProvider>
|
||||
<SectionPageLayout>
|
||||
<SectionPageLayout fixedContent>
|
||||
<SectionPageLayout.Title>{t('Channels')}</SectionPageLayout.Title>
|
||||
<SectionPageLayout.Actions>
|
||||
<ChannelsPrimaryButtons />
|
||||
|
||||
@@ -76,18 +76,18 @@ export function ApiKeyCell({ apiKey }: { apiKey: ApiKey }) {
|
||||
}, [resolvedFullKey, resolveRealKey, apiKey.id, markKeyCopied, t])
|
||||
|
||||
return (
|
||||
<div className='flex items-center'>
|
||||
<div className='flex max-w-full min-w-0 items-center'>
|
||||
<Popover open={popoverOpen} onOpenChange={handlePopoverOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='text-muted-foreground h-7 font-mono text-xs'
|
||||
className='text-muted-foreground h-7 max-w-full min-w-0 justify-start truncate px-0 font-mono text-xs hover:bg-transparent aria-expanded:bg-transparent'
|
||||
/>
|
||||
}
|
||||
>
|
||||
{maskedKey}
|
||||
<span className='truncate'>{maskedKey}</span>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className='w-auto max-w-[min(90vw,28rem)]'
|
||||
|
||||
@@ -92,6 +92,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
size: 40,
|
||||
meta: { label: t('Select') },
|
||||
},
|
||||
{
|
||||
@@ -104,6 +105,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
||||
{row.getValue('name')}
|
||||
</div>
|
||||
),
|
||||
size: 180,
|
||||
meta: { label: t('Name'), mobileTitle: true },
|
||||
},
|
||||
{
|
||||
@@ -123,6 +125,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
||||
)
|
||||
},
|
||||
filterFn: (row, id, value) => value.includes(String(row.getValue(id))),
|
||||
size: 120,
|
||||
meta: { label: t('Status'), mobileBadge: true },
|
||||
},
|
||||
{
|
||||
@@ -131,6 +134,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
||||
header: t('API Key'),
|
||||
cell: ({ row }) => <ApiKeyCell apiKey={row.original} />,
|
||||
enableSorting: false,
|
||||
size: 260,
|
||||
meta: { label: t('API Key') },
|
||||
},
|
||||
{
|
||||
@@ -189,6 +193,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
size: 170,
|
||||
meta: { label: t('Quota') },
|
||||
},
|
||||
{
|
||||
@@ -230,6 +235,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
||||
}
|
||||
return <GroupBadge group={group} ratio={ratio} />
|
||||
},
|
||||
size: 160,
|
||||
meta: { label: t('Group'), mobileHidden: true },
|
||||
},
|
||||
{
|
||||
@@ -240,6 +246,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
||||
),
|
||||
cell: ({ row }) => <ModelLimitsCell apiKey={row.original} />,
|
||||
enableSorting: false,
|
||||
size: 160,
|
||||
meta: { label: t('Models'), mobileHidden: true },
|
||||
},
|
||||
{
|
||||
@@ -250,6 +257,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
||||
),
|
||||
cell: ({ row }) => <IpRestrictionsCell apiKey={row.original} />,
|
||||
enableSorting: false,
|
||||
size: 160,
|
||||
meta: { label: t('IP Restriction'), mobileHidden: true },
|
||||
},
|
||||
{
|
||||
@@ -258,10 +266,11 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
||||
<DataTableColumnHeader column={column} title={t('Created')} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className='text-muted-foreground font-mono text-xs tabular-nums'>
|
||||
<span className='text-muted-foreground block truncate font-mono text-xs tabular-nums'>
|
||||
{formatTimestampToDate(row.getValue('created_time'))}
|
||||
</span>
|
||||
),
|
||||
size: 180,
|
||||
meta: { label: t('Created'), mobileHidden: true },
|
||||
},
|
||||
{
|
||||
@@ -275,11 +284,12 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
||||
return <span className='text-muted-foreground text-xs'>-</span>
|
||||
}
|
||||
return (
|
||||
<span className='text-muted-foreground font-mono text-xs tabular-nums'>
|
||||
<span className='text-muted-foreground block truncate font-mono text-xs tabular-nums'>
|
||||
{formatTimestampToDate(accessedTime)}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
size: 180,
|
||||
meta: { label: t('Last Used'), mobileHidden: true },
|
||||
},
|
||||
{
|
||||
@@ -302,7 +312,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'font-mono text-xs tabular-nums',
|
||||
'block truncate font-mono text-xs tabular-nums',
|
||||
isExpired ? 'text-destructive' : 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
@@ -310,6 +320,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
||||
</span>
|
||||
)
|
||||
},
|
||||
size: 180,
|
||||
meta: { label: t('Expires'), mobileHidden: true },
|
||||
},
|
||||
{
|
||||
|
||||
+20
-62
@@ -16,21 +16,9 @@ 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 { useDebounce } from '@/hooks'
|
||||
import { type Table as TanstackTable } from '@tanstack/react-table'
|
||||
import { Database } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
@@ -50,6 +38,8 @@ import {
|
||||
DISABLED_ROW_DESKTOP,
|
||||
DISABLED_ROW_MOBILE,
|
||||
DataTablePage,
|
||||
useDebouncedColumnFilter,
|
||||
useDataTable,
|
||||
} from '@/components/data-table'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { getApiKeys, searchApiKeys } from '../api'
|
||||
@@ -99,7 +89,7 @@ function ApiKeysMobileList({
|
||||
table,
|
||||
isLoading,
|
||||
}: {
|
||||
table: ReturnType<typeof useReactTable<ApiKey>>
|
||||
table: TanstackTable<ApiKey>
|
||||
isLoading: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
@@ -192,9 +182,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,
|
||||
@@ -215,27 +202,15 @@ export function ApiKeysTable() {
|
||||
],
|
||||
})
|
||||
|
||||
const tokenFilterFromUrl =
|
||||
(columnFilters.find((f) => f.id === '_tokenSearch')?.value as string) || ''
|
||||
const [tokenFilterInput, setTokenFilterInput] = useState(tokenFilterFromUrl)
|
||||
const debouncedTokenFilter = useDebounce(tokenFilterInput, 500)
|
||||
|
||||
useEffect(() => {
|
||||
setTokenFilterInput(tokenFilterFromUrl)
|
||||
}, [tokenFilterFromUrl])
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedTokenFilter !== tokenFilterFromUrl) {
|
||||
onColumnFiltersChange((prev) => {
|
||||
const filtered = prev.filter((f) => f.id !== '_tokenSearch')
|
||||
return debouncedTokenFilter
|
||||
? [...filtered, { id: '_tokenSearch', value: debouncedTokenFilter }]
|
||||
: filtered
|
||||
})
|
||||
}
|
||||
}, [debouncedTokenFilter, tokenFilterFromUrl, onColumnFiltersChange])
|
||||
|
||||
const tokenFilter = tokenFilterFromUrl
|
||||
const {
|
||||
value: tokenFilter,
|
||||
inputValue: tokenFilterInput,
|
||||
setInputValue: setTokenFilterInput,
|
||||
} = useDebouncedColumnFilter({
|
||||
columnFilters,
|
||||
columnId: '_tokenSearch',
|
||||
onColumnFiltersChange,
|
||||
})
|
||||
const shouldSearch = Boolean(globalFilter?.trim() || tokenFilter.trim())
|
||||
|
||||
// Fetch data with React Query
|
||||
@@ -284,40 +259,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}
|
||||
@@ -329,6 +286,7 @@ export function ApiKeysTable() {
|
||||
'No API keys available. Create your first API key to get started.'
|
||||
)}
|
||||
skeletonKeyPrefix='api-keys-skeleton'
|
||||
applyHeaderSize
|
||||
toolbarProps={{
|
||||
searchPlaceholder: t('Filter by name...'),
|
||||
additionalSearch: (
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ export function ApiKeys() {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<ApiKeysProvider>
|
||||
<SectionPageLayout>
|
||||
<SectionPageLayout fixedContent>
|
||||
<SectionPageLayout.Title>{t('API Keys')}</SectionPageLayout.Title>
|
||||
<SectionPageLayout.Actions>
|
||||
<ApiKeysPrimaryButtons />
|
||||
|
||||
@@ -21,7 +21,7 @@ import { Eye, Info, Pencil, Settings2, Timer, Trash2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { formatTimestampToDate } from '@/lib/format'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DataTableColumnHeader } from '@/components/data-table/column-header'
|
||||
import { DataTableColumnHeader } from '@/components/data-table'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { TableId } from '@/components/table-id'
|
||||
import { getDeploymentStatusConfig } from '../constants'
|
||||
|
||||
@@ -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,22 @@ 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,
|
||||
withSortedRowModel: false,
|
||||
ensurePageInRange,
|
||||
})
|
||||
|
||||
const pageCount = table.getPageCount()
|
||||
useEffect(() => {
|
||||
ensurePageInRange(pageCount)
|
||||
}, [ensurePageInRange, pageCount])
|
||||
|
||||
const statusFilterOptions = useMemo(() => {
|
||||
return [...getDeploymentStatusOptions(t)].map((opt) => ({
|
||||
label: opt.label,
|
||||
|
||||
+111
-111
@@ -46,15 +46,8 @@ import {
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/empty'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
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 +337,117 @@ 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]'
|
||||
data={normalizedGroups}
|
||||
getRowKey={({ group }) => group.id}
|
||||
columns={[
|
||||
{
|
||||
id: 'group',
|
||||
header: t('Group'),
|
||||
cellClassName: 'align-top whitespace-normal',
|
||||
cell: ({ group }) => (
|
||||
<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>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'type',
|
||||
header: t('Type'),
|
||||
cellClassName: 'align-top',
|
||||
cell: ({ meta }) => (
|
||||
<StatusBadge
|
||||
label={meta.label}
|
||||
variant={meta.badge}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'items',
|
||||
header: t('Items'),
|
||||
className: 'min-w-[240px]',
|
||||
cellClassName: 'align-top whitespace-normal',
|
||||
cell: ({ group, parsedItems }) => (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('Actions'),
|
||||
className: 'w-[120px] text-right',
|
||||
cellClassName: 'align-top',
|
||||
cell: ({ group }) => (
|
||||
<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>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
+23
-64
@@ -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'
|
||||
@@ -78,6 +65,8 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
enable_groups: 'Enable Groups',
|
||||
}
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [5, 10, 20, 50] as const
|
||||
|
||||
const formatValue = (value: unknown) => {
|
||||
if (value === null || value === undefined) return '—'
|
||||
if (typeof value === 'string') return value || '—'
|
||||
@@ -341,16 +330,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 +526,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'>
|
||||
@@ -587,12 +548,10 @@ export function UpstreamConflictDialog({
|
||||
{t('Rows per page')}
|
||||
</span>
|
||||
<Select
|
||||
items={[
|
||||
...[5, 10, 20, 50].map((size) => ({
|
||||
value: String(size),
|
||||
label: size,
|
||||
})),
|
||||
]}
|
||||
items={PAGE_SIZE_OPTIONS.map((size) => ({
|
||||
value: String(size),
|
||||
label: size,
|
||||
}))}
|
||||
value={String(pageSize)}
|
||||
onValueChange={(value) => {
|
||||
setPageSize(Number(value))
|
||||
@@ -604,7 +563,7 @@ export function UpstreamConflictDialog({
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectGroup>
|
||||
{[5, 10, 20, 50].map((size) => (
|
||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<SelectItem key={size} value={String(size)}>
|
||||
{size}
|
||||
</SelectItem>
|
||||
|
||||
+15
-15
@@ -27,8 +27,9 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { DataTableColumnHeader } from '@/components/data-table/column-header'
|
||||
import { DataTableColumnHeader } from '@/components/data-table'
|
||||
import { GroupBadge } from '@/components/group-badge'
|
||||
import { ProviderBadge } from '@/components/provider-badge'
|
||||
import { StatusBadge, StatusBadgeList } from '@/components/status-badge'
|
||||
import { TableId } from '@/components/table-id'
|
||||
import {
|
||||
@@ -41,6 +42,12 @@ import type { Model, Vendor } from '../types'
|
||||
import { DataTableRowActions } from './data-table-row-actions'
|
||||
import { DescriptionCell } from './description-cell'
|
||||
|
||||
function getCompactModelIcon(iconKey: string) {
|
||||
const baseIconKey = iconKey.split('.')[0]
|
||||
|
||||
return getLobeIcon(`${baseIconKey}.Avatar.type={'platform'}`, 20)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render limited items with "and X more" indicator
|
||||
*/
|
||||
@@ -123,9 +130,13 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
|
||||
vendorMap[model.vendor_id || 0]?.icon ||
|
||||
model.model_name?.[0] ||
|
||||
'N'
|
||||
const icon = getLobeIcon(iconKey, 20)
|
||||
const icon = getCompactModelIcon(iconKey)
|
||||
|
||||
return <div className='flex items-center justify-center'>{icon}</div>
|
||||
return (
|
||||
<div className='ms-1 flex size-5 items-center justify-center overflow-hidden'>
|
||||
{icon}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
size: 70,
|
||||
enableSorting: false,
|
||||
@@ -259,18 +270,7 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
|
||||
return <span className='text-muted-foreground text-xs'>-</span>
|
||||
}
|
||||
|
||||
const icon = vendor.icon ? getLobeIcon(vendor.icon, 14) : null
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-1.5'>
|
||||
{icon}
|
||||
<StatusBadge
|
||||
label={vendor.name}
|
||||
autoColor={vendor.name}
|
||||
size='sm'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
return <ProviderBadge iconKey={vendor.icon} label={vendor.name} />
|
||||
},
|
||||
filterFn: (row, id, value) => {
|
||||
if (!value || value.length === 0 || value.includes('all')) return true
|
||||
|
||||
+12
-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,28 @@ 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,
|
||||
ensurePageInRange,
|
||||
})
|
||||
|
||||
// Ensure page is in range when total count changes
|
||||
const pageCount = table.getPageCount()
|
||||
useEffect(() => {
|
||||
ensurePageInRange(pageCount)
|
||||
}, [pageCount, ensurePageInRange])
|
||||
|
||||
// Prepare filter options
|
||||
const vendorFilterOptions = [
|
||||
{
|
||||
|
||||
+19
-17
@@ -119,7 +119,7 @@ function ModelsContent() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionPageLayout>
|
||||
<SectionPageLayout fixedContent>
|
||||
<SectionPageLayout.Title>{t(meta.titleKey)}</SectionPageLayout.Title>
|
||||
<SectionPageLayout.Actions>
|
||||
{activeSection === 'metadata' ? (
|
||||
@@ -132,7 +132,7 @@ function ModelsContent() {
|
||||
)}
|
||||
</SectionPageLayout.Actions>
|
||||
<SectionPageLayout.Content>
|
||||
<div className='space-y-4'>
|
||||
<div className='flex h-full min-h-0 flex-col gap-4'>
|
||||
<Tabs value={activeSection} onValueChange={handleSectionChange}>
|
||||
<TabsList className='max-w-full flex-wrap justify-start group-data-horizontal/tabs:h-auto'>
|
||||
{MODELS_SECTION_IDS.map((section) => (
|
||||
@@ -142,21 +142,23 @@ function ModelsContent() {
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
{activeSection === 'metadata' ? (
|
||||
<ModelsTable />
|
||||
) : (
|
||||
<DeploymentAccessGuard
|
||||
loading={deploymentLoading}
|
||||
loadingPhase={loadingPhase}
|
||||
isEnabled={isIoNetEnabled}
|
||||
connectionLoading={connectionLoading}
|
||||
connectionOk={connectionOk}
|
||||
connectionError={connectionError}
|
||||
onRetry={testConnection}
|
||||
>
|
||||
<DeploymentsTable />
|
||||
</DeploymentAccessGuard>
|
||||
)}
|
||||
<div className='min-h-0 flex-1'>
|
||||
{activeSection === 'metadata' ? (
|
||||
<ModelsTable />
|
||||
) : (
|
||||
<DeploymentAccessGuard
|
||||
loading={deploymentLoading}
|
||||
loadingPhase={loadingPhase}
|
||||
isEnabled={isIoNetEnabled}
|
||||
connectionLoading={connectionLoading}
|
||||
connectionOk={connectionOk}
|
||||
connectionError={connectionError}
|
||||
onRetry={testConnection}
|
||||
>
|
||||
<DeploymentsTable />
|
||||
</DeploymentAccessGuard>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SectionPageLayout.Content>
|
||||
</SectionPageLayout>
|
||||
|
||||
+68
-79
@@ -22,14 +22,7 @@ import { useTranslation } from 'react-i18next'
|
||||
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'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import {
|
||||
BILLING_PRICING_VARS,
|
||||
MATCH_CONTAINS,
|
||||
@@ -307,86 +300,82 @@ 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')}
|
||||
</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)
|
||||
<StaticDataTable
|
||||
className='hidden rounded-none border-0 sm:block'
|
||||
tableClassName='text-sm'
|
||||
headerRowClassName='hover:bg-transparent'
|
||||
data={tiers}
|
||||
getRowKey={(_tier, index) => `tier-${index}`}
|
||||
getRowClassName={(tier) => {
|
||||
const isMatched =
|
||||
normalizedMatchedTierLabel !== '' &&
|
||||
normalizeTierLabel(tier.label) === normalizedMatchedTierLabel
|
||||
return cn(
|
||||
isMatched &&
|
||||
'bg-emerald-50/70 hover:bg-emerald-50/70 dark:bg-emerald-500/10 dark:hover:bg-emerald-500/10'
|
||||
)
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
id: 'tier',
|
||||
header: t('Tier'),
|
||||
className: 'text-muted-foreground py-2 font-medium',
|
||||
cellClassName: 'py-2.5 align-top',
|
||||
cell: (tier) => {
|
||||
const condSummary = formatConditionSummary(
|
||||
tier.conditions,
|
||||
t
|
||||
)
|
||||
const isMatched =
|
||||
normalizedMatchedTierLabel !== '' &&
|
||||
normalizeTierLabel(tier.label) ===
|
||||
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'>
|
||||
<>
|
||||
<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>
|
||||
</div>
|
||||
{condSummary && (
|
||||
<div className='text-muted-foreground mt-1 text-xs'>
|
||||
{condSummary}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
},
|
||||
},
|
||||
...visiblePriceFields.map((v, index) => ({
|
||||
id: v.field ?? `price-${index}`,
|
||||
header: t(v.shortLabel),
|
||||
className: 'text-muted-foreground py-2 text-right font-medium',
|
||||
cellClassName: 'py-2.5 text-right align-top font-mono',
|
||||
cell: (tier: ParsedTier) => {
|
||||
const value = Number(
|
||||
tier[v.field as string as keyof ParsedTier] || 0
|
||||
)
|
||||
return value > 0 ? (
|
||||
<span className='font-semibold'>
|
||||
{`${symbol}${(value * rate).toFixed(4)}`}
|
||||
</span>
|
||||
) : (
|
||||
'-'
|
||||
)
|
||||
},
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -32,19 +32,15 @@ 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 {
|
||||
StaticDataTable,
|
||||
staticDataTableClassNames as tableStyles,
|
||||
} from '@/components/data-table'
|
||||
import {
|
||||
buildRateLimits,
|
||||
buildSupportedParameters,
|
||||
@@ -570,53 +566,62 @@ 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>
|
||||
</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'>
|
||||
<StaticDataTable
|
||||
className={tableStyles.sectionContainer}
|
||||
headerRowClassName={tableStyles.mutedHeaderRow}
|
||||
data={params}
|
||||
getRowKey={(param) => param.name}
|
||||
getRowClassName={() => 'hover:bg-muted/20'}
|
||||
columns={[
|
||||
{
|
||||
id: 'parameter',
|
||||
header: t('Parameter'),
|
||||
className: 'h-9 w-44',
|
||||
cellClassName: tableStyles.topCell,
|
||||
cell: (p) => (
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<code className='font-mono text-sm font-medium'>{p.name}</code>
|
||||
{p.required && (
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='h-7 rounded-full px-2.5 font-mono text-sm font-normal'
|
||||
variant='outline'
|
||||
className='h-6 border-rose-500/40 px-2 text-sm text-rose-600 dark:text-rose-400'
|
||||
>
|
||||
{p.type}
|
||||
{t('required')}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'type',
|
||||
header: t('Type'),
|
||||
className: 'h-9 w-24',
|
||||
cellClassName: tableStyles.topCell,
|
||||
cell: (p) => (
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='h-7 rounded-full px-2.5 font-mono text-sm font-normal'
|
||||
>
|
||||
{p.type}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'range',
|
||||
header: t('Default / range'),
|
||||
className: 'h-9 w-32',
|
||||
cellClassName: tableStyles.topCell,
|
||||
cell: (p) => <ParamRangeCell param={p} />,
|
||||
},
|
||||
{
|
||||
id: 'description',
|
||||
header: t('Description'),
|
||||
className: 'h-9',
|
||||
cellClassName: tableStyles.topMutedCell,
|
||||
cell: (p) => t(p.descriptionKey),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -671,34 +676,43 @@ 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>
|
||||
</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>
|
||||
<StaticDataTable
|
||||
className={tableStyles.sectionContainer}
|
||||
headerRowClassName={tableStyles.mutedHeaderRow}
|
||||
data={limits}
|
||||
getRowKey={(limit) => limit.group}
|
||||
getRowClassName={() => 'hover:bg-muted/20'}
|
||||
columns={[
|
||||
{
|
||||
id: 'group',
|
||||
header: t('Group'),
|
||||
className: 'h-9',
|
||||
cellClassName: 'py-2 font-mono',
|
||||
cell: (limit) => limit.group,
|
||||
},
|
||||
{
|
||||
id: 'rpm',
|
||||
header: 'RPM',
|
||||
className: 'h-9 text-right',
|
||||
cellClassName: tableStyles.topNumericCell,
|
||||
cell: (limit) => formatRateLimit(limit.rpm),
|
||||
},
|
||||
{
|
||||
id: 'tpm',
|
||||
header: 'TPM',
|
||||
className: 'h-9 text-right',
|
||||
cellClassName: tableStyles.topNumericCell,
|
||||
cell: (limit) => formatRateLimit(limit.tpm),
|
||||
},
|
||||
{
|
||||
id: 'rpd',
|
||||
header: 'RPD',
|
||||
className: 'h-9 text-right',
|
||||
cellClassName: tableStyles.topNumericCell,
|
||||
cell: (limit) => formatRateLimit(limit.rpd),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<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,9 @@ import {
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
StaticDataTable,
|
||||
staticDataTableClassNames as tableStyles,
|
||||
} from '@/components/data-table'
|
||||
import {
|
||||
buildAppRankings,
|
||||
formatTokenVolume,
|
||||
@@ -123,9 +119,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 +158,70 @@ 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>
|
||||
</div>
|
||||
<StaticDataTable
|
||||
className='rounded-lg'
|
||||
tableClassName='text-sm'
|
||||
headerRowClassName={tableStyles.compactHeaderRow}
|
||||
data={apps}
|
||||
getRowKey={(app) => `${app.rank}-${app.name}`}
|
||||
columns={[
|
||||
{
|
||||
id: 'rank',
|
||||
header: '#',
|
||||
className: cn(tableStyles.compactHeaderCell, 'w-12'),
|
||||
cellClassName: tableStyles.compactCell,
|
||||
cell: (app) => <RankBadge rank={app.rank} />,
|
||||
},
|
||||
{
|
||||
id: 'app',
|
||||
header: t('App'),
|
||||
className: tableStyles.compactHeaderCell,
|
||||
cellClassName: tableStyles.compactCell,
|
||||
cell: (app) => (
|
||||
<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>
|
||||
</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>
|
||||
<p className='text-muted-foreground line-clamp-1 text-sm'>
|
||||
{app.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'category',
|
||||
header: t('Category'),
|
||||
className: cn(
|
||||
tableStyles.compactHeaderCell,
|
||||
'hidden md:table-cell'
|
||||
),
|
||||
cellClassName: cn(
|
||||
tableStyles.compactMutedCell,
|
||||
'hidden md:table-cell'
|
||||
),
|
||||
cell: (app) => app.category,
|
||||
},
|
||||
{
|
||||
id: 'monthly-tokens',
|
||||
header: t('Monthly tokens'),
|
||||
className: tableStyles.compactHeaderCellRight,
|
||||
cellClassName: cn(tableStyles.compactNumericCell, 'tabular-nums'),
|
||||
cell: (app) => formatTokenVolume(app.monthly_tokens),
|
||||
},
|
||||
{
|
||||
id: 'growth',
|
||||
header: t('30d change'),
|
||||
className: tableStyles.compactHeaderCellRight,
|
||||
cellClassName: cn(tableStyles.compactCell, 'text-right'),
|
||||
cell: (app) => <GrowthChip value={app.growth_pct} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<p className='text-muted-foreground/60 text-[11px] leading-relaxed'>
|
||||
{t(
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import type { Modality } from '../types'
|
||||
|
||||
type IconComponent = React.ComponentType<{ className?: string }>
|
||||
@@ -95,79 +96,65 @@ export function ModalitiesMatrix(props: {
|
||||
const inputSet = new Set(props.input)
|
||||
const outputSet = new Set(props.output)
|
||||
|
||||
const renderRow = (label: string, set: Set<Modality>) => (
|
||||
<tr>
|
||||
<th
|
||||
scope='row'
|
||||
className='text-muted-foreground bg-muted/30 px-3 py-2 text-left text-[11px] font-medium tracking-wider uppercase'
|
||||
>
|
||||
{label}
|
||||
</th>
|
||||
{ALL_MODALITIES.map((modality) => {
|
||||
const enabled = set.has(modality)
|
||||
const Icon = MODALITY_META[modality].icon
|
||||
return (
|
||||
<td
|
||||
key={modality}
|
||||
className={cn(
|
||||
return (
|
||||
<StaticDataTable
|
||||
className='rounded-lg'
|
||||
tableClassName='text-sm'
|
||||
headerRowClassName='bg-muted/40'
|
||||
data={[
|
||||
{ label: t('Input'), set: inputSet },
|
||||
{ label: t('Output'), set: outputSet },
|
||||
]}
|
||||
getRowKey={(row) => row.label}
|
||||
columns={[
|
||||
{
|
||||
id: 'modality',
|
||||
header: t('Modality'),
|
||||
className:
|
||||
'text-muted-foreground px-3 py-2 text-left text-[11px] font-medium tracking-wider uppercase',
|
||||
cellClassName:
|
||||
'text-muted-foreground bg-muted/30 px-3 py-2 text-left text-[11px] font-medium tracking-wider uppercase',
|
||||
cell: (row) => row.label,
|
||||
},
|
||||
...ALL_MODALITIES.map((modality) => ({
|
||||
id: modality,
|
||||
header: t(MODALITY_META[modality].labelKey),
|
||||
className:
|
||||
'text-muted-foreground border-l px-3 py-2 text-center text-[11px] font-medium tracking-wider uppercase',
|
||||
cellClassName: (row: { label: string; set: Set<Modality> }) =>
|
||||
cn(
|
||||
'border-l px-3 py-2 text-center',
|
||||
enabled
|
||||
row.set.has(modality)
|
||||
? 'bg-emerald-50/40 dark:bg-emerald-500/10'
|
||||
: 'bg-background'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center',
|
||||
enabled
|
||||
? 'text-emerald-700 dark:text-emerald-300'
|
||||
: 'text-muted-foreground/40'
|
||||
)}
|
||||
aria-label={
|
||||
enabled
|
||||
? t('{{modality}} supported', {
|
||||
modality: t(MODALITY_META[modality].labelKey),
|
||||
})
|
||||
: t('{{modality}} not supported', {
|
||||
modality: t(MODALITY_META[modality].labelKey),
|
||||
})
|
||||
}
|
||||
>
|
||||
<Icon className='size-4' />
|
||||
</span>
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='overflow-x-auto rounded-lg border'>
|
||||
<table className='w-full text-sm'>
|
||||
<thead>
|
||||
<tr className='bg-muted/40'>
|
||||
<th
|
||||
scope='col'
|
||||
className='text-muted-foreground px-3 py-2 text-left 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'
|
||||
),
|
||||
cell: (row: { label: string; set: Set<Modality> }) => {
|
||||
const enabled = row.set.has(modality)
|
||||
const Icon = MODALITY_META[modality].icon
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center',
|
||||
enabled
|
||||
? 'text-emerald-700 dark:text-emerald-300'
|
||||
: 'text-muted-foreground/40'
|
||||
)}
|
||||
aria-label={
|
||||
enabled
|
||||
? t('{{modality}} supported', {
|
||||
modality: t(MODALITY_META[modality].labelKey),
|
||||
})
|
||||
: t('{{modality}} not supported', {
|
||||
modality: t(MODALITY_META[modality].labelKey),
|
||||
})
|
||||
}
|
||||
>
|
||||
{t(MODALITY_META[modality].labelKey)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{renderRow(t('Input'), inputSet)}
|
||||
{renderRow(t('Output'), outputSet)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Icon className='size-4' />
|
||||
</span>
|
||||
)
|
||||
},
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
+52
-57
@@ -22,13 +22,9 @@ 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'
|
||||
StaticDataTable,
|
||||
staticDataTableClassNames as tableStyles,
|
||||
} from '@/components/data-table'
|
||||
import { GroupBadge } from '@/components/group-badge'
|
||||
import { getPerfMetrics } from '@/features/performance-metrics/api'
|
||||
import {
|
||||
@@ -218,9 +214,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 +249,55 @@ 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>
|
||||
</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>
|
||||
<StaticDataTable
|
||||
className='rounded-lg'
|
||||
tableClassName='text-sm'
|
||||
headerRowClassName={tableStyles.compactHeaderRow}
|
||||
data={performances}
|
||||
getRowKey={(perf) => perf.group}
|
||||
columns={[
|
||||
{
|
||||
id: 'group',
|
||||
header: t('Group'),
|
||||
className: tableStyles.compactHeaderCell,
|
||||
cellClassName: tableStyles.compactCell,
|
||||
cell: (perf) => <GroupBadge group={perf.group} size='sm' />,
|
||||
},
|
||||
{
|
||||
id: 'tps',
|
||||
header: 'TPS',
|
||||
className: tableStyles.compactHeaderCellRight,
|
||||
cellClassName: tableStyles.compactNumericCell,
|
||||
cell: (perf) => formatThroughput(perf.avg_tps),
|
||||
},
|
||||
{
|
||||
id: 'ttft',
|
||||
header: t('Average TTFT'),
|
||||
className: tableStyles.compactHeaderCellRight,
|
||||
cellClassName: tableStyles.compactNumericCell,
|
||||
cell: (perf) => formatLatency(perf.avg_ttft_ms),
|
||||
},
|
||||
{
|
||||
id: 'latency',
|
||||
header: t('Average latency'),
|
||||
className: tableStyles.compactHeaderCellRight,
|
||||
cellClassName: tableStyles.compactMutedNumericCell,
|
||||
cell: (perf) => formatLatency(perf.avg_latency_ms),
|
||||
},
|
||||
{
|
||||
id: 'success',
|
||||
header: t('Success rate'),
|
||||
className: cn(tableStyles.compactHeaderCell, 'min-w-[180px]'),
|
||||
cellClassName: tableStyles.compactCell,
|
||||
cell: (perf) => (
|
||||
<UptimeSparkline
|
||||
size='sm'
|
||||
series={uptimeByGroup[perf.group] ?? []}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
|
||||
+167
-177
@@ -32,16 +32,9 @@ import {
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
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'
|
||||
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 +262,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 =
|
||||
@@ -586,6 +577,40 @@ function AutoGroupChain(props: { model: PricingModel; autoGroups: string[] }) {
|
||||
)
|
||||
}
|
||||
|
||||
type DynamicPriceOptions = Parameters<typeof getDynamicPriceEntries>[1]
|
||||
type DynamicPricingTier = ReturnType<typeof getDynamicPricingTiers>[number]
|
||||
type DynamicFormattedPricesByTier = Map<DynamicPricingTier, Map<string, string>>
|
||||
|
||||
function getDynamicPriceFields(
|
||||
tiers: DynamicPricingTier[],
|
||||
options: DynamicPriceOptions
|
||||
) {
|
||||
return Array.from(
|
||||
new Map(
|
||||
tiers
|
||||
.flatMap((tier) => getDynamicPriceEntries(tier, options))
|
||||
.map((entry) => [entry.field, entry])
|
||||
).values()
|
||||
)
|
||||
}
|
||||
|
||||
function getDynamicFormattedPricesByTier(
|
||||
tiers: DynamicPricingTier[],
|
||||
options: DynamicPriceOptions
|
||||
): DynamicFormattedPricesByTier {
|
||||
return new Map(
|
||||
tiers.map((tier) => [
|
||||
tier,
|
||||
new Map(
|
||||
getDynamicPriceEntries(tier, options).map((entry) => [
|
||||
entry.field,
|
||||
entry.formatted,
|
||||
])
|
||||
),
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Group pricing table
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -676,20 +701,27 @@ function GroupPricingSection(props: {
|
||||
)
|
||||
}
|
||||
|
||||
const priceFields = Array.from(
|
||||
new Map(
|
||||
dynamicTiers
|
||||
.flatMap((tier) =>
|
||||
getDynamicPriceEntries(tier, {
|
||||
tokenUnit: props.tokenUnit,
|
||||
showRechargePrice,
|
||||
priceRate: props.priceRate,
|
||||
usdExchangeRate: props.usdExchangeRate,
|
||||
groupRatioMultiplier: 1,
|
||||
})
|
||||
)
|
||||
.map((entry) => [entry.field, entry])
|
||||
).values()
|
||||
const priceFields = getDynamicPriceFields(dynamicTiers, {
|
||||
tokenUnit: props.tokenUnit,
|
||||
showRechargePrice,
|
||||
priceRate: props.priceRate,
|
||||
usdExchangeRate: props.usdExchangeRate,
|
||||
groupRatioMultiplier: 1,
|
||||
})
|
||||
const formattedPricesByGroup = new Map(
|
||||
availableGroups.map((group) => {
|
||||
const ratio = props.groupRatio[group] || 1
|
||||
return [
|
||||
group,
|
||||
getDynamicFormattedPricesByTier(dynamicTiers, {
|
||||
tokenUnit: props.tokenUnit,
|
||||
showRechargePrice,
|
||||
priceRate: props.priceRate,
|
||||
usdExchangeRate: props.usdExchangeRate,
|
||||
groupRatioMultiplier: ratio,
|
||||
}),
|
||||
] as const
|
||||
})
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -699,6 +731,10 @@ function GroupPricingSection(props: {
|
||||
<div className='space-y-3'>
|
||||
{availableGroups.map((group) => {
|
||||
const ratio = props.groupRatio[group] || 1
|
||||
const formattedPricesByTier =
|
||||
formattedPricesByGroup.get(group) ??
|
||||
new Map<DynamicPricingTier, Map<string, string>>()
|
||||
|
||||
return (
|
||||
<div key={group} className='overflow-hidden rounded-lg border'>
|
||||
<div className='bg-muted/20 flex items-center justify-between gap-3 border-b px-3 py-2'>
|
||||
@@ -707,56 +743,34 @@ 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])
|
||||
)
|
||||
|
||||
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>
|
||||
<StaticDataTable
|
||||
className='rounded-none border-0'
|
||||
tableClassName='text-sm'
|
||||
headerRowClassName='hover:bg-transparent'
|
||||
data={dynamicTiers}
|
||||
getRowKey={(tier, tierIndex) =>
|
||||
`${group}-${tier.label || tierIndex}`
|
||||
}
|
||||
columns={[
|
||||
{
|
||||
id: 'tier',
|
||||
header: t('Tier'),
|
||||
className: thClass,
|
||||
cellClassName: 'text-muted-foreground py-2.5',
|
||||
cell: (tier) => tier.label || t('Default'),
|
||||
},
|
||||
...priceFields.map((fieldEntry) => ({
|
||||
id: fieldEntry.field,
|
||||
header: t(fieldEntry.shortLabel),
|
||||
className: `${thClass} text-right`,
|
||||
cellClassName: 'py-2.5 text-right font-mono',
|
||||
cell: (tier: (typeof dynamicTiers)[number]) =>
|
||||
formattedPricesByTier
|
||||
.get(tier)
|
||||
?.get(fieldEntry.field) ?? '-',
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -768,112 +782,88 @@ function GroupPricingSection(props: {
|
||||
)
|
||||
}
|
||||
|
||||
const renderGroupPrice = (group: string, type: PriceType) =>
|
||||
formatGroupPrice(
|
||||
props.model,
|
||||
group,
|
||||
type,
|
||||
props.tokenUnit,
|
||||
showRechargePrice,
|
||||
props.priceRate,
|
||||
props.usdExchangeRate,
|
||||
props.groupRatio
|
||||
)
|
||||
const renderFixedGroupPrice = (group: string) =>
|
||||
formatFixedPrice(
|
||||
props.model,
|
||||
group,
|
||||
showRechargePrice,
|
||||
props.priceRate,
|
||||
props.usdExchangeRate,
|
||||
props.groupRatio
|
||||
)
|
||||
|
||||
return (
|
||||
<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>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<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'>
|
||||
{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>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<TableCell className='py-2.5 text-right font-mono'>
|
||||
{formatFixedPrice(
|
||||
props.model,
|
||||
group,
|
||||
showRechargePrice,
|
||||
props.priceRate,
|
||||
props.usdExchangeRate,
|
||||
props.groupRatio
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<StaticDataTable
|
||||
className='-mx-4 rounded-none border-0 sm:mx-0'
|
||||
tableClassName='text-sm'
|
||||
headerRowClassName='hover:bg-transparent'
|
||||
data={availableGroups}
|
||||
getRowKey={(group) => group}
|
||||
columns={[
|
||||
{
|
||||
id: 'group',
|
||||
header: t('Group'),
|
||||
className: thClass,
|
||||
cellClassName: 'py-2.5',
|
||||
cell: (group) => <GroupBadge group={group} size='sm' />,
|
||||
},
|
||||
{
|
||||
id: 'ratio',
|
||||
header: t('Ratio'),
|
||||
className: thClass,
|
||||
cellClassName: 'text-muted-foreground py-2.5 font-mono',
|
||||
cell: (group) => `${props.groupRatio[group] || 1}x`,
|
||||
},
|
||||
...(isTokenBased
|
||||
? [
|
||||
{
|
||||
id: 'input',
|
||||
header: t('Input'),
|
||||
className: `${thClass} text-right`,
|
||||
cellClassName: 'py-2.5 text-right font-mono',
|
||||
cell: (group: string) => renderGroupPrice(group, 'input'),
|
||||
},
|
||||
{
|
||||
id: 'output',
|
||||
header: t('Output'),
|
||||
className: `${thClass} text-right`,
|
||||
cellClassName: 'py-2.5 text-right font-mono',
|
||||
cell: (group: string) => renderGroupPrice(group, 'output'),
|
||||
},
|
||||
...extraPriceTypes.map((ep) => ({
|
||||
id: ep.type,
|
||||
header: ep.label,
|
||||
className: `${thClass} text-right`,
|
||||
cellClassName: 'py-2.5 text-right font-mono',
|
||||
cell: (group: string) => renderGroupPrice(group, ep.type),
|
||||
})),
|
||||
]
|
||||
: [
|
||||
{
|
||||
id: 'price',
|
||||
header: t('Price'),
|
||||
className: `${thClass} text-right`,
|
||||
cellClassName: 'py-2.5 text-right font-mono',
|
||||
cell: renderFixedGroupPrice,
|
||||
},
|
||||
]),
|
||||
]}
|
||||
/>
|
||||
<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
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { DataTableColumnHeader } from '@/components/data-table/column-header'
|
||||
import { DataTableColumnHeader } from '@/components/data-table'
|
||||
import { GroupBadge } from '@/components/group-badge'
|
||||
import { StatusBadge, StatusBadgeList } from '@/components/status-badge'
|
||||
import { DEFAULT_TOKEN_UNIT, QUOTA_TYPE_VALUES } from '../constants'
|
||||
@@ -107,9 +107,7 @@ export function usePricingColumns(
|
||||
cell: ({ row }) => {
|
||||
const model = row.original
|
||||
const modelIconKey = model.icon || model.vendor_icon
|
||||
const modelIcon = modelIconKey
|
||||
? getLobeIcon(modelIconKey, 14)
|
||||
: null
|
||||
const modelIcon = modelIconKey ? getLobeIcon(modelIconKey, 14) : null
|
||||
|
||||
return (
|
||||
<div className='flex min-w-[200px] items-center gap-2'>
|
||||
|
||||
+30
-72
@@ -17,24 +17,14 @@ 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'
|
||||
import { DataTablePagination } from '@/components/data-table/pagination'
|
||||
DataTablePagination,
|
||||
DataTableRow,
|
||||
DataTableView,
|
||||
useDataTable,
|
||||
} from '@/components/data-table'
|
||||
import { DEFAULT_PRICING_PAGE_SIZE, DEFAULT_TOKEN_UNIT } from '../constants'
|
||||
import type { PricingModel, TokenUnit } from '../types'
|
||||
import { usePricingColumns } from './pricing-columns'
|
||||
@@ -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>
|
||||
|
||||
+12
-2
@@ -59,6 +59,7 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
size: 40,
|
||||
},
|
||||
{
|
||||
accessorKey: 'id',
|
||||
@@ -71,6 +72,7 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
|
||||
<TableId value={row.getValue('id') as number} className='w-[60px]' />
|
||||
)
|
||||
},
|
||||
size: 80,
|
||||
},
|
||||
{
|
||||
accessorKey: 'name',
|
||||
@@ -85,6 +87,7 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
|
||||
</div>
|
||||
)
|
||||
},
|
||||
size: 180,
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
@@ -135,6 +138,7 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
|
||||
// Check regular status
|
||||
return value.includes(String(statusValue))
|
||||
},
|
||||
size: 120,
|
||||
},
|
||||
{
|
||||
id: 'code',
|
||||
@@ -159,6 +163,7 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
|
||||
)
|
||||
},
|
||||
enableSorting: false,
|
||||
size: 320,
|
||||
},
|
||||
{
|
||||
accessorKey: 'quota',
|
||||
@@ -176,6 +181,7 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
|
||||
/>
|
||||
)
|
||||
},
|
||||
size: 120,
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_time',
|
||||
@@ -185,11 +191,12 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className='min-w-[140px] font-mono text-sm'>
|
||||
<div className='min-w-[160px] font-mono text-sm'>
|
||||
{formatTimestampToDate(row.getValue('created_time'))}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
size: 180,
|
||||
},
|
||||
{
|
||||
accessorKey: 'expired_time',
|
||||
@@ -211,12 +218,13 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
|
||||
const isExpired = isTimestampExpired(expiredTime)
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[140px] font-mono text-sm ${isExpired ? 'text-destructive' : ''}`}
|
||||
className={`min-w-[160px] font-mono text-sm ${isExpired ? 'text-destructive' : ''}`}
|
||||
>
|
||||
{formatTimestampToDate(expiredTime)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
size: 180,
|
||||
},
|
||||
{
|
||||
accessorKey: 'used_user_id',
|
||||
@@ -260,10 +268,12 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
size: 140,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
cell: ({ row }) => <DataTableRowActions row={row} />,
|
||||
size: 88,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
+9
-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]
|
||||
@@ -166,6 +135,7 @@ export function RedemptionsTable() {
|
||||
'No redemption codes available. Create your first redemption code to get started.'
|
||||
)}
|
||||
skeletonKeyPrefix='redemptions-skeleton'
|
||||
applyHeaderSize
|
||||
toolbarProps={{
|
||||
searchPlaceholder: t('Filter by name or ID...'),
|
||||
filters: [
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ export function Redemptions() {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<RedemptionsProvider>
|
||||
<SectionPageLayout>
|
||||
<SectionPageLayout fixedContent>
|
||||
<SectionPageLayout.Title>
|
||||
{t('Redemption Codes')}
|
||||
</SectionPageLayout.Title>
|
||||
|
||||
+111
-113
@@ -36,15 +36,8 @@ import {
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
} from '@/components/ui/sheet'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import {
|
||||
sideDrawerContentClassName,
|
||||
sideDrawerFormClassName,
|
||||
@@ -245,112 +238,117 @@ 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
|
||||
data={loading ? [] : subs}
|
||||
getRowKey={(record) => record.subscription.id}
|
||||
emptyClassName={loading ? 'py-8' : 'text-muted-foreground py-8'}
|
||||
emptyContent={
|
||||
loading ? t('Loading...') : t('No subscription records')
|
||||
}
|
||||
columns={[
|
||||
{
|
||||
id: 'id',
|
||||
header: t('ID'),
|
||||
cell: (record) => <TableId value={record.subscription.id} />,
|
||||
},
|
||||
{
|
||||
id: 'plan',
|
||||
header: t('Plan'),
|
||||
cell: (record) => {
|
||||
const sub = record.subscription
|
||||
|
||||
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>
|
||||
{t('Start')}: {formatTimestamp(sub.start_time)}
|
||||
</div>
|
||||
<div>
|
||||
{t('End')}: {formatTimestamp(sub.end_time)}
|
||||
</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>
|
||||
</Table>
|
||||
</div>
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
header: t('Status'),
|
||||
cell: (record) => (
|
||||
<SubscriptionStatusBadge sub={record.subscription} t={t} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'validity',
|
||||
header: t('Validity'),
|
||||
cell: (record) => {
|
||||
const sub = record.subscription
|
||||
|
||||
return (
|
||||
<div className='text-sm'>
|
||||
<div>
|
||||
{t('Start')}: {formatTimestamp(sub.start_time)}
|
||||
</div>
|
||||
<div>
|
||||
{t('End')}: {formatTimestamp(sub.end_time)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'quota',
|
||||
header: t('Total Quota'),
|
||||
cell: (record) => {
|
||||
const sub = record.subscription
|
||||
const total = Number(sub.amount_total || 0)
|
||||
const used = Number(sub.amount_used || 0)
|
||||
return total > 0 ? `${used}/${total}` : t('Unlimited')
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('Actions'),
|
||||
className: 'text-right',
|
||||
cellClassName: 'text-right',
|
||||
cell: (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
|
||||
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
+6
-6
@@ -36,9 +36,9 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] {
|
||||
{
|
||||
accessorFn: (row) => row.plan.id,
|
||||
id: 'id',
|
||||
meta: { label: 'ID', mobileHidden: true },
|
||||
meta: { label: t('ID'), mobileHidden: true },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title='ID' />
|
||||
<DataTableColumnHeader column={column} title={t('ID')} />
|
||||
),
|
||||
cell: ({ row }) => <TableId value={row.original.plan.id} />,
|
||||
size: 60,
|
||||
@@ -103,7 +103,7 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] {
|
||||
{formatResetPeriod(row.original.plan, t)}
|
||||
</span>
|
||||
),
|
||||
size: 80,
|
||||
size: 100,
|
||||
},
|
||||
{
|
||||
accessorFn: (row) => row.plan.sort_order,
|
||||
@@ -117,7 +117,7 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] {
|
||||
{row.original.plan.sort_order}
|
||||
</span>
|
||||
),
|
||||
size: 80,
|
||||
size: 100,
|
||||
},
|
||||
{
|
||||
accessorFn: (row) => row.plan.enabled,
|
||||
@@ -188,7 +188,7 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] {
|
||||
</span>
|
||||
)
|
||||
},
|
||||
size: 100,
|
||||
size: 150,
|
||||
},
|
||||
{
|
||||
id: 'upgrade_group',
|
||||
@@ -205,7 +205,7 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] {
|
||||
}
|
||||
return <GroupBadge group={group} />
|
||||
},
|
||||
size: 100,
|
||||
size: 120,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
|
||||
@@ -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 (
|
||||
@@ -71,6 +57,7 @@ export function SubscriptionsTable() {
|
||||
'Click "Create Plan" to create your first subscription plan'
|
||||
)}
|
||||
skeletonKeyPrefix='subscriptions-skeleton'
|
||||
applyHeaderSize
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
+15
-11
@@ -34,7 +34,7 @@ function SubscriptionsContent() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionPageLayout>
|
||||
<SectionPageLayout fixedContent>
|
||||
<SectionPageLayout.Title>
|
||||
{t('Subscription Management')}
|
||||
</SectionPageLayout.Title>
|
||||
@@ -52,16 +52,20 @@ function SubscriptionsContent() {
|
||||
</div>
|
||||
</SectionPageLayout.Actions>
|
||||
<SectionPageLayout.Content>
|
||||
{!complianceConfirmed ? (
|
||||
<Alert variant='destructive' className='mb-4'>
|
||||
<AlertDescription>
|
||||
{t(
|
||||
'Subscription plan creation and changes are locked until the administrator confirms compliance terms in Payment Gateway settings.'
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<SubscriptionsTable />
|
||||
<div className='flex h-full min-h-0 flex-col gap-4'>
|
||||
{!complianceConfirmed ? (
|
||||
<Alert variant='destructive' className='shrink-0'>
|
||||
<AlertDescription>
|
||||
{t(
|
||||
'Subscription plan creation and changes are locked until the administrator confirms compliance terms in Payment Gateway settings.'
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<div className='min-h-0 flex-1'>
|
||||
<SubscriptionsTable />
|
||||
</div>
|
||||
</div>
|
||||
</SectionPageLayout.Content>
|
||||
</SectionPageLayout>
|
||||
|
||||
|
||||
+77
-75
@@ -20,15 +20,8 @@ import { useState } from 'react'
|
||||
import { Pencil, Trash2, Plus } 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'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
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'
|
||||
@@ -64,73 +57,82 @@ export function ProviderTable(props: ProviderTableProps) {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{props.providers.length === 0 ? (
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
|
||||
{t('No custom OAuth providers configured yet.')}
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Icon')}</TableHead>
|
||||
<TableHead>{t('Name')}</TableHead>
|
||||
<TableHead>{t('Slug')}</TableHead>
|
||||
<TableHead>{t('Status')}</TableHead>
|
||||
<TableHead>{t('Client ID')}</TableHead>
|
||||
<TableHead className='text-right'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{props.providers.map((provider) => (
|
||||
<TableRow key={provider.id}>
|
||||
<TableCell>
|
||||
{provider.icon ? (
|
||||
<span className='text-lg'>{provider.icon}</span>
|
||||
) : (
|
||||
<span className='text-muted-foreground text-sm'>--</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className='font-medium'>{provider.name}</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge
|
||||
label={provider.slug}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge
|
||||
label={provider.enabled ? t('Enabled') : t('Disabled')}
|
||||
variant={provider.enabled ? 'success' : 'neutral'}
|
||||
copyable={false}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className='text-muted-foreground max-w-[120px] truncate font-mono'>
|
||||
{provider.client_id}
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<div className='flex justify-end gap-1'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => props.onEdit(provider)}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => setDeleteTarget(provider)}
|
||||
>
|
||||
<Trash2 className='text-destructive h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
<StaticDataTable
|
||||
data={props.providers}
|
||||
getRowKey={(provider) => provider.id}
|
||||
emptyClassName='text-sm'
|
||||
emptyContent={t('No custom OAuth providers configured yet.')}
|
||||
columns={[
|
||||
{
|
||||
id: 'icon',
|
||||
header: t('Icon'),
|
||||
cell: (provider) =>
|
||||
provider.icon ? (
|
||||
<span className='text-lg'>{provider.icon}</span>
|
||||
) : (
|
||||
<span className='text-muted-foreground text-sm'>--</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'name',
|
||||
header: t('Name'),
|
||||
cellClassName: 'font-medium',
|
||||
cell: (provider) => provider.name,
|
||||
},
|
||||
{
|
||||
id: 'slug',
|
||||
header: t('Slug'),
|
||||
cell: (provider) => (
|
||||
<StatusBadge
|
||||
label={provider.slug}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
header: t('Status'),
|
||||
cell: (provider) => (
|
||||
<StatusBadge
|
||||
label={provider.enabled ? t('Enabled') : t('Disabled')}
|
||||
variant={provider.enabled ? 'success' : 'neutral'}
|
||||
copyable={false}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'client-id',
|
||||
header: t('Client ID'),
|
||||
cellClassName: 'text-muted-foreground max-w-[120px] truncate font-mono',
|
||||
cell: (provider) => provider.client_id,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('Actions'),
|
||||
className: 'text-right',
|
||||
cellClassName: 'text-right',
|
||||
cell: (provider) => (
|
||||
<div className='flex justify-end gap-1'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => props.onEdit(provider)}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => setDeleteTarget(provider)}
|
||||
>
|
||||
<Trash2 className='text-destructive h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
|
||||
+98
-110
@@ -54,15 +54,8 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import { DateTimePicker } from '@/components/datetime-picker'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
@@ -350,109 +343,104 @@ 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
|
||||
data={sortedAnnouncements}
|
||||
getRowKey={(announcement) => announcement.id}
|
||||
emptyContent={t(
|
||||
'No announcements yet. Click "Add Announcement" to create one.'
|
||||
)}
|
||||
columns={[
|
||||
{
|
||||
id: 'select',
|
||||
header: (
|
||||
<Checkbox
|
||||
checked={
|
||||
selectedIds.length === announcements.length &&
|
||||
announcements.length > 0
|
||||
}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
/>
|
||||
),
|
||||
className: 'w-12',
|
||||
cell: (announcement) => (
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(announcement.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleSelectOne(announcement.id, checked as boolean)
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'content',
|
||||
header: t('Content'),
|
||||
cellClassName: 'max-w-xs truncate',
|
||||
cell: (announcement) => announcement.content,
|
||||
},
|
||||
{
|
||||
id: 'publish-date',
|
||||
header: t('Publish Date'),
|
||||
cell: (announcement) => (
|
||||
<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'
|
||||
)}
|
||||
</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>
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'type',
|
||||
header: t('Type'),
|
||||
cell: (announcement) => (
|
||||
<StatusBadge
|
||||
label={
|
||||
typeOptions.find((opt) => opt.value === announcement.type)
|
||||
?.label
|
||||
}
|
||||
variant={
|
||||
typeOptions.find((opt) => opt.value === announcement.type)
|
||||
?.badgeVariant ?? 'neutral'
|
||||
}
|
||||
copyable={false}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'extra',
|
||||
header: t('Extra'),
|
||||
cellClassName: 'text-muted-foreground max-w-xs truncate',
|
||||
cell: (announcement) => announcement.extra || '-',
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('Actions'),
|
||||
className: 'w-32',
|
||||
cell: (announcement) => (
|
||||
<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>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
|
||||
@@ -54,14 +54,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { SettingsSwitchField } from '../components/settings-form-layout'
|
||||
@@ -306,101 +299,98 @@ 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}
|
||||
<StaticDataTable
|
||||
data={apiInfoList}
|
||||
getRowKey={(apiInfo) => apiInfo.id}
|
||||
emptyContent={t('No API Domains yet. Click "Add API" to create one.')}
|
||||
columns={[
|
||||
{
|
||||
id: 'select',
|
||||
header: (
|
||||
<Checkbox
|
||||
checked={
|
||||
selectedIds.length === apiInfoList.length &&
|
||||
apiInfoList.length > 0
|
||||
}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
/>
|
||||
),
|
||||
className: 'w-12',
|
||||
cell: (apiInfo) => (
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(apiInfo.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleSelectOne(apiInfo.id, checked as boolean)
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'url',
|
||||
header: t('URL'),
|
||||
cellClassName: 'max-w-xs truncate font-mono text-sm',
|
||||
cell: (apiInfo) => (
|
||||
<StatusBadge
|
||||
label={apiInfo.url}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'route',
|
||||
header: t('Route'),
|
||||
cell: (apiInfo) => (
|
||||
<StatusBadge
|
||||
label={apiInfo.route}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'description',
|
||||
header: t('Description'),
|
||||
cellClassName: 'max-w-xs truncate',
|
||||
cell: (apiInfo) => apiInfo.description,
|
||||
},
|
||||
{
|
||||
id: 'color',
|
||||
header: t('Color'),
|
||||
cell: (apiInfo) => (
|
||||
<div className='flex items-center gap-2'>
|
||||
<div
|
||||
className={`h-4 w-4 rounded-full ${getColorClass(apiInfo.color)}`}
|
||||
/>
|
||||
</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.')}
|
||||
</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>
|
||||
<span className='text-sm capitalize'>{apiInfo.color}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('Actions'),
|
||||
className: 'w-32',
|
||||
cell: (apiInfo) => (
|
||||
<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>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
|
||||
+47
-54
@@ -21,14 +21,7 @@ import { Pencil, Plus, Search, Trash2 } from 'lucide-react'
|
||||
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'
|
||||
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'
|
||||
@@ -149,55 +142,55 @@ export function ChatSettingsVisualEditor({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{filteredChats.length === 0 ? (
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
|
||||
{searchText
|
||||
<StaticDataTable
|
||||
data={filteredChats}
|
||||
getRowKey={(chat) => chat.name}
|
||||
emptyContent={
|
||||
searchText
|
||||
? t('No chat presets match your search')
|
||||
: t(
|
||||
'No chat presets configured. Click "Add chat preset" to get started.'
|
||||
)}
|
||||
</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>
|
||||
</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>
|
||||
)}
|
||||
)
|
||||
}
|
||||
columns={[
|
||||
{
|
||||
id: 'name',
|
||||
header: t('Chat Client Name'),
|
||||
cellClassName: 'font-medium',
|
||||
cell: (chat) => chat.name,
|
||||
},
|
||||
{
|
||||
id: 'url',
|
||||
header: t('URL'),
|
||||
cellClassName: 'max-w-md truncate font-mono text-sm',
|
||||
cell: (chat) => chat.url,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('Actions'),
|
||||
className: 'text-right',
|
||||
cellClassName: 'text-right',
|
||||
cell: (chat) => (
|
||||
<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>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<ChatDialog
|
||||
open={dialogOpen}
|
||||
|
||||
@@ -45,15 +45,8 @@ import {
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { SettingsSwitchField } from '../components/settings-form-layout'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
@@ -269,78 +262,68 @@ 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.')}
|
||||
</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>
|
||||
<StaticDataTable
|
||||
data={faqList}
|
||||
getRowKey={(faq) => faq.id}
|
||||
emptyContent={t('No FAQ entries yet. Click "Add FAQ" to create one.')}
|
||||
columns={[
|
||||
{
|
||||
id: 'select',
|
||||
header: (
|
||||
<Checkbox
|
||||
checked={
|
||||
selectedIds.length === faqList.length && faqList.length > 0
|
||||
}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
/>
|
||||
),
|
||||
className: 'w-12',
|
||||
cell: (faq) => (
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(faq.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleSelectOne(faq.id, checked as boolean)
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'question',
|
||||
header: t('Question'),
|
||||
cellClassName: 'max-w-xs truncate font-medium',
|
||||
cell: (faq) => faq.question,
|
||||
},
|
||||
{
|
||||
id: 'answer',
|
||||
header: t('Answer'),
|
||||
cellClassName: 'text-muted-foreground max-w-md truncate',
|
||||
cell: (faq) => faq.answer,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('Actions'),
|
||||
className: 'w-32',
|
||||
cell: (faq) => (
|
||||
<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>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
|
||||
@@ -45,14 +45,7 @@ import {
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { SettingsSwitchField } from '../components/settings-form-layout'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
@@ -278,80 +271,77 @@ 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.'
|
||||
)}
|
||||
</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>
|
||||
<StaticDataTable
|
||||
data={groups}
|
||||
getRowKey={(group) => group.id}
|
||||
emptyContent={t(
|
||||
'No Uptime Kuma groups yet. Click "Add Group" to create one.'
|
||||
)}
|
||||
columns={[
|
||||
{
|
||||
id: 'select',
|
||||
header: (
|
||||
<Checkbox
|
||||
checked={
|
||||
selectedIds.length === groups.length && groups.length > 0
|
||||
}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
/>
|
||||
),
|
||||
className: 'w-12',
|
||||
cell: (group) => (
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(group.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleSelectOne(group.id, checked as boolean)
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'category',
|
||||
header: t('Category Name'),
|
||||
cellClassName: 'font-medium',
|
||||
cell: (group) => group.categoryName,
|
||||
},
|
||||
{
|
||||
id: 'url',
|
||||
header: t('Uptime Kuma URL'),
|
||||
cellClassName:
|
||||
'text-primary max-w-xs truncate font-mono text-sm',
|
||||
cell: (group) => group.url,
|
||||
},
|
||||
{
|
||||
id: 'slug',
|
||||
header: t('Status Page Slug'),
|
||||
cellClassName: 'text-muted-foreground font-mono text-sm',
|
||||
cell: (group) => group.slug,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('Actions'),
|
||||
className: 'w-32',
|
||||
cell: (group) => (
|
||||
<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>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
|
||||
+111
-119
@@ -31,15 +31,8 @@ import {
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { StaticDataTable } 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 +539,117 @@ 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'
|
||||
<StaticDataTable
|
||||
tableClassName='min-w-max'
|
||||
data={rules}
|
||||
emptyClassName='text-muted-foreground py-8'
|
||||
emptyContent={t('No rules yet')}
|
||||
columns={[
|
||||
{
|
||||
id: 'name',
|
||||
header: t('Name'),
|
||||
cellClassName: 'font-medium',
|
||||
cell: (rule) => rule.name || '-',
|
||||
},
|
||||
{
|
||||
id: 'model-regex',
|
||||
header: t('Model Regex'),
|
||||
cell: (rule) => <RuleBadgeList items={rule.model_regex || []} />,
|
||||
},
|
||||
{
|
||||
id: 'key-sources',
|
||||
header: t('Key Sources'),
|
||||
cell: (rule) => (
|
||||
<RuleBadgeList
|
||||
items={(rule.key_sources || []).map(
|
||||
(src) =>
|
||||
`${src.type}:${src.type === 'gjson' ? src.path : src.key}`
|
||||
)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'ttl',
|
||||
header: t('TTL'),
|
||||
cell: (rule) => rule.ttl_seconds || '-',
|
||||
},
|
||||
{
|
||||
id: 'retry',
|
||||
header: t('Retry'),
|
||||
cell: (rule) => (
|
||||
<StatusBadge
|
||||
label={
|
||||
rule.skip_retry_on_failure ? t('No Retry') : t('Retry')
|
||||
}
|
||||
variant={rule.skip_retry_on_failure ? 'danger' : 'neutral'}
|
||||
copyable={false}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'scope',
|
||||
header: t('Scope'),
|
||||
cell: (rule) => {
|
||||
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} />
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'cache',
|
||||
header: t('Cache'),
|
||||
cell: (rule) =>
|
||||
rule.include_rule_name && cacheStats?.by_rule_name
|
||||
? cacheStats.by_rule_name[rule.name] || 0
|
||||
: 'N/A',
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('Actions'),
|
||||
className: 'text-right',
|
||||
cellClassName: 'text-right',
|
||||
cell: (rule, idx) => (
|
||||
<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)
|
||||
}}
|
||||
>
|
||||
{t('No rules yet')}
|
||||
</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>
|
||||
<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>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('Rules JSON')}</Label>
|
||||
|
||||
+73
-73
@@ -20,14 +20,7 @@ import { useState, useMemo } from 'react'
|
||||
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'
|
||||
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 +140,78 @@ 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}
|
||||
>
|
||||
{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>
|
||||
<StaticDataTable
|
||||
className='hidden rounded-none border-0 sm:block'
|
||||
data={discounts}
|
||||
getRowKey={(discount) => discount.amount}
|
||||
columns={[
|
||||
{
|
||||
id: 'amount',
|
||||
header: t('Recharge Amount'),
|
||||
cell: (discount) => (
|
||||
<span className='font-mono text-sm'>
|
||||
${discount.amount}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'discount-rate',
|
||||
header: t('Discount Rate'),
|
||||
cell: (discount) => (
|
||||
<code className='bg-muted rounded px-1.5 py-0.5 text-sm'>
|
||||
{discount.discountRate.toFixed(2)}
|
||||
</code>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'discount',
|
||||
header: t('Discount'),
|
||||
cell: (discount) => (
|
||||
<StatusBadge
|
||||
variant={discount.discountRate < 1 ? 'info' : 'neutral'}
|
||||
className='font-mono'
|
||||
copyable={false}
|
||||
>
|
||||
{formatPercentage(discount.discountRate)} {t('off')}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('Actions'),
|
||||
className: 'text-right',
|
||||
cellClassName: 'text-right',
|
||||
cell: (discount) => (
|
||||
<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>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Mobile card view */}
|
||||
<div className='divide-y sm:hidden'>
|
||||
|
||||
+75
-73
@@ -21,14 +21,7 @@ import { Pencil, Plus, Search, Trash2 } from 'lucide-react'
|
||||
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'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import {
|
||||
formatCreemPrice,
|
||||
formatQuotaShort,
|
||||
@@ -183,71 +176,80 @@ 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>
|
||||
</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>
|
||||
<StaticDataTable
|
||||
className='hidden rounded-none border-0 md:block'
|
||||
data={filteredProducts}
|
||||
getRowKey={(product) => product.productId}
|
||||
columns={[
|
||||
{
|
||||
id: 'name',
|
||||
header: t('Name'),
|
||||
cellClassName: 'font-medium',
|
||||
cell: (product) => product.name,
|
||||
},
|
||||
{
|
||||
id: 'product-id',
|
||||
header: t('Product ID'),
|
||||
cell: (product) => (
|
||||
<code className='bg-muted rounded px-1.5 py-0.5 text-sm'>
|
||||
{product.productId}
|
||||
</code>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'price',
|
||||
header: t('Price'),
|
||||
cell: (product) => (
|
||||
<span className='font-mono text-sm'>
|
||||
{formatCreemPrice(product.price, product.currency)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'quota',
|
||||
header: t('Quota'),
|
||||
cell: (product) => (
|
||||
<span className='font-mono text-sm'>
|
||||
{formatQuotaShort(product.quota)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('Actions'),
|
||||
className: 'text-right',
|
||||
cellClassName: 'text-right',
|
||||
cell: (product) => (
|
||||
<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>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Mobile card view */}
|
||||
<div className='divide-y md:hidden'>
|
||||
|
||||
+88
-86
@@ -27,13 +27,8 @@ import {
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
StaticDataTable,
|
||||
} from '@/components/data-table'
|
||||
import { safeJsonParseWithValidation } from '../utils/json-parser'
|
||||
import { isArray } from '../utils/json-validators'
|
||||
import {
|
||||
@@ -291,88 +286,95 @@ 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) => {
|
||||
<StaticDataTable
|
||||
className='hidden rounded-none border-0 md:block'
|
||||
data={filteredMethods}
|
||||
getRowKey={(method, index) => `${method.type}-${index}`}
|
||||
columns={[
|
||||
{
|
||||
id: 'name',
|
||||
header: t('Name'),
|
||||
cellClassName: 'font-medium',
|
||||
cell: (method) => method.name,
|
||||
},
|
||||
{
|
||||
id: 'type',
|
||||
header: t('Type'),
|
||||
cell: (method) => (
|
||||
<code className='bg-muted rounded px-1.5 py-0.5 text-sm'>
|
||||
{method.type}
|
||||
</code>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'color',
|
||||
header: t('Color'),
|
||||
cell: (method) => {
|
||||
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>
|
||||
)}
|
||||
</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>
|
||||
<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>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'min-top-up',
|
||||
header: t('Min Top-up'),
|
||||
cell: (method) =>
|
||||
method.min_topup ? (
|
||||
<span className='font-mono text-sm'>
|
||||
{method.min_topup}
|
||||
</span>
|
||||
) : (
|
||||
<span className='text-muted-foreground text-sm'>—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('Actions'),
|
||||
className: 'text-right',
|
||||
cellClassName: 'text-right',
|
||||
cell: (method) => (
|
||||
<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>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Mobile card view */}
|
||||
<div className='divide-y md:hidden'>
|
||||
|
||||
+68
-77
@@ -25,15 +25,8 @@ 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 {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { SettingsSwitchField } from '../components/settings-form-layout'
|
||||
|
||||
@@ -333,76 +326,74 @@ 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'
|
||||
<StaticDataTable
|
||||
data={payMethods}
|
||||
emptyClassName='text-muted-foreground py-8'
|
||||
emptyContent={t('No payment methods configured')}
|
||||
columns={[
|
||||
{
|
||||
id: 'name',
|
||||
header: t('Display name'),
|
||||
cell: (m) => m.name,
|
||||
},
|
||||
{
|
||||
id: 'icon',
|
||||
header: t('Icon'),
|
||||
cell: (m) =>
|
||||
m.icon ? (
|
||||
<img
|
||||
src={m.icon}
|
||||
alt={m.name}
|
||||
className='h-6 w-6 rounded object-contain'
|
||||
/>
|
||||
) : (
|
||||
<span className='text-muted-foreground'>-</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'type',
|
||||
header: t('Payment method type'),
|
||||
cell: (m) => m.payMethodType || '-',
|
||||
},
|
||||
{
|
||||
id: 'method',
|
||||
header: t('Payment method name'),
|
||||
cell: (m) => m.payMethodName || '-',
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('Actions'),
|
||||
className: 'text-right',
|
||||
cellClassName: 'text-right',
|
||||
cell: (_m, idx) => (
|
||||
<div className='flex justify-end gap-1'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-7 w-7'
|
||||
onClick={() => openEdit(idx)}
|
||||
>
|
||||
{t('No payment methods configured')}
|
||||
</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>
|
||||
<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>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
|
||||
+16
-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'
|
||||
@@ -40,14 +32,10 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { DataTablePagination } from '@/components/data-table/pagination'
|
||||
DataTablePagination,
|
||||
DataTableView,
|
||||
useDataTable,
|
||||
} from '@/components/data-table'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import type { UpstreamChannel } from '../types'
|
||||
@@ -295,23 +283,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 +336,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>
|
||||
|
||||
+38
-41
@@ -28,13 +28,8 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
StaticDataTable,
|
||||
} from '@/components/data-table'
|
||||
|
||||
export type ConflictItem = {
|
||||
channel: string
|
||||
@@ -71,40 +66,42 @@ 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>
|
||||
</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>
|
||||
<StaticDataTable
|
||||
className='max-h-96 overflow-y-auto'
|
||||
data={conflicts}
|
||||
columns={[
|
||||
{
|
||||
id: 'channel',
|
||||
header: t('Channel'),
|
||||
cellClassName: 'font-medium',
|
||||
cell: (conflict) => conflict.channel,
|
||||
},
|
||||
{
|
||||
id: 'model',
|
||||
header: t('Model'),
|
||||
cellClassName: 'font-mono text-sm',
|
||||
cell: (conflict) => conflict.model,
|
||||
},
|
||||
{
|
||||
id: 'current',
|
||||
header: t('Current Billing'),
|
||||
cell: (conflict) => (
|
||||
<pre className='text-sm whitespace-pre-wrap'>
|
||||
{conflict.current}
|
||||
</pre>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'new',
|
||||
header: t('Change To'),
|
||||
cell: (conflict) => (
|
||||
<pre className='text-sm whitespace-pre-wrap'>
|
||||
{conflict.newVal}
|
||||
</pre>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isLoading}>
|
||||
|
||||
+191
-205
@@ -35,14 +35,7 @@ import {
|
||||
} from '@/components/ui/collapsible'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { safeJsonParse } from '../utils/json-parser'
|
||||
|
||||
@@ -427,54 +420,51 @@ 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>
|
||||
</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>
|
||||
<StaticDataTable
|
||||
data={topupRatioList}
|
||||
getRowKey={(group) => group.name}
|
||||
columns={[
|
||||
{
|
||||
id: 'group',
|
||||
header: t('Group name'),
|
||||
cellClassName: 'font-medium',
|
||||
cell: (group) => group.name,
|
||||
},
|
||||
{
|
||||
id: 'multiplier',
|
||||
header: t('Multiplier'),
|
||||
cell: (group) => group.value,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('Actions'),
|
||||
className: 'text-right',
|
||||
cellClassName: 'text-right',
|
||||
cell: (group) => (
|
||||
<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>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -541,55 +531,58 @@ export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({
|
||||
<CollapsibleContent>
|
||||
{userGroupData.overrides.length > 0 && (
|
||||
<div className='border-t'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Target group')}</TableHead>
|
||||
<TableHead>{t('Ratio')}</TableHead>
|
||||
<TableHead className='text-right'>
|
||||
{t('Actions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{userGroupData.overrides.map((override) => (
|
||||
<TableRow key={override.targetGroup}>
|
||||
<TableCell className='font-medium'>
|
||||
{override.targetGroup}
|
||||
</TableCell>
|
||||
<TableCell>{override.ratio}</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
handleOverrideEdit(
|
||||
userGroupData.userGroup,
|
||||
override
|
||||
)
|
||||
}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
handleOverrideDelete(
|
||||
userGroupData.userGroup,
|
||||
override.targetGroup
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<StaticDataTable
|
||||
className='rounded-none border-0'
|
||||
data={userGroupData.overrides}
|
||||
getRowKey={(override) => override.targetGroup}
|
||||
columns={[
|
||||
{
|
||||
id: 'target-group',
|
||||
header: t('Target group'),
|
||||
cellClassName: 'font-medium',
|
||||
cell: (override) => override.targetGroup,
|
||||
},
|
||||
{
|
||||
id: 'ratio',
|
||||
header: t('Ratio'),
|
||||
cell: (override) => override.ratio,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('Actions'),
|
||||
className: 'text-right',
|
||||
cellClassName: 'text-right',
|
||||
cell: (override) => (
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
handleOverrideEdit(
|
||||
userGroupData.userGroup,
|
||||
override
|
||||
)
|
||||
}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
handleOverrideDelete(
|
||||
userGroupData.userGroup,
|
||||
override.targetGroup
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
@@ -858,106 +851,99 @@ 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.')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
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>
|
||||
<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-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>
|
||||
<StaticDataTable
|
||||
data={rows}
|
||||
getRowKey={(row) => row._id}
|
||||
emptyClassName='text-muted-foreground h-20 text-sm'
|
||||
emptyContent={t('No groups yet. Add a group to get started.')}
|
||||
columns={[
|
||||
{
|
||||
id: 'group',
|
||||
header: t('Group name'),
|
||||
className: 'min-w-40',
|
||||
cell: (row) => (
|
||||
<Input
|
||||
value={row.name}
|
||||
onChange={(event) =>
|
||||
updateRow(row._id, 'name', event.target.value)
|
||||
}
|
||||
aria-invalid={duplicateNames.includes(row.name.trim())}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'ratio',
|
||||
header: t('Ratio'),
|
||||
className: 'w-28',
|
||||
cell: (row) => (
|
||||
<Input
|
||||
type='number'
|
||||
min={0}
|
||||
step={0.1}
|
||||
value={String(row.ratio)}
|
||||
onChange={(event) =>
|
||||
updateRow(
|
||||
row._id,
|
||||
'ratio',
|
||||
normalizeRatio(event.target.value)
|
||||
)
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'selectable',
|
||||
header: t('User selectable'),
|
||||
className: 'w-28 text-center',
|
||||
cell: (row) => (
|
||||
<div className='flex justify-center'>
|
||||
<Checkbox
|
||||
checked={row.selectable}
|
||||
onCheckedChange={(checked) =>
|
||||
updateRow(row._id, 'selectable', checked === true)
|
||||
}
|
||||
aria-label={t('User selectable')}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'description',
|
||||
header: t('Description'),
|
||||
className: 'min-w-56',
|
||||
cell: (row) =>
|
||||
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>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('Actions'),
|
||||
className: 'w-16 text-right',
|
||||
cellClassName: 'text-right',
|
||||
cell: (row) => (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => removeRow(row._id)}
|
||||
aria-label={t('Delete')}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{duplicateNames.length > 0 && (
|
||||
<p className='text-destructive text-sm'>
|
||||
|
||||
+14
-7
@@ -71,6 +71,7 @@ export function buildModelRatioColumns({
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
size: 40,
|
||||
meta: { label: t('Select') },
|
||||
},
|
||||
{
|
||||
@@ -79,16 +80,22 @@ export function buildModelRatioColumns({
|
||||
<DataTableColumnHeader column={column} title={t('Model name')} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className='flex items-center gap-2 font-medium'>
|
||||
{row.getValue('name')}
|
||||
<div className='flex min-w-0 items-center gap-2 font-medium'>
|
||||
<span className='min-w-0 truncate'>{row.getValue('name')}</span>
|
||||
{row.original.billingMode === 'tiered_expr' && (
|
||||
<StatusBadge label={t('Tiered')} variant='info' copyable={false} />
|
||||
<StatusBadge
|
||||
label={t('Tiered')}
|
||||
variant='info'
|
||||
copyable={false}
|
||||
className='shrink-0'
|
||||
/>
|
||||
)}
|
||||
{row.original.hasConflict && (
|
||||
<StatusBadge
|
||||
label={t('Conflict')}
|
||||
variant='danger'
|
||||
copyable={false}
|
||||
className='shrink-0'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -119,11 +126,11 @@ export function buildModelRatioColumns({
|
||||
<DataTableColumnHeader column={column} title={t('Price summary')} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className='flex min-w-[180px] flex-col gap-1'>
|
||||
<span className='font-medium'>
|
||||
<div className='flex min-w-0 flex-col gap-1'>
|
||||
<span className='truncate font-medium'>
|
||||
{getPriceSummary(row.original, t)}
|
||||
</span>
|
||||
<span className='text-muted-foreground max-w-[320px] truncate text-xs'>
|
||||
<span className='text-muted-foreground truncate text-xs'>
|
||||
{getPriceDetail(row.original, t)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -136,7 +143,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
|
||||
|
||||
+62
-92
@@ -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)
|
||||
@@ -629,6 +615,8 @@ const ModelRatioVisualEditorComponent = forwardRef<
|
||||
[editorOpen, persistPricingData]
|
||||
)
|
||||
|
||||
const hasRows = table.getRowModel().rows.length > 0
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='grid h-[clamp(720px,calc(100vh-12rem),900px)] min-h-0 gap-4 md:grid-cols-[minmax(300px,0.72fr)_minmax(520px,1.28fr)] xl:grid-cols-[minmax(320px,0.68fr)_minmax(640px,1.32fr)]'>
|
||||
@@ -667,82 +655,64 @@ const ModelRatioVisualEditorComponent = forwardRef<
|
||||
}
|
||||
/>
|
||||
|
||||
{table.getRowModel().rows.length === 0 ? (
|
||||
{!hasRows ? (
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
|
||||
{table.getState().globalFilter
|
||||
? t('No models match your search')
|
||||
: 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-[852px] table-fixed'
|
||||
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-9' />
|
||||
<col className='w-[300px]' />
|
||||
<col className='w-[120px]' />
|
||||
<col className='w-[300px]' />
|
||||
<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')
|
||||
: 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 && (
|
||||
<DataTablePagination table={table} />
|
||||
)}
|
||||
{hasRows && <DataTablePagination table={table} />}
|
||||
</div>
|
||||
|
||||
<div className='hidden min-h-0 min-w-0 md:block'>
|
||||
|
||||
@@ -464,7 +464,7 @@ function ConditionRow({ condition, onChange, onRemove }: ConditionRowProps) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
items={[...OPS.map((op) => ({ value: op, label: op }))]}
|
||||
items={OPS.map((op) => ({ value: op, label: op }))}
|
||||
value={condition.op}
|
||||
onValueChange={(value) =>
|
||||
onChange({ ...condition, op: value as TierConditionInput['op'] })
|
||||
|
||||
@@ -23,15 +23,8 @@ import { toast } from 'sonner'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import { useUpdateOption } from '../hooks/use-update-option'
|
||||
|
||||
const OPTION_KEY = 'tool_price_setting.prices'
|
||||
@@ -109,7 +102,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 +253,57 @@ 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')}
|
||||
</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>
|
||||
<StaticDataTable
|
||||
data={rows}
|
||||
getRowKey={(row) => row.id}
|
||||
emptyClassName='text-muted-foreground py-8'
|
||||
emptyContent={t('No tools configured')}
|
||||
columns={[
|
||||
{
|
||||
id: 'tool',
|
||||
header: t('Tool identifier'),
|
||||
cell: (row) => (
|
||||
<Input
|
||||
value={row.key}
|
||||
placeholder='web_search_preview:gpt-4o*'
|
||||
onChange={(e) => updateRow(row.id, 'key', e.target.value)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'price',
|
||||
header: t('Price ($/1K calls)'),
|
||||
className: 'w-[200px]',
|
||||
cell: (row) => (
|
||||
<Input
|
||||
type='number'
|
||||
min={0}
|
||||
step={0.5}
|
||||
value={row.price}
|
||||
onChange={(e) =>
|
||||
updateRow(row.id, 'price', Number(e.target.value) || 0)
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('Actions'),
|
||||
className: 'w-[80px] text-right',
|
||||
cellClassName: 'text-right',
|
||||
cell: (row) => (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={() => removeRow(row.id)}
|
||||
aria-label={t('Delete')}
|
||||
>
|
||||
<Trash2 className='text-destructive h-4 w-4' />
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<div className='space-y-2'>
|
||||
<Textarea
|
||||
|
||||
+18
-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'
|
||||
@@ -35,14 +29,10 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { DataTablePagination } from '@/components/data-table/pagination'
|
||||
DataTablePagination,
|
||||
DataTableView,
|
||||
useDataTable,
|
||||
} from '@/components/data-table'
|
||||
import type { DifferencesMap, RatioType } from '../types'
|
||||
import { RATIO_TYPE_OPTIONS } from './constants'
|
||||
import { useUpstreamRatioSyncColumns } from './upstream-ratio-sync-columns'
|
||||
@@ -180,15 +170,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 +247,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>
|
||||
|
||||
+65
-68
@@ -21,14 +21,7 @@ import { Pencil, Plus, Search, Trash2 } from 'lucide-react'
|
||||
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'
|
||||
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'
|
||||
@@ -142,69 +135,73 @@ export function RateLimitVisualEditor({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{filteredRateLimits.length === 0 ? (
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
|
||||
{searchText
|
||||
<StaticDataTable
|
||||
data={filteredRateLimits}
|
||||
getRowKey={(limit) => limit.groupName}
|
||||
emptyContent={
|
||||
searchText
|
||||
? t('No groups match your search')
|
||||
: t(
|
||||
'No group-based rate limits configured. Click "Add group" to get started.'
|
||||
)}
|
||||
</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>
|
||||
</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>
|
||||
)}
|
||||
)
|
||||
}
|
||||
columns={[
|
||||
{
|
||||
id: 'group',
|
||||
header: t('Group Name'),
|
||||
cellClassName: 'font-medium',
|
||||
cell: (limit) => limit.groupName,
|
||||
},
|
||||
{
|
||||
id: 'max-requests',
|
||||
header: t('Max Requests (incl. failures)'),
|
||||
className: 'text-right',
|
||||
cellClassName: 'text-right',
|
||||
cell: (limit) => (
|
||||
<span className='font-mono'>
|
||||
{limit.maxRequests === 0
|
||||
? t('Unlimited')
|
||||
: limit.maxRequests.toLocaleString()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'max-success',
|
||||
header: t('Max Success'),
|
||||
className: 'text-right',
|
||||
cellClassName: 'text-right',
|
||||
cell: (limit) => (
|
||||
<span className='font-mono'>
|
||||
{limit.maxSuccess.toLocaleString()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('Actions'),
|
||||
className: 'text-right',
|
||||
cellClassName: 'text-right',
|
||||
cell: (limit) => (
|
||||
<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>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<RateLimitDialog
|
||||
open={dialogOpen}
|
||||
|
||||
+4
-3
@@ -274,8 +274,8 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
const config = getLogTypeConfig(log.type)
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-0.5'>
|
||||
<span className='font-mono text-xs tabular-nums'>
|
||||
<div className='flex min-w-0 flex-col gap-0.5'>
|
||||
<span className='truncate font-mono text-xs tabular-nums'>
|
||||
{formatTimestampToDate(timestamp)}
|
||||
</span>
|
||||
<StatusBadge
|
||||
@@ -294,6 +294,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
return value.includes(String(row.original.type))
|
||||
},
|
||||
enableHiding: false,
|
||||
size: 180,
|
||||
meta: { label: t('Time') },
|
||||
},
|
||||
]
|
||||
@@ -752,7 +753,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-0.5'>
|
||||
<span className='border-border/80 bg-muted/60 inline-flex h-6 w-fit items-center rounded-md border px-2 text-sm leading-none [font-family:var(--font-body)] font-semibold tabular-nums'>
|
||||
<span className='border-border/80 bg-muted/60 inline-flex h-6 w-fit items-center rounded-md border px-2 [font-family:var(--font-body)] text-sm leading-none font-semibold tabular-nums'>
|
||||
{quotaDisplay.prefix && (
|
||||
<span className='mr-1'>{quotaDisplay.prefix}</span>
|
||||
)}
|
||||
|
||||
+3
-2
@@ -95,8 +95,8 @@ export function useDrawingLogsColumns(
|
||||
const submitTime = row.getValue('submit_time') as number
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-0.5'>
|
||||
<span className='font-mono text-xs tabular-nums'>
|
||||
<div className='flex min-w-0 flex-col gap-0.5'>
|
||||
<span className='truncate font-mono text-xs tabular-nums'>
|
||||
{formatTimestampToDate(submitTime)}
|
||||
</span>
|
||||
<StatusBadge
|
||||
@@ -108,6 +108,7 @@ export function useDrawingLogsColumns(
|
||||
</div>
|
||||
)
|
||||
},
|
||||
size: 180,
|
||||
meta: { label: t('Submit Time') },
|
||||
},
|
||||
]
|
||||
|
||||
+4
-3
@@ -102,12 +102,12 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
|
||||
const submitTime = row.getValue('submit_time') as number
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-0.5'>
|
||||
<span className='font-mono text-xs tabular-nums'>
|
||||
<div className='flex min-w-0 flex-col gap-0.5'>
|
||||
<span className='truncate font-mono text-xs tabular-nums'>
|
||||
{formatTimestampToDate(submitTime, 'seconds')}
|
||||
</span>
|
||||
{log.finish_time ? (
|
||||
<span className='text-muted-foreground/60 font-mono text-[11px] tabular-nums'>
|
||||
<span className='text-muted-foreground/60 truncate font-mono text-[11px] tabular-nums'>
|
||||
{formatTimestampToDate(log.finish_time, 'seconds')}
|
||||
</span>
|
||||
) : (
|
||||
@@ -116,6 +116,7 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
|
||||
</div>
|
||||
)
|
||||
},
|
||||
size: 180,
|
||||
meta: { label: t('Submit Time') },
|
||||
},
|
||||
]
|
||||
|
||||
+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 (
|
||||
@@ -187,11 +169,10 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
|
||||
'No usage logs available. Logs will appear here once API calls are made.'
|
||||
)}
|
||||
skeletonKeyPrefix='usage-log-skeleton'
|
||||
applyHeaderSize
|
||||
tableClassName={cn(
|
||||
'overflow-x-auto',
|
||||
'[&_[data-slot=table]]:text-[13px] [&_[data-slot=table]_td]:text-[13px] [&_[data-slot=table]_td_*]:text-[13px] [&_[data-slot=table]_th]:text-[13px] [&_[data-slot=table]_th_*]:text-[13px]'
|
||||
)}
|
||||
tableHeaderClassName='bg-muted/30 sticky top-0 z-10'
|
||||
mobile={
|
||||
<UsageLogsMobileList
|
||||
table={table}
|
||||
@@ -214,13 +195,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')}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
||||
+5
-3
@@ -110,12 +110,12 @@ function UsageLogsContent() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionPageLayout>
|
||||
<SectionPageLayout fixedContent>
|
||||
<SectionPageLayout.Title>
|
||||
{t(pageMeta.titleKey)}
|
||||
</SectionPageLayout.Title>
|
||||
<SectionPageLayout.Content>
|
||||
<div className='space-y-4'>
|
||||
<div className='flex h-full min-h-0 flex-col gap-4'>
|
||||
{showTaskSwitcher && (
|
||||
<Tabs value={activeCategory} onValueChange={handleSectionChange}>
|
||||
<TabsList className='max-w-full flex-wrap justify-start group-data-horizontal/tabs:h-auto'>
|
||||
@@ -127,7 +127,9 @@ function UsageLogsContent() {
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)}
|
||||
<UsageLogsTable logCategory={activeCategory} />
|
||||
<div className='min-h-0 flex-1'>
|
||||
<UsageLogsTable logCategory={activeCategory} />
|
||||
</div>
|
||||
</div>
|
||||
</SectionPageLayout.Content>
|
||||
</SectionPageLayout>
|
||||
|
||||
+11
-1
@@ -66,6 +66,7 @@ export function useUsersColumns(): ColumnDef<User>[] {
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
size: 40,
|
||||
meta: { label: t('Select') },
|
||||
},
|
||||
{
|
||||
@@ -78,6 +79,7 @@ export function useUsersColumns(): ColumnDef<User>[] {
|
||||
<TableId value={row.getValue('id') as number} className='w-[60px]' />
|
||||
)
|
||||
},
|
||||
size: 80,
|
||||
meta: { label: t('ID'), mobileHidden: true },
|
||||
},
|
||||
{
|
||||
@@ -118,6 +120,7 @@ export function useUsersColumns(): ColumnDef<User>[] {
|
||||
)
|
||||
},
|
||||
enableHiding: false,
|
||||
size: 220,
|
||||
meta: { label: t('Username'), mobileTitle: true },
|
||||
},
|
||||
{
|
||||
@@ -158,6 +161,7 @@ export function useUsersColumns(): ColumnDef<User>[] {
|
||||
return value.includes(String(row.getValue(id)))
|
||||
},
|
||||
enableSorting: false,
|
||||
size: 120,
|
||||
meta: { label: t('Status'), mobileBadge: true },
|
||||
},
|
||||
{
|
||||
@@ -220,6 +224,7 @@ export function useUsersColumns(): ColumnDef<User>[] {
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
size: 170,
|
||||
meta: { label: t('Quota') },
|
||||
},
|
||||
{
|
||||
@@ -236,6 +241,7 @@ export function useUsersColumns(): ColumnDef<User>[] {
|
||||
const searchValue = String(value).toLowerCase()
|
||||
return group.includes(searchValue)
|
||||
},
|
||||
size: 140,
|
||||
meta: { label: t('Group') },
|
||||
},
|
||||
{
|
||||
@@ -264,6 +270,7 @@ export function useUsersColumns(): ColumnDef<User>[] {
|
||||
return value.includes(String(row.getValue(id)))
|
||||
},
|
||||
enableSorting: false,
|
||||
size: 120,
|
||||
meta: { label: t('Role') },
|
||||
},
|
||||
{
|
||||
@@ -278,7 +285,7 @@ export function useUsersColumns(): ColumnDef<User>[] {
|
||||
const inviterId = user.inviter_id || 0
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-1'>
|
||||
<div className='flex min-w-[220px] flex-wrap items-center gap-1'>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
@@ -338,6 +345,7 @@ export function useUsersColumns(): ColumnDef<User>[] {
|
||||
</div>
|
||||
)
|
||||
},
|
||||
size: 240,
|
||||
enableSorting: false,
|
||||
meta: { label: t('Invite Info'), mobileHidden: true },
|
||||
},
|
||||
@@ -354,6 +362,7 @@ export function useUsersColumns(): ColumnDef<User>[] {
|
||||
</span>
|
||||
)
|
||||
},
|
||||
size: 180,
|
||||
meta: { label: t('Created At'), mobileHidden: true },
|
||||
},
|
||||
{
|
||||
@@ -369,6 +378,7 @@ export function useUsersColumns(): ColumnDef<User>[] {
|
||||
</span>
|
||||
)
|
||||
},
|
||||
size: 180,
|
||||
meta: { label: t('Last Login'), mobileHidden: true },
|
||||
},
|
||||
{
|
||||
|
||||
+8
-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}
|
||||
@@ -203,6 +171,7 @@ export function UsersTable() {
|
||||
'No users available. Try adjusting your search or filters.'
|
||||
)}
|
||||
skeletonKeyPrefix='users-skeleton'
|
||||
applyHeaderSize
|
||||
toolbarProps={{
|
||||
searchPlaceholder: t('Filter by username, name or email...'),
|
||||
filters: [
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ function UsersContent() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionPageLayout>
|
||||
<SectionPageLayout fixedContent>
|
||||
<SectionPageLayout.Title>{t('Users')}</SectionPageLayout.Title>
|
||||
<SectionPageLayout.Actions>
|
||||
<UsersPrimaryButtons />
|
||||
|
||||
Vendored
+2
@@ -1856,6 +1856,7 @@
|
||||
"Go to io.net API Keys": "Go to io.net API Keys",
|
||||
"Go to last page": "Go to last page",
|
||||
"Go to next page": "Go to next page",
|
||||
"Go to page {{page}}": "Go to page {{page}}",
|
||||
"Go to previous page": "Go to previous page",
|
||||
"Go to settings": "Go to settings",
|
||||
"Go to Settings": "Go to Settings",
|
||||
@@ -3208,6 +3209,7 @@
|
||||
"Redeem codes": "Redeem codes",
|
||||
"Redeemed By": "Redeemed By",
|
||||
"Redeemed:": "Redeemed:",
|
||||
"Received amount": "Received amount",
|
||||
"redemption code": "redemption code",
|
||||
"Redemption Code": "Redemption Code",
|
||||
"Redemption code deleted successfully": "Redemption code deleted successfully",
|
||||
|
||||
Vendored
+2
@@ -1856,6 +1856,7 @@
|
||||
"Go to io.net API Keys": "Accéder aux clés API io.net",
|
||||
"Go to last page": "Aller à la dernière page",
|
||||
"Go to next page": "Aller à la page suivante",
|
||||
"Go to page {{page}}": "Aller à la page {{page}}",
|
||||
"Go to previous page": "Aller à la page précédente",
|
||||
"Go to settings": "Aller aux paramètres",
|
||||
"Go to Settings": "Aller aux paramètres",
|
||||
@@ -3208,6 +3209,7 @@
|
||||
"Redeem codes": "Échanger des codes",
|
||||
"Redeemed By": "Utilisé par",
|
||||
"Redeemed:": "Utilisé :",
|
||||
"Received amount": "Montant reçu",
|
||||
"redemption code": "code d'échange",
|
||||
"Redemption Code": "Code d'échange",
|
||||
"Redemption code deleted successfully": "Code d'échange supprimé avec succès",
|
||||
|
||||
Vendored
+2
@@ -1856,6 +1856,7 @@
|
||||
"Go to io.net API Keys": "io.net API キーへ移動",
|
||||
"Go to last page": "最後のページへ移動",
|
||||
"Go to next page": "次のページへ移動",
|
||||
"Go to page {{page}}": "{{page}}ページ目へ移動",
|
||||
"Go to previous page": "前のページへ移動",
|
||||
"Go to settings": "設定へ",
|
||||
"Go to Settings": "設定へ移動",
|
||||
@@ -3208,6 +3209,7 @@
|
||||
"Redeem codes": "コードを交換",
|
||||
"Redeemed By": "引き換え元",
|
||||
"Redeemed:": "引き換え済み:",
|
||||
"Received amount": "受け取り額",
|
||||
"redemption code": "引き換えコード",
|
||||
"Redemption Code": "引き換えコード",
|
||||
"Redemption code deleted successfully": "引き換えコードを正常に削除しました",
|
||||
|
||||
Vendored
+2
@@ -1856,6 +1856,7 @@
|
||||
"Go to io.net API Keys": "Перейти к ключам API io.net",
|
||||
"Go to last page": "Перейти на последнюю страницу",
|
||||
"Go to next page": "Перейти на следующую страницу",
|
||||
"Go to page {{page}}": "Перейти на страницу {{page}}",
|
||||
"Go to previous page": "Перейти на предыдущую страницу",
|
||||
"Go to settings": "Перейти к настройкам",
|
||||
"Go to Settings": "Перейти к настройкам",
|
||||
@@ -3208,6 +3209,7 @@
|
||||
"Redeem codes": "Активировать коды",
|
||||
"Redeemed By": "Активировано",
|
||||
"Redeemed:": "Активировано:",
|
||||
"Received amount": "Полученная сумма",
|
||||
"redemption code": "код активации",
|
||||
"Redemption Code": "Код активации",
|
||||
"Redemption code deleted successfully": "Код активации успешно удален",
|
||||
|
||||
Vendored
+4
-2
@@ -1851,11 +1851,12 @@
|
||||
"Go Back": "Quay lại",
|
||||
"Go back and edit": "Quay lại và chỉnh sửa",
|
||||
"Go to Dashboard": "Truy cập Dashboard",
|
||||
"Go to first page": "Go to the first page",
|
||||
"Go to first page": "Đi đến trang đầu tiên",
|
||||
"Go to home": "Về trang chủ",
|
||||
"Go to io.net API Keys": "Đi đến Khóa API io.net",
|
||||
"Go to last page": "Go to the last page",
|
||||
"Go to last page": "Đi đến trang cuối cùng",
|
||||
"Go to next page": "Đi đến trang tiếp theo",
|
||||
"Go to page {{page}}": "Đi đến trang {{page}}",
|
||||
"Go to previous page": "Quay lại trang trước",
|
||||
"Go to settings": "Đi tới cài đặt",
|
||||
"Go to Settings": "Đi đến Cài đặt",
|
||||
@@ -3208,6 +3209,7 @@
|
||||
"Redeem codes": "Đổi mã",
|
||||
"Redeemed By": "Được chuộc bởi",
|
||||
"Redeemed:": "Đã đổi:",
|
||||
"Received amount": "Số tiền đã nhận",
|
||||
"redemption code": "mã đổi thưởng",
|
||||
"Redemption Code": "Mã đổi thưởng",
|
||||
"Redemption code deleted successfully": "Mã đổi thưởng đã xóa thành công",
|
||||
|
||||
Vendored
+2
@@ -1856,6 +1856,7 @@
|
||||
"Go to io.net API Keys": "前往 io.net API 密钥",
|
||||
"Go to last page": "前往末页",
|
||||
"Go to next page": "前往下一页",
|
||||
"Go to page {{page}}": "前往第 {{page}} 页",
|
||||
"Go to previous page": "前往上一页",
|
||||
"Go to settings": "前往设置",
|
||||
"Go to Settings": "前往设置",
|
||||
@@ -3208,6 +3209,7 @@
|
||||
"Redeem codes": "兑换码",
|
||||
"Redeemed By": "兑换人",
|
||||
"Redeemed:": "已兑换:",
|
||||
"Received amount": "已收额度",
|
||||
"redemption code": "兑换码",
|
||||
"Redemption Code": "兑换码",
|
||||
"Redemption code deleted successfully": "兑换码删除成功",
|
||||
|
||||
Vendored
+10
-21
@@ -46,42 +46,31 @@ export function sanitizeCssVariableName(name: string): string {
|
||||
* @returns Array of page numbers and ellipsis strings
|
||||
*
|
||||
* Examples:
|
||||
* - Small dataset (≤5 pages): [1, 2, 3, 4, 5]
|
||||
* - Near beginning: [1, 2, 3, 4, '...', 10]
|
||||
* - In middle: [1, '...', 4, 5, 6, '...', 10]
|
||||
* - Near end: [1, '...', 7, 8, 9, 10]
|
||||
* - Small dataset (≤4 pages): [1, 2, 3, 4]
|
||||
* - Near beginning: [1, 2, '...', 10]
|
||||
* - In middle: [1, '...', 5, '...', 10]
|
||||
* - Near end: [1, '...', 9, 10]
|
||||
*/
|
||||
export function getPageNumbers(currentPage: number, totalPages: number) {
|
||||
const maxVisiblePages = 5 // Maximum number of page buttons to show
|
||||
const maxVisiblePages = 4
|
||||
const rangeWithDots = []
|
||||
|
||||
if (totalPages <= maxVisiblePages) {
|
||||
// If total pages is 5 or less, show all pages
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
rangeWithDots.push(i)
|
||||
}
|
||||
} else {
|
||||
// Always show first page
|
||||
rangeWithDots.push(1)
|
||||
|
||||
if (currentPage <= 3) {
|
||||
// Near the beginning: [1] [2] [3] [4] ... [10]
|
||||
for (let i = 2; i <= 4; i++) {
|
||||
rangeWithDots.push(i)
|
||||
}
|
||||
if (currentPage <= 2) {
|
||||
rangeWithDots.push(2)
|
||||
rangeWithDots.push('...', totalPages)
|
||||
} else if (currentPage >= totalPages - 2) {
|
||||
// Near the end: [1] ... [7] [8] [9] [10]
|
||||
} else if (currentPage >= totalPages - 1) {
|
||||
rangeWithDots.push('...')
|
||||
for (let i = totalPages - 3; i <= totalPages; i++) {
|
||||
rangeWithDots.push(i)
|
||||
}
|
||||
rangeWithDots.push(totalPages - 1, totalPages)
|
||||
} else {
|
||||
// In the middle: [1] ... [4] [5] [6] ... [10]
|
||||
rangeWithDots.push('...')
|
||||
for (let i = currentPage - 1; i <= currentPage + 1; i++) {
|
||||
rangeWithDots.push(i)
|
||||
}
|
||||
rangeWithDots.push(currentPage)
|
||||
rangeWithDots.push('...', totalPages)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user