-
- }
- >
-
- {props.variant === 'card' && (
-
- {t('Remaining')}
- ({quotaUnit})
-
- )}
-
- {props.apiKey.unlimited_quota
- ? t('Unlimited')
- : formattedRemaining}
-
- {props.variant === 'card' && (
- {t('Used amount')}
- )}
-
- {formattedUsed}
-
-
-
- {!props.apiKey.unlimited_quota && (
+
+ )
+ }
+ >
+
-
-
- {t('Quota')} ({quotaUnit})
-
-
- {!props.apiKey.unlimited_quota && (
- <>
- - {t('Remaining')}
- -
- {formattedRemaining}
-
- >
+ {props.variant === 'card' && (
+
+ {t('Remaining')}
+ ({quotaUnit})
+
+ )}
+ {t('Used amount')}
- - {formattedUsed}
- {!props.apiKey.unlimited_quota && (
- <>
- -
- {t('Current total quota')}
-
- -
- {formattedTotal}
-
- >
+ >
+ {props.apiKey.unlimited_quota ? t('Unlimited') : formattedRemaining}
+
+ {props.variant === 'card' && (
+ {t('Used amount')}
+ )}
+
- -
- {t('Remaining percentage')}
-
- - {formattedPercentage}%
- >
- )}
-
-
- {props.apiKey.unlimited_quota
- ? t(
- 'This API key has no quota limit. Requests still require available wallet or subscription quota.'
- )
- : t(
- 'Total = used + remaining. It is not an initial allocation or a periodic budget; changing the remaining quota changes the total and percentage.'
- )}
-
-
-
+ >
+ {formattedUsed}
+
+
+
)
}
diff --git a/web/src/features/keys/components/api-key-timestamp-cell.tsx b/web/src/features/keys/components/api-key-timestamp-cell.tsx
index f08f67272..2583e53e8 100644
--- a/web/src/features/keys/components/api-key-timestamp-cell.tsx
+++ b/web/src/features/keys/components/api-key-timestamp-cell.tsx
@@ -18,108 +18,32 @@ 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 { ActivityTimeCell } from '@/components/activity-time-cell'
import dayjs from '@/lib/dayjs'
-import { formatTimestampRelative, formatTimestampToDate } from '@/lib/format'
-import { cn } from '@/lib/utils'
import type { ApiKey } from '../types'
-interface ApiKeyTimestampCellProps {
- timestamp: number
- now: number
- locale?: string
- justNowLabel: string
- className?: string
-}
-
-export function ApiKeyTimestampCell(props: ApiKeyTimestampCellProps) {
- if (!props.timestamp || props.timestamp === -1) {
- return
-
- }
-
- 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 (
-
-
- }
- >
- {relativePrefix}
- {relativeNumber && (
- {relativeNumber}
- )}
- {relativeSuffix}
-
-
- {absoluteTime}
-
-
- )
-}
+export { TimestampCell as ApiKeyTimestampCell } from '@/components/activity-time-cell'
export function ApiKeyActivityCell(props: {
apiKey: ApiKey
now: number
layout?: 'rows' | 'columns'
}) {
- const { t, i18n } = useTranslation()
- const locale = toIntlLocale(i18n.resolvedLanguage || i18n.language)
+ const { t } = useTranslation()
const accessedTime = props.apiKey.accessed_time
const isStale =
accessedTime > 0 &&
accessedTime * 1000 < dayjs(props.now).subtract(3, 'month').valueOf()
return (
-
-
{t('Created')}
-
-
{t('Last Used')}
-
-
+
)
}
diff --git a/web/src/features/keys/components/api-keys-cells.tsx b/web/src/features/keys/components/api-keys-cells.tsx
index 39b334ec0..024fdffc0 100644
--- a/web/src/features/keys/components/api-keys-cells.tsx
+++ b/web/src/features/keys/components/api-keys-cells.tsx
@@ -21,6 +21,7 @@ import { useState, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { BadgeCell } from '@/components/data-table'
+import { MaskedValueTrigger } from '@/components/masked-value-display'
import { StatusBadge } from '@/components/status-badge'
import { Button } from '@/components/ui/button'
import {
@@ -86,15 +87,7 @@ export function ApiKeyCell({ apiKey }: { apiKey: ApiKey }) {
return (
-
- }
- >
+ }>
{maskedKey}
.
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 { MaskedValueDisplay } from '@/components/masked-value-display'
@@ -32,7 +32,7 @@ import { formatQuota, formatTimestampToDate } from '@/lib/format'
import { REDEMPTION_FILTER_EXPIRED, REDEMPTION_STATUSES } from '../constants'
import { isRedemptionExpired, isTimestampExpired } from '../lib'
-import { type Redemption } from '../types'
+import type { Redemption } from '../types'
import { DataTableRowActions } from './data-table-row-actions'
export function useRedemptionsColumns(): ColumnDef[] {
@@ -135,7 +135,7 @@ export function useRedemptionsColumns(): ColumnDef[] {
{
id: 'code',
accessorKey: 'key',
- header: t('Code'),
+ header: t('Redemption Code'),
cell: function CodeCell({ row }) {
const redemption = row.original
const key = redemption.key
diff --git a/web/src/features/users/components/__tests__/quota-display.test.tsx b/web/src/features/users/components/__tests__/quota-display.test.tsx
index f0a7d7fd8..8b48629fc 100644
--- a/web/src/features/users/components/__tests__/quota-display.test.tsx
+++ b/web/src/features/users/components/__tests__/quota-display.test.tsx
@@ -42,8 +42,10 @@ import { createInstance } from 'i18next'
import { I18nextProvider } from 'react-i18next'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import { TooltipProvider } from '@/components/ui/tooltip'
import zh from '@/i18n/locales/zh.json'
import { api } from '@/lib/api'
+import { formatTimestampToDate } from '@/lib/format'
import { useAuthStore } from '@/stores/auth-store'
import {
DEFAULT_CURRENCY_CONFIG,
@@ -132,7 +134,7 @@ afterEach(() => {
.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(
@@ -148,32 +150,58 @@ it('shows labeled balance and cumulative usage in one sortable quota column', ()
expect(
within(cells[0]).queryByText('Available Balance')
).not.toBeInTheDocument()
- expect(screen.getByText('0.0038').parentElement).toHaveClass('text-left')
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.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])(
'shows usage for a zero balance only when used quota is nonzero (used=%s)',
- (used) => {
+ async (used) => {
render(
)
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()
+ 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
}
expect(screen.queryByText('No Quota')).not.toBeInTheDocument()
- expect(within(screen.getByRole('cell')).getByText('0')).toBeInTheDocument()
- expect(within(screen.getByRole('cell')).getByText('1')).toBeInTheDocument()
+ expect(
+ 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(
@@ -181,6 +209,11 @@ it('preserves a negative balance and uses warning styling', () => {
)
expect(screen.getByText('-1')).toHaveClass('text-destructive')
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', () => {
@@ -200,20 +233,24 @@ it('shows the custom symbol only in the column header', () => {
expect(
screen.getByRole('columnheader', { name: 'Available Balance (🐱)' })
).toBeInTheDocument()
- expect(screen.getByRole('cell')).not.toHaveTextContent('🐱')
+ for (const cell of screen.getAllByRole('cell')) {
+ expect(cell).not.toHaveTextContent('🐱')
+ }
expect(
- within(screen.getByRole('cell')).getByText('0.0038')
+ within(screen.getAllByRole('cell')[0]).getByText('0.0038')
).toBeInTheDocument()
expect(
- within(screen.getByRole('cell')).getByText('0.0022')
+ within(screen.getAllByRole('cell')[0]).getByText('0.0022')
).toBeInTheDocument()
})
function UsersPage() {
return (
-
-
-
+
+
+
+
+
)
}
@@ -233,6 +270,8 @@ async function renderUsersList(emptyInvitation = false) {
quota: 1900,
used_quota: 1100,
request_count: 0,
+ created_at: Math.floor(Date.now() / 1000) - 86400,
+ last_login_at: Math.floor(Date.now() / 1000) - 20,
group: 'default',
aff_count: emptyInvitation ? 0 : 2,
aff_history_quota: emptyInvitation ? 0 : 500000,
@@ -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()
})
-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
vi.spyOn(window, 'matchMedia').mockImplementation((query) => ({
...originalMatchMedia(query),
@@ -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.0022')).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 {
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)
- expect(
- screen.queryByRole('columnheader', { name: /Created At/ })
- ).not.toBeInTheDocument()
+ expect(screen.getByRole('columnheader', { name: /Time/ })).toBeInTheDocument()
expect(
screen.queryByRole('columnheader', { name: /Last Login/ })
).not.toBeInTheDocument()
@@ -323,14 +368,23 @@ it('hides date columns by default and replaces empty invitation information with
name: /long-user-name-for-table-layout/,
})
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()
await userEvent.click(screen.getByRole('button', { name: 'View' }))
- await userEvent.click(
- screen.getByRole('menuitemcheckbox', { name: 'Created At' })
- )
+ await userEvent.click(screen.getByRole('menuitemcheckbox', { name: 'Time' }))
expect(
- screen.getByRole('columnheader', { name: /Created At/ })
- ).toBeInTheDocument()
+ screen.queryByRole('columnheader', { name: /Time/ })
+ ).not.toBeInTheDocument()
})
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
expect(
screen.getByRole('columnheader', { name: 'Available Balance (¥)' })
).toBeInTheDocument()
- expect(within(screen.getByRole('cell')).getByText('7')).toBeInTheDocument()
- expect(within(screen.getByRole('cell')).getByText('14')).toBeInTheDocument()
- expect(screen.getByRole('cell')).not.toHaveTextContent('¥')
+ expect(
+ within(screen.getAllByRole('cell')[0]).getByText('7')
+ ).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', () => {
@@ -368,6 +428,10 @@ it('labels raw quota mode as tokens without introducing a currency symbol', () =
expect(
screen.getByRole('columnheader', { name: 'Available Balance (Tokens)' })
).toBeInTheDocument()
- expect(within(screen.getByRole('cell')).getByText('100')).toBeInTheDocument()
- expect(within(screen.getByRole('cell')).getByText('200')).toBeInTheDocument()
+ expect(
+ within(screen.getAllByRole('cell')[0]).getByText('100')
+ ).toBeInTheDocument()
+ expect(
+ within(screen.getAllByRole('cell')[0]).getByText('200')
+ ).toBeInTheDocument()
})
diff --git a/web/src/features/users/components/user-quota-cell.tsx b/web/src/features/users/components/user-quota-cell.tsx
index 93e76e0d7..8af2015b4 100644
--- a/web/src/features/users/components/user-quota-cell.tsx
+++ b/web/src/features/users/components/user-quota-cell.tsx
@@ -18,8 +18,9 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { useTranslation } from 'react-i18next'
+import { QuotaDetailsPopover } from '@/components/quota-details-popover'
import { StatusBadge } from '@/components/status-badge'
-import { formatQuotaWithCurrency } from '@/lib/currency'
+import { formatQuotaWithCurrency, getCurrencyDisplay } from '@/lib/currency'
import { cn } from '@/lib/utils'
import { useSystemConfigStore } from '@/stores/system-config-store'
@@ -32,34 +33,55 @@ export function UserQuotaCell(props: UserQuotaCellProps) {
const { t } = useTranslation()
useSystemConfigStore((state) => state.config.currency)
- if (props.remaining === 0 && props.used === 0) {
- return (
-
- )
- }
+ const { meta: currency } = getCurrencyDisplay()
+ const quotaUnit = currency.kind === 'tokens' ? t('Tokens') : currency.symbol
+ const hasQuota = props.remaining !== 0 || props.used !== 0
+ const formattedRemaining = formatQuotaWithCurrency(props.remaining, {
+ showSymbol: false,
+ })
+ const formattedUsed = formatQuotaWithCurrency(props.used, {
+ showSymbol: false,
+ })
return (
-
-
- {formatQuotaWithCurrency(props.remaining, { showSymbol: false })}
-
-
- {t('Used amount')}
-
- {formatQuotaWithCurrency(props.used, { showSymbol: false })}
+
+ {hasQuota ? (
+
+
+ {formattedRemaining}
+
+
+ {t('Used amount')}
+ {formattedUsed}
+
-
-
+ ) : (
+
+ )}
+
)
}
diff --git a/web/src/features/users/components/users-columns.tsx b/web/src/features/users/components/users-columns.tsx
index f66dd4ca2..7d8e126b0 100644
--- a/web/src/features/users/components/users-columns.tsx
+++ b/web/src/features/users/components/users-columns.tsx
@@ -19,6 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
import type { ColumnDef } from '@tanstack/react-table'
import { useTranslation } from 'react-i18next'
+import { ActivityTimeCell } from '@/components/activity-time-cell'
import { BadgeCell } from '@/components/data-table'
import { GroupBadge } from '@/components/group-badge'
import { LongText } from '@/components/long-text'
@@ -31,7 +32,7 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip'
import { getCurrencyDisplay } from '@/lib/currency'
-import { formatQuota, formatTimestamp } from '@/lib/format'
+import { formatQuota } from '@/lib/format'
import { useSystemConfigStore } from '@/stores/system-config-store'
import {
@@ -80,7 +81,7 @@ export function useUsersColumns(): ColumnDef[] {
return (
)
},
@@ -98,13 +99,19 @@ export function useUsersColumns(): ColumnDef[] {
return (
-
+
{username}
{remark && (
}
+ render={
+
+ }
>
{remark}
@@ -115,9 +122,12 @@ export function useUsersColumns(): ColumnDef[] {
)}
{displayName && displayName !== username && (
-
- {displayName}
-
+
+ {displayName}
+
)}
)
@@ -148,6 +158,7 @@ export function useUsersColumns(): ColumnDef[] {
label={t(statusConfig.labelKey)}
variant={isUserDeleted(user) ? 'neutral' : statusConfig.variant}
copyable={false}
+ className='font-normal'
/>
@@ -184,7 +195,7 @@ export function useUsersColumns(): ColumnDef[] {
const group = row.getValue('group') as string
return (
-
+
)
},
@@ -230,7 +241,10 @@ export function useUsersColumns(): ColumnDef[] {
}
return (
-
+
{(affCount > 0 || affHistoryQuota !== 0) && (
{t('Invited {{count}} users', { count: affCount })} ·{' '}
@@ -254,30 +268,17 @@ export function useUsersColumns(): ColumnDef[] {
},
{
accessorKey: 'created_at',
- header: t('Created At'),
- cell: ({ row }) => {
- const ts = row.getValue('created_at') as number | undefined
- return (
-
- {ts ? formatTimestamp(ts) : '-'}
-
- )
- },
- size: 180,
- meta: { mobileHidden: true },
- },
- {
- accessorKey: 'last_login_at',
- header: t('Last Login'),
- cell: ({ row }) => {
- const ts = row.getValue('last_login_at') as number | undefined
- return (
-
- {ts ? formatTimestamp(ts) : '-'}
-
- )
- },
- size: 180,
+ header: t('Time'),
+ cell: ({ row }) => (
+
+ ),
+ size: 260,
+ minSize: 240,
meta: { mobileHidden: true },
},
{
diff --git a/web/src/features/users/components/users-table.tsx b/web/src/features/users/components/users-table.tsx
index f4073f508..bd9f4b61c 100644
--- a/web/src/features/users/components/users-table.tsx
+++ b/web/src/features/users/components/users-table.tsx
@@ -174,7 +174,6 @@ export function UsersTable() {
data: users,
columns,
enableRowSelection: true,
- initialColumnVisibility: { created_at: false, last_login_at: false },
columnFilters,
globalFilter,
pagination,