From a5f83c6ecb278fe801ebae164da138892bb4822c Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sat, 22 Aug 2026 21:47:58 +0700 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=D0=B2=D1=8B=D1=80=D0=BE=D0=B2=D0=BD?= =?UTF-8?q?=D1=8F=D1=82=D1=8C=20header=20pin=20=D0=B8=20=D0=BC=D0=B0=D1=82?= =?UTF-8?q?=D1=80=D0=B8=D1=86=D1=83=20=D0=B1=D0=BB=D0=BE=D0=BA=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=BE=D0=BA=20=D0=BF=D0=BE=D0=B4=20ReUI=20v9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kit больше не перебивает defaults примитива: header без серой полосы, CRUD с table-fixed. Матрица /blocking получает sticky identity, иконки сервисов и статусы без текста ОК/Блок. Co-authored-by: Cursor --- apps/web/package.json | 3 +- .../data-grid-base-4/components/columns.tsx | 271 ++++++++++ .../components/data-grid-view.tsx | 411 +++++++++++++++ .../data-grid-base-4/components/data.tsx | 490 ++++++++++++++++++ .../blocks/data-grid-base-4/page.tsx | 15 + .../components/censorcheck/blocking-grid.tsx | 32 +- .../censorcheck/service-icons.test.ts | 21 + .../components/censorcheck/service-icons.tsx | 114 ++++ .../censorcheck/status-matrix-cell.test.ts | 24 + .../censorcheck/status-matrix-cell.tsx | 41 +- apps/web/src/components/data-grid-types.ts | 4 +- .../reui-kit/data-grid-kit-defaults.test.ts | 31 ++ .../components/reui-kit/frame-data-grid.tsx | 108 +++- apps/web/src/components/reui-kit/index.ts | 1 + .../src/components/reui-kit/resource-page.tsx | 50 +- .../data-grid/data-grid-column-filter.tsx | 4 +- .../data-grid/data-grid-column-header.tsx | 15 +- .../data-grid/data-grid-column-visibility.tsx | 2 - .../reui/data-grid/data-grid-scroll-area.tsx | 2 - .../reui/data-grid/data-grid-table-dnd.tsx | 2 - .../reui/data-grid/data-grid-table.tsx | 3 - .../components/reui/data-grid/data-grid.tsx | 7 +- apps/web/src/components/reui/frame.tsx | 37 +- apps/web/src/routes/_auth/providers.tsx | 1 - apps/web/src/routes/_auth/spaces.tsx | 2 - pnpm-lock.yaml | 27 +- 26 files changed, 1609 insertions(+), 109 deletions(-) create mode 100644 apps/web/src/components/blocks/data-grid-base-4/components/columns.tsx create mode 100644 apps/web/src/components/blocks/data-grid-base-4/components/data-grid-view.tsx create mode 100644 apps/web/src/components/blocks/data-grid-base-4/components/data.tsx create mode 100644 apps/web/src/components/blocks/data-grid-base-4/page.tsx create mode 100644 apps/web/src/components/censorcheck/service-icons.test.ts create mode 100644 apps/web/src/components/censorcheck/service-icons.tsx create mode 100644 apps/web/src/components/censorcheck/status-matrix-cell.test.ts create mode 100644 apps/web/src/components/reui-kit/data-grid-kit-defaults.test.ts diff --git a/apps/web/package.json b/apps/web/package.json index 27cef16..3576dc8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -24,7 +24,7 @@ "@tanstack/react-router": "^1.130.2", "@tanstack/react-router-devtools": "^1.130.2", "@tanstack/react-table": "^9.1.2", - "@tanstack/react-virtual": "^3.14.4", + "@tanstack/react-virtual": "^3.14.10", "@xyflow/react": "^12.11.2", "class-variance-authority": "^0.7.1", "cmdk": "^1.1.1", @@ -37,6 +37,7 @@ "react-dom": "^19.2.0", "react-hook-form": "^7.60.0", "recharts": "3.8.0", + "simple-icons": "^16.28.0", "sonner": "^1.7.0", "zod": "^3.25.0" }, diff --git a/apps/web/src/components/blocks/data-grid-base-4/components/columns.tsx b/apps/web/src/components/blocks/data-grid-base-4/components/columns.tsx new file mode 100644 index 0000000..a72159d --- /dev/null +++ b/apps/web/src/components/blocks/data-grid-base-4/components/columns.tsx @@ -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 = { + billable: "bg-emerald-500", + internal: "bg-sky-500", + support: "bg-amber-500", + leave: "bg-zinc-400", + empty: "bg-transparent", +} + +const entryLabel: Record = { + 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 ( +
+
+ + {person.avatar ? ( + + ) : null} + {person.initials} + +
+ +
+
+ {person.name} +
+
+ {person.role} +
+
+
+ ) +} + +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 ( + + + } + > +
+ 0 + ? "text-foreground font-medium" + : "text-muted-foreground", + weekend && minutes === 0 && "opacity-80" + )} + > + {minutes > 0 ? formatMinutes(minutes) : "-"} + + + + + + + + {helperLabel} + +
+
+ + {/* Content */} + +
+
+ {dayLabel} + + {minutes > 0 ? formatMinutes(minutes) : "-"} + +
+

{tooltipCopy}

+
+
+
+ ) +} + +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 ( +
+ + {formatMinutes(row.totalMinutes)} + + + {isPositiveTrend ? ( + +
+ ) +} + +export function createTimesheetColumns({ + visibleDays, +}: { + visibleDays: Date[] +}): ColumnDef[] { + const dayColumns: ColumnDef[] = + 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 }) => ( + + ), + cell: ({ row }) => ( + + ), + size: 112, + enableSorting: false, + enablePinning: false, + meta: { + headerClassName: "text-center!", + cellClassName: "", + }, + } + }) + + return [ + { + accessorFn: (row) => row.person.name, + id: "person", + header: ({ column }) => ( + + ), + cell: ({ row }) => , + size: 240, + enableSorting: false, + enableHiding: false, + enablePinning: false, + }, + ...dayColumns, + { + accessorFn: (row) => row.totalMinutes, + id: "total", + header: ({ column }) => ( + + ), + cell: ({ row }) => , + size: 120, + enableSorting: false, + enablePinning: false, + meta: { + headerClassName: "text-right", + cellClassName: "text-right", + }, + }, + ] +} \ No newline at end of file diff --git a/apps/web/src/components/blocks/data-grid-base-4/components/data-grid-view.tsx b/apps/web/src/components/blocks/data-grid-base-4/components/data-grid-view.tsx new file mode 100644 index 0000000..afda0b9 --- /dev/null +++ b/apps/web/src/components/blocks/data-grid-base-4/components/data-grid-view.tsx @@ -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 { + 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 { + 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("everyone") + const [trackedTimeFilter, setTrackedTimeFilter] = + useState("any") + const [billableStatusFilter, setBillableStatusFilter] = + useState("any") + const [weekStart, setWeekStart] = useState( + normalizeWeekStart(parseISO(DEFAULT_WEEK_START)) + ) + const [pagination, setPagination] = useState({ + 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 ( + + + +
+ Timesheets + + Weekly team timesheets + +
+ + +
+ + + {/* customize: px stays on the frame header token; py-2.5 gives the filter row more breathing room */} +
+
+ + + + + +
+ +
+ + + + + + + {formatRangeLabel(visibleDays)} + + + + + +
+
+ + + + + + + + + + + + +
+ +
+ ) +} \ No newline at end of file diff --git a/apps/web/src/components/blocks/data-grid-base-4/components/data.tsx b/apps/web/src/components/blocks/data-grid-base-4/components/data.tsx new file mode 100644 index 0000000..fa2a3b3 --- /dev/null +++ b/apps/web/src/components/blocks/data-grid-base-4/components/data.tsx @@ -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 + teamLabel: string + weeklyTargetMinutes: number + entries: Record +} + +export interface ITimesheetRow { + id: string + person: ITimesheetPerson + entries: Record + totalMinutes: number + billableMinutes: number + billablePercent: number + activeDays: number + varianceMinutes: number + targetCoverage: number + utilizationState: Exclude + billableState: Exclude +} + +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>) { + 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, + ]) + ), + }, +] \ No newline at end of file diff --git a/apps/web/src/components/blocks/data-grid-base-4/page.tsx b/apps/web/src/components/blocks/data-grid-base-4/page.tsx new file mode 100644 index 0000000..c6313ed --- /dev/null +++ b/apps/web/src/components/blocks/data-grid-base-4/page.tsx @@ -0,0 +1,15 @@ +import { TimesheetGridView } from "./components/data-grid-view" + +export function Page() { + return ( +
+

+ Timesheet data grid +

+ +
+ ) +} \ No newline at end of file diff --git a/apps/web/src/components/censorcheck/blocking-grid.tsx b/apps/web/src/components/censorcheck/blocking-grid.tsx index 70d64fa..acdb901 100644 --- a/apps/web/src/components/censorcheck/blocking-grid.tsx +++ b/apps/web/src/components/censorcheck/blocking-grid.tsx @@ -3,7 +3,7 @@ import { Link } from '@tanstack/react-router' import { ServerIcon, ShieldAlertIcon } from 'lucide-react' import type { DataGridColumn } from '@/components/data-grid-types' -import { dataGridCellStack } from '@/components/data-grid-cells' +import { dataGridCellStack, dataGridCellWithIcon } from '@/components/data-grid-cells' import { columnDefFromDataGrid, FrameDataGrid } from '@/components/reui-kit' import { collectProbeColumns, @@ -11,9 +11,16 @@ import { resultByService, type BlockingServiceRow, } from './blocking-filters' +import { resolveServiceIcon, ServiceGlyph } from './service-icons' import { StatusMatrixCell } from './status-matrix-cell' import type { CensorcheckRunDto } from './types' +/** DNA data-grid-base-4: auto width + H-scroll + pin start. Preview: https://reui.io/preview/base/data-grid-base-4 */ +export const BLOCKING_MATRIX_GRID = { + tableWidth: 'auto' as const, + horizontalScroll: true, +} + const MATRIX_CELL = 'w-16 min-w-16 px-1 text-center' function vpsIdentityColumn(): DataGridColumn { @@ -63,12 +70,9 @@ export function BlockingVpsGrid({ ...serviceCols.map( (svc): DataGridColumn => ({ key: `svc:${svc.key}`, - header: ( - - {svc.label} - - ), + header: svc.label, headerTitle: svc.title, + icon: resolveServiceIcon(svc.key), className: MATRIX_CELL, headerClassName: MATRIX_CELL, size: 72, @@ -100,7 +104,7 @@ export function BlockingVpsGrid({ dense pagination={runs.length > 10} pinLeftColumnIds={['vps']} - horizontalScroll + {...BLOCKING_MATRIX_GRID} emptyTitle="Нет проверок" emptyDescription="Запустите launcher на VPS, чтобы увидеть статусы блокировок." emptyAction={emptyAction} @@ -135,16 +139,16 @@ export function BlockingServiceGrid({ size: 180, minSize: 140, sortValue: (row) => row.serviceKey, - cell: (row) => dataGridCellStack(row.serviceLabel, row.category), + cell: (row) => + dataGridCellWithIcon( + , + dataGridCellStack(row.serviceLabel, row.category), + ), }, ...probeCols.map( (probe): DataGridColumn => ({ key: `probe:${probe.key}`, - header: ( - - {probe.label} - - ), + header: probe.label, headerTitle: probe.title, className: MATRIX_CELL, headerClassName: MATRIX_CELL, @@ -180,7 +184,7 @@ export function BlockingServiceGrid({ dense pagination={groups.length > 10} pinLeftColumnIds={['service']} - horizontalScroll + {...BLOCKING_MATRIX_GRID} emptyTitle="Нет сервисов" emptyAction={emptyAction} /> diff --git a/apps/web/src/components/censorcheck/service-icons.test.ts b/apps/web/src/components/censorcheck/service-icons.test.ts new file mode 100644 index 0000000..84af08e --- /dev/null +++ b/apps/web/src/components/censorcheck/service-icons.test.ts @@ -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) + }) +}) diff --git a/apps/web/src/components/censorcheck/service-icons.tsx b/apps/web/src/components/censorcheck/service-icons.tsx new file mode 100644 index 0000000..440d7ea --- /dev/null +++ b/apps/web/src/components/censorcheck/service-icons.tsx @@ -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 ( + + {icon.title} + + + ) +} + +function brandComponent(icon: SimpleIcon): ComponentType<{ className?: string }> { + function BrandIcon({ className }: { className?: string }) { + return + } + BrandIcon.displayName = `BrandIcon(${icon.slug})` + return BrandIcon +} + +const BRAND_ICONS: Record> = { + '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 = { + '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 & { 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 +} diff --git a/apps/web/src/components/censorcheck/status-matrix-cell.test.ts b/apps/web/src/components/censorcheck/status-matrix-cell.test.ts new file mode 100644 index 0000000..01c0a37 --- /dev/null +++ b/apps/web/src/components/censorcheck/status-matrix-cell.test.ts @@ -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) + }) +}) diff --git a/apps/web/src/components/censorcheck/status-matrix-cell.tsx b/apps/web/src/components/censorcheck/status-matrix-cell.tsx index 9148de9..b8d7b1b 100644 --- a/apps/web/src/components/censorcheck/status-matrix-cell.tsx +++ b/apps/web/src/components/censorcheck/status-matrix-cell.tsx @@ -1,20 +1,33 @@ +import type { LucideIcon } from 'lucide-react' +import { + BanIcon, + CheckIcon, + CircleAlertIcon, + ClockIcon, + CornerUpRightIcon, + MinusIcon, + XIcon, +} from 'lucide-react' + import { Badge } from '@/components/reui/badge' import { Tooltip, TooltipContent, TooltipTrigger, } from '@cfdm/ui/components/tooltip' -import { StatusBadge } from '@/components/status-badge' import { CENSORCHECK_STATUS_LABELS, formatCheckedAt } from './types' /** Compact timesheet-style cell — preview: https://reui.io/preview/base/data-grid-base-4 */ -const MATRIX_SHORT: Record = { - available: 'ОК', - blocked: 'Блок', - denied: 'Отказ', - timeout: 'TO', - redirected: '3xx', - error: 'Err', +export const MATRIX_STATUS: Record< + string, + { icon: LucideIcon; variant: 'success-light' | 'destructive-light' | 'destructive-outline' | 'warning-light' | 'info-light' | 'warning-outline' } +> = { + available: { icon: CheckIcon, variant: 'success-light' }, + blocked: { icon: XIcon, variant: 'destructive-light' }, + denied: { icon: BanIcon, variant: 'destructive-outline' }, + timeout: { icon: ClockIcon, variant: 'warning-light' }, + redirected: { icon: CornerUpRightIcon, variant: 'info-light' }, + error: { icon: CircleAlertIcon, variant: 'warning-outline' }, } export function StatusMatrixCell({ @@ -32,8 +45,10 @@ export function StatusMatrixCell({ checkedAt?: string onSelect?: () => void }) { - const short = status ? (MATRIX_SHORT[status] ?? status) : '—' const full = status ? (CENSORCHECK_STATUS_LABELS[status] ?? status) : 'Нет результата' + const mapped = status ? MATRIX_STATUS[status] : undefined + const Icon = mapped?.icon ?? MinusIcon + const variant = mapped?.variant ?? 'outline' const tip = [ serviceLabel, vpsLabel, @@ -44,11 +59,9 @@ export function StatusMatrixCell({ .filter(Boolean) .join(' · ') - const badge = status ? ( - - ) : ( - - — + const badge = ( + + ) diff --git a/apps/web/src/components/data-grid-types.ts b/apps/web/src/components/data-grid-types.ts index 5e63a7b..056da31 100644 --- a/apps/web/src/components/data-grid-types.ts +++ b/apps/web/src/components/data-grid-types.ts @@ -1,11 +1,11 @@ -import type { ReactNode } from 'react' +import type { ReactNode, ComponentType } from 'react' import type { LucideIcon } from 'lucide-react' export interface DataGridColumn { key: string header: ReactNode cell: (row: T, index: number) => ReactNode - icon?: LucideIcon + icon?: LucideIcon | ComponentType<{ className?: string }> sortable?: boolean sortValue?: (row: T) => string | number /** TanStack v9 `sortFn`; для числовых sortValue — `'basic'`. */ diff --git a/apps/web/src/components/reui-kit/data-grid-kit-defaults.test.ts b/apps/web/src/components/reui-kit/data-grid-kit-defaults.test.ts new file mode 100644 index 0000000..df7c05d --- /dev/null +++ b/apps/web/src/components/reui-kit/data-grid-kit-defaults.test.ts @@ -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) + }) +}) diff --git a/apps/web/src/components/reui-kit/frame-data-grid.tsx b/apps/web/src/components/reui-kit/frame-data-grid.tsx index ca1add5..0545fc0 100644 --- a/apps/web/src/components/reui-kit/frame-data-grid.tsx +++ b/apps/web/src/components/reui-kit/frame-data-grid.tsx @@ -1,4 +1,11 @@ -import { useState, useEffect, type ReactNode } from 'react' +import { + cloneElement, + isValidElement, + useState, + useEffect, + type ReactElement, + type ReactNode, +} from 'react' import { useTable, flexRender, @@ -89,6 +96,52 @@ export function dataGridColumnVisibilityOptions( })) } +/** 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( + columns: DataGridColumnDef[], + columnPinControls: boolean, +): DataGridColumnDef[] { + 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 + }) +} + export interface FrameDataGridProps { title?: ReactNode description?: ReactNode @@ -140,6 +193,10 @@ export interface FrameDataGridProps { pinLeftColumnIds?: string[] /** Горизонтальный скролл широкой матрицы. */ horizontalScroll?: boolean + /** `table-layout`. CRUD default `fixed`; матрица — `auto`. Docs: https://reui.io/docs/components/base/data-grid */ + tableWidth?: 'fixed' | 'auto' + /** Показать Pin/Unpin в header. По умолчанию скрыто при programmatic pin. */ + columnPinControls?: boolean } function DataGridSectionHeader({ @@ -189,6 +246,7 @@ function FrameDataGridBody({ enableColumnVisibility, columnsPinnable, horizontalScroll, + tableWidth, }: { table: DataGridTableInstance data: TData[] @@ -202,6 +260,7 @@ function FrameDataGridBody({ enableColumnVisibility: boolean columnsPinnable: boolean horizontalScroll: boolean + tableWidth: 'fixed' | 'auto' }) { const tableNode = virtualization ? ( @@ -209,27 +268,25 @@ function FrameDataGridBody({ ) + const scrollOrientation = + virtualization && horizontalScroll + ? 'both' + : virtualization + ? 'vertical' + : 'horizontal' + return ( ({ {virtualization || horizontalScroll ? ( {tableNode} @@ -283,6 +340,8 @@ export function FrameDataGrid({ getRowCanExpand, pinLeftColumnIds, horizontalScroll = false, + tableWidth = 'fixed', + columnPinControls = false, }: FrameDataGridProps) { const showPagination = pagination ?? true const [sorting, setSorting] = useState(initialSorting ?? []) @@ -340,11 +399,14 @@ export function FrameDataGrid({ }, } - const tableColumns: DataGridColumnDef[] = [ - ...(expandedContent ? [expandColumn] : []), - ...(enableRowSelection ? [selectColumn] : []), - ...columns, - ] + const tableColumns: DataGridColumnDef[] = applyColumnPinControls( + [ + ...(expandedContent ? [expandColumn] : []), + ...(enableRowSelection ? [selectColumn] : []), + ...columns, + ], + columnPinControls, + ) const lastColId = pinLastColumn ? tableColumns[tableColumns.length - 1]?.id ?? '' : '' const pinLeft = pinLeftColumnIds ?? [] @@ -444,6 +506,7 @@ export function FrameDataGrid({ enableColumnVisibility={enableColumnVisibility} columnsPinnable={enablePinning} horizontalScroll={horizontalScroll} + tableWidth={tableWidth} /> ) @@ -460,7 +523,9 @@ export function FrameDataGrid({ /** Хелпер для конвертации DataGridColumn → ColumnDef с DataGridColumnHeader. */ export function columnDefFromDataGrid( cols: DataGridColumn[], + options?: { columnPinControls?: boolean }, ): DataGridColumnDef[] { + const pinnable = options?.columnPinControls ?? false return cols.map((c) => { const title = resolveHeaderTitle(c.header, c.headerTitle) const Icon = c.icon @@ -482,6 +547,7 @@ export function columnDefFromDataGrid( column={column} title={title} icon={} + pinnable={pinnable} /> ) : () => c.header, diff --git a/apps/web/src/components/reui-kit/index.ts b/apps/web/src/components/reui-kit/index.ts index b7e2ebc..b178c43 100644 --- a/apps/web/src/components/reui-kit/index.ts +++ b/apps/web/src/components/reui-kit/index.ts @@ -15,6 +15,7 @@ export { QuickActionGrid, type QuickActionItem } from './quick-action-grid' export { FrameDataGrid, columnDefFromDataGrid, + kitDataGridTableLayout, loadStoredColumnVisibility, dataGridColumnVisibilityOptions, type FrameDataGridProps, diff --git a/apps/web/src/components/reui-kit/resource-page.tsx b/apps/web/src/components/reui-kit/resource-page.tsx index de56233..c8ea051 100644 --- a/apps/web/src/components/reui-kit/resource-page.tsx +++ b/apps/web/src/components/reui-kit/resource-page.tsx @@ -9,9 +9,8 @@ import { CircleAlertIcon, FilterIcon, FilterXIcon } from 'lucide-react' import { CountedLineTabs } from '@/components/counted-line-tabs' import { Badge } from '@/components/reui/badge' -import { DataGrid, dataGridFeatures } from '@/components/reui/data-grid/data-grid' +import { DataGrid, DataGridContainer, dataGridFeatures } from '@/components/reui/data-grid/data-grid' import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination' -import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area' import { DataGridTable } from '@/components/reui/data-grid/data-grid-table' import { Filters, @@ -38,6 +37,7 @@ import { EmptyState } from '@/components/empty-state' import { applyFiltersToData } from './filter-utils' import { FrameDataGrid, + kitDataGridTableLayout, type DataGridColumnDef, type FrameDataGridProps, } from './frame-data-grid' @@ -65,6 +65,10 @@ type SimpleGridPassthrough = Pick< | 'columnVisibilityStorageKey' | 'initialColumnVisibility' | 'className' + | 'tableWidth' + | 'horizontalScroll' + | 'pinLeftColumnIds' + | 'columnPinControls' > export interface ResourcePageProps extends SimpleGridPassthrough { @@ -189,6 +193,10 @@ function ResourcePageSimple({ columnVisibilityStorageKey, initialColumnVisibility, className, + tableWidth, + horizontalScroll, + pinLeftColumnIds, + columnPinControls, }: ResourcePageProps) { if (isLoading) return if (isError) return @@ -233,6 +241,10 @@ function ResourcePageSimple({ columnVisibilityStorageKey={columnVisibilityStorageKey} initialColumnVisibility={initialColumnVisibility} className={className} + tableWidth={tableWidth} + horizontalScroll={horizontalScroll} + pinLeftColumnIds={pinLeftColumnIds} + columnPinControls={columnPinControls} /> ) @@ -265,6 +277,8 @@ function ResourcePageFiltered({ selectionToolbar, toolbarExtra, hideHeader = false, + pinLastColumn = false, + enableColumnVisibility = false, }: ResourcePageProps) { const headerActions = primaryAction ?? actions const [internalTab, setInternalTab] = useState(tabs?.[0]?.id ?? 'all') @@ -316,6 +330,13 @@ function ResourcePageFiltered({ const selectedCount = selectedIds.length + const lastColId = pinLastColumn ? (columns[columns.length - 1]?.id ?? '') : '' + const enablePinning = Boolean(pinLastColumn && lastColId) + const columnPinning = { + start: [] as string[], + end: enablePinning ? [lastColId] : [], + } + const clearSelection = useCallback(() => { setRowSelection({}) }, []) @@ -325,7 +346,13 @@ function ResourcePageFiltered({ data: filteredData, columns, getRowId: (row) => getRowId(row), - state: { sorting, rowSelection, pagination }, + state: { + sorting, + rowSelection, + pagination, + ...(enablePinning ? { columnPinning } : {}), + }, + initialState: enablePinning ? { columnPinning } : undefined, enableRowSelection, onSortingChange: setSorting, onRowSelectionChange: setRowSelection, @@ -394,15 +421,12 @@ function ResourcePageFiltered({ table={table} recordCount={filteredData.length} emptyMessage="Нет записей по выбранным фильтрам." - tableLayout={{ + tableLayout={kitDataGridTableLayout({ dense: true, - stripped: true, - rowBorder: true, - headerSticky: true, - headerBackground: true, - headerBorder: true, - width: 'auto', - }} + width: 'fixed', + columnsPinnable: enablePinning, + columnsVisibility: enableColumnVisibility, + })} > {!hideHeader ? ( @@ -488,9 +512,9 @@ function ResourcePageFiltered({ {(showFilters || toolbarExtra || selectedCount > 0) ? : null} - + - + diff --git a/apps/web/src/components/reui/data-grid/data-grid-column-filter.tsx b/apps/web/src/components/reui/data-grid/data-grid-column-filter.tsx index c611bf2..2f332f0 100644 --- a/apps/web/src/components/reui/data-grid/data-grid-column-filter.tsx +++ b/apps/web/src/components/reui/data-grid/data-grid-column-filter.tsx @@ -1,5 +1,3 @@ -"use client" - import { useMemo, useState } from "react" import { Badge } from "@/components/reui/badge" import type { DataGridFeatures } from "@/components/reui/data-grid/data-grid" @@ -14,7 +12,7 @@ import { PopoverTrigger, } from "@cfdm/ui/components/popover" import { Separator } from "@cfdm/ui/components/separator" -import { CheckIcon, CirclePlusIcon } from "lucide-react" +import { CirclePlusIcon, CheckIcon } from "lucide-react" interface DataGridColumnFilterProps { column?: Column diff --git a/apps/web/src/components/reui/data-grid/data-grid-column-header.tsx b/apps/web/src/components/reui/data-grid/data-grid-column-header.tsx index a45a4c8..4c8eb72 100644 --- a/apps/web/src/components/reui/data-grid/data-grid-column-header.tsx +++ b/apps/web/src/components/reui/data-grid/data-grid-column-header.tsx @@ -25,7 +25,7 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@cfdm/ui/components/dropdown-menu" -import { ArrowDownIcon, ArrowLeftIcon, ArrowLeftToLineIcon, ArrowRightIcon, ArrowRightToLineIcon, ArrowUpIcon, CheckIcon, ChevronsUpDownIcon, PinOffIcon, Settings2Icon } from "lucide-react" +import { ArrowDownIcon, ArrowUpIcon, ChevronsUpDownIcon, CheckIcon, ArrowLeftToLineIcon, ArrowRightToLineIcon, ArrowLeftIcon, ArrowRightIcon, Settings2Icon, PinOffIcon } from "lucide-react" interface DataGridColumnHeaderProps< TData extends object, @@ -35,7 +35,7 @@ interface DataGridColumnHeaderProps< /** When omitted, uses `column.columnDef.meta.headerTitle`, then a string `columnDef.header`, then `column.id`. */ title?: string icon?: ReactNode - /** Reserved; pin controls are gated by tableLayout.columnsPinnable + column.getCanPin(). */ + /** When true and tableLayout.columnsPinnable, show Pin/Unpin chrome. Default false (programmatic pin). */ pinnable?: boolean filter?: ReactNode visibility?: boolean @@ -48,6 +48,7 @@ function DataGridColumnHeaderInner({ className, filter, visibility = false, + pinnable = false, }: DataGridColumnHeaderProps) { const { isLoading, table, props } = useDataGrid() const resolvedTitle = title ?? getColumnHeaderLabel(column) @@ -103,10 +104,13 @@ function DataGridColumnHeaderInner({