Преглед изворни кода

feat(dashboard): Add saved views for data tables (#3825)

David Höck пре 3 месеци
родитељ
комит
afdc37ccdc
21 измењених фајлова са 1267 додато и 119 уклоњено
  1. 3 0
      packages/dashboard/plugin/constants.ts
  2. 15 1
      packages/dashboard/plugin/dashboard.plugin.ts
  3. 4 1
      packages/dashboard/src/app/routes/_authenticated/_zones/components/zone-countries-sheet.tsx
  4. 13 6
      packages/dashboard/src/lib/components/data-table/add-filter-menu.tsx
  5. 91 0
      packages/dashboard/src/lib/components/data-table/data-table-context.tsx
  6. 9 5
      packages/dashboard/src/lib/components/data-table/data-table-filter-badge.tsx
  7. 17 8
      packages/dashboard/src/lib/components/data-table/data-table-view-options.tsx
  8. 146 94
      packages/dashboard/src/lib/components/data-table/data-table.tsx
  9. 97 0
      packages/dashboard/src/lib/components/data-table/global-views-bar.tsx
  10. 11 0
      packages/dashboard/src/lib/components/data-table/global-views-sheet.tsx
  11. 26 0
      packages/dashboard/src/lib/components/data-table/manage-global-views-button.tsx
  12. 47 0
      packages/dashboard/src/lib/components/data-table/my-views-button.tsx
  13. 12 3
      packages/dashboard/src/lib/components/data-table/refresh-button.tsx
  14. 45 0
      packages/dashboard/src/lib/components/data-table/save-view-button.tsx
  15. 113 0
      packages/dashboard/src/lib/components/data-table/save-view-dialog.tsx
  16. 11 0
      packages/dashboard/src/lib/components/data-table/user-views-sheet.tsx
  17. 297 0
      packages/dashboard/src/lib/components/data-table/views-sheet.tsx
  18. 1 1
      packages/dashboard/src/lib/components/ui/button.tsx
  19. 230 0
      packages/dashboard/src/lib/hooks/use-saved-views.ts
  20. 39 0
      packages/dashboard/src/lib/types/saved-views.ts
  21. 40 0
      packages/dashboard/src/lib/utils/saved-views-utils.ts

+ 3 - 0
packages/dashboard/plugin/constants.ts

@@ -1,3 +1,4 @@
+import { RwPermissionDefinition } from '@vendure/core';
 import { join } from 'path';
 
 export const DEFAULT_APP_PATH = join(__dirname, 'dist');
@@ -6,3 +7,5 @@ export const defaultLanguage = 'en';
 export const defaultLocale = undefined;
 export const defaultAvailableLanguages = ['en', 'de', 'es', 'cs', 'zh_Hans', 'pt_BR', 'pt_PT', 'zh_Hant'];
 export const defaultAvailableLocales = ['en-US', 'de-DE', 'es-ES', 'zh-CN', 'zh-TW', 'pt-BR', 'pt-PT'];
+
+export const manageDashboardGlobalViews = new RwPermissionDefinition('DashboardGlobalViews');

+ 15 - 1
packages/dashboard/plugin/dashboard.plugin.ts

@@ -15,7 +15,7 @@ import path from 'path';
 
 import { adminApiExtensions } from './api/api-extensions.js';
 import { MetricsResolver } from './api/metrics.resolver.js';
-import { DEFAULT_APP_PATH, loggerCtx } from './constants.js';
+import { DEFAULT_APP_PATH, loggerCtx, manageDashboardGlobalViews } from './constants.js';
 import { MetricsService } from './service/metrics.service.js';
 
 /**
@@ -106,11 +106,25 @@ export interface DashboardPluginOptions {
     },
     providers: [MetricsService],
     configuration: config => {
+        config.authOptions.customPermissions.push(manageDashboardGlobalViews);
+
         config.settingsStoreFields['vendure.dashboard'] = [
             {
                 name: 'userSettings',
                 scope: SettingsStoreScopes.user,
             },
+            {
+                name: 'globalSavedViews',
+                scope: SettingsStoreScopes.global,
+                requiresPermission: {
+                    read: manageDashboardGlobalViews.Read,
+                    write: manageDashboardGlobalViews.Write,
+                },
+            },
+            {
+                name: 'userSavedViews',
+                scope: SettingsStoreScopes.user,
+            },
         ];
         return config;
     },

+ 4 - 1
packages/dashboard/src/app/routes/_authenticated/_zones/components/zone-countries-sheet.tsx

@@ -1,6 +1,7 @@
 import { Button } from '@/vdb/components/ui/button.js';
 import { ScrollArea } from '@/vdb/components/ui/scroll-area.js';
 import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/vdb/components/ui/sheet.js';
+import { FullWidthPageBlock } from '@/vdb/framework/layout-engine/page-layout.js';
 import { ZoneCountriesTable } from './zone-countries-table.js';
 
 interface ZoneCountriesSheetProps {
@@ -23,7 +24,9 @@ export function ZoneCountriesSheet({ zoneId, zoneName, children }: Readonly<Zone
                 </SheetHeader>
                 <div className="flex items-center gap-2"></div>
                 <ScrollArea className="px-6 max-h-[600px]">
-                    <ZoneCountriesTable zoneId={zoneId} />
+                    <FullWidthPageBlock blockId="zone-countries">
+                        <ZoneCountriesTable zoneId={zoneId} />
+                    </FullWidthPageBlock>
                 </ScrollArea>
             </SheetContent>
         </Sheet>

+ 13 - 6
packages/dashboard/src/lib/components/data-table/add-filter-menu.tsx

@@ -7,10 +7,11 @@ import {
     DropdownMenuItem,
     DropdownMenuTrigger,
 } from '@/vdb/components/ui/dropdown-menu.js';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/vdb/components/ui/tooltip.js';
 import { Trans } from '@/vdb/lib/trans.js';
 import { camelCaseToTitleCase } from '@/vdb/lib/utils.js';
 import { Column, ColumnDef } from '@tanstack/react-table';
-import { PlusCircle } from 'lucide-react';
+import { FilterIcon } from 'lucide-react';
 import { useState } from 'react';
 
 export interface AddFilterMenuProps {
@@ -26,12 +27,18 @@ export function AddFilterMenu({ columns }: Readonly<AddFilterMenuProps>) {
     return (
         <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
             <DropdownMenu>
-                <DropdownMenuTrigger asChild>
-                    <Button variant="outline" size="sm" className="h-8 border-dashed">
-                        <PlusCircle className="mr-2 h-4 w-4" />
+                <Tooltip>
+                    <TooltipTrigger asChild>
+                        <DropdownMenuTrigger asChild>
+                            <Button variant="outline" size="icon">
+                                <FilterIcon />
+                            </Button>
+                        </DropdownMenuTrigger>
+                    </TooltipTrigger>
+                    <TooltipContent>
                         <Trans>Add filter</Trans>
-                    </Button>
-                </DropdownMenuTrigger>
+                    </TooltipContent>
+                </Tooltip>
                 <DropdownMenuContent align="end" className="w-[200px]">
                     {filterableColumns.map(column => (
                         <DropdownMenuItem

+ 91 - 0
packages/dashboard/src/lib/components/data-table/data-table-context.tsx

@@ -0,0 +1,91 @@
+'use client';
+
+import { ColumnFiltersState, SortingState, Table } from '@tanstack/react-table';
+import React, { createContext, ReactNode, useContext } from 'react';
+
+interface DataTableContextValue {
+    columnFilters: ColumnFiltersState;
+    setColumnFilters: React.Dispatch<React.SetStateAction<ColumnFiltersState>>;
+    searchTerm: string;
+    setSearchTerm: React.Dispatch<React.SetStateAction<string>>;
+    sorting: SortingState;
+    setSorting: React.Dispatch<React.SetStateAction<SortingState>>;
+    pageId?: string;
+    onFilterChange?: (table: Table<any>, filters: ColumnFiltersState) => void;
+    onSearchTermChange?: (searchTerm: string) => void;
+    onRefresh?: () => void;
+    isLoading?: boolean;
+    table?: Table<any>;
+    handleApplyView: (filters: ColumnFiltersState, searchTerm?: string) => void;
+}
+
+const DataTableContext = createContext<DataTableContextValue | undefined>(undefined);
+
+export interface DataTableProviderProps {
+    children: ReactNode;
+    columnFilters: ColumnFiltersState;
+    setColumnFilters: React.Dispatch<React.SetStateAction<ColumnFiltersState>>;
+    searchTerm: string;
+    setSearchTerm: React.Dispatch<React.SetStateAction<string>>;
+    sorting: SortingState;
+    setSorting: React.Dispatch<React.SetStateAction<SortingState>>;
+    pageId?: string;
+    onFilterChange?: (table: Table<any>, filters: ColumnFiltersState) => void;
+    onSearchTermChange?: (searchTerm: string) => void;
+    onRefresh?: () => void;
+    isLoading?: boolean;
+    table?: Table<any>;
+}
+
+export function DataTableProvider({
+    children,
+    columnFilters,
+    setColumnFilters,
+    searchTerm,
+    setSearchTerm,
+    sorting,
+    setSorting,
+    pageId,
+    onFilterChange,
+    onSearchTermChange,
+    onRefresh,
+    isLoading,
+    table,
+}: DataTableProviderProps) {
+    const handleApplyView = (filters: ColumnFiltersState, viewSearchTerm?: string) => {
+        setColumnFilters(filters);
+        if (viewSearchTerm !== undefined && onSearchTermChange) {
+            setSearchTerm(viewSearchTerm);
+            onSearchTermChange(viewSearchTerm);
+        }
+        if (onFilterChange && table) {
+            onFilterChange(table, filters);
+        }
+    };
+
+    const value: DataTableContextValue = {
+        columnFilters,
+        setColumnFilters,
+        searchTerm,
+        setSearchTerm,
+        sorting,
+        setSorting,
+        pageId,
+        onFilterChange,
+        onSearchTermChange,
+        onRefresh,
+        isLoading,
+        table,
+        handleApplyView,
+    };
+
+    return <DataTableContext.Provider value={value}>{children}</DataTableContext.Provider>;
+}
+
+export function useDataTableContext() {
+    const context = useContext(DataTableContext);
+    if (context === undefined) {
+        throw new Error('useDataTableContext must be used within a DataTableProvider');
+    }
+    return context;
+}

+ 9 - 5
packages/dashboard/src/lib/components/data-table/data-table-filter-badge.tsx

@@ -1,5 +1,5 @@
 import { useLocalFormat } from '@/vdb/hooks/use-local-format.js';
-import { CircleX, Filter } from 'lucide-react';
+import { Filter, XIcon } from 'lucide-react';
 import { Badge } from '../ui/badge.js';
 import { HumanReadableOperator, Operator } from './human-readable-operator.js';
 import { ColumnDataType } from './types.js';
@@ -17,16 +17,20 @@ export function DataTableFilterBadge({
 }) {
     const [operator, value] = Object.entries(filter.value as Record<string, unknown>)[0];
     return (
-        <Badge key={filter.id} className="flex gap-1 items-center" variant="secondary">
+        <Badge
+            key={filter.id}
+            className="flex gap-1 items-center font-mono cursor-pointer "
+            variant="outline"
+            onClick={() => onRemove(filter)}
+        >
             <Filter size="12" className="opacity-50" />
             <div>{filter.id}</div>
             <div className="text-muted-foreground">
                 <HumanReadableOperator operator={operator as Operator} mode="short" />
             </div>
             <FilterValue value={value} dataType={dataType} currencyCode={currencyCode} />
-            <button className="cursor-pointer" onClick={() => onRemove(filter)}>
-                <CircleX size="14" />
-            </button>
+
+            <XIcon className="h-4" />
         </Badge>
     );
 }

+ 17 - 8
packages/dashboard/src/lib/components/data-table/data-table-view-options.tsx

@@ -17,6 +17,7 @@ import {
     DropdownMenuTrigger,
 } from '@/vdb/components/ui/dropdown-menu.js';
 import { ScrollArea } from '@/vdb/components/ui/scroll-area.js';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/vdb/components/ui/tooltip.js';
 import { usePage } from '@/vdb/hooks/use-page.js';
 import { useUserSettings } from '@/vdb/hooks/use-user-settings.js';
 import { Trans } from '@/vdb/lib/trans.js';
@@ -78,12 +79,18 @@ export function DataTableViewOptions<TData>({ table }: DataTableViewOptionsProps
     return (
         <div className="flex items-center gap-2">
             <DropdownMenu modal={false}>
-                <DropdownMenuTrigger asChild>
-                    <Button variant="ghost" size="sm" className="ml-auto hidden h-8 lg:flex">
-                        <Settings2 />
-                        <Trans>Columns</Trans>
-                    </Button>
-                </DropdownMenuTrigger>
+                <Tooltip>
+                    <TooltipTrigger asChild>
+                        <DropdownMenuTrigger asChild>
+                            <Button variant="outline" size="sm" className="ml-auto hidden h-8 lg:flex">
+                                <Settings2 />
+                            </Button>
+                        </DropdownMenuTrigger>
+                    </TooltipTrigger>
+                    <TooltipContent>
+                        <Trans>Column settings</Trans>
+                    </TooltipContent>
+                </Tooltip>
                 <DropdownMenuContent align="end" className="overflow-auto">
                     <ScrollArea className="max-h-[60vh]" type="always">
                         <DndContext
@@ -100,7 +107,7 @@ export function DataTableViewOptions<TData>({ table }: DataTableViewOptionsProps
                                         <DropdownMenuCheckboxItem
                                             className="capitalize"
                                             checked={column.getIsVisible()}
-                                            onCheckedChange={value => column.toggleVisibility(!!value)}
+                                            onCheckedChange={value => column.toggleVisibility(value)}
                                             onSelect={e => e.preventDefault()}
                                         >
                                             {column.id}
@@ -110,7 +117,9 @@ export function DataTableViewOptions<TData>({ table }: DataTableViewOptionsProps
                             </SortableContext>
                         </DndContext>
                         <DropdownMenuSeparator />
-                        <DropdownMenuItem onClick={handleReset}>Reset</DropdownMenuItem>
+                        <DropdownMenuItem onClick={handleReset}>
+                            <Trans>Reset</Trans>
+                        </DropdownMenuItem>
                     </ScrollArea>
                 </DropdownMenuContent>
             </DropdownMenu>

+ 146 - 94
packages/dashboard/src/lib/components/data-table/data-table.tsx

@@ -2,12 +2,19 @@
 
 import { DataTablePagination } from '@/vdb/components/data-table/data-table-pagination.js';
 import { DataTableViewOptions } from '@/vdb/components/data-table/data-table-view-options.js';
+import { GlobalViewsBar } from '@/vdb/components/data-table/global-views-bar.js';
+import { MyViewsButton } from '@/vdb/components/data-table/my-views-button.js';
 import { RefreshButton } from '@/vdb/components/data-table/refresh-button.js';
+import { SaveViewButton } from '@/vdb/components/data-table/save-view-button.js';
+import { Button } from '@/vdb/components/ui/button.js';
 import { Input } from '@/vdb/components/ui/input.js';
 import { Skeleton } from '@/vdb/components/ui/skeleton.js';
 import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/vdb/components/ui/table.js';
 import { BulkAction } from '@/vdb/framework/extension-api/types/index.js';
 import { useChannel } from '@/vdb/hooks/use-channel.js';
+import { usePage } from '@/vdb/hooks/use-page.js';
+import { useSavedViews } from '@/vdb/hooks/use-saved-views.js';
+import { Trans, useLingui } from '@/vdb/lib/trans.js';
 import {
     ColumnDef,
     ColumnFilter,
@@ -25,6 +32,7 @@ import { RowSelectionState, TableOptions } from '@tanstack/table-core';
 import React, { Suspense, useEffect } from 'react';
 import { AddFilterMenu } from './add-filter-menu.js';
 import { DataTableBulkActions } from './data-table-bulk-actions.js';
+import { DataTableProvider } from './data-table-context.js';
 import { DataTableFacetedFilter, DataTableFacetedFilterOption } from './data-table-faceted-filter.js';
 import { DataTableFilterBadge } from './data-table-filter-badge.js';
 
@@ -106,7 +114,12 @@ export function DataTable<TData>({
 }: Readonly<DataTableProps<TData>>) {
     const [sorting, setSorting] = React.useState<SortingState>(sortingInitialState || []);
     const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(filtersInitialState || []);
+    const [searchTerm, setSearchTerm] = React.useState<string>('');
     const { activeChannel } = useChannel();
+    const { pageId } = usePage();
+    const savedViewsResult = useSavedViews();
+    const globalViews = pageId && onFilterChange ? savedViewsResult.globalViews : [];
+    const { i18n } = useLingui();
     const [pagination, setPagination] = React.useState<PaginationState>({
         pageIndex: (page ?? 1) - 1,
         pageSize: itemsPerPage ?? 10,
@@ -175,19 +188,37 @@ export function DataTable<TData>({
     }, [columnVisibility]);
 
     const visibleColumnCount = Object.values(columnVisibility).filter(Boolean).length;
+
+    const handleSearchChange = (value: string) => {
+        setSearchTerm(value);
+        onSearchTermChange?.(value);
+    };
+
     return (
-        <>
-            <div className="flex justify-between items-start">
-                <div className="flex flex-col space-y-2">
-                    <div className="flex items-center justify-start gap-2">
+        <DataTableProvider
+            columnFilters={columnFilters}
+            setColumnFilters={setColumnFilters}
+            searchTerm={searchTerm}
+            setSearchTerm={setSearchTerm}
+            sorting={sorting}
+            setSorting={setSorting}
+            pageId={pageId}
+            onFilterChange={onFilterChange}
+            onSearchTermChange={onSearchTermChange}
+            onRefresh={onRefresh}
+            isLoading={isLoading}
+            table={table}
+        >
+            <div className="space-y-2">
+                <div className="flex items-center justify-between gap-2">
+                    <div className="flex items-center gap-2">
                         {onSearchTermChange && (
-                            <div className="flex items-center">
-                                <Input
-                                    placeholder="Filter..."
-                                    onChange={event => onSearchTermChange(event.target.value)}
-                                    className="max-w-sm w-md"
-                                />
-                            </div>
+                            <Input
+                                placeholder={i18n.t('Filter...')}
+                                value={searchTerm}
+                                onChange={event => handleSearchChange(event.target.value)}
+                                className="w-64"
+                            />
                         )}
                         <Suspense>
                             {Object.entries(facetedFilters ?? {}).map(([key, filter]) => (
@@ -201,99 +232,120 @@ export function DataTable<TData>({
                             ))}
                         </Suspense>
                         {onFilterChange && <AddFilterMenu columns={table.getAllColumns()} />}
+                        {pageId && onFilterChange && <MyViewsButton />}
                     </div>
-                    <div className="flex gap-1">
-                        {columnFilters
-                            .filter(f => !facetedFilters?.[f.id])
-                            .map(f => {
-                                const column = table.getColumn(f.id);
-                                const currency = activeChannel?.defaultCurrencyCode ?? 'USD';
-                                return (
-                                    <DataTableFilterBadge
-                                        key={f.id}
-                                        filter={f}
-                                        currencyCode={currency}
-                                        dataType={
-                                            (column?.columnDef.meta as any)?.fieldInfo?.type ?? 'String'
-                                        }
-                                        onRemove={() =>
-                                            setColumnFilters(old => old.filter(x => x.id !== f.id))
-                                        }
-                                    />
-                                );
-                            })}
+                    <div className="flex items-center gap-2">
+                        {pageId && onFilterChange && <SaveViewButton />}
+                        {!disableViewOptions && <DataTableViewOptions table={table} />}
+                        {onRefresh && <RefreshButton onRefresh={onRefresh} isLoading={isLoading ?? false} />}
                     </div>
                 </div>
-                <div className="flex items-center justify-start gap-2">
-                    {!disableViewOptions && <DataTableViewOptions table={table} />}
-                    {onRefresh && <RefreshButton onRefresh={onRefresh} isLoading={isLoading ?? false} />}
-                </div>
-            </div>
 
-            <div className="rounded-md border my-2 relative">
-                <Table>
-                    <TableHeader>
-                        {table.getHeaderGroups().map(headerGroup => (
-                            <TableRow key={headerGroup.id}>
-                                {headerGroup.headers.map(header => {
+                {(pageId && onFilterChange && globalViews.length > 0) ||
+                columnFilters.filter(f => !facetedFilters?.[f.id]).length > 0 ? (
+                    <div className="flex items-center justify-between bg-muted/40 rounded border border-border p-2">
+                        <div className="flex items-center">
+                            {pageId && onFilterChange && <GlobalViewsBar />}
+                        </div>
+                        <div className="flex gap-1 items-center">
+                            {columnFilters
+                                .filter(f => !facetedFilters?.[f.id])
+                                .map(f => {
+                                    const column = table.getColumn(f.id);
+                                    const currency = activeChannel?.defaultCurrencyCode ?? 'USD';
                                     return (
-                                        <TableHead key={header.id}>
-                                            {header.isPlaceholder
-                                                ? null
-                                                : flexRender(
-                                                      header.column.columnDef.header,
-                                                      header.getContext(),
-                                                  )}
-                                        </TableHead>
+                                        <DataTableFilterBadge
+                                            key={f.id}
+                                            filter={f}
+                                            currencyCode={currency}
+                                            dataType={
+                                                (column?.columnDef.meta as any)?.fieldInfo?.type ?? 'String'
+                                            }
+                                            onRemove={() =>
+                                                setColumnFilters(old => old.filter(x => x.id !== f.id))
+                                            }
+                                        />
                                     );
                                 })}
-                            </TableRow>
-                        ))}
-                    </TableHeader>
-                    <TableBody>
-                        {isLoading && !data?.length ? (
-                            Array.from({ length: pagination.pageSize }).map((_, index) => (
-                                <TableRow
-                                    key={`skeleton-${index}`}
-                                    className="animate-in fade-in duration-100"
+                            {columnFilters.filter(f => !facetedFilters?.[f.id]).length > 0 && (
+                                <Button
+                                    variant="ghost"
+                                    size="sm"
+                                    onClick={() => setColumnFilters([])}
+                                    className="text-xs opacity-60 hover:opacity-100"
                                 >
-                                    {Array.from({ length: visibleColumnCount }).map((_, cellIndex) => (
-                                        <TableCell
-                                            key={`skeleton-cell-${index}-${cellIndex}`}
-                                            className="h-12"
-                                        >
-                                            <Skeleton className="h-4 my-2 w-full" />
-                                        </TableCell>
-                                    ))}
+                                    <Trans>Clear all</Trans>
+                                </Button>
+                            )}
+                        </div>
+                    </div>
+                ) : null}
+
+                <div className="rounded-md border my-2 relative shadow-sm">
+                    <Table>
+                        <TableHeader className="bg-muted/50">
+                            {table.getHeaderGroups().map(headerGroup => (
+                                <TableRow key={headerGroup.id}>
+                                    {headerGroup.headers.map(header => {
+                                        return (
+                                            <TableHead key={header.id}>
+                                                {header.isPlaceholder
+                                                    ? null
+                                                    : flexRender(
+                                                          header.column.columnDef.header,
+                                                          header.getContext(),
+                                                      )}
+                                            </TableHead>
+                                        );
+                                    })}
                                 </TableRow>
-                            ))
-                        ) : table.getRowModel().rows?.length ? (
-                            table.getRowModel().rows.map(row => (
-                                <TableRow
-                                    key={row.id}
-                                    data-state={row.getIsSelected() && 'selected'}
-                                    className="animate-in fade-in duration-100"
-                                >
-                                    {row.getVisibleCells().map(cell => (
-                                        <TableCell key={cell.id} className="h-12">
-                                            {flexRender(cell.column.columnDef.cell, cell.getContext())}
-                                        </TableCell>
-                                    ))}
+                            ))}
+                        </TableHeader>
+                        <TableBody>
+                            {isLoading && !data?.length ? (
+                                Array.from({ length: pagination.pageSize }).map((_, index) => (
+                                    <TableRow
+                                        key={`skeleton-${index}`}
+                                        className="animate-in fade-in duration-100"
+                                    >
+                                        {Array.from({ length: visibleColumnCount }).map((_, cellIndex) => (
+                                            <TableCell
+                                                key={`skeleton-cell-${index}-${cellIndex}`}
+                                                className="h-12"
+                                            >
+                                                <Skeleton className="h-4 my-2 w-full" />
+                                            </TableCell>
+                                        ))}
+                                    </TableRow>
+                                ))
+                            ) : table.getRowModel().rows?.length ? (
+                                table.getRowModel().rows.map(row => (
+                                    <TableRow
+                                        key={row.id}
+                                        data-state={row.getIsSelected() && 'selected'}
+                                        className="animate-in fade-in duration-100"
+                                    >
+                                        {row.getVisibleCells().map(cell => (
+                                            <TableCell key={cell.id} className="h-12">
+                                                {flexRender(cell.column.columnDef.cell, cell.getContext())}
+                                            </TableCell>
+                                        ))}
+                                    </TableRow>
+                                ))
+                            ) : (
+                                <TableRow className="animate-in fade-in duration-100">
+                                    <TableCell colSpan={columns.length} className="h-24 text-center">
+                                        <Trans>No results</Trans>
+                                    </TableCell>
                                 </TableRow>
-                            ))
-                        ) : (
-                            <TableRow className="animate-in fade-in duration-100">
-                                <TableCell colSpan={columns.length} className="h-24 text-center">
-                                    No results.
-                                </TableCell>
-                            </TableRow>
-                        )}
-                        {children}
-                    </TableBody>
-                </Table>
-                <DataTableBulkActions bulkActions={bulkActions ?? []} table={table} />
+                            )}
+                            {children}
+                        </TableBody>
+                    </Table>
+                    <DataTableBulkActions bulkActions={bulkActions ?? []} table={table} />
+                </div>
+                {onPageChange && totalItems != null && <DataTablePagination table={table} />}
             </div>
-            {onPageChange && totalItems != null && <DataTablePagination table={table} />}
-        </>
+        </DataTableProvider>
     );
 }

+ 97 - 0
packages/dashboard/src/lib/components/data-table/global-views-bar.tsx

@@ -0,0 +1,97 @@
+import { ChevronDown } from 'lucide-react';
+import React from 'react';
+import { useSavedViews } from '../../hooks/use-saved-views.js';
+import { Trans } from '../../lib/trans.js';
+import { SavedView } from '../../types/saved-views.js';
+import { findMatchingSavedView } from '../../utils/saved-views-utils.js';
+import { PermissionGuard } from '../shared/permission-guard.js';
+import { Button } from '../ui/button.js';
+import {
+    DropdownMenu,
+    DropdownMenuContent,
+    DropdownMenuItem,
+    DropdownMenuTrigger,
+} from '../ui/dropdown-menu.js';
+import { useDataTableContext } from './data-table-context.js';
+import { ManageGlobalViewsButton } from './manage-global-views-button.js';
+
+export const GlobalViewsBar: React.FC = () => {
+    const { globalViews, canManageGlobalViews } = useSavedViews();
+    const { columnFilters, searchTerm, handleApplyView } = useDataTableContext();
+
+    if (globalViews.length === 0) {
+        return null;
+    }
+
+    const sortedViews = [...globalViews].sort(
+        (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
+    );
+
+    const handleViewClick = (view: SavedView) => {
+        handleApplyView(view.filters, view.searchTerm);
+    };
+
+    const isViewActive = (view: SavedView) => {
+        return findMatchingSavedView(columnFilters, searchTerm, [view]) !== undefined;
+    };
+
+    if (sortedViews.length <= 3) {
+        // Show all views as buttons
+        return (
+            <div className="flex items-center gap-1">
+                {sortedViews.map(view => (
+                    <Button
+                        key={view.id}
+                        variant={isViewActive(view) ? 'default' : 'outline'}
+                        size="sm"
+                        onClick={() => handleViewClick(view)}
+                    >
+                        {view.name}
+                    </Button>
+                ))}
+                {canManageGlobalViews && <ManageGlobalViewsButton />}
+            </div>
+        );
+    }
+
+    // Show first 3 as buttons, rest in dropdown
+    const visibleViews = sortedViews.slice(0, 3);
+    const dropdownViews = sortedViews.slice(3);
+
+    return (
+        <div className="flex items-center gap-1">
+            {visibleViews.map(view => (
+                <Button
+                    key={view.id}
+                    variant={isViewActive(view) ? 'default' : 'outline'}
+                    size="sm"
+                    onClick={() => handleViewClick(view)}
+                >
+                    {view.name}
+                </Button>
+            ))}
+            <DropdownMenu>
+                <DropdownMenuTrigger asChild>
+                    <Button variant="outline" size="sm">
+                        <Trans>More views</Trans>
+                        <ChevronDown className="h-3 w-3" />
+                    </Button>
+                </DropdownMenuTrigger>
+                <DropdownMenuContent align="start">
+                    {dropdownViews.map(view => (
+                        <DropdownMenuItem
+                            key={view.id}
+                            onClick={() => handleViewClick(view)}
+                            className={isViewActive(view) ? 'bg-primary' : ''}
+                        >
+                            {view.name}
+                        </DropdownMenuItem>
+                    ))}
+                </DropdownMenuContent>
+            </DropdownMenu>
+            <PermissionGuard requires={['WriteDashboardGlobalViews']}>
+                {canManageGlobalViews && <ManageGlobalViewsButton />}
+            </PermissionGuard>
+        </div>
+    );
+};

+ 11 - 0
packages/dashboard/src/lib/components/data-table/global-views-sheet.tsx

@@ -0,0 +1,11 @@
+import React from 'react';
+import { ViewsSheet } from './views-sheet.js';
+
+interface GlobalViewsSheetProps {
+    open: boolean;
+    onOpenChange: (open: boolean) => void;
+}
+
+export const GlobalViewsSheet: React.FC<GlobalViewsSheetProps> = ({ open, onOpenChange }) => {
+    return <ViewsSheet open={open} onOpenChange={onOpenChange} type="global" />;
+};

+ 26 - 0
packages/dashboard/src/lib/components/data-table/manage-global-views-button.tsx

@@ -0,0 +1,26 @@
+import { Settings } from 'lucide-react';
+import React, { useState } from 'react';
+import { Button } from '../ui/button.js';
+import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip.js';
+import { Trans } from '@/vdb/lib/trans.js';
+import { GlobalViewsSheet } from './global-views-sheet.js';
+
+export const ManageGlobalViewsButton: React.FC = () => {
+    const [sheetOpen, setSheetOpen] = useState(false);
+
+    return (
+        <>
+            <Tooltip>
+                <TooltipTrigger asChild>
+                    <Button variant="outline" size="icon-sm" onClick={() => setSheetOpen(true)}>
+                        <Settings />
+                    </Button>
+                </TooltipTrigger>
+                <TooltipContent>
+                    <Trans>Manage global views</Trans>
+                </TooltipContent>
+            </Tooltip>
+            <GlobalViewsSheet open={sheetOpen} onOpenChange={setSheetOpen} />
+        </>
+    );
+};

+ 47 - 0
packages/dashboard/src/lib/components/data-table/my-views-button.tsx

@@ -0,0 +1,47 @@
+import { Bookmark } from 'lucide-react';
+import React, { useState, useMemo } from 'react';
+import { Button } from '../ui/button.js';
+import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip.js';
+import { Trans } from '@/vdb/lib/trans.js';
+import { UserViewsSheet } from './user-views-sheet.js';
+import { useSavedViews } from '../../hooks/use-saved-views.js';
+import { findMatchingSavedView } from '../../utils/saved-views-utils.js';
+import { useDataTableContext } from './data-table-context.js';
+
+export const MyViewsButton: React.FC = () => {
+    const [sheetOpen, setSheetOpen] = useState(false);
+    const { userViews } = useSavedViews();
+    const { columnFilters, searchTerm, handleApplyView } = useDataTableContext();
+
+    // Find the active view using centralized utility
+    const activeView = useMemo(() => {
+        return findMatchingSavedView(columnFilters, searchTerm, userViews);
+    }, [userViews, columnFilters, searchTerm]);
+
+    return (
+        <>
+            <div className="flex items-center gap-2">
+                <Tooltip>
+                    <TooltipTrigger asChild>
+                        <Button
+                            variant={activeView ? "default" : "outline"}
+                            size="icon"
+                            onClick={() => setSheetOpen(true)}
+                        >
+                            <Bookmark />
+                        </Button>
+                    </TooltipTrigger>
+                    <TooltipContent>
+                        <Trans>My saved views</Trans>
+                    </TooltipContent>
+                </Tooltip>
+                {activeView && (
+                    <span className="text-sm text-muted-foreground">
+                        {activeView.name}
+                    </span>
+                )}
+            </div>
+            <UserViewsSheet open={sheetOpen} onOpenChange={setSheetOpen} />
+        </>
+    );
+};

+ 12 - 3
packages/dashboard/src/lib/components/data-table/refresh-button.tsx

@@ -1,4 +1,6 @@
 import { Button } from '@/vdb/components/ui/button.js';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/vdb/components/ui/tooltip.js';
+import { Trans } from '@/vdb/lib/trans.js';
 import { RefreshCw } from 'lucide-react';
 import { useEffect, useState } from 'react';
 
@@ -33,8 +35,15 @@ export function RefreshButton({
     };
 
     return (
-        <Button variant="ghost" size="sm" onClick={handleClick} disabled={delayedLoading}>
-            <RefreshCw className={delayedLoading ? 'animate-rotate' : ''} />
-        </Button>
+        <Tooltip>
+            <TooltipTrigger asChild>
+                <Button variant="outline" size="sm" onClick={handleClick} disabled={delayedLoading}>
+                    <RefreshCw className={delayedLoading ? 'animate-rotate' : ''} />
+                </Button>
+            </TooltipTrigger>
+            <TooltipContent>
+                <Trans>Refresh data</Trans>
+            </TooltipContent>
+        </Tooltip>
     );
 }

+ 45 - 0
packages/dashboard/src/lib/components/data-table/save-view-button.tsx

@@ -0,0 +1,45 @@
+import { BookmarkPlus } from 'lucide-react';
+import React, { useState } from 'react';
+import { Button } from '../ui/button.js';
+import { SaveViewDialog } from './save-view-dialog.js';
+import { useSavedViews } from '../../hooks/use-saved-views.js';
+import { isMatchingSavedView } from '../../utils/saved-views-utils.js';
+import { useDataTableContext } from './data-table-context.js';
+
+interface SaveViewButtonProps {
+    disabled?: boolean;
+}
+
+export const SaveViewButton: React.FC<SaveViewButtonProps> = ({ disabled }) => {
+    const [dialogOpen, setDialogOpen] = useState(false);
+    const { userViews, globalViews } = useSavedViews();
+    const { columnFilters, searchTerm } = useDataTableContext();
+
+    const hasFilters = columnFilters.length > 0 || (searchTerm && searchTerm.length > 0);
+    const matchesExistingView = isMatchingSavedView(columnFilters, searchTerm || '', userViews, globalViews);
+
+    // Don't show the button if there are no filters or if filters match an existing saved view
+    if (!hasFilters || matchesExistingView) {
+        return null;
+    }
+
+    return (
+        <>
+            <Button
+                variant="outline"
+                size="sm"
+                onClick={() => setDialogOpen(true)}
+                disabled={disabled}
+            >
+                <BookmarkPlus className="h-4 w-4 mr-1" />
+                Save View
+            </Button>
+            <SaveViewDialog
+                open={dialogOpen}
+                onOpenChange={setDialogOpen}
+                filters={columnFilters}
+                searchTerm={searchTerm}
+            />
+        </>
+    );
+};

+ 113 - 0
packages/dashboard/src/lib/components/data-table/save-view-dialog.tsx

@@ -0,0 +1,113 @@
+import { ColumnFiltersState } from '@tanstack/react-table';
+import React, { useState } from 'react';
+import { useSavedViews } from '../../hooks/use-saved-views.js';
+import { Button } from '../ui/button.js';
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog.js';
+import { Input } from '../ui/input.js';
+import { Label } from '../ui/label.js';
+import { RadioGroup, RadioGroupItem } from '../ui/radio-group.js';
+import { toast } from 'sonner';
+
+interface SaveViewDialogProps {
+    open: boolean;
+    onOpenChange: (open: boolean) => void;
+    filters: ColumnFiltersState;
+    searchTerm?: string;
+}
+
+export const SaveViewDialog: React.FC<SaveViewDialogProps> = ({
+    open,
+    onOpenChange,
+    filters,
+    searchTerm,
+}) => {
+    const [name, setName] = useState('');
+    const [scope, setScope] = useState<'user' | 'global'>('user');
+    const [saving, setSaving] = useState(false);
+    const { saveView, userViews, globalViews, canManageGlobalViews } = useSavedViews();
+
+    const handleSave = async () => {
+        if (!name.trim()) {
+            toast.error('Please enter a name for the view');
+            return;
+        }
+
+        // Check for duplicate names
+        const existingViews = scope === 'user' ? userViews : globalViews;
+        if (existingViews.some(v => v.name === name.trim())) {
+            toast.error(`A ${scope} view with this name already exists`);
+            return;
+        }
+
+        setSaving(true);
+        try {
+            await saveView({
+                name: name.trim(),
+                scope,
+                filters,
+                searchTerm,
+            });
+            toast.success(`View "${name}" saved successfully`);
+            onOpenChange(false);
+            setName('');
+            setScope('user');
+        } catch (error) {
+            toast.error('Failed to save view');
+            console.error('Failed to save view:', error);
+        } finally {
+            setSaving(false);
+        }
+    };
+
+    return (
+        <Dialog open={open} onOpenChange={onOpenChange}>
+            <DialogContent>
+                <DialogHeader>
+                    <DialogTitle>Save Current View</DialogTitle>
+                    <DialogDescription>
+                        Save the current filters and search term as a reusable view.
+                    </DialogDescription>
+                </DialogHeader>
+                <div className="space-y-4 py-4">
+                    <div className="space-y-2">
+                        <Label htmlFor="view-name">View Name</Label>
+                        <Input
+                            id="view-name"
+                            value={name}
+                            onChange={e => setName(e.target.value)}
+                            placeholder="Enter a name for this view"
+                            autoFocus
+                        />
+                    </div>
+                    {canManageGlobalViews && (
+                        <div className="space-y-2">
+                            <Label>View Scope</Label>
+                            <RadioGroup value={scope} onValueChange={value => setScope(value as 'user' | 'global')}>
+                                <div className="flex items-center space-x-2">
+                                    <RadioGroupItem value="user" id="scope-user" />
+                                    <Label htmlFor="scope-user" className="font-normal">
+                                        Personal View (only visible to you)
+                                    </Label>
+                                </div>
+                                <div className="flex items-center space-x-2">
+                                    <RadioGroupItem value="global" id="scope-global" />
+                                    <Label htmlFor="scope-global" className="font-normal">
+                                        Global View (visible to all users)
+                                    </Label>
+                                </div>
+                            </RadioGroup>
+                        </div>
+                    )}
+                </div>
+                <DialogFooter>
+                    <Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
+                        Cancel
+                    </Button>
+                    <Button onClick={handleSave} disabled={saving || !name.trim()}>
+                        {saving ? 'Saving...' : 'Save View'}
+                    </Button>
+                </DialogFooter>
+            </DialogContent>
+        </Dialog>
+    );
+};

+ 11 - 0
packages/dashboard/src/lib/components/data-table/user-views-sheet.tsx

@@ -0,0 +1,11 @@
+import React from 'react';
+import { ViewsSheet } from './views-sheet.js';
+
+interface UserViewsSheetProps {
+    open: boolean;
+    onOpenChange: (open: boolean) => void;
+}
+
+export const UserViewsSheet: React.FC<UserViewsSheetProps> = ({ open, onOpenChange }) => {
+    return <ViewsSheet open={open} onOpenChange={onOpenChange} type="user" />;
+};

+ 297 - 0
packages/dashboard/src/lib/components/data-table/views-sheet.tsx

@@ -0,0 +1,297 @@
+import { Copy, Edit, Globe, MoreHorizontal, Trash2 } from 'lucide-react';
+import React, { useState } from 'react';
+import { useSavedViews } from '../../hooks/use-saved-views.js';
+import { useDataTableContext } from './data-table-context.js';
+import { Button } from '../ui/button.js';
+import { Input } from '../ui/input.js';
+import {
+    DropdownMenu,
+    DropdownMenuContent,
+    DropdownMenuItem,
+    DropdownMenuTrigger,
+} from '../ui/dropdown-menu.js';
+import {
+    Sheet,
+    SheetContent,
+    SheetDescription,
+    SheetHeader,
+    SheetTitle,
+} from '../ui/sheet.js';
+import { SavedView } from '../../types/saved-views.js';
+import { toast } from 'sonner';
+import {
+    AlertDialog,
+    AlertDialogAction,
+    AlertDialogCancel,
+    AlertDialogContent,
+    AlertDialogDescription,
+    AlertDialogFooter,
+    AlertDialogHeader,
+    AlertDialogTitle,
+} from '../ui/alert-dialog.js';
+import { Trans, useLingui } from '@/vdb/lib/trans.js';
+
+interface ViewsSheetProps {
+    open: boolean;
+    onOpenChange: (open: boolean) => void;
+    type: 'user' | 'global';
+}
+
+export const ViewsSheet: React.FC<ViewsSheetProps> = ({ open, onOpenChange, type }) => {
+    const { userViews, globalViews, deleteView, updateView, duplicateView, canManageGlobalViews } = useSavedViews();
+    const { handleApplyView } = useDataTableContext();
+    const { i18n } = useLingui();
+    const [editingId, setEditingId] = useState<string | null>(null);
+    const [editingName, setEditingName] = useState('');
+    const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
+
+    const views = type === 'global' ? globalViews : userViews;
+    const isGlobal = type === 'global';
+
+    const handleViewApply = (view: SavedView) => {
+        handleApplyView(view.filters, view.searchTerm);
+        const message = isGlobal
+            ? i18n.t(`Applied global view "${view.name}"`)
+            : i18n.t(`Applied view "${view.name}"`);
+        toast.success(message);
+    };
+
+    const handleStartEdit = (view: SavedView) => {
+        setEditingId(view.id);
+        setEditingName(view.name);
+    };
+
+    const handleSaveEdit = async () => {
+        if (!editingId || !editingName.trim()) return;
+
+        try {
+            await updateView({ id: editingId, name: editingName.trim() });
+            const message = isGlobal
+                ? i18n.t('Global view renamed successfully')
+                : i18n.t('View renamed successfully');
+            toast.success(message);
+            setEditingId(null);
+            setEditingName('');
+        } catch (error) {
+            const message = isGlobal
+                ? i18n.t('Failed to rename global view')
+                : i18n.t('Failed to rename view');
+            toast.error(message);
+        }
+    };
+
+    const handleCancelEdit = () => {
+        setEditingId(null);
+        setEditingName('');
+    };
+
+    const handleDelete = async () => {
+        if (!deleteConfirmId) return;
+
+        try {
+            await deleteView(deleteConfirmId);
+            const message = isGlobal
+                ? i18n.t('Global view deleted successfully')
+                : i18n.t('View deleted successfully');
+            toast.success(message);
+            setDeleteConfirmId(null);
+        } catch (error) {
+            const message = isGlobal
+                ? i18n.t('Failed to delete global view')
+                : i18n.t('Failed to delete view');
+            toast.error(message);
+        }
+    };
+
+    const handleDuplicate = async (view: SavedView) => {
+        try {
+            await duplicateView(view.id, type);
+            const message = isGlobal
+                ? i18n.t('Global view duplicated successfully')
+                : i18n.t('View duplicated successfully');
+            toast.success(message);
+        } catch (error) {
+            const message = isGlobal
+                ? i18n.t('Failed to duplicate global view')
+                : i18n.t('Failed to duplicate view');
+            toast.error(message);
+        }
+    };
+
+    const handleConvertToUser = async (view: SavedView) => {
+        try {
+            await duplicateView(view.id, 'user');
+            toast.success(i18n.t('Global view converted to personal view successfully'));
+        } catch (error) {
+            toast.error(i18n.t('Failed to convert global view to personal view'));
+        }
+    };
+
+    const handleConvertToGlobal = async (view: SavedView) => {
+        try {
+            await duplicateView(view.id, 'global');
+            await deleteView(view.id);
+            toast.success(i18n.t('View converted to global successfully'));
+        } catch (error) {
+            toast.error(i18n.t('Failed to convert view to global'));
+        }
+    };
+
+    const getTitle = () => {
+        return isGlobal ? <Trans>Manage Global Views</Trans> : <Trans>My Saved Views</Trans>;
+    };
+
+    const getDescription = () => {
+        return isGlobal
+            ? <Trans>Manage global saved views that are visible to all users</Trans>
+            : <Trans>Manage your personal saved views for this table</Trans>;
+    };
+
+    const getEmptyStateMessage = () => {
+        if (isGlobal) {
+            return (
+                <>
+                    <p><Trans>No global views have been created yet.</Trans></p>
+                    <p className="text-sm mt-2">
+                        <Trans>Save a view as "Global" to make it available to all users.</Trans>
+                    </p>
+                </>
+            );
+        } else {
+            return (
+                <>
+                    <p><Trans>You haven't saved any views yet.</Trans></p>
+                    <p className="text-sm mt-2">
+                        <Trans>Apply filters to the table and click "Save View" to get started.</Trans>
+                    </p>
+                </>
+            );
+        }
+    };
+
+    const getDeleteDialogTitle = () => {
+        return isGlobal ? <Trans>Delete Global View</Trans> : <Trans>Delete View</Trans>;
+    };
+
+    const getDeleteDialogDescription = () => {
+        return isGlobal
+            ? <Trans>Are you sure you want to delete this global view? This action cannot be undone and will affect all users.</Trans>
+            : <Trans>Are you sure you want to delete this view? This action cannot be undone.</Trans>;
+    };
+
+    return (
+        <>
+            <Sheet open={open} onOpenChange={onOpenChange}>
+                <SheetContent className="w-[400px] sm:w-[540px]">
+                    <SheetHeader>
+                        <SheetTitle>{getTitle()}</SheetTitle>
+                        <SheetDescription>
+                            {getDescription()}
+                        </SheetDescription>
+                    </SheetHeader>
+                    <div className="mt-4">
+                        {views.length === 0 ? (
+                            <div className="text-center py-8 text-muted-foreground">
+                                {getEmptyStateMessage()}
+                            </div>
+                        ) : (
+                            <div className="divide-y">
+                                {views.map(view => (
+                                    <div
+                                        key={view.id}
+                                        className="flex items-center justify-between py-3 first:pt-0 last:pb-0 hover:bg-accent/50 transition-colors rounded-md px-2"
+                                    >
+                                        {editingId === view.id ? (
+                                            <div className="flex items-center gap-2 flex-1">
+                                                <Input
+                                                    value={editingName}
+                                                    onChange={e => setEditingName(e.target.value)}
+                                                    onKeyDown={e => {
+                                                        if (e.key === 'Enter') handleSaveEdit();
+                                                        if (e.key === 'Escape') handleCancelEdit();
+                                                    }}
+                                                    autoFocus
+                                                    className="flex-1"
+                                                />
+                                                <Button size="sm" onClick={handleSaveEdit}>
+                                                    <Trans>Save</Trans>
+                                                </Button>
+                                                <Button size="sm" variant="outline" onClick={handleCancelEdit}>
+                                                    <Trans>Cancel</Trans>
+                                                </Button>
+                                            </div>
+                                        ) : (
+                                            <>
+                                                <span className="font-medium text-sm truncate flex-1">{view.name}</span>
+                                                <div className="flex items-center gap-1">
+                                                    <Button
+                                                        size="sm"
+                                                        onClick={() => handleViewApply(view)}
+                                                    >
+                                                        <Trans>Apply</Trans>
+                                                    </Button>
+                                                    <DropdownMenu>
+                                                        <DropdownMenuTrigger asChild>
+                                                            <Button variant="ghost" size="sm">
+                                                                <MoreHorizontal className="h-4 w-4" />
+                                                            </Button>
+                                                        </DropdownMenuTrigger>
+                                                        <DropdownMenuContent align="end">
+                                                            <DropdownMenuItem onClick={() => handleStartEdit(view)}>
+                                                                <Edit className="h-4 w-4 mr-2" />
+                                                                <Trans>Rename</Trans>
+                                                            </DropdownMenuItem>
+                                                            <DropdownMenuItem onClick={() => handleDuplicate(view)}>
+                                                                <Copy className="h-4 w-4 mr-2" />
+                                                                <Trans>Duplicate</Trans>
+                                                            </DropdownMenuItem>
+                                                            {isGlobal ? (
+                                                                <DropdownMenuItem onClick={() => handleConvertToUser(view)}>
+                                                                    <Copy className="h-4 w-4 mr-2" />
+                                                                    <Trans>Copy to Personal</Trans>
+                                                                </DropdownMenuItem>
+                                                            ) : (
+                                                                canManageGlobalViews && (
+                                                                    <DropdownMenuItem onClick={() => handleConvertToGlobal(view)}>
+                                                                        <Globe className="h-4 w-4 mr-2" />
+                                                                        <Trans>Make Global</Trans>
+                                                                    </DropdownMenuItem>
+                                                                )
+                                                            )}
+                                                            <DropdownMenuItem
+                                                                onClick={() => setDeleteConfirmId(view.id)}
+                                                                className="text-destructive"
+                                                            >
+                                                                <Trash2 className="h-4 w-4 mr-2" />
+                                                                <Trans>Delete</Trans>
+                                                            </DropdownMenuItem>
+                                                        </DropdownMenuContent>
+                                                    </DropdownMenu>
+                                                </div>
+                                            </>
+                                        )}
+                                    </div>
+                                ))}
+                            </div>
+                        )}
+                    </div>
+                </SheetContent>
+            </Sheet>
+
+            <AlertDialog open={!!deleteConfirmId} onOpenChange={() => setDeleteConfirmId(null)}>
+                <AlertDialogContent>
+                    <AlertDialogHeader>
+                        <AlertDialogTitle>{getDeleteDialogTitle()}</AlertDialogTitle>
+                        <AlertDialogDescription>
+                            {getDeleteDialogDescription()}
+                        </AlertDialogDescription>
+                    </AlertDialogHeader>
+                    <AlertDialogFooter>
+                        <AlertDialogCancel><Trans>Cancel</Trans></AlertDialogCancel>
+                        <AlertDialogAction onClick={handleDelete}><Trans>Delete</Trans></AlertDialogAction>
+                    </AlertDialogFooter>
+                </AlertDialogContent>
+            </AlertDialog>
+        </>
+    );
+};

+ 1 - 1
packages/dashboard/src/lib/components/ui/button.tsx

@@ -23,7 +23,7 @@ const buttonVariants = cva(
                 lg: 'h-10 rounded-md px-8',
                 icon: 'h-9 w-9',
                 xs: 'h-5 rounded-md px-2 text-xs',
-                'icon-sm': 'h-7 w-7 text-xs',
+                'icon-sm': 'h-8 w-8 text-xs',
                 'icon-xs': 'h-5 w-5 text-xs',
             },
         },

+ 230 - 0
packages/dashboard/src/lib/hooks/use-saved-views.ts

@@ -0,0 +1,230 @@
+import { api } from '@/vdb/graphql/api.js';
+import {
+    getSettingsStoreValueDocument,
+    setSettingsStoreValueDocument,
+} from '@/vdb/graphql/settings-store-operations.js';
+import { SavedView, SavedViewsStore, SaveViewInput, UpdateViewInput } from '@/vdb/types/saved-views.js';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { ColumnFiltersState } from '@tanstack/react-table';
+
+import { usePageBlock } from './use-page-block.js';
+import { usePage } from './use-page.js';
+import { usePermissions } from './use-permissions.js';
+
+const generateId = () => {
+    const array = new Uint32Array(2);
+    crypto.getRandomValues(array);
+    return array[0].toString(36) + array[1].toString(36);
+};
+
+export function useSavedViews() {
+    const queryClient = useQueryClient();
+    const { pageId } = usePage();
+    const pageBlock = usePageBlock({ optional: true });
+    const blockId = pageBlock?.blockId || 'default';
+
+    if (!pageId) {
+        throw new Error('useSavedViews must be used within a Page context');
+    }
+
+    const userViewsKey = 'vendure.dashboard.userSavedViews';
+    const globalViewsKey = 'vendure.dashboard.globalSavedViews';
+
+    // Query for user views
+    const { data: userViewsData, isLoading: userViewsLoading } = useQuery({
+        queryKey: ['saved-views-user', pageId, blockId],
+        queryFn: async () => {
+            const result = await api.query(getSettingsStoreValueDocument, { key: userViewsKey });
+            const allUserViews = (result?.getSettingsStoreValue as SavedViewsStore) || {};
+            return allUserViews[pageId]?.[blockId] || [];
+        },
+    });
+
+    // Query for global views
+    const { data: globalViewsData, isLoading: globalViewsLoading } = useQuery({
+        queryKey: ['saved-views-global', pageId, blockId],
+        queryFn: async () => {
+            const result = await api.query(getSettingsStoreValueDocument, { key: globalViewsKey });
+            const allGlobalViews = (result?.getSettingsStoreValue as SavedViewsStore) || {};
+            return allGlobalViews[pageId]?.[blockId] || [];
+        },
+    });
+
+    // Save user view mutation
+    const saveUserViewMutation = useMutation({
+        mutationFn: async (views: SavedView[]) => {
+            // Get current data first
+            const result = await api.query(getSettingsStoreValueDocument, { key: userViewsKey });
+            const allUserViews = (result?.getSettingsStoreValue as SavedViewsStore) || {};
+
+            // Update the specific page and block
+            const updatedViews = {
+                ...allUserViews,
+                [pageId]: {
+                    ...allUserViews[pageId],
+                    [blockId]: views,
+                },
+            };
+
+            return api.mutate(setSettingsStoreValueDocument, {
+                input: { key: userViewsKey, value: updatedViews },
+            });
+        },
+        onSuccess: () => {
+            void queryClient.invalidateQueries({ queryKey: ['saved-views-user', pageId, blockId] });
+        },
+    });
+
+    // Save global view mutation
+    const saveGlobalViewMutation = useMutation({
+        mutationFn: async (views: SavedView[]) => {
+            // Get current data first
+            const result = await api.query(getSettingsStoreValueDocument, { key: globalViewsKey });
+            const allGlobalViews = (result?.getSettingsStoreValue as SavedViewsStore) || {};
+
+            // Update the specific page and block
+            const updatedViews = {
+                ...allGlobalViews,
+                [pageId]: {
+                    ...allGlobalViews[pageId],
+                    [blockId]: views,
+                },
+            };
+
+            return api.mutate(setSettingsStoreValueDocument, {
+                input: { key: globalViewsKey, value: updatedViews },
+            });
+        },
+        onSuccess: () => {
+            void queryClient.invalidateQueries({ queryKey: ['saved-views-global', pageId, blockId] });
+        },
+    });
+
+    const saveView = async (input: SaveViewInput) => {
+        const newView: SavedView = {
+            id: generateId(),
+            name: input.name,
+            scope: input.scope,
+            filters: input.filters,
+            searchTerm: input.searchTerm,
+            pageId,
+            blockId: blockId === 'default' ? undefined : blockId,
+            createdAt: new Date().toISOString(),
+            updatedAt: new Date().toISOString(),
+        };
+
+        if (input.scope === 'user') {
+            const currentViews = userViewsData || [];
+            await saveUserViewMutation.mutateAsync([...currentViews, newView]);
+        } else {
+            const currentViews = globalViewsData || [];
+            await saveGlobalViewMutation.mutateAsync([...currentViews, newView]);
+        }
+
+        return newView;
+    };
+
+    const updateView = async (input: UpdateViewInput) => {
+        const userViews = userViewsData || [];
+        const globalViews = globalViewsData || [];
+
+        const viewInUserViews = userViews.find(v => v.id === input.id);
+        const viewInGlobalViews = globalViews.find(v => v.id === input.id);
+
+        if (viewInUserViews) {
+            const updatedViews = userViews.map(v =>
+                v.id === input.id
+                    ? {
+                          ...v,
+                          name: input.name ?? v.name,
+                          filters: input.filters ?? v.filters,
+                          searchTerm: input.searchTerm !== undefined ? input.searchTerm : v.searchTerm,
+                          updatedAt: new Date().toISOString(),
+                      }
+                    : v,
+            );
+            await saveUserViewMutation.mutateAsync(updatedViews);
+        } else if (viewInGlobalViews) {
+            const updatedViews = globalViews.map(v =>
+                v.id === input.id
+                    ? {
+                          ...v,
+                          name: input.name ?? v.name,
+                          filters: input.filters ?? v.filters,
+                          searchTerm: input.searchTerm !== undefined ? input.searchTerm : v.searchTerm,
+                          updatedAt: new Date().toISOString(),
+                      }
+                    : v,
+            );
+            await saveGlobalViewMutation.mutateAsync(updatedViews);
+        }
+    };
+
+    const deleteView = async (viewId: string) => {
+        const userViews = userViewsData || [];
+        const globalViews = globalViewsData || [];
+
+        if (userViews.some(v => v.id === viewId)) {
+            const updatedViews = userViews.filter(v => v.id !== viewId);
+            await saveUserViewMutation.mutateAsync(updatedViews);
+        } else if (globalViews.some(v => v.id === viewId)) {
+            const updatedViews = globalViews.filter(v => v.id !== viewId);
+            await saveGlobalViewMutation.mutateAsync(updatedViews);
+        }
+    };
+
+    const duplicateView = async (viewId: string, newScope: 'user' | 'global') => {
+        const allViews = [...(userViewsData || []), ...(globalViewsData || [])];
+        const viewToDuplicate = allViews.find(v => v.id === viewId);
+
+        if (viewToDuplicate) {
+            const newView: SavedView = {
+                ...viewToDuplicate,
+                id: generateId(),
+                name: `${viewToDuplicate.name} (Copy)`,
+                scope: newScope,
+                pageId,
+                blockId: blockId === 'default' ? undefined : blockId,
+                createdAt: new Date().toISOString(),
+                updatedAt: new Date().toISOString(),
+            };
+
+            if (newScope === 'user') {
+                const currentViews = userViewsData || [];
+                await saveUserViewMutation.mutateAsync([...currentViews, newView]);
+            } else {
+                const currentViews = globalViewsData || [];
+                await saveGlobalViewMutation.mutateAsync([...currentViews, newView]);
+            }
+
+            return newView;
+        }
+    };
+
+    const applyView = (
+        view: SavedView,
+        setFilters: (filters: ColumnFiltersState) => void,
+        setSearchTerm?: (term: string) => void,
+    ) => {
+        setFilters(view.filters);
+        if (setSearchTerm && view.searchTerm !== undefined) {
+            setSearchTerm(view.searchTerm);
+        }
+    };
+
+    // Use UpdateSettings permission for managing global views
+    const { hasPermissions } = usePermissions();
+    const canManageGlobalViews = hasPermissions(['WriteDashboardGlobalViews']);
+
+    return {
+        userViews: userViewsData || [],
+        globalViews: globalViewsData || [],
+        isLoading: userViewsLoading || globalViewsLoading,
+        saveView,
+        updateView,
+        deleteView,
+        duplicateView,
+        applyView,
+        canManageGlobalViews,
+    };
+}

+ 39 - 0
packages/dashboard/src/lib/types/saved-views.ts

@@ -0,0 +1,39 @@
+import { ColumnFiltersState } from '@tanstack/react-table';
+
+export interface SavedView {
+    id: string;
+    name: string;
+    scope: 'user' | 'global';
+    filters: ColumnFiltersState;
+    searchTerm?: string;
+    pageId?: string;
+    blockId?: string;
+    createdAt: string; // ISO timestamp string
+    updatedAt: string; // ISO timestamp string
+    createdBy?: string;
+}
+
+export interface SavedViewsData {
+    userViews: SavedView[];
+    globalViews: SavedView[];
+}
+
+export interface SavedViewsStore {
+    [pageId: string]: {
+        [blockId: string]: SavedView[];
+    };
+}
+
+export interface SaveViewInput {
+    name: string;
+    scope: 'user' | 'global';
+    filters: ColumnFiltersState;
+    searchTerm?: string;
+}
+
+export interface UpdateViewInput {
+    id: string;
+    name?: string;
+    filters?: ColumnFiltersState;
+    searchTerm?: string;
+}

+ 40 - 0
packages/dashboard/src/lib/utils/saved-views-utils.ts

@@ -0,0 +1,40 @@
+import { ColumnFiltersState } from '@tanstack/react-table';
+
+import { SavedView } from '../types/saved-views.js';
+
+/**
+ * Checks if the current filters and search term match any of the provided saved views
+ * @param currentFilters - The current column filters
+ * @param currentSearchTerm - The current search term
+ * @param views - Array of saved views to check against
+ * @returns The matching saved view if found, undefined otherwise
+ */
+export function findMatchingSavedView(
+    currentFilters: ColumnFiltersState,
+    currentSearchTerm: string,
+    views: SavedView[],
+): SavedView | undefined {
+    return views.find(view => {
+        const filtersMatch = JSON.stringify(view.filters) === JSON.stringify(currentFilters);
+        const searchMatch = (view.searchTerm || '') === currentSearchTerm;
+        return filtersMatch && searchMatch;
+    });
+}
+
+/**
+ * Checks if the current filters match any saved view (user or global)
+ * @param currentFilters - The current column filters
+ * @param currentSearchTerm - The current search term
+ * @param userViews - Array of user saved views
+ * @param globalViews - Array of global saved views
+ * @returns true if a matching view is found, false otherwise
+ */
+export function isMatchingSavedView(
+    currentFilters: ColumnFiltersState,
+    currentSearchTerm: string,
+    userViews: SavedView[],
+    globalViews: SavedView[],
+): boolean {
+    const allViews = [...userViews, ...globalViews];
+    return findMatchingSavedView(currentFilters, currentSearchTerm, allViews) !== undefined;
+}