fix(ui): выровнять header pin и матрицу блокировок под ReUI v9
Docker / build (push) Failing after 25s
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:
@@ -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>
|
||||
)
|
||||
|
||||
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
|
||||
@@ -155,7 +155,6 @@ function ProvidersPage() {
|
||||
columns={columnDefFromDataGrid(columns)}
|
||||
data={snap.providers}
|
||||
getRowId={(p) => p.id}
|
||||
pinLastColumn
|
||||
/>
|
||||
)}
|
||||
</CrudListPage>
|
||||
|
||||
@@ -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="Удалённых пространств нет"
|
||||
/>
|
||||
|
||||
Generated
+18
-9
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user