fix(ui): выровнять header pin и матрицу блокировок под ReUI v9
Docker / build (push) Failing after 25s

Kit больше не перебивает defaults примитива: header без серой полосы, CRUD с table-fixed. Матрица /blocking получает sticky identity, иконки сервисов и статусы без текста ОК/Блок.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-08-22 21:47:58 +07:00
co-authored by Cursor
parent 5847188dc7
commit a5f83c6ecb
26 changed files with 1609 additions and 109 deletions
+2 -1
View File
@@ -24,7 +24,7 @@
"@tanstack/react-router": "^1.130.2",
"@tanstack/react-router-devtools": "^1.130.2",
"@tanstack/react-table": "^9.1.2",
"@tanstack/react-virtual": "^3.14.4",
"@tanstack/react-virtual": "^3.14.10",
"@xyflow/react": "^12.11.2",
"class-variance-authority": "^0.7.1",
"cmdk": "^1.1.1",
@@ -37,6 +37,7 @@
"react-dom": "^19.2.0",
"react-hook-form": "^7.60.0",
"recharts": "3.8.0",
"simple-icons": "^16.28.0",
"sonner": "^1.7.0",
"zod": "^3.25.0"
},
@@ -0,0 +1,271 @@
import { type DataGridFeatures } from "@/components/reui/data-grid/data-grid"
import { DataGridColumnHeader } from "@/components/reui/data-grid/data-grid-column-header"
import { type ColumnDef } from "@tanstack/react-table"
import { format, isWeekend } from "date-fns"
import { cn } from "@cfdm/ui/lib/utils"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@cfdm/ui/components/avatar"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@cfdm/ui/components/tooltip"
import {
EMPTY_ENTRY,
formatMinutes,
type EntryKind,
type ITimeEntry,
type ITimesheetRow,
} from "./data"
import { TrendingUp, TrendingDown } from "lucide-react"
const entryToneClass: Record<EntryKind, string> = {
billable: "bg-emerald-500",
internal: "bg-sky-500",
support: "bg-amber-500",
leave: "bg-zinc-400",
empty: "bg-transparent",
}
const entryLabel: Record<EntryKind, string> = {
billable: "Client",
internal: "Internal",
support: "Support",
leave: "Leave",
empty: "Open",
}
function getDayHeaderDateLabel(day: Date) {
return format(day, "EEE, MMM d")
}
function getEntryHelperLabel(entry: ITimeEntry, weekend: boolean) {
if (entry.minutes === 0) {
return weekend ? "Off" : "Open"
}
return entryLabel[entry.kind]
}
function getEntryTooltipCopy(entry: ITimeEntry, weekend: boolean) {
if (entry.minutes === 0) {
return weekend
? "No weekend hours were logged for this day."
: "No time has been logged for this work day yet."
}
return entry.note ?? `${entryLabel[entry.kind]} time entry.`
}
function PersonCell({ row }: { row: ITimesheetRow }) {
const { person } = row
return (
<div className="flex min-w-0 items-center gap-2.5">
<div className="relative shrink-0">
<Avatar className="size-8">
{person.avatar ? (
<AvatarImage src={person.avatar} alt={person.name} />
) : null}
<AvatarFallback>{person.initials}</AvatarFallback>
</Avatar>
</div>
<div className="flex min-w-0 flex-col leading-tight">
<div className="text-foreground truncate text-sm font-medium">
{person.name}
</div>
<div
className="text-muted-foreground truncate pt-0.5 text-xs"
title={person.role}
>
{person.role}
</div>
</div>
</div>
)
}
function DayCell({
entry,
weekend,
dayLabel,
}: {
entry: ITimeEntry
weekend: boolean
dayLabel: string
}) {
const minutes = entry.minutes
const progress =
minutes > 0 ? Math.max(18, Math.min(100, (minutes / 480) * 100)) : 0
const helperLabel = getEntryHelperLabel(entry, weekend)
const tooltipCopy = getEntryTooltipCopy(entry, weekend)
const ariaLabel =
minutes > 0
? `${dayLabel}: ${formatMinutes(minutes)} logged as ${helperLabel.toLowerCase()}. ${tooltipCopy}`
: `${dayLabel}: ${tooltipCopy}`
return (
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
className="hover:bg-muted/60 focus-visible:ring-ring focus-visible:ring-offset-background inline-flex w-full cursor-pointer justify-center rounded-md px-1.5 py-1.5 text-left transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
aria-label={ariaLabel}
/>
}
>
<div className="flex w-full min-w-[76px] flex-col items-center gap-1.5 text-center">
<span
className={cn(
"text-sm leading-none tabular-nums",
minutes > 0
? "text-foreground font-medium"
: "text-muted-foreground",
weekend && minutes === 0 && "opacity-80"
)}
>
{minutes > 0 ? formatMinutes(minutes) : "-"}
</span>
<span className="bg-border block h-1 w-full max-w-[56px] overflow-hidden rounded-full">
<span
className={cn(
"block h-full rounded-full",
entryToneClass[entry.kind]
)}
style={{ width: `${progress}%` }}
/>
</span>
<span className="text-muted-foreground text-[11px] leading-none">
{helperLabel}
</span>
</div>
</TooltipTrigger>
{/* Content */}
<TooltipContent side="top" className="max-w-[220px] p-3">
<div className="flex flex-col gap-1.5 text-left">
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium">{dayLabel}</span>
<span className="text-xs tabular-nums opacity-80">
{minutes > 0 ? formatMinutes(minutes) : "-"}
</span>
</div>
<p className="text-xs leading-relaxed opacity-80">{tooltipCopy}</p>
</div>
</TooltipContent>
</Tooltip>
)
}
function TotalCell({ row }: { row: ITimesheetRow }) {
const toneClass =
row.utilizationState === "on-target"
? "text-emerald-600"
: row.utilizationState === "overtime"
? "text-sky-600"
: "text-amber-600"
const isPositiveTrend = row.utilizationState !== "under-target"
return (
<div className="flex flex-col items-end gap-1 text-right">
<span className="text-foreground text-sm font-semibold tabular-nums">
{formatMinutes(row.totalMinutes)}
</span>
<span
className={cn(
"inline-flex items-center gap-1 text-xs tabular-nums",
toneClass
)}
>
{isPositiveTrend ? (
<TrendingUp className="size-3" aria-hidden="true" />
) : (
<TrendingDown className="size-3" aria-hidden="true" />
)}
{row.targetCoverage}%
</span>
</div>
)
}
export function createTimesheetColumns({
visibleDays,
}: {
visibleDays: Date[]
}): ColumnDef<DataGridFeatures, ITimesheetRow>[] {
const dayColumns: ColumnDef<DataGridFeatures, ITimesheetRow>[] =
visibleDays.map((day) => {
const dayKey = format(day, "yyyy-MM-dd")
const weekend = isWeekend(day)
const dateLabel = getDayHeaderDateLabel(day)
return {
accessorFn: (row) => row.entries[dayKey]?.minutes ?? 0,
id: dayKey,
header: ({ column }) => (
<DataGridColumnHeader
title={dateLabel}
column={column}
className="w-full justify-center text-center text-xs font-normal"
/>
),
cell: ({ row }) => (
<DayCell
entry={row.original.entries[dayKey] ?? EMPTY_ENTRY}
weekend={weekend}
dayLabel={format(day, "EEEE, MMM d")}
/>
),
size: 112,
enableSorting: false,
enablePinning: false,
meta: {
headerClassName: "text-center!",
cellClassName: "",
},
}
})
return [
{
accessorFn: (row) => row.person.name,
id: "person",
header: ({ column }) => (
<DataGridColumnHeader title="People" column={column} />
),
cell: ({ row }) => <PersonCell row={row.original} />,
size: 240,
enableSorting: false,
enableHiding: false,
enablePinning: false,
},
...dayColumns,
{
accessorFn: (row) => row.totalMinutes,
id: "total",
header: ({ column }) => (
<DataGridColumnHeader
title="Total"
column={column}
className="w-full justify-end text-right"
/>
),
cell: ({ row }) => <TotalCell row={row.original} />,
size: 120,
enableSorting: false,
enablePinning: false,
meta: {
headerClassName: "text-right",
cellClassName: "text-right",
},
},
]
}
@@ -0,0 +1,411 @@
"use client"
import { useCallback, useMemo, useState } from "react"
import {
DataGrid,
dataGridFeatures,
} from "@/components/reui/data-grid/data-grid"
import { DataGridPagination } from "@/components/reui/data-grid/data-grid-pagination"
import { DataGridScrollArea } from "@/components/reui/data-grid/data-grid-scroll-area"
import { DataGridTable } from "@/components/reui/data-grid/data-grid-table"
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from "@/components/reui/frame"
import { useTable, type PaginationState } from "@tanstack/react-table"
import {
addWeeks,
eachDayOfInterval,
endOfWeek,
format,
parseISO,
startOfWeek,
} from "date-fns"
import { Button } from "@cfdm/ui/components/button"
import { ButtonGroup } from "@cfdm/ui/components/button-group"
import { Calendar } from "@cfdm/ui/components/calendar"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@cfdm/ui/components/popover"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@cfdm/ui/components/select"
import { Separator } from "@cfdm/ui/components/separator"
import { createTimesheetColumns } from "./columns"
import {
BILLABLE_STATUS_OPTIONS,
DEFAULT_WEEK_START,
EMPTY_ENTRY,
TEAM_OPTIONS,
TIMESHEET_PEOPLE,
TRACKED_TIME_OPTIONS,
type BillableStatusFilter,
type ITimesheetPerson,
type ITimesheetRow,
type TeamFilter,
type TrackedTimeFilter,
} from "./data"
import { ChevronLeftIcon, CalendarIcon, ChevronRightIcon } from "lucide-react"
function normalizeWeekStart(date: Date) {
return startOfWeek(date, { weekStartsOn: 1 })
}
function getDateKey(date: Date) {
return format(date, "yyyy-MM-dd")
}
function getTrackedTimeState(
totalMinutes: number,
targetMinutes: number
): Exclude<TrackedTimeFilter, "any"> {
if (targetMinutes <= 0) return "on-target"
const ratio = totalMinutes / targetMinutes
if (ratio > 1.08) return "overtime"
if (ratio >= 0.85) return "on-target"
return "under-target"
}
function getBillableState(
totalMinutes: number,
billableMinutes: number
): Exclude<BillableStatusFilter, "any"> {
if (totalMinutes <= 0 || billableMinutes <= 0) return "internal-only"
const ratio = billableMinutes / totalMinutes
return ratio >= 0.75 ? "mostly-billable" : "mixed"
}
function createTimesheetRow(
person: ITimesheetPerson,
visibleDays: Date[]
): ITimesheetRow {
const entries = Object.fromEntries(
visibleDays.map((day) => {
const key = getDateKey(day)
return [key, person.entries[key] ?? EMPTY_ENTRY]
})
)
const totalMinutes = Object.values(entries).reduce(
(sum, entry) => sum + entry.minutes,
0
)
const billableMinutes = Object.values(entries).reduce(
(sum, entry) => sum + (entry.kind === "billable" ? entry.minutes : 0),
0
)
const activeDays = Object.values(entries).filter(
(entry) => entry.minutes > 0
).length
const billablePercent =
totalMinutes > 0 ? Math.round((billableMinutes / totalMinutes) * 100) : 0
const varianceMinutes = totalMinutes - person.weeklyTargetMinutes
const targetCoverage =
person.weeklyTargetMinutes > 0
? Math.round((totalMinutes / person.weeklyTargetMinutes) * 100)
: 0
return {
id: person.id,
person,
entries,
totalMinutes,
billableMinutes,
billablePercent,
activeDays,
varianceMinutes,
targetCoverage,
utilizationState: getTrackedTimeState(
totalMinutes,
person.weeklyTargetMinutes
),
billableState: getBillableState(totalMinutes, billableMinutes),
}
}
function formatRangeLabel(days: Date[]) {
if (days.length === 0) return ""
const start = days[0]
const end = days[days.length - 1]
if (format(start, "MMM") === format(end, "MMM")) {
return `${format(start, "MMM d")} - ${format(end, "d")}`
}
return `${format(start, "MMM d")} - ${format(end, "MMM d")}`
}
export function TimesheetGridView() {
const [teamFilter, setTeamFilter] = useState<TeamFilter>("everyone")
const [trackedTimeFilter, setTrackedTimeFilter] =
useState<TrackedTimeFilter>("any")
const [billableStatusFilter, setBillableStatusFilter] =
useState<BillableStatusFilter>("any")
const [weekStart, setWeekStart] = useState<Date>(
normalizeWeekStart(parseISO(DEFAULT_WEEK_START))
)
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 5,
})
const resetPagination = useCallback(() => {
setPagination((current) =>
current.pageIndex === 0 ? current : { ...current, pageIndex: 0 }
)
}, [])
const weekDays = useMemo(
() =>
eachDayOfInterval({
start: weekStart,
end: endOfWeek(weekStart, { weekStartsOn: 1 }),
}),
[weekStart]
)
const visibleDays = weekDays
const filteredRows = useMemo(() => {
return TIMESHEET_PEOPLE.map((person) =>
createTimesheetRow(person, visibleDays)
)
.filter((row) =>
teamFilter === "everyone" ? true : row.person.team === teamFilter
)
.filter((row) =>
trackedTimeFilter === "any"
? true
: row.utilizationState === trackedTimeFilter
)
.filter((row) =>
billableStatusFilter === "any"
? true
: row.billableState === billableStatusFilter
)
}, [billableStatusFilter, teamFilter, trackedTimeFilter, visibleDays])
const columns = useMemo(
() => createTimesheetColumns({ visibleDays }),
[visibleDays]
)
const table = useTable({
features: dataGridFeatures,
data: filteredRows,
columns,
getRowId: (row) => row.id,
state: {
pagination,
},
onPaginationChange: setPagination,
})
const handleWeekPick = useCallback(
(date: Date | undefined) => {
if (!date) return
setWeekStart(normalizeWeekStart(date))
resetPagination()
},
[resetPagination]
)
const shiftWeek = useCallback(
(delta: number) => {
setWeekStart((current) => addWeeks(current, delta))
resetPagination()
},
[resetPagination]
)
const emptyMessage = "No timesheets match the current filters."
return (
<DataGrid
table={table}
recordCount={filteredRows.length}
emptyMessage={emptyMessage}
tableLayout={{
cellBorder: true,
columnsPinnable: false,
dense: true,
}}
// customize: edge cells borrow the frame's own px token so the first and
// last columns line up with the FrameHeader instead of the 8px cell default
tableClassNames={{
edgeCell:
"first:ps-(--frame-panel-header-px) last:pe-(--frame-panel-header-px)",
}}
>
<Frame dense className="w-full">
<FrameHeader className="flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-col gap-px">
<FrameTitle className="text-balance">Timesheets</FrameTitle>
<FrameDescription className="text-xs text-pretty">
Weekly team timesheets
</FrameDescription>
</div>
<Button type="button" className="w-full sm:w-auto">
New entry
</Button>
</FrameHeader>
<FramePanel className="bg-card p-0! shadow-none!">
{/* customize: px stays on the frame header token; py-2.5 gives the filter row more breathing room */}
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-2.5">
<div className="flex flex-wrap items-center gap-2">
<Select
value={teamFilter}
onValueChange={(value) => {
setTeamFilter(value as TeamFilter)
resetPagination()
}}
items={TEAM_OPTIONS}
>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{TEAM_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Select
value={trackedTimeFilter}
onValueChange={(value) => {
setTrackedTimeFilter(value as TrackedTimeFilter)
resetPagination()
}}
items={TRACKED_TIME_OPTIONS}
>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{TRACKED_TIME_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Select
value={billableStatusFilter}
onValueChange={(value) => {
setBillableStatusFilter(value as BillableStatusFilter)
resetPagination()
}}
items={BILLABLE_STATUS_OPTIONS}
>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{BILLABLE_STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
<ButtonGroup>
<Button
type="button"
variant="outline"
size="icon"
aria-label="Previous week"
onClick={() => shiftWeek(-1)}
>
<ChevronLeftIcon aria-hidden="true" />
</Button>
<Popover>
<PopoverTrigger
render={
<Button
type="button"
variant="outline"
className="w-[168px] justify-between font-normal tabular-nums"
>
<span className="truncate">
{formatRangeLabel(visibleDays)}
</span>
<CalendarIcon className="text-muted-foreground" aria-hidden="true" />
</Button>
}
/>
<PopoverContent align="end" className="w-auto p-0">
<Calendar
mode="single"
selected={weekStart}
onSelect={handleWeekPick}
className="rounded-lg"
/>
</PopoverContent>
</Popover>
<Button
type="button"
variant="outline"
size="icon"
aria-label="Next week"
onClick={() => shiftWeek(1)}
>
<ChevronRightIcon aria-hidden="true" />
</Button>
</ButtonGroup>
</div>
</div>
<Separator />
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
<Separator />
<FrameFooter>
<DataGridPagination />
</FrameFooter>
</FramePanel>
</Frame>
</DataGrid>
)
}
@@ -0,0 +1,490 @@
import { addDays, format, parseISO } from "date-fns"
export type TeamFilter =
| "everyone"
| "client-delivery"
| "product"
| "operations"
| "support"
export type TrackedTimeFilter =
| "any"
| "under-target"
| "on-target"
| "overtime"
export type BillableStatusFilter =
| "any"
| "mostly-billable"
| "mixed"
| "internal-only"
export type EntryKind = "billable" | "internal" | "support" | "leave" | "empty"
export interface ITimeEntry {
minutes: number
kind: EntryKind
note?: string
}
export interface ITimesheetPerson {
id: string
name: string
initials: string
avatar?: string
role: string
team: Exclude<TeamFilter, "everyone">
teamLabel: string
weeklyTargetMinutes: number
entries: Record<string, ITimeEntry>
}
export interface ITimesheetRow {
id: string
person: ITimesheetPerson
entries: Record<string, ITimeEntry>
totalMinutes: number
billableMinutes: number
billablePercent: number
activeDays: number
varianceMinutes: number
targetCoverage: number
utilizationState: Exclude<TrackedTimeFilter, "any">
billableState: Exclude<BillableStatusFilter, "any">
}
export const TEAM_OPTIONS: { value: TeamFilter; label: string }[] = [
{ value: "everyone", label: "People" },
{ value: "client-delivery", label: "Client delivery" },
{ value: "product", label: "Product" },
{ value: "operations", label: "Operations" },
{ value: "support", label: "Support" },
]
export const TRACKED_TIME_OPTIONS: {
value: TrackedTimeFilter
label: string
}[] = [
{ value: "any", label: "Tracked time" },
{ value: "under-target", label: "Under target" },
{ value: "on-target", label: "On target" },
{ value: "overtime", label: "Over target" },
]
export const BILLABLE_STATUS_OPTIONS: {
value: BillableStatusFilter
label: string
}[] = [
{ value: "any", label: "Billable status" },
{ value: "mostly-billable", label: "Mostly billable" },
{ value: "mixed", label: "Mixed" },
{ value: "internal-only", label: "Internal only" },
]
export const DEFAULT_WEEK_START = "2026-03-23"
export const EMPTY_ENTRY: ITimeEntry = {
minutes: 0,
kind: "empty",
}
function hours(
value: number,
kind: EntryKind = "billable",
note?: string
): ITimeEntry {
return {
minutes: Math.round(value * 60),
kind,
note,
}
}
function buildWeekEntries(weekStart: string, items: ITimeEntry[]) {
const startDate = parseISO(weekStart)
return Object.fromEntries(
items.map((item, index) => [
format(addDays(startDate, index), "yyyy-MM-dd"),
item,
])
)
}
function mergeEntries(...weeks: Array<Record<string, ITimeEntry>>) {
return Object.assign({}, ...weeks)
}
export function formatMinutes(minutes: number) {
if (minutes <= 0) return "0h"
const hoursPart = Math.floor(minutes / 60)
const minutesPart = minutes % 60
if (minutesPart === 0) return `${hoursPart}h`
return `${hoursPart}h ${minutesPart}m`
}
export const TIMESHEET_PEOPLE: ITimesheetPerson[] = [
{
id: "amara-ortiz",
name: "Amara Ortiz",
initials: "AO",
avatar:
"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=96&h=96&dpr=2&q=80",
role: "Client delivery lead",
team: "client-delivery",
teamLabel: "Client delivery",
weeklyTargetMinutes: 32 * 60,
entries: mergeEntries(
buildWeekEntries("2026-03-16", [
hours(6, "billable", "Sprint planning with the Aurora account"),
hours(6.5, "billable", "Client workshop and follow-up notes"),
hours(5, "billable", "Delivery handoff edits"),
hours(6, "billable", "Roadmap review with success team"),
hours(4, "billable", "Executive recap deck"),
EMPTY_ENTRY,
EMPTY_ENTRY,
]),
buildWeekEntries("2026-03-23", [
hours(6.5, "billable", "Migration kickoff with Atlas Health"),
hours(5.5, "internal", "Weekly staffing and margin review"),
hours(7, "billable", "Pilot implementation sync"),
hours(6, "billable", "Partner escalation planning"),
hours(4.5, "billable", "Renewal prep"),
EMPTY_ENTRY,
EMPTY_ENTRY,
]),
buildWeekEntries("2026-03-30", [
hours(7, "billable", "Client leadership review"),
hours(6.5, "billable", "Design QA handoff"),
hours(6.5, "billable", "Retention plan workshop"),
hours(6, "billable", "Launch review"),
hours(5, "billable", "Weekly closeout"),
EMPTY_ENTRY,
EMPTY_ENTRY,
])
),
},
{
id: "theo-mercer",
name: "Theo Mercer",
initials: "TM",
avatar:
"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=96&h=96&dpr=2&q=80",
role: "Platform engineer",
team: "product",
teamLabel: "Product",
weeklyTargetMinutes: 40 * 60,
entries: mergeEntries(
buildWeekEntries("2026-03-16", [
hours(8, "billable", "Registry licensing rollout"),
hours(8, "billable", "Private install token support"),
hours(7.5, "internal", "Infra refactor"),
hours(8, "billable", "Partner sandbox fixes"),
hours(6.5, "billable", "Observability cleanup"),
EMPTY_ENTRY,
EMPTY_ENTRY,
]),
buildWeekEntries("2026-03-23", [
hours(8, "billable", "Gateway failover validation"),
hours(8, "billable", "Webhook retries"),
hours(7.5, "billable", "Tenant sync fixes"),
hours(8, "billable", "SSO edge cases"),
hours(8, "billable", "Metrics rollout"),
EMPTY_ENTRY,
EMPTY_ENTRY,
]),
buildWeekEntries("2026-03-30", [
hours(8, "billable", "Multi-region smoke tests"),
hours(8, "billable", "Registry cache hardening"),
hours(8, "billable", "Usage ledger fixes"),
hours(7.5, "billable", "Provisioning automation"),
hours(8, "billable", "Ops docs"),
EMPTY_ENTRY,
hours(2, "internal", "Weekend incident follow-up"),
])
),
},
{
id: "lena-hoffman",
name: "Lena Hoffman",
initials: "LH",
avatar:
"https://images.unsplash.com/photo-1517841905240-472988babdf9?w=96&h=96&dpr=2&q=80",
role: "Content systems editor",
team: "operations",
teamLabel: "Operations",
weeklyTargetMinutes: 30 * 60,
entries: mergeEntries(
buildWeekEntries("2026-03-16", [
hours(4, "internal", "Pattern taxonomy review"),
hours(4.5, "internal", "Docs cleanup"),
hours(5, "billable", "Client knowledge base rewrite"),
hours(5, "internal", "Release notes"),
hours(4, "billable", "Customer enablement assets"),
EMPTY_ENTRY,
EMPTY_ENTRY,
]),
buildWeekEntries("2026-03-23", [
hours(5, "internal", "Publishing QA"),
hours(4.5, "billable", "Implementation guide updates"),
hours(5, "internal", "Docs audit"),
hours(3.5, "billable", "Admin walkthrough copy"),
hours(4, "billable", "Email setup checklist"),
EMPTY_ENTRY,
EMPTY_ENTRY,
]),
buildWeekEntries("2026-03-30", [
hours(6, "billable", "Playbook rewrite"),
hours(5.5, "internal", "Navigation audit"),
hours(5, "internal", "New registry docs"),
hours(4.5, "billable", "Workspace onboarding copy"),
hours(4, "billable", "Support macros"),
EMPTY_ENTRY,
EMPTY_ENTRY,
])
),
},
{
id: "iris-calder",
name: "Iris Calder",
initials: "IC",
avatar:
"https://images.unsplash.com/photo-1544005313-94ddf0286df2?w=96&h=96&dpr=2&q=80",
role: "Design systems lead",
team: "product",
teamLabel: "Product",
weeklyTargetMinutes: 32 * 60,
entries: mergeEntries(
buildWeekEntries("2026-03-16", [
hours(6, "billable", "Dashboard refinements"),
hours(6, "billable", "Billing table polish"),
hours(6.5, "billable", "Settings review"),
hours(5.5, "internal", "Library maintenance"),
hours(4, "billable", "Handoff QA"),
EMPTY_ENTRY,
EMPTY_ENTRY,
]),
buildWeekEntries("2026-03-23", [
hours(6, "billable", "Timesheet explorations"),
hours(6.5, "billable", "Usage analytics QA"),
hours(6, "internal", "Pattern inventory"),
hours(6.5, "billable", "Workspace theming"),
hours(4, "billable", "Review fixes"),
EMPTY_ENTRY,
EMPTY_ENTRY,
]),
buildWeekEntries("2026-03-30", [
hours(6.5, "billable", "Sidebar migration"),
hours(7, "billable", "Table density pass"),
hours(6.5, "billable", "Forms package polish"),
hours(6.5, "billable", "Release candidate QA"),
hours(4.5, "billable", "Documentation review"),
EMPTY_ENTRY,
EMPTY_ENTRY,
])
),
},
{
id: "samir-vale",
name: "Samir Vale",
initials: "SV",
avatar:
"https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?w=96&h=96&dpr=2&q=80",
role: "Partner success manager",
team: "support",
teamLabel: "Support",
weeklyTargetMinutes: 35 * 60,
entries: mergeEntries(
buildWeekEntries("2026-03-16", [
hours(7, "support", "High-touch client office hours"),
hours(7, "support", "Renewal planning"),
hours(6, "billable", "Implementation steering"),
hours(7, "support", "Escalation review"),
hours(6.5, "billable", "Adoption workshop"),
EMPTY_ENTRY,
EMPTY_ENTRY,
]),
buildWeekEntries("2026-03-23", [
hours(7, "support", "Go-live triage"),
hours(7.5, "support", "Partner QBR prep"),
hours(6.5, "billable", "Migration playbook review"),
hours(8, "billable", "Expansion scoping"),
hours(7, "support", "Weekly care plan"),
EMPTY_ENTRY,
EMPTY_ENTRY,
]),
buildWeekEntries("2026-03-30", [
hours(8, "support", "Enterprise rollout watch"),
hours(7.5, "support", "Escalation retro"),
hours(7, "billable", "Success handoff"),
hours(7.5, "billable", "Client roadmap recap"),
hours(7, "support", "Support coverage"),
EMPTY_ENTRY,
EMPTY_ENTRY,
])
),
},
{
id: "nina-flores",
name: "Nina Flores",
initials: "NF",
avatar:
"https://images.unsplash.com/photo-1488426862026-3ee34a7d66df?w=96&h=96&dpr=2&q=80",
role: "Launch producer",
team: "client-delivery",
teamLabel: "Client delivery",
weeklyTargetMinutes: 28 * 60,
entries: mergeEntries(
buildWeekEntries("2026-03-16", [
hours(5, "billable", "Client kickoff logistics"),
hours(4.5, "billable", "Launch checklist review"),
hours(5, "billable", "Support routing"),
hours(4.5, "internal", "Team planning"),
hours(3.5, "billable", "Go-live notes"),
EMPTY_ENTRY,
EMPTY_ENTRY,
]),
buildWeekEntries("2026-03-23", [
hours(5, "billable", "Workspace provisioning"),
hours(5.5, "billable", "Rollout communications"),
hours(5.5, "billable", "Checklist QA"),
hours(4, "internal", "Process retro"),
hours(4, "billable", "Launch follow-up"),
EMPTY_ENTRY,
EMPTY_ENTRY,
]),
buildWeekEntries("2026-03-30", [
hours(5.5, "billable", "Calendar + onboarding flow"),
hours(5.5, "billable", "Billing handoff"),
hours(6, "billable", "Activation reporting"),
hours(4.5, "internal", "Ops planning"),
hours(4, "billable", "Weekly close"),
EMPTY_ENTRY,
EMPTY_ENTRY,
])
),
},
{
id: "owen-hart",
name: "Owen Hart",
initials: "OH",
avatar:
"https://images.unsplash.com/photo-1504593811423-6dd665756598?w=96&h=96&dpr=2&q=80",
role: "QA automation engineer",
team: "product",
teamLabel: "Product",
weeklyTargetMinutes: 40 * 60,
entries: mergeEntries(
buildWeekEntries("2026-03-16", [
hours(8, "billable", "Regression pack"),
hours(8, "billable", "Smoke tests"),
hours(7, "billable", "Accessibility fixes"),
hours(7.5, "support", "Release support"),
hours(6, "billable", "Cross-browser QA"),
EMPTY_ENTRY,
EMPTY_ENTRY,
]),
buildWeekEntries("2026-03-23", [
hours(8, "billable", "Release candidate QA"),
hours(8, "billable", "Import workflow pass"),
hours(8, "billable", "Nested table checks"),
hours(6, "support", "Launch support"),
hours(5, "billable", "Timesheet regressions"),
hours(2, "support", "Weekend support"),
EMPTY_ENTRY,
]),
buildWeekEntries("2026-03-30", [
hours(8, "billable", "Billing QA"),
hours(8, "billable", "Seat management checks"),
hours(7.5, "billable", "Advanced filters"),
hours(8, "billable", "Regression triage"),
hours(7, "billable", "Accessibility sweep"),
EMPTY_ENTRY,
EMPTY_ENTRY,
])
),
},
{
id: "cleo-warner",
name: "Cleo Warner",
initials: "CW",
avatar:
"https://images.unsplash.com/photo-1485893086445-ed75865251e0?w=96&h=96&dpr=2&q=80",
role: "Brand editor",
team: "operations",
teamLabel: "Operations",
weeklyTargetMinutes: 26 * 60,
entries: mergeEntries(
buildWeekEntries("2026-03-16", [
hours(4, "internal", "Voice and tone QA"),
hours(4.5, "internal", "Landing copy reviews"),
hours(4, "internal", "Release notes"),
hours(3.5, "billable", "Case study edits"),
hours(4, "billable", "Partner launch copy"),
EMPTY_ENTRY,
EMPTY_ENTRY,
]),
buildWeekEntries("2026-03-23", [
hours(4.5, "internal", "Meta description pass"),
hours(5, "internal", "Copy QA"),
hours(4, "billable", "Customer stories"),
hours(3.5, "billable", "Support snippets"),
hours(4, "internal", "Release copy"),
EMPTY_ENTRY,
EMPTY_ENTRY,
]),
buildWeekEntries("2026-03-30", [
hours(5, "internal", "Brand polish"),
hours(5, "billable", "Launch assets"),
hours(4.5, "internal", "Publishing queue"),
hours(4, "billable", "Email copy"),
hours(4, "billable", "Closeout edits"),
EMPTY_ENTRY,
EMPTY_ENTRY,
])
),
},
{
id: "jules-park",
name: "Jules Park",
initials: "JP",
avatar:
"https://images.unsplash.com/photo-1620075225255-8c2051b6c015?w=96&h=96&dpr=2&q=80",
role: "Revenue operations analyst",
team: "operations",
teamLabel: "Operations",
weeklyTargetMinutes: 20 * 60,
entries: mergeEntries(
buildWeekEntries("2026-03-16", [
hours(3.5, "internal", "Forecast model updates"),
hours(4, "billable", "Pricing QA"),
hours(4, "internal", "Usage audit"),
hours(3, "billable", "Renewal scorecard"),
hours(3, "internal", "Pipeline review"),
EMPTY_ENTRY,
EMPTY_ENTRY,
]),
buildWeekEntries("2026-03-23", [
hours(4, "internal", "ARR reconciliation"),
hours(4.5, "billable", "Expansion model"),
hours(3.5, "billable", "Usage reporting"),
hours(3, "internal", "Margin review"),
hours(3, "billable", "Team closeout"),
EMPTY_ENTRY,
EMPTY_ENTRY,
]),
buildWeekEntries("2026-03-30", [
hours(4, "internal", "Pipeline hygiene"),
hours(4.5, "billable", "Budget forecast"),
hours(4, "internal", "Seat utilization audit"),
hours(3.5, "billable", "MRR roll-up"),
hours(3, "internal", "Planning prep"),
EMPTY_ENTRY,
EMPTY_ENTRY,
])
),
},
]
@@ -0,0 +1,15 @@
import { TimesheetGridView } from "./components/data-grid-view"
export function Page() {
return (
<main
className="mx-auto flex min-h-svh w-full max-w-7xl items-start justify-center p-8 pt-12"
aria-labelledby="page-heading"
>
<h1 id="page-heading" className="sr-only">
Timesheet data grid
</h1>
<TimesheetGridView />
</main>
)
}
@@ -3,7 +3,7 @@ import { Link } from '@tanstack/react-router'
import { ServerIcon, ShieldAlertIcon } from 'lucide-react'
import type { DataGridColumn } from '@/components/data-grid-types'
import { dataGridCellStack } from '@/components/data-grid-cells'
import { dataGridCellStack, dataGridCellWithIcon } from '@/components/data-grid-cells'
import { columnDefFromDataGrid, FrameDataGrid } from '@/components/reui-kit'
import {
collectProbeColumns,
@@ -11,9 +11,16 @@ import {
resultByService,
type BlockingServiceRow,
} from './blocking-filters'
import { resolveServiceIcon, ServiceGlyph } from './service-icons'
import { StatusMatrixCell } from './status-matrix-cell'
import type { CensorcheckRunDto } from './types'
/** DNA data-grid-base-4: auto width + H-scroll + pin start. Preview: https://reui.io/preview/base/data-grid-base-4 */
export const BLOCKING_MATRIX_GRID = {
tableWidth: 'auto' as const,
horizontalScroll: true,
}
const MATRIX_CELL = 'w-16 min-w-16 px-1 text-center'
function vpsIdentityColumn(): DataGridColumn<CensorcheckRunDto> {
@@ -63,12 +70,9 @@ export function BlockingVpsGrid({
...serviceCols.map(
(svc): DataGridColumn<CensorcheckRunDto> => ({
key: `svc:${svc.key}`,
header: (
<span className="block max-w-16 truncate" title={svc.title}>
{svc.label}
</span>
),
header: svc.label,
headerTitle: svc.title,
icon: resolveServiceIcon(svc.key),
className: MATRIX_CELL,
headerClassName: MATRIX_CELL,
size: 72,
@@ -100,7 +104,7 @@ export function BlockingVpsGrid({
dense
pagination={runs.length > 10}
pinLeftColumnIds={['vps']}
horizontalScroll
{...BLOCKING_MATRIX_GRID}
emptyTitle="Нет проверок"
emptyDescription="Запустите launcher на VPS, чтобы увидеть статусы блокировок."
emptyAction={emptyAction}
@@ -135,16 +139,16 @@ export function BlockingServiceGrid({
size: 180,
minSize: 140,
sortValue: (row) => row.serviceKey,
cell: (row) => dataGridCellStack(row.serviceLabel, row.category),
cell: (row) =>
dataGridCellWithIcon(
<ServiceGlyph serviceKey={row.serviceKey} />,
dataGridCellStack(row.serviceLabel, row.category),
),
},
...probeCols.map(
(probe): DataGridColumn<BlockingServiceRow> => ({
key: `probe:${probe.key}`,
header: (
<span className="block max-w-16 truncate" title={probe.title}>
{probe.label}
</span>
),
header: probe.label,
headerTitle: probe.title,
className: MATRIX_CELL,
headerClassName: MATRIX_CELL,
@@ -180,7 +184,7 @@ export function BlockingServiceGrid({
dense
pagination={groups.length > 10}
pinLeftColumnIds={['service']}
horizontalScroll
{...BLOCKING_MATRIX_GRID}
emptyTitle="Нет сервисов"
emptyAction={emptyAction}
/>
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import {
CENSORCHECK_DPI_HOSTS,
CENSORCHECK_GEOBLOCK_HOSTS,
} from '@cfdm/shared/contracts/censorcheck'
import { GlobeIcon } from 'lucide-react'
import { resolveServiceIcon } from './service-icons'
describe('resolveServiceIcon', () => {
it('резолвит все DPI и geo хосты без fallback Globe', () => {
const hosts = [...CENSORCHECK_DPI_HOSTS, ...CENSORCHECK_GEOBLOCK_HOSTS]
for (const host of hosts) {
expect(resolveServiceIcon(host), host).not.toBe(GlobeIcon)
}
})
it('unknown / custom → Globe', () => {
expect(resolveServiceIcon('unknown.example')).toBe(GlobeIcon)
})
})
@@ -0,0 +1,114 @@
import type { ComponentType, SVGProps } from 'react'
import {
BookOpenIcon,
BoxIcon,
BugIcon,
ClapperboardIcon,
DownloadIcon,
FileJsonIcon,
GlobeIcon,
HeartIcon,
KeyRoundIcon,
LinkedinIcon,
MailIcon,
ScrollTextIcon,
ShieldIcon,
SparklesIcon,
VideoIcon,
type LucideIcon,
} from 'lucide-react'
import {
siDigitalocean,
siDiscord,
siFacebook,
siGoogleplay,
siInstagram,
siMongodb,
siNetflix,
siRedis,
siSpotify,
siTelegram,
siX,
siYoutube,
type SimpleIcon,
} from 'simple-icons'
import { cn } from '@cfdm/ui/lib/utils'
function BrandGlyph({
icon,
className,
}: {
icon: SimpleIcon
className?: string
}) {
return (
<svg
role="img"
viewBox="0 0 24 24"
className={cn('size-3.5 shrink-0', className)}
aria-hidden
>
<title>{icon.title}</title>
<path fill="currentColor" d={icon.path} />
</svg>
)
}
function brandComponent(icon: SimpleIcon): ComponentType<{ className?: string }> {
function BrandIcon({ className }: { className?: string }) {
return <BrandGlyph icon={icon} className={className} />
}
BrandIcon.displayName = `BrandIcon(${icon.slug})`
return BrandIcon
}
const BRAND_ICONS: Record<string, ComponentType<{ className?: string }>> = {
'discord.com': brandComponent(siDiscord),
'youtube.com': brandComponent(siYoutube),
'instagram.com': brandComponent(siInstagram),
'facebook.com': brandComponent(siFacebook),
'x.com': brandComponent(siX),
'api.telegram.org': brandComponent(siTelegram),
'spotify.com': brandComponent(siSpotify),
'netflix.com': brandComponent(siNetflix),
'mongodb.com': brandComponent(siMongodb),
'redis.io': brandComponent(siRedis),
'digitalocean.com': brandComponent(siDigitalocean),
'play.google.com': brandComponent(siGoogleplay),
}
const GENERIC_ICONS: Record<string, LucideIcon> = {
'linkedin.com': LinkedinIcon,
'redirector.googlevideo.com': VideoIcon,
'rutracker.org': DownloadIcon,
'amnezia.org': ShieldIcon,
'getoutline.org': KeyRoundIcon,
'mailfence.com': MailIcon,
'flibusta.is': BookOpenIcon,
'rezka.ag': ClapperboardIcon,
'patreon.com': HeartIcon,
'swagger.io': FileJsonIcon,
'snyk.io': BugIcon,
'autodesk.com': BoxIcon,
'graylog.org': ScrollTextIcon,
'copilot.microsoft.com': SparklesIcon,
}
export function resolveServiceIcon(
serviceKey: string,
): ComponentType<SVGProps<SVGSVGElement> & { className?: string }> {
const key = serviceKey.trim().toLowerCase()
return BRAND_ICONS[key] ?? GENERIC_ICONS[key] ?? GlobeIcon
}
export function ServiceGlyph({
serviceKey,
className,
}: {
serviceKey: string
className?: string
}) {
const Icon = resolveServiceIcon(serviceKey)
return <Icon className={cn('size-3.5 shrink-0', className)} />
}
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import {
BanIcon,
CheckIcon,
CircleAlertIcon,
ClockIcon,
CornerUpRightIcon,
XIcon,
} from 'lucide-react'
import { MATRIX_STATUS } from './status-matrix-cell'
describe('MATRIX_STATUS', () => {
it('статусы — lucide-иконки, не текст ОК/Блок', () => {
expect(MATRIX_STATUS.available.icon).toBe(CheckIcon)
expect(MATRIX_STATUS.available.variant).toBe('success-light')
expect(MATRIX_STATUS.blocked.icon).toBe(XIcon)
expect(MATRIX_STATUS.blocked.variant).toBe('destructive-light')
expect(MATRIX_STATUS.denied.icon).toBe(BanIcon)
expect(MATRIX_STATUS.timeout.icon).toBe(ClockIcon)
expect(MATRIX_STATUS.redirected.icon).toBe(CornerUpRightIcon)
expect(MATRIX_STATUS.error.icon).toBe(CircleAlertIcon)
})
})
@@ -1,20 +1,33 @@
import type { LucideIcon } from 'lucide-react'
import {
BanIcon,
CheckIcon,
CircleAlertIcon,
ClockIcon,
CornerUpRightIcon,
MinusIcon,
XIcon,
} from 'lucide-react'
import { Badge } from '@/components/reui/badge'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@cfdm/ui/components/tooltip'
import { StatusBadge } from '@/components/status-badge'
import { CENSORCHECK_STATUS_LABELS, formatCheckedAt } from './types'
/** Compact timesheet-style cell — preview: https://reui.io/preview/base/data-grid-base-4 */
const MATRIX_SHORT: Record<string, string> = {
available: 'ОК',
blocked: 'Блок',
denied: 'Отказ',
timeout: 'TO',
redirected: '3xx',
error: 'Err',
export const MATRIX_STATUS: Record<
string,
{ icon: LucideIcon; variant: 'success-light' | 'destructive-light' | 'destructive-outline' | 'warning-light' | 'info-light' | 'warning-outline' }
> = {
available: { icon: CheckIcon, variant: 'success-light' },
blocked: { icon: XIcon, variant: 'destructive-light' },
denied: { icon: BanIcon, variant: 'destructive-outline' },
timeout: { icon: ClockIcon, variant: 'warning-light' },
redirected: { icon: CornerUpRightIcon, variant: 'info-light' },
error: { icon: CircleAlertIcon, variant: 'warning-outline' },
}
export function StatusMatrixCell({
@@ -32,8 +45,10 @@ export function StatusMatrixCell({
checkedAt?: string
onSelect?: () => void
}) {
const short = status ? (MATRIX_SHORT[status] ?? status) : '—'
const full = status ? (CENSORCHECK_STATUS_LABELS[status] ?? status) : 'Нет результата'
const mapped = status ? MATRIX_STATUS[status] : undefined
const Icon = mapped?.icon ?? MinusIcon
const variant = mapped?.variant ?? 'outline'
const tip = [
serviceLabel,
vpsLabel,
@@ -44,11 +59,9 @@ export function StatusMatrixCell({
.filter(Boolean)
.join(' · ')
const badge = status ? (
<StatusBadge status={status} label={short} size="sm" />
) : (
<Badge variant="outline" size="sm" className="text-muted-foreground">
const badge = (
<Badge variant={variant} size="sm" radius="full" aria-label={full}>
<Icon className="size-3" />
</Badge>
)
+2 -2
View File
@@ -1,11 +1,11 @@
import type { ReactNode } from 'react'
import type { ReactNode, ComponentType } from 'react'
import type { LucideIcon } from 'lucide-react'
export interface DataGridColumn<T> {
key: string
header: ReactNode
cell: (row: T, index: number) => ReactNode
icon?: LucideIcon
icon?: LucideIcon | ComponentType<{ className?: string }>
sortable?: boolean
sortValue?: (row: T) => string | number
/** TanStack v9 `sortFn`; для числовых sortValue — `'basic'`. */
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import { kitDataGridTableLayout } from './frame-data-grid'
import { BLOCKING_MATRIX_GRID } from '../censorcheck/blocking-grid'
describe('kitDataGridTableLayout', () => {
it('CRUD defaults: без bg-muted header и width fixed', () => {
const layout = kitDataGridTableLayout()
expect(layout.headerBackground).toBe(false)
expect(layout.width).toBe('fixed')
expect(layout.headerSticky).toBe(true)
expect(layout.columnsPinnable).toBe(false)
})
it('матрица: auto + pin', () => {
const layout = kitDataGridTableLayout({
width: 'auto',
columnsPinnable: true,
})
expect(layout.width).toBe('auto')
expect(layout.columnsPinnable).toBe(true)
expect(layout.headerBackground).toBe(false)
})
})
describe('BLOCKING_MATRIX_GRID', () => {
it('data-grid-base-4: auto + horizontal scroll', () => {
expect(BLOCKING_MATRIX_GRID.tableWidth).toBe('auto')
expect(BLOCKING_MATRIX_GRID.horizontalScroll).toBe(true)
})
})
@@ -1,4 +1,11 @@
import { useState, useEffect, type ReactNode } from 'react'
import {
cloneElement,
isValidElement,
useState,
useEffect,
type ReactElement,
type ReactNode,
} from 'react'
import {
useTable,
flexRender,
@@ -89,6 +96,52 @@ export function dataGridColumnVisibilityOptions<T>(
}))
}
/** v9 primitive defaults: headerBackground false, width fixed. Docs: https://reui.io/docs/components/base/data-grid */
export function kitDataGridTableLayout(opts: {
dense?: boolean
width?: 'fixed' | 'auto'
columnsPinnable?: boolean
columnsVisibility?: boolean
} = {}) {
return {
dense: opts.dense ?? true,
stripped: true,
rowBorder: true,
headerSticky: true,
headerBackground: false,
headerBorder: true,
width: opts.width ?? ('fixed' as const),
columnsVisibility: opts.columnsVisibility ?? false,
columnsResizable: false,
columnsPinnable: opts.columnsPinnable ?? false,
columnsMovable: false,
rowsDraggable: false,
rowsPinnable: false,
}
}
function applyColumnPinControls<T extends object>(
columns: DataGridColumnDef<T>[],
columnPinControls: boolean,
): DataGridColumnDef<T>[] {
return columns.map((col) => {
const origHeader = col.header
if (typeof origHeader !== 'function') return col
return {
...col,
header: (ctx) => {
const node = origHeader(ctx)
if (isValidElement(node) && node.type === DataGridColumnHeader) {
return cloneElement(node as ReactElement<{ pinnable?: boolean }>, {
pinnable: columnPinControls,
})
}
return node
},
} as DataGridColumnDef<T>
})
}
export interface FrameDataGridProps<TData extends object> {
title?: ReactNode
description?: ReactNode
@@ -140,6 +193,10 @@ export interface FrameDataGridProps<TData extends object> {
pinLeftColumnIds?: string[]
/** Горизонтальный скролл широкой матрицы. */
horizontalScroll?: boolean
/** `table-layout`. CRUD default `fixed`; матрица — `auto`. Docs: https://reui.io/docs/components/base/data-grid */
tableWidth?: 'fixed' | 'auto'
/** Показать Pin/Unpin в header. По умолчанию скрыто при programmatic pin. */
columnPinControls?: boolean
}
function DataGridSectionHeader({
@@ -189,6 +246,7 @@ function FrameDataGridBody<TData extends object>({
enableColumnVisibility,
columnsPinnable,
horizontalScroll,
tableWidth,
}: {
table: DataGridTableInstance<TData>
data: TData[]
@@ -202,6 +260,7 @@ function FrameDataGridBody<TData extends object>({
enableColumnVisibility: boolean
columnsPinnable: boolean
horizontalScroll: boolean
tableWidth: 'fixed' | 'auto'
}) {
const tableNode = virtualization ? (
<DataGridTableVirtual height={height} footerContent={footerContent} />
@@ -209,27 +268,25 @@ function FrameDataGridBody<TData extends object>({
<DataGridTable footerContent={footerContent} />
)
const scrollOrientation =
virtualization && horizontalScroll
? 'both'
: virtualization
? 'vertical'
: 'horizontal'
return (
<DataGrid
table={table}
recordCount={data.length}
onRowClick={onRowClick}
emptyMessage={emptyTitle}
tableLayout={{
tableLayout={kitDataGridTableLayout({
dense,
stripped: true,
rowBorder: true,
headerSticky: true,
headerBackground: true,
headerBorder: true,
width: 'auto',
columnsVisibility: enableColumnVisibility,
columnsResizable: false,
width: tableWidth,
columnsPinnable,
columnsMovable: false,
rowsDraggable: false,
rowsPinnable: false,
}}
columnsVisibility: enableColumnVisibility,
})}
tableClassNames={{
header: 'text-xs font-medium text-muted-foreground',
}}
@@ -237,8 +294,8 @@ function FrameDataGridBody<TData extends object>({
<DataGridContainer border={false}>
{virtualization || horizontalScroll ? (
<DataGridScrollArea
orientation={virtualization && horizontalScroll ? 'both' : virtualization ? 'vertical' : 'both'}
style={virtualization ? { height } : { maxHeight: 'min(70vh, 40rem)' }}
orientation={scrollOrientation}
style={virtualization ? { height } : undefined}
>
{tableNode}
</DataGridScrollArea>
@@ -283,6 +340,8 @@ export function FrameDataGrid<TData extends object>({
getRowCanExpand,
pinLeftColumnIds,
horizontalScroll = false,
tableWidth = 'fixed',
columnPinControls = false,
}: FrameDataGridProps<TData>) {
const showPagination = pagination ?? true
const [sorting, setSorting] = useState<SortingState>(initialSorting ?? [])
@@ -340,11 +399,14 @@ export function FrameDataGrid<TData extends object>({
},
}
const tableColumns: DataGridColumnDef<TData>[] = [
...(expandedContent ? [expandColumn] : []),
...(enableRowSelection ? [selectColumn] : []),
...columns,
]
const tableColumns: DataGridColumnDef<TData>[] = applyColumnPinControls(
[
...(expandedContent ? [expandColumn] : []),
...(enableRowSelection ? [selectColumn] : []),
...columns,
],
columnPinControls,
)
const lastColId = pinLastColumn ? tableColumns[tableColumns.length - 1]?.id ?? '' : ''
const pinLeft = pinLeftColumnIds ?? []
@@ -444,6 +506,7 @@ export function FrameDataGrid<TData extends object>({
enableColumnVisibility={enableColumnVisibility}
columnsPinnable={enablePinning}
horizontalScroll={horizontalScroll}
tableWidth={tableWidth}
/>
)
@@ -460,7 +523,9 @@ export function FrameDataGrid<TData extends object>({
/** Хелпер для конвертации DataGridColumn<T> → ColumnDef<T> с DataGridColumnHeader. */
export function columnDefFromDataGrid<T extends object>(
cols: DataGridColumn<T>[],
options?: { columnPinControls?: boolean },
): DataGridColumnDef<T>[] {
const pinnable = options?.columnPinControls ?? false
return cols.map((c) => {
const title = resolveHeaderTitle(c.header, c.headerTitle)
const Icon = c.icon
@@ -482,6 +547,7 @@ export function columnDefFromDataGrid<T extends object>(
column={column}
title={title}
icon={<Icon />}
pinnable={pinnable}
/>
)
: () => c.header,
@@ -15,6 +15,7 @@ export { QuickActionGrid, type QuickActionItem } from './quick-action-grid'
export {
FrameDataGrid,
columnDefFromDataGrid,
kitDataGridTableLayout,
loadStoredColumnVisibility,
dataGridColumnVisibilityOptions,
type FrameDataGridProps,
@@ -9,9 +9,8 @@ import { CircleAlertIcon, FilterIcon, FilterXIcon } from 'lucide-react'
import { CountedLineTabs } from '@/components/counted-line-tabs'
import { Badge } from '@/components/reui/badge'
import { DataGrid, dataGridFeatures } from '@/components/reui/data-grid/data-grid'
import { DataGrid, DataGridContainer, dataGridFeatures } from '@/components/reui/data-grid/data-grid'
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
import {
Filters,
@@ -38,6 +37,7 @@ import { EmptyState } from '@/components/empty-state'
import { applyFiltersToData } from './filter-utils'
import {
FrameDataGrid,
kitDataGridTableLayout,
type DataGridColumnDef,
type FrameDataGridProps,
} from './frame-data-grid'
@@ -65,6 +65,10 @@ type SimpleGridPassthrough<T extends object> = Pick<
| 'columnVisibilityStorageKey'
| 'initialColumnVisibility'
| 'className'
| 'tableWidth'
| 'horizontalScroll'
| 'pinLeftColumnIds'
| 'columnPinControls'
>
export interface ResourcePageProps<T extends object> extends SimpleGridPassthrough<T> {
@@ -189,6 +193,10 @@ function ResourcePageSimple<T extends object>({
columnVisibilityStorageKey,
initialColumnVisibility,
className,
tableWidth,
horizontalScroll,
pinLeftColumnIds,
columnPinControls,
}: ResourcePageProps<T>) {
if (isLoading) return <ResourcePageSkeleton />
if (isError) return <ResourceLoadError error={error} onRetry={onRetry} />
@@ -233,6 +241,10 @@ function ResourcePageSimple<T extends object>({
columnVisibilityStorageKey={columnVisibilityStorageKey}
initialColumnVisibility={initialColumnVisibility}
className={className}
tableWidth={tableWidth}
horizontalScroll={horizontalScroll}
pinLeftColumnIds={pinLeftColumnIds}
columnPinControls={columnPinControls}
/>
</div>
)
@@ -265,6 +277,8 @@ function ResourcePageFiltered<T extends object>({
selectionToolbar,
toolbarExtra,
hideHeader = false,
pinLastColumn = false,
enableColumnVisibility = false,
}: ResourcePageProps<T>) {
const headerActions = primaryAction ?? actions
const [internalTab, setInternalTab] = useState(tabs?.[0]?.id ?? 'all')
@@ -316,6 +330,13 @@ function ResourcePageFiltered<T extends object>({
const selectedCount = selectedIds.length
const lastColId = pinLastColumn ? (columns[columns.length - 1]?.id ?? '') : ''
const enablePinning = Boolean(pinLastColumn && lastColId)
const columnPinning = {
start: [] as string[],
end: enablePinning ? [lastColId] : [],
}
const clearSelection = useCallback(() => {
setRowSelection({})
}, [])
@@ -325,7 +346,13 @@ function ResourcePageFiltered<T extends object>({
data: filteredData,
columns,
getRowId: (row) => getRowId(row),
state: { sorting, rowSelection, pagination },
state: {
sorting,
rowSelection,
pagination,
...(enablePinning ? { columnPinning } : {}),
},
initialState: enablePinning ? { columnPinning } : undefined,
enableRowSelection,
onSortingChange: setSorting,
onRowSelectionChange: setRowSelection,
@@ -394,15 +421,12 @@ function ResourcePageFiltered<T extends object>({
table={table}
recordCount={filteredData.length}
emptyMessage="Нет записей по выбранным фильтрам."
tableLayout={{
tableLayout={kitDataGridTableLayout({
dense: true,
stripped: true,
rowBorder: true,
headerSticky: true,
headerBackground: true,
headerBorder: true,
width: 'auto',
}}
width: 'fixed',
columnsPinnable: enablePinning,
columnsVisibility: enableColumnVisibility,
})}
>
<Frame dense variant="default" spacing="sm" className="w-full">
{!hideHeader ? (
@@ -488,9 +512,9 @@ function ResourcePageFiltered<T extends object>({
{(showFilters || toolbarExtra || selectedCount > 0) ? <Separator /> : null}
<DataGridScrollArea>
<DataGridContainer border={false}>
<DataGridTable />
</DataGridScrollArea>
</DataGridContainer>
<Separator />
@@ -1,5 +1,3 @@
"use client"
import { useMemo, useState } from "react"
import { Badge } from "@/components/reui/badge"
import type { DataGridFeatures } from "@/components/reui/data-grid/data-grid"
@@ -14,7 +12,7 @@ import {
PopoverTrigger,
} from "@cfdm/ui/components/popover"
import { Separator } from "@cfdm/ui/components/separator"
import { CheckIcon, CirclePlusIcon } from "lucide-react"
import { CirclePlusIcon, CheckIcon } from "lucide-react"
interface DataGridColumnFilterProps<TData extends object, TValue> {
column?: Column<DataGridFeatures, TData, TValue>
@@ -25,7 +25,7 @@ import {
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@cfdm/ui/components/dropdown-menu"
import { ArrowDownIcon, ArrowLeftIcon, ArrowLeftToLineIcon, ArrowRightIcon, ArrowRightToLineIcon, ArrowUpIcon, CheckIcon, ChevronsUpDownIcon, PinOffIcon, Settings2Icon } from "lucide-react"
import { ArrowDownIcon, ArrowUpIcon, ChevronsUpDownIcon, CheckIcon, ArrowLeftToLineIcon, ArrowRightToLineIcon, ArrowLeftIcon, ArrowRightIcon, Settings2Icon, PinOffIcon } from "lucide-react"
interface DataGridColumnHeaderProps<
TData extends object,
@@ -35,7 +35,7 @@ interface DataGridColumnHeaderProps<
/** When omitted, uses `column.columnDef.meta.headerTitle`, then a string `columnDef.header`, then `column.id`. */
title?: string
icon?: ReactNode
/** Reserved; pin controls are gated by tableLayout.columnsPinnable + column.getCanPin(). */
/** When true and tableLayout.columnsPinnable, show Pin/Unpin chrome. Default false (programmatic pin). */
pinnable?: boolean
filter?: ReactNode
visibility?: boolean
@@ -48,6 +48,7 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
className,
filter,
visibility = false,
pinnable = false,
}: DataGridColumnHeaderProps<TData, TValue>) {
const { isLoading, table, props } = useDataGrid()
const resolvedTitle = title ?? getColumnHeaderLabel(column)
@@ -103,10 +104,13 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
<ChevronsUpDownIcon className="mt-px size-3.25" aria-hidden="true" />
))
const pinChromeEnabled =
Boolean(pinnable) && Boolean(props.tableLayout?.columnsPinnable) && canPin
const hasControls =
props.tableLayout?.columnsMovable ||
(props.tableLayout?.columnsVisibility && visibility) ||
(props.tableLayout?.columnsPinnable && canPin) ||
pinChromeEnabled ||
filter
const menuItems = useMemo(() => {
@@ -168,7 +172,7 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
}
// Pin section
if (props.tableLayout?.columnsPinnable && canPin) {
if (pinChromeEnabled) {
if (hasPreviousSection) {
items.push(<DropdownMenuSeparator key="sep-pin" />)
}
@@ -276,6 +280,7 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
isSorted,
column,
props.tableLayout?.columnsPinnable,
pinChromeEnabled,
props.tableLayout?.columnsMovable,
props.tableLayout?.columnsVisibility,
canPin,
@@ -310,7 +315,7 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
{menuItems}
</DropdownMenuContent>
</DropdownMenu>
{props.tableLayout?.columnsPinnable && canPin && isPinned && (
{pinChromeEnabled && isPinned && (
<Button
size="icon-sm"
variant="ghost"
@@ -1,5 +1,3 @@
"use client"
import type { ReactElement } from "react"
import { getColumnHeaderLabel } from "@/components/reui/data-grid/data-grid"
import type { DataGridFeatures } from "@/components/reui/data-grid/data-grid"
@@ -1,5 +1,3 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import type { PointerEvent, ReactNode } from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
@@ -1,5 +1,3 @@
"use client"
import {
Fragment,
memo,
@@ -1,5 +1,3 @@
"use client"
import {
Fragment,
memo,
@@ -776,7 +774,6 @@ function DataGridTableHead({ children }: { children: ReactNode }) {
function DataGridTableHeadRow({
children,
rowId: _rowId,
}: {
children: ReactNode
rowId: string
@@ -564,9 +564,4 @@ function DataGridContainer({
)
}
export {
useDataGrid,
DataGridProvider,
DataGrid,
DataGridContainer,
}
export { useDataGrid, DataGridProvider, DataGrid, DataGridContainer }
+28 -9
View File
@@ -25,6 +25,14 @@ const frameVariants = cva(
"(1)] (1)] (1.25)] (1.5)] (1.5)] (0.5)] (1)] (1)]",
// Default panel token values — overridden per-variant below
"[--frame-panel-bg:var(--color-card)] [--frame-panel-border-color:var(--color-border)] [--frame-border-color:var(--color-border)]",
// Concentric inner radius: the panel corner nests smoothly inside the frame
// corner instead of matching it. The panel sits inset from the frame's outer
// edge by the frame's 1px border + --frame-px padding, so its radius is
// reduced by that same gap (radius gap keeps the two arcs parallel). This
// base value assumes the bordered default/inverse frame; `ghost` drops the
// 1px border term and `dense` pins it back to the frame radius (its panels
// are pulled flush to the edge).
"[--frame-panel-radius:calc(var(--frame-radius)_-_var(--frame-px)_-_1px)]",
],
{
variants: {
@@ -32,14 +40,23 @@ const frameVariants = cva(
default: "border border-[var(--frame-border-color)] bg-clip-padding",
inverse:
"[--frame-panel-bg:color-mix(in_oklch,var(--color-muted)_40%,transparent)] border border-[var(--frame-border-color)] bg-background bg-clip-padding",
ghost: "",
// No frame border, so the panel is inset by --frame-px padding only.
ghost: "[--frame-panel-radius:calc(var(--frame-radius)_-_var(--frame-px))]",
},
// Header/footer vertical rhythm is tighter than the panel body's, and
// the gap widens as the frame grows: the bars read as chrome rather than
// as another content block. py ladder is 0.5 / 1.5 / 2 / 2.5 against a
// body py of 2 / 3.5 / 4 / 5. These vars are style-agnostic - no
// style-*.css overrides them - so this single ladder drives all shadcn
// styles. `px` is deliberately left level with the body so header,
// content and footer stay left-aligned. `xs` holds at 0.5 (2px): it is
// the practical floor, since anything lower stops reading as padding.
spacing: {
xs: "[--frame-panel-px-base:--spacing(2)] [--frame-panel-py-base:--spacing(2)] [--frame-panel-header-px-base:--spacing(2)] [--frame-panel-header-py-base:--spacing(1)] [--frame-panel-footer-px-base:--spacing(2)] [--frame-panel-footer-py-base:--spacing(1)] (3)] (1)] (3)] (3)]",
sm: "[--frame-panel-px-base:--spacing(3)] [--frame-panel-py-base:--spacing(3.5)] [--frame-panel-header-px-base:--spacing(3)] [--frame-panel-header-py-base:--spacing(2.5)] [--frame-panel-footer-px-base:--spacing(3)] [--frame-panel-footer-py-base:--spacing(2.5)] (2)] (2)] (2)]",
xs: "[--frame-panel-px-base:--spacing(2)] [--frame-panel-py-base:--spacing(2)] [--frame-panel-header-px-base:--spacing(2)] [--frame-panel-header-py-base:--spacing(0.5)] [--frame-panel-footer-px-base:--spacing(2)] [--frame-panel-footer-py-base:--spacing(0.5)]",
sm: "[--frame-panel-px-base:--spacing(3)] [--frame-panel-py-base:--spacing(3.5)] [--frame-panel-header-px-base:--spacing(3)] [--frame-panel-header-py-base:--spacing(1.5)] [--frame-panel-footer-px-base:--spacing(3)] [--frame-panel-footer-py-base:--spacing(1.5)]",
default:
"[--frame-panel-px-base:--spacing(4)] [--frame-panel-py-base:--spacing(4)] [--frame-panel-header-px-base:--spacing(4)] [--frame-panel-header-py-base:--spacing(3)] [--frame-panel-footer-px-base:--spacing(4)] [--frame-panel-footer-py-base:--spacing(3)] (2)] (2)] (2)]",
lg: "[--frame-panel-px-base:--spacing(5)] [--frame-panel-py-base:--spacing(5)] [--frame-panel-header-px-base:--spacing(5)] [--frame-panel-header-py-base:--spacing(4)] [--frame-panel-footer-px-base:--spacing(5)] [--frame-panel-footer-py-base:--spacing(4)] (2)] (2)] (2)]",
"[--frame-panel-px-base:--spacing(4)] [--frame-panel-py-base:--spacing(4)] [--frame-panel-header-px-base:--spacing(4)] [--frame-panel-header-py-base:--spacing(2)] [--frame-panel-footer-px-base:--spacing(4)] [--frame-panel-footer-py-base:--spacing(2)]",
lg: "[--frame-panel-px-base:--spacing(5)] [--frame-panel-py-base:--spacing(5)] [--frame-panel-header-px-base:--spacing(5)] [--frame-panel-header-py-base:--spacing(2.5)] [--frame-panel-footer-px-base:--spacing(5)] [--frame-panel-footer-py-base:--spacing(2.5)]",
},
stacked: {
true: [
@@ -55,8 +72,10 @@ const frameVariants = cva(
],
},
dense: {
// Positional rules must stay as parent selectors — cannot be expressed via CSS vars
true: "p-0 gap-0 border-[var(--frame-border-color)] [&_[data-slot=frame-panel]]:-mx-px [&_[data-slot=frame-panel]]:before:hidden [&_[data-slot=frame-panel]:last-child]:-mb-px [&:not(:has([data-slot=frame-panel-header]))_[data-slot=frame-panel]:is(:first-child)]:-mt-px",
// Positional rules must stay as parent selectors — cannot be expressed via CSS vars.
// Padding is 0 and panels are pulled flush to the frame edge (-mx-px), so
// their corners align with the frame radius rather than nesting inside it.
true: "p-0 gap-0 border-[var(--frame-border-color)] [--frame-panel-radius:var(--frame-radius)] [&_[data-slot=frame-panel]]:-mx-px [&_[data-slot=frame-panel]]:before:hidden [&_[data-slot=frame-panel]:last-child]:-mb-px [&:not(:has([data-slot=frame-panel-header]))_[data-slot=frame-panel]:is(:first-child)]:-mt-px",
false: "",
},
},
@@ -101,10 +120,10 @@ function FramePanel({
// bg-(--frame-panel-bg) and border-(--frame-panel-border-color) consume the
// CSS vars set by the Frame parent. Any explicit bg-* or border-* class passed
// via className overrides these by Tailwind source order - no ! needed.
"relative overflow-hidden rounded-(--frame-radius) border border-(--frame-panel-border-color) bg-(--frame-panel-bg) bg-clip-padding shadow-xs",
"relative overflow-hidden rounded-(--frame-panel-radius) border border-(--frame-panel-border-color) bg-(--frame-panel-bg) bg-clip-padding shadow-xs",
// `fit` sizes the panel to its content; otherwise it grows to fill the frame.
!fit && "grow",
"before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--frame-radius)-1px)] before:shadow-black/5",
"before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--frame-panel-radius)_-_1px)] before:shadow-black/5",
"dark:bg-clip-border dark:before:shadow-white/5",
"px-(--frame-panel-px) py-(--frame-panel-py)",
className
-1
View File
@@ -155,7 +155,6 @@ function ProvidersPage() {
columns={columnDefFromDataGrid(columns)}
data={snap.providers}
getRowId={(p) => p.id}
pinLastColumn
/>
)}
</CrudListPage>
-2
View File
@@ -377,7 +377,6 @@ function SpacesPage() {
data={membersQuery.data ?? []}
getRowId={(m) => `${m.spaceId}-${m.userId}`}
pagination={false}
pinLastColumn
isLoading={membersQuery.isLoading}
isError={membersQuery.isError}
error={membersQuery.error as Error | null}
@@ -394,7 +393,6 @@ function SpacesPage() {
data={trash}
getRowId={(s) => s.id}
pagination={false}
pinLastColumn
emptyTitle="Корзина пуста"
emptyDescription="Удалённых пространств нет"
/>
+18 -9
View File
@@ -133,8 +133,8 @@ importers:
specifier: ^9.1.2
version: 9.1.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@tanstack/react-virtual':
specifier: ^3.14.4
version: 3.14.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
specifier: ^3.14.10
version: 3.14.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@xyflow/react':
specifier: ^12.11.2
version: 12.11.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(immer@11.1.8)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -171,6 +171,9 @@ importers:
recharts:
specifier: 3.8.0
version: 3.8.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@18.3.1)(react@19.2.7)(redux@5.0.1)
simple-icons:
specifier: ^16.28.0
version: 16.28.0
sonner:
specifier: ^1.7.0
version: 1.7.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -1511,8 +1514,8 @@ packages:
peerDependencies:
react: '>=18'
'@tanstack/react-virtual@3.14.4':
resolution: {integrity: sha512-dZzAQP2uCDAd+9sAehqmx/DcU+B91Q4Gb0aDSM7t9bJvWDyGF9sapFNW5r1gNLsHs4wTb6ScZENJeYaHxJLiOw==}
'@tanstack/react-virtual@3.14.10':
resolution: {integrity: sha512-SRyoUbdFMRHuYXMijV5H4ZarQWpXkj3iANq8OFre+pybeVap8ZJjZ3Nz9bVjx4d8PfobVUQUdKyyyHYk3E+djw==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
@@ -1570,8 +1573,8 @@ packages:
resolution: {integrity: sha512-ONpWQeass1sfg80CWF1NSwQ8r3GiqxA2lT/EdqIcrDEPZ0Z+0mM94eQoFYLPN0Kztzj8TQVb2+PrSZSItqA61g==}
engines: {node: '>=20'}
'@tanstack/virtual-core@3.17.2':
resolution: {integrity: sha512-w43MvWvmShpb6kIC9MOoLyUkLmRTLPjt61bHWs+X29hACSpX+n8DvgZ3qM7cUfflKlRRcHR9KVJE6TmcqnQvcA==}
'@tanstack/virtual-core@3.17.8':
resolution: {integrity: sha512-BfEvehNpOT75r5Ksc5xW6NZuXujTfb7nlSEyVu4XHG3gdxNg1KqXruWbDewXOUaUYIo4oRbSfkjIajz4MAT8tA==}
'@tanstack/virtual-file-routes@1.162.0':
resolution: {integrity: sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA==}
@@ -3229,6 +3232,10 @@ packages:
simple-get@4.0.1:
resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
simple-icons@16.28.0:
resolution: {integrity: sha512-sQPR5AtK/ijRjou7zw7mlLp08oB6FH7i0lOy5XJ2zp9mJs/yejgiOn7KvQoe2q4YJIx6VmgUSW5AOefebPt5kg==}
engines: {node: '>=0.12.18'}
sonic-boom@4.2.1:
resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==}
@@ -4547,9 +4554,9 @@ snapshots:
transitivePeerDependencies:
- react-dom
'@tanstack/react-virtual@3.14.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
'@tanstack/react-virtual@3.14.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@tanstack/virtual-core': 3.17.2
'@tanstack/virtual-core': 3.17.8
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
@@ -4626,7 +4633,7 @@ snapshots:
dependencies:
'@tanstack/store': 0.11.1
'@tanstack/virtual-core@3.17.2': {}
'@tanstack/virtual-core@3.17.8': {}
'@tanstack/virtual-file-routes@1.162.0': {}
@@ -6312,6 +6319,8 @@ snapshots:
once: 1.4.0
simple-concat: 1.0.1
simple-icons@16.28.0: {}
sonic-boom@4.2.1:
dependencies:
atomic-sleep: 1.0.0