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'); });