feat(ui): add reusable dashboard and log controls

This commit is contained in:
CaIon
2026-04-30 13:55:25 +08:00
parent 22fd1741ab
commit 8bff691089
3 changed files with 473 additions and 0 deletions
@@ -0,0 +1,197 @@
import { useState, useEffect, useCallback, type ReactNode } from 'react'
import { useNavigate, getRouteApi } from '@tanstack/react-router'
import { useQueryClient, useIsFetching } from '@tanstack/react-query'
import { Loader2, RotateCcw, Search } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useIsAdmin } from '@/hooks/use-admin'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { buildSearchParams } from '../lib/filter'
import { getDefaultTimeRange } from '../lib/utils'
import type { DrawingLogFilters, LogCategory, TaskLogFilters } from '../types'
import { CompactDateTimeRangePicker } from './compact-date-time-range-picker'
const route = getRouteApi('/_authenticated/usage-logs/$section')
type TaskLikeLogCategory = Extract<LogCategory, 'drawing' | 'task'>
type TaskLogsFilters = DrawingLogFilters | TaskLogFilters
interface TaskLogsFilterBarProps {
logCategory: TaskLikeLogCategory
viewOptions?: ReactNode
}
function getFilterPlaceholder(_logCategory: TaskLikeLogCategory): string {
return 'Filter by task ID'
}
function getFilterValue(
filters: TaskLogsFilters,
logCategory: TaskLikeLogCategory
): string {
if (logCategory === 'drawing') {
return (filters as DrawingLogFilters).mjId || ''
}
return (filters as TaskLogFilters).taskId || ''
}
function setFilterValue(
filters: TaskLogsFilters,
logCategory: TaskLikeLogCategory,
value: string
): TaskLogsFilters {
if (logCategory === 'drawing') {
return { ...filters, mjId: value }
}
return { ...filters, taskId: value }
}
export function TaskLogsFilterBar(props: TaskLogsFilterBarProps) {
const { t } = useTranslation()
const navigate = useNavigate()
const queryClient = useQueryClient()
const searchParams = route.useSearch()
const isAdmin = useIsAdmin()
const fetchingLogs = useIsFetching({ queryKey: ['logs'] })
const [filters, setFilters] = useState<TaskLogsFilters>(() => {
const { start, end } = getDefaultTimeRange()
return { startTime: start, endTime: end }
})
useEffect(() => {
const { start, end } = getDefaultTimeRange()
const baseFilters = {
startTime: searchParams.startTime ? new Date(searchParams.startTime) : start,
endTime: searchParams.endTime ? new Date(searchParams.endTime) : end,
...(searchParams.channel ? { channel: String(searchParams.channel) } : {}),
}
const next: TaskLogsFilters =
props.logCategory === 'drawing'
? {
...baseFilters,
...(searchParams.filter ? { mjId: searchParams.filter } : {}),
}
: {
...baseFilters,
...(searchParams.filter ? { taskId: searchParams.filter } : {}),
}
setFilters(next)
}, [
props.logCategory,
searchParams.startTime,
searchParams.endTime,
searchParams.channel,
searchParams.filter,
])
const handleChange = useCallback(
(field: keyof TaskLogsFilters, value: Date | string | undefined) => {
setFilters((prev) => ({ ...prev, [field]: value }))
},
[]
)
const handleApply = useCallback(() => {
const filterParams = buildSearchParams(filters, props.logCategory)
navigate({
to: '/usage-logs/$section',
params: { section: props.logCategory },
search: {
...filterParams,
page: 1,
},
})
queryClient.invalidateQueries({ queryKey: ['logs'] })
}, [filters, navigate, props.logCategory, queryClient])
const handleReset = useCallback(() => {
const { start, end } = getDefaultTimeRange()
const resetFilters: TaskLogsFilters = { startTime: start, endTime: end }
setFilters(resetFilters)
navigate({
to: '/usage-logs/$section',
params: { section: props.logCategory },
search: {
page: 1,
startTime: start.getTime(),
endTime: end.getTime(),
},
})
queryClient.invalidateQueries({ queryKey: ['logs'] })
}, [navigate, props.logCategory, queryClient])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter') handleApply()
},
[handleApply]
)
const handleFilterChange = useCallback(
(value: string) => {
setFilters((prev) => setFilterValue(prev, props.logCategory, value))
},
[props.logCategory]
)
return (
<div className='space-y-3'>
<div className='grid grid-cols-2 gap-2 lg:grid-cols-[minmax(280px,2fr)_minmax(180px,1fr)_minmax(120px,0.8fr)_auto]'>
<CompactDateTimeRangePicker
start={filters.startTime}
end={filters.endTime}
onChange={({ start, end }) => {
handleChange('startTime', start)
handleChange('endTime', end)
}}
className='col-span-2 lg:col-span-1'
/>
<Input
aria-label={t('Task ID')}
placeholder={t(getFilterPlaceholder(props.logCategory))}
value={getFilterValue(filters, props.logCategory)}
onChange={(e) => handleFilterChange(e.target.value)}
onKeyDown={handleKeyDown}
className='h-9'
/>
{isAdmin && (
<Input
placeholder={t('Channel ID')}
value={filters.channel || ''}
onChange={(e) => handleChange('channel', e.target.value)}
onKeyDown={handleKeyDown}
className='h-9'
/>
)}
<div className='col-span-2 flex shrink-0 items-center justify-end gap-2 lg:col-span-1'>
<Button
variant='outline'
size='sm'
className='h-8'
onClick={handleReset}
>
<RotateCcw className='size-3.5' />
{t('Reset')}
</Button>
<Button
size='sm'
className='h-8'
onClick={handleApply}
disabled={fetchingLogs > 0}
>
{fetchingLogs > 0 ? (
<Loader2 className='size-3.5 animate-spin' />
) : (
<Search className='size-3.5' />
)}
{t('Search')}
</Button>
{props.viewOptions}
</div>
</div>
</div>
)
}