Add option to clear transaction category and rework bulk action pill

This commit is contained in:
Víctor Falcón 2025-11-10 12:27:47 +00:00
parent d5ce7a24b4
commit a14e11775b
6 changed files with 196 additions and 174 deletions

View File

@ -1,8 +1,15 @@
import { BulkCategorySelect } from '@/components/transactions/bulk-category-select';
import { Button } from '@/components/ui/button';
import { ButtonGroup } from '@/components/ui/button-group';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { type Category } from '@/types/category';
import { RefreshCw, Trash2, X } from 'lucide-react';
import { MoreHorizontal, RefreshCw, Trash2, X } from 'lucide-react';
interface BulkActionsBarProps {
selectedCount: number;
@ -30,7 +37,7 @@ export function BulkActionsBar({
return (
<div className="fixed bottom-6 left-1/2 z-50 -translate-x-1/2 animate-in duration-300 fade-in slide-in-from-bottom-4">
<div className="flex flex-row items-center justify-between gap-10 rounded-full border bg-background px-4 py-2 shadow-lg">
<div className="text-sm">
<div className="pl-2 text-sm">
{selectedCount} transaction{selectedCount !== 1 ? 's' : ''}{' '}
selected
</div>
@ -42,29 +49,39 @@ export function BulkActionsBar({
onCategoryChange={onCategoryChange}
disabled={isUpdating}
/>
</ButtonGroup>
<ButtonGroup>
<Button
variant="outline"
onClick={onReEvaluateRules}
disabled={isUpdating}
>
<RefreshCw className="h-4 w-4" />
Re-evaluate Rules
</Button>
</ButtonGroup>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="icon"
disabled={isUpdating}
aria-label="More actions"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuGroup>
<ButtonGroup>
<Button
variant="outline"
onClick={onDelete}
disabled={isUpdating}
className="text-red-600 hover:bg-red-50 hover:text-red-700 dark:hover:bg-red-950"
>
<Trash2 className="h-4 w-4" />
Delete
</Button>
<DropdownMenuItem
onClick={onReEvaluateRules}
disabled={isUpdating}
>
<RefreshCw className="h-4 w-4" />
Re-evaluate rules
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={onDelete}
>
<Trash2 />
Delete
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</ButtonGroup>
<ButtonGroup>

View File

@ -1,14 +1,6 @@
import { CategorySelect } from '@/components/transactions/category-select';
import { type Category } from '@/types/category';
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[];
@ -30,31 +22,14 @@ export function BulkCategorySelect({
}
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>
<CategorySelect
value={value}
onValueChange={handleChange}
categories={categories}
disabled={disabled}
placeholder="Change category"
triggerClassName="h-9 w-[180px]"
showUncategorized={true}
/>
);
}

View File

@ -1,19 +1,11 @@
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';
import { type DecryptedTransaction } from '@/types/transaction';
import { type Account, type Bank } from '@/types/account';
import { transactionSyncService } from '@/services/transaction-sync';
import { CategorySelect } from '@/components/transactions/category-select';
import { encrypt, importKey } from '@/lib/crypto';
import { getStoredKey } from '@/lib/key-storage';
import { transactionSyncService } from '@/services/transaction-sync';
import { type Account, type Bank } from '@/types/account';
import { type Category } from '@/types/category';
import { type DecryptedTransaction } from '@/types/transaction';
import { useState } from 'react';
interface CategoryCellProps {
transaction: DecryptedTransaction;
@ -58,11 +50,13 @@ export function CategoryCell({
await transactionSyncService.update(transaction.id, updateData);
const updatedCategory = categoryId
const updatedCategory = categoryId
? categories.find((c) => c.id === categoryId) || null
: null;
const account = accounts.find((a) => a.id === transaction.account_id);
const account = accounts.find(
(a) => a.id === transaction.account_id,
);
const bank = account?.bank?.id
? banks.find((b) => b.id === account.bank.id)
: undefined;
@ -83,54 +77,19 @@ export function CategoryCell({
}
}
const currentCategory = transaction.category;
const colorClasses = currentCategory
? getCategoryColorClasses(currentCategory.color)
: null;
const CurrentCategoryIconComponent = (currentCategory ? Icons[currentCategory.icon as keyof typeof Icons] : "CircleQuestionMark") as Icons.LucideIcon;
return (
<Select
<CategorySelect
value={
transaction.category_id
? String(transaction.category_id)
: 'null'
}
onValueChange={handleCategoryChange}
categories={categories}
disabled={isUpdating}
>
<SelectTrigger className="h-auto w-auto border-0 bg-transparent p-0 shadow-none focus:ring-0">
<SelectValue>
{currentCategory ? (
<Badge
className={`${colorClasses?.bg} ${colorClasses?.text} flex gap-2`}
>
<CurrentCategoryIconComponent className={`opacity-80 h-2 w-2 ${colorClasses?.text}`} />
{currentCategory.name}
</Badge>
) : (
<Badge className="text-zinc-500 bg-zinc-50 dark:bg-zinc-950 flex gap-2">
<Icons.CircleQuestionMark className={`opacity-80 h-2 w-2 text-zinc-500`} />
Uncategorized
</Badge>
)}
</SelectValue>
</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>
placeholder="Uncategorized"
triggerClassName="h-auto w-auto border-0 bg-transparent p-0 shadow-none focus:ring-0"
showUncategorized={true}
/>
);
}

View File

@ -0,0 +1,106 @@
import { Badge } from '@/components/ui/badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { type Category, getCategoryColorClasses } from '@/types/category';
import * as Icons from 'lucide-react';
interface CategorySelectProps {
value: string | null;
onValueChange: (value: string) => void;
categories: Category[];
disabled?: boolean;
placeholder?: string;
className?: string;
triggerClassName?: string;
showUncategorized?: boolean;
}
export function CategorySelect({
value,
onValueChange,
categories,
disabled = false,
placeholder = 'Select category',
className,
triggerClassName,
showUncategorized = true,
}: CategorySelectProps) {
const selectedCategory =
value && value !== 'null'
? categories.find((c) => c.id === parseInt(value))
: null;
const colorClasses = selectedCategory
? getCategoryColorClasses(selectedCategory.color)
: null;
const SelectedIconComponent = selectedCategory
? (Icons[
selectedCategory.icon as keyof typeof Icons
] as Icons.LucideIcon)
: Icons.CircleQuestionMark;
return (
<Select
value={value || 'null'}
onValueChange={onValueChange}
disabled={disabled}
>
<SelectTrigger className={triggerClassName}>
<SelectValue>
{selectedCategory ? (
<Badge
className={`${colorClasses?.bg} ${colorClasses?.text} flex gap-2`}
>
<SelectedIconComponent
className={`h-2 w-2 opacity-80 ${colorClasses?.text}`}
/>
{selectedCategory.name}
</Badge>
) : (
<Badge className="flex gap-2 bg-zinc-50 text-zinc-500 dark:bg-zinc-950">
<Icons.CircleQuestionMark className="h-2 w-2 text-zinc-500 opacity-80" />
{placeholder}
</Badge>
)}
</SelectValue>
</SelectTrigger>
<SelectContent className={className}>
{showUncategorized && (
<SelectItem value="null">
<Badge className="flex items-center gap-2 bg-zinc-50 py-0.5 text-zinc-500 dark:bg-zinc-950">
<Icons.CircleQuestionMark className="h-2 w-2 text-zinc-500 opacity-80" />
<span>Uncategorized</span>
</Badge>
</SelectItem>
)}
{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={`h-2 w-2 opacity-80 ${classes.text}`}
/>
<span>{category.name}</span>
</Badge>
</SelectItem>
);
})}
</SelectContent>
</Select>
);
}

View File

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { CategorySelect } from '@/components/transactions/category-select';
import { Button } from '@/components/ui/button';
import {
Dialog,
@ -8,22 +8,15 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { type Category, getCategoryColorClasses } from '@/types/category';
import { type DecryptedTransaction } from '@/types/transaction';
import { transactionSyncService } from '@/services/transaction-sync';
import { encrypt, importKey } from '@/lib/crypto';
import { getStoredKey } from '@/lib/key-storage';
import { transactionSyncService } from '@/services/transaction-sync';
import { type Category } from '@/types/category';
import { type DecryptedTransaction } from '@/types/transaction';
import { format, parseISO } from 'date-fns';
import { useEffect, useState } from 'react';
interface EditTransactionDialogProps {
transaction: DecryptedTransaction | null;
@ -47,7 +40,9 @@ export function EditTransactionDialog({
useEffect(() => {
if (transaction) {
setCategoryId(
transaction.category_id ? String(transaction.category_id) : 'null',
transaction.category_id
? String(transaction.category_id)
: 'null',
);
setNotes(transaction.decryptedNotes || '');
}
@ -116,10 +111,6 @@ export function EditTransactionDialog({
return null;
}
const selectedCategory = categories.find(
(c) => c.id === parseInt(categoryId),
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[525px]">
@ -133,16 +124,19 @@ export function EditTransactionDialog({
<form onSubmit={handleSubmit}>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label className="text-muted-foreground text-sm">
<Label className="text-sm text-muted-foreground">
Date
</Label>
<div className="text-sm">
{format(parseISO(transaction.transaction_date), 'PPP')}
{format(
parseISO(transaction.transaction_date),
'PPP',
)}
</div>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground text-sm">
<Label className="text-sm text-muted-foreground">
Description
</Label>
<div className="text-sm">
@ -151,7 +145,7 @@ export function EditTransactionDialog({
</div>
<div className="space-y-2">
<Label className="text-muted-foreground text-sm">
<Label className="text-sm text-muted-foreground">
Amount
</Label>
<div className="text-sm font-medium">
@ -164,43 +158,15 @@ export function EditTransactionDialog({
<div className="space-y-2">
<Label htmlFor="category">Category</Label>
<Select
<CategorySelect
value={categoryId}
onValueChange={setCategoryId}
>
<SelectTrigger id="category">
<SelectValue>
{selectedCategory ? (
<div className="flex items-center gap-2">
<span>{selectedCategory.icon}</span>
<span>{selectedCategory.name}</span>
</div>
) : (
<span className="text-zinc-500">
Uncategorized
</span>
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="null">
<span className="text-zinc-500">
Uncategorized
</span>
</SelectItem>
{categories.map((category) => (
<SelectItem
key={category.id}
value={String(category.id)}
>
<div className="flex items-center gap-2">
<span>{category.icon}</span>
<span>{category.name}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
categories={categories}
disabled={isSubmitting}
placeholder="Uncategorized"
triggerClassName="w-full"
showUncategorized={true}
/>
</div>
<div className="space-y-2">
@ -233,4 +199,3 @@ export function EditTransactionDialog({
</Dialog>
);
}

View File

@ -213,7 +213,7 @@ export function createTransactionColumns({
<DropdownMenuItem
onClick={() => onReEvaluateRules(transaction)}
>
Re-evaluate Rules
Re-evaluate rules
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"