Bulk actions on transactions
This commit is contained in:
parent
f0f062468d
commit
ea9cec184f
|
|
@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
26
bun.lock
26
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=="],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue