quality / changes (push) Successful in 8s
quality / api (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m2s
CD / quality (push) Successful in 1m12s
CD / publish (push) Successful in 1m31s
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
124 lines
4.3 KiB
TypeScript
124 lines
4.3 KiB
TypeScript
/**
|
|
* Каркас списочной страницы как в MikrotikManager (/servers): карточка Frame без полей, тулбар
|
|
* «сегменты · фильтры · поиск · счётчик», таблица с подложкой шапки и ячейками py-3.
|
|
*/
|
|
import type { ReactNode } from 'react'
|
|
import type { Column } from '@tanstack/react-table'
|
|
import { ArrowDownIcon, ArrowUpDownIcon, ArrowUpIcon, SearchIcon } from 'lucide-react'
|
|
import { Frame, FramePanel } from '@/components/reui/frame'
|
|
import { InputGroup, InputGroupAddon, InputGroupInput } from '@authportal/ui/components/input-group'
|
|
import { cn } from '@authportal/ui/lib/utils'
|
|
import type { DataGridFeatures } from '@/components/reui/data-grid/data-grid'
|
|
|
|
export function DataPageCard({ children, className }: { children: ReactNode; className?: string }) {
|
|
return (
|
|
<Frame dense className={cn('w-full min-w-0', className)}>
|
|
<FramePanel className="flex w-full min-w-0 flex-col gap-0 overflow-hidden p-0 py-0 shadow-none">{children}</FramePanel>
|
|
</Frame>
|
|
)
|
|
}
|
|
|
|
export function SegmentedControl<T extends string>({
|
|
value,
|
|
onChange,
|
|
options,
|
|
label,
|
|
}: {
|
|
value: T
|
|
onChange: (value: T) => void
|
|
options: { value: T; label: string; count?: number }[]
|
|
label?: string
|
|
}) {
|
|
return (
|
|
<div role="group" aria-label={label} className="border-border bg-muted/40 flex w-fit items-center gap-1 rounded-md border p-0.5">
|
|
{options.map((o) => (
|
|
<button
|
|
key={o.value}
|
|
type="button"
|
|
aria-pressed={value === o.value}
|
|
onClick={() => onChange(o.value)}
|
|
className={cn(
|
|
'flex items-center gap-1.5 rounded px-3 py-1 text-sm transition-colors',
|
|
value === o.value ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
|
|
)}
|
|
>
|
|
{o.label}
|
|
{o.count != null ? <span className="text-xs tabular-nums">{o.count}</span> : null}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function DataPageToolbar({
|
|
leading,
|
|
segmented,
|
|
filterControl,
|
|
search,
|
|
onSearchChange,
|
|
searchPlaceholder = 'Поиск…',
|
|
countLabel,
|
|
actions,
|
|
}: {
|
|
leading?: ReactNode
|
|
segmented?: { value: string; onChange: (value: string) => void; options: { value: string; label: string; count?: number }[]; label?: string }
|
|
filterControl?: ReactNode
|
|
search?: string
|
|
onSearchChange?: (value: string) => void
|
|
searchPlaceholder?: string
|
|
countLabel?: string
|
|
actions?: ReactNode
|
|
}) {
|
|
return (
|
|
<div className="flex flex-wrap items-center gap-3 border-b px-5 py-3">
|
|
{leading}
|
|
{segmented ? <SegmentedControl {...segmented} /> : null}
|
|
{filterControl}
|
|
{onSearchChange ? (
|
|
<InputGroup className="max-w-sm min-w-[220px]">
|
|
<InputGroupAddon>
|
|
<SearchIcon className="size-3.5" aria-hidden />
|
|
</InputGroupAddon>
|
|
<InputGroupInput aria-label={searchPlaceholder} placeholder={searchPlaceholder} value={search ?? ''} onChange={(e) => onSearchChange(e.target.value)} />
|
|
</InputGroup>
|
|
) : null}
|
|
{countLabel ? <span className="text-muted-foreground ml-auto text-sm">{countLabel}</span> : null}
|
|
{actions}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function DataGridSortHeader<TData extends object>({
|
|
column,
|
|
title,
|
|
className,
|
|
}: {
|
|
column: Column<DataGridFeatures, TData, unknown>
|
|
title: string
|
|
className?: string
|
|
}) {
|
|
const sorted = column.getIsSorted()
|
|
if (!column.getCanSort()) {
|
|
return <span className={cn('text-muted-foreground text-xs font-medium', className)}>{title}</span>
|
|
}
|
|
return (
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
'text-muted-foreground hover:text-foreground focus-visible:outline-ring -ml-1 inline-flex items-center gap-1.5 rounded-md px-1 py-0.5 text-xs font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2',
|
|
className,
|
|
)}
|
|
onClick={column.getToggleSortingHandler()}
|
|
>
|
|
{title}
|
|
{sorted === 'asc' ? (
|
|
<ArrowUpIcon aria-hidden className="text-foreground size-3" />
|
|
) : sorted === 'desc' ? (
|
|
<ArrowDownIcon aria-hidden className="text-foreground size-3" />
|
|
) : (
|
|
<ArrowUpDownIcon aria-hidden className="size-3 opacity-35" />
|
|
)}
|
|
</button>
|
|
)
|
|
}
|