Commit ea7cb0ba by CaIon

refactor(web): unify table cells and quota details

parent ebe4c368
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useTranslation } from 'react-i18next'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { toIntlLocale } from '@/i18n/languages'
import { formatTimestampRelative, formatTimestampToDate } from '@/lib/format'
import { cn } from '@/lib/utils'
interface TimestampCellProps {
timestamp: number
now?: number
format?: 'relative' | 'absolute'
locale?: string
justNowLabel: string
className?: string
}
export function TimestampCell(props: TimestampCellProps) {
if (!props.timestamp || props.timestamp === -1) {
return <span className='text-muted-foreground'>-</span>
}
const timestampMs = props.timestamp * 1000
const absoluteTime = formatTimestampToDate(props.timestamp)
if (props.format === 'absolute') {
return (
<time
dateTime={new Date(timestampMs).toISOString()}
className={cn('block whitespace-nowrap tabular-nums', props.className)}
>
{absoluteTime}
</time>
)
}
const now = props.now ?? Date.now()
const isJustNow = timestampMs <= now && now - timestampMs < 60_000
const relativeTime = isJustNow
? props.justNowLabel
: formatTimestampRelative(props.timestamp, 'seconds', props.locale)
const [relativePrefix, relativeNumber, relativeSuffix] = relativeTime.split(
/(\p{Number}+(?:[.,\u00a0\u202f]\p{Number}+)*)/u
)
return (
<Tooltip>
<TooltipTrigger
render={
<time
dateTime={new Date(timestampMs).toISOString()}
tabIndex={0}
className={cn('block truncate', props.className)}
/>
}
>
{relativePrefix}
{relativeNumber && (
<span className='tabular-nums'>{relativeNumber}</span>
)}
{relativeSuffix}
</TooltipTrigger>
<TooltipContent>
<span className='tabular-nums'>{absoluteTime}</span>
</TooltipContent>
</Tooltip>
)
}
export function ActivityTimeCell(props: {
createdAt: number
lastAt: number
lastLabel: string
lastClassName?: string
now?: number
format?: 'relative' | 'absolute'
layout?: 'rows' | 'columns'
}) {
const { t, i18n } = useTranslation()
const locale = toIntlLocale(i18n.resolvedLanguage || i18n.language)
return (
<div
data-table-text='secondary'
className={cn(
'grid min-w-0 gap-y-1 text-xs font-normal',
props.layout === 'columns'
? 'grid-flow-col grid-cols-2 grid-rows-[auto_1fr] items-start gap-x-3'
: 'grid-cols-[auto_minmax(0,1fr)] items-center gap-x-2'
)}
>
<span className='text-muted-foreground'>{t('Created')}</span>
<TimestampCell
timestamp={props.createdAt}
now={props.now}
format={props.format}
locale={locale}
justNowLabel={t('Just now')}
className={cn(
'text-muted-foreground',
props.layout === 'columns' && 'whitespace-normal'
)}
/>
<span className='text-muted-foreground'>{props.lastLabel}</span>
<TimestampCell
timestamp={props.lastAt}
now={props.now}
format={props.format}
locale={locale}
justNowLabel={t('Just now')}
className={cn(
props.lastClassName ?? 'text-muted-foreground',
props.layout === 'columns' && 'whitespace-normal'
)}
/>
</div>
)
}
...@@ -145,12 +145,9 @@ function SplitHeaderTableView<TData>({ ...@@ -145,12 +145,9 @@ function SplitHeaderTableView<TData>({
props.bodyContainerClassName props.bodyContainerClassName
)} )}
> >
<table <Table
data-slot='table' withContainer={false}
className={cn( className={props.tableClassName}
'w-full caption-bottom text-sm tabular-nums',
props.tableClassName
)}
style={tableSizing.style} style={tableSizing.style}
> >
{tableSizing.colgroup} {tableSizing.colgroup}
...@@ -162,7 +159,7 @@ function SplitHeaderTableView<TData>({ ...@@ -162,7 +159,7 @@ function SplitHeaderTableView<TData>({
getColumnClassName={getColumnClassName} getColumnClassName={getColumnClassName}
/> />
{renderTableBody(props, rows, colSpan, getColumnClassName)} {renderTableBody(props, rows, colSpan, getColumnClassName)}
</table> </Table>
</div> </div>
</div> </div>
) )
......
...@@ -42,8 +42,8 @@ export function GroupMultiplierBadge(props: { ...@@ -42,8 +42,8 @@ export function GroupMultiplierBadge(props: {
<Badge <Badge
variant='outline' variant='outline'
className={cn( className={cn(
'relative h-5 min-w-12 rounded-full px-1.5 py-0 text-xs leading-none font-medium shadow-none', 'relative h-5 min-w-12 rounded-full px-1.5 py-0 text-sm leading-none font-medium shadow-none',
!props.label && 'font-mono tabular-nums', !props.label && 'tabular-nums',
colorClassName, colorClassName,
props.className props.className
)} )}
...@@ -122,7 +122,7 @@ export function GroupBadge(props: GroupBadgeProps) { ...@@ -122,7 +122,7 @@ export function GroupBadge(props: GroupBadgeProps) {
return ( return (
<span <span
className={cn( className={cn(
'inline-flex max-w-full min-w-0 items-center gap-2 text-xs', 'inline-flex max-w-full min-w-0 items-center gap-2 text-sm',
containerClassName containerClassName
)} )}
> >
......
...@@ -16,6 +16,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,6 +16,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import type { ComponentProps } from 'react'
import { CopyButton } from '@/components/copy-button' import { CopyButton } from '@/components/copy-button'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
...@@ -23,6 +25,21 @@ import { ...@@ -23,6 +25,21 @@ import {
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from '@/components/ui/popover' } from '@/components/ui/popover'
import { cn } from '@/lib/utils'
export function MaskedValueTrigger(props: ComponentProps<typeof Button>) {
return (
<Button
variant='ghost'
size='sm'
{...props}
className={cn(
'text-muted-foreground h-7 max-w-full min-w-0 justify-start truncate px-0 font-mono text-xs hover:bg-transparent aria-expanded:bg-transparent',
props.className
)}
/>
)
}
interface MaskedValueDisplayProps { interface MaskedValueDisplayProps {
/** 弹层内标题,如 "Full API Key" / "Full Code" */ /** 弹层内标题,如 "Full API Key" / "Full Code" */
...@@ -44,15 +61,7 @@ export function MaskedValueDisplay(props: MaskedValueDisplayProps) { ...@@ -44,15 +61,7 @@ export function MaskedValueDisplay(props: MaskedValueDisplayProps) {
return ( return (
<div className='flex max-w-full min-w-0 items-center'> <div className='flex max-w-full min-w-0 items-center'>
<Popover> <Popover>
<PopoverTrigger <PopoverTrigger render={<MaskedValueTrigger />}>
render={
<Button
variant='ghost'
size='sm'
className='h-7 max-w-full min-w-0 justify-start truncate px-0 font-mono hover:bg-transparent aria-expanded:bg-transparent'
/>
}
>
<span className='truncate'>{props.maskedValue}</span> <span className='truncate'>{props.maskedValue}</span>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent
......
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { Fragment, type ReactNode } from 'react'
import { Button } from '@/components/ui/button'
import {
Popover,
PopoverContent,
PopoverTitle,
PopoverTrigger,
} from '@/components/ui/popover'
import { cn } from '@/lib/utils'
type QuotaDetailsPopoverProps = {
title: string
triggerLabel: string
details: ReadonlyArray<{ label: string; value: string }>
description?: string
children: ReactNode
afterTrigger?: ReactNode
className?: string
triggerClassName?: string
}
export function QuotaDetailsPopover(props: QuotaDetailsPopoverProps) {
return (
<Popover>
<div className={cn('w-full min-w-0', props.className)}>
<PopoverTrigger
render={
<Button
variant='ghost'
aria-label={props.triggerLabel}
className={cn(
'h-auto w-full min-w-0 justify-start px-0 py-0.5 text-left font-normal hover:bg-transparent aria-expanded:bg-transparent',
props.triggerClassName
)}
/>
}
>
{props.children}
</PopoverTrigger>
{props.afterTrigger}
</div>
<PopoverContent
align='start'
className='w-72 max-w-[calc(100vw-2rem)] gap-3 p-3'
>
<PopoverTitle>{props.title}</PopoverTitle>
<dl className='grid grid-cols-[1fr_auto] gap-x-4 gap-y-2 text-sm tabular-nums'>
{props.details.map((detail) => (
<Fragment key={detail.label}>
<dt className='text-muted-foreground'>{detail.label}</dt>
<dd className='text-right break-all'>{detail.value}</dd>
</Fragment>
))}
</dl>
{props.description && (
<p className='text-muted-foreground text-xs leading-relaxed'>
{props.description}
</p>
)}
</PopoverContent>
</Popover>
)
}
...@@ -22,17 +22,31 @@ import * as React from 'react' ...@@ -22,17 +22,31 @@ import * as React from 'react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
function Table({ className, ...props }: React.ComponentProps<'table'>) { function Table({
className,
withContainer = true,
...props
}: React.ComponentProps<'table'> & { withContainer?: boolean }) {
const table = (
<table
data-slot='table'
className={cn(
'w-full caption-bottom text-sm tabular-nums [font-family:var(--font-body)] [&_td]:text-sm [&_td]:font-medium [&_th]:text-sm [&_:is(th,td)_*]:[font-family:inherit] [&_:is(th,td)_*]:[font-size:inherit] [&_:is(th,td)_*]:[font-weight:inherit]',
'[&_[data-table-text=secondary]]:text-xs [&_[data-table-text=secondary]]:font-normal',
className
)}
{...props}
/>
)
if (!withContainer) return table
return ( return (
<div <div
data-slot='table-container' data-slot='table-container'
className='relative w-full overflow-x-auto overflow-y-hidden' className='relative w-full overflow-x-auto overflow-y-hidden'
> >
<table {table}
data-slot='table'
className={cn('w-full caption-bottom text-sm tabular-nums', className)}
{...props}
/>
</div> </div>
) )
} }
......
...@@ -113,7 +113,7 @@ describe('API key group table cell', () => { ...@@ -113,7 +113,7 @@ describe('API key group table cell', () => {
color, color,
border, border,
'rounded-full', 'rounded-full',
'font-mono', 'tabular-nums',
'h-5', 'h-5',
'min-w-12' 'min-w-12'
) )
......
...@@ -166,7 +166,7 @@ describe('API key group combobox Auto effect', () => { ...@@ -166,7 +166,7 @@ describe('API key group combobox Auto effect', () => {
'h-5', 'h-5',
'min-w-12', 'min-w-12',
'rounded-full', 'rounded-full',
'font-mono', 'tabular-nums',
'border-muted-foreground/30' 'border-muted-foreground/30'
) )
expect(defaultRatio).not.toHaveTextContent('Ratio') expect(defaultRatio).not.toHaveTextContent('Ratio')
......
...@@ -508,7 +508,7 @@ it('keeps mobile quota readable and opens complete model and IP restrictions by ...@@ -508,7 +508,7 @@ it('keeps mobile quota readable and opens complete model and IP restrictions by
'font-normal' 'font-normal'
) )
expect(within(quota).getByText('4,490.16')).toHaveClass( expect(within(quota).getByText('4,490.16')).toHaveClass(
'font-mono', 'tabular-nums',
'text-sm', 'text-sm',
'font-normal', 'font-normal',
'text-right' 'text-right'
......
...@@ -18,13 +18,7 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -18,13 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button' import { QuotaDetailsPopover } from '@/components/quota-details-popover'
import {
Popover,
PopoverContent,
PopoverTitle,
PopoverTrigger,
} from '@/components/ui/popover'
import { Progress } from '@/components/ui/progress' import { Progress } from '@/components/ui/progress'
import { toIntlLocale } from '@/i18n/languages' import { toIntlLocale } from '@/i18n/languages'
import { formatQuotaWithCurrency, getCurrencyDisplay } from '@/lib/currency' import { formatQuotaWithCurrency, getCurrencyDisplay } from '@/lib/currency'
...@@ -78,78 +72,45 @@ export function ApiKeyQuotaCell(props: ApiKeyQuotaCellProps) { ...@@ -78,78 +72,45 @@ export function ApiKeyQuotaCell(props: ApiKeyQuotaCellProps) {
? `${t('Unlimited')}; ${usageDescription}` ? `${t('Unlimited')}; ${usageDescription}`
: `${remainingDescription}; ${usageDescription}` : `${remainingDescription}; ${usageDescription}`
const details = []
if (!props.apiKey.unlimited_quota) {
details.push({ label: t('Remaining'), value: formattedRemaining })
}
details.push({ label: t('Used amount'), value: formattedUsed })
if (!props.apiKey.unlimited_quota) {
details.push({ label: t('Current total quota'), value: formattedTotal })
}
if (hasProgress) {
details.push({
label: t('Remaining percentage'),
value: `${formattedPercentage}%`,
})
}
return ( return (
<Popover> <QuotaDetailsPopover
<div title={`${t('Quota')} (${quotaUnit})`}
className={cn( triggerLabel={
'w-full min-w-0', props.variant === 'card'
props.variant === 'card' ? 'space-y-2.5' : 'max-w-45 space-y-1.5' ? `${t('Quota')} (${quotaUnit}); ${triggerLabel}`
)} : triggerLabel
> }
<PopoverTrigger details={details}
render={ description={
<Button props.apiKey.unlimited_quota
variant='ghost' ? t(
aria-label={ 'This API key has no quota limit. Requests still require available wallet or subscription quota.'
props.variant === 'card' )
? `${t('Quota')} (${quotaUnit}); ${triggerLabel}` : t(
: triggerLabel 'Total = used + remaining. It is not an initial allocation or a periodic budget; changing the remaining quota changes the total and percentage.'
} )
className={cn( }
'h-auto w-full min-w-0 justify-start px-0 text-left font-normal hover:bg-transparent aria-expanded:bg-transparent', className={
props.variant === 'card' ? 'py-0' : 'py-0.5' props.variant === 'card' ? 'space-y-2.5' : 'max-w-45 space-y-1.5'
)} }
/> triggerClassName={props.variant === 'card' ? 'py-0' : undefined}
} afterTrigger={
> !props.apiKey.unlimited_quota && (
<span
data-slot='api-key-quota-values'
className={cn(
'grid w-full min-w-0 items-baseline gap-x-2 gap-y-1 text-xs',
props.variant === 'card'
? 'grid-cols-[auto_minmax(0,1fr)]'
: 'grid-cols-2'
)}
>
{props.variant === 'card' && (
<span className='text-muted-foreground'>
{t('Remaining')}
<span className='ml-1'>({quotaUnit})</span>
</span>
)}
<span
className={cn(
'min-w-0 truncate',
props.variant === 'card'
? 'text-right text-sm leading-5 font-normal'
: 'text-left font-medium',
!props.apiKey.unlimited_quota && 'font-mono tabular-nums',
!props.apiKey.unlimited_quota &&
remaining < 0 &&
'text-destructive',
remaining === 0 &&
!props.apiKey.unlimited_quota &&
'text-muted-foreground'
)}
>
{props.apiKey.unlimited_quota
? t('Unlimited')
: formattedRemaining}
</span>
{props.variant === 'card' && (
<span className='text-muted-foreground'>{t('Used amount')}</span>
)}
<span
className={cn(
'text-muted-foreground min-w-0 truncate text-right font-mono tabular-nums',
props.variant === 'card' && 'text-sm leading-5 font-normal'
)}
>
{formattedUsed}
</span>
</span>
</PopoverTrigger>
{!props.apiKey.unlimited_quota && (
<Progress <Progress
value={percentage} value={percentage}
aria-label={t('Remaining percentage')} aria-label={t('Remaining percentage')}
...@@ -158,55 +119,53 @@ export function ApiKeyQuotaCell(props: ApiKeyQuotaCellProps) { ...@@ -158,55 +119,53 @@ export function ApiKeyQuotaCell(props: ApiKeyQuotaCellProps) {
progressColor progressColor
)} )}
/> />
)
}
>
<span
data-slot='api-key-quota-values'
className={cn(
'grid w-full min-w-0 items-baseline gap-x-2 gap-y-1 text-sm',
props.variant === 'card'
? 'grid-cols-[auto_minmax(0,1fr)]'
: 'grid-cols-2'
)} )}
</div>
<PopoverContent
align='start'
className='w-72 max-w-[calc(100vw-2rem)] gap-3 p-3'
> >
<PopoverTitle> {props.variant === 'card' && (
{t('Quota')} ({quotaUnit}) <span className='text-muted-foreground'>
</PopoverTitle> {t('Remaining')}
<dl className='grid grid-cols-[1fr_auto] gap-x-4 gap-y-2 text-sm tabular-nums'> <span className='ml-1'>({quotaUnit})</span>
{!props.apiKey.unlimited_quota && ( </span>
<> )}
<dt className='text-muted-foreground'>{t('Remaining')}</dt> <span
<dd className='text-right font-mono break-all'> className={cn(
{formattedRemaining} 'min-w-0 truncate',
</dd> props.variant === 'card'
</> ? 'text-right text-sm leading-5 font-normal'
)} : 'text-left font-medium',
<dt className='text-muted-foreground'>{t('Used amount')}</dt> !props.apiKey.unlimited_quota && 'tabular-nums',
<dd className='text-right font-mono break-all'>{formattedUsed}</dd> !props.apiKey.unlimited_quota &&
{!props.apiKey.unlimited_quota && ( remaining < 0 &&
<> 'text-destructive',
<dt className='text-muted-foreground'> remaining === 0 &&
{t('Current total quota')} !props.apiKey.unlimited_quota &&
</dt> 'text-muted-foreground'
<dd className='text-right font-mono break-all'>
{formattedTotal}
</dd>
</>
)} )}
{hasProgress && ( >
<> {props.apiKey.unlimited_quota ? t('Unlimited') : formattedRemaining}
<dt className='text-muted-foreground'> </span>
{t('Remaining percentage')} {props.variant === 'card' && (
</dt> <span className='text-muted-foreground'>{t('Used amount')}</span>
<dd className='text-right font-mono'>{formattedPercentage}%</dd> )}
</> <span
className={cn(
'text-muted-foreground min-w-0 truncate text-right tabular-nums',
props.variant === 'card' && 'text-sm leading-5 font-normal'
)} )}
</dl> >
<p className='text-muted-foreground text-xs leading-relaxed'> {formattedUsed}
{props.apiKey.unlimited_quota </span>
? t( </span>
'This API key has no quota limit. Requests still require available wallet or subscription quota.' </QuotaDetailsPopover>
)
: t(
'Total = used + remaining. It is not an initial allocation or a periodic budget; changing the remaining quota changes the total and percentage.'
)}
</p>
</PopoverContent>
</Popover>
) )
} }
...@@ -18,108 +18,32 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -18,108 +18,32 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { import { ActivityTimeCell } from '@/components/activity-time-cell'
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { toIntlLocale } from '@/i18n/languages'
import dayjs from '@/lib/dayjs' import dayjs from '@/lib/dayjs'
import { formatTimestampRelative, formatTimestampToDate } from '@/lib/format'
import { cn } from '@/lib/utils'
import type { ApiKey } from '../types' import type { ApiKey } from '../types'
interface ApiKeyTimestampCellProps { export { TimestampCell as ApiKeyTimestampCell } from '@/components/activity-time-cell'
timestamp: number
now: number
locale?: string
justNowLabel: string
className?: string
}
export function ApiKeyTimestampCell(props: ApiKeyTimestampCellProps) {
if (!props.timestamp || props.timestamp === -1) {
return <span className='text-muted-foreground text-xs'>-</span>
}
const timestampMs = props.timestamp * 1000
const isJustNow = timestampMs <= props.now && props.now - timestampMs < 60_000
const relativeTime = isJustNow
? props.justNowLabel
: formatTimestampRelative(props.timestamp, 'seconds', props.locale)
const [relativePrefix, relativeNumber, relativeSuffix] = relativeTime.split(
/(\p{Number}+(?:[.,\u00a0\u202f]\p{Number}+)*)/u
)
const absoluteTime = formatTimestampToDate(props.timestamp)
return (
<Tooltip>
<TooltipTrigger
render={
<time
dateTime={new Date(timestampMs).toISOString()}
tabIndex={0}
className={cn('block truncate text-xs', props.className)}
/>
}
>
{relativePrefix}
{relativeNumber && (
<span className='font-mono tabular-nums'>{relativeNumber}</span>
)}
{relativeSuffix}
</TooltipTrigger>
<TooltipContent>
<span className='font-mono tabular-nums'>{absoluteTime}</span>
</TooltipContent>
</Tooltip>
)
}
export function ApiKeyActivityCell(props: { export function ApiKeyActivityCell(props: {
apiKey: ApiKey apiKey: ApiKey
now: number now: number
layout?: 'rows' | 'columns' layout?: 'rows' | 'columns'
}) { }) {
const { t, i18n } = useTranslation() const { t } = useTranslation()
const locale = toIntlLocale(i18n.resolvedLanguage || i18n.language)
const accessedTime = props.apiKey.accessed_time const accessedTime = props.apiKey.accessed_time
const isStale = const isStale =
accessedTime > 0 && accessedTime > 0 &&
accessedTime * 1000 < dayjs(props.now).subtract(3, 'month').valueOf() accessedTime * 1000 < dayjs(props.now).subtract(3, 'month').valueOf()
return ( return (
<div <ActivityTimeCell
className={cn( createdAt={props.apiKey.created_time}
'grid min-w-0 gap-y-1 text-xs', lastAt={accessedTime}
props.layout === 'columns' lastLabel={t('Last Used')}
? 'grid-flow-col grid-cols-2 grid-rows-[auto_1fr] items-start gap-x-3' lastClassName={isStale ? 'text-warning' : 'text-muted-foreground'}
: 'grid-cols-[auto_minmax(0,1fr)] items-center gap-x-2' now={props.now}
)} layout={props.layout}
> />
<span className='text-muted-foreground'>{t('Created')}</span>
<ApiKeyTimestampCell
timestamp={props.apiKey.created_time}
now={props.now}
locale={locale}
justNowLabel={t('Just now')}
className={cn(
'text-muted-foreground',
props.layout === 'columns' && 'whitespace-normal'
)}
/>
<span className='text-muted-foreground'>{t('Last Used')}</span>
<ApiKeyTimestampCell
timestamp={accessedTime}
now={props.now}
locale={locale}
justNowLabel={t('Just now')}
className={cn(
isStale ? 'text-warning' : 'text-muted-foreground',
props.layout === 'columns' && 'whitespace-normal'
)}
/>
</div>
) )
} }
...@@ -21,6 +21,7 @@ import { useState, useCallback } from 'react' ...@@ -21,6 +21,7 @@ import { useState, useCallback } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { BadgeCell } from '@/components/data-table' import { BadgeCell } from '@/components/data-table'
import { MaskedValueTrigger } from '@/components/masked-value-display'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
...@@ -86,15 +87,7 @@ export function ApiKeyCell({ apiKey }: { apiKey: ApiKey }) { ...@@ -86,15 +87,7 @@ export function ApiKeyCell({ apiKey }: { apiKey: ApiKey }) {
return ( return (
<div className='flex max-w-full min-w-0 items-center'> <div className='flex max-w-full min-w-0 items-center'>
<Popover open={popoverOpen} onOpenChange={handlePopoverOpen}> <Popover open={popoverOpen} onOpenChange={handlePopoverOpen}>
<PopoverTrigger <PopoverTrigger render={<MaskedValueTrigger />}>
render={
<Button
variant='ghost'
size='sm'
className='text-muted-foreground h-7 max-w-full min-w-0 justify-start truncate px-0 font-mono text-xs hover:bg-transparent aria-expanded:bg-transparent'
/>
}
>
<span className='truncate'>{maskedKey}</span> <span className='truncate'>{maskedKey}</span>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent
......
...@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { type ColumnDef } from '@tanstack/react-table' import type { ColumnDef } from '@tanstack/react-table'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { MaskedValueDisplay } from '@/components/masked-value-display' import { MaskedValueDisplay } from '@/components/masked-value-display'
...@@ -32,7 +32,7 @@ import { formatQuota, formatTimestampToDate } from '@/lib/format' ...@@ -32,7 +32,7 @@ import { formatQuota, formatTimestampToDate } from '@/lib/format'
import { REDEMPTION_FILTER_EXPIRED, REDEMPTION_STATUSES } from '../constants' import { REDEMPTION_FILTER_EXPIRED, REDEMPTION_STATUSES } from '../constants'
import { isRedemptionExpired, isTimestampExpired } from '../lib' import { isRedemptionExpired, isTimestampExpired } from '../lib'
import { type Redemption } from '../types' import type { Redemption } from '../types'
import { DataTableRowActions } from './data-table-row-actions' import { DataTableRowActions } from './data-table-row-actions'
export function useRedemptionsColumns(): ColumnDef<Redemption>[] { export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
...@@ -135,7 +135,7 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] { ...@@ -135,7 +135,7 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
{ {
id: 'code', id: 'code',
accessorKey: 'key', accessorKey: 'key',
header: t('Code'), header: t('Redemption Code'),
cell: function CodeCell({ row }) { cell: function CodeCell({ row }) {
const redemption = row.original const redemption = row.original
const key = redemption.key const key = redemption.key
......
...@@ -42,8 +42,10 @@ import { createInstance } from 'i18next' ...@@ -42,8 +42,10 @@ import { createInstance } from 'i18next'
import { I18nextProvider } from 'react-i18next' import { I18nextProvider } from 'react-i18next'
import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import zh from '@/i18n/locales/zh.json' import zh from '@/i18n/locales/zh.json'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { formatTimestampToDate } from '@/lib/format'
import { useAuthStore } from '@/stores/auth-store' import { useAuthStore } from '@/stores/auth-store'
import { import {
DEFAULT_CURRENCY_CONFIG, DEFAULT_CURRENCY_CONFIG,
...@@ -132,7 +134,7 @@ afterEach(() => { ...@@ -132,7 +134,7 @@ afterEach(() => {
.setConfig({ currency: { ...DEFAULT_CURRENCY_CONFIG } }) .setConfig({ currency: { ...DEFAULT_CURRENCY_CONFIG } })
}) })
it('shows labeled balance and cumulative usage in one sortable quota column', () => { it('shows balance above secondary usage text and opens quota details on click', async () => {
render( render(
<I18nextProvider i18n={i18n}> <I18nextProvider i18n={i18n}>
<QuotaTable remaining={1900} used={1100} /> <QuotaTable remaining={1900} used={1100} />
...@@ -148,32 +150,58 @@ it('shows labeled balance and cumulative usage in one sortable quota column', () ...@@ -148,32 +150,58 @@ it('shows labeled balance and cumulative usage in one sortable quota column', ()
expect( expect(
within(cells[0]).queryByText('Available Balance') within(cells[0]).queryByText('Available Balance')
).not.toBeInTheDocument() ).not.toBeInTheDocument()
expect(screen.getByText('0.0038').parentElement).toHaveClass('text-left')
expect(within(cells[0]).getByText('0.0022')).toBeInTheDocument() expect(within(cells[0]).getByText('0.0022')).toBeInTheDocument()
expect(screen.getByText('0.0038').parentElement).toHaveClass('grid-cols-1')
expect(screen.getByText('Used amount').parentElement).toHaveAttribute(
'data-table-text',
'secondary'
)
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument() expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
expect(screen.queryByText('0.006')).not.toBeInTheDocument() expect(screen.queryByText('0.006')).not.toBeInTheDocument()
const trigger = screen.getByRole('button', {
name: 'Available Balance 0.0038; Used amount 0.0022',
})
await userEvent.click(trigger)
const detail = await screen.findByRole('dialog', { name: 'Quota ($)' })
expect(within(detail).getByText('Available Balance')).toBeInTheDocument()
expect(within(detail).getByText('Total Used')).toBeInTheDocument()
expect(within(detail).getByText('0.0038')).toBeInTheDocument()
expect(within(detail).getByText('0.0022')).toBeInTheDocument()
expect(within(detail).queryByRole('progressbar')).not.toBeInTheDocument()
await userEvent.keyboard('{Escape}')
await waitFor(() =>
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
)
expect(trigger).toHaveFocus()
}) })
it.each([0, 500000])( it.each([0, 500000])(
'shows usage for a zero balance only when used quota is nonzero (used=%s)', 'shows usage for a zero balance only when used quota is nonzero (used=%s)',
(used) => { async (used) => {
render( render(
<I18nextProvider i18n={i18n}> <I18nextProvider i18n={i18n}>
<QuotaTable remaining={0} used={used} /> <QuotaTable remaining={0} used={used} />
</I18nextProvider> </I18nextProvider>
) )
if (used === 0) { if (used === 0) {
expect(screen.getByRole('cell')).toHaveTextContent(/^No Quota$/) expect(screen.getAllByRole('cell')[0]).toHaveTextContent(/^No Quota$/)
expect(screen.queryByText('Used amount')).not.toBeInTheDocument() expect(screen.queryByText('Used amount')).not.toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'No Quota' }))
const detail = await screen.findByRole('dialog', { name: 'Quota ($)' })
expect(within(detail).getAllByText('0')).toHaveLength(2)
return return
} }
expect(screen.queryByText('No Quota')).not.toBeInTheDocument() expect(screen.queryByText('No Quota')).not.toBeInTheDocument()
expect(within(screen.getByRole('cell')).getByText('0')).toBeInTheDocument() expect(
expect(within(screen.getByRole('cell')).getByText('1')).toBeInTheDocument() within(screen.getAllByRole('cell')[0]).getByText('0')
).toBeInTheDocument()
expect(
within(screen.getAllByRole('cell')[0]).getByText('1')
).toBeInTheDocument()
} }
) )
it('preserves a negative balance and uses warning styling', () => { it('preserves negative balances in details opened with the keyboard', async () => {
render( render(
<I18nextProvider i18n={i18n}> <I18nextProvider i18n={i18n}>
<QuotaTable remaining={-500000} used={1000000} /> <QuotaTable remaining={-500000} used={1000000} />
...@@ -181,6 +209,11 @@ it('preserves a negative balance and uses warning styling', () => { ...@@ -181,6 +209,11 @@ it('preserves a negative balance and uses warning styling', () => {
) )
expect(screen.getByText('-1')).toHaveClass('text-destructive') expect(screen.getByText('-1')).toHaveClass('text-destructive')
expect(screen.getByText('2')).toBeInTheDocument() expect(screen.getByText('2')).toBeInTheDocument()
await userEvent.tab()
await userEvent.keyboard('{Enter}')
const detail = await screen.findByRole('dialog')
expect(within(detail).getByText('-1')).toBeInTheDocument()
expect(within(detail).getByText('2')).toBeInTheDocument()
}) })
it('shows the custom symbol only in the column header', () => { it('shows the custom symbol only in the column header', () => {
...@@ -200,20 +233,24 @@ it('shows the custom symbol only in the column header', () => { ...@@ -200,20 +233,24 @@ it('shows the custom symbol only in the column header', () => {
expect( expect(
screen.getByRole('columnheader', { name: 'Available Balance (🐱)' }) screen.getByRole('columnheader', { name: 'Available Balance (🐱)' })
).toBeInTheDocument() ).toBeInTheDocument()
expect(screen.getByRole('cell')).not.toHaveTextContent('🐱') for (const cell of screen.getAllByRole('cell')) {
expect(cell).not.toHaveTextContent('🐱')
}
expect( expect(
within(screen.getByRole('cell')).getByText('0.0038') within(screen.getAllByRole('cell')[0]).getByText('0.0038')
).toBeInTheDocument() ).toBeInTheDocument()
expect( expect(
within(screen.getByRole('cell')).getByText('0.0022') within(screen.getAllByRole('cell')[0]).getByText('0.0022')
).toBeInTheDocument() ).toBeInTheDocument()
}) })
function UsersPage() { function UsersPage() {
return ( return (
<UsersProvider> <TooltipProvider>
<UsersTable /> <UsersProvider>
</UsersProvider> <UsersTable />
</UsersProvider>
</TooltipProvider>
) )
} }
...@@ -233,6 +270,8 @@ async function renderUsersList(emptyInvitation = false) { ...@@ -233,6 +270,8 @@ async function renderUsersList(emptyInvitation = false) {
quota: 1900, quota: 1900,
used_quota: 1100, used_quota: 1100,
request_count: 0, request_count: 0,
created_at: Math.floor(Date.now() / 1000) - 86400,
last_login_at: Math.floor(Date.now() / 1000) - 20,
group: 'default', group: 'default',
aff_count: emptyInvitation ? 0 : 2, aff_count: emptyInvitation ? 0 : 2,
aff_history_quota: emptyInvitation ? 0 : 500000, aff_history_quota: emptyInvitation ? 0 : 500000,
...@@ -291,7 +330,7 @@ it('sends balance sorting to the server and keeps invitation details on two line ...@@ -291,7 +330,7 @@ it('sends balance sorting to the server and keeps invitation details on two line
expect(screen.getByText(/Invited 2 users · Earnings:/)).toBeInTheDocument() expect(screen.getByText(/Invited 2 users · Earnings:/)).toBeInTheDocument()
}) })
it('shows both labeled amounts on mobile cards in Chinese', async () => { it('shows balance above usage on mobile cards in Chinese', async () => {
const originalMatchMedia = window.matchMedia const originalMatchMedia = window.matchMedia
vi.spyOn(window, 'matchMedia').mockImplementation((query) => ({ vi.spyOn(window, 'matchMedia').mockImplementation((query) => ({
...originalMatchMedia(query), ...originalMatchMedia(query),
...@@ -306,16 +345,22 @@ it('shows both labeled amounts on mobile cards in Chinese', async () => { ...@@ -306,16 +345,22 @@ it('shows both labeled amounts on mobile cards in Chinese', async () => {
expect(screen.getByText('0.0038')).toBeInTheDocument() expect(screen.getByText('0.0038')).toBeInTheDocument()
expect(screen.getByText('0.0022')).toBeInTheDocument() expect(screen.getByText('0.0022')).toBeInTheDocument()
expect(screen.queryByRole('table')).not.toBeInTheDocument() expect(screen.queryByRole('table')).not.toBeInTheDocument()
await userEvent.click(
screen.getByRole('button', { name: /可用余额 0.0038/ })
)
const detail = await screen.findByRole('dialog', { name: '额度 ($)' })
expect(within(detail).getByText('累计已用')).toBeInTheDocument()
expect(within(detail).getByText('0.0022')).toBeInTheDocument()
await userEvent.keyboard('{Escape}')
} finally { } finally {
await i18n.changeLanguage('en') await i18n.changeLanguage('en')
} }
}) })
it('hides date columns by default and replaces empty invitation information with a dash', async () => { it('combines creation and last login into one column with full dates visible directly', async () => {
vi.spyOn(Date, 'now').mockReturnValue(Date.UTC(2026, 8, 8, 12))
await renderUsersList(true) await renderUsersList(true)
expect( expect(screen.getByRole('columnheader', { name: /Time/ })).toBeInTheDocument()
screen.queryByRole('columnheader', { name: /Created At/ })
).not.toBeInTheDocument()
expect( expect(
screen.queryByRole('columnheader', { name: /Last Login/ }) screen.queryByRole('columnheader', { name: /Last Login/ })
).not.toBeInTheDocument() ).not.toBeInTheDocument()
...@@ -323,14 +368,23 @@ it('hides date columns by default and replaces empty invitation information with ...@@ -323,14 +368,23 @@ it('hides date columns by default and replaces empty invitation information with
name: /long-user-name-for-table-layout/, name: /long-user-name-for-table-layout/,
}) })
expect(within(row).getByText('—')).toBeInTheDocument() expect(within(row).getByText('—')).toBeInTheDocument()
const times = within(row).getAllByRole('time')
expect(times).toHaveLength(2)
expect(times[0]).toHaveTextContent(
formatTimestampToDate(Math.floor(Date.now() / 1000) - 86400)
)
expect(times[1]).toHaveTextContent(
formatTimestampToDate(Math.floor(Date.now() / 1000) - 20)
)
expect(within(row).queryByText('Just now')).not.toBeInTheDocument()
expect(within(row).getByText('Created')).toBeInTheDocument()
expect(within(row).getByText('Last Login')).toBeInTheDocument()
expect(screen.queryByText('No Inviter')).not.toBeInTheDocument() expect(screen.queryByText('No Inviter')).not.toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'View' })) await userEvent.click(screen.getByRole('button', { name: 'View' }))
await userEvent.click( await userEvent.click(screen.getByRole('menuitemcheckbox', { name: 'Time' }))
screen.getByRole('menuitemcheckbox', { name: 'Created At' })
)
expect( expect(
screen.getByRole('columnheader', { name: /Created At/ }) screen.queryByRole('columnheader', { name: /Time/ })
).toBeInTheDocument() ).not.toBeInTheDocument()
}) })
it('updates the header unit and converted amounts together when currency settings change', () => { it('updates the header unit and converted amounts together when currency settings change', () => {
...@@ -351,9 +405,15 @@ it('updates the header unit and converted amounts together when currency setting ...@@ -351,9 +405,15 @@ it('updates the header unit and converted amounts together when currency setting
expect( expect(
screen.getByRole('columnheader', { name: 'Available Balance (¥)' }) screen.getByRole('columnheader', { name: 'Available Balance (¥)' })
).toBeInTheDocument() ).toBeInTheDocument()
expect(within(screen.getByRole('cell')).getByText('7')).toBeInTheDocument() expect(
expect(within(screen.getByRole('cell')).getByText('14')).toBeInTheDocument() within(screen.getAllByRole('cell')[0]).getByText('7')
expect(screen.getByRole('cell')).not.toHaveTextContent('¥') ).toBeInTheDocument()
expect(
within(screen.getAllByRole('cell')[0]).getByText('14')
).toBeInTheDocument()
for (const cell of screen.getAllByRole('cell')) {
expect(cell).not.toHaveTextContent('¥')
}
}) })
it('labels raw quota mode as tokens without introducing a currency symbol', () => { it('labels raw quota mode as tokens without introducing a currency symbol', () => {
...@@ -368,6 +428,10 @@ it('labels raw quota mode as tokens without introducing a currency symbol', () = ...@@ -368,6 +428,10 @@ it('labels raw quota mode as tokens without introducing a currency symbol', () =
expect( expect(
screen.getByRole('columnheader', { name: 'Available Balance (Tokens)' }) screen.getByRole('columnheader', { name: 'Available Balance (Tokens)' })
).toBeInTheDocument() ).toBeInTheDocument()
expect(within(screen.getByRole('cell')).getByText('100')).toBeInTheDocument() expect(
expect(within(screen.getByRole('cell')).getByText('200')).toBeInTheDocument() within(screen.getAllByRole('cell')[0]).getByText('100')
).toBeInTheDocument()
expect(
within(screen.getAllByRole('cell')[0]).getByText('200')
).toBeInTheDocument()
}) })
...@@ -18,8 +18,9 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -18,8 +18,9 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { QuotaDetailsPopover } from '@/components/quota-details-popover'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { formatQuotaWithCurrency } from '@/lib/currency' import { formatQuotaWithCurrency, getCurrencyDisplay } from '@/lib/currency'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useSystemConfigStore } from '@/stores/system-config-store' import { useSystemConfigStore } from '@/stores/system-config-store'
...@@ -32,34 +33,55 @@ export function UserQuotaCell(props: UserQuotaCellProps) { ...@@ -32,34 +33,55 @@ export function UserQuotaCell(props: UserQuotaCellProps) {
const { t } = useTranslation() const { t } = useTranslation()
useSystemConfigStore((state) => state.config.currency) useSystemConfigStore((state) => state.config.currency)
if (props.remaining === 0 && props.used === 0) { const { meta: currency } = getCurrencyDisplay()
return ( const quotaUnit = currency.kind === 'tokens' ? t('Tokens') : currency.symbol
<StatusBadge const hasQuota = props.remaining !== 0 || props.used !== 0
label={t('No Quota')} const formattedRemaining = formatQuotaWithCurrency(props.remaining, {
variant='neutral' showSymbol: false,
copyable={false} })
className='-ml-1.5' const formattedUsed = formatQuotaWithCurrency(props.used, {
/> showSymbol: false,
) })
}
return ( return (
<div className='min-w-0 space-y-1 text-left tabular-nums'> <QuotaDetailsPopover
<div title={`${t('Quota')} (${quotaUnit})`}
className={cn( triggerLabel={
'font-mono text-sm font-semibold whitespace-nowrap', hasQuota
props.remaining < 0 && 'text-destructive', ? `${t('Available Balance')} ${formattedRemaining}; ${t('Used amount')} ${formattedUsed}`
props.remaining === 0 && 'text-muted-foreground' : t('No Quota')
)} }
> details={[
{formatQuotaWithCurrency(props.remaining, { showSymbol: false })} { label: t('Available Balance'), value: formattedRemaining },
</div> { label: t('Total Used'), value: formattedUsed },
<div className='text-muted-foreground flex items-baseline gap-1 text-xs whitespace-nowrap'> ]}
<span>{t('Used amount')}</span> >
<span className='font-mono'> {hasQuota ? (
{formatQuotaWithCurrency(props.used, { showSymbol: false })} <span className='grid min-w-0 grid-cols-1 gap-y-1 text-sm tabular-nums'>
<span
className={cn(
props.remaining < 0 && 'text-destructive',
props.remaining === 0 && 'text-muted-foreground'
)}
>
{formattedRemaining}
</span>
<span
data-table-text='secondary'
className='text-muted-foreground flex items-baseline gap-1 text-xs font-normal'
>
<span>{t('Used amount')}</span>
<span>{formattedUsed}</span>
</span>
</span> </span>
</div> ) : (
</div> <StatusBadge
label={t('No Quota')}
variant='neutral'
copyable={false}
className='-ml-1.5 font-normal'
/>
)}
</QuotaDetailsPopover>
) )
} }
...@@ -19,6 +19,7 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -19,6 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
import type { ColumnDef } from '@tanstack/react-table' import type { ColumnDef } from '@tanstack/react-table'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { ActivityTimeCell } from '@/components/activity-time-cell'
import { BadgeCell } from '@/components/data-table' import { BadgeCell } from '@/components/data-table'
import { GroupBadge } from '@/components/group-badge' import { GroupBadge } from '@/components/group-badge'
import { LongText } from '@/components/long-text' import { LongText } from '@/components/long-text'
...@@ -31,7 +32,7 @@ import { ...@@ -31,7 +32,7 @@ import {
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip' } from '@/components/ui/tooltip'
import { getCurrencyDisplay } from '@/lib/currency' import { getCurrencyDisplay } from '@/lib/currency'
import { formatQuota, formatTimestamp } from '@/lib/format' import { formatQuota } from '@/lib/format'
import { useSystemConfigStore } from '@/stores/system-config-store' import { useSystemConfigStore } from '@/stores/system-config-store'
import { import {
...@@ -80,7 +81,7 @@ export function useUsersColumns(): ColumnDef<User>[] { ...@@ -80,7 +81,7 @@ export function useUsersColumns(): ColumnDef<User>[] {
return ( return (
<TableId <TableId
value={row.getValue('id') as number} value={row.getValue('id') as number}
className='w-[60px] text-sm' className='w-[60px] [font-family:inherit] text-sm'
/> />
) )
}, },
...@@ -98,13 +99,19 @@ export function useUsersColumns(): ColumnDef<User>[] { ...@@ -98,13 +99,19 @@ export function useUsersColumns(): ColumnDef<User>[] {
return ( return (
<div className='flex min-w-[160px] flex-col gap-1'> <div className='flex min-w-[160px] flex-col gap-1'>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<LongText className='max-w-[140px] font-medium'> <LongText className='max-w-[140px] text-sm font-normal'>
{username} {username}
</LongText> </LongText>
{remark && ( {remark && (
<Tooltip> <Tooltip>
<TooltipTrigger <TooltipTrigger
render={<StatusBadge variant='success' copyable={false} />} render={
<StatusBadge
variant='success'
copyable={false}
className='font-normal'
/>
}
> >
<LongText className='max-w-[80px]'>{remark}</LongText> <LongText className='max-w-[80px]'>{remark}</LongText>
</TooltipTrigger> </TooltipTrigger>
...@@ -115,9 +122,12 @@ export function useUsersColumns(): ColumnDef<User>[] { ...@@ -115,9 +122,12 @@ export function useUsersColumns(): ColumnDef<User>[] {
)} )}
</div> </div>
{displayName && displayName !== username && ( {displayName && displayName !== username && (
<LongText className='text-muted-foreground max-w-[180px] text-xs'> <div
{displayName} data-table-text='secondary'
</LongText> className='text-muted-foreground max-w-[180px] text-xs font-normal'
>
<LongText>{displayName}</LongText>
</div>
)} )}
</div> </div>
) )
...@@ -148,6 +158,7 @@ export function useUsersColumns(): ColumnDef<User>[] { ...@@ -148,6 +158,7 @@ export function useUsersColumns(): ColumnDef<User>[] {
label={t(statusConfig.labelKey)} label={t(statusConfig.labelKey)}
variant={isUserDeleted(user) ? 'neutral' : statusConfig.variant} variant={isUserDeleted(user) ? 'neutral' : statusConfig.variant}
copyable={false} copyable={false}
className='font-normal'
/> />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
...@@ -184,7 +195,7 @@ export function useUsersColumns(): ColumnDef<User>[] { ...@@ -184,7 +195,7 @@ export function useUsersColumns(): ColumnDef<User>[] {
const group = row.getValue('group') as string const group = row.getValue('group') as string
return ( return (
<BadgeCell> <BadgeCell>
<GroupBadge group={group} /> <GroupBadge group={group} className='font-normal' />
</BadgeCell> </BadgeCell>
) )
}, },
...@@ -230,7 +241,10 @@ export function useUsersColumns(): ColumnDef<User>[] { ...@@ -230,7 +241,10 @@ export function useUsersColumns(): ColumnDef<User>[] {
} }
return ( return (
<div className='min-w-0 space-y-1 text-xs'> <div
data-table-text='secondary'
className='min-w-0 space-y-1 text-xs font-normal'
>
{(affCount > 0 || affHistoryQuota !== 0) && ( {(affCount > 0 || affHistoryQuota !== 0) && (
<LongText> <LongText>
{t('Invited {{count}} users', { count: affCount })} ·{' '} {t('Invited {{count}} users', { count: affCount })} ·{' '}
...@@ -254,30 +268,17 @@ export function useUsersColumns(): ColumnDef<User>[] { ...@@ -254,30 +268,17 @@ export function useUsersColumns(): ColumnDef<User>[] {
}, },
{ {
accessorKey: 'created_at', accessorKey: 'created_at',
header: t('Created At'), header: t('Time'),
cell: ({ row }) => { cell: ({ row }) => (
const ts = row.getValue('created_at') as number | undefined <ActivityTimeCell
return ( createdAt={row.original.created_at ?? 0}
<span className='text-muted-foreground text-sm'> lastAt={row.original.last_login_at ?? 0}
{ts ? formatTimestamp(ts) : '-'} lastLabel={t('Last Login')}
</span> format='absolute'
) />
}, ),
size: 180, size: 260,
meta: { mobileHidden: true }, minSize: 240,
},
{
accessorKey: 'last_login_at',
header: t('Last Login'),
cell: ({ row }) => {
const ts = row.getValue('last_login_at') as number | undefined
return (
<span className='text-muted-foreground text-sm'>
{ts ? formatTimestamp(ts) : '-'}
</span>
)
},
size: 180,
meta: { mobileHidden: true }, meta: { mobileHidden: true },
}, },
{ {
......
...@@ -174,7 +174,6 @@ export function UsersTable() { ...@@ -174,7 +174,6 @@ export function UsersTable() {
data: users, data: users,
columns, columns,
enableRowSelection: true, enableRowSelection: true,
initialColumnVisibility: { created_at: false, last_login_at: false },
columnFilters, columnFilters,
globalFilter, globalFilter,
pagination, pagination,
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment