refactor(web): rework usage-logs date range picker and unify status row styling

- Replace native datetime-local inputs with a shadcn Calendar range picker:
  preset rail (today/7d/this~last week/30d/this~last month), dual-month
  desktop view, themed time inputs, fresh-range reselect, no outside days
- Default log time range now covers the whole current day (00:00-23:59:59)
- Fix range highlight invisible inside popovers (muted -> accent) and mark
  today by primary text instead of a fill that mimicked selection
- Extract shared calendar locale map (adds zh-TW) for all date pickers
- Generalize disabled-row treatment into status row palettes (error/info/
  warning) and apply them to usage-logs desktop rows and mobile cards
- Split "Earned this month" from "This month" i18n key to stop the check-in
  card translation leaking into date presets
This commit is contained in:
t0ng7u
2026-07-12 16:49:22 +08:00
parent 7c3e2cc66f
commit ed134bbd2b
20 changed files with 382 additions and 188 deletions
+27 -4
View File
@@ -80,8 +80,31 @@ export {
} from './hooks/use-data-table-view-mode'
export { useDebouncedColumnFilter } from './hooks/use-debounced-column-filter'
export const DISABLED_ROW_DESKTOP =
'[--data-table-card-bg:var(--table-disabled)] hover:[--data-table-card-bg:var(--table-disabled-hover)] data-[state=selected]:![--data-table-card-bg:var(--table-disabled)] data-[state=selected]:hover:![--data-table-card-bg:var(--table-disabled-hover)] [background-color:var(--table-disabled)] hover:[background-color:var(--table-disabled-hover)] [&>td:first-child]:[border-left-color:var(--table-disabled-border)] [&>td:first-child]:border-l-4 [&>td:first-child]:pl-1'
// Shared "status row" treatment: tinted background with a hover step and a
// 4px accent stripe on the first cell (desktop), tinted background only
// (mobile cards). The palette classes below just bind the three CSS
// variables, so every status row is structurally identical — only the hue
// changes (disabled = gray, error = red, info = blue, warning = amber).
const STATUS_ROW_DESKTOP =
'[background-color:var(--status-row-bg)] hover:[background-color:var(--status-row-bg-hover)] [--data-table-card-bg:var(--status-row-bg)] hover:[--data-table-card-bg:var(--status-row-bg-hover)] data-[state=selected]:![--data-table-card-bg:var(--status-row-bg)] data-[state=selected]:hover:![--data-table-card-bg:var(--status-row-bg-hover)] [&>td:first-child]:[border-left-color:var(--status-row-border)] [&>td:first-child]:border-l-4 [&>td:first-child]:pl-1'
export const DISABLED_ROW_MOBILE =
'[--data-table-card-bg:var(--table-disabled)] data-[state=selected]:![--data-table-card-bg:var(--table-disabled)] [background-color:var(--table-disabled)]'
const STATUS_ROW_MOBILE =
'[background-color:var(--status-row-bg)] [--data-table-card-bg:var(--status-row-bg)] data-[state=selected]:![--data-table-card-bg:var(--status-row-bg)]'
const DISABLED_PALETTE =
'[--status-row-bg:var(--table-disabled)] [--status-row-bg-hover:var(--table-disabled-hover)] [--status-row-border:var(--table-disabled-border)]'
const ERROR_PALETTE =
'[--status-row-bg:var(--table-error)] [--status-row-bg-hover:var(--table-error-hover)] [--status-row-border:var(--table-error-border)]'
const INFO_PALETTE =
'[--status-row-bg:var(--table-info)] [--status-row-bg-hover:var(--table-info-hover)] [--status-row-border:var(--table-info-border)]'
const WARNING_PALETTE =
'[--status-row-bg:var(--table-warning)] [--status-row-bg-hover:var(--table-warning-hover)] [--status-row-border:var(--table-warning-border)]'
export const DISABLED_ROW_DESKTOP = `${STATUS_ROW_DESKTOP} ${DISABLED_PALETTE}`
export const DISABLED_ROW_MOBILE = `${STATUS_ROW_MOBILE} ${DISABLED_PALETTE}`
export const ERROR_ROW_DESKTOP = `${STATUS_ROW_DESKTOP} ${ERROR_PALETTE}`
export const ERROR_ROW_MOBILE = `${STATUS_ROW_MOBILE} ${ERROR_PALETTE}`
export const INFO_ROW_DESKTOP = `${STATUS_ROW_DESKTOP} ${INFO_PALETTE}`
export const INFO_ROW_MOBILE = `${STATUS_ROW_MOBILE} ${INFO_PALETTE}`
export const WARNING_ROW_DESKTOP = `${STATUS_ROW_DESKTOP} ${WARNING_PALETTE}`
export const WARNING_ROW_MOBILE = `${STATUS_ROW_MOBILE} ${WARNING_PALETTE}`
+2 -12
View File
@@ -17,7 +17,6 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { Calendar as CalendarIcon } from 'lucide-react'
import { enUS, fr, ja, ru, vi, zhCN } from 'react-day-picker/locale'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/design-system/button'
@@ -27,17 +26,9 @@ import {
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { getCalendarLocale } from '@/lib/calendar-locale'
import dayjs from '@/lib/dayjs'
const calendarLocales = {
en: enUS,
zh: zhCN,
fr,
ru,
ja,
vi,
} as const
type DatePickerProps = {
selected: Date | undefined
onSelect: (date: Date | undefined) => void
@@ -51,8 +42,7 @@ export function DatePicker({
}: DatePickerProps) {
const { t, i18n } = useTranslation()
const placeholderText = placeholder ?? t('Pick a date')
const calendarLocale =
calendarLocales[i18n.language as keyof typeof calendarLocales] ?? enUS
const calendarLocale = getCalendarLocale(i18n.language)
return (
<Popover>
<PopoverTrigger
+2 -12
View File
@@ -18,7 +18,6 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { ChevronDownIcon } from 'lucide-react'
import * as React from 'react'
import { enUS, fr, ja, ru, vi, zhCN } from 'react-day-picker/locale'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/design-system/button'
@@ -29,18 +28,10 @@ import {
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { getCalendarLocale } from '@/lib/calendar-locale'
import dayjs from '@/lib/dayjs'
import { cn } from '@/lib/utils'
const calendarLocales = {
en: enUS,
zh: zhCN,
fr,
ru,
ja,
vi,
} as const
interface DateTimePickerProps {
value?: Date
onChange?: (date: Date | undefined) => void
@@ -56,8 +47,7 @@ export function DateTimePicker({
}: DateTimePickerProps) {
const { t, i18n } = useTranslation()
const placeholderText = placeholder ?? t('Select date')
const calendarLocale =
calendarLocales[i18n.language as keyof typeof calendarLocales] ?? enUS
const calendarLocale = getCalendarLocale(i18n.language)
const currentYear = new Date().getFullYear()
const [open, setOpen] = React.useState(false)
const [date, setDate] = React.useState<Date | undefined>(value)
+8 -4
View File
@@ -131,16 +131,20 @@ function Calendar({
defaultClassNames.day
),
range_start: cn(
'relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted',
// bg-accent (not bg-muted): muted matches the popover surface, so
// the range highlight would be invisible inside popover calendars.
'relative isolate z-0 rounded-l-(--cell-radius) bg-accent after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-accent',
defaultClassNames.range_start
),
range_middle: cn('rounded-none', defaultClassNames.range_middle),
range_end: cn(
'relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted',
'relative isolate z-0 rounded-r-(--cell-radius) bg-accent after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-accent',
defaultClassNames.range_end
),
today: cn(
'rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none',
// Mark today with primary-colored text only; a filled background
// would be indistinguishable from the accent range highlight.
'text-primary',
defaultClassNames.today
),
outside: cn(
@@ -245,7 +249,7 @@ function CalendarDayButton({
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
'relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70',
'relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-accent data-[range-middle=true]:text-foreground data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70',
defaultClassNames.day,
className
)}
@@ -332,7 +332,7 @@ export function CheckinCalendarCard({
{formatQuotaWithCurrency(monthlyQuota, { digitsLarge: 0 })}
</div>
<div className='text-muted-foreground mt-0.5 truncate text-xs'>
{t('This month')}
{t('Earned this month')}
</div>
</div>
<div className='min-w-0 px-3 py-3 text-center sm:py-4'>
@@ -18,15 +18,19 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { CalendarDays } from 'lucide-react'
import { useMemo, useState } from 'react'
import type { DateRange } from 'react-day-picker'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/design-system/button'
import { Input } from '@/components/design-system/input'
import { Calendar } from '@/components/ui/calendar'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { useIsMobile } from '@/hooks/use-mobile'
import { getCalendarLocale } from '@/lib/calendar-locale'
import dayjs from '@/lib/dayjs'
import { cn } from '@/lib/utils'
@@ -37,14 +41,107 @@ interface CompactDateTimeRangePickerProps {
className?: string
}
function toInputValue(date?: Date): string {
return date ? dayjs(date).format('YYYY-MM-DDTHH:mm') : ''
// Labels are translated at render time; keep them registered in
// src/i18n/static-keys.ts so the i18n sync tooling can see them.
const RANGE_PRESETS: Array<{
label: string
getRange: () => { start: Date; end: Date }
}> = [
{
label: 'Today',
getRange: () => {
const now = dayjs()
return {
start: now.startOf('day').toDate(),
end: now.endOf('day').toDate(),
}
},
},
{
label: '7 Days',
getRange: () => {
const now = dayjs()
return {
start: now.subtract(6, 'day').startOf('day').toDate(),
end: now.endOf('day').toDate(),
}
},
},
{
label: 'This week',
getRange: () => {
const now = dayjs()
return {
start: now.startOf('week').toDate(),
end: now.endOf('week').toDate(),
}
},
},
{
label: 'Last week',
getRange: () => {
const lastWeek = dayjs().subtract(1, 'week')
return {
start: lastWeek.startOf('week').toDate(),
end: lastWeek.endOf('week').toDate(),
}
},
},
{
label: '30 Days',
getRange: () => {
const now = dayjs()
return {
start: now.subtract(29, 'day').startOf('day').toDate(),
end: now.endOf('day').toDate(),
}
},
},
{
label: 'This month',
getRange: () => {
const now = dayjs()
return {
start: now.startOf('month').toDate(),
end: now.endOf('month').toDate(),
}
},
},
{
label: 'Last month',
getRange: () => {
const lastMonth = dayjs().subtract(1, 'month')
return {
start: lastMonth.startOf('month').toDate(),
end: lastMonth.endOf('month').toDate(),
}
},
},
]
// Matches the shadcn "date and time picker" pattern (Calendar + time input):
// the native picker indicator is hidden so only the themed field shows.
const timeInputClassName =
'appearance-none tabular-nums [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none'
function toTimeValue(date: Date | undefined, fallback: string): string {
return date ? dayjs(date).format('HH:mm') : fallback
}
function fromInputValue(value: string): Date | undefined {
if (!value) return undefined
const date = new Date(value)
return Number.isNaN(date.getTime()) ? undefined : date
function combineDateTime(date: Date, time: string): Date {
const [hours = 0, minutes = 0] = time.split(':').map(Number)
const combined = new Date(date)
combined.setHours(hours, minutes, 0, 0)
return combined
}
// Time inputs are minute-precision, so an end of "23:59" must cover the whole
// minute (23:59:59.999) — same as the endOf('day') the presets produce.
// Otherwise reopening a preset range and confirming would silently trim it.
function combineEndDateTime(date: Date, time: string): Date {
const combined = combineDateTime(date, time)
combined.setSeconds(59, 999)
return combined
}
export function CompactDateTimeRangePicker({
@@ -53,17 +150,18 @@ export function CompactDateTimeRangePicker({
onChange,
className,
}: CompactDateTimeRangePickerProps) {
const { t } = useTranslation()
const { t, i18n } = useTranslation()
const isMobile = useIsMobile()
const calendarLocale = getCalendarLocale(i18n.language)
const [open, setOpen] = useState(false)
const [draftStart, setDraftStart] = useState(toInputValue(start))
const [draftEnd, setDraftEnd] = useState(toInputValue(end))
const [draftRange, setDraftRange] = useState<DateRange | undefined>()
const [startTime, setStartTime] = useState('00:00')
const [endTime, setEndTime] = useState('23:59')
const label = useMemo(() => {
if (!start && !end) return t('Date Range')
// The popover's <input type="datetime-local"> only supports minute
// precision, so seconds are always 00 (manual pick) or 59 (preset
// end-of-day). Hide them in the trigger label to keep the button
// width compact while still showing the meaningful timestamp.
// Times are minute-precision (the time inputs cannot express seconds),
// so hide seconds in the trigger label to keep the button compact.
const startText = start ? dayjs(start).format('YYYY-MM-DD HH:mm') : '-'
const endText = end ? dayjs(end).format('YYYY-MM-DD HH:mm') : '-'
return `${startText} ~ ${endText}`
@@ -71,47 +169,42 @@ export function CompactDateTimeRangePicker({
const handleOpenChange = (nextOpen: boolean) => {
if (nextOpen) {
setDraftStart(toInputValue(start))
setDraftEnd(toInputValue(end))
setDraftRange(start || end ? { from: start, to: end } : undefined)
setStartTime(toTimeValue(start, '00:00'))
setEndTime(toTimeValue(end, '23:59'))
}
setOpen(nextOpen)
}
const handleCalendarSelect = (
range: DateRange | undefined,
selectedDay: Date
) => {
// Once a full range exists, the next click starts a fresh range instead
// of react-day-picker's default edge adjustment, which feels erratic.
if (draftRange?.from && draftRange?.to) {
setDraftRange({ from: selectedDay, to: undefined })
return
}
setDraftRange(range)
}
const applyDraft = () => {
const from = draftRange?.from
// Selecting a single day leaves `to` empty; treat it as a one-day range.
const to = draftRange?.to ?? draftRange?.from
onChange({
start: fromInputValue(draftStart),
end: fromInputValue(draftEnd),
start: from ? combineDateTime(from, startTime) : undefined,
end: to ? combineEndDateTime(to, endTime) : undefined,
})
setOpen(false)
}
const applyPreset = (kind: 'today' | '7d' | 'week' | '30d' | 'month') => {
const now = dayjs()
const presets = {
today: {
start: now.startOf('day').toDate(),
end: now.endOf('day').toDate(),
},
'7d': {
start: now.subtract(6, 'day').startOf('day').toDate(),
end: now.endOf('day').toDate(),
},
week: {
start: now.startOf('week').toDate(),
end: now.endOf('week').toDate(),
},
'30d': {
start: now.subtract(29, 'day').startOf('day').toDate(),
end: now.endOf('day').toDate(),
},
month: {
start: now.startOf('month').toDate(),
end: now.endOf('month').toDate(),
},
}
const range = presets[kind]
setDraftStart(toInputValue(range.start))
setDraftEnd(toInputValue(range.end))
const applyPreset = (getRange: () => { start: Date; end: Date }) => {
const range = getRange()
setDraftRange({ from: range.start, to: range.end })
setStartTime(toTimeValue(range.start, '00:00'))
setEndTime(toTimeValue(range.end, '23:59'))
onChange(range)
setOpen(false)
}
@@ -136,84 +229,65 @@ export function CompactDateTimeRangePicker({
</PopoverTrigger>
<PopoverContent
align='start'
className='w-[min(520px,calc(100vw-2rem))] p-3'
className='w-auto max-w-[calc(100vw-2rem)] p-0'
>
<div className='space-y-3'>
<div className='grid gap-2 sm:grid-cols-[1fr_auto_1fr] sm:items-end'>
<div className='space-y-1.5'>
<div className='flex max-sm:max-h-[75vh] max-sm:flex-col max-sm:overflow-y-auto'>
{/* One-click presets: side rail on desktop, grid on mobile. */}
<div className='grid shrink-0 grid-cols-2 gap-1 border-b p-2 sm:flex sm:flex-col sm:border-r sm:border-b-0'>
{RANGE_PRESETS.map((preset) => (
<Button
key={preset.label}
type='button'
variant='ghost'
className='justify-start font-normal'
onClick={() => applyPreset(preset.getRange)}
>
{t(preset.label)}
</Button>
))}
</div>
<div className='p-3'>
<Calendar
mode='range'
numberOfMonths={isMobile ? 1 : 2}
// Outside days would render range days twice across the two
// months (e.g. Jul 31 again in August's first row).
showOutsideDays={false}
selected={draftRange}
onSelect={handleCalendarSelect}
defaultMonth={draftRange?.from}
locale={calendarLocale}
/>
<div className='mt-3 flex items-end gap-2 border-t pt-3'>
<div className='min-w-0 flex-1 space-y-1.5'>
<div className='text-muted-foreground text-xs'>
{t('Start Time')}
</div>
<Input
type='datetime-local'
value={draftStart}
onChange={(e) => setDraftStart(e.target.value)}
className='text-sm leading-5 tabular-nums'
type='time'
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
className={timeInputClassName}
/>
</div>
<span className='text-muted-foreground hidden pb-2 text-xs sm:block'>
~
</span>
<div className='space-y-1.5'>
<span className='text-muted-foreground pb-2 text-xs'>~</span>
<div className='min-w-0 flex-1 space-y-1.5'>
<div className='text-muted-foreground text-xs'>
{t('End Time')}
</div>
<Input
type='datetime-local'
value={draftEnd}
onChange={(e) => setDraftEnd(e.target.value)}
className='text-sm leading-5 tabular-nums'
type='time'
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
className={timeInputClassName}
/>
</div>
</div>
<div className='flex flex-wrap gap-1.5'>
<Button
type='button'
variant='secondary'
className='flex-1'
onClick={() => applyPreset('today')}
>
{t('Today')}
</Button>
<Button
type='button'
variant='secondary'
className='flex-1'
onClick={() => applyPreset('7d')}
>
{t('7 Days')}
</Button>
<Button
type='button'
variant='secondary'
className='flex-1'
onClick={() => applyPreset('week')}
>
{t('This week')}
</Button>
<Button
type='button'
variant='secondary'
className='flex-1'
onClick={() => applyPreset('30d')}
>
{t('30 Days')}
</Button>
<Button
type='button'
variant='secondary'
className='flex-1'
onClick={() => applyPreset('month')}
>
{t('This month')}
</Button>
</div>
<div className='flex justify-end'>
<Button onClick={applyDraft}>{t('Confirm')}</Button>
</div>
</div>
</div>
</PopoverContent>
</Popover>
)
@@ -27,15 +27,19 @@ import { useTranslation } from 'react-i18next'
import {
DataTableCardField,
DataTableCardRow,
ERROR_ROW_MOBILE,
INFO_ROW_MOBILE,
MobileCardList,
} from '@/components/data-table'
import { cn } from '@/lib/utils'
import { LOG_TYPE_ENUM } from '../constants'
const logTypeRowTint: Record<number, string> = {
[LOG_TYPE_ENUM.ERROR]: 'bg-destructive/5 border-destructive/20',
[LOG_TYPE_ENUM.REFUND]: 'bg-info/5 border-info/20',
// Same treatment as disabled cards in the keys/channels/users mobile lists
// (tinted card background), only the hue differs.
const logTypeRowClass: Record<number, string> = {
[LOG_TYPE_ENUM.ERROR]: ERROR_ROW_MOBILE,
[LOG_TYPE_ENUM.REFUND]: INFO_ROW_MOBILE,
}
interface UsageLogsMobileListProps<TData> {
@@ -182,12 +186,9 @@ export function UsageLogsMobileList<TData>({
const logType = (row.original as Record<string, unknown>).type as
| number
| undefined
const tintClass = logType != null ? (logTypeRowTint[logType] ?? '') : ''
return cn(
'border-l-2 border-l-transparent transition-colors',
tintClass,
getRowClassName?.(row)
)
const statusClass =
logType != null ? (logTypeRowClass[logType] ?? '') : ''
return cn('transition-colors', statusClass, getRowClassName?.(row))
}}
/>
)
@@ -25,6 +25,10 @@ import { toast } from 'sonner'
import {
DataTablePage,
DataTableRow,
ERROR_ROW_DESKTOP,
INFO_ROW_DESKTOP,
WARNING_ROW_DESKTOP,
WARNING_ROW_MOBILE,
useDataTable,
} from '@/components/data-table'
import { useTableUrlState } from '@/hooks/use-table-url-state'
@@ -46,15 +50,13 @@ import { useLogsViewScope } from './usage-logs-provider'
const route = getRouteApi('/_authenticated/usage-logs/$section')
const logTypeRowTint: Record<number, string> = {
[LOG_TYPE_ENUM.ERROR]: 'bg-destructive/5',
[LOG_TYPE_ENUM.REFUND]: 'bg-info/5',
// Same structural treatment as disabled rows in the keys/channels/users
// tables (tinted row + 4px accent stripe), only the hue differs.
const logTypeRowClass: Record<number, string> = {
[LOG_TYPE_ENUM.ERROR]: ERROR_ROW_DESKTOP,
[LOG_TYPE_ENUM.REFUND]: INFO_ROW_DESKTOP,
}
// Warning tint for logs where a quota conversion saturated (admin-only marker).
// Takes precedence over the per-type tint since it flags a billing anomaly.
const quotaSaturationRowTint = 'bg-warning/10'
function getColumnVisibilityStorageKey(
logCategory: LogCategory,
isAdmin: boolean
@@ -206,7 +208,7 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
((row.original as Record<string, unknown>).other as string) ?? ''
)
return other?.admin_info?.quota_saturation
? quotaSaturationRowTint
? WARNING_ROW_MOBILE
: undefined
}}
/>
@@ -222,14 +224,16 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
const logType = (row.original as Record<string, unknown>).type as
| number
| undefined
let tintClass =
isCommon && logType != null ? (logTypeRowTint[logType] ?? '') : ''
let statusClass =
isCommon && logType != null ? (logTypeRowClass[logType] ?? '') : ''
if (isCommon && isAdmin) {
const other = parseLogOther(
((row.original as Record<string, unknown>).other as string) ?? ''
)
// Quota saturation is an admin-only billing anomaly marker; it
// takes precedence over the per-type row treatment.
if (other?.admin_info?.quota_saturation) {
tintClass = quotaSaturationRowTint
statusClass = WARNING_ROW_DESKTOP
}
}
@@ -237,7 +241,7 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
<DataTableRow
key={row.id}
row={row}
className={cn('transition-colors', tintClass)}
className={cn('transition-colors', statusClass)}
getColumnClassName={(columnId) =>
helpers.getCellClassName(columnId, isCommon ? 'py-2' : 'py-3.5')
}
-14
View File
@@ -68,20 +68,6 @@ export const LOG_TYPE_ENUM = {
*/
export const LOG_TYPE_ALL_VALUE = '0' as const
// ============================================================================
// Time Range Presets
// ============================================================================
/**
* Quick time range presets for filter dialog
*/
export const TIME_RANGE_PRESETS = [
{ days: 1, label: '24 Hours' },
{ days: 7, label: '7 Days' },
{ days: 14, label: '14 Days' },
{ days: 30, label: '30 Days' },
] as const
// ============================================================================
// Common Logs Configuration
// ============================================================================
+3 -2
View File
@@ -73,13 +73,14 @@ export function isPerCallBilling(modelPrice?: number): boolean {
}
/**
* Get default time range (today 00:00:00 to now + 1 hour)
* Get default time range (today 00:00:00 to 23:59:59)
*/
export function getDefaultTimeRange(): { start: Date; end: Date } {
const now = new Date()
const start = new Date(now)
start.setHours(0, 0, 0, 0)
const end = new Date(now.getTime() + 3600 * 1000) // +1 hour
const end = new Date(now)
end.setHours(23, 59, 59, 999)
return { start, end }
}
+3
View File
@@ -1514,6 +1514,7 @@
"Each tier supports up to 2 conditions; the last tier is the catch-all without conditions. Use full input length for tier conditions to avoid mis-routing when cache hits reduce billable input tokens.": "Each tier supports up to 2 conditions; the last tier is the catch-all without conditions. Use full input length for tier conditions to avoid mis-routing when cache hits reduce billable input tokens.",
"Each tier supports up to 2 conditions. The last tier without conditions is the fallback.": "Each tier supports up to 2 conditions. The last tier without conditions is the fallback.",
"Earn rewards when users join through your referral link. Transfer accumulated rewards to your balance anytime.": "Earn rewards when users join through your referral link. Transfer accumulated rewards to your balance anytime.",
"Earned this month": "Earned this month",
"Edit": "Edit",
"Edit {{title}}": "Edit {{title}}",
"Edit all channels with tag:": "Edit all channels with tag:",
@@ -2420,11 +2421,13 @@
"Last check time": "Last check time",
"Last detected addable models": "Last detected addable models",
"Last Login": "Last Login",
"Last month": "Last month",
"Last Seen": "Last Seen",
"Last Tested": "Last Tested",
"Last updated:": "Last updated:",
"Last Used": "Last Used",
"Last used:": "Last used:",
"Last week": "Last week",
"Latency": "Latency",
"Latency check": "Latency check",
"Latency short": "Lat.",
+3
View File
@@ -1514,6 +1514,7 @@
"Each tier supports up to 2 conditions; the last tier is the catch-all without conditions. Use full input length for tier conditions to avoid mis-routing when cache hits reduce billable input tokens.": "Chaque palier accepte jusqu’à 2 conditions ; le dernier palier sert de repli sans condition. Utilisez la longueur complète de lentrée pour éviter un mauvais aiguillage lorsque les lectures de cache réduisent les tokens dentrée facturables.",
"Each tier supports up to 2 conditions. The last tier without conditions is the fallback.": "Chaque palier prend en charge jusqu’à 2 conditions. Le dernier palier sans condition sert de repli.",
"Earn rewards when users join through your referral link. Transfer accumulated rewards to your balance anytime.": "Gagnez des récompenses quand des utilisateurs rejoignent via votre lien de parrainage. Transférez-les vers votre solde à tout moment.",
"Earned this month": "Gagné ce mois-ci",
"Edit": "Modifier",
"Edit {{title}}": "Modifier {{title}}",
"Edit all channels with tag:": "Modifier tous les canaux avec l'étiquette :",
@@ -2420,11 +2421,13 @@
"Last check time": "Dernière vérification",
"Last detected addable models": "Derniers modèles ajoutables détectés",
"Last Login": "Dernière connexion",
"Last month": "Mois dernier",
"Last Seen": "Dernier signal",
"Last Tested": "Dernier testé",
"Last updated:": "Dernière mise à jour :",
"Last Used": "Dernière utilisation",
"Last used:": "Dernière utilisation :",
"Last week": "Semaine dernière",
"Latency": "Latence",
"Latency check": "Contrôle de latence",
"Latency short": "Lat.",
+3
View File
@@ -1514,6 +1514,7 @@
"Each tier supports up to 2 conditions; the last tier is the catch-all without conditions. Use full input length for tier conditions to avoid mis-routing when cache hits reduce billable input tokens.": "各階層は最大2つの条件をサポートします。最後の階層は条件なしのフォールバックです。キャッシュヒットで課金対象の入力トークンが減っても誤った階層にならないよう、条件には完全な入力長を使用してください。",
"Each tier supports up to 2 conditions. The last tier without conditions is the fallback.": "各段階は最大 2 つの条件に対応します。条件のない最後の段階がフォールバックです。",
"Earn rewards when users join through your referral link. Transfer accumulated rewards to your balance anytime.": "ユーザーがあなたの紹介リンクから登録すると報酬を獲得できます。貯まった報酬はいつでも残高へ振り替えられます。",
"Earned this month": "今月の獲得",
"Edit": "編集",
"Edit {{title}}": "{{title}}を編集",
"Edit all channels with tag:": "タグを持つすべてのチャネルを編集:",
@@ -2420,11 +2421,13 @@
"Last check time": "最終チェック時刻",
"Last detected addable models": "最後に検出された追加可能モデル",
"Last Login": "最終ログイン",
"Last month": "先月",
"Last Seen": "最終報告",
"Last Tested": "最終テスト日時",
"Last updated:": "最終更新日:",
"Last Used": "最終使用",
"Last used:": "最終使用日:",
"Last week": "先週",
"Latency": "レイテンシ",
"Latency check": "レイテンシ確認",
"Latency short": "遅延",
+3
View File
@@ -1514,6 +1514,7 @@
"Each tier supports up to 2 conditions; the last tier is the catch-all without conditions. Use full input length for tier conditions to avoid mis-routing when cache hits reduce billable input tokens.": "Каждый уровень поддерживает до 2 условий; последний уровень является резервным и не содержит условий. Используйте полную длину входа для условий уровня, чтобы кэш-попадания не снижали оплачиваемые входные токены и не приводили к неверному маршруту.",
"Each tier supports up to 2 conditions. The last tier without conditions is the fallback.": "Каждый уровень поддерживает до 2 условий. Последний уровень без условий используется как резервный.",
"Earn rewards when users join through your referral link. Transfer accumulated rewards to your balance anytime.": "Получайте вознаграждения, когда пользователи регистрируются по вашей реферальной ссылке. Переводите накопленные вознаграждения на баланс в любое время.",
"Earned this month": "Получено в этом месяце",
"Edit": "Редактировать",
"Edit {{title}}": "Редактировать {{title}}",
"Edit all channels with tag:": "Редактировать все каналы с тегом:",
@@ -2420,11 +2421,13 @@
"Last check time": "Время последней проверки",
"Last detected addable models": "Последние обнаруженные модели для добавления",
"Last Login": "Последний вход",
"Last month": "Прошлый месяц",
"Last Seen": "Последний сигнал",
"Last Tested": "Последняя проверка",
"Last updated:": "Последнее обновление:",
"Last Used": "Последнее использование",
"Last used:": "Последнее использование:",
"Last week": "Прошлая неделя",
"Latency": "Задержка",
"Latency check": "Проверка задержки",
"Latency short": "Зад.",
+3
View File
@@ -1514,6 +1514,7 @@
"Each tier supports up to 2 conditions; the last tier is the catch-all without conditions. Use full input length for tier conditions to avoid mis-routing when cache hits reduce billable input tokens.": "Mỗi tầng hỗ trợ tối đa 2 điều kiện; tầng cuối cùng là tầng dự phòng không có điều kiện. Hãy dùng độ dài đầu vào đầy đủ cho điều kiện tầng để tránh chọn sai tầng khi cache hit làm giảm token đầu vào tính phí.",
"Each tier supports up to 2 conditions. The last tier without conditions is the fallback.": "Mỗi tầng hỗ trợ tối đa 2 điều kiện. Tầng cuối cùng không có điều kiện là tầng dự phòng.",
"Earn rewards when users join through your referral link. Transfer accumulated rewards to your balance anytime.": "Nhận phần thưởng khi người dùng đăng ký qua liên kết giới thiệu của bạn. Chuyển phần thưởng tích lũy vào số dư bất cứ lúc nào.",
"Earned this month": "Nhận trong tháng này",
"Edit": "Chỉnh sửa",
"Edit {{title}}": "Chỉnh sửa {{title}}",
"Edit all channels with tag:": "Chỉnh sửa tất cả các kênh với thẻ:",
@@ -2420,11 +2421,13 @@
"Last check time": "Thời gian kiểm tra gần nhất",
"Last detected addable models": "Mô hình có thể thêm được phát hiện gần nhất",
"Last Login": "Lần đăng nhập cuối",
"Last month": "Tháng trước",
"Last Seen": "Lần cuối thấy",
"Last Tested": "Được kiểm tra lần cuối",
"Last updated:": "Cập nhật lần cuối:",
"Last Used": "Dùng lần cuối",
"Last used:": "Lần cuối sử dụng:",
"Last week": "Tuần trước",
"Latency": "Độ trễ",
"Latency check": "Kiểm tra độ trễ",
"Latency short": "Trễ",
+4 -1
View File
@@ -1514,6 +1514,7 @@
"Each tier supports up to 2 conditions; the last tier is the catch-all without conditions. Use full input length for tier conditions to avoid mis-routing when cache hits reduce billable input tokens.": "每個檔位最多支援 2 個條件;最後一個檔位是不帶條件的兜底檔。建議使用完整輸入長度作為檔位條件,避免緩存命中減少收費輸入 token 後誤判檔位。",
"Each tier supports up to 2 conditions. The last tier without conditions is the fallback.": "每個階梯最多支援 2 個條件。最後一個無條件階梯作為兜底。",
"Earn rewards when users join through your referral link. Transfer accumulated rewards to your balance anytime.": "Earn rewards when users join through your referral link. Transfer accumulated rewards to your balance anytime.",
"Earned this month": "本月獲得",
"Edit": "編輯",
"Edit {{title}}": "編輯{{title}}",
"Edit all channels with tag:": "編輯所有帶有標籤的渠道:",
@@ -2420,11 +2421,13 @@
"Last check time": "上次檢測時間",
"Last detected addable models": "上次檢測到可加入模型",
"Last Login": "最後登入",
"Last month": "上個月",
"Last Seen": "最後上報",
"Last Tested": "上次測試",
"Last updated:": "上次更新時間:",
"Last Used": "最後使用時間",
"Last used:": "上次使用時間:",
"Last week": "上週",
"Latency": "延遲",
"Latency check": "延遲檢測",
"Latency short": "延遲",
@@ -4521,7 +4524,7 @@
"This model has both fixed-price and ratio settings. Saving the current mode will rewrite the conflicting fields.": "該模型同時存在固定價格和比例設定。儲存目前模式會重寫衝突欄位。",
"This model has both fixed-price and token-price settings. Saving the current mode will rewrite the conflicting fields.": "該模型同時存在固定價格和按 token 價格設定。儲存目前模式會重寫衝突欄位。",
"This model is not available in any group, or no group pricing information is configured.": "此模型在任何分組中均不可用,或未設定分組定價資訊。",
"This month": "本月獲得",
"This month": "本月",
"This page has not been created yet.": "此頁面尚未建立。",
"This plan does not allow balance redemption": "該套餐不允許使用餘額兌換",
"This project must be used in compliance with the": "此項目的使用必須遵守",
+4 -1
View File
@@ -1514,6 +1514,7 @@
"Each tier supports up to 2 conditions; the last tier is the catch-all without conditions. Use full input length for tier conditions to avoid mis-routing when cache hits reduce billable input tokens.": "每个档位最多支持 2 个条件;最后一个档位是不带条件的兜底档。建议使用完整输入长度作为档位条件,避免缓存命中减少计费输入 token 后误判档位。",
"Each tier supports up to 2 conditions. The last tier without conditions is the fallback.": "每个阶梯最多支持 2 个条件。最后一个无条件阶梯作为兜底。",
"Earn rewards when users join through your referral link. Transfer accumulated rewards to your balance anytime.": "用户通过您的推荐链接注册后,您即可获得奖励。可随时将累计奖励转入余额。",
"Earned this month": "本月获得",
"Edit": "编辑",
"Edit {{title}}": "编辑{{title}}",
"Edit all channels with tag:": "编辑所有带有标签的渠道:",
@@ -2420,11 +2421,13 @@
"Last check time": "上次检测时间",
"Last detected addable models": "上次检测到可加入模型",
"Last Login": "最后登录",
"Last month": "上个月",
"Last Seen": "最后上报",
"Last Tested": "上次测试",
"Last updated:": "上次更新时间:",
"Last Used": "最后使用时间",
"Last used:": "上次使用时间:",
"Last week": "上周",
"Latency": "延迟",
"Latency check": "延迟检测",
"Latency short": "延迟",
@@ -4521,7 +4524,7 @@
"This model has both fixed-price and ratio settings. Saving the current mode will rewrite the conflicting fields.": "该模型同时存在固定价格和比例设置。保存当前模式会重写冲突字段。",
"This model has both fixed-price and token-price settings. Saving the current mode will rewrite the conflicting fields.": "该模型同时存在固定价格和按 token 价格设置。保存当前模式会重写冲突字段。",
"This model is not available in any group, or no group pricing information is configured.": "此模型在任何分组中均不可用,或未配置分组定价信息。",
"This month": "本月获得",
"This month": "本月",
"This page has not been created yet.": "此页面尚未创建。",
"This plan does not allow balance redemption": "该套餐不允许使用余额兑换",
"This project must be used in compliance with the": "此项目的使用必须遵守",
+9
View File
@@ -544,6 +544,15 @@ export const STATIC_I18N_KEYS = [
'The model that was requested',
'The upstream channel that served the requests',
// Usage logs date range presets
'Today',
'7 Days',
'This week',
'Last week',
'30 Days',
'This month',
'Last month',
// Misc
'Cancel',
'Status',
+35
View File
@@ -0,0 +1,35 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type { Locale } from 'react-day-picker'
import { enUS, fr, ja, ru, vi, zhCN, zhTW } from 'react-day-picker/locale'
const calendarLocales: Record<string, Locale> = {
en: enUS,
zh: zhCN,
'zh-TW': zhTW,
fr,
ru,
ja,
vi,
}
/** Map the active i18next language to a react-day-picker locale. */
export function getCalendarLocale(language: string): Locale {
return calendarLocales[language] ?? enUS
}
+56
View File
@@ -294,6 +294,35 @@ For commercial licensing, please contact support@quantumnous.com
var(--foreground) 24%,
var(--table-row)
);
/* Semantic status-row palettes (error/info/warning). Same structure as the
* disabled palette above so every "special row" treatment looks identical,
* only the hue differs. Borders mix a much higher share so the accent
* stripe stays recognizable at 4px. */
--table-error: color-mix(in oklch, var(--destructive) 5%, var(--table-row));
--table-error-hover: color-mix(
in oklch,
var(--destructive) 8%,
var(--table-row)
);
--table-error-border: color-mix(
in oklch,
var(--destructive) 60%,
var(--table-row)
);
--table-info: color-mix(in oklch, var(--info) 5%, var(--table-row));
--table-info-hover: color-mix(in oklch, var(--info) 8%, var(--table-row));
--table-info-border: color-mix(in oklch, var(--info) 60%, var(--table-row));
--table-warning: color-mix(in oklch, var(--warning) 10%, var(--table-row));
--table-warning-hover: color-mix(
in oklch,
var(--warning) 13%,
var(--table-row)
);
--table-warning-border: color-mix(
in oklch,
var(--warning) 65%,
var(--table-row)
);
}
.dark {
@@ -366,4 +395,31 @@ For commercial licensing, please contact support@quantumnous.com
var(--foreground) 34%,
var(--background)
);
/* Dark-mode status-row palettes; higher mixes than light mode because dark
* surfaces swallow subtle tints (mirrors the disabled palette above). */
--table-error: color-mix(in oklch, var(--destructive) 10%, var(--table-row));
--table-error-hover: color-mix(
in oklch,
var(--destructive) 13%,
var(--table-row)
);
--table-error-border: color-mix(
in oklch,
var(--destructive) 65%,
var(--table-row)
);
--table-info: color-mix(in oklch, var(--info) 10%, var(--table-row));
--table-info-hover: color-mix(in oklch, var(--info) 13%, var(--table-row));
--table-info-border: color-mix(in oklch, var(--info) 65%, var(--table-row));
--table-warning: color-mix(in oklch, var(--warning) 14%, var(--table-row));
--table-warning-hover: color-mix(
in oklch,
var(--warning) 17%,
var(--table-row)
);
--table-warning-border: color-mix(
in oklch,
var(--warning) 70%,
var(--table-row)
);
}