diff --git a/app/Http/Controllers/TransactionController.php b/app/Http/Controllers/TransactionController.php
index 30ecca5f..748fbb0f 100644
--- a/app/Http/Controllers/TransactionController.php
+++ b/app/Http/Controllers/TransactionController.php
@@ -2,6 +2,7 @@
namespace App\Http\Controllers;
+use App\Http\Requests\BulkUpdateTransactionsRequest;
use App\Http\Requests\UpdateTransactionRequest;
use App\Models\Account;
use App\Models\Bank;
@@ -68,4 +69,48 @@ class TransactionController extends Controller
'message' => 'Transaction deleted successfully',
]);
}
+
+ public function bulkUpdate(BulkUpdateTransactionsRequest $request): JsonResponse
+ {
+ $user = $request->user();
+ $transactionIds = $request->input('transaction_ids');
+
+ $transactions = Transaction::query()
+ ->whereIn('id', $transactionIds)
+ ->where('user_id', $user->id)
+ ->get();
+
+ if ($transactions->count() !== count($transactionIds)) {
+ return response()->json([
+ 'message' => 'Some transactions were not found or do not belong to you.',
+ ], 403);
+ }
+
+ $updateData = [];
+ if ($request->has('category_id')) {
+ $updateData['category_id'] = $request->input('category_id');
+ }
+ if ($request->has('notes')) {
+ $updateData['notes'] = $request->input('notes');
+ }
+ if ($request->has('notes_iv')) {
+ $updateData['notes_iv'] = $request->input('notes_iv');
+ }
+
+ if (empty($updateData)) {
+ return response()->json([
+ 'message' => 'No update data provided.',
+ ], 400);
+ }
+
+ Transaction::query()
+ ->whereIn('id', $transactionIds)
+ ->where('user_id', $user->id)
+ ->update($updateData);
+
+ return response()->json([
+ 'message' => 'Transactions updated successfully',
+ 'count' => count($transactionIds),
+ ]);
+ }
}
diff --git a/app/Http/Requests/BulkUpdateTransactionsRequest.php b/app/Http/Requests/BulkUpdateTransactionsRequest.php
new file mode 100644
index 00000000..5738f6f6
--- /dev/null
+++ b/app/Http/Requests/BulkUpdateTransactionsRequest.php
@@ -0,0 +1,34 @@
+ ['required', 'array', 'min:1'],
+ 'transaction_ids.*' => ['required', 'string', 'uuid'],
+ 'category_id' => ['nullable', 'exists:categories,id'],
+ 'notes' => ['nullable', 'string'],
+ 'notes_iv' => ['nullable', 'string', 'size:16'],
+ ];
+ }
+
+ public function messages(): array
+ {
+ return [
+ 'transaction_ids.required' => 'At least one transaction must be selected.',
+ 'transaction_ids.*.uuid' => 'Invalid transaction ID format.',
+ 'category_id.exists' => 'The selected category does not exist.',
+ 'notes_iv.size' => 'The notes IV must be exactly 16 characters.',
+ ];
+ }
+}
diff --git a/bun.lock b/bun.lock
index 0ebf1881..32174448 100644
--- a/bun.lock
+++ b/bun.lock
@@ -16,8 +16,8 @@
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-select": "^2.2.6",
- "@radix-ui/react-separator": "^1.1.2",
- "@radix-ui/react-slot": "^1.2.3",
+ "@radix-ui/react-separator": "^1.1.8",
+ "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-toggle": "^1.1.2",
"@radix-ui/react-toggle-group": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
@@ -279,9 +279,9 @@
"@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@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-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "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-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="],
- "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.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-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="],
+ "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "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-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="],
- "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+ "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
"@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-primitive": "2.1.3", "@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" } }, "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ=="],
@@ -1097,6 +1097,24 @@
"@humanfs/node/@humanwhocodes/retry": ["@humanwhocodes/retry@0.3.1", "", {}, "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA=="],
+ "@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+
+ "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+
+ "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+
+ "@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+
+ "@radix-ui/react-popover/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+
+ "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+
+ "@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+
+ "@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "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-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
+
+ "@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
diff --git a/package.json b/package.json
index 203155e3..3b86c20a 100644
--- a/package.json
+++ b/package.json
@@ -39,8 +39,8 @@
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-select": "^2.2.6",
- "@radix-ui/react-separator": "^1.1.2",
- "@radix-ui/react-slot": "^1.2.3",
+ "@radix-ui/react-separator": "^1.1.8",
+ "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-toggle": "^1.1.2",
"@radix-ui/react-toggle-group": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
diff --git a/resources/js/components/transactions/bulk-actions-bar.tsx b/resources/js/components/transactions/bulk-actions-bar.tsx
new file mode 100644
index 00000000..ab118837
--- /dev/null
+++ b/resources/js/components/transactions/bulk-actions-bar.tsx
@@ -0,0 +1,77 @@
+import { X, Trash2 } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import {
+ ButtonGroup,
+ ButtonGroupSeparator,
+ ButtonGroupText,
+} from '@/components/ui/button-group';
+import { BulkCategorySelect } from '@/components/transactions/bulk-category-select';
+import { type Category } from '@/types/category';
+
+interface BulkActionsBarProps {
+ selectedCount: number;
+ categories: Category[];
+ onCategoryChange: (categoryId: number | null) => void;
+ onDelete: () => void;
+ onClear: () => void;
+ isUpdating?: boolean;
+}
+
+export function BulkActionsBar({
+ selectedCount,
+ categories,
+ onCategoryChange,
+ onDelete,
+ onClear,
+ isUpdating = false,
+}: BulkActionsBarProps) {
+ if (selectedCount === 0) {
+ return null;
+ }
+
+ return (
+
+
+
+ {selectedCount} transaction{selectedCount !== 1 ? 's' : ''}{' '}
+ selected
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
diff --git a/resources/js/components/transactions/bulk-category-select.tsx b/resources/js/components/transactions/bulk-category-select.tsx
new file mode 100644
index 00000000..bf6208f7
--- /dev/null
+++ b/resources/js/components/transactions/bulk-category-select.tsx
@@ -0,0 +1,60 @@
+import { useState } from 'react';
+import { Badge } from '@/components/ui/badge';
+import * as Icons from 'lucide-react';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { type Category, getCategoryColorClasses } from '@/types/category';
+
+interface BulkCategorySelectProps {
+ categories: Category[];
+ onCategoryChange: (categoryId: number | null) => void;
+ disabled?: boolean;
+}
+
+export function BulkCategorySelect({
+ categories,
+ onCategoryChange,
+ disabled = false,
+}: BulkCategorySelectProps) {
+ const [value, setValue] = useState('');
+
+ function handleChange(newValue: string) {
+ setValue(newValue);
+ const categoryId = newValue === 'null' ? null : parseInt(newValue);
+ onCategoryChange(categoryId);
+ }
+
+ return (
+
+ );
+}
+
diff --git a/resources/js/components/transactions/transaction-columns.tsx b/resources/js/components/transactions/transaction-columns.tsx
index b5358bbe..e6b3c4cb 100644
--- a/resources/js/components/transactions/transaction-columns.tsx
+++ b/resources/js/components/transactions/transaction-columns.tsx
@@ -3,6 +3,7 @@ import { ArrowDown, MoreHorizontal } from 'lucide-react';
import { format, parseISO } from 'date-fns';
import { Button } from '@/components/ui/button';
+import { Checkbox } from '@/components/ui/checkbox';
import {
DropdownMenu,
DropdownMenuContent,
@@ -34,6 +35,28 @@ export function createTransactionColumns({
onUpdate,
}: CreateColumnsOptions): ColumnDef[] {
return [
+ {
+ id: 'select',
+ header: ({ table }) => (
+ table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ />
+ ),
+ cell: ({ row }) => (
+ row.toggleSelected(!!value)}
+ aria-label="Select row"
+ />
+ ),
+ enableSorting: false,
+ enableHiding: false,
+ },
{
accessorKey: 'transaction_date',
meta: { label: 'Date' },
diff --git a/resources/js/components/ui/button-group.tsx b/resources/js/components/ui/button-group.tsx
new file mode 100644
index 00000000..8600af03
--- /dev/null
+++ b/resources/js/components/ui/button-group.tsx
@@ -0,0 +1,83 @@
+import { Slot } from "@radix-ui/react-slot"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+import { Separator } from "@/components/ui/separator"
+
+const buttonGroupVariants = cva(
+ "flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
+ {
+ variants: {
+ orientation: {
+ horizontal:
+ "[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
+ vertical:
+ "flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
+ },
+ },
+ defaultVariants: {
+ orientation: "horizontal",
+ },
+ }
+)
+
+function ButtonGroup({
+ className,
+ orientation,
+ ...props
+}: React.ComponentProps<"div"> & VariantProps) {
+ return (
+
+ )
+}
+
+function ButtonGroupText({
+ className,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"div"> & {
+ asChild?: boolean
+}) {
+ const Comp = asChild ? Slot : "div"
+
+ return (
+
+ )
+}
+
+function ButtonGroupSeparator({
+ className,
+ orientation = "vertical",
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ ButtonGroup,
+ ButtonGroupSeparator,
+ ButtonGroupText,
+ buttonGroupVariants,
+}
diff --git a/resources/js/components/ui/button.tsx b/resources/js/components/ui/button.tsx
index 761d2fe9..21409a06 100644
--- a/resources/js/components/ui/button.tsx
+++ b/resources/js/components/ui/button.tsx
@@ -5,26 +5,28 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
- "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
- default:
- "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
- "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
+ "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
- "border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
+ "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
- "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
- ghost: "hover:bg-accent hover:text-accent-foreground",
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ ghost:
+ "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
- sm: "h-8 rounded-md px-3 has-[>svg]:px-2.5",
+ sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
+ "icon-sm": "size-8",
+ "icon-lg": "size-10",
},
},
defaultVariants: {
diff --git a/resources/js/components/ui/separator.tsx b/resources/js/components/ui/separator.tsx
index 12175a9c..bb3ad74c 100644
--- a/resources/js/components/ui/separator.tsx
+++ b/resources/js/components/ui/separator.tsx
@@ -4,23 +4,23 @@ import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
function Separator({
- className,
- orientation = "horizontal",
- decorative = true,
- ...props
+ className,
+ orientation = "horizontal",
+ decorative = true,
+ ...props
}: React.ComponentProps) {
- return (
-
- )
+ return (
+
+ )
}
export { Separator }
diff --git a/resources/js/pages/transactions/index.tsx b/resources/js/pages/transactions/index.tsx
index 9a3b0c45..52508e4e 100644
--- a/resources/js/pages/transactions/index.tsx
+++ b/resources/js/pages/transactions/index.tsx
@@ -20,6 +20,7 @@ import { Skeleton } from '@/components/ui/skeleton';
import { TransactionFilters } from '@/components/transactions/transaction-filters';
import { EditTransactionDialog } from '@/components/transactions/edit-transaction-dialog';
import { createTransactionColumns } from '@/components/transactions/transaction-columns';
+import { BulkActionsBar } from '@/components/transactions/bulk-actions-bar';
import {
AlertDialog,
AlertDialogAction,
@@ -67,6 +68,7 @@ export default function Transactions({ categories, accounts, banks }: Props) {
const [columnVisibility, setColumnVisibility] = useState({
account: false,
});
+ const [rowSelection, setRowSelection] = useState({});
const [filters, setFilters] = useState({
dateFrom: null,
dateTo: null,
@@ -81,6 +83,8 @@ export default function Transactions({ categories, accounts, banks }: Props) {
const [deleteTransaction, setDeleteTransaction] =
useState(null);
const [isDeleting, setIsDeleting] = useState(false);
+ const [isBulkDeleting, setIsBulkDeleting] = useState(false);
+ const [isBulkUpdating, setIsBulkUpdating] = useState(false);
const [displayedCount, setDisplayedCount] = useState(25);
const observerTarget = useRef(null);
@@ -376,10 +380,13 @@ export default function Transactions({ categories, accounts, banks }: Props) {
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
+ onRowSelectionChange: setRowSelection,
+ enableRowSelection: true,
state: {
sorting,
columnFilters,
columnVisibility,
+ rowSelection,
},
});
@@ -436,6 +443,85 @@ export default function Transactions({ categories, accounts, banks }: Props) {
}
}
+ async function handleBulkCategoryChange(categoryId: number | null) {
+ const selectedIds = Object.keys(rowSelection);
+ if (selectedIds.length === 0) {
+ return;
+ }
+
+ setIsBulkUpdating(true);
+ try {
+ await transactionSyncService.updateMany(selectedIds, {
+ category_id: categoryId,
+ });
+
+ const categoriesMap = new Map(
+ categories.map((category) => [category.id, category]),
+ );
+ const selectedCategory = categoryId
+ ? categoriesMap.get(categoryId) || null
+ : null;
+
+ setTransactions((previous) =>
+ previous.map((transaction) => {
+ if (selectedIds.includes(transaction.id)) {
+ return {
+ ...transaction,
+ category_id: categoryId,
+ category: selectedCategory,
+ };
+ }
+ return transaction;
+ }),
+ );
+
+ setRowSelection({});
+ } catch (error) {
+ console.error('Failed to update transactions:', error);
+ } finally {
+ setIsBulkUpdating(false);
+ }
+ }
+
+ function handleBulkDeleteClick() {
+ const selectedIds = Object.keys(rowSelection);
+ if (selectedIds.length === 0) {
+ return;
+ }
+
+ const firstSelectedTransaction = transactions.find(
+ (t) => t.id === selectedIds[0],
+ );
+ if (firstSelectedTransaction) {
+ setDeleteTransaction(firstSelectedTransaction);
+ }
+ }
+
+ async function handleBulkDelete() {
+ const selectedIds = Object.keys(rowSelection);
+ if (selectedIds.length === 0) {
+ return;
+ }
+
+ setIsBulkDeleting(true);
+ try {
+ await transactionSyncService.deleteMany(selectedIds);
+ setTransactions((previous) =>
+ previous.filter((transaction) => !selectedIds.includes(transaction.id)),
+ );
+ setDeleteTransaction(null);
+ setRowSelection({});
+ } catch (error) {
+ console.error('Failed to delete transactions:', error);
+ } finally {
+ setIsBulkDeleting(false);
+ }
+ }
+
+ function handleClearSelection() {
+ setRowSelection({});
+ }
+
return (
@@ -523,26 +609,42 @@ export default function Transactions({ categories, accounts, banks }: Props) {
>
- Delete Transaction
+
+ Delete Transaction{Object.keys(rowSelection).length > 1 ? 's' : ''}
+
- Are you sure you want to delete this transaction?
- This action cannot be undone.
+ {Object.keys(rowSelection).length > 1
+ ? `Are you sure you want to delete ${Object.keys(rowSelection).length} transactions? This action cannot be undone.`
+ : 'Are you sure you want to delete this transaction? This action cannot be undone.'}
-
+
Cancel
1
+ ? handleBulkDelete
+ : handleDelete
+ }
+ disabled={isDeleting || isBulkDeleting}
className="bg-red-600 hover:bg-red-700"
>
- {isDeleting ? 'Deleting...' : 'Delete'}
+ {isDeleting || isBulkDeleting ? 'Deleting...' : 'Delete'}
+
+
);
}
diff --git a/resources/js/services/transaction-sync.ts b/resources/js/services/transaction-sync.ts
index fbfb705b..5d21dbdb 100644
--- a/resources/js/services/transaction-sync.ts
+++ b/resources/js/services/transaction-sync.ts
@@ -102,6 +102,27 @@ class TransactionSyncService {
await indexedDBService.addPendingChange('transactions', 'update', updated);
}
+ async updateMany(ids: string[], data: Partial): Promise {
+ const timestamp = new Date().toISOString();
+
+ for (const id of ids) {
+ const existing = await this.getById(id);
+ if (!existing) {
+ console.warn(`Transaction ${id} not found, skipping`);
+ continue;
+ }
+
+ const updated = {
+ ...existing,
+ ...data,
+ updated_at: timestamp,
+ };
+
+ await indexedDBService.put('transactions', updated);
+ await indexedDBService.addPendingChange('transactions', 'update', updated);
+ }
+ }
+
async delete(id: string): Promise {
const transaction = await this.getById(id);
if (!transaction) {
@@ -112,6 +133,19 @@ class TransactionSyncService {
await indexedDBService.addPendingChange('transactions', 'delete', transaction);
}
+ async deleteMany(ids: string[]): Promise {
+ for (const id of ids) {
+ const transaction = await this.getById(id);
+ if (!transaction) {
+ console.warn(`Transaction ${id} not found, skipping`);
+ continue;
+ }
+
+ await indexedDBService.delete('transactions', id);
+ await indexedDBService.addPendingChange('transactions', 'delete', transaction);
+ }
+ }
+
async isDuplicate(
accountId: number,
transactionDate: string,
diff --git a/routes/web.php b/routes/web.php
index 22824c16..432dd213 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -39,6 +39,7 @@ Route::middleware(['auth', 'verified', 'redirect.encryption'])->group(function (
})->name('dashboard');
Route::get('transactions', [TransactionController::class, 'index'])->name('transactions.index');
+ Route::patch('transactions/bulk', [TransactionController::class, 'bulkUpdate'])->name('transactions.bulk-update');
Route::patch('transactions/{transaction}', [TransactionController::class, 'update'])->name('transactions.update');
Route::delete('transactions/{transaction}', [TransactionController::class, 'destroy'])->name('transactions.destroy');
});