diff --git a/src/app/(modules)/package/components/PackageTable.tsx b/src/app/(modules)/package/components/PackageTable.tsx index a329494..47d5b7c 100644 --- a/src/app/(modules)/package/components/PackageTable.tsx +++ b/src/app/(modules)/package/components/PackageTable.tsx @@ -58,6 +58,7 @@ export function PackageTable({ onPageChange, onLimitChange, }} + actionsSticky /> ); } diff --git a/src/app/(modules)/plans/components/PlanTable.tsx b/src/app/(modules)/plans/components/PlanTable.tsx index 9a4dede..9e539af 100644 --- a/src/app/(modules)/plans/components/PlanTable.tsx +++ b/src/app/(modules)/plans/components/PlanTable.tsx @@ -73,6 +73,7 @@ export function PlanTable({ }} sorting={sorting} onSortingChange={onSortingChange} + actionsSticky /> ); } diff --git a/src/app/(modules)/project/components/ProjectTable.tsx b/src/app/(modules)/project/components/ProjectTable.tsx index 651a319..09ee438 100644 --- a/src/app/(modules)/project/components/ProjectTable.tsx +++ b/src/app/(modules)/project/components/ProjectTable.tsx @@ -58,6 +58,7 @@ export function ProjectTable({ onPageChange, onLimitChange, }} + actionsSticky /> ); } diff --git a/src/app/(modules)/roles/components/RoleTable.tsx b/src/app/(modules)/roles/components/RoleTable.tsx index 66551b9..8b0f95e 100644 --- a/src/app/(modules)/roles/components/RoleTable.tsx +++ b/src/app/(modules)/roles/components/RoleTable.tsx @@ -73,6 +73,7 @@ export function RoleTable({ }} sorting={sorting} onSortingChange={onSortingChange} + actionsSticky /> ); } diff --git a/src/app/(modules)/roles/page.tsx b/src/app/(modules)/roles/page.tsx index 2eaebe2..c6ee3fd 100644 --- a/src/app/(modules)/roles/page.tsx +++ b/src/app/(modules)/roles/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useCallback, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { Plus, ShieldCheck } from 'lucide-react'; import { PageHeader } from '@/components/page-header'; @@ -27,6 +27,7 @@ export default function RolesPage() { (state) => state.user?.organization_id ?? null, ); const [isSheetOpen, setIsSheetOpen] = useState(false); + const [activeRole, setActiveRole] = useState(null); const { skip, setSkip, @@ -49,6 +50,7 @@ export default function RolesPage() { sorting, }); const permissionsQuery = usePermissionsTreeQuery(isSheetOpen); + const roleForm = useRoleForm({ organizationId, permissionTree: permissionsQuery.permissionTree, @@ -72,18 +74,32 @@ export default function RolesPage() { statusMutation; const openCreate = useCallback(() => { - prepareCreateRole(); + setActiveRole(null); setIsSheetOpen(true); - }, [prepareCreateRole]); + }, []); const openEdit = useCallback( (role: Role) => { - prepareEditRole(role); + setActiveRole(role); setIsSheetOpen(true); }, - [prepareEditRole], + [], ); + useEffect(() => { + if (!isSheetOpen || permissionsQuery.permissionTree.length === 0) { + return; + } + + activeRole ? prepareEditRole(activeRole) : prepareCreateRole(); + }, [ + activeRole, + isSheetOpen, + permissionsQuery.permissionTree, + prepareCreateRole, + prepareEditRole, + ]); + const toggleStatus = useCallback( (role: Role) => { updateRoleStatus(role); diff --git a/src/app/(modules)/segment/components/SegmentTable.tsx b/src/app/(modules)/segment/components/SegmentTable.tsx index 18f72be..d6b27a6 100644 --- a/src/app/(modules)/segment/components/SegmentTable.tsx +++ b/src/app/(modules)/segment/components/SegmentTable.tsx @@ -58,6 +58,7 @@ export function SegmentTable({ onPageChange, onLimitChange, }} + actionsSticky /> ); } diff --git a/src/app/(modules)/tenants/components/TenantTable.tsx b/src/app/(modules)/tenants/components/TenantTable.tsx index da2d73c..4c04323 100644 --- a/src/app/(modules)/tenants/components/TenantTable.tsx +++ b/src/app/(modules)/tenants/components/TenantTable.tsx @@ -74,6 +74,7 @@ export function TenantTable({ }} sorting={sorting} onSortingChange={onSortingChange} + actionsSticky /> ); } diff --git a/src/app/(modules)/users/components/UserColumns.tsx b/src/app/(modules)/users/components/UserColumns.tsx index 7966c43..f120db7 100644 --- a/src/app/(modules)/users/components/UserColumns.tsx +++ b/src/app/(modules)/users/components/UserColumns.tsx @@ -32,6 +32,8 @@ export function useUserColumns(): ColumnDef[] { { accessorKey: 'email', header: 'Email', + size: 200, + minSize: 160, }, { accessorKey: 'phone_number', @@ -42,6 +44,7 @@ export function useUserColumns(): ColumnDef[] { accessorKey: 'roles', header: 'Roles', enableSorting: false, + meta: { disableTruncate: true }, cell: ({ row }) => (
{row.original.roles?.length ? ( diff --git a/src/app/(modules)/users/components/UserTable.tsx b/src/app/(modules)/users/components/UserTable.tsx index c61362a..cb1476e 100644 --- a/src/app/(modules)/users/components/UserTable.tsx +++ b/src/app/(modules)/users/components/UserTable.tsx @@ -75,6 +75,7 @@ export function UserTable({ }} sorting={sorting} onSortingChange={onSortingChange} + actionsSticky /> ); } diff --git a/src/components/data-table/ColumnViewOptions.tsx b/src/components/data-table/ColumnViewOptions.tsx new file mode 100644 index 0000000..d825e50 --- /dev/null +++ b/src/components/data-table/ColumnViewOptions.tsx @@ -0,0 +1,48 @@ +'use client'; + +import { useMemo } from 'react'; +import type { Table } from '@tanstack/react-table'; +import { Columns3 } from 'lucide-react'; + +import { MultiSelectPopover } from '@/components/form'; + +function getColumnLabel(column: { id: string; columnDef: { header?: unknown } }) { + const header = column.columnDef.header; + return typeof header === 'string' ? header : column.id; +} + +export function ColumnViewOptions({ table }: { table: Table }) { + const columns = useMemo( + () => + table + .getAllLeafColumns() + .filter((column) => column.id !== 'select') + .map((column) => ({ + column, + value: column.id, + label: getColumnLabel(column), + disabled: !column.getCanHide(), + })), + [table], + ); + const visibleColumnIds = columns + .filter(({ column }) => column.getIsVisible()) + .map(({ value }) => value); + + return ( + } + options={columns} + values={visibleColumnIds} + searchPlaceholder="Search columns" + emptyMessage="No columns found." + onValuesChange={(nextValues) => { + const visibleValues = new Set(nextValues); + columns.forEach(({ column, value }) => { + column.toggleVisibility(visibleValues.has(value)); + }); + }} + /> + ); +} diff --git a/src/components/data-table/Header/index.tsx b/src/components/data-table/Header/index.tsx index f61c68b..7fd8b67 100644 --- a/src/components/data-table/Header/index.tsx +++ b/src/components/data-table/Header/index.tsx @@ -9,6 +9,7 @@ interface TopHeaderProps { onAddNew?: () => void; addButtonText?: string; toolbar?: React.ReactNode; + tableTools?: React.ReactNode; } const TopHeader = ({ @@ -17,6 +18,7 @@ const TopHeader = ({ onAddNew, addButtonText = 'Add New', toolbar, + tableTools, }: TopHeaderProps) => { return (
@@ -34,7 +36,14 @@ const TopHeader = ({ ) : null}
)} - {toolbar ?
{toolbar}
: null} + {(toolbar || tableTools) && ( +
+
{toolbar}
+ {tableTools ? ( +
{tableTools}
+ ) : null} +
+ )}
); }; diff --git a/src/components/data-table/TableHeader.tsx b/src/components/data-table/TableHeader.tsx index 7aae817..55a70f0 100644 --- a/src/components/data-table/TableHeader.tsx +++ b/src/components/data-table/TableHeader.tsx @@ -12,14 +12,16 @@ import { ArrowUpNarrowWide, } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { getPinnedColumnStyles } from './columnStyles'; const TableHeader = ({ table }: { table: Table }) => { return ( - + {table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => { const canSort = header.column.getCanSort(); + const canResize = header.column.getCanResize(); const sortDirection = header.column.getIsSorted(); const SortIcon = sortDirection === 'asc' @@ -31,17 +33,20 @@ const TableHeader = ({ table }: { table: Table }) => { return ( {header.isPlaceholder ? null : ( -
+
{flexRender( header.column.columnDef.header, @@ -56,6 +61,19 @@ const TableHeader = ({ table }: { table: Table }) => { ? 'text-foreground' : 'text-muted-foreground/60', )} + /> + ) : null} + {canResize ? ( +
diff --git a/src/components/data-table/columnStyles.ts b/src/components/data-table/columnStyles.ts new file mode 100644 index 0000000..42b6269 --- /dev/null +++ b/src/components/data-table/columnStyles.ts @@ -0,0 +1,19 @@ +import type { Column } from '@tanstack/react-table'; +import type { CSSProperties } from 'react'; + +export function getPinnedColumnStyles( + column: Column, + variant: 'header' | 'cell' = 'cell', +): CSSProperties { + const pinned = column.getIsPinned(); + + return { + width: column.getSize(), + minWidth: column.getSize(), + left: pinned === 'left' ? `${column.getStart('left')}px` : undefined, + right: pinned === 'right' ? `${column.getAfter('right')}px` : undefined, + position: pinned ? 'sticky' : 'relative', + // Header cells must sit above body cells when both are sticky (vertical + pin). + zIndex: pinned ? (variant === 'header' ? 30 : 10) : undefined, + }; +} diff --git a/src/components/data-table/index.tsx b/src/components/data-table/index.tsx index a46d9b2..1321b62 100644 --- a/src/components/data-table/index.tsx +++ b/src/components/data-table/index.tsx @@ -2,7 +2,10 @@ import React from 'react'; import { ColumnDef, + ColumnPinningState, + ColumnSizingState, SortingState, + VisibilityState, flexRender, getCoreRowModel, useReactTable, @@ -14,11 +17,23 @@ import { Table, TableBody, TableCell, TableRow } from '@/components/ui/table'; import { Skeleton } from '@/components/ui/skeleton'; import { TooltipProvider } from '@/components/ui/tooltip'; import type { PermissionInput } from '@/hooks/usePermissions'; +import { PermissionGuard } from '@/guards'; import TopHeader from './Header'; import TableHeader from './TableHeader'; import { TableFooter } from './Footer'; import { TableActionButton } from './TableActionButton'; +import { ColumnViewOptions } from './ColumnViewOptions'; +import { getPinnedColumnStyles } from './columnStyles'; +import { cn } from '@/lib/utils'; + +function shouldTruncateCell(column: { id: string; columnDef: { meta?: unknown } }) { + if (column.id === 'actions') return false; + const meta = column.columnDef.meta as { disableTruncate?: boolean } | undefined; + return !meta?.disableTruncate; +} + +export type ColumnAlign = 'left' | 'right'; export interface DataTableAction { label: string | ((item: TData) => string); @@ -37,6 +52,9 @@ export interface DataTableProps { addButtonText?: string; isLoading?: boolean; actions?: DataTableAction[]; + /** Sticky-pin the actions column (use with actionsAlign). */ + actionsSticky?: boolean; + actionsAlign?: ColumnAlign; toolbar?: React.ReactNode; emptyTitle?: string; emptyDescription?: string; @@ -49,9 +67,32 @@ export interface DataTableProps { }; sorting?: SortingState; onSortingChange?: (sorting: SortingState) => void; + enableColumnControls?: boolean; + /** Minimum width (px) for every column unless overridden in the column def. */ + minColumnSize?: number; } -export function DataTable({ +export function DataTable(props: DataTableProps) { + const { actions, ...rest } = props; + + if (!actions?.length) { + return ; + } + + const actionPermissions = actions.map((action) => action.permission); + + return ( + } + > + + + ); +} + +function DataTableContent({ columns: initialColumns, data, title, @@ -59,14 +100,21 @@ export function DataTable({ addButtonText, isLoading = false, actions, + actionsSticky = false, + actionsAlign = 'right', toolbar, emptyTitle = 'No results found.', emptyDescription = 'Try adjusting your filters or search terms.', pagination, sorting: controlledSorting, onSortingChange, + enableColumnControls = true, + minColumnSize = 120, }: DataTableProps) { const [rowSelection, setRowSelection] = React.useState({}); + const [columnVisibility, setColumnVisibility] = + React.useState({}); + const [columnSizing, setColumnSizing] = React.useState({}); const [internalSorting, setInternalSorting] = React.useState( [], ); @@ -91,11 +139,16 @@ export function DataTable({ if (actions?.length) { cols.push({ id: 'actions', + size: 110, + minSize: 90, + enableHiding: false, + enableSorting: false, + enablePinning: false, header: () =>
Actions
, cell: ({ row }) => { const item = row.original; return ( -
+
{actions.map((action) => { const label = typeof action.label === 'function' @@ -126,14 +179,31 @@ export function DataTable({ return cols; }, [actions, initialColumns]); + const columnPinning = React.useMemo( + () => + actionsSticky && actions?.length + ? { [actionsAlign]: ['actions'] } + : {}, + [actions, actionsAlign, actionsSticky], + ); + const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel(), onRowSelectionChange: setRowSelection, + onColumnVisibilityChange: setColumnVisibility, + onColumnSizingChange: setColumnSizing, onSortingChange: handleSortingChange, getSortedRowModel: getSortedRowModel(), getPaginationRowModel: getPaginationRowModel(), + enableColumnResizing: true, + enableColumnPinning: true, + columnResizeMode: 'onChange', + defaultColumn: { + minSize: minColumnSize, + size: minColumnSize, + }, manualPagination: !!pagination, manualSorting: isManualSorting, pageCount: pagination?.totalItems @@ -141,6 +211,9 @@ export function DataTable({ : -1, state: { rowSelection, + columnVisibility, + columnPinning, + columnSizing, sorting, pagination: pagination ? { @@ -177,12 +250,16 @@ export function DataTable({ onAddNew={onAddNew} addButtonText={addButtonText} toolbar={toolbar} + tableTools={ + enableColumnControls ? : null + } />
@@ -202,14 +279,30 @@ export function DataTable({ key={row.id} data-state={row.getIsSelected() && 'selected'} > - {row.getVisibleCells().map((cell) => ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext(), - )} - - ))} + {row.getVisibleCells().map((cell) => { + const truncate = shouldTruncateCell(cell.column); + const content = flexRender( + cell.column.columnDef.cell, + cell.getContext(), + ); + + return ( + + {truncate ? ( +
{content}
+ ) : ( + content + )} +
+ ); + })} )) ) : ( diff --git a/src/components/form/MultiSelectPopover.tsx b/src/components/form/MultiSelectPopover.tsx new file mode 100644 index 0000000..9c1717a --- /dev/null +++ b/src/components/form/MultiSelectPopover.tsx @@ -0,0 +1,154 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { Check, ChevronDown } from 'lucide-react'; + +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { cn } from '@/lib/utils'; + +export interface MultiSelectOption { + value: string; + label: string; + disabled?: boolean; +} + +interface MultiSelectPopoverProps { + label: string; + icon?: React.ReactNode; + options: MultiSelectOption[]; + values: string[]; + onValuesChange: (values: string[]) => void; + searchPlaceholder?: string; + emptyMessage?: string; + align?: 'start' | 'center' | 'end'; +} + +function checkboxClass(checked: boolean) { + return cn( + 'flex size-4 shrink-0 items-center justify-center rounded border', + checked && 'border-primary bg-primary text-primary-foreground', + ); +} + +export function MultiSelectPopover({ + label, + icon, + options, + values, + onValuesChange, + searchPlaceholder = 'Search', + emptyMessage = 'No options found.', + align = 'end', +}: MultiSelectPopoverProps) { + const [search, setSearch] = useState(''); + const selectedValues = useMemo(() => new Set(values), [values]); + const selectableOptions = options.filter((option) => !option.disabled); + const areAllSelected = + selectableOptions.length > 0 && + selectableOptions.every((option) => selectedValues.has(option.value)); + const filteredOptions = useMemo(() => { + const term = search.trim().toLowerCase(); + if (!term) return options; + return options.filter((option) => + option.label.toLowerCase().includes(term), + ); + }, [options, search]); + + const toggleValue = (value: string) => { + const nextValues = new Set(values); + if (nextValues.has(value)) { + nextValues.delete(value); + } else { + nextValues.add(value); + } + onValuesChange(Array.from(nextValues)); + }; + + const toggleAll = () => { + const nextValues = new Set(values); + + selectableOptions.forEach((option) => { + if (areAllSelected) { + nextValues.delete(option.value); + } else { + nextValues.add(option.value); + } + }); + + onValuesChange(Array.from(nextValues)); + }; + + return ( + + + + + +
+ + setSearch(event.target.value)} + placeholder={searchPlaceholder} + className="flex-1" + /> +
+ +
+ {filteredOptions.length ? ( + filteredOptions.map((option) => { + const checked = selectedValues.has(option.value); + + return ( +
+ +
+ ); + }) + ) : ( +
+ {emptyMessage} +
+ )} +
+
+
+ ); +} diff --git a/src/components/form/index.ts b/src/components/form/index.ts index 52dcd7b..c986d42 100644 --- a/src/components/form/index.ts +++ b/src/components/form/index.ts @@ -1,2 +1,4 @@ export { FormField } from './FormField'; +export { MultiSelectPopover } from './MultiSelectPopover'; +export type { MultiSelectOption } from './MultiSelectPopover'; export { PasswordField } from './PasswordField'; diff --git a/src/constants/apiRoutes.ts b/src/constants/apiRoutes.ts index 00fd395..80f9fa4 100644 --- a/src/constants/apiRoutes.ts +++ b/src/constants/apiRoutes.ts @@ -52,7 +52,7 @@ export const API_ROUTES = { }, VIDEOS: { LIST: '/videos', - UPLOAD: '/upload', + UPLOAD: '/biz/api/v1/upload', STATUS: (id: string) => `/status/${id}`, RESULTS: (id: string) => `/results/${id}`, }, diff --git a/src/guards/PermissionGuard.tsx b/src/guards/PermissionGuard.tsx index 73f6709..a5a1dff 100644 --- a/src/guards/PermissionGuard.tsx +++ b/src/guards/PermissionGuard.tsx @@ -5,18 +5,27 @@ import type { ReactNode } from 'react'; type PermissionGuardProps = { children: ReactNode; - permissions?: PermissionInput; + permissions?: PermissionInput | PermissionInput[]; + /** When `permissions` is an array: `any` (default) or `all` must pass. */ + match?: 'any' | 'all'; fallback?: ReactNode; }; export function PermissionGuard({ children, permissions, + match = 'any', fallback = null, }: PermissionGuardProps) { - const { hasPermission } = usePermissions(); + const { hasPermission, hasAnyPermission } = usePermissions(); - if (!hasPermission(permissions)) return fallback; + const allowed = Array.isArray(permissions) + ? match === 'all' + ? permissions.every((permission) => hasPermission(permission)) + : hasAnyPermission(permissions) + : hasPermission(permissions); + + if (!allowed) return fallback; return children; } diff --git a/src/hooks/usePermissions.ts b/src/hooks/usePermissions.ts index 8679989..6dd6f44 100644 --- a/src/hooks/usePermissions.ts +++ b/src/hooks/usePermissions.ts @@ -22,6 +22,20 @@ export function hasPermissionValue({ return normalizedPermissions.has(permissions.toLowerCase()); } +function hasAnyPermissionValue({ + grantedPermissions, + permissions, +}: { + grantedPermissions: string[]; + permissions: PermissionInput[]; +}) { + if (permissions.length === 0) return true; + + return permissions.some((permission) => + hasPermissionValue({ grantedPermissions, permissions: permission }), + ); +} + export function usePermissions() { const grantedPermissions = useAppStore((state) => state.permissions); @@ -33,6 +47,8 @@ export function usePermissions() { grantedPermissions, permissions, }), + hasAnyPermission: (permissions: PermissionInput[]) => + hasAnyPermissionValue({ grantedPermissions, permissions }), }), [grantedPermissions], ); diff --git a/src/services/axios/axios.ts b/src/services/axios/axios.ts index b40a990..b5820ea 100644 --- a/src/services/axios/axios.ts +++ b/src/services/axios/axios.ts @@ -9,7 +9,6 @@ const axiosClient = axios.create({ baseURL: BASE_URL, headers: { 'Content-Type': 'application/json', - 'ngrok-skip-browser-warning': 'true', }, }); @@ -87,7 +86,6 @@ export const axiosAuth = axios.create({ baseURL: BASE_URL, headers: { 'Content-Type': 'application/json', - 'ngrok-skip-browser-warning': 'true', }, });