feat(automation): Add re-evaluate all transactions functionality
This commit is contained in:
parent
7f243bbc1e
commit
e937a8647d
|
|
@ -25,7 +25,7 @@ class CreateDefaultCategories
|
|||
*/
|
||||
public static function getDefaultCategories(): array
|
||||
{
|
||||
return
|
||||
return [
|
||||
['name' => 'Administrative violations', 'icon' => 'AlertTriangle', 'color' => 'red'],
|
||||
['name' => 'Alimony and child support', 'icon' => 'Users', 'color' => 'teal'],
|
||||
['name' => 'Association membership fees', 'icon' => 'Users2', 'color' => 'cyan'],
|
||||
|
|
|
|||
|
|
@ -26,5 +26,3 @@ class AccountSyncController extends Controller
|
|||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -27,5 +27,3 @@ class BankSyncController extends Controller
|
|||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -24,5 +24,3 @@ class CategorySyncController extends Controller
|
|||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -83,12 +83,12 @@ export function RuleBuilder({ value, onChange, error }: RuleBuilderProps) {
|
|||
variant="outline"
|
||||
size="sm"
|
||||
onClick={toggleGroupOperator}
|
||||
className='py-4 px-1.5'
|
||||
className="px-1.5 py-4"
|
||||
>
|
||||
Groups joined by:{' '}
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{structure.groupOperator.toUpperCase()}
|
||||
<ChevronDown className="inline-block h-3 w-3 -mr-1" />
|
||||
<ChevronDown className="-mr-1 inline-block h-3 w-3" />
|
||||
</Badge>
|
||||
</Button>
|
||||
)}
|
||||
|
|
@ -97,32 +97,39 @@ export function RuleBuilder({ value, onChange, error }: RuleBuilderProps) {
|
|||
<div className="space-y-4">
|
||||
{structure.groups.map((group, groupIndex) => (
|
||||
<div key={group.id}>
|
||||
<Card className="p-4 gap-2">
|
||||
<Card className="gap-2 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{group.conditions.length > 1 && (<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className='py-4 px-1.5'
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
updateGroup(group.id, {
|
||||
...group,
|
||||
operator:
|
||||
group.operator === 'and'
|
||||
? 'or'
|
||||
: 'and',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span className='text-sm'>Conditions joined by:{' '}</span>
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{group.operator.toUpperCase()}
|
||||
<ChevronDown className="inline-block h-3 w-3 -mr-1" />
|
||||
</Badge>
|
||||
</Button>
|
||||
</>
|
||||
{group.conditions.length > 1 && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="px-1.5 py-4"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
updateGroup(group.id, {
|
||||
...group,
|
||||
operator:
|
||||
group.operator ===
|
||||
'and'
|
||||
? 'or'
|
||||
: 'and',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span className="text-sm">
|
||||
Conditions joined by:{' '}
|
||||
</span>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="ml-2"
|
||||
>
|
||||
{group.operator.toUpperCase()}
|
||||
<ChevronDown className="-mr-1 inline-block h-3 w-3" />
|
||||
</Badge>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{structure.groups.length > 1 && (
|
||||
|
|
@ -143,14 +150,16 @@ export function RuleBuilder({ value, onChange, error }: RuleBuilderProps) {
|
|||
<div key={condition.id}>
|
||||
<ConditionRow
|
||||
condition={condition}
|
||||
onChange={(updatedCondition) => {
|
||||
onChange={(
|
||||
updatedCondition,
|
||||
) => {
|
||||
updateGroup(group.id, {
|
||||
...group,
|
||||
conditions:
|
||||
group.conditions.map(
|
||||
(c) =>
|
||||
c.id ===
|
||||
condition.id
|
||||
condition.id
|
||||
? updatedCondition
|
||||
: c,
|
||||
),
|
||||
|
|
@ -186,7 +195,7 @@ export function RuleBuilder({ value, onChange, error }: RuleBuilderProps) {
|
|||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full mt-0"
|
||||
className="mt-0 w-full"
|
||||
onClick={() => {
|
||||
updateGroup(group.id, {
|
||||
...group,
|
||||
|
|
@ -267,8 +276,8 @@ function ConditionRow({
|
|||
fieldConfig?.type === 'number'
|
||||
? 'number'
|
||||
: fieldConfig?.type === 'date'
|
||||
? 'date'
|
||||
: 'text';
|
||||
? 'date'
|
||||
: 'text';
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -328,4 +337,3 @@ function ConditionRow({
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -74,5 +74,3 @@ export function SyncStatusButton() {
|
|||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -104,5 +104,3 @@ export function CategorySelect({
|
|||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -315,18 +315,22 @@ export function EditTransactionDialog({
|
|||
/>
|
||||
) : (
|
||||
<div className="text-sm">
|
||||
{transaction && (() => {
|
||||
const date = parseISO(
|
||||
transaction.transaction_date,
|
||||
);
|
||||
const currentYear = getYear(new Date());
|
||||
const transactionYear = getYear(date);
|
||||
const formatString =
|
||||
transactionYear === currentYear
|
||||
? 'MMMM d'
|
||||
: 'MMMM d, yyyy';
|
||||
return format(date, formatString);
|
||||
})()}
|
||||
{transaction &&
|
||||
(() => {
|
||||
const date = parseISO(
|
||||
transaction.transaction_date,
|
||||
);
|
||||
const currentYear = getYear(
|
||||
new Date(),
|
||||
);
|
||||
const transactionYear =
|
||||
getYear(date);
|
||||
const formatString =
|
||||
transactionYear === currentYear
|
||||
? 'MMMM d'
|
||||
: 'MMMM d, yyyy';
|
||||
return format(date, formatString);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -399,7 +403,9 @@ export function EditTransactionDialog({
|
|||
new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: transaction.currency_code,
|
||||
}).format(parseFloat(transaction.amount))}
|
||||
}).format(
|
||||
parseFloat(transaction.amount),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -53,7 +53,9 @@ export function ImportTransactionsButton({
|
|||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{!isKeySet ? `Unlock encryption to import transactions` : `Import transactions from CSV/Excel`}
|
||||
{!isKeySet
|
||||
? `Unlock encryption to import transactions`
|
||||
: `Import transactions from CSV/Excel`}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ import {
|
|||
type ColumnMapping,
|
||||
type ImportState,
|
||||
} from '@/types/import';
|
||||
import { Check, Loader2 } from 'lucide-react';
|
||||
import { Check } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { ImportStepAccount } from './import-step-account';
|
||||
|
|
|
|||
|
|
@ -13,9 +13,11 @@ import {
|
|||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { useEncryptionKey } from '@/contexts/encryption-key-context';
|
||||
import { useReEvaluateAllTransactions } from '@/hooks/use-re-evaluate-all-transactions';
|
||||
import { type Account, type Bank } from '@/types/account';
|
||||
import { type Category } from '@/types/category';
|
||||
import { ChevronDown, Plus, Upload } from 'lucide-react';
|
||||
import { type DecryptedTransaction } from '@/types/transaction';
|
||||
import { ChevronDown, Plus, Upload, Zap } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { ImportTransactionsDrawer } from './import-transactions-drawer';
|
||||
|
|
@ -25,6 +27,8 @@ interface TransactionActionsMenuProps {
|
|||
accounts: Account[];
|
||||
banks: Bank[];
|
||||
onAddTransaction: () => void;
|
||||
transactions: DecryptedTransaction[];
|
||||
onReEvaluateComplete?: () => void;
|
||||
}
|
||||
|
||||
export function TransactionActionsMenu({
|
||||
|
|
@ -32,9 +36,13 @@ export function TransactionActionsMenu({
|
|||
accounts,
|
||||
banks,
|
||||
onAddTransaction,
|
||||
transactions,
|
||||
onReEvaluateComplete,
|
||||
}: TransactionActionsMenuProps) {
|
||||
const { isKeySet } = useEncryptionKey();
|
||||
const [importDrawerOpen, setImportDrawerOpen] = useState(false);
|
||||
const [isReEvaluating, setIsReEvaluating] = useState(false);
|
||||
const { reEvaluateAll } = useReEvaluateAllTransactions();
|
||||
|
||||
const handleAddTransaction = () => {
|
||||
if (!isKeySet) {
|
||||
|
|
@ -56,6 +64,28 @@ export function TransactionActionsMenu({
|
|||
setImportDrawerOpen(true);
|
||||
};
|
||||
|
||||
const handleReEvaluateAll = async () => {
|
||||
if (!isKeySet) {
|
||||
toast.error(
|
||||
'Please unlock your encryption key to re-evaluate transactions',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!transactions.length) {
|
||||
toast.error('No transactions to re-evaluate');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsReEvaluating(true);
|
||||
try {
|
||||
await reEvaluateAll(transactions, categories, accounts, banks);
|
||||
onReEvaluateComplete?.();
|
||||
} finally {
|
||||
setIsReEvaluating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ButtonGroup>
|
||||
|
|
@ -64,8 +94,12 @@ export function TransactionActionsMenu({
|
|||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size={"sm"}
|
||||
className={!isKeySet ? 'cursor-not-allowed opacity-50' : ''}
|
||||
size={'sm'}
|
||||
className={
|
||||
!isKeySet
|
||||
? 'cursor-not-allowed opacity-50'
|
||||
: ''
|
||||
}
|
||||
onClick={handleAddTransaction}
|
||||
aria-label="Add transaction"
|
||||
>
|
||||
|
|
@ -106,6 +140,17 @@ export function TransactionActionsMenu({
|
|||
<Upload className="mr-2 h-4 w-4" />
|
||||
Import Transactions
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={handleReEvaluateAll}
|
||||
disabled={
|
||||
!isKeySet ||
|
||||
isReEvaluating ||
|
||||
!transactions.length
|
||||
}
|
||||
>
|
||||
<Zap className="mr-2 h-4 w-4" />
|
||||
Re-evaluate All Expenses
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</ButtonGroup>
|
||||
|
|
@ -120,5 +165,3 @@ export function TransactionActionsMenu({
|
|||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -23,3 +23,4 @@ export function useOnlineStatus() {
|
|||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,124 @@
|
|||
import { decrypt, encrypt, importKey } from '@/lib/crypto';
|
||||
import { getStoredKey } from '@/lib/key-storage';
|
||||
import { evaluateRules } from '@/lib/rule-engine';
|
||||
import { automationRuleSyncService } from '@/services/automation-rule-sync';
|
||||
import { transactionSyncService } from '@/services/transaction-sync';
|
||||
import type { Category } from '@/types/category';
|
||||
import type { Account, Bank } from '@/types/account';
|
||||
import type { DecryptedTransaction } from '@/types/transaction';
|
||||
import { useCallback } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface ReEvaluateAllOptions {
|
||||
onProgress?: (progress: {
|
||||
current: number;
|
||||
total: number;
|
||||
transactionId: string;
|
||||
description: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export function useReEvaluateAllTransactions() {
|
||||
const reEvaluateAll = useCallback(
|
||||
async (
|
||||
transactions: DecryptedTransaction[],
|
||||
categories: Category[],
|
||||
accounts: Account[],
|
||||
banks: Bank[],
|
||||
options?: ReEvaluateAllOptions,
|
||||
) => {
|
||||
if (!transactions.length) {
|
||||
toast.error('No transactions to re-evaluate');
|
||||
return;
|
||||
}
|
||||
|
||||
const keyString = getStoredKey();
|
||||
if (!keyString) {
|
||||
toast.error('Please unlock your encryption key');
|
||||
return;
|
||||
}
|
||||
|
||||
const key = await importKey(keyString);
|
||||
const rules = await automationRuleSyncService.getAll();
|
||||
|
||||
if (!rules.length) {
|
||||
toast.error('No automation rules found');
|
||||
return;
|
||||
}
|
||||
|
||||
const toastId = toast.loading(
|
||||
`Re-evaluating 0 of ${transactions.length} transactions...`,
|
||||
);
|
||||
|
||||
let successCount = 0;
|
||||
|
||||
try {
|
||||
for (let i = 0; i < transactions.length; i++) {
|
||||
const transaction = transactions[i];
|
||||
const progress = i + 1;
|
||||
|
||||
options?.onProgress?.({
|
||||
current: progress,
|
||||
total: transactions.length,
|
||||
transactionId: transaction.id,
|
||||
description: transaction.decryptedDescription,
|
||||
});
|
||||
|
||||
const result = evaluateRules(
|
||||
transaction,
|
||||
rules,
|
||||
categories,
|
||||
accounts,
|
||||
banks,
|
||||
);
|
||||
|
||||
if (result) {
|
||||
let finalNotes = transaction.notes;
|
||||
let finalNotesIv = transaction.notes_iv;
|
||||
|
||||
if (result.note && result.noteIv) {
|
||||
if (transaction.decryptedNotes) {
|
||||
const combinedNote = `${transaction.decryptedNotes}\n${await decrypt(result.note, key, result.noteIv)}`;
|
||||
const encrypted = await encrypt(combinedNote, key);
|
||||
finalNotes = encrypted.encrypted;
|
||||
finalNotesIv = encrypted.iv;
|
||||
} else {
|
||||
finalNotes = result.note;
|
||||
finalNotesIv = result.noteIv;
|
||||
}
|
||||
}
|
||||
|
||||
await transactionSyncService.update(transaction.id, {
|
||||
category_id: result.categoryId,
|
||||
notes: finalNotes,
|
||||
notes_iv: finalNotesIv,
|
||||
});
|
||||
|
||||
successCount++;
|
||||
}
|
||||
|
||||
toast.loading(
|
||||
`Re-evaluating ${progress} of ${transactions.length} transactions...`,
|
||||
{ id: toastId },
|
||||
);
|
||||
}
|
||||
|
||||
toast.success(
|
||||
`Re-evaluation complete! ${successCount} transaction(s) updated.`,
|
||||
{ id: toastId },
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to re-evaluate transactions:', error);
|
||||
toast.error(
|
||||
'Failed to re-evaluate transactions. Please try again.',
|
||||
{ id: toastId },
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return { reEvaluateAll };
|
||||
}
|
||||
|
||||
|
|
@ -52,7 +52,6 @@ import {
|
|||
type DecryptedTransaction,
|
||||
type TransactionFilters as Filters,
|
||||
} from '@/types/transaction';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
|
|
@ -966,6 +965,13 @@ export default function Transactions({ categories, accounts, banks }: Props) {
|
|||
onAddTransaction={() =>
|
||||
setCreateDialogOpen(true)
|
||||
}
|
||||
transactions={transactions}
|
||||
onReEvaluateComplete={() => {
|
||||
setRowSelection({});
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 500);
|
||||
}}
|
||||
/>
|
||||
<DataTableViewOptions table={table} />
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -47,3 +47,4 @@ class AccountSyncService {
|
|||
export const accountSyncService = new AccountSyncService();
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -43,3 +43,4 @@ class BankSyncService {
|
|||
export const bankSyncService = new BankSyncService();
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -47,3 +47,4 @@ class CategorySyncService {
|
|||
export const categorySyncService = new CategorySyncService();
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -62,3 +62,4 @@ export function formatRuleActions(rule: AutomationRule): string {
|
|||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -78,5 +78,3 @@ it('requires authentication', function () {
|
|||
|
||||
$response->assertUnauthorized();
|
||||
});
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -52,5 +52,3 @@ it('requires authentication', function () {
|
|||
|
||||
$response->assertUnauthorized();
|
||||
});
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue