Bulk actions on transactions

This commit is contained in:
Víctor Falcón 2025-11-10 10:02:28 +00:00
parent f0f062468d
commit ea9cec184f
13 changed files with 516 additions and 37 deletions

View File

@ -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),
]);
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class BulkUpdateTransactionsRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'transaction_ids' => ['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.',
];
}
}

View File

@ -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=="],

View File

@ -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",

View File

@ -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 (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 animate-in fade-in slide-in-from-bottom-4 duration-300">
<div className="flex flex-row justify-between items-center gap-10 bg-background py-2 px-4 border rounded-full shadow-lg">
<div className='text-sm'>
{selectedCount} transaction{selectedCount !== 1 ? 's' : ''}{' '}
selected
</div>
<ButtonGroup>
<ButtonGroup>
<BulkCategorySelect
categories={categories}
onCategoryChange={onCategoryChange}
disabled={isUpdating}
/>
</ButtonGroup>
<ButtonGroup>
<Button
variant="outline"
onClick={onDelete}
disabled={isUpdating}
className="text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950"
>
<Trash2 className="h-4 w-4" />
Delete
</Button>
</ButtonGroup>
<ButtonGroup>
<Button
variant="ghost"
size="icon"
onClick={onClear}
disabled={isUpdating}
aria-label="Clear selection"
>
<X className="h-4 w-4" />
</Button>
</ButtonGroup>
</ButtonGroup>
</div>
</div>
);
}

View File

@ -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<string>('');
function handleChange(newValue: string) {
setValue(newValue);
const categoryId = newValue === 'null' ? null : parseInt(newValue);
onCategoryChange(categoryId);
}
return (
<Select value={value} onValueChange={handleChange} disabled={disabled}>
<SelectTrigger className="h-9 w-[180px]">
<SelectValue placeholder="Change category" />
</SelectTrigger>
<SelectContent>
{categories.map((category) => {
const IconComponent = Icons[
category.icon as keyof typeof Icons
] as Icons.LucideIcon;
const classes = getCategoryColorClasses(category.color);
return (
<SelectItem key={category.id} value={String(category.id)}>
<Badge
className={`flex items-center gap-2 py-0.5 ${classes.bg} ${classes.text}`}
>
<IconComponent
className={`opacity-80 h-2 w-2 ${classes.text}`}
/>
<span>{category.name}</span>
</Badge>
</SelectItem>
);
})}
</SelectContent>
</Select>
);
}

View File

@ -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<DecryptedTransaction>[] {
return [
{
id: 'select',
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && 'indeterminate')
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: 'transaction_date',
meta: { label: 'Date' },

View File

@ -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<typeof buttonGroupVariants>) {
return (
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
)
}
function ButtonGroupText({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "div"
return (
<Comp
className={cn(
"bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function ButtonGroupSeparator({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
"bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto",
className
)}
{...props}
/>
)
}
export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
}

View File

@ -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: {

View File

@ -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<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator-root"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-[1px]",
className
)}
{...props}
/>
)
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@ -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<VisibilityState>({
account: false,
});
const [rowSelection, setRowSelection] = useState({});
const [filters, setFilters] = useState<Filters>({
dateFrom: null,
dateTo: null,
@ -81,6 +83,8 @@ export default function Transactions({ categories, accounts, banks }: Props) {
const [deleteTransaction, setDeleteTransaction] =
useState<DecryptedTransaction | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [isBulkDeleting, setIsBulkDeleting] = useState(false);
const [isBulkUpdating, setIsBulkUpdating] = useState(false);
const [displayedCount, setDisplayedCount] = useState(25);
const observerTarget = useRef<HTMLDivElement>(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 (
<AppSidebarLayout breadcrumbs={breadcrumbs}>
<Head title="Transactions" />
@ -523,26 +609,42 @@ export default function Transactions({ categories, accounts, banks }: Props) {
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Transaction</AlertDialogTitle>
<AlertDialogTitle>
Delete Transaction{Object.keys(rowSelection).length > 1 ? 's' : ''}
</AlertDialogTitle>
<AlertDialogDescription>
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.'}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>
<AlertDialogCancel disabled={isDeleting || isBulkDeleting}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={isDeleting}
onClick={
Object.keys(rowSelection).length > 1
? handleBulkDelete
: handleDelete
}
disabled={isDeleting || isBulkDeleting}
className="bg-red-600 hover:bg-red-700"
>
{isDeleting ? 'Deleting...' : 'Delete'}
{isDeleting || isBulkDeleting ? 'Deleting...' : 'Delete'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<BulkActionsBar
selectedCount={Object.keys(rowSelection).length}
categories={categories}
onCategoryChange={handleBulkCategoryChange}
onDelete={handleBulkDeleteClick}
onClear={handleClearSelection}
isUpdating={isBulkUpdating}
/>
</AppSidebarLayout>
);
}

View File

@ -102,6 +102,27 @@ class TransactionSyncService {
await indexedDBService.addPendingChange('transactions', 'update', updated);
}
async updateMany(ids: string[], data: Partial<Transaction>): Promise<void> {
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<void> {
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<void> {
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,

View File

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