Add option to clear transaction category and rework bulk action pill
This commit is contained in:
parent
d5ce7a24b4
commit
a14e11775b
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -213,7 +213,7 @@ export function createTransactionColumns({
|
|||
<DropdownMenuItem
|
||||
onClick={() => onReEvaluateRules(transaction)}
|
||||
>
|
||||
Re-evaluate Rules
|
||||
Re-evaluate rules
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
|
|
|
|||
Loading…
Reference in New Issue