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}
/>