refactor(web): unify table rendering components
- centralize static table headers, bodies, empty states, and shared class names behind the data-table package. - migrate settings, pricing, channel, key, subscription, and model tables to the shared table APIs. - remove data-table exports for low-level table primitives so feature code uses one supported abstraction.
This commit is contained in:
+3
-11
@@ -21,21 +21,12 @@ export { DataTableColumnHeader } from './column-header'
|
||||
export { DataTableViewOptions } from './view-options'
|
||||
export { DataTableToolbar } from './toolbar'
|
||||
export { DataTableBulkActions } from './bulk-actions'
|
||||
export {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCaption,
|
||||
TableCell,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
export {
|
||||
StaticDataTable,
|
||||
StaticDataTableEmptyRow,
|
||||
staticDataTableClassNames,
|
||||
type StaticDataTableColumn,
|
||||
} from './static-data-table'
|
||||
export { staticDataTableClassNames } from './static-data-table-classnames'
|
||||
export {
|
||||
DataTableRow,
|
||||
DataTableView,
|
||||
@@ -47,6 +38,7 @@ export {
|
||||
export { MobileCardList } from './mobile-card-list'
|
||||
export { DataTablePage, type DataTablePageProps } from './data-table-page'
|
||||
export { useDataTable } from './use-data-table'
|
||||
export { useDebouncedColumnFilter } from './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'
|
||||
|
||||
@@ -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
|
||||
+96
-35
@@ -18,39 +18,27 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Table, TableCell, TableRow } from '@/components/ui/table'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { staticDataTableClassNames } from './static-data-table-classnames'
|
||||
|
||||
export const staticDataTableClassNames = {
|
||||
container: 'overflow-hidden rounded-md border',
|
||||
sectionContainer: 'border-border/60 rounded-lg',
|
||||
embeddedContainer: 'rounded-none border-0',
|
||||
compactTable: 'text-sm',
|
||||
compactHeaderRow: 'hover:bg-transparent',
|
||||
mutedHeaderRow: 'bg-muted/30 hover:bg-muted/30',
|
||||
compactHeaderCell:
|
||||
'text-muted-foreground py-2 text-[10px] font-medium tracking-wider uppercase',
|
||||
compactHeaderCellRight:
|
||||
'text-muted-foreground py-2 text-right text-[10px] font-medium tracking-wider uppercase',
|
||||
compactCell: 'py-2.5',
|
||||
compactTopCell: 'py-2.5 align-top',
|
||||
compactTopNumericCell: 'py-2.5 text-right align-top font-mono',
|
||||
compactMutedCell: 'text-muted-foreground py-2.5',
|
||||
compactMutedCodeCell: 'text-muted-foreground py-2.5 font-mono',
|
||||
compactNumericCell: 'py-2.5 text-right font-mono',
|
||||
compactMutedNumericCell: 'text-muted-foreground py-2.5 text-right font-mono',
|
||||
topCell: 'py-2 align-top',
|
||||
topMutedCell: 'text-muted-foreground py-2 align-top',
|
||||
codeCell: 'font-mono text-sm',
|
||||
mutedCell: 'text-muted-foreground text-sm',
|
||||
mutedCodeCell: 'text-muted-foreground font-mono text-sm',
|
||||
topNumericCell: 'py-2 text-right font-mono',
|
||||
mediumCell: 'font-medium',
|
||||
actionHeaderCell: 'text-right',
|
||||
actionCell: 'text-right',
|
||||
} as const
|
||||
|
||||
type StaticDataTableProps = {
|
||||
children: React.ReactNode
|
||||
type StaticDataTableProps<TData = unknown> = {
|
||||
children?: React.ReactNode
|
||||
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
|
||||
className?: string
|
||||
tableClassName?: string
|
||||
containerProps?: Omit<React.ComponentProps<'div'>, 'className' | 'children'>
|
||||
@@ -60,20 +48,93 @@ type StaticDataTableProps = {
|
||||
>
|
||||
}
|
||||
|
||||
export function StaticDataTable({
|
||||
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>({
|
||||
children,
|
||||
columns,
|
||||
data,
|
||||
getRowKey,
|
||||
getRowClassName,
|
||||
renderRow,
|
||||
empty,
|
||||
emptyContent,
|
||||
emptyClassName,
|
||||
headerRowClassName,
|
||||
className,
|
||||
tableClassName,
|
||||
containerProps,
|
||||
tableProps,
|
||||
}: StaticDataTableProps) {
|
||||
}: StaticDataTableProps<TData>) {
|
||||
const rows = data
|
||||
? data.map((row, index) => {
|
||||
const key = getRowKey?.(row, index) ?? index
|
||||
|
||||
if (renderRow) {
|
||||
return <React.Fragment key={key}>{renderRow(row, index)}</React.Fragment>
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRow key={key} className={getRowClassName?.(row, index)}>
|
||||
{columns?.map((column) => {
|
||||
const cellClassName =
|
||||
typeof column.cellClassName === 'function'
|
||||
? column.cellClassName(row, index)
|
||||
: column.cellClassName
|
||||
|
||||
return (
|
||||
<TableCell key={column.id} className={cellClassName}>
|
||||
{column.cell?.(row, index)}
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
)
|
||||
})
|
||||
: children
|
||||
const isEmpty = empty ?? (data !== undefined && data.length === 0)
|
||||
|
||||
const content = columns ? (
|
||||
<>
|
||||
<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>
|
||||
) : (
|
||||
rows
|
||||
)}
|
||||
</TableBody>
|
||||
</>
|
||||
) : (
|
||||
rows
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(staticDataTableClassNames.container, className)}
|
||||
{...containerProps}
|
||||
>
|
||||
<Table className={tableClassName} {...tableProps}>
|
||||
{children}
|
||||
{content}
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
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 debouncedValue = useDebounce(inputValue, delay)
|
||||
|
||||
React.useEffect(() => {
|
||||
// Keep the input aligned when URL state changes outside the local field.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setInputValue(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])
|
||||
|
||||
return {
|
||||
value,
|
||||
inputValue,
|
||||
setInputValue,
|
||||
}
|
||||
}
|
||||
+16
-29
@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getRouteApi } from '@tanstack/react-router'
|
||||
import {
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
type SortingState,
|
||||
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'
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
DISABLED_ROW_DESKTOP,
|
||||
DISABLED_ROW_MOBILE,
|
||||
DataTablePage,
|
||||
useDebouncedColumnFilter,
|
||||
useDataTable,
|
||||
} from '@/components/data-table'
|
||||
import { getChannels, searchChannels, getGroups } from '../api'
|
||||
@@ -106,35 +107,21 @@ 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 [modelFilterInput, setModelFilterInput] = useState(modelFilterFromUrl)
|
||||
const debouncedModelFilter = useDebounce(modelFilterInput, 500)
|
||||
|
||||
// Sync local input with URL when URL changes (e.g., from back/forward navigation)
|
||||
useEffect(() => {
|
||||
setModelFilterInput(modelFilterFromUrl)
|
||||
}, [modelFilterFromUrl])
|
||||
|
||||
// Update URL when debounced value changes
|
||||
useEffect(() => {
|
||||
if (debouncedModelFilter !== modelFilterFromUrl) {
|
||||
onColumnFiltersChange((prev) => {
|
||||
const filtered = prev.filter((f) => f.id !== 'model')
|
||||
return debouncedModelFilter
|
||||
? [...filtered, { id: 'model', value: debouncedModelFilter }]
|
||||
: filtered
|
||||
})
|
||||
}
|
||||
}, [debouncedModelFilter, modelFilterFromUrl, onColumnFiltersChange])
|
||||
|
||||
const modelFilter = modelFilterFromUrl
|
||||
const {
|
||||
value: modelFilter,
|
||||
inputValue: modelFilterInput,
|
||||
setInputValue: setModelFilterInput,
|
||||
} = useDebouncedColumnFilter({
|
||||
columnFilters,
|
||||
columnId: 'model',
|
||||
onColumnFiltersChange,
|
||||
})
|
||||
|
||||
// Determine whether to use search or regular list API
|
||||
const shouldSearch = Boolean(globalFilter?.trim() || modelFilter.trim())
|
||||
|
||||
+44
-45
@@ -32,13 +32,6 @@ import {
|
||||
} from '@/components/ui/select'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import {
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/data-table'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
@@ -361,44 +354,50 @@ export function MultiKeyManageDialog({
|
||||
<StaticDataTable
|
||||
className='rounded-none border-0'
|
||||
tableClassName='min-w-[800px]'
|
||||
>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className='w-20'>{t('Index')}</TableHead>
|
||||
<TableHead className='w-32'>{t('Status')}</TableHead>
|
||||
<TableHead className='min-w-[200px]'>
|
||||
{t('Disabled Reason')}
|
||||
</TableHead>
|
||||
<TableHead className='w-44'>{t('Disabled Time')}</TableHead>
|
||||
<TableHead className='w-44 text-right'>
|
||||
{t('Actions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{keys.map((key) => (
|
||||
<TableRow key={key.index}>
|
||||
<TableCell className='font-mono text-sm'>
|
||||
#{key.index + 1}
|
||||
</TableCell>
|
||||
<TableCell>{renderStatusBadge(key.status)}</TableCell>
|
||||
<TableCell className='max-w-xs truncate text-sm'>
|
||||
{key.reason || '-'}
|
||||
</TableCell>
|
||||
<TableCell className='text-muted-foreground text-sm'>
|
||||
{formatKeyTimestamp(key.disabled_time)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MultiKeyTableRowActions
|
||||
keyIndex={key.index}
|
||||
status={key.status}
|
||||
onAction={setConfirmAction}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</StaticDataTable>
|
||||
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>
|
||||
|
||||
|
||||
+10
-23
@@ -16,11 +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 Table as TanstackTable } from '@tanstack/react-table'
|
||||
import { useDebounce } from '@/hooks'
|
||||
import { Database } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
@@ -40,6 +38,7 @@ import {
|
||||
DISABLED_ROW_DESKTOP,
|
||||
DISABLED_ROW_MOBILE,
|
||||
DataTablePage,
|
||||
useDebouncedColumnFilter,
|
||||
useDataTable,
|
||||
} from '@/components/data-table'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
@@ -203,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
|
||||
|
||||
+78
-72
@@ -47,13 +47,6 @@ import {
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/empty'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import {
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/data-table'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
@@ -344,46 +337,53 @@ export function PrefillGroupManagementDialog({
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<StaticDataTable tableClassName='min-w-[680px]'>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Group')}</TableHead>
|
||||
<TableHead>{t('Type')}</TableHead>
|
||||
<TableHead className='min-w-[240px]'>{t('Items')}</TableHead>
|
||||
<TableHead className='w-[120px] text-right'>
|
||||
{t('Actions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{normalizedGroups.map(({ group, meta, parsedItems }) => (
|
||||
<TableRow key={group.id}>
|
||||
<TableCell className='align-top whitespace-normal'>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<span className='font-medium'>{group.name}</span>
|
||||
<TableId value={group.id} />
|
||||
</div>
|
||||
{group.description ? (
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{group.description}
|
||||
</p>
|
||||
) : (
|
||||
<p className='text-muted-foreground text-xs italic'>
|
||||
No description provided
|
||||
</p>
|
||||
)}
|
||||
<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>
|
||||
</TableCell>
|
||||
<TableCell className='align-top'>
|
||||
<StatusBadge
|
||||
label={meta.label}
|
||||
variant={meta.badge}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className='align-top whitespace-normal'>
|
||||
{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 ? (
|
||||
<>
|
||||
@@ -416,32 +416,38 @@ export function PrefillGroupManagementDialog({
|
||||
{parsedItems.length} item
|
||||
{parsedItems.length === 1 ? '' : 's'}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='align-top'>
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button
|
||||
size='icon'
|
||||
variant='outline'
|
||||
onClick={() => onEditGroup(group)}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
<span className='sr-only'>Edit group</span>
|
||||
</Button>
|
||||
<Button
|
||||
size='icon'
|
||||
variant='ghost'
|
||||
className='text-destructive hover:text-destructive'
|
||||
onClick={() => handleDeleteClick(group)}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
<span className='sr-only'>Delete group</span>
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</StaticDataTable>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
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>
|
||||
|
||||
+52
-63
@@ -22,13 +22,6 @@ import { useTranslation } from 'react-i18next'
|
||||
import { useSystemConfigStore } from '@/stores/system-config-store'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/data-table'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import {
|
||||
BILLING_PRICING_VARS,
|
||||
@@ -310,37 +303,34 @@ export function DynamicPricingBreakdown({
|
||||
<StaticDataTable
|
||||
className='hidden rounded-none border-0 sm:block'
|
||||
tableClassName='text-sm'
|
||||
>
|
||||
<TableHeader>
|
||||
<TableRow className='hover:bg-transparent'>
|
||||
<TableHead className='text-muted-foreground py-2 font-medium'>
|
||||
{t('Tier')}
|
||||
</TableHead>
|
||||
{visiblePriceFields.map((v) => (
|
||||
<TableHead
|
||||
key={v.field}
|
||||
className='text-muted-foreground py-2 text-right font-medium'
|
||||
>
|
||||
{t(v.shortLabel)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tiers.map((tier, i) => {
|
||||
const condSummary = formatConditionSummary(tier.conditions, t)
|
||||
const isMatched =
|
||||
normalizedMatchedTierLabel !== '' &&
|
||||
normalizeTierLabel(tier.label) === normalizedMatchedTierLabel
|
||||
return (
|
||||
<TableRow
|
||||
key={`tier-${i}`}
|
||||
className={cn(
|
||||
isMatched &&
|
||||
'bg-emerald-50/70 hover:bg-emerald-50/70 dark:bg-emerald-500/10 dark:hover:bg-emerald-500/10'
|
||||
)}
|
||||
>
|
||||
<TableCell className='py-2.5 align-top'>
|
||||
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
|
||||
return (
|
||||
<>
|
||||
<div className='flex flex-wrap items-center gap-1.5'>
|
||||
<Badge
|
||||
variant='secondary'
|
||||
@@ -362,31 +352,30 @@ export function DynamicPricingBreakdown({
|
||||
{condSummary}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
{visiblePriceFields.map((v) => {
|
||||
const value = Number(
|
||||
tier[v.field as string as keyof ParsedTier] || 0
|
||||
)
|
||||
return (
|
||||
<TableCell
|
||||
key={v.field}
|
||||
className='py-2.5 text-right align-top font-mono'
|
||||
>
|
||||
{value > 0 ? (
|
||||
<span className='font-semibold'>
|
||||
{`${symbol}${(value * rate).toFixed(4)}`}
|
||||
</span>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</StaticDataTable>
|
||||
</>
|
||||
)
|
||||
},
|
||||
},
|
||||
...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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -37,13 +37,6 @@ import {
|
||||
CodeBlock,
|
||||
CodeBlockCopyButton,
|
||||
} from '@/components/ai-elements/code-block'
|
||||
import {
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/data-table'
|
||||
import {
|
||||
StaticDataTable,
|
||||
staticDataTableClassNames as tableStyles,
|
||||
@@ -573,51 +566,62 @@ function SupportedParametersSection(props: { model: PricingModel }) {
|
||||
return (
|
||||
<section>
|
||||
<SectionTitle icon={Sigma}>{t('Supported parameters')}</SectionTitle>
|
||||
<StaticDataTable className={tableStyles.sectionContainer}>
|
||||
<TableHeader>
|
||||
<TableRow className={tableStyles.mutedHeaderRow}>
|
||||
<TableHead className='h-9 w-44'>{t('Parameter')}</TableHead>
|
||||
<TableHead className='h-9 w-24'>{t('Type')}</TableHead>
|
||||
<TableHead className='h-9 w-32'>{t('Default / range')}</TableHead>
|
||||
<TableHead className='h-9'>{t('Description')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{params.map((p) => (
|
||||
<TableRow key={p.name} className='hover:bg-muted/20'>
|
||||
<TableCell className={tableStyles.topCell}>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<code className='font-mono text-sm font-medium'>
|
||||
{p.name}
|
||||
</code>
|
||||
{p.required && (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='h-6 border-rose-500/40 px-2 text-sm text-rose-600 dark:text-rose-400'
|
||||
>
|
||||
{t('required')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className={tableStyles.topCell}>
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='h-7 rounded-full px-2.5 font-mono text-sm font-normal'
|
||||
>
|
||||
{p.type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className={tableStyles.topCell}>
|
||||
<ParamRangeCell param={p} />
|
||||
</TableCell>
|
||||
<TableCell className={tableStyles.topMutedCell}>
|
||||
{t(p.descriptionKey)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</StaticDataTable>
|
||||
<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='outline'
|
||||
className='h-6 border-rose-500/40 px-2 text-sm text-rose-600 dark:text-rose-400'
|
||||
>
|
||||
{t('required')}
|
||||
</Badge>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -672,32 +676,43 @@ function RateLimitsSection(props: { model: PricingModel }) {
|
||||
return (
|
||||
<section>
|
||||
<SectionTitle icon={Gauge}>{t('Rate limits')}</SectionTitle>
|
||||
<StaticDataTable className={tableStyles.sectionContainer}>
|
||||
<TableHeader>
|
||||
<TableRow className={tableStyles.mutedHeaderRow}>
|
||||
<TableHead className='h-9'>{t('Group')}</TableHead>
|
||||
<TableHead className='h-9 text-right'>RPM</TableHead>
|
||||
<TableHead className='h-9 text-right'>TPM</TableHead>
|
||||
<TableHead className='h-9 text-right'>RPD</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{limits.map((l) => (
|
||||
<TableRow key={l.group} className='hover:bg-muted/20'>
|
||||
<TableCell className='py-2 font-mono'>{l.group}</TableCell>
|
||||
<TableCell className={tableStyles.topNumericCell}>
|
||||
{formatRateLimit(l.rpm)}
|
||||
</TableCell>
|
||||
<TableCell className={tableStyles.topNumericCell}>
|
||||
{formatRateLimit(l.tpm)}
|
||||
</TableCell>
|
||||
<TableCell className={tableStyles.topNumericCell}>
|
||||
{formatRateLimit(l.rpd)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</StaticDataTable>
|
||||
<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.'
|
||||
|
||||
@@ -25,13 +25,6 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/data-table'
|
||||
import {
|
||||
StaticDataTable,
|
||||
staticDataTableClassNames as tableStyles,
|
||||
@@ -165,72 +158,70 @@ export function ModelDetailsApps(props: { model: PricingModel }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StaticDataTable className='rounded-lg' tableClassName='text-sm'>
|
||||
<TableHeader>
|
||||
<TableRow className={tableStyles.compactHeaderRow}>
|
||||
<TableHead className={cn(tableStyles.compactHeaderCell, 'w-12')}>
|
||||
#
|
||||
</TableHead>
|
||||
<TableHead className={tableStyles.compactHeaderCell}>
|
||||
{t('App')}
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className={cn(
|
||||
tableStyles.compactHeaderCell,
|
||||
'hidden md:table-cell'
|
||||
)}
|
||||
>
|
||||
{t('Category')}
|
||||
</TableHead>
|
||||
<TableHead className={tableStyles.compactHeaderCellRight}>
|
||||
{t('Monthly tokens')}
|
||||
</TableHead>
|
||||
<TableHead className={tableStyles.compactHeaderCellRight}>
|
||||
{t('30d change')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{apps.map((app) => (
|
||||
<TableRow key={`${app.rank}-${app.name}`}>
|
||||
<TableCell className={tableStyles.compactCell}>
|
||||
<RankBadge rank={app.rank} />
|
||||
</TableCell>
|
||||
<TableCell className={tableStyles.compactCell}>
|
||||
<div className='flex items-center gap-3'>
|
||||
<span className='bg-muted text-muted-foreground inline-flex size-7 shrink-0 items-center justify-center rounded-md font-bold'>
|
||||
{app.initial}
|
||||
</span>
|
||||
<div className='min-w-0'>
|
||||
<div className='text-sm font-medium'>
|
||||
<AppLink app={app} />
|
||||
</div>
|
||||
<p className='text-muted-foreground line-clamp-1 text-sm'>
|
||||
{app.description}
|
||||
</p>
|
||||
<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>
|
||||
<p className='text-muted-foreground line-clamp-1 text-sm'>
|
||||
{app.description}
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={cn(
|
||||
tableStyles.compactMutedCell,
|
||||
'hidden md:table-cell'
|
||||
)}
|
||||
>
|
||||
{app.category}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={cn(tableStyles.compactNumericCell, 'tabular-nums')}
|
||||
>
|
||||
{formatTokenVolume(app.monthly_tokens)}
|
||||
</TableCell>
|
||||
<TableCell className={cn(tableStyles.compactCell, 'text-right')}>
|
||||
<GrowthChip value={app.growth_pct} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</StaticDataTable>
|
||||
</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,13 +30,6 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import {
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/data-table'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import type { Modality } from '../types'
|
||||
|
||||
@@ -103,77 +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>) => (
|
||||
<TableRow>
|
||||
<TableHead
|
||||
scope='row'
|
||||
className='text-muted-foreground bg-muted/30 px-3 py-2 text-left text-[11px] font-medium tracking-wider uppercase'
|
||||
>
|
||||
{label}
|
||||
</TableHead>
|
||||
{ALL_MODALITIES.map((modality) => {
|
||||
const enabled = set.has(modality)
|
||||
const Icon = MODALITY_META[modality].icon
|
||||
return (
|
||||
<TableCell
|
||||
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>
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
)
|
||||
|
||||
return (
|
||||
<StaticDataTable className='rounded-lg' tableClassName='text-sm'>
|
||||
<TableHeader>
|
||||
<TableRow className='bg-muted/40'>
|
||||
<TableHead
|
||||
scope='col'
|
||||
className='text-muted-foreground px-3 py-2 text-left text-[11px] font-medium tracking-wider uppercase'
|
||||
>
|
||||
{t('Modality')}
|
||||
</TableHead>
|
||||
{ALL_MODALITIES.map((modality) => (
|
||||
<TableHead
|
||||
key={modality}
|
||||
scope='col'
|
||||
className='text-muted-foreground border-l px-3 py-2 text-center text-[11px] font-medium tracking-wider uppercase'
|
||||
>
|
||||
{t(MODALITY_META[modality].labelKey)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{renderRow(t('Input'), inputSet)}
|
||||
{renderRow(t('Output'), outputSet)}
|
||||
</TableBody>
|
||||
</StaticDataTable>
|
||||
),
|
||||
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),
|
||||
})
|
||||
}
|
||||
>
|
||||
<Icon className='size-4' />
|
||||
</span>
|
||||
)
|
||||
},
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
+49
-54
@@ -21,13 +21,6 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import { AlertTriangle, HeartPulse, Timer } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/data-table'
|
||||
import {
|
||||
StaticDataTable,
|
||||
staticDataTableClassNames as tableStyles,
|
||||
@@ -256,53 +249,55 @@ export function ModelDetailsPerformance(props: { model: PricingModel }) {
|
||||
title={t('Per-group performance')}
|
||||
description={t('Average latency, TTFT, TPS, and success rate')}
|
||||
/>
|
||||
<StaticDataTable className='rounded-lg' tableClassName='text-sm'>
|
||||
<TableHeader>
|
||||
<TableRow className={tableStyles.compactHeaderRow}>
|
||||
<TableHead className={tableStyles.compactHeaderCell}>
|
||||
{t('Group')}
|
||||
</TableHead>
|
||||
<TableHead className={tableStyles.compactHeaderCellRight}>
|
||||
TPS
|
||||
</TableHead>
|
||||
<TableHead className={tableStyles.compactHeaderCellRight}>
|
||||
{t('Average TTFT')}
|
||||
</TableHead>
|
||||
<TableHead className={tableStyles.compactHeaderCellRight}>
|
||||
{t('Average latency')}
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className={cn(tableStyles.compactHeaderCell, 'min-w-[180px]')}
|
||||
>
|
||||
{t('Success rate')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{performances.map((perf) => (
|
||||
<TableRow key={perf.group}>
|
||||
<TableCell className={tableStyles.compactCell}>
|
||||
<GroupBadge group={perf.group} size='sm' />
|
||||
</TableCell>
|
||||
<TableCell className={tableStyles.compactNumericCell}>
|
||||
{formatThroughput(perf.avg_tps)}
|
||||
</TableCell>
|
||||
<TableCell className={tableStyles.compactNumericCell}>
|
||||
{formatLatency(perf.avg_ttft_ms)}
|
||||
</TableCell>
|
||||
<TableCell className={tableStyles.compactMutedNumericCell}>
|
||||
{formatLatency(perf.avg_latency_ms)}
|
||||
</TableCell>
|
||||
<TableCell className={tableStyles.compactCell}>
|
||||
<UptimeSparkline
|
||||
size='sm'
|
||||
series={uptimeByGroup[perf.group] ?? []}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</StaticDataTable>
|
||||
<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>
|
||||
|
||||
+119
-147
@@ -34,13 +34,6 @@ import {
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { CopyButton } from '@/components/copy-button'
|
||||
import {
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/data-table'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import { sideDrawerContentClassName } from '@/components/drawer-layout'
|
||||
import { GroupBadge } from '@/components/group-badge'
|
||||
@@ -708,54 +701,40 @@ function GroupPricingSection(props: {
|
||||
<StaticDataTable
|
||||
className='rounded-none border-0'
|
||||
tableClassName='text-sm'
|
||||
>
|
||||
<TableHeader>
|
||||
<TableRow className='hover:bg-transparent'>
|
||||
<TableHead className={thClass}>{t('Tier')}</TableHead>
|
||||
{priceFields.map((entry) => (
|
||||
<TableHead
|
||||
key={entry.field}
|
||||
className={`${thClass} text-right`}
|
||||
>
|
||||
{t(entry.shortLabel)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{dynamicTiers.map((tier, tierIndex) => {
|
||||
const entries = getDynamicPriceEntries(tier, {
|
||||
tokenUnit: props.tokenUnit,
|
||||
showRechargePrice,
|
||||
priceRate: props.priceRate,
|
||||
usdExchangeRate: props.usdExchangeRate,
|
||||
groupRatioMultiplier: ratio,
|
||||
})
|
||||
const entryMap = new Map(
|
||||
entries.map((entry) => [entry.field, entry])
|
||||
)
|
||||
|
||||
return (
|
||||
<TableRow key={`${group}-${tier.label || tierIndex}`}>
|
||||
<TableCell className='text-muted-foreground py-2.5'>
|
||||
{tier.label || t('Default')}
|
||||
</TableCell>
|
||||
{priceFields.map((fieldEntry) => {
|
||||
const entry = entryMap.get(fieldEntry.field)
|
||||
return (
|
||||
<TableCell
|
||||
key={fieldEntry.field}
|
||||
className='py-2.5 text-right font-mono'
|
||||
>
|
||||
{entry?.formatted ?? '-'}
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</StaticDataTable>
|
||||
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]) => {
|
||||
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 entryMap.get(fieldEntry.field)?.formatted ?? '-'
|
||||
},
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -774,104 +753,97 @@ function GroupPricingSection(props: {
|
||||
<StaticDataTable
|
||||
className='-mx-4 rounded-none border-0 sm:mx-0'
|
||||
tableClassName='text-sm'
|
||||
>
|
||||
<TableHeader>
|
||||
<TableRow className='hover:bg-transparent'>
|
||||
<TableHead className={thClass}>{t('Group')}</TableHead>
|
||||
<TableHead className={thClass}>{t('Ratio')}</TableHead>
|
||||
{isTokenBased ? (
|
||||
<>
|
||||
<TableHead className={`${thClass} text-right`}>
|
||||
{t('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(
|
||||
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) =>
|
||||
formatGroupPrice(
|
||||
props.model,
|
||||
group,
|
||||
'input',
|
||||
props.tokenUnit,
|
||||
showRechargePrice,
|
||||
props.priceRate,
|
||||
props.usdExchangeRate,
|
||||
props.groupRatio
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'output',
|
||||
header: t('Output'),
|
||||
className: `${thClass} text-right`,
|
||||
cellClassName: 'py-2.5 text-right font-mono',
|
||||
cell: (group: string) =>
|
||||
formatGroupPrice(
|
||||
props.model,
|
||||
group,
|
||||
'output',
|
||||
props.tokenUnit,
|
||||
showRechargePrice,
|
||||
props.priceRate,
|
||||
props.usdExchangeRate,
|
||||
props.groupRatio
|
||||
),
|
||||
},
|
||||
...extraPriceTypes.map((ep) => ({
|
||||
id: ep.type,
|
||||
header: ep.label,
|
||||
className: `${thClass} text-right`,
|
||||
cellClassName: 'py-2.5 text-right font-mono',
|
||||
cell: (group: string) =>
|
||||
formatGroupPrice(
|
||||
props.model,
|
||||
group,
|
||||
ep.type,
|
||||
props.tokenUnit,
|
||||
showRechargePrice,
|
||||
props.priceRate,
|
||||
props.usdExchangeRate,
|
||||
props.groupRatio
|
||||
),
|
||||
})),
|
||||
]
|
||||
: [
|
||||
{
|
||||
id: 'price',
|
||||
header: t('Price'),
|
||||
className: `${thClass} text-right`,
|
||||
cellClassName: 'py-2.5 text-right font-mono',
|
||||
cell: (group: string) =>
|
||||
formatFixedPrice(
|
||||
props.model,
|
||||
group,
|
||||
showRechargePrice,
|
||||
props.priceRate,
|
||||
props.usdExchangeRate,
|
||||
props.groupRatio
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</StaticDataTable>
|
||||
),
|
||||
},
|
||||
]),
|
||||
]}
|
||||
/>
|
||||
<div className='-mx-4 sm:mx-0'>
|
||||
{isTokenBased && (
|
||||
<p className='text-muted-foreground/40 mt-1.5 px-4 text-[10px] sm:px-0'>
|
||||
|
||||
+107
-103
@@ -37,17 +37,7 @@ import {
|
||||
SheetDescription,
|
||||
} from '@/components/ui/sheet'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import {
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/data-table'
|
||||
import {
|
||||
StaticDataTable,
|
||||
StaticDataTableEmptyRow,
|
||||
} from '@/components/data-table'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import {
|
||||
sideDrawerContentClassName,
|
||||
sideDrawerFormClassName,
|
||||
@@ -248,106 +238,120 @@ export function UserSubscriptionsDialog(props: Props) {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<StaticDataTable>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>{t('Plan')}</TableHead>
|
||||
<TableHead>{t('Status')}</TableHead>
|
||||
<TableHead>{t('Validity')}</TableHead>
|
||||
<TableHead>{t('Total Quota')}</TableHead>
|
||||
<TableHead className='text-right'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<StaticDataTableEmptyRow colSpan={6} className='py-8'>
|
||||
{t('Loading...')}
|
||||
</StaticDataTableEmptyRow>
|
||||
) : subs.length === 0 ? (
|
||||
<StaticDataTableEmptyRow
|
||||
colSpan={6}
|
||||
className='text-muted-foreground py-8'
|
||||
>
|
||||
{t('No subscription records')}
|
||||
</StaticDataTableEmptyRow>
|
||||
) : (
|
||||
subs.map((record) => {
|
||||
<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: 'ID',
|
||||
cell: (record) => (
|
||||
<TableId value={record.subscription.id} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'plan',
|
||||
header: t('Plan'),
|
||||
cell: (record) => {
|
||||
const sub = record.subscription
|
||||
|
||||
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
|
||||
const total = Number(sub.amount_total || 0)
|
||||
const used = Number(sub.amount_used || 0)
|
||||
|
||||
return (
|
||||
<TableRow key={sub.id}>
|
||||
<TableCell>
|
||||
<TableId value={sub.id} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<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>
|
||||
<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>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</StaticDataTable>
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
+76
-74
@@ -21,13 +21,6 @@ import { Pencil, Trash2, Plus } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import {
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/data-table'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { useDeleteProvider } from '../hooks/use-custom-oauth-mutations'
|
||||
@@ -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>
|
||||
) : (
|
||||
<StaticDataTable>
|
||||
<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>
|
||||
</StaticDataTable>
|
||||
)}
|
||||
<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}
|
||||
|
||||
+90
-101
@@ -55,17 +55,7 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/data-table'
|
||||
import {
|
||||
StaticDataTable,
|
||||
StaticDataTableEmptyRow,
|
||||
} from '@/components/data-table'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import { DateTimePicker } from '@/components/datetime-picker'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
@@ -353,10 +343,16 @@ export function AnnouncementsSection({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<StaticDataTable>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className='w-12'>
|
||||
<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 &&
|
||||
@@ -364,94 +360,87 @@ export function AnnouncementsSection({
|
||||
}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>{t('Content')}</TableHead>
|
||||
<TableHead>{t('Publish Date')}</TableHead>
|
||||
<TableHead>{t('Type')}</TableHead>
|
||||
<TableHead>{t('Extra')}</TableHead>
|
||||
<TableHead className='w-32'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedAnnouncements.length === 0 ? (
|
||||
<StaticDataTableEmptyRow colSpan={6}>
|
||||
{t(
|
||||
'No announcements yet. Click "Add Announcement" to create one.'
|
||||
)}
|
||||
</StaticDataTableEmptyRow>
|
||||
) : (
|
||||
sortedAnnouncements.map((announcement) => (
|
||||
<TableRow key={announcement.id}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(announcement.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleSelectOne(announcement.id, checked as boolean)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className='max-w-xs truncate'
|
||||
title={announcement.content}
|
||||
),
|
||||
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'
|
||||
)}
|
||||
</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'
|
||||
>
|
||||
{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}
|
||||
<Edit className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleDelete(announcement)}
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
>
|
||||
{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>
|
||||
</StaticDataTable>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
|
||||
@@ -54,17 +54,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/data-table'
|
||||
import {
|
||||
StaticDataTable,
|
||||
StaticDataTableEmptyRow,
|
||||
} from '@/components/data-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'
|
||||
@@ -309,10 +299,14 @@ export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<StaticDataTable>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className='w-12'>
|
||||
<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 &&
|
||||
@@ -320,86 +314,83 @@ export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) {
|
||||
}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>{t('URL')}</TableHead>
|
||||
<TableHead>{t('Route')}</TableHead>
|
||||
<TableHead>{t('Description')}</TableHead>
|
||||
<TableHead>{t('Color')}</TableHead>
|
||||
<TableHead className='w-32'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{apiInfoList.length === 0 ? (
|
||||
<StaticDataTableEmptyRow colSpan={6}>
|
||||
{t('No API Domains yet. Click "Add API" to create one.')}
|
||||
</StaticDataTableEmptyRow>
|
||||
) : (
|
||||
apiInfoList.map((apiInfo) => (
|
||||
<TableRow key={apiInfo.id}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(apiInfo.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleSelectOne(apiInfo.id, checked as boolean)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className='max-w-xs truncate font-mono text-sm'
|
||||
title={apiInfo.url}
|
||||
),
|
||||
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)}`}
|
||||
/>
|
||||
<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'
|
||||
>
|
||||
<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}
|
||||
<Edit className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleDelete(apiInfo)}
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
>
|
||||
{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>
|
||||
</StaticDataTable>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
|
||||
+46
-51
@@ -21,13 +21,6 @@ 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 {
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/data-table'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import { safeJsonParseWithValidation } from '../utils/json-parser'
|
||||
import { isArray } from '../utils/json-validators'
|
||||
@@ -149,53 +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>
|
||||
) : (
|
||||
<StaticDataTable>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Chat Client Name')}</TableHead>
|
||||
<TableHead>{t('URL')}</TableHead>
|
||||
<TableHead className='text-right'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredChats.map((chat) => (
|
||||
<TableRow key={chat.name}>
|
||||
<TableCell className='font-medium'>{chat.name}</TableCell>
|
||||
<TableCell className='max-w-md truncate font-mono text-sm'>
|
||||
{chat.url}
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => handleEdit(chat)}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => handleDelete(chat.name)}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</StaticDataTable>
|
||||
)}
|
||||
)
|
||||
}
|
||||
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}
|
||||
|
||||
@@ -46,17 +46,7 @@ import {
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/data-table'
|
||||
import {
|
||||
StaticDataTable,
|
||||
StaticDataTableEmptyRow,
|
||||
} from '@/components/data-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'
|
||||
@@ -272,73 +262,68 @@ export function FAQSection({ enabled, data }: FAQSectionProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<StaticDataTable>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className='w-12'>
|
||||
<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}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>{t('Question')}</TableHead>
|
||||
<TableHead>{t('Answer')}</TableHead>
|
||||
<TableHead className='w-32'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{faqList.length === 0 ? (
|
||||
<StaticDataTableEmptyRow colSpan={4}>
|
||||
{t('No FAQ entries yet. Click "Add FAQ" to create one.')}
|
||||
</StaticDataTableEmptyRow>
|
||||
) : (
|
||||
faqList.map((faq) => (
|
||||
<TableRow key={faq.id}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(faq.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleSelectOne(faq.id, checked as boolean)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className='max-w-xs truncate font-medium'
|
||||
title={faq.question}
|
||||
),
|
||||
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'
|
||||
>
|
||||
{faq.question}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className='text-muted-foreground max-w-md truncate'
|
||||
title={faq.answer}
|
||||
<Edit className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleDelete(faq)}
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
>
|
||||
{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>
|
||||
</StaticDataTable>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
|
||||
@@ -45,17 +45,7 @@ import {
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/data-table'
|
||||
import {
|
||||
StaticDataTable,
|
||||
StaticDataTableEmptyRow,
|
||||
} from '@/components/data-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'
|
||||
@@ -281,76 +271,77 @@ export function UptimeKumaSection({ enabled, data }: UptimeKumaSectionProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<StaticDataTable>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className='w-12'>
|
||||
<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}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>{t('Category Name')}</TableHead>
|
||||
<TableHead>{t('Uptime Kuma URL')}</TableHead>
|
||||
<TableHead>{t('Status Page Slug')}</TableHead>
|
||||
<TableHead className='w-32'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{groups.length === 0 ? (
|
||||
<StaticDataTableEmptyRow colSpan={5}>
|
||||
{t(
|
||||
'No Uptime Kuma groups yet. Click "Add Group" to create one.'
|
||||
)}
|
||||
</StaticDataTableEmptyRow>
|
||||
) : (
|
||||
groups.map((group) => (
|
||||
<TableRow key={group.id}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(group.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleSelectOne(group.id, checked as boolean)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className='font-medium'>
|
||||
{group.categoryName}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className='text-primary max-w-xs truncate font-mono text-sm'
|
||||
title={group.url}
|
||||
),
|
||||
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'
|
||||
>
|
||||
{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>
|
||||
</StaticDataTable>
|
||||
<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
|
||||
|
||||
+112
-119
@@ -32,17 +32,7 @@ import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/data-table'
|
||||
import {
|
||||
StaticDataTable,
|
||||
StaticDataTableEmptyRow,
|
||||
} from '@/components/data-table'
|
||||
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'
|
||||
@@ -549,114 +539,117 @@ export function ChannelAffinitySection(props: Props) {
|
||||
|
||||
{/* Rules Table or JSON Editor */}
|
||||
{editMode === 'visual' ? (
|
||||
<StaticDataTable tableClassName='min-w-max'>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Name')}</TableHead>
|
||||
<TableHead>{t('Model Regex')}</TableHead>
|
||||
<TableHead>{t('Key Sources')}</TableHead>
|
||||
<TableHead>{t('TTL')}</TableHead>
|
||||
<TableHead>{t('Retry')}</TableHead>
|
||||
<TableHead>{t('Scope')}</TableHead>
|
||||
<TableHead>{t('Cache')}</TableHead>
|
||||
<TableHead className='text-right'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rules.length === 0 ? (
|
||||
<StaticDataTableEmptyRow
|
||||
colSpan={8}
|
||||
className='text-muted-foreground py-8'
|
||||
>
|
||||
{t('No rules yet')}
|
||||
</StaticDataTableEmptyRow>
|
||||
) : (
|
||||
rules.map((rule, idx) => (
|
||||
<TableRow key={idx}>
|
||||
<TableCell className='font-medium'>
|
||||
{rule.name || '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<RuleBadgeList items={rule.model_regex || []} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<RuleBadgeList
|
||||
items={(rule.key_sources || []).map(
|
||||
(src) =>
|
||||
`${src.type}:${src.type === 'gjson' ? src.path : src.key}`
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{rule.ttl_seconds || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge
|
||||
label={
|
||||
rule.skip_retry_on_failure
|
||||
? t('No Retry')
|
||||
: t('Retry')
|
||||
}
|
||||
variant={
|
||||
rule.skip_retry_on_failure ? 'danger' : 'neutral'
|
||||
}
|
||||
copyable={false}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{(() => {
|
||||
const scopeItems = [
|
||||
rule.include_using_group && t('Group'),
|
||||
rule.include_model_name && t('Model'),
|
||||
rule.include_rule_name && t('Rule'),
|
||||
].filter(Boolean) as string[]
|
||||
if (scopeItems.length === 0) return '-'
|
||||
return <RuleBadgeList items={scopeItems} />
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{rule.include_rule_name && cacheStats?.by_rule_name
|
||||
? cacheStats.by_rule_name[rule.name] || 0
|
||||
: 'N/A'}
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<div className='flex justify-end gap-1'>
|
||||
{rule.include_rule_name && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-7 w-7'
|
||||
onClick={() => setClearRuleName(rule.name)}
|
||||
title={t('Clear cache for this rule')}
|
||||
>
|
||||
<X className='h-3 w-3' />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-7 w-7'
|
||||
onClick={() => {
|
||||
setEditingRule(rule)
|
||||
setRuleTemplateKey(null)
|
||||
setRuleEditorOpen(true)
|
||||
}}
|
||||
>
|
||||
<Edit className='h-3 w-3' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-7 w-7'
|
||||
onClick={() => handleDeleteRule(idx)}
|
||||
>
|
||||
<Trash2 className='h-3 w-3' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</StaticDataTable>
|
||||
<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)
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
|
||||
+71
-69
@@ -20,13 +20,6 @@ import { useState, useMemo } from 'react'
|
||||
import { Pencil, Plus, Trash2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/data-table'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { safeJsonParseWithValidation } from '../utils/json-parser'
|
||||
@@ -147,69 +140,78 @@ export function AmountDiscountVisualEditor({
|
||||
) : (
|
||||
<div className='rounded-md border'>
|
||||
{/* Desktop table view */}
|
||||
<StaticDataTable className='hidden rounded-none border-0 sm:block'>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Recharge Amount')}</TableHead>
|
||||
<TableHead>{t('Discount Rate')}</TableHead>
|
||||
<TableHead>{t('Discount')}</TableHead>
|
||||
<TableHead className='text-right'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{discounts.map((discount) => (
|
||||
<TableRow key={discount.amount}>
|
||||
<TableCell>
|
||||
<span className='font-mono text-sm'>
|
||||
${discount.amount}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className='bg-muted rounded px-1.5 py-0.5 text-sm'>
|
||||
{discount.discountRate.toFixed(2)}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge
|
||||
variant={discount.discountRate < 1 ? 'info' : 'neutral'}
|
||||
className='font-mono'
|
||||
copyable={false}
|
||||
<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)
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
</StaticDataTable>
|
||||
<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'>
|
||||
|
||||
+74
-68
@@ -21,13 +21,6 @@ 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 {
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/data-table'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import {
|
||||
formatCreemPrice,
|
||||
@@ -183,67 +176,80 @@ export function CreemProductsVisualEditor({
|
||||
) : (
|
||||
<div className='rounded-md border'>
|
||||
{/* Desktop table view */}
|
||||
<StaticDataTable className='hidden rounded-none border-0 md:block'>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Name')}</TableHead>
|
||||
<TableHead>{t('Product ID')}</TableHead>
|
||||
<TableHead>{t('Price')}</TableHead>
|
||||
<TableHead>{t('Quota')}</TableHead>
|
||||
<TableHead className='text-right'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredProducts.map((product) => (
|
||||
<TableRow key={product.productId}>
|
||||
<TableCell className='font-medium'>{product.name}</TableCell>
|
||||
<TableCell>
|
||||
<code className='bg-muted rounded px-1.5 py-0.5 text-sm'>
|
||||
{product.productId}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className='font-mono text-sm'>
|
||||
{formatCreemPrice(product.price, product.currency)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className='font-mono text-sm'>
|
||||
{formatQuotaShort(product.quota)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleEdit(product)
|
||||
}}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleDelete(product)
|
||||
}}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</StaticDataTable>
|
||||
<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'>
|
||||
|
||||
+89
-81
@@ -27,13 +27,8 @@ import {
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import {
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
StaticDataTable,
|
||||
} from '@/components/data-table'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import { safeJsonParseWithValidation } from '../utils/json-parser'
|
||||
import { isArray } from '../utils/json-validators'
|
||||
import {
|
||||
@@ -291,82 +286,95 @@ export function PaymentMethodsVisualEditor({
|
||||
) : (
|
||||
<div className='rounded-md border'>
|
||||
{/* Desktop table view */}
|
||||
<StaticDataTable className='hidden rounded-none border-0 md:block'>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Name')}</TableHead>
|
||||
<TableHead>{t('Type')}</TableHead>
|
||||
<TableHead>{t('Color')}</TableHead>
|
||||
<TableHead>{t('Min Top-up')}</TableHead>
|
||||
<TableHead className='text-right'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredMethods.map((method, index) => {
|
||||
const colorPreview = getColorPreview(method.color)
|
||||
return (
|
||||
<TableRow key={`${method.type}-${index}`}>
|
||||
<TableCell className='font-medium'>{method.name}</TableCell>
|
||||
<TableCell>
|
||||
<code className='bg-muted rounded px-1.5 py-0.5 text-sm'>
|
||||
{method.type}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='flex items-center gap-2'>
|
||||
{colorPreview && (
|
||||
<div
|
||||
className='size-5 shrink-0 rounded border'
|
||||
style={{ backgroundColor: colorPreview }}
|
||||
/>
|
||||
)}
|
||||
<span className='text-muted-foreground truncate font-mono text-sm'>
|
||||
{method.color}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{method.min_topup ? (
|
||||
<span className='font-mono text-sm'>
|
||||
{method.min_topup}
|
||||
</span>
|
||||
) : (
|
||||
<span className='text-muted-foreground text-sm'>—</span>
|
||||
<StaticDataTable
|
||||
className='hidden rounded-none border-0 md:block'
|
||||
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 (
|
||||
<div className='flex items-center gap-2'>
|
||||
{colorPreview && (
|
||||
<div
|
||||
className='size-5 shrink-0 rounded border'
|
||||
style={{ backgroundColor: colorPreview }}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleEdit(method)
|
||||
}}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleDelete(method)
|
||||
}}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</StaticDataTable>
|
||||
<span className='text-muted-foreground truncate font-mono text-sm'>
|
||||
{method.color}
|
||||
</span>
|
||||
</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'>
|
||||
|
||||
+69
-77
@@ -26,17 +26,7 @@ import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/data-table'
|
||||
import {
|
||||
StaticDataTable,
|
||||
StaticDataTableEmptyRow,
|
||||
} from '@/components/data-table'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { SettingsSwitchField } from '../components/settings-form-layout'
|
||||
|
||||
@@ -336,72 +326,74 @@ export function WaffoSettingsSection({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<StaticDataTable>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Display name')}</TableHead>
|
||||
<TableHead>{t('Icon')}</TableHead>
|
||||
<TableHead>{t('Payment method type')}</TableHead>
|
||||
<TableHead>{t('Payment method name')}</TableHead>
|
||||
<TableHead className='text-right'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{payMethods.length === 0 ? (
|
||||
<StaticDataTableEmptyRow
|
||||
colSpan={5}
|
||||
className='text-muted-foreground py-8'
|
||||
>
|
||||
{t('No payment methods configured')}
|
||||
</StaticDataTableEmptyRow>
|
||||
) : (
|
||||
payMethods.map((m, idx) => (
|
||||
<TableRow key={idx}>
|
||||
<TableCell>{m.name}</TableCell>
|
||||
<TableCell>
|
||||
{m.icon ? (
|
||||
<img
|
||||
src={m.icon}
|
||||
alt={m.name}
|
||||
className='h-6 w-6 rounded object-contain'
|
||||
/>
|
||||
) : (
|
||||
<span className='text-muted-foreground'>-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{m.payMethodType || '-'}</TableCell>
|
||||
<TableCell>{m.payMethodName || '-'}</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<div className='flex justify-end gap-1'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-7 w-7'
|
||||
onClick={() => openEdit(idx)}
|
||||
>
|
||||
<Pencil className='h-3 w-3' />
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-7 w-7'
|
||||
onClick={() =>
|
||||
onPayMethodsChange((prev) =>
|
||||
prev.filter((_, i) => i !== idx)
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 className='h-3 w-3' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</StaticDataTable>
|
||||
<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)}
|
||||
>
|
||||
<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
|
||||
|
||||
+31
-32
@@ -28,13 +28,8 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
StaticDataTable,
|
||||
} from '@/components/data-table'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
|
||||
export type ConflictItem = {
|
||||
channel: string
|
||||
@@ -71,38 +66,42 @@ export function ConflictConfirmDialog({
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<StaticDataTable className='max-h-96 overflow-y-auto'>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Channel')}</TableHead>
|
||||
<TableHead>{t('Model')}</TableHead>
|
||||
<TableHead>{t('Current Billing')}</TableHead>
|
||||
<TableHead>{t('Change To')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{conflicts.map((conflict, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell className='font-medium'>
|
||||
{conflict.channel}
|
||||
</TableCell>
|
||||
<TableCell className='font-mono text-sm'>
|
||||
{conflict.model}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<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>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'new',
|
||||
header: t('Change To'),
|
||||
cell: (conflict) => (
|
||||
<pre className='text-sm whitespace-pre-wrap'>
|
||||
{conflict.newVal}
|
||||
</pre>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</StaticDataTable>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isLoading}>
|
||||
|
||||
+191
-195
@@ -35,17 +35,7 @@ import {
|
||||
} from '@/components/ui/collapsible'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/data-table'
|
||||
import {
|
||||
StaticDataTable,
|
||||
StaticDataTableEmptyRow,
|
||||
} from '@/components/data-table'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { safeJsonParse } from '../utils/json-parser'
|
||||
|
||||
@@ -430,47 +420,51 @@ export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({
|
||||
{t('Add group')}
|
||||
</Button>
|
||||
{topupRatioList.length > 0 && (
|
||||
<StaticDataTable>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Group name')}</TableHead>
|
||||
<TableHead>{t('Multiplier')}</TableHead>
|
||||
<TableHead className='text-right'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{topupRatioList.map((group) => (
|
||||
<TableRow key={group.name}>
|
||||
<TableCell className='font-medium'>
|
||||
{group.name}
|
||||
</TableCell>
|
||||
<TableCell>{group.value}</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
handleSimpleEdit('topupGroupRatio', group)
|
||||
}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
handleSimpleDelete('topupGroupRatio', group.name)
|
||||
}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</StaticDataTable>
|
||||
<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>
|
||||
@@ -537,55 +531,58 @@ export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({
|
||||
<CollapsibleContent>
|
||||
{userGroupData.overrides.length > 0 && (
|
||||
<div className='border-t'>
|
||||
<StaticDataTable className='rounded-none border-0'>
|
||||
<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>
|
||||
</StaticDataTable>
|
||||
<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>
|
||||
@@ -854,100 +851,99 @@ function GroupPricingTable({
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='space-y-3'>
|
||||
<StaticDataTable>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className='min-w-40'>{t('Group name')}</TableHead>
|
||||
<TableHead className='w-28'>{t('Ratio')}</TableHead>
|
||||
<TableHead className='w-28 text-center'>
|
||||
{t('User selectable')}
|
||||
</TableHead>
|
||||
<TableHead className='min-w-56'>{t('Description')}</TableHead>
|
||||
<TableHead className='w-16 text-right'>
|
||||
{t('Actions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.length === 0 ? (
|
||||
<StaticDataTableEmptyRow
|
||||
colSpan={5}
|
||||
className='text-muted-foreground h-20 text-sm'
|
||||
>
|
||||
{t('No groups yet. Add a group to get started.')}
|
||||
</StaticDataTableEmptyRow>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<TableRow key={row._id}>
|
||||
<TableCell>
|
||||
<Input
|
||||
value={row.name}
|
||||
onChange={(event) =>
|
||||
updateRow(row._id, 'name', event.target.value)
|
||||
}
|
||||
aria-invalid={duplicateNames.includes(row.name.trim())}
|
||||
/>
|
||||
</TableCell>
|
||||
<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>
|
||||
</StaticDataTable>
|
||||
<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'>
|
||||
|
||||
@@ -24,17 +24,7 @@ import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/data-table'
|
||||
import {
|
||||
StaticDataTable,
|
||||
StaticDataTableEmptyRow,
|
||||
} from '@/components/data-table'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import { useUpdateOption } from '../hooks/use-update-option'
|
||||
|
||||
const OPTION_KEY = 'tool_price_setting.prices'
|
||||
@@ -263,37 +253,28 @@ export const ToolPriceSettings = memo(function ToolPriceSettings({
|
||||
</div>
|
||||
|
||||
{editMode === 'visual' ? (
|
||||
<StaticDataTable>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Tool identifier')}</TableHead>
|
||||
<TableHead className='w-[200px]'>
|
||||
{t('Price ($/1K calls)')}
|
||||
</TableHead>
|
||||
<TableHead className='w-[80px] text-right'>
|
||||
{t('Actions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.length === 0 ? (
|
||||
<StaticDataTableEmptyRow
|
||||
colSpan={3}
|
||||
className='text-muted-foreground py-8'
|
||||
>
|
||||
{t('No tools configured')}
|
||||
</StaticDataTableEmptyRow>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell>
|
||||
<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)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'price',
|
||||
header: t('Price ($/1K calls)'),
|
||||
className: 'w-[200px]',
|
||||
cell: (row) => (
|
||||
<Input
|
||||
type='number'
|
||||
min={0}
|
||||
@@ -303,8 +284,14 @@ export const ToolPriceSettings = memo(function ToolPriceSettings({
|
||||
updateRow(row.id, 'price', Number(e.target.value) || 0)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('Actions'),
|
||||
className: 'w-[80px] text-right',
|
||||
cellClassName: 'text-right',
|
||||
cell: (row) => (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
@@ -313,12 +300,10 @@ export const ToolPriceSettings = memo(function ToolPriceSettings({
|
||||
>
|
||||
<Trash2 className='text-destructive h-4 w-4' />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</StaticDataTable>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<div className='space-y-2'>
|
||||
<Textarea
|
||||
|
||||
+64
-63
@@ -21,13 +21,6 @@ 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 {
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/data-table'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import { safeJsonParseWithValidation } from '../utils/json-parser'
|
||||
import { isObjectRecord } from '../utils/json-validators'
|
||||
@@ -142,65 +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>
|
||||
) : (
|
||||
<StaticDataTable>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Group Name')}</TableHead>
|
||||
<TableHead className='text-right'>
|
||||
{t('Max Requests (incl. failures)')}
|
||||
</TableHead>
|
||||
<TableHead className='text-right'>{t('Max Success')}</TableHead>
|
||||
<TableHead className='text-right'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredRateLimits.map((limit) => (
|
||||
<TableRow key={limit.groupName}>
|
||||
<TableCell className='font-medium'>{limit.groupName}</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<span className='font-mono'>
|
||||
{limit.maxRequests === 0
|
||||
? t('Unlimited')
|
||||
: limit.maxRequests.toLocaleString()}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<span className='font-mono'>
|
||||
{limit.maxSuccess.toLocaleString()}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => handleEdit(limit)}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => handleDelete(limit.groupName)}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</StaticDataTable>
|
||||
)}
|
||||
)
|
||||
}
|
||||
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}
|
||||
|
||||
Reference in New Issue
Block a user