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 <[email protected]>
This commit is contained in:
@@ -24,7 +24,7 @@
|
|||||||
"@tanstack/react-router": "^1.130.2",
|
"@tanstack/react-router": "^1.130.2",
|
||||||
"@tanstack/react-router-devtools": "^1.130.2",
|
"@tanstack/react-router-devtools": "^1.130.2",
|
||||||
"@tanstack/react-table": "^9.1.2",
|
"@tanstack/react-table": "^9.1.2",
|
||||||
"@tanstack/react-virtual": "^3.14.4",
|
"@tanstack/react-virtual": "^3.14.10",
|
||||||
"@xyflow/react": "^12.11.2",
|
"@xyflow/react": "^12.11.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
@@ -37,6 +37,7 @@
|
|||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-hook-form": "^7.60.0",
|
"react-hook-form": "^7.60.0",
|
||||||
"recharts": "3.8.0",
|
"recharts": "3.8.0",
|
||||||
|
"simple-icons": "^16.28.0",
|
||||||
"sonner": "^1.7.0",
|
"sonner": "^1.7.0",
|
||||||
"zod": "^3.25.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 { ServerIcon, ShieldAlertIcon } from 'lucide-react'
|
||||||
|
|
||||||
import type { DataGridColumn } from '@/components/data-grid-types'
|
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 { columnDefFromDataGrid, FrameDataGrid } from '@/components/reui-kit'
|
||||||
import {
|
import {
|
||||||
collectProbeColumns,
|
collectProbeColumns,
|
||||||
@@ -11,9 +11,16 @@ import {
|
|||||||
resultByService,
|
resultByService,
|
||||||
type BlockingServiceRow,
|
type BlockingServiceRow,
|
||||||
} from './blocking-filters'
|
} from './blocking-filters'
|
||||||
|
import { resolveServiceIcon, ServiceGlyph } from './service-icons'
|
||||||
import { StatusMatrixCell } from './status-matrix-cell'
|
import { StatusMatrixCell } from './status-matrix-cell'
|
||||||
import type { CensorcheckRunDto } from './types'
|
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'
|
const MATRIX_CELL = 'w-16 min-w-16 px-1 text-center'
|
||||||
|
|
||||||
function vpsIdentityColumn(): DataGridColumn<CensorcheckRunDto> {
|
function vpsIdentityColumn(): DataGridColumn<CensorcheckRunDto> {
|
||||||
@@ -63,12 +70,9 @@ export function BlockingVpsGrid({
|
|||||||
...serviceCols.map(
|
...serviceCols.map(
|
||||||
(svc): DataGridColumn<CensorcheckRunDto> => ({
|
(svc): DataGridColumn<CensorcheckRunDto> => ({
|
||||||
key: `svc:${svc.key}`,
|
key: `svc:${svc.key}`,
|
||||||
header: (
|
header: svc.label,
|
||||||
<span className="block max-w-16 truncate" title={svc.title}>
|
|
||||||
{svc.label}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
headerTitle: svc.title,
|
headerTitle: svc.title,
|
||||||
|
icon: resolveServiceIcon(svc.key),
|
||||||
className: MATRIX_CELL,
|
className: MATRIX_CELL,
|
||||||
headerClassName: MATRIX_CELL,
|
headerClassName: MATRIX_CELL,
|
||||||
size: 72,
|
size: 72,
|
||||||
@@ -100,7 +104,7 @@ export function BlockingVpsGrid({
|
|||||||
dense
|
dense
|
||||||
pagination={runs.length > 10}
|
pagination={runs.length > 10}
|
||||||
pinLeftColumnIds={['vps']}
|
pinLeftColumnIds={['vps']}
|
||||||
horizontalScroll
|
{...BLOCKING_MATRIX_GRID}
|
||||||
emptyTitle="Нет проверок"
|
emptyTitle="Нет проверок"
|
||||||
emptyDescription="Запустите launcher на VPS, чтобы увидеть статусы блокировок."
|
emptyDescription="Запустите launcher на VPS, чтобы увидеть статусы блокировок."
|
||||||
emptyAction={emptyAction}
|
emptyAction={emptyAction}
|
||||||
@@ -135,16 +139,16 @@ export function BlockingServiceGrid({
|
|||||||
size: 180,
|
size: 180,
|
||||||
minSize: 140,
|
minSize: 140,
|
||||||
sortValue: (row) => row.serviceKey,
|
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(
|
...probeCols.map(
|
||||||
(probe): DataGridColumn<BlockingServiceRow> => ({
|
(probe): DataGridColumn<BlockingServiceRow> => ({
|
||||||
key: `probe:${probe.key}`,
|
key: `probe:${probe.key}`,
|
||||||
header: (
|
header: probe.label,
|
||||||
<span className="block max-w-16 truncate" title={probe.title}>
|
|
||||||
{probe.label}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
headerTitle: probe.title,
|
headerTitle: probe.title,
|
||||||
className: MATRIX_CELL,
|
className: MATRIX_CELL,
|
||||||
headerClassName: MATRIX_CELL,
|
headerClassName: MATRIX_CELL,
|
||||||
@@ -180,7 +184,7 @@ export function BlockingServiceGrid({
|
|||||||
dense
|
dense
|
||||||
pagination={groups.length > 10}
|
pagination={groups.length > 10}
|
||||||
pinLeftColumnIds={['service']}
|
pinLeftColumnIds={['service']}
|
||||||
horizontalScroll
|
{...BLOCKING_MATRIX_GRID}
|
||||||
emptyTitle="Нет сервисов"
|
emptyTitle="Нет сервисов"
|
||||||
emptyAction={emptyAction}
|
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 { Badge } from '@/components/reui/badge'
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@cfdm/ui/components/tooltip'
|
} from '@cfdm/ui/components/tooltip'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
|
||||||
import { CENSORCHECK_STATUS_LABELS, formatCheckedAt } from './types'
|
import { CENSORCHECK_STATUS_LABELS, formatCheckedAt } from './types'
|
||||||
|
|
||||||
/** Compact timesheet-style cell — preview: https://reui.io/preview/base/data-grid-base-4 */
|
/** Compact timesheet-style cell — preview: https://reui.io/preview/base/data-grid-base-4 */
|
||||||
const MATRIX_SHORT: Record<string, string> = {
|
export const MATRIX_STATUS: Record<
|
||||||
available: 'ОК',
|
string,
|
||||||
blocked: 'Блок',
|
{ icon: LucideIcon; variant: 'success-light' | 'destructive-light' | 'destructive-outline' | 'warning-light' | 'info-light' | 'warning-outline' }
|
||||||
denied: 'Отказ',
|
> = {
|
||||||
timeout: 'TO',
|
available: { icon: CheckIcon, variant: 'success-light' },
|
||||||
redirected: '3xx',
|
blocked: { icon: XIcon, variant: 'destructive-light' },
|
||||||
error: 'Err',
|
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({
|
export function StatusMatrixCell({
|
||||||
@@ -32,8 +45,10 @@ export function StatusMatrixCell({
|
|||||||
checkedAt?: string
|
checkedAt?: string
|
||||||
onSelect?: () => void
|
onSelect?: () => void
|
||||||
}) {
|
}) {
|
||||||
const short = status ? (MATRIX_SHORT[status] ?? status) : '—'
|
|
||||||
const full = status ? (CENSORCHECK_STATUS_LABELS[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 = [
|
const tip = [
|
||||||
serviceLabel,
|
serviceLabel,
|
||||||
vpsLabel,
|
vpsLabel,
|
||||||
@@ -44,11 +59,9 @@ export function StatusMatrixCell({
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' · ')
|
.join(' · ')
|
||||||
|
|
||||||
const badge = status ? (
|
const badge = (
|
||||||
<StatusBadge status={status} label={short} size="sm" />
|
<Badge variant={variant} size="sm" radius="full" aria-label={full}>
|
||||||
) : (
|
<Icon className="size-3" />
|
||||||
<Badge variant="outline" size="sm" className="text-muted-foreground">
|
|
||||||
—
|
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode, ComponentType } from 'react'
|
||||||
import type { LucideIcon } from 'lucide-react'
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
|
||||||
export interface DataGridColumn<T> {
|
export interface DataGridColumn<T> {
|
||||||
key: string
|
key: string
|
||||||
header: ReactNode
|
header: ReactNode
|
||||||
cell: (row: T, index: number) => ReactNode
|
cell: (row: T, index: number) => ReactNode
|
||||||
icon?: LucideIcon
|
icon?: LucideIcon | ComponentType<{ className?: string }>
|
||||||
sortable?: boolean
|
sortable?: boolean
|
||||||
sortValue?: (row: T) => string | number
|
sortValue?: (row: T) => string | number
|
||||||
/** TanStack v9 `sortFn`; для числовых sortValue — `'basic'`. */
|
/** 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 {
|
import {
|
||||||
useTable,
|
useTable,
|
||||||
flexRender,
|
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> {
|
export interface FrameDataGridProps<TData extends object> {
|
||||||
title?: ReactNode
|
title?: ReactNode
|
||||||
description?: ReactNode
|
description?: ReactNode
|
||||||
@@ -140,6 +193,10 @@ export interface FrameDataGridProps<TData extends object> {
|
|||||||
pinLeftColumnIds?: string[]
|
pinLeftColumnIds?: string[]
|
||||||
/** Горизонтальный скролл широкой матрицы. */
|
/** Горизонтальный скролл широкой матрицы. */
|
||||||
horizontalScroll?: boolean
|
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({
|
function DataGridSectionHeader({
|
||||||
@@ -189,6 +246,7 @@ function FrameDataGridBody<TData extends object>({
|
|||||||
enableColumnVisibility,
|
enableColumnVisibility,
|
||||||
columnsPinnable,
|
columnsPinnable,
|
||||||
horizontalScroll,
|
horizontalScroll,
|
||||||
|
tableWidth,
|
||||||
}: {
|
}: {
|
||||||
table: DataGridTableInstance<TData>
|
table: DataGridTableInstance<TData>
|
||||||
data: TData[]
|
data: TData[]
|
||||||
@@ -202,6 +260,7 @@ function FrameDataGridBody<TData extends object>({
|
|||||||
enableColumnVisibility: boolean
|
enableColumnVisibility: boolean
|
||||||
columnsPinnable: boolean
|
columnsPinnable: boolean
|
||||||
horizontalScroll: boolean
|
horizontalScroll: boolean
|
||||||
|
tableWidth: 'fixed' | 'auto'
|
||||||
}) {
|
}) {
|
||||||
const tableNode = virtualization ? (
|
const tableNode = virtualization ? (
|
||||||
<DataGridTableVirtual height={height} footerContent={footerContent} />
|
<DataGridTableVirtual height={height} footerContent={footerContent} />
|
||||||
@@ -209,27 +268,25 @@ function FrameDataGridBody<TData extends object>({
|
|||||||
<DataGridTable footerContent={footerContent} />
|
<DataGridTable footerContent={footerContent} />
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const scrollOrientation =
|
||||||
|
virtualization && horizontalScroll
|
||||||
|
? 'both'
|
||||||
|
: virtualization
|
||||||
|
? 'vertical'
|
||||||
|
: 'horizontal'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataGrid
|
<DataGrid
|
||||||
table={table}
|
table={table}
|
||||||
recordCount={data.length}
|
recordCount={data.length}
|
||||||
onRowClick={onRowClick}
|
onRowClick={onRowClick}
|
||||||
emptyMessage={emptyTitle}
|
emptyMessage={emptyTitle}
|
||||||
tableLayout={{
|
tableLayout={kitDataGridTableLayout({
|
||||||
dense,
|
dense,
|
||||||
stripped: true,
|
width: tableWidth,
|
||||||
rowBorder: true,
|
|
||||||
headerSticky: true,
|
|
||||||
headerBackground: true,
|
|
||||||
headerBorder: true,
|
|
||||||
width: 'auto',
|
|
||||||
columnsVisibility: enableColumnVisibility,
|
|
||||||
columnsResizable: false,
|
|
||||||
columnsPinnable,
|
columnsPinnable,
|
||||||
columnsMovable: false,
|
columnsVisibility: enableColumnVisibility,
|
||||||
rowsDraggable: false,
|
})}
|
||||||
rowsPinnable: false,
|
|
||||||
}}
|
|
||||||
tableClassNames={{
|
tableClassNames={{
|
||||||
header: 'text-xs font-medium text-muted-foreground',
|
header: 'text-xs font-medium text-muted-foreground',
|
||||||
}}
|
}}
|
||||||
@@ -237,8 +294,8 @@ function FrameDataGridBody<TData extends object>({
|
|||||||
<DataGridContainer border={false}>
|
<DataGridContainer border={false}>
|
||||||
{virtualization || horizontalScroll ? (
|
{virtualization || horizontalScroll ? (
|
||||||
<DataGridScrollArea
|
<DataGridScrollArea
|
||||||
orientation={virtualization && horizontalScroll ? 'both' : virtualization ? 'vertical' : 'both'}
|
orientation={scrollOrientation}
|
||||||
style={virtualization ? { height } : { maxHeight: 'min(70vh, 40rem)' }}
|
style={virtualization ? { height } : undefined}
|
||||||
>
|
>
|
||||||
{tableNode}
|
{tableNode}
|
||||||
</DataGridScrollArea>
|
</DataGridScrollArea>
|
||||||
@@ -283,6 +340,8 @@ export function FrameDataGrid<TData extends object>({
|
|||||||
getRowCanExpand,
|
getRowCanExpand,
|
||||||
pinLeftColumnIds,
|
pinLeftColumnIds,
|
||||||
horizontalScroll = false,
|
horizontalScroll = false,
|
||||||
|
tableWidth = 'fixed',
|
||||||
|
columnPinControls = false,
|
||||||
}: FrameDataGridProps<TData>) {
|
}: FrameDataGridProps<TData>) {
|
||||||
const showPagination = pagination ?? true
|
const showPagination = pagination ?? true
|
||||||
const [sorting, setSorting] = useState<SortingState>(initialSorting ?? [])
|
const [sorting, setSorting] = useState<SortingState>(initialSorting ?? [])
|
||||||
@@ -340,11 +399,14 @@ export function FrameDataGrid<TData extends object>({
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const tableColumns: DataGridColumnDef<TData>[] = [
|
const tableColumns: DataGridColumnDef<TData>[] = applyColumnPinControls(
|
||||||
|
[
|
||||||
...(expandedContent ? [expandColumn] : []),
|
...(expandedContent ? [expandColumn] : []),
|
||||||
...(enableRowSelection ? [selectColumn] : []),
|
...(enableRowSelection ? [selectColumn] : []),
|
||||||
...columns,
|
...columns,
|
||||||
]
|
],
|
||||||
|
columnPinControls,
|
||||||
|
)
|
||||||
|
|
||||||
const lastColId = pinLastColumn ? tableColumns[tableColumns.length - 1]?.id ?? '' : ''
|
const lastColId = pinLastColumn ? tableColumns[tableColumns.length - 1]?.id ?? '' : ''
|
||||||
const pinLeft = pinLeftColumnIds ?? []
|
const pinLeft = pinLeftColumnIds ?? []
|
||||||
@@ -444,6 +506,7 @@ export function FrameDataGrid<TData extends object>({
|
|||||||
enableColumnVisibility={enableColumnVisibility}
|
enableColumnVisibility={enableColumnVisibility}
|
||||||
columnsPinnable={enablePinning}
|
columnsPinnable={enablePinning}
|
||||||
horizontalScroll={horizontalScroll}
|
horizontalScroll={horizontalScroll}
|
||||||
|
tableWidth={tableWidth}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -460,7 +523,9 @@ export function FrameDataGrid<TData extends object>({
|
|||||||
/** Хелпер для конвертации DataGridColumn<T> → ColumnDef<T> с DataGridColumnHeader. */
|
/** Хелпер для конвертации DataGridColumn<T> → ColumnDef<T> с DataGridColumnHeader. */
|
||||||
export function columnDefFromDataGrid<T extends object>(
|
export function columnDefFromDataGrid<T extends object>(
|
||||||
cols: DataGridColumn<T>[],
|
cols: DataGridColumn<T>[],
|
||||||
|
options?: { columnPinControls?: boolean },
|
||||||
): DataGridColumnDef<T>[] {
|
): DataGridColumnDef<T>[] {
|
||||||
|
const pinnable = options?.columnPinControls ?? false
|
||||||
return cols.map((c) => {
|
return cols.map((c) => {
|
||||||
const title = resolveHeaderTitle(c.header, c.headerTitle)
|
const title = resolveHeaderTitle(c.header, c.headerTitle)
|
||||||
const Icon = c.icon
|
const Icon = c.icon
|
||||||
@@ -482,6 +547,7 @@ export function columnDefFromDataGrid<T extends object>(
|
|||||||
column={column}
|
column={column}
|
||||||
title={title}
|
title={title}
|
||||||
icon={<Icon />}
|
icon={<Icon />}
|
||||||
|
pinnable={pinnable}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
: () => c.header,
|
: () => c.header,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export { QuickActionGrid, type QuickActionItem } from './quick-action-grid'
|
|||||||
export {
|
export {
|
||||||
FrameDataGrid,
|
FrameDataGrid,
|
||||||
columnDefFromDataGrid,
|
columnDefFromDataGrid,
|
||||||
|
kitDataGridTableLayout,
|
||||||
loadStoredColumnVisibility,
|
loadStoredColumnVisibility,
|
||||||
dataGridColumnVisibilityOptions,
|
dataGridColumnVisibilityOptions,
|
||||||
type FrameDataGridProps,
|
type FrameDataGridProps,
|
||||||
|
|||||||
@@ -9,9 +9,8 @@ import { CircleAlertIcon, FilterIcon, FilterXIcon } from 'lucide-react'
|
|||||||
|
|
||||||
import { CountedLineTabs } from '@/components/counted-line-tabs'
|
import { CountedLineTabs } from '@/components/counted-line-tabs'
|
||||||
import { Badge } from '@/components/reui/badge'
|
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 { 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 { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
||||||
import {
|
import {
|
||||||
Filters,
|
Filters,
|
||||||
@@ -38,6 +37,7 @@ import { EmptyState } from '@/components/empty-state'
|
|||||||
import { applyFiltersToData } from './filter-utils'
|
import { applyFiltersToData } from './filter-utils'
|
||||||
import {
|
import {
|
||||||
FrameDataGrid,
|
FrameDataGrid,
|
||||||
|
kitDataGridTableLayout,
|
||||||
type DataGridColumnDef,
|
type DataGridColumnDef,
|
||||||
type FrameDataGridProps,
|
type FrameDataGridProps,
|
||||||
} from './frame-data-grid'
|
} from './frame-data-grid'
|
||||||
@@ -65,6 +65,10 @@ type SimpleGridPassthrough<T extends object> = Pick<
|
|||||||
| 'columnVisibilityStorageKey'
|
| 'columnVisibilityStorageKey'
|
||||||
| 'initialColumnVisibility'
|
| 'initialColumnVisibility'
|
||||||
| 'className'
|
| 'className'
|
||||||
|
| 'tableWidth'
|
||||||
|
| 'horizontalScroll'
|
||||||
|
| 'pinLeftColumnIds'
|
||||||
|
| 'columnPinControls'
|
||||||
>
|
>
|
||||||
|
|
||||||
export interface ResourcePageProps<T extends object> extends SimpleGridPassthrough<T> {
|
export interface ResourcePageProps<T extends object> extends SimpleGridPassthrough<T> {
|
||||||
@@ -189,6 +193,10 @@ function ResourcePageSimple<T extends object>({
|
|||||||
columnVisibilityStorageKey,
|
columnVisibilityStorageKey,
|
||||||
initialColumnVisibility,
|
initialColumnVisibility,
|
||||||
className,
|
className,
|
||||||
|
tableWidth,
|
||||||
|
horizontalScroll,
|
||||||
|
pinLeftColumnIds,
|
||||||
|
columnPinControls,
|
||||||
}: ResourcePageProps<T>) {
|
}: ResourcePageProps<T>) {
|
||||||
if (isLoading) return <ResourcePageSkeleton />
|
if (isLoading) return <ResourcePageSkeleton />
|
||||||
if (isError) return <ResourceLoadError error={error} onRetry={onRetry} />
|
if (isError) return <ResourceLoadError error={error} onRetry={onRetry} />
|
||||||
@@ -233,6 +241,10 @@ function ResourcePageSimple<T extends object>({
|
|||||||
columnVisibilityStorageKey={columnVisibilityStorageKey}
|
columnVisibilityStorageKey={columnVisibilityStorageKey}
|
||||||
initialColumnVisibility={initialColumnVisibility}
|
initialColumnVisibility={initialColumnVisibility}
|
||||||
className={className}
|
className={className}
|
||||||
|
tableWidth={tableWidth}
|
||||||
|
horizontalScroll={horizontalScroll}
|
||||||
|
pinLeftColumnIds={pinLeftColumnIds}
|
||||||
|
columnPinControls={columnPinControls}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -265,6 +277,8 @@ function ResourcePageFiltered<T extends object>({
|
|||||||
selectionToolbar,
|
selectionToolbar,
|
||||||
toolbarExtra,
|
toolbarExtra,
|
||||||
hideHeader = false,
|
hideHeader = false,
|
||||||
|
pinLastColumn = false,
|
||||||
|
enableColumnVisibility = false,
|
||||||
}: ResourcePageProps<T>) {
|
}: ResourcePageProps<T>) {
|
||||||
const headerActions = primaryAction ?? actions
|
const headerActions = primaryAction ?? actions
|
||||||
const [internalTab, setInternalTab] = useState(tabs?.[0]?.id ?? 'all')
|
const [internalTab, setInternalTab] = useState(tabs?.[0]?.id ?? 'all')
|
||||||
@@ -316,6 +330,13 @@ function ResourcePageFiltered<T extends object>({
|
|||||||
|
|
||||||
const selectedCount = selectedIds.length
|
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(() => {
|
const clearSelection = useCallback(() => {
|
||||||
setRowSelection({})
|
setRowSelection({})
|
||||||
}, [])
|
}, [])
|
||||||
@@ -325,7 +346,13 @@ function ResourcePageFiltered<T extends object>({
|
|||||||
data: filteredData,
|
data: filteredData,
|
||||||
columns,
|
columns,
|
||||||
getRowId: (row) => getRowId(row),
|
getRowId: (row) => getRowId(row),
|
||||||
state: { sorting, rowSelection, pagination },
|
state: {
|
||||||
|
sorting,
|
||||||
|
rowSelection,
|
||||||
|
pagination,
|
||||||
|
...(enablePinning ? { columnPinning } : {}),
|
||||||
|
},
|
||||||
|
initialState: enablePinning ? { columnPinning } : undefined,
|
||||||
enableRowSelection,
|
enableRowSelection,
|
||||||
onSortingChange: setSorting,
|
onSortingChange: setSorting,
|
||||||
onRowSelectionChange: setRowSelection,
|
onRowSelectionChange: setRowSelection,
|
||||||
@@ -394,15 +421,12 @@ function ResourcePageFiltered<T extends object>({
|
|||||||
table={table}
|
table={table}
|
||||||
recordCount={filteredData.length}
|
recordCount={filteredData.length}
|
||||||
emptyMessage="Нет записей по выбранным фильтрам."
|
emptyMessage="Нет записей по выбранным фильтрам."
|
||||||
tableLayout={{
|
tableLayout={kitDataGridTableLayout({
|
||||||
dense: true,
|
dense: true,
|
||||||
stripped: true,
|
width: 'fixed',
|
||||||
rowBorder: true,
|
columnsPinnable: enablePinning,
|
||||||
headerSticky: true,
|
columnsVisibility: enableColumnVisibility,
|
||||||
headerBackground: true,
|
})}
|
||||||
headerBorder: true,
|
|
||||||
width: 'auto',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Frame dense variant="default" spacing="sm" className="w-full">
|
<Frame dense variant="default" spacing="sm" className="w-full">
|
||||||
{!hideHeader ? (
|
{!hideHeader ? (
|
||||||
@@ -488,9 +512,9 @@ function ResourcePageFiltered<T extends object>({
|
|||||||
|
|
||||||
{(showFilters || toolbarExtra || selectedCount > 0) ? <Separator /> : null}
|
{(showFilters || toolbarExtra || selectedCount > 0) ? <Separator /> : null}
|
||||||
|
|
||||||
<DataGridScrollArea>
|
<DataGridContainer border={false}>
|
||||||
<DataGridTable />
|
<DataGridTable />
|
||||||
</DataGridScrollArea>
|
</DataGridContainer>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { Badge } from "@/components/reui/badge"
|
import { Badge } from "@/components/reui/badge"
|
||||||
import type { DataGridFeatures } from "@/components/reui/data-grid/data-grid"
|
import type { DataGridFeatures } from "@/components/reui/data-grid/data-grid"
|
||||||
@@ -14,7 +12,7 @@ import {
|
|||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@cfdm/ui/components/popover"
|
} from "@cfdm/ui/components/popover"
|
||||||
import { Separator } from "@cfdm/ui/components/separator"
|
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> {
|
interface DataGridColumnFilterProps<TData extends object, TValue> {
|
||||||
column?: Column<DataGridFeatures, TData, TValue>
|
column?: Column<DataGridFeatures, TData, TValue>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
DropdownMenuSubTrigger,
|
DropdownMenuSubTrigger,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@cfdm/ui/components/dropdown-menu"
|
} 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<
|
interface DataGridColumnHeaderProps<
|
||||||
TData extends object,
|
TData extends object,
|
||||||
@@ -35,7 +35,7 @@ interface DataGridColumnHeaderProps<
|
|||||||
/** When omitted, uses `column.columnDef.meta.headerTitle`, then a string `columnDef.header`, then `column.id`. */
|
/** When omitted, uses `column.columnDef.meta.headerTitle`, then a string `columnDef.header`, then `column.id`. */
|
||||||
title?: string
|
title?: string
|
||||||
icon?: ReactNode
|
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
|
pinnable?: boolean
|
||||||
filter?: ReactNode
|
filter?: ReactNode
|
||||||
visibility?: boolean
|
visibility?: boolean
|
||||||
@@ -48,6 +48,7 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
|
|||||||
className,
|
className,
|
||||||
filter,
|
filter,
|
||||||
visibility = false,
|
visibility = false,
|
||||||
|
pinnable = false,
|
||||||
}: DataGridColumnHeaderProps<TData, TValue>) {
|
}: DataGridColumnHeaderProps<TData, TValue>) {
|
||||||
const { isLoading, table, props } = useDataGrid()
|
const { isLoading, table, props } = useDataGrid()
|
||||||
const resolvedTitle = title ?? getColumnHeaderLabel(column)
|
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" />
|
<ChevronsUpDownIcon className="mt-px size-3.25" aria-hidden="true" />
|
||||||
))
|
))
|
||||||
|
|
||||||
|
const pinChromeEnabled =
|
||||||
|
Boolean(pinnable) && Boolean(props.tableLayout?.columnsPinnable) && canPin
|
||||||
|
|
||||||
const hasControls =
|
const hasControls =
|
||||||
props.tableLayout?.columnsMovable ||
|
props.tableLayout?.columnsMovable ||
|
||||||
(props.tableLayout?.columnsVisibility && visibility) ||
|
(props.tableLayout?.columnsVisibility && visibility) ||
|
||||||
(props.tableLayout?.columnsPinnable && canPin) ||
|
pinChromeEnabled ||
|
||||||
filter
|
filter
|
||||||
|
|
||||||
const menuItems = useMemo(() => {
|
const menuItems = useMemo(() => {
|
||||||
@@ -168,7 +172,7 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Pin section
|
// Pin section
|
||||||
if (props.tableLayout?.columnsPinnable && canPin) {
|
if (pinChromeEnabled) {
|
||||||
if (hasPreviousSection) {
|
if (hasPreviousSection) {
|
||||||
items.push(<DropdownMenuSeparator key="sep-pin" />)
|
items.push(<DropdownMenuSeparator key="sep-pin" />)
|
||||||
}
|
}
|
||||||
@@ -276,6 +280,7 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
|
|||||||
isSorted,
|
isSorted,
|
||||||
column,
|
column,
|
||||||
props.tableLayout?.columnsPinnable,
|
props.tableLayout?.columnsPinnable,
|
||||||
|
pinChromeEnabled,
|
||||||
props.tableLayout?.columnsMovable,
|
props.tableLayout?.columnsMovable,
|
||||||
props.tableLayout?.columnsVisibility,
|
props.tableLayout?.columnsVisibility,
|
||||||
canPin,
|
canPin,
|
||||||
@@ -310,7 +315,7 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
|
|||||||
{menuItems}
|
{menuItems}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
{props.tableLayout?.columnsPinnable && canPin && isPinned && (
|
{pinChromeEnabled && isPinned && (
|
||||||
<Button
|
<Button
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import type { ReactElement } from "react"
|
import type { ReactElement } from "react"
|
||||||
import { getColumnHeaderLabel } from "@/components/reui/data-grid/data-grid"
|
import { getColumnHeaderLabel } from "@/components/reui/data-grid/data-grid"
|
||||||
import type { DataGridFeatures } 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 { useCallback, useEffect, useRef, useState } from "react"
|
||||||
import type { PointerEvent, ReactNode } from "react"
|
import type { PointerEvent, ReactNode } from "react"
|
||||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Fragment,
|
Fragment,
|
||||||
memo,
|
memo,
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Fragment,
|
Fragment,
|
||||||
memo,
|
memo,
|
||||||
@@ -776,7 +774,6 @@ function DataGridTableHead({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
function DataGridTableHeadRow({
|
function DataGridTableHeadRow({
|
||||||
children,
|
children,
|
||||||
rowId: _rowId,
|
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
rowId: string
|
rowId: string
|
||||||
|
|||||||
@@ -564,9 +564,4 @@ function DataGridContainer({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export { useDataGrid, DataGridProvider, DataGrid, DataGridContainer }
|
||||||
useDataGrid,
|
|
||||||
DataGridProvider,
|
|
||||||
DataGrid,
|
|
||||||
DataGridContainer,
|
|
||||||
}
|
|
||||||
@@ -25,6 +25,14 @@ const frameVariants = cva(
|
|||||||
"(1)] (1)] (1.25)] (1.5)] (1.5)] (0.5)] (1)] (1)]",
|
"(1)] (1)] (1.25)] (1.5)] (1.5)] (0.5)] (1)] (1)]",
|
||||||
// Default panel token values — overridden per-variant below
|
// 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)]",
|
"[--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: {
|
variants: {
|
||||||
@@ -32,14 +40,23 @@ const frameVariants = cva(
|
|||||||
default: "border border-[var(--frame-border-color)] bg-clip-padding",
|
default: "border border-[var(--frame-border-color)] bg-clip-padding",
|
||||||
inverse:
|
inverse:
|
||||||
"[--frame-panel-bg:color-mix(in_oklch,var(--color-muted)_40%,transparent)] border border-[var(--frame-border-color)] bg-background bg-clip-padding",
|
"[--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: {
|
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)]",
|
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(2.5)] [--frame-panel-footer-px-base:--spacing(3)] [--frame-panel-footer-py-base:--spacing(2.5)] (2)] (2)] (2)]",
|
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:
|
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)]",
|
"[--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(4)] [--frame-panel-footer-px-base:--spacing(5)] [--frame-panel-footer-py-base:--spacing(4)] (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(2.5)] [--frame-panel-footer-px-base:--spacing(5)] [--frame-panel-footer-py-base:--spacing(2.5)]",
|
||||||
},
|
},
|
||||||
stacked: {
|
stacked: {
|
||||||
true: [
|
true: [
|
||||||
@@ -55,8 +72,10 @@ const frameVariants = cva(
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
dense: {
|
dense: {
|
||||||
// Positional rules must stay as parent selectors — cannot be expressed via CSS vars
|
// 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",
|
// 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: "",
|
false: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -101,10 +120,10 @@ function FramePanel({
|
|||||||
// bg-(--frame-panel-bg) and border-(--frame-panel-border-color) consume the
|
// 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
|
// CSS vars set by the Frame parent. Any explicit bg-* or border-* class passed
|
||||||
// via className overrides these by Tailwind source order - no ! needed.
|
// 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` sizes the panel to its content; otherwise it grows to fill the frame.
|
||||||
!fit && "grow",
|
!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",
|
"dark:bg-clip-border dark:before:shadow-white/5",
|
||||||
"px-(--frame-panel-px) py-(--frame-panel-py)",
|
"px-(--frame-panel-px) py-(--frame-panel-py)",
|
||||||
className
|
className
|
||||||
|
|||||||
@@ -155,7 +155,6 @@ function ProvidersPage() {
|
|||||||
columns={columnDefFromDataGrid(columns)}
|
columns={columnDefFromDataGrid(columns)}
|
||||||
data={snap.providers}
|
data={snap.providers}
|
||||||
getRowId={(p) => p.id}
|
getRowId={(p) => p.id}
|
||||||
pinLastColumn
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</CrudListPage>
|
</CrudListPage>
|
||||||
|
|||||||
@@ -377,7 +377,6 @@ function SpacesPage() {
|
|||||||
data={membersQuery.data ?? []}
|
data={membersQuery.data ?? []}
|
||||||
getRowId={(m) => `${m.spaceId}-${m.userId}`}
|
getRowId={(m) => `${m.spaceId}-${m.userId}`}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
pinLastColumn
|
|
||||||
isLoading={membersQuery.isLoading}
|
isLoading={membersQuery.isLoading}
|
||||||
isError={membersQuery.isError}
|
isError={membersQuery.isError}
|
||||||
error={membersQuery.error as Error | null}
|
error={membersQuery.error as Error | null}
|
||||||
@@ -394,7 +393,6 @@ function SpacesPage() {
|
|||||||
data={trash}
|
data={trash}
|
||||||
getRowId={(s) => s.id}
|
getRowId={(s) => s.id}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
pinLastColumn
|
|
||||||
emptyTitle="Корзина пуста"
|
emptyTitle="Корзина пуста"
|
||||||
emptyDescription="Удалённых пространств нет"
|
emptyDescription="Удалённых пространств нет"
|
||||||
/>
|
/>
|
||||||
|
|||||||
Generated
+18
-9
@@ -133,8 +133,8 @@ importers:
|
|||||||
specifier: ^9.1.2
|
specifier: ^9.1.2
|
||||||
version: 9.1.2([email protected]([email protected]))([email protected])
|
version: 9.1.2([email protected]([email protected]))([email protected])
|
||||||
'@tanstack/react-virtual':
|
'@tanstack/react-virtual':
|
||||||
specifier: ^3.14.4
|
specifier: ^3.14.10
|
||||||
version: 3.14.4([email protected]([email protected]))([email protected])
|
version: 3.14.10([email protected]([email protected]))([email protected])
|
||||||
'@xyflow/react':
|
'@xyflow/react':
|
||||||
specifier: ^12.11.2
|
specifier: ^12.11.2
|
||||||
version: 12.11.2(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected])([email protected]([email protected]))([email protected])
|
version: 12.11.2(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected])([email protected]([email protected]))([email protected])
|
||||||
@@ -171,6 +171,9 @@ importers:
|
|||||||
recharts:
|
recharts:
|
||||||
specifier: 3.8.0
|
specifier: 3.8.0
|
||||||
version: 3.8.0(@types/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected])
|
version: 3.8.0(@types/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected])
|
||||||
|
simple-icons:
|
||||||
|
specifier: ^16.28.0
|
||||||
|
version: 16.28.0
|
||||||
sonner:
|
sonner:
|
||||||
specifier: ^1.7.0
|
specifier: ^1.7.0
|
||||||
version: 1.7.4([email protected]([email protected]))([email protected])
|
version: 1.7.4([email protected]([email protected]))([email protected])
|
||||||
@@ -1511,8 +1514,8 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: '>=18'
|
react: '>=18'
|
||||||
|
|
||||||
'@tanstack/[email protected].4':
|
'@tanstack/[email protected].10':
|
||||||
resolution: {integrity: sha512-dZzAQP2uCDAd+9sAehqmx/DcU+B91Q4Gb0aDSM7t9bJvWDyGF9sapFNW5r1gNLsHs4wTb6ScZENJeYaHxJLiOw==}
|
resolution: {integrity: sha512-SRyoUbdFMRHuYXMijV5H4ZarQWpXkj3iANq8OFre+pybeVap8ZJjZ3Nz9bVjx4d8PfobVUQUdKyyyHYk3E+djw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
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
|
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==}
|
resolution: {integrity: sha512-ONpWQeass1sfg80CWF1NSwQ8r3GiqxA2lT/EdqIcrDEPZ0Z+0mM94eQoFYLPN0Kztzj8TQVb2+PrSZSItqA61g==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
'@tanstack/[email protected].2':
|
'@tanstack/[email protected].8':
|
||||||
resolution: {integrity: sha512-w43MvWvmShpb6kIC9MOoLyUkLmRTLPjt61bHWs+X29hACSpX+n8DvgZ3qM7cUfflKlRRcHR9KVJE6TmcqnQvcA==}
|
resolution: {integrity: sha512-BfEvehNpOT75r5Ksc5xW6NZuXujTfb7nlSEyVu4XHG3gdxNg1KqXruWbDewXOUaUYIo4oRbSfkjIajz4MAT8tA==}
|
||||||
|
|
||||||
'@tanstack/[email protected]':
|
'@tanstack/[email protected]':
|
||||||
resolution: {integrity: sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA==}
|
resolution: {integrity: sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA==}
|
||||||
@@ -3229,6 +3232,10 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
|
resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-sQPR5AtK/ijRjou7zw7mlLp08oB6FH7i0lOy5XJ2zp9mJs/yejgiOn7KvQoe2q4YJIx6VmgUSW5AOefebPt5kg==}
|
||||||
|
engines: {node: '>=0.12.18'}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==}
|
resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==}
|
||||||
|
|
||||||
@@ -4547,9 +4554,9 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- react-dom
|
- react-dom
|
||||||
|
|
||||||
'@tanstack/[email protected].4([email protected]([email protected]))([email protected])':
|
'@tanstack/[email protected].10([email protected]([email protected]))([email protected])':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tanstack/virtual-core': 3.17.2
|
'@tanstack/virtual-core': 3.17.8
|
||||||
react: 19.2.7
|
react: 19.2.7
|
||||||
react-dom: 19.2.7([email protected])
|
react-dom: 19.2.7([email protected])
|
||||||
|
|
||||||
@@ -4626,7 +4633,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@tanstack/store': 0.11.1
|
'@tanstack/store': 0.11.1
|
||||||
|
|
||||||
'@tanstack/[email protected].2': {}
|
'@tanstack/[email protected].8': {}
|
||||||
|
|
||||||
'@tanstack/[email protected]': {}
|
'@tanstack/[email protected]': {}
|
||||||
|
|
||||||
@@ -6312,6 +6319,8 @@ snapshots:
|
|||||||
once: 1.4.0
|
once: 1.4.0
|
||||||
simple-concat: 1.0.1
|
simple-concat: 1.0.1
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
atomic-sleep: 1.0.0
|
atomic-sleep: 1.0.0
|
||||||
|
|||||||
Reference in New Issue
Block a user