diff --git a/bun.lock b/bun.lock index 1f1df7c3..4580c485 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,7 @@ "@radix-ui/react-avatar": "^1.1.3", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-collapsible": "^1.1.3", + "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-label": "^2.1.2", @@ -248,6 +249,8 @@ "@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + "@radix-ui/react-context-menu": ["@radix-ui/react-context-menu@2.2.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww=="], + "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="], "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="], diff --git a/package.json b/package.json index 35b3203e..e7022547 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@radix-ui/react-avatar": "^1.1.3", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-collapsible": "^1.1.3", + "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-label": "^2.1.2", diff --git a/resources/js/components/transactions/bulk-actions-bar.tsx b/resources/js/components/transactions/bulk-actions-bar.tsx index 157da2ac..93c4ad53 100644 --- a/resources/js/components/transactions/bulk-actions-bar.tsx +++ b/resources/js/components/transactions/bulk-actions-bar.tsx @@ -30,13 +30,13 @@ export function BulkActionsBar({ onClear, isUpdating = false, }: BulkActionsBarProps) { - if (selectedCount === 0) { + if (selectedCount < 1) { return null; } return ( -
-
+
+
{selectedCount} transaction{selectedCount !== 1 ? 's' : ''}{' '} selected diff --git a/resources/js/components/ui/context-menu.tsx b/resources/js/components/ui/context-menu.tsx new file mode 100644 index 00000000..624c588a --- /dev/null +++ b/resources/js/components/ui/context-menu.tsx @@ -0,0 +1,250 @@ +import * as React from "react" +import * as ContextMenuPrimitive from "@radix-ui/react-context-menu" +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function ContextMenu({ + ...props +}: React.ComponentProps) { + return +} + +function ContextMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuSub({ + ...props +}: React.ComponentProps) { + return +} + +function ContextMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function ContextMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function ContextMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function ContextMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function ContextMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function ContextMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function ContextMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +export { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuGroup, + ContextMenuPortal, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuRadioGroup, +} diff --git a/resources/js/components/ui/data-table.tsx b/resources/js/components/ui/data-table.tsx index a5bad124..2f0cb954 100644 --- a/resources/js/components/ui/data-table.tsx +++ b/resources/js/components/ui/data-table.tsx @@ -19,12 +19,14 @@ interface DataTableProps { table: TableType; columns: ColumnDef[]; emptyMessage?: string; + renderRow?: (row: any, virtualRow: any, rowVirtualizer: any) => React.ReactNode; } export function DataTable({ table, columns, emptyMessage = 'No results found.', + renderRow, }: DataTableProps) { const tableContainerRef = useRef(null); const rows = table.getRowModel().rows; @@ -85,6 +87,11 @@ export function DataTable({ )} {virtualRows.map((virtualRow) => { const row = rows[virtualRow.index]; + + if (renderRow) { + return renderRow(row, virtualRow, rowVirtualizer); + } + return ( void }) { + const account = row.original; + const [editOpen, setEditOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + const [contextMenuOpen, setContextMenuOpen] = useState(false); + + return ( + <> + + + + {row + .getVisibleCells() + .map((cell: any) => ( + + {flexRender( + cell.column + .columnDef + .cell, + cell.getContext(), + )} + + ))} + + + + Actions + setEditOpen(true)}> + Edit + + setDeleteOpen(true)} + className="text-red-600" + > + Delete + + + + + + + + ); +} + export default function Accounts() { const accounts = useLiveQuery(() => db.accounts.toArray(), []) || []; const [sorting, setSorting] = useState([]); @@ -278,28 +347,11 @@ export default function Accounts() { {table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => ( - - {row - .getVisibleCells() - .map((cell) => ( - - {flexRender( - cell.column - .columnDef - .cell, - cell.getContext(), - )} - - ))} - + row={row} + onSuccess={handleAccountCreated} + /> )) ) : ( diff --git a/resources/js/pages/settings/automation-rules.tsx b/resources/js/pages/settings/automation-rules.tsx index b44f8b26..a609fbd0 100644 --- a/resources/js/pages/settings/automation-rules.tsx +++ b/resources/js/pages/settings/automation-rules.tsx @@ -22,6 +22,13 @@ import { EditAutomationRuleDialog } from '@/components/automation-rules/edit-aut import HeadingSmall from '@/components/heading-small'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuLabel, + ContextMenuTrigger, +} from '@/components/ui/context-menu'; import { DropdownMenu, DropdownMenuContent, @@ -94,6 +101,66 @@ function AutomationRuleActions({ rule }: { rule: AutomationRule }) { ); } +function AutomationRuleRow({ row }: { row: any }) { + const rule = row.original; + const [editOpen, setEditOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + const [contextMenuOpen, setContextMenuOpen] = useState(false); + + return ( + <> + + + + {row + .getVisibleCells() + .map((cell: any) => ( + + {flexRender( + cell.column + .columnDef + .cell, + cell.getContext(), + )} + + ))} + + + + Actions + setEditOpen(true)}> + Edit + + setDeleteOpen(true)} + variant="destructive" + > + Delete + + + + + + + + ); +} + export default function AutomationRules() { const { isKeySet } = useEncryptionKey(); const rawRules = @@ -285,28 +352,7 @@ export default function AutomationRules() { {table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => ( - - {row - .getVisibleCells() - .map((cell) => ( - - {flexRender( - cell.column - .columnDef - .cell, - cell.getContext(), - )} - - ))} - + )) ) : ( diff --git a/resources/js/pages/settings/categories.tsx b/resources/js/pages/settings/categories.tsx index fbd77a9c..dd1d82ae 100644 --- a/resources/js/pages/settings/categories.tsx +++ b/resources/js/pages/settings/categories.tsx @@ -22,6 +22,13 @@ import { EditCategoryDialog } from '@/components/categories/edit-category-dialog import HeadingSmall from '@/components/heading-small'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuLabel, + ContextMenuTrigger, +} from '@/components/ui/context-menu'; import { DropdownMenu, DropdownMenuContent, @@ -94,6 +101,68 @@ function CategoryActions({ category }: { category: Category }) { ); } +function CategoryRow({ row }: { row: any }) { + const category = row.original; + const [editOpen, setEditOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + const [contextMenuOpen, setContextMenuOpen] = useState(false); + + return ( + <> + + + + {row + .getVisibleCells() + .map((cell: any) => ( + + {flexRender( + cell.column + .columnDef + .cell, + cell.getContext(), + )} + + ))} + + + + Actions + setEditOpen(true)}> + Edit + + setDeleteOpen(true)} + variant="destructive" + > + Delete + + + + + {}} + /> + {}} + /> + + ); +} + export default function Categories() { const categories = useLiveQuery(() => db.categories.toArray(), []) || []; const [sorting, setSorting] = useState([ @@ -243,28 +312,7 @@ export default function Categories() { {table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => ( - - {row - .getVisibleCells() - .map((cell) => ( - - {flexRender( - cell.column - .columnDef - .cell, - cell.getContext(), - )} - - ))} - + )) ) : ( diff --git a/resources/js/pages/transactions/index.tsx b/resources/js/pages/transactions/index.tsx index 63c41d04..218fb300 100644 --- a/resources/js/pages/transactions/index.tsx +++ b/resources/js/pages/transactions/index.tsx @@ -3,6 +3,7 @@ import { ColumnFiltersState, SortingState, VisibilityState, + flexRender, getCoreRowModel, getFilteredRowModel, getSortedRowModel, @@ -31,11 +32,19 @@ import { AlertDialogTitle, } from '@/components/ui/alert-dialog'; import { Button } from '@/components/ui/button'; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuLabel, + ContextMenuTrigger, +} from '@/components/ui/context-menu'; import { DataTable } from '@/components/ui/data-table'; import { DataTablePagination } from '@/components/ui/data-table-pagination'; import { DataTableViewOptions } from '@/components/ui/data-table-view-options'; import { Skeleton } from '@/components/ui/skeleton'; import { Spinner } from '@/components/ui/spinner'; +import { TableCell, TableRow } from '@/components/ui/table'; import { useEncryptionKey } from '@/contexts/encryption-key-context'; import AppSidebarLayout from '@/layouts/app/app-sidebar-layout'; import { decrypt, encrypt, importKey } from '@/lib/crypto'; @@ -941,6 +950,68 @@ export default function Transactions({ categories, accounts, banks }: Props) { setRowSelection({}); } + const TransactionRow = useCallback( + ({ row, virtualRow, rowVirtualizer }: { row: any; virtualRow: any; rowVirtualizer: any }) => { + const transaction = row.original; + const [contextMenuOpen, setContextMenuOpen] = useState(false); + + return ( + + + + {row.getVisibleCells().map((cell: any) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + + + Actions + setEditTransaction(transaction)} + > + Edit + + handleReEvaluateRules(transaction)} + > + Re-evaluate rules + + setDeleteTransaction(transaction)} + variant="destructive" + > + Delete + + + + ); + }, + [handleReEvaluateRules], + ); + + const renderTransactionRow = useCallback( + (row: any, virtualRow: any, rowVirtualizer: any) => { + return ( + + ); + }, + [TransactionRow], + ); + return ( @@ -1020,6 +1091,7 @@ export default function Transactions({ categories, accounts, banks }: Props) { table={table} columns={columns} emptyMessage="No transactions found." + renderRow={renderTransactionRow} />