perf(web): improve frontend table rendering and pinned columns/UI table (#5405)
* refactor(web): centralize data table implementation - route all TanStack table setup through a shared data-table hook to remove repeated state and row model wiring. - move table rendering, static table wrappers, empty states, and primitive exports behind the data-table module. - update feature tables and configuration editors to share the same table UX while preserving their existing workflows. * refactor(web): trim data table public API - remove unused data-table exports and dead static table helper types. - keep internal table header, skeleton, empty state, and faceted filter helpers private to the data-table module. - route feature imports through the data-table barrel to avoid subpath coupling. * 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. * perf(web): keep list tables fixed within page content - make shared data table pages fill available height and scroll row data inside the table body. - add a fixed content layout mode so selected list pages avoid page-level scrolling. - apply the fixed table behavior to keys, logs, channels, models, users, redemptions, and subscriptions. * perf(web): refine table pagination controls - show total row counts instead of redundant page range text. - tighten visible page buttons so pagination fits constrained table widths. - align pagination controls and tune text hierarchy for clearer scanning. * perf(web): stabilize model pricing table columns - keep model pricing columns at fixed widths so headers do not collapse in narrow layouts. - truncate long model names and pricing summaries within their cells instead of squeezing adjacent columns. * refactor(web): simplify data table rendering internals - split table body rendering into focused helpers for loading, empty, and row states. - extract static table row and cell class resolution to reduce branching in the main component. - reuse a single pagination page-size option list to avoid duplicated constants. * perf(pricing): reduce dynamic pricing table render work - reuse dynamic pricing field metadata instead of rebuilding it inside table columns. - precompute formatted dynamic prices per tier and group to avoid repeated entry mapping for each cell. - simplify select option construction in related dialogs while preserving the same choices. * refactor(web): streamline pricing table rendering - reuse translated endpoint select options between trigger data and menu items. - precompute dynamic pricing maps per group so table cells only resolve formatted values. - add local dynamic pricing type aliases to keep helper signatures readable. * refactor(web): merge pricing table imports * refactor(web): merge upstream ratio table imports * refactor(web): merge channel selector table imports * refactor(web): simplify tiered pricing select items * refactor(web): reuse model ratio row state * refactor(web): rely on table view row defaults * refactor(web): reuse pagination state values * refactor(web): hoist pagination size select items * refactor(web): clarify static table body rows * refactor(web): extract table page pagination rendering * fix(web): remove direct hast type dependency - rely on Shiki transformer contextual typing for line nodes. - allow frontend typecheck to pass without an undeclared hast package. * refactor(web): trim data table hook return API - return only the TanStack table instance from useDataTable. - keep internal state handling private because callers do not consume it directly. * refactor(web): keep static table empty row private - stop exporting the internal StaticDataTableEmptyRow helper. - keep the public static table API focused on the table component and column type. * refactor(web): hide data table view props from barrel * refactor(web): remove stale long text lint override * fix(web): keep pinned table columns opaque - apply pinned column background classes after custom column classes. - use an opaque hover background so scrolled content cannot show through fixed cells. * refactor(data-table): organize shared table components - group table primitives, page composition, toolbar controls, static tables, and hooks by responsibility. - split shared view types, row rendering, header rendering, and pinned-column styling out of the main table view. - keep the public data-table barrel stable while documenting the new ownership boundaries. * fix(web): stabilize split table column sizing - derive default colgroup widths from visible columns when split headers or header sizing are enabled. - apply a fixed table layout with computed minimum width so header and body columns stay aligned. - keep split-header containers from leaking horizontal overflow and avoid extra pinned-column borders. * fix(web): set stable table utility column widths - assign fixed widths to selection columns so shared colgroup sizing keeps checkbox cells compact. - size id columns in redemption and user tables to keep split headers aligned with body rows. * fix(web): align model metadata icon cells - render compact provider avatars in the metadata icon column instead of wide wordmarks. - position icons in a fixed-size wrapper so they line up with the existing icon header alignment. * fix(status-badge): hide status dot by default * fix(web): prevent user invite info overlap - give the invite info and created-at columns explicit widths so table sizing reserves enough space. - allow invite badges to wrap within the cell instead of spilling into adjacent columns. * perf(data-table): cache pinned column class resolution - reuse the pinned column lookup while table props stay stable to reduce repeated per-render work. - share the resolved column class handler across unified and split-header table layouts. - localize page-number screen reader labels so pagination remains accessible in every locale. * refactor(data-table): tighten static table modes - make StaticDataTable distinguish data-driven and children-only usage through explicit prop shapes. - remove unsupported columns-without-data fallback after confirming no repository callers rely on it. - default manual table modes away from unused local row models to reduce repeated table work. * fix(data-table): make pinned edit column opaque - use an opaque muted background for the active action column so sticky cells do not reveal scrolled content underneath. * fix(data-table): prevent narrow column overlap - apply stable header sizing to remaining desktop data table pages so constrained layouts scroll instead of compressing cells. - add explicit widths for key, quota, badge, and timestamp columns that contain fixed-format content. - constrain masked values and timestamp cells with truncation to keep content inside its assigned column. * fix(table): align table cell content with headers - remove extra inline padding from masked table text buttons so values start at the cell edge. - tag status badges and offset leading badges inside table cells to match header text alignment. * fix(table): prevent admin list column overflow - widen redemption and subscription table columns so masked codes, timestamps, and localized headers fit. - localize subscription ID headers and add Received amount translations across supported locales. * fix(provider-badge): unify provider icon spacing - add a shared provider badge component for icon and status label layout. - reuse it in channel type and model vendor columns so OpenAI icons align consistently.
This commit is contained in:
+77
-75
@@ -20,15 +20,8 @@ import { useState } from 'react'
|
||||
import { Pencil, Trash2, Plus } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { useDeleteProvider } from '../hooks/use-custom-oauth-mutations'
|
||||
import type { CustomOAuthProvider } from '../types'
|
||||
@@ -64,73 +57,82 @@ export function ProviderTable(props: ProviderTableProps) {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{props.providers.length === 0 ? (
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
|
||||
{t('No custom OAuth providers configured yet.')}
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Icon')}</TableHead>
|
||||
<TableHead>{t('Name')}</TableHead>
|
||||
<TableHead>{t('Slug')}</TableHead>
|
||||
<TableHead>{t('Status')}</TableHead>
|
||||
<TableHead>{t('Client ID')}</TableHead>
|
||||
<TableHead className='text-right'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{props.providers.map((provider) => (
|
||||
<TableRow key={provider.id}>
|
||||
<TableCell>
|
||||
{provider.icon ? (
|
||||
<span className='text-lg'>{provider.icon}</span>
|
||||
) : (
|
||||
<span className='text-muted-foreground text-sm'>--</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className='font-medium'>{provider.name}</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge
|
||||
label={provider.slug}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge
|
||||
label={provider.enabled ? t('Enabled') : t('Disabled')}
|
||||
variant={provider.enabled ? 'success' : 'neutral'}
|
||||
copyable={false}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className='text-muted-foreground max-w-[120px] truncate font-mono'>
|
||||
{provider.client_id}
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<div className='flex justify-end gap-1'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => props.onEdit(provider)}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => setDeleteTarget(provider)}
|
||||
>
|
||||
<Trash2 className='text-destructive h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
<StaticDataTable
|
||||
data={props.providers}
|
||||
getRowKey={(provider) => provider.id}
|
||||
emptyClassName='text-sm'
|
||||
emptyContent={t('No custom OAuth providers configured yet.')}
|
||||
columns={[
|
||||
{
|
||||
id: 'icon',
|
||||
header: t('Icon'),
|
||||
cell: (provider) =>
|
||||
provider.icon ? (
|
||||
<span className='text-lg'>{provider.icon}</span>
|
||||
) : (
|
||||
<span className='text-muted-foreground text-sm'>--</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'name',
|
||||
header: t('Name'),
|
||||
cellClassName: 'font-medium',
|
||||
cell: (provider) => provider.name,
|
||||
},
|
||||
{
|
||||
id: 'slug',
|
||||
header: t('Slug'),
|
||||
cell: (provider) => (
|
||||
<StatusBadge
|
||||
label={provider.slug}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
header: t('Status'),
|
||||
cell: (provider) => (
|
||||
<StatusBadge
|
||||
label={provider.enabled ? t('Enabled') : t('Disabled')}
|
||||
variant={provider.enabled ? 'success' : 'neutral'}
|
||||
copyable={false}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'client-id',
|
||||
header: t('Client ID'),
|
||||
cellClassName: 'text-muted-foreground max-w-[120px] truncate font-mono',
|
||||
cell: (provider) => provider.client_id,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('Actions'),
|
||||
className: 'text-right',
|
||||
cellClassName: 'text-right',
|
||||
cell: (provider) => (
|
||||
<div className='flex justify-end gap-1'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => props.onEdit(provider)}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => setDeleteTarget(provider)}
|
||||
>
|
||||
<Trash2 className='text-destructive h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
|
||||
+98
-110
@@ -54,15 +54,8 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import { DateTimePicker } from '@/components/datetime-picker'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
@@ -350,109 +343,104 @@ export function AnnouncementsSection({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='rounded-md border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className='w-12'>
|
||||
<Checkbox
|
||||
checked={
|
||||
selectedIds.length === announcements.length &&
|
||||
announcements.length > 0
|
||||
}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>{t('Content')}</TableHead>
|
||||
<TableHead>{t('Publish Date')}</TableHead>
|
||||
<TableHead>{t('Type')}</TableHead>
|
||||
<TableHead>{t('Extra')}</TableHead>
|
||||
<TableHead className='w-32'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedAnnouncements.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className='h-24 text-center'>
|
||||
{t(
|
||||
'No announcements yet. Click "Add Announcement" to create one.'
|
||||
<StaticDataTable
|
||||
data={sortedAnnouncements}
|
||||
getRowKey={(announcement) => announcement.id}
|
||||
emptyContent={t(
|
||||
'No announcements yet. Click "Add Announcement" to create one.'
|
||||
)}
|
||||
columns={[
|
||||
{
|
||||
id: 'select',
|
||||
header: (
|
||||
<Checkbox
|
||||
checked={
|
||||
selectedIds.length === announcements.length &&
|
||||
announcements.length > 0
|
||||
}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
/>
|
||||
),
|
||||
className: 'w-12',
|
||||
cell: (announcement) => (
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(announcement.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleSelectOne(announcement.id, checked as boolean)
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'content',
|
||||
header: t('Content'),
|
||||
cellClassName: 'max-w-xs truncate',
|
||||
cell: (announcement) => announcement.content,
|
||||
},
|
||||
{
|
||||
id: 'publish-date',
|
||||
header: t('Publish Date'),
|
||||
cell: (announcement) => (
|
||||
<div className='flex flex-col gap-1'>
|
||||
<span className='text-sm font-medium'>
|
||||
{getRelativeTime(announcement.publishDate)}
|
||||
</span>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{dayjs(announcement.publishDate).format(
|
||||
'YYYY-MM-DD HH:mm:ss'
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
sortedAnnouncements.map((announcement) => (
|
||||
<TableRow key={announcement.id}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(announcement.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleSelectOne(announcement.id, checked as boolean)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className='max-w-xs truncate'
|
||||
title={announcement.content}
|
||||
>
|
||||
{announcement.content}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<span className='text-sm font-medium'>
|
||||
{getRelativeTime(announcement.publishDate)}
|
||||
</span>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{dayjs(announcement.publishDate).format(
|
||||
'YYYY-MM-DD HH:mm:ss'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge
|
||||
label={
|
||||
typeOptions.find(
|
||||
(opt) => opt.value === announcement.type
|
||||
)?.label
|
||||
}
|
||||
variant={
|
||||
typeOptions.find(
|
||||
(opt) => opt.value === announcement.type
|
||||
)?.badgeVariant ?? 'neutral'
|
||||
}
|
||||
copyable={false}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className='text-muted-foreground max-w-xs truncate'
|
||||
title={announcement.extra}
|
||||
>
|
||||
{announcement.extra || '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
onClick={() => handleEdit(announcement)}
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
>
|
||||
<Edit className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleDelete(announcement)}
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'type',
|
||||
header: t('Type'),
|
||||
cell: (announcement) => (
|
||||
<StatusBadge
|
||||
label={
|
||||
typeOptions.find((opt) => opt.value === announcement.type)
|
||||
?.label
|
||||
}
|
||||
variant={
|
||||
typeOptions.find((opt) => opt.value === announcement.type)
|
||||
?.badgeVariant ?? 'neutral'
|
||||
}
|
||||
copyable={false}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'extra',
|
||||
header: t('Extra'),
|
||||
cellClassName: 'text-muted-foreground max-w-xs truncate',
|
||||
cell: (announcement) => announcement.extra || '-',
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('Actions'),
|
||||
className: 'w-32',
|
||||
cell: (announcement) => (
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
onClick={() => handleEdit(announcement)}
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
>
|
||||
<Edit className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleDelete(announcement)}
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
|
||||
@@ -54,14 +54,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { SettingsSwitchField } from '../components/settings-form-layout'
|
||||
@@ -306,101 +299,98 @@ export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='rounded-md border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className='w-12'>
|
||||
<Checkbox
|
||||
checked={
|
||||
selectedIds.length === apiInfoList.length &&
|
||||
apiInfoList.length > 0
|
||||
}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
<StaticDataTable
|
||||
data={apiInfoList}
|
||||
getRowKey={(apiInfo) => apiInfo.id}
|
||||
emptyContent={t('No API Domains yet. Click "Add API" to create one.')}
|
||||
columns={[
|
||||
{
|
||||
id: 'select',
|
||||
header: (
|
||||
<Checkbox
|
||||
checked={
|
||||
selectedIds.length === apiInfoList.length &&
|
||||
apiInfoList.length > 0
|
||||
}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
/>
|
||||
),
|
||||
className: 'w-12',
|
||||
cell: (apiInfo) => (
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(apiInfo.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleSelectOne(apiInfo.id, checked as boolean)
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'url',
|
||||
header: t('URL'),
|
||||
cellClassName: 'max-w-xs truncate font-mono text-sm',
|
||||
cell: (apiInfo) => (
|
||||
<StatusBadge
|
||||
label={apiInfo.url}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'route',
|
||||
header: t('Route'),
|
||||
cell: (apiInfo) => (
|
||||
<StatusBadge
|
||||
label={apiInfo.route}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'description',
|
||||
header: t('Description'),
|
||||
cellClassName: 'max-w-xs truncate',
|
||||
cell: (apiInfo) => apiInfo.description,
|
||||
},
|
||||
{
|
||||
id: 'color',
|
||||
header: t('Color'),
|
||||
cell: (apiInfo) => (
|
||||
<div className='flex items-center gap-2'>
|
||||
<div
|
||||
className={`h-4 w-4 rounded-full ${getColorClass(apiInfo.color)}`}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>{t('URL')}</TableHead>
|
||||
<TableHead>{t('Route')}</TableHead>
|
||||
<TableHead>{t('Description')}</TableHead>
|
||||
<TableHead>{t('Color')}</TableHead>
|
||||
<TableHead className='w-32'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{apiInfoList.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className='h-24 text-center'>
|
||||
{t('No API Domains yet. Click "Add API" to create one.')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
apiInfoList.map((apiInfo) => (
|
||||
<TableRow key={apiInfo.id}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(apiInfo.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleSelectOne(apiInfo.id, checked as boolean)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className='max-w-xs truncate font-mono text-sm'
|
||||
title={apiInfo.url}
|
||||
>
|
||||
<StatusBadge
|
||||
label={apiInfo.url}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge
|
||||
label={apiInfo.route}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className='max-w-xs truncate'
|
||||
title={apiInfo.description}
|
||||
>
|
||||
{apiInfo.description}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div
|
||||
className={`h-4 w-4 rounded-full ${getColorClass(apiInfo.color)}`}
|
||||
/>
|
||||
<span className='text-sm capitalize'>
|
||||
{apiInfo.color}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
onClick={() => handleEdit(apiInfo)}
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
>
|
||||
<Edit className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleDelete(apiInfo)}
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<span className='text-sm capitalize'>{apiInfo.color}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('Actions'),
|
||||
className: 'w-32',
|
||||
cell: (apiInfo) => (
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
onClick={() => handleEdit(apiInfo)}
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
>
|
||||
<Edit className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleDelete(apiInfo)}
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
|
||||
+47
-54
@@ -21,14 +21,7 @@ import { Pencil, Plus, Search, Trash2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import { safeJsonParseWithValidation } from '../utils/json-parser'
|
||||
import { isArray } from '../utils/json-validators'
|
||||
import { ChatDialog, type ChatEntryData } from './chat-dialog'
|
||||
@@ -149,55 +142,55 @@ export function ChatSettingsVisualEditor({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{filteredChats.length === 0 ? (
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
|
||||
{searchText
|
||||
<StaticDataTable
|
||||
data={filteredChats}
|
||||
getRowKey={(chat) => chat.name}
|
||||
emptyContent={
|
||||
searchText
|
||||
? t('No chat presets match your search')
|
||||
: t(
|
||||
'No chat presets configured. Click "Add chat preset" to get started.'
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className='rounded-md border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Chat Client Name')}</TableHead>
|
||||
<TableHead>{t('URL')}</TableHead>
|
||||
<TableHead className='text-right'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredChats.map((chat) => (
|
||||
<TableRow key={chat.name}>
|
||||
<TableCell className='font-medium'>{chat.name}</TableCell>
|
||||
<TableCell className='max-w-md truncate font-mono text-sm'>
|
||||
{chat.url}
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => handleEdit(chat)}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => handleDelete(chat.name)}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
)
|
||||
}
|
||||
columns={[
|
||||
{
|
||||
id: 'name',
|
||||
header: t('Chat Client Name'),
|
||||
cellClassName: 'font-medium',
|
||||
cell: (chat) => chat.name,
|
||||
},
|
||||
{
|
||||
id: 'url',
|
||||
header: t('URL'),
|
||||
cellClassName: 'max-w-md truncate font-mono text-sm',
|
||||
cell: (chat) => chat.url,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('Actions'),
|
||||
className: 'text-right',
|
||||
cellClassName: 'text-right',
|
||||
cell: (chat) => (
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => handleEdit(chat)}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => handleDelete(chat.name)}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<ChatDialog
|
||||
open={dialogOpen}
|
||||
|
||||
@@ -45,15 +45,8 @@ import {
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { SettingsSwitchField } from '../components/settings-form-layout'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
@@ -269,78 +262,68 @@ export function FAQSection({ enabled, data }: FAQSectionProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='rounded-md border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className='w-12'>
|
||||
<Checkbox
|
||||
checked={
|
||||
selectedIds.length === faqList.length &&
|
||||
faqList.length > 0
|
||||
}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>{t('Question')}</TableHead>
|
||||
<TableHead>{t('Answer')}</TableHead>
|
||||
<TableHead className='w-32'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{faqList.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className='h-24 text-center'>
|
||||
{t('No FAQ entries yet. Click "Add FAQ" to create one.')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
faqList.map((faq) => (
|
||||
<TableRow key={faq.id}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(faq.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleSelectOne(faq.id, checked as boolean)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className='max-w-xs truncate font-medium'
|
||||
title={faq.question}
|
||||
>
|
||||
{faq.question}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className='text-muted-foreground max-w-md truncate'
|
||||
title={faq.answer}
|
||||
>
|
||||
{faq.answer}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
onClick={() => handleEdit(faq)}
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
>
|
||||
<Edit className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleDelete(faq)}
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<StaticDataTable
|
||||
data={faqList}
|
||||
getRowKey={(faq) => faq.id}
|
||||
emptyContent={t('No FAQ entries yet. Click "Add FAQ" to create one.')}
|
||||
columns={[
|
||||
{
|
||||
id: 'select',
|
||||
header: (
|
||||
<Checkbox
|
||||
checked={
|
||||
selectedIds.length === faqList.length && faqList.length > 0
|
||||
}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
/>
|
||||
),
|
||||
className: 'w-12',
|
||||
cell: (faq) => (
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(faq.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleSelectOne(faq.id, checked as boolean)
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'question',
|
||||
header: t('Question'),
|
||||
cellClassName: 'max-w-xs truncate font-medium',
|
||||
cell: (faq) => faq.question,
|
||||
},
|
||||
{
|
||||
id: 'answer',
|
||||
header: t('Answer'),
|
||||
cellClassName: 'text-muted-foreground max-w-md truncate',
|
||||
cell: (faq) => faq.answer,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('Actions'),
|
||||
className: 'w-32',
|
||||
cell: (faq) => (
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
onClick={() => handleEdit(faq)}
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
>
|
||||
<Edit className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleDelete(faq)}
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
|
||||
@@ -45,14 +45,7 @@ import {
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { SettingsSwitchField } from '../components/settings-form-layout'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
@@ -278,80 +271,77 @@ export function UptimeKumaSection({ enabled, data }: UptimeKumaSectionProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='rounded-md border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className='w-12'>
|
||||
<Checkbox
|
||||
checked={
|
||||
selectedIds.length === groups.length && groups.length > 0
|
||||
}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>{t('Category Name')}</TableHead>
|
||||
<TableHead>{t('Uptime Kuma URL')}</TableHead>
|
||||
<TableHead>{t('Status Page Slug')}</TableHead>
|
||||
<TableHead className='w-32'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{groups.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className='h-24 text-center'>
|
||||
{t(
|
||||
'No Uptime Kuma groups yet. Click "Add Group" to create one.'
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
groups.map((group) => (
|
||||
<TableRow key={group.id}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(group.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleSelectOne(group.id, checked as boolean)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className='font-medium'>
|
||||
{group.categoryName}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className='text-primary max-w-xs truncate font-mono text-sm'
|
||||
title={group.url}
|
||||
>
|
||||
{group.url}
|
||||
</TableCell>
|
||||
<TableCell className='text-muted-foreground font-mono text-sm'>
|
||||
{group.slug}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
onClick={() => handleEdit(group)}
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
>
|
||||
<Edit className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleDelete(group)}
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<StaticDataTable
|
||||
data={groups}
|
||||
getRowKey={(group) => group.id}
|
||||
emptyContent={t(
|
||||
'No Uptime Kuma groups yet. Click "Add Group" to create one.'
|
||||
)}
|
||||
columns={[
|
||||
{
|
||||
id: 'select',
|
||||
header: (
|
||||
<Checkbox
|
||||
checked={
|
||||
selectedIds.length === groups.length && groups.length > 0
|
||||
}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
/>
|
||||
),
|
||||
className: 'w-12',
|
||||
cell: (group) => (
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(group.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleSelectOne(group.id, checked as boolean)
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'category',
|
||||
header: t('Category Name'),
|
||||
cellClassName: 'font-medium',
|
||||
cell: (group) => group.categoryName,
|
||||
},
|
||||
{
|
||||
id: 'url',
|
||||
header: t('Uptime Kuma URL'),
|
||||
cellClassName:
|
||||
'text-primary max-w-xs truncate font-mono text-sm',
|
||||
cell: (group) => group.url,
|
||||
},
|
||||
{
|
||||
id: 'slug',
|
||||
header: t('Status Page Slug'),
|
||||
cellClassName: 'text-muted-foreground font-mono text-sm',
|
||||
cell: (group) => group.slug,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('Actions'),
|
||||
className: 'w-32',
|
||||
cell: (group) => (
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
onClick={() => handleEdit(group)}
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
>
|
||||
<Edit className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleDelete(group)}
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
|
||||
+111
-119
@@ -31,15 +31,8 @@ import {
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { StatusBadge, StatusBadgeList } from '@/components/status-badge'
|
||||
import { SettingsSwitchField } from '../../components/settings-form-layout'
|
||||
@@ -546,118 +539,117 @@ export function ChannelAffinitySection(props: Props) {
|
||||
|
||||
{/* Rules Table or JSON Editor */}
|
||||
{editMode === 'visual' ? (
|
||||
<div className='overflow-x-auto rounded-md border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Name')}</TableHead>
|
||||
<TableHead>{t('Model Regex')}</TableHead>
|
||||
<TableHead>{t('Key Sources')}</TableHead>
|
||||
<TableHead>{t('TTL')}</TableHead>
|
||||
<TableHead>{t('Retry')}</TableHead>
|
||||
<TableHead>{t('Scope')}</TableHead>
|
||||
<TableHead>{t('Cache')}</TableHead>
|
||||
<TableHead className='text-right'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rules.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={8}
|
||||
className='text-muted-foreground py-8 text-center'
|
||||
<StaticDataTable
|
||||
tableClassName='min-w-max'
|
||||
data={rules}
|
||||
emptyClassName='text-muted-foreground py-8'
|
||||
emptyContent={t('No rules yet')}
|
||||
columns={[
|
||||
{
|
||||
id: 'name',
|
||||
header: t('Name'),
|
||||
cellClassName: 'font-medium',
|
||||
cell: (rule) => rule.name || '-',
|
||||
},
|
||||
{
|
||||
id: 'model-regex',
|
||||
header: t('Model Regex'),
|
||||
cell: (rule) => <RuleBadgeList items={rule.model_regex || []} />,
|
||||
},
|
||||
{
|
||||
id: 'key-sources',
|
||||
header: t('Key Sources'),
|
||||
cell: (rule) => (
|
||||
<RuleBadgeList
|
||||
items={(rule.key_sources || []).map(
|
||||
(src) =>
|
||||
`${src.type}:${src.type === 'gjson' ? src.path : src.key}`
|
||||
)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'ttl',
|
||||
header: t('TTL'),
|
||||
cell: (rule) => rule.ttl_seconds || '-',
|
||||
},
|
||||
{
|
||||
id: 'retry',
|
||||
header: t('Retry'),
|
||||
cell: (rule) => (
|
||||
<StatusBadge
|
||||
label={
|
||||
rule.skip_retry_on_failure ? t('No Retry') : t('Retry')
|
||||
}
|
||||
variant={rule.skip_retry_on_failure ? 'danger' : 'neutral'}
|
||||
copyable={false}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'scope',
|
||||
header: t('Scope'),
|
||||
cell: (rule) => {
|
||||
const scopeItems = [
|
||||
rule.include_using_group && t('Group'),
|
||||
rule.include_model_name && t('Model'),
|
||||
rule.include_rule_name && t('Rule'),
|
||||
].filter(Boolean) as string[]
|
||||
if (scopeItems.length === 0) return '-'
|
||||
return <RuleBadgeList items={scopeItems} />
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'cache',
|
||||
header: t('Cache'),
|
||||
cell: (rule) =>
|
||||
rule.include_rule_name && cacheStats?.by_rule_name
|
||||
? cacheStats.by_rule_name[rule.name] || 0
|
||||
: 'N/A',
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('Actions'),
|
||||
className: 'text-right',
|
||||
cellClassName: 'text-right',
|
||||
cell: (rule, idx) => (
|
||||
<div className='flex justify-end gap-1'>
|
||||
{rule.include_rule_name && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-7 w-7'
|
||||
onClick={() => setClearRuleName(rule.name)}
|
||||
title={t('Clear cache for this rule')}
|
||||
>
|
||||
<X className='h-3 w-3' />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-7 w-7'
|
||||
onClick={() => {
|
||||
setEditingRule(rule)
|
||||
setRuleTemplateKey(null)
|
||||
setRuleEditorOpen(true)
|
||||
}}
|
||||
>
|
||||
{t('No rules yet')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
rules.map((rule, idx) => (
|
||||
<TableRow key={idx}>
|
||||
<TableCell className='font-medium'>
|
||||
{rule.name || '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<RuleBadgeList items={rule.model_regex || []} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<RuleBadgeList
|
||||
items={(rule.key_sources || []).map(
|
||||
(src) =>
|
||||
`${src.type}:${src.type === 'gjson' ? src.path : src.key}`
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{rule.ttl_seconds || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge
|
||||
label={
|
||||
rule.skip_retry_on_failure
|
||||
? t('No Retry')
|
||||
: t('Retry')
|
||||
}
|
||||
variant={
|
||||
rule.skip_retry_on_failure ? 'danger' : 'neutral'
|
||||
}
|
||||
copyable={false}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{(() => {
|
||||
const scopeItems = [
|
||||
rule.include_using_group && t('Group'),
|
||||
rule.include_model_name && t('Model'),
|
||||
rule.include_rule_name && t('Rule'),
|
||||
].filter(Boolean) as string[]
|
||||
if (scopeItems.length === 0) return '-'
|
||||
return <RuleBadgeList items={scopeItems} />
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{rule.include_rule_name && cacheStats?.by_rule_name
|
||||
? cacheStats.by_rule_name[rule.name] || 0
|
||||
: 'N/A'}
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<div className='flex justify-end gap-1'>
|
||||
{rule.include_rule_name && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-7 w-7'
|
||||
onClick={() => setClearRuleName(rule.name)}
|
||||
title={t('Clear cache for this rule')}
|
||||
>
|
||||
<X className='h-3 w-3' />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-7 w-7'
|
||||
onClick={() => {
|
||||
setEditingRule(rule)
|
||||
setRuleTemplateKey(null)
|
||||
setRuleEditorOpen(true)
|
||||
}}
|
||||
>
|
||||
<Edit className='h-3 w-3' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-7 w-7'
|
||||
onClick={() => handleDeleteRule(idx)}
|
||||
>
|
||||
<Trash2 className='h-3 w-3' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<Edit className='h-3 w-3' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-7 w-7'
|
||||
onClick={() => handleDeleteRule(idx)}
|
||||
>
|
||||
<Trash2 className='h-3 w-3' />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('Rules JSON')}</Label>
|
||||
|
||||
+73
-73
@@ -20,14 +20,7 @@ import { useState, useMemo } from 'react'
|
||||
import { Pencil, Plus, Trash2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { safeJsonParseWithValidation } from '../utils/json-parser'
|
||||
import { isObjectRecord } from '../utils/json-validators'
|
||||
@@ -147,71 +140,78 @@ export function AmountDiscountVisualEditor({
|
||||
) : (
|
||||
<div className='rounded-md border'>
|
||||
{/* Desktop table view */}
|
||||
<div className='hidden sm:block'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Recharge Amount')}</TableHead>
|
||||
<TableHead>{t('Discount Rate')}</TableHead>
|
||||
<TableHead>{t('Discount')}</TableHead>
|
||||
<TableHead className='text-right'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{discounts.map((discount) => (
|
||||
<TableRow key={discount.amount}>
|
||||
<TableCell>
|
||||
<span className='font-mono text-sm'>
|
||||
${discount.amount}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className='bg-muted rounded px-1.5 py-0.5 text-sm'>
|
||||
{discount.discountRate.toFixed(2)}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge
|
||||
variant={discount.discountRate < 1 ? 'info' : 'neutral'}
|
||||
className='font-mono'
|
||||
copyable={false}
|
||||
>
|
||||
{formatPercentage(discount.discountRate)} {t('off')}
|
||||
</StatusBadge>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleEdit(discount)
|
||||
}}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleDelete(discount.amount)
|
||||
}}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<StaticDataTable
|
||||
className='hidden rounded-none border-0 sm:block'
|
||||
data={discounts}
|
||||
getRowKey={(discount) => discount.amount}
|
||||
columns={[
|
||||
{
|
||||
id: 'amount',
|
||||
header: t('Recharge Amount'),
|
||||
cell: (discount) => (
|
||||
<span className='font-mono text-sm'>
|
||||
${discount.amount}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'discount-rate',
|
||||
header: t('Discount Rate'),
|
||||
cell: (discount) => (
|
||||
<code className='bg-muted rounded px-1.5 py-0.5 text-sm'>
|
||||
{discount.discountRate.toFixed(2)}
|
||||
</code>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'discount',
|
||||
header: t('Discount'),
|
||||
cell: (discount) => (
|
||||
<StatusBadge
|
||||
variant={discount.discountRate < 1 ? 'info' : 'neutral'}
|
||||
className='font-mono'
|
||||
copyable={false}
|
||||
>
|
||||
{formatPercentage(discount.discountRate)} {t('off')}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('Actions'),
|
||||
className: 'text-right',
|
||||
cellClassName: 'text-right',
|
||||
cell: (discount) => (
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleEdit(discount)
|
||||
}}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleDelete(discount.amount)
|
||||
}}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Mobile card view */}
|
||||
<div className='divide-y sm:hidden'>
|
||||
|
||||
+75
-73
@@ -21,14 +21,7 @@ import { Pencil, Plus, Search, Trash2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import {
|
||||
formatCreemPrice,
|
||||
formatQuotaShort,
|
||||
@@ -183,71 +176,80 @@ export function CreemProductsVisualEditor({
|
||||
) : (
|
||||
<div className='rounded-md border'>
|
||||
{/* Desktop table view */}
|
||||
<div className='hidden md:block'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Name')}</TableHead>
|
||||
<TableHead>{t('Product ID')}</TableHead>
|
||||
<TableHead>{t('Price')}</TableHead>
|
||||
<TableHead>{t('Quota')}</TableHead>
|
||||
<TableHead className='text-right'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredProducts.map((product) => (
|
||||
<TableRow key={product.productId}>
|
||||
<TableCell className='font-medium'>
|
||||
{product.name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className='bg-muted rounded px-1.5 py-0.5 text-sm'>
|
||||
{product.productId}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className='font-mono text-sm'>
|
||||
{formatCreemPrice(product.price, product.currency)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className='font-mono text-sm'>
|
||||
{formatQuotaShort(product.quota)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleEdit(product)
|
||||
}}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleDelete(product)
|
||||
}}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<StaticDataTable
|
||||
className='hidden rounded-none border-0 md:block'
|
||||
data={filteredProducts}
|
||||
getRowKey={(product) => product.productId}
|
||||
columns={[
|
||||
{
|
||||
id: 'name',
|
||||
header: t('Name'),
|
||||
cellClassName: 'font-medium',
|
||||
cell: (product) => product.name,
|
||||
},
|
||||
{
|
||||
id: 'product-id',
|
||||
header: t('Product ID'),
|
||||
cell: (product) => (
|
||||
<code className='bg-muted rounded px-1.5 py-0.5 text-sm'>
|
||||
{product.productId}
|
||||
</code>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'price',
|
||||
header: t('Price'),
|
||||
cell: (product) => (
|
||||
<span className='font-mono text-sm'>
|
||||
{formatCreemPrice(product.price, product.currency)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'quota',
|
||||
header: t('Quota'),
|
||||
cell: (product) => (
|
||||
<span className='font-mono text-sm'>
|
||||
{formatQuotaShort(product.quota)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('Actions'),
|
||||
className: 'text-right',
|
||||
cellClassName: 'text-right',
|
||||
cell: (product) => (
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleEdit(product)
|
||||
}}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleDelete(product)
|
||||
}}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Mobile card view */}
|
||||
<div className='divide-y md:hidden'>
|
||||
|
||||
+88
-86
@@ -27,13 +27,8 @@ import {
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
StaticDataTable,
|
||||
} from '@/components/data-table'
|
||||
import { safeJsonParseWithValidation } from '../utils/json-parser'
|
||||
import { isArray } from '../utils/json-validators'
|
||||
import {
|
||||
@@ -291,88 +286,95 @@ export function PaymentMethodsVisualEditor({
|
||||
) : (
|
||||
<div className='rounded-md border'>
|
||||
{/* Desktop table view */}
|
||||
<div className='hidden md:block'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Name')}</TableHead>
|
||||
<TableHead>{t('Type')}</TableHead>
|
||||
<TableHead>{t('Color')}</TableHead>
|
||||
<TableHead>{t('Min Top-up')}</TableHead>
|
||||
<TableHead className='text-right'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredMethods.map((method, index) => {
|
||||
<StaticDataTable
|
||||
className='hidden rounded-none border-0 md:block'
|
||||
data={filteredMethods}
|
||||
getRowKey={(method, index) => `${method.type}-${index}`}
|
||||
columns={[
|
||||
{
|
||||
id: 'name',
|
||||
header: t('Name'),
|
||||
cellClassName: 'font-medium',
|
||||
cell: (method) => method.name,
|
||||
},
|
||||
{
|
||||
id: 'type',
|
||||
header: t('Type'),
|
||||
cell: (method) => (
|
||||
<code className='bg-muted rounded px-1.5 py-0.5 text-sm'>
|
||||
{method.type}
|
||||
</code>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'color',
|
||||
header: t('Color'),
|
||||
cell: (method) => {
|
||||
const colorPreview = getColorPreview(method.color)
|
||||
|
||||
return (
|
||||
<TableRow key={`${method.type}-${index}`}>
|
||||
<TableCell className='font-medium'>
|
||||
{method.name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className='bg-muted rounded px-1.5 py-0.5 text-sm'>
|
||||
{method.type}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='flex items-center gap-2'>
|
||||
{colorPreview && (
|
||||
<div
|
||||
className='size-5 shrink-0 rounded border'
|
||||
style={{ backgroundColor: colorPreview }}
|
||||
/>
|
||||
)}
|
||||
<span className='text-muted-foreground truncate font-mono text-sm'>
|
||||
{method.color}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{method.min_topup ? (
|
||||
<span className='font-mono text-sm'>
|
||||
{method.min_topup}
|
||||
</span>
|
||||
) : (
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
—
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleEdit(method)
|
||||
}}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleDelete(method)
|
||||
}}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<div className='flex items-center gap-2'>
|
||||
{colorPreview && (
|
||||
<div
|
||||
className='size-5 shrink-0 rounded border'
|
||||
style={{ backgroundColor: colorPreview }}
|
||||
/>
|
||||
)}
|
||||
<span className='text-muted-foreground truncate font-mono text-sm'>
|
||||
{method.color}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'min-top-up',
|
||||
header: t('Min Top-up'),
|
||||
cell: (method) =>
|
||||
method.min_topup ? (
|
||||
<span className='font-mono text-sm'>
|
||||
{method.min_topup}
|
||||
</span>
|
||||
) : (
|
||||
<span className='text-muted-foreground text-sm'>—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('Actions'),
|
||||
className: 'text-right',
|
||||
cellClassName: 'text-right',
|
||||
cell: (method) => (
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleEdit(method)
|
||||
}}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleDelete(method)
|
||||
}}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Mobile card view */}
|
||||
<div className='divide-y md:hidden'>
|
||||
|
||||
+68
-77
@@ -25,15 +25,8 @@ import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { SettingsSwitchField } from '../components/settings-form-layout'
|
||||
|
||||
@@ -333,76 +326,74 @@ export function WaffoSettingsSection({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='rounded-md border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Display name')}</TableHead>
|
||||
<TableHead>{t('Icon')}</TableHead>
|
||||
<TableHead>{t('Payment method type')}</TableHead>
|
||||
<TableHead>{t('Payment method name')}</TableHead>
|
||||
<TableHead className='text-right'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{payMethods.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={5}
|
||||
className='text-muted-foreground py-8 text-center'
|
||||
<StaticDataTable
|
||||
data={payMethods}
|
||||
emptyClassName='text-muted-foreground py-8'
|
||||
emptyContent={t('No payment methods configured')}
|
||||
columns={[
|
||||
{
|
||||
id: 'name',
|
||||
header: t('Display name'),
|
||||
cell: (m) => m.name,
|
||||
},
|
||||
{
|
||||
id: 'icon',
|
||||
header: t('Icon'),
|
||||
cell: (m) =>
|
||||
m.icon ? (
|
||||
<img
|
||||
src={m.icon}
|
||||
alt={m.name}
|
||||
className='h-6 w-6 rounded object-contain'
|
||||
/>
|
||||
) : (
|
||||
<span className='text-muted-foreground'>-</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'type',
|
||||
header: t('Payment method type'),
|
||||
cell: (m) => m.payMethodType || '-',
|
||||
},
|
||||
{
|
||||
id: 'method',
|
||||
header: t('Payment method name'),
|
||||
cell: (m) => m.payMethodName || '-',
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('Actions'),
|
||||
className: 'text-right',
|
||||
cellClassName: 'text-right',
|
||||
cell: (_m, idx) => (
|
||||
<div className='flex justify-end gap-1'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-7 w-7'
|
||||
onClick={() => openEdit(idx)}
|
||||
>
|
||||
{t('No payment methods configured')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
payMethods.map((m, idx) => (
|
||||
<TableRow key={idx}>
|
||||
<TableCell>{m.name}</TableCell>
|
||||
<TableCell>
|
||||
{m.icon ? (
|
||||
<img
|
||||
src={m.icon}
|
||||
alt={m.name}
|
||||
className='h-6 w-6 rounded object-contain'
|
||||
/>
|
||||
) : (
|
||||
<span className='text-muted-foreground'>-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{m.payMethodType || '-'}</TableCell>
|
||||
<TableCell>{m.payMethodName || '-'}</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<div className='flex justify-end gap-1'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-7 w-7'
|
||||
onClick={() => openEdit(idx)}
|
||||
>
|
||||
<Pencil className='h-3 w-3' />
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-7 w-7'
|
||||
onClick={() =>
|
||||
onPayMethodsChange((prev) =>
|
||||
prev.filter((_, i) => i !== idx)
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 className='h-3 w-3' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<Pencil className='h-3 w-3' />
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-7 w-7'
|
||||
onClick={() =>
|
||||
onPayMethodsChange((prev) =>
|
||||
prev.filter((_, i) => i !== idx)
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 className='h-3 w-3' />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
|
||||
+16
-77
@@ -17,15 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
type RowSelectionState,
|
||||
} from '@tanstack/react-table'
|
||||
import { type ColumnDef, type RowSelectionState } from '@tanstack/react-table'
|
||||
import { Search } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -40,14 +32,10 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { DataTablePagination } from '@/components/data-table/pagination'
|
||||
DataTablePagination,
|
||||
DataTableView,
|
||||
useDataTable,
|
||||
} from '@/components/data-table'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import type { UpstreamChannel } from '../types'
|
||||
@@ -295,23 +283,16 @@ export function ChannelSelectorDialog({
|
||||
})
|
||||
}, [filteredChannels])
|
||||
|
||||
const table = useReactTable({
|
||||
const { table } = useDataTable({
|
||||
data: sortedChannels,
|
||||
columns,
|
||||
state: {
|
||||
rowSelection,
|
||||
},
|
||||
rowSelection,
|
||||
getRowId: (row) => row.id.toString(),
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
initialState: {
|
||||
pagination: {
|
||||
pageSize: 10,
|
||||
},
|
||||
},
|
||||
initialPagination: { pageIndex: 0, pageSize: 10 },
|
||||
withSortedRowModel: false,
|
||||
withFacetedRowModel: false,
|
||||
})
|
||||
|
||||
const handleConfirm = () => {
|
||||
@@ -355,54 +336,12 @@ export function ChannelSelectorDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex-1 overflow-auto rounded-md border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && 'selected'}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className='h-24 text-center'
|
||||
>
|
||||
{t('No channels found')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<DataTableView
|
||||
table={table}
|
||||
containerClassName='flex-1 overflow-auto rounded-md'
|
||||
emptyContent={t('No channels found')}
|
||||
emptyCellClassName='h-24 text-center'
|
||||
/>
|
||||
|
||||
<DataTablePagination table={table} />
|
||||
</div>
|
||||
|
||||
+38
-41
@@ -28,13 +28,8 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
StaticDataTable,
|
||||
} from '@/components/data-table'
|
||||
|
||||
export type ConflictItem = {
|
||||
channel: string
|
||||
@@ -71,40 +66,42 @@ export function ConflictConfirmDialog({
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<div className='max-h-96 overflow-y-auto rounded-md border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Channel')}</TableHead>
|
||||
<TableHead>{t('Model')}</TableHead>
|
||||
<TableHead>{t('Current Billing')}</TableHead>
|
||||
<TableHead>{t('Change To')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{conflicts.map((conflict, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell className='font-medium'>
|
||||
{conflict.channel}
|
||||
</TableCell>
|
||||
<TableCell className='font-mono text-sm'>
|
||||
{conflict.model}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<pre className='text-sm whitespace-pre-wrap'>
|
||||
{conflict.current}
|
||||
</pre>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<pre className='text-sm whitespace-pre-wrap'>
|
||||
{conflict.newVal}
|
||||
</pre>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<StaticDataTable
|
||||
className='max-h-96 overflow-y-auto'
|
||||
data={conflicts}
|
||||
columns={[
|
||||
{
|
||||
id: 'channel',
|
||||
header: t('Channel'),
|
||||
cellClassName: 'font-medium',
|
||||
cell: (conflict) => conflict.channel,
|
||||
},
|
||||
{
|
||||
id: 'model',
|
||||
header: t('Model'),
|
||||
cellClassName: 'font-mono text-sm',
|
||||
cell: (conflict) => conflict.model,
|
||||
},
|
||||
{
|
||||
id: 'current',
|
||||
header: t('Current Billing'),
|
||||
cell: (conflict) => (
|
||||
<pre className='text-sm whitespace-pre-wrap'>
|
||||
{conflict.current}
|
||||
</pre>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'new',
|
||||
header: t('Change To'),
|
||||
cell: (conflict) => (
|
||||
<pre className='text-sm whitespace-pre-wrap'>
|
||||
{conflict.newVal}
|
||||
</pre>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isLoading}>
|
||||
|
||||
+191
-205
@@ -35,14 +35,7 @@ import {
|
||||
} from '@/components/ui/collapsible'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { safeJsonParse } from '../utils/json-parser'
|
||||
|
||||
@@ -427,54 +420,51 @@ export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({
|
||||
{t('Add group')}
|
||||
</Button>
|
||||
{topupRatioList.length > 0 && (
|
||||
<div className='rounded-md border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Group name')}</TableHead>
|
||||
<TableHead>{t('Multiplier')}</TableHead>
|
||||
<TableHead className='text-right'>
|
||||
{t('Actions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{topupRatioList.map((group) => (
|
||||
<TableRow key={group.name}>
|
||||
<TableCell className='font-medium'>
|
||||
{group.name}
|
||||
</TableCell>
|
||||
<TableCell>{group.value}</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
handleSimpleEdit('topupGroupRatio', group)
|
||||
}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
handleSimpleDelete(
|
||||
'topupGroupRatio',
|
||||
group.name
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<StaticDataTable
|
||||
data={topupRatioList}
|
||||
getRowKey={(group) => group.name}
|
||||
columns={[
|
||||
{
|
||||
id: 'group',
|
||||
header: t('Group name'),
|
||||
cellClassName: 'font-medium',
|
||||
cell: (group) => group.name,
|
||||
},
|
||||
{
|
||||
id: 'multiplier',
|
||||
header: t('Multiplier'),
|
||||
cell: (group) => group.value,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('Actions'),
|
||||
className: 'text-right',
|
||||
cellClassName: 'text-right',
|
||||
cell: (group) => (
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
handleSimpleEdit('topupGroupRatio', group)
|
||||
}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
handleSimpleDelete('topupGroupRatio', group.name)
|
||||
}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -541,55 +531,58 @@ export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({
|
||||
<CollapsibleContent>
|
||||
{userGroupData.overrides.length > 0 && (
|
||||
<div className='border-t'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Target group')}</TableHead>
|
||||
<TableHead>{t('Ratio')}</TableHead>
|
||||
<TableHead className='text-right'>
|
||||
{t('Actions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{userGroupData.overrides.map((override) => (
|
||||
<TableRow key={override.targetGroup}>
|
||||
<TableCell className='font-medium'>
|
||||
{override.targetGroup}
|
||||
</TableCell>
|
||||
<TableCell>{override.ratio}</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
handleOverrideEdit(
|
||||
userGroupData.userGroup,
|
||||
override
|
||||
)
|
||||
}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
handleOverrideDelete(
|
||||
userGroupData.userGroup,
|
||||
override.targetGroup
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<StaticDataTable
|
||||
className='rounded-none border-0'
|
||||
data={userGroupData.overrides}
|
||||
getRowKey={(override) => override.targetGroup}
|
||||
columns={[
|
||||
{
|
||||
id: 'target-group',
|
||||
header: t('Target group'),
|
||||
cellClassName: 'font-medium',
|
||||
cell: (override) => override.targetGroup,
|
||||
},
|
||||
{
|
||||
id: 'ratio',
|
||||
header: t('Ratio'),
|
||||
cell: (override) => override.ratio,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('Actions'),
|
||||
className: 'text-right',
|
||||
cellClassName: 'text-right',
|
||||
cell: (override) => (
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
handleOverrideEdit(
|
||||
userGroupData.userGroup,
|
||||
override
|
||||
)
|
||||
}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
handleOverrideDelete(
|
||||
userGroupData.userGroup,
|
||||
override.targetGroup
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
@@ -858,106 +851,99 @@ function GroupPricingTable({
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='space-y-3'>
|
||||
<div className='overflow-hidden rounded-md border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className='min-w-40'>{t('Group name')}</TableHead>
|
||||
<TableHead className='w-28'>{t('Ratio')}</TableHead>
|
||||
<TableHead className='w-28 text-center'>
|
||||
{t('User selectable')}
|
||||
</TableHead>
|
||||
<TableHead className='min-w-56'>{t('Description')}</TableHead>
|
||||
<TableHead className='w-16 text-right'>
|
||||
{t('Actions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={5}
|
||||
className='text-muted-foreground h-20 text-center text-sm'
|
||||
>
|
||||
{t('No groups yet. Add a group to get started.')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<TableRow key={row._id}>
|
||||
<TableCell>
|
||||
<Input
|
||||
value={row.name}
|
||||
onChange={(event) =>
|
||||
updateRow(row._id, 'name', event.target.value)
|
||||
}
|
||||
aria-invalid={duplicateNames.includes(
|
||||
row.name.trim()
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type='number'
|
||||
min={0}
|
||||
step={0.1}
|
||||
value={String(row.ratio)}
|
||||
onChange={(event) =>
|
||||
updateRow(
|
||||
row._id,
|
||||
'ratio',
|
||||
normalizeRatio(event.target.value)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='flex justify-center'>
|
||||
<Checkbox
|
||||
checked={row.selectable}
|
||||
onCheckedChange={(checked) =>
|
||||
updateRow(row._id, 'selectable', checked === true)
|
||||
}
|
||||
aria-label={t('User selectable')}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{row.selectable ? (
|
||||
<Input
|
||||
value={row.description}
|
||||
placeholder={t('Group description')}
|
||||
onChange={(event) =>
|
||||
updateRow(
|
||||
row._id,
|
||||
'description',
|
||||
event.target.value
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<span className='text-muted-foreground px-3 text-sm'>
|
||||
-
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => removeRow(row._id)}
|
||||
aria-label={t('Delete')}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<StaticDataTable
|
||||
data={rows}
|
||||
getRowKey={(row) => row._id}
|
||||
emptyClassName='text-muted-foreground h-20 text-sm'
|
||||
emptyContent={t('No groups yet. Add a group to get started.')}
|
||||
columns={[
|
||||
{
|
||||
id: 'group',
|
||||
header: t('Group name'),
|
||||
className: 'min-w-40',
|
||||
cell: (row) => (
|
||||
<Input
|
||||
value={row.name}
|
||||
onChange={(event) =>
|
||||
updateRow(row._id, 'name', event.target.value)
|
||||
}
|
||||
aria-invalid={duplicateNames.includes(row.name.trim())}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'ratio',
|
||||
header: t('Ratio'),
|
||||
className: 'w-28',
|
||||
cell: (row) => (
|
||||
<Input
|
||||
type='number'
|
||||
min={0}
|
||||
step={0.1}
|
||||
value={String(row.ratio)}
|
||||
onChange={(event) =>
|
||||
updateRow(
|
||||
row._id,
|
||||
'ratio',
|
||||
normalizeRatio(event.target.value)
|
||||
)
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'selectable',
|
||||
header: t('User selectable'),
|
||||
className: 'w-28 text-center',
|
||||
cell: (row) => (
|
||||
<div className='flex justify-center'>
|
||||
<Checkbox
|
||||
checked={row.selectable}
|
||||
onCheckedChange={(checked) =>
|
||||
updateRow(row._id, 'selectable', checked === true)
|
||||
}
|
||||
aria-label={t('User selectable')}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'description',
|
||||
header: t('Description'),
|
||||
className: 'min-w-56',
|
||||
cell: (row) =>
|
||||
row.selectable ? (
|
||||
<Input
|
||||
value={row.description}
|
||||
placeholder={t('Group description')}
|
||||
onChange={(event) =>
|
||||
updateRow(row._id, 'description', event.target.value)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<span className='text-muted-foreground px-3 text-sm'>
|
||||
-
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('Actions'),
|
||||
className: 'w-16 text-right',
|
||||
cellClassName: 'text-right',
|
||||
cell: (row) => (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => removeRow(row._id)}
|
||||
aria-label={t('Delete')}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{duplicateNames.length > 0 && (
|
||||
<p className='text-destructive text-sm'>
|
||||
|
||||
+14
-7
@@ -71,6 +71,7 @@ export function buildModelRatioColumns({
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
size: 40,
|
||||
meta: { label: t('Select') },
|
||||
},
|
||||
{
|
||||
@@ -79,16 +80,22 @@ export function buildModelRatioColumns({
|
||||
<DataTableColumnHeader column={column} title={t('Model name')} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className='flex items-center gap-2 font-medium'>
|
||||
{row.getValue('name')}
|
||||
<div className='flex min-w-0 items-center gap-2 font-medium'>
|
||||
<span className='min-w-0 truncate'>{row.getValue('name')}</span>
|
||||
{row.original.billingMode === 'tiered_expr' && (
|
||||
<StatusBadge label={t('Tiered')} variant='info' copyable={false} />
|
||||
<StatusBadge
|
||||
label={t('Tiered')}
|
||||
variant='info'
|
||||
copyable={false}
|
||||
className='shrink-0'
|
||||
/>
|
||||
)}
|
||||
{row.original.hasConflict && (
|
||||
<StatusBadge
|
||||
label={t('Conflict')}
|
||||
variant='danger'
|
||||
copyable={false}
|
||||
className='shrink-0'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -119,11 +126,11 @@ export function buildModelRatioColumns({
|
||||
<DataTableColumnHeader column={column} title={t('Price summary')} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className='flex min-w-[180px] flex-col gap-1'>
|
||||
<span className='font-medium'>
|
||||
<div className='flex min-w-0 flex-col gap-1'>
|
||||
<span className='truncate font-medium'>
|
||||
{getPriceSummary(row.original, t)}
|
||||
</span>
|
||||
<span className='text-muted-foreground max-w-[320px] truncate text-xs'>
|
||||
<span className='text-muted-foreground truncate text-xs'>
|
||||
{getPriceDetail(row.original, t)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -136,7 +143,7 @@ export function buildModelRatioColumns({
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => <div className='text-right'>{t('Actions')}</div>,
|
||||
header: () => <div>{t('Actions')}</div>,
|
||||
cell: ({ row }) => (
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button
|
||||
|
||||
+62
-92
@@ -33,25 +33,19 @@ import {
|
||||
type RowSelectionState,
|
||||
type VisibilityState,
|
||||
type SortingState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFacetedRowModel,
|
||||
getFacetedUniqueValues,
|
||||
getFilteredRowModel,
|
||||
getSortedRowModel,
|
||||
getPaginationRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table'
|
||||
import { useMediaQuery } from '@/hooks'
|
||||
import { Copy, Plus } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DataTableBulkActions,
|
||||
DataTableToolbar,
|
||||
DataTablePagination,
|
||||
DataTableRow,
|
||||
DataTableView,
|
||||
useDataTable,
|
||||
} from '@/components/data-table'
|
||||
import { combineBillingExpr } from '@/features/pricing/lib/billing-expr'
|
||||
import { safeJsonParse } from '../utils/json-parser'
|
||||
@@ -424,17 +418,15 @@ const ModelRatioVisualEditorComponent = forwardRef<
|
||||
[handleEdit, handleDelete, t]
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
const { table } = useDataTable({
|
||||
data: models,
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
globalFilter,
|
||||
columnVisibility,
|
||||
pagination,
|
||||
rowSelection,
|
||||
},
|
||||
sorting,
|
||||
columnFilters,
|
||||
globalFilter,
|
||||
columnVisibility,
|
||||
pagination,
|
||||
rowSelection,
|
||||
enableRowSelection: true,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
@@ -443,12 +435,6 @@ const ModelRatioVisualEditorComponent = forwardRef<
|
||||
onPaginationChange: setPagination,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
autoResetPageIndex: false,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getFacetedRowModel: getFacetedRowModel(),
|
||||
getFacetedUniqueValues: getFacetedUniqueValues(),
|
||||
globalFilterFn: (row, _columnId, filterValue) => {
|
||||
const searchValue = String(filterValue).toLowerCase()
|
||||
return row.original.name.toLowerCase().includes(searchValue)
|
||||
@@ -629,6 +615,8 @@ const ModelRatioVisualEditorComponent = forwardRef<
|
||||
[editorOpen, persistPricingData]
|
||||
)
|
||||
|
||||
const hasRows = table.getRowModel().rows.length > 0
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='grid h-[clamp(720px,calc(100vh-12rem),900px)] min-h-0 gap-4 md:grid-cols-[minmax(300px,0.72fr)_minmax(520px,1.28fr)] xl:grid-cols-[minmax(320px,0.68fr)_minmax(640px,1.32fr)]'>
|
||||
@@ -667,82 +655,64 @@ const ModelRatioVisualEditorComponent = forwardRef<
|
||||
}
|
||||
/>
|
||||
|
||||
{table.getRowModel().rows.length === 0 ? (
|
||||
{!hasRows ? (
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
|
||||
{table.getState().globalFilter
|
||||
? t('No models match your search')
|
||||
: t('No models configured. Use Add model to get started.')}
|
||||
</div>
|
||||
) : (
|
||||
<div className='min-h-0 flex-1 overflow-auto rounded-md border'>
|
||||
<table className='w-full caption-bottom text-sm tabular-nums'>
|
||||
<thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id} className='border-b'>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th
|
||||
key={header.id}
|
||||
colSpan={header.colSpan}
|
||||
className={cn(
|
||||
'bg-background text-foreground sticky top-0 z-10 h-10 px-2 text-left align-middle text-sm font-medium whitespace-nowrap',
|
||||
header.column.id === 'actions' &&
|
||||
'right-0 z-30 w-24 min-w-24 shadow-[-10px_0_14px_-14px_hsl(var(--foreground))]'
|
||||
)}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<tr
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() ? 'selected' : undefined}
|
||||
className={
|
||||
editData?.name === row.original.name
|
||||
? 'bg-muted/45 hover:bg-muted/50 data-[state=selected]:bg-muted group border-b transition-colors'
|
||||
: 'hover:bg-muted/50 data-[state=selected]:bg-muted group border-b transition-colors'
|
||||
}
|
||||
onClick={(event) => {
|
||||
const target = event.target as HTMLElement
|
||||
if (target.closest('button, [role="checkbox"]')) return
|
||||
handleEdit(row.original)
|
||||
}}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td
|
||||
key={cell.id}
|
||||
className={cn(
|
||||
'p-2 align-middle text-sm whitespace-nowrap',
|
||||
cell.column.id === 'actions' &&
|
||||
(editData?.name === row.original.name
|
||||
? 'bg-muted/45 group-hover:bg-muted/50 group-data-[state=selected]:bg-muted sticky right-0 z-10 w-24 min-w-24 shadow-[-10px_0_14px_-14px_hsl(var(--foreground))]'
|
||||
: 'bg-background group-hover:bg-muted/50 group-data-[state=selected]:bg-muted sticky right-0 z-10 w-24 min-w-24 shadow-[-10px_0_14px_-14px_hsl(var(--foreground))]')
|
||||
)}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<DataTableView
|
||||
table={table}
|
||||
containerClassName='min-h-0 flex-1 rounded-md'
|
||||
tableContainerClassName='h-full'
|
||||
tableClassName='min-w-[852px] table-fixed'
|
||||
tableHeaderClassName='[&_tr]:border-b-0'
|
||||
splitHeaderScrollClassName='h-full'
|
||||
bodyContainerClassName='[scrollbar-gutter:stable]'
|
||||
splitHeader
|
||||
pinnedColumns={[
|
||||
{
|
||||
columnId: 'actions',
|
||||
side: 'right',
|
||||
className: 'w-24 min-w-24',
|
||||
},
|
||||
]}
|
||||
colgroup={
|
||||
<colgroup>
|
||||
<col className='w-9' />
|
||||
<col className='w-[300px]' />
|
||||
<col className='w-[120px]' />
|
||||
<col className='w-[300px]' />
|
||||
<col className='w-24' />
|
||||
</colgroup>
|
||||
}
|
||||
renderRow={(row, { getCellClassName }) => (
|
||||
<DataTableRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
className={
|
||||
editData?.name === row.original.name
|
||||
? 'bg-muted/45 hover:bg-muted/50 data-[state=selected]:bg-muted group'
|
||||
: 'group'
|
||||
}
|
||||
getColumnClassName={(columnId) =>
|
||||
columnId === 'actions' &&
|
||||
editData?.name === row.original.name
|
||||
? getCellClassName(columnId, 'bg-muted')
|
||||
: getCellClassName(columnId)
|
||||
}
|
||||
onClick={(event) => {
|
||||
const target = event.target as HTMLElement
|
||||
if (target.closest('button, [role="checkbox"]')) return
|
||||
handleEdit(row.original)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{table.getRowModel().rows.length > 0 && (
|
||||
<DataTablePagination table={table} />
|
||||
)}
|
||||
{hasRows && <DataTablePagination table={table} />}
|
||||
</div>
|
||||
|
||||
<div className='hidden min-h-0 min-w-0 md:block'>
|
||||
|
||||
@@ -464,7 +464,7 @@ function ConditionRow({ condition, onChange, onRemove }: ConditionRowProps) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
items={[...OPS.map((op) => ({ value: op, label: op }))]}
|
||||
items={OPS.map((op) => ({ value: op, label: op }))}
|
||||
value={condition.op}
|
||||
onValueChange={(value) =>
|
||||
onChange({ ...condition, op: value as TierConditionInput['op'] })
|
||||
|
||||
@@ -23,15 +23,8 @@ import { toast } from 'sonner'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import { useUpdateOption } from '../hooks/use-update-option'
|
||||
|
||||
const OPTION_KEY = 'tool_price_setting.prices'
|
||||
@@ -109,7 +102,6 @@ export const ToolPriceSettings = memo(function ToolPriceSettings({
|
||||
useEffect(() => {
|
||||
const prices = parseInitialPrices(defaultValue)
|
||||
const initialRows = objectToRows(prices)
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setRows(initialRows)
|
||||
setJsonText(JSON.stringify(prices, null, 2))
|
||||
setJsonError('')
|
||||
@@ -261,72 +253,57 @@ export const ToolPriceSettings = memo(function ToolPriceSettings({
|
||||
</div>
|
||||
|
||||
{editMode === 'visual' ? (
|
||||
<div className='overflow-hidden rounded-md border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Tool identifier')}</TableHead>
|
||||
<TableHead className='w-[200px]'>
|
||||
{t('Price ($/1K calls)')}
|
||||
</TableHead>
|
||||
<TableHead className='w-[80px] text-right'>
|
||||
{t('Actions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={3}
|
||||
className='text-muted-foreground py-8 text-center'
|
||||
>
|
||||
{t('No tools configured')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell>
|
||||
<Input
|
||||
value={row.key}
|
||||
placeholder='web_search_preview:gpt-4o*'
|
||||
onChange={(e) =>
|
||||
updateRow(row.id, 'key', e.target.value)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type='number'
|
||||
min={0}
|
||||
step={0.5}
|
||||
value={row.price}
|
||||
onChange={(e) =>
|
||||
updateRow(
|
||||
row.id,
|
||||
'price',
|
||||
Number(e.target.value) || 0
|
||||
)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={() => removeRow(row.id)}
|
||||
aria-label={t('Delete')}
|
||||
>
|
||||
<Trash2 className='text-destructive h-4 w-4' />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<StaticDataTable
|
||||
data={rows}
|
||||
getRowKey={(row) => row.id}
|
||||
emptyClassName='text-muted-foreground py-8'
|
||||
emptyContent={t('No tools configured')}
|
||||
columns={[
|
||||
{
|
||||
id: 'tool',
|
||||
header: t('Tool identifier'),
|
||||
cell: (row) => (
|
||||
<Input
|
||||
value={row.key}
|
||||
placeholder='web_search_preview:gpt-4o*'
|
||||
onChange={(e) => updateRow(row.id, 'key', e.target.value)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'price',
|
||||
header: t('Price ($/1K calls)'),
|
||||
className: 'w-[200px]',
|
||||
cell: (row) => (
|
||||
<Input
|
||||
type='number'
|
||||
min={0}
|
||||
step={0.5}
|
||||
value={row.price}
|
||||
onChange={(e) =>
|
||||
updateRow(row.id, 'price', Number(e.target.value) || 0)
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('Actions'),
|
||||
className: 'w-[80px] text-right',
|
||||
cellClassName: 'text-right',
|
||||
cell: (row) => (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={() => removeRow(row.id)}
|
||||
aria-label={t('Delete')}
|
||||
>
|
||||
<Trash2 className='text-destructive h-4 w-4' />
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<div className='space-y-2'>
|
||||
<Textarea
|
||||
|
||||
+18
-67
@@ -17,12 +17,6 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table'
|
||||
import { Loader2, Search } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -35,14 +29,10 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { DataTablePagination } from '@/components/data-table/pagination'
|
||||
DataTablePagination,
|
||||
DataTableView,
|
||||
useDataTable,
|
||||
} from '@/components/data-table'
|
||||
import type { DifferencesMap, RatioType } from '../types'
|
||||
import { RATIO_TYPE_OPTIONS } from './constants'
|
||||
import { useUpstreamRatioSyncColumns } from './upstream-ratio-sync-columns'
|
||||
@@ -180,15 +170,14 @@ export function UpstreamRatioSyncTable({
|
||||
handleBulkUnselect
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
const { table } = useDataTable({
|
||||
data: filteredData,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getRowId: (row) => row.key,
|
||||
initialState: {
|
||||
pagination: { pageSize: 10 },
|
||||
},
|
||||
initialPagination: { pageIndex: 0, pageSize: 10 },
|
||||
withFilteredRowModel: false,
|
||||
withSortedRowModel: false,
|
||||
withFacetedRowModel: false,
|
||||
})
|
||||
|
||||
if (dataSource.length === 0) {
|
||||
@@ -258,53 +247,15 @@ export function UpstreamRatioSyncTable({
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className='overflow-hidden rounded-md border'>
|
||||
<div className='overflow-x-auto'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id} className='align-top'>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.length > 0 ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id} className='align-top'>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id} className='align-top'>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className='h-24 text-center'
|
||||
>
|
||||
{t('No results found')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
<DataTableView
|
||||
table={table}
|
||||
containerClassName='rounded-md'
|
||||
tableContainerClassName='overflow-x-auto'
|
||||
getColumnClassName={() => 'align-top'}
|
||||
getRowClassName={() => 'align-top'}
|
||||
emptyContent={t('No results found')}
|
||||
emptyCellClassName='h-24 text-center'
|
||||
/>
|
||||
|
||||
<DataTablePagination table={table} />
|
||||
</div>
|
||||
|
||||
+65
-68
@@ -21,14 +21,7 @@ import { Pencil, Plus, Search, Trash2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import { safeJsonParseWithValidation } from '../utils/json-parser'
|
||||
import { isObjectRecord } from '../utils/json-validators'
|
||||
import { RateLimitDialog, type RateLimitEntryData } from './rate-limit-dialog'
|
||||
@@ -142,69 +135,73 @@ export function RateLimitVisualEditor({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{filteredRateLimits.length === 0 ? (
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
|
||||
{searchText
|
||||
<StaticDataTable
|
||||
data={filteredRateLimits}
|
||||
getRowKey={(limit) => limit.groupName}
|
||||
emptyContent={
|
||||
searchText
|
||||
? t('No groups match your search')
|
||||
: t(
|
||||
'No group-based rate limits configured. Click "Add group" to get started.'
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className='rounded-md border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Group Name')}</TableHead>
|
||||
<TableHead className='text-right'>
|
||||
{t('Max Requests (incl. failures)')}
|
||||
</TableHead>
|
||||
<TableHead className='text-right'>{t('Max Success')}</TableHead>
|
||||
<TableHead className='text-right'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredRateLimits.map((limit) => (
|
||||
<TableRow key={limit.groupName}>
|
||||
<TableCell className='font-medium'>
|
||||
{limit.groupName}
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<span className='font-mono'>
|
||||
{limit.maxRequests === 0
|
||||
? t('Unlimited')
|
||||
: limit.maxRequests.toLocaleString()}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<span className='font-mono'>
|
||||
{limit.maxSuccess.toLocaleString()}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => handleEdit(limit)}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => handleDelete(limit.groupName)}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
)
|
||||
}
|
||||
columns={[
|
||||
{
|
||||
id: 'group',
|
||||
header: t('Group Name'),
|
||||
cellClassName: 'font-medium',
|
||||
cell: (limit) => limit.groupName,
|
||||
},
|
||||
{
|
||||
id: 'max-requests',
|
||||
header: t('Max Requests (incl. failures)'),
|
||||
className: 'text-right',
|
||||
cellClassName: 'text-right',
|
||||
cell: (limit) => (
|
||||
<span className='font-mono'>
|
||||
{limit.maxRequests === 0
|
||||
? t('Unlimited')
|
||||
: limit.maxRequests.toLocaleString()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'max-success',
|
||||
header: t('Max Success'),
|
||||
className: 'text-right',
|
||||
cellClassName: 'text-right',
|
||||
cell: (limit) => (
|
||||
<span className='font-mono'>
|
||||
{limit.maxSuccess.toLocaleString()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('Actions'),
|
||||
className: 'text-right',
|
||||
cellClassName: 'text-right',
|
||||
cell: (limit) => (
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => handleEdit(limit)}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => handleDelete(limit.groupName)}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<RateLimitDialog
|
||||
open={dialogOpen}
|
||||
|
||||
Reference in New Issue
Block a user