whisper-money/resources/js/pages/transactions/categorize.tsx

943 lines
41 KiB
TypeScript

import { categorize as categorizeRoute } from '@/actions/App/Http/Controllers/TransactionController';
import { AutomationRulesDialog } from '@/components/automation-rules/automation-rules-dialog';
import { EncryptedText } from '@/components/encrypted-text';
import { CategoryIcon } from '@/components/shared/category-combobox';
import { AmountDisplay } from '@/components/ui/amount-display';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { Kbd } from '@/components/ui/kbd';
import { Skeleton } from '@/components/ui/skeleton';
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import { decrypt, importKey } from '@/lib/crypto';
import { db } from '@/lib/dexie-db';
import { getStoredKey } from '@/lib/key-storage';
import { evaluateRules } from '@/lib/rule-engine';
import { cn } from '@/lib/utils';
import { transactionSyncService } from '@/services/transaction-sync';
import { type Account, type Bank } from '@/types/account';
import { type AutomationRule } from '@/types/automation-rule';
import { type Category, getCategoryColorClasses } from '@/types/category';
import { type DecryptedTransaction } from '@/types/transaction';
import { Head, Link, router, usePage } from '@inertiajs/react';
import { parseISO } from 'date-fns';
import { useLiveQuery } from 'dexie-react-hooks';
import {
ArrowDown,
ArrowLeft,
ArrowUp,
CheckCircle2,
PartyPopper,
Settings2,
SkipBack,
SkipForward,
} from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
const CATEGORY_USAGE_KEY = 'category-usage-order';
interface Props {
categories: Category[];
accounts: Account[];
banks: Bank[];
}
type AnimationState = 'idle' | 'exiting' | 'entering' | 'success';
function getCategoryUsageOrder(): string[] {
try {
const stored = localStorage.getItem(CATEGORY_USAGE_KEY);
if (stored) {
return JSON.parse(stored);
}
} catch {
// Ignore errors
}
return [];
}
function updateCategoryUsageOrder(categoryId: string): void {
try {
const current = getCategoryUsageOrder();
const filtered = current.filter((id) => id !== categoryId);
const updated = [categoryId, ...filtered];
localStorage.setItem(CATEGORY_USAGE_KEY, JSON.stringify(updated));
} catch {
// Ignore errors
}
}
function sortCategoriesByUsage(
categories: Category[],
usageOrder: string[],
): Category[] {
const orderMap = new Map(usageOrder.map((id, index) => [id, index]));
return [...categories].sort((a, b) => {
const aIndex = orderMap.get(a.id) ?? Infinity;
const bIndex = orderMap.get(b.id) ?? Infinity;
if (aIndex === bIndex) {
return a.name.localeCompare(b.name);
}
return aIndex - bIndex;
});
}
export default function CategorizeTransactions({
categories,
accounts,
banks,
}: Props) {
const { isKeySet } = useEncryptionKey();
const { automationRules: sharedAutomationRules } = usePage<{
automationRules: AutomationRule[];
}>().props;
const transactionIds = useLiveQuery(
async () => {
const txs = await db.transactions.toArray();
return txs
.map((t) => t.id)
.sort()
.join(',');
},
[],
'',
);
const [uncategorizedTransactions, setUncategorizedTransactions] = useState<
DecryptedTransaction[]
>([]);
const [currentIndex, setCurrentIndex] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const [animationState, setAnimationState] =
useState<AnimationState>('idle');
const [categoryUsageOrder, setCategoryUsageOrder] = useState<string[]>([]);
const [lastSelectedCategory, setLastSelectedCategory] =
useState<Category | null>(null);
const [searchValue, setSearchValue] = useState('');
const [rulesDialogOpen, setRulesDialogOpen] = useState(false);
const [encryptionKey, setEncryptionKey] = useState<CryptoKey | null>(null);
const commandInputRef = useRef<HTMLInputElement>(null);
const automationRules = useMemo(
() =>
sharedAutomationRules.map((rule) => ({
...rule,
rules_json:
typeof rule.rules_json === 'string'
? JSON.parse(rule.rules_json)
: rule.rules_json,
})) as AutomationRule[],
[sharedAutomationRules],
);
useEffect(() => {
setCategoryUsageOrder(getCategoryUsageOrder());
}, []);
useEffect(() => {
if (!isLoading && animationState === 'idle') {
commandInputRef.current?.focus();
}
}, [isLoading, animationState, currentIndex]);
useEffect(() => {
async function loadUncategorizedTransactions() {
if (transactionIds === undefined) {
setIsLoading(true);
return;
}
setIsLoading(true);
try {
const rawTransactions = await db.transactions.toArray();
const uncategorized = rawTransactions.filter(
(t) => !t.category_id,
);
const accountsMap = new Map(
accounts.map((account) => [account.id, account]),
);
const banksMap = new Map(banks.map((bank) => [bank.id, bank]));
const keyString = getStoredKey();
let key: CryptoKey | null = null;
if (keyString && isKeySet) {
try {
key = await importKey(keyString);
setEncryptionKey(key);
} catch (error) {
console.error(
'Failed to import encryption key:',
error,
);
}
}
const decrypted = await Promise.all(
uncategorized.map(async (transaction) => {
try {
let decryptedDescription = '';
let decryptedNotes: string | null = null;
if (key) {
try {
decryptedDescription = await decrypt(
transaction.description,
key,
transaction.description_iv,
);
if (
transaction.notes &&
transaction.notes_iv
) {
decryptedNotes = await decrypt(
transaction.notes,
key,
transaction.notes_iv,
);
}
} catch (error) {
console.error(
'Failed to decrypt transaction:',
transaction.id,
error,
);
}
}
const account = accountsMap.get(
transaction.account_id,
);
const bank = account?.bank?.id
? banksMap.get(account.bank.id)
: undefined;
return {
...transaction,
decryptedDescription,
decryptedNotes,
account,
category: null,
bank,
} as DecryptedTransaction;
} catch (error) {
console.error(
'Failed to process transaction:',
transaction.id,
error,
);
return null;
}
}),
);
const validTransactions = decrypted.filter(
(transaction): transaction is DecryptedTransaction =>
transaction !== null,
);
validTransactions.sort((a, b) => {
const dateA = parseISO(a.transaction_date).getTime();
const dateB = parseISO(b.transaction_date).getTime();
return dateB - dateA;
});
setUncategorizedTransactions(validTransactions);
} catch (error) {
console.error(
'Failed to load uncategorized transactions:',
error,
);
} finally {
setIsLoading(false);
}
}
loadUncategorizedTransactions();
}, [transactionIds, accounts, banks, isKeySet]);
const currentTransaction = uncategorizedTransactions[currentIndex];
const remainingCount = uncategorizedTransactions.length - currentIndex;
const isComplete = currentIndex >= uncategorizedTransactions.length;
const sortedCategories = useMemo(() => {
return sortCategoriesByUsage(categories, categoryUsageOrder);
}, [categories, categoryUsageOrder]);
const applyAutomationRulesToQueue = useCallback(async () => {
if (
!encryptionKey ||
!automationRules.length ||
uncategorizedTransactions.length === 0
) {
return;
}
const remainingTransactions =
uncategorizedTransactions.slice(currentIndex);
let categorizedCount = 0;
const newUncategorizedList: DecryptedTransaction[] = [
...uncategorizedTransactions.slice(0, currentIndex),
];
for (const transaction of remainingTransactions) {
const result = await evaluateRules(
transaction,
automationRules,
categories,
accounts,
banks,
encryptionKey,
);
if (result?.categoryId) {
try {
await transactionSyncService.update(transaction.id, {
category_id: result.categoryId,
});
categorizedCount++;
const matchedCategory = categories.find(
(c) => c.id === result.categoryId,
);
if (matchedCategory) {
updateCategoryUsageOrder(matchedCategory.id);
}
} catch (error) {
console.error(
'Failed to apply automation rule to transaction:',
error,
);
newUncategorizedList.push(transaction);
}
} else {
newUncategorizedList.push(transaction);
}
}
if (categorizedCount > 0) {
setCategoryUsageOrder(getCategoryUsageOrder());
setUncategorizedTransactions(newUncategorizedList);
setCurrentIndex(
Math.min(currentIndex, newUncategorizedList.length),
);
}
return categorizedCount;
}, [
encryptionKey,
automationRules,
uncategorizedTransactions,
currentIndex,
categories,
accounts,
banks,
]);
const handleRulesDialogClose = useCallback(
async (open: boolean) => {
setRulesDialogOpen(open);
if (!open) {
await applyAutomationRulesToQueue();
commandInputRef.current?.focus();
}
},
[applyAutomationRulesToQueue],
);
const handleCategorySelect = useCallback(
async (category: Category) => {
if (!currentTransaction || animationState !== 'idle') return;
setLastSelectedCategory(category);
setAnimationState('exiting');
try {
await transactionSyncService.update(currentTransaction.id, {
category_id: category.id,
});
updateCategoryUsageOrder(category.id);
setCategoryUsageOrder(getCategoryUsageOrder());
} catch (error) {
console.error('Failed to update transaction:', error);
setAnimationState('idle');
return;
}
setTimeout(() => {
setAnimationState('success');
setTimeout(() => {
setCurrentIndex((prev) => prev + 1);
setAnimationState('entering');
setSearchValue('');
setTimeout(() => {
setAnimationState('idle');
setLastSelectedCategory(null);
commandInputRef.current?.focus();
}, 300);
}, 400);
}, 300);
},
[currentTransaction, animationState],
);
const handleSkip = useCallback(() => {
if (animationState !== 'idle') return;
setAnimationState('exiting');
setTimeout(() => {
setCurrentIndex((prev) => prev + 1);
setAnimationState('entering');
setTimeout(() => {
setAnimationState('idle');
}, 300);
}, 300);
}, [animationState]);
const handlePrevious = useCallback(() => {
if (animationState !== 'idle' || currentIndex === 0) return;
setAnimationState('exiting');
setTimeout(() => {
setCurrentIndex((prev) => prev - 1);
setAnimationState('entering');
setTimeout(() => {
setAnimationState('idle');
}, 300);
}, 300);
}, [animationState, currentIndex]);
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Ctrl + R for rules dialog
if (e.ctrlKey && e.key === 'r') {
e.preventDefault();
setRulesDialogOpen(true);
}
// Ctrl + N for skip (next)
if (e.ctrlKey && e.key === 'n') {
e.preventDefault();
if (animationState === 'idle' && currentTransaction) {
handleSkip();
}
}
// Ctrl + B for previous (back)
if (e.ctrlKey && e.key === 'b') {
e.preventDefault();
if (animationState === 'idle' && currentIndex > 0) {
handlePrevious();
}
}
// Escape to go back to transactions
if (e.key === 'Escape' && !rulesDialogOpen) {
e.preventDefault();
const transactionsUrl = categorizeRoute
.url()
?.replace('/categorize', '');
if (transactionsUrl) {
router.visit(transactionsUrl);
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [
animationState,
currentTransaction,
handleSkip,
handlePrevious,
currentIndex,
rulesDialogOpen,
]);
const formatDate = (dateStr: string): string => {
const date = new Date(dateStr);
return date.toLocaleDateString('en-US', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
if (isLoading) {
return (
<>
<Head title="Categorize Transactions" />
<div className="flex min-h-screen flex-col items-center justify-center bg-white p-4 dark:bg-zinc-950">
<div className="w-full max-w-lg space-y-6">
<Skeleton className="mx-auto h-6 w-32" />
<Skeleton className="h-48 w-full rounded-2xl" />
<div className="grid grid-cols-3 gap-3">
{Array.from({ length: 9 }).map((_, i) => (
<Skeleton key={i} className="h-12 rounded-xl" />
))}
</div>
</div>
</div>
</>
);
}
if (isComplete) {
return (
<>
<Head title="All Done!" />
<div className="flex min-h-screen flex-col items-center justify-center bg-white p-4 dark:bg-zinc-950">
<div className="animate-bounce-in flex flex-col items-center gap-6 text-center">
<div className="relative">
<div className="absolute inset-0 animate-ping rounded-full bg-emerald-400/30" />
<div className="relative flex h-24 w-24 items-center justify-center rounded-full bg-gradient-to-br from-emerald-400 to-teal-500 shadow-lg shadow-emerald-500/30">
<PartyPopper className="h-12 w-12 text-white" />
</div>
</div>
<div className="space-y-2">
<h1 className="text-3xl font-bold text-zinc-900 dark:text-zinc-100">
All Done!
</h1>
<p className="text-lg text-zinc-600 dark:text-zinc-400">
You've categorized all your transactions.
</p>
</div>
<Link
href={categorizeRoute
.url()
?.replace('/categorize', '')}
>
<Button size="lg" className="mt-4">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Transactions
</Button>
</Link>
</div>
</div>
</>
);
}
if (uncategorizedTransactions.length === 0) {
return (
<>
<Head title="Categorize Transactions" />
<div className="flex min-h-screen flex-col items-center justify-center bg-white p-4 dark:bg-zinc-950">
<div className="flex flex-col items-center gap-6 text-center">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-900/30">
<CheckCircle2 className="h-10 w-10 text-emerald-600 dark:text-emerald-400" />
</div>
<div className="space-y-2">
<h1 className="text-2xl font-bold text-zinc-900 dark:text-zinc-100">
No Uncategorized Transactions
</h1>
<p className="text-zinc-600 dark:text-zinc-400">
All your transactions are already categorized.
</p>
</div>
<Link
href={categorizeRoute
.url()
?.replace('/categorize', '')}
>
<Button>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Transactions
</Button>
</Link>
</div>
</div>
</>
);
}
return (
<>
<Head title="Categorize Transactions" />
<div className="flex min-h-screen flex-col bg-white dark:bg-zinc-950">
<header className="flex items-center justify-between gap-6 px-4 py-3 dark:border-zinc-800">
<Link
href={categorizeRoute.url()?.replace('/categorize', '')}
className="flex w-fit flex-1 items-center gap-2 text-sm text-zinc-600 opacity-50 transition-all duration-200 hover:text-zinc-900 hover:opacity-100 dark:text-zinc-400 dark:hover:text-zinc-100"
>
<ArrowLeft className="h-4 w-4" />
<div className="hidden text-nowrap sm:block">
Back to Transactions
</div>
</Link>
<div className="flex w-full items-center justify-end gap-6 sm:justify-center">
<div className="flex items-center gap-3">
<Button
variant="outline"
size="sm"
onClick={() => setRulesDialogOpen(true)}
className="gap-2 pr-2"
>
<Settings2 className="h-4 w-4" />
Rules
<Kbd>Ctrl+R</Kbd>
</Button>
</div>
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="sm"
onClick={handlePrevious}
disabled={
animationState !== 'idle' ||
currentIndex === 0
}
className="gap-2 pr-2 text-muted-foreground"
>
<SkipBack className="h-4 w-4" />
<span className="">Prev</span>
<Kbd>Ctrl+B</Kbd>
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleSkip}
disabled={
animationState !== 'idle' ||
!currentTransaction
}
className="gap-2 pr-2 text-muted-foreground"
>
<SkipForward className="h-4 w-4" />
<span className="">Skip</span>
<Kbd>Ctrl+N</Kbd>
</Button>
</div>
</div>
<div className="hidden flex-1 items-center gap-2 text-sm text-zinc-600 sm:flex dark:text-zinc-400">
<span className="font-medium text-zinc-900 dark:text-zinc-100">
{remainingCount}
</span>
remaining
</div>
</header>
<main className="flex flex-1 flex-col items-center justify-start p-4 sm:justify-center">
<div className="w-full max-w-xl space-y-8">
<div className="relative">
{animationState === 'success' &&
lastSelectedCategory && (
<div className="flex items-center justify-center py-8">
<div className="animate-success-pop flex flex-col items-center gap-3">
<div
className={cn(
'flex h-16 w-16 items-center justify-center rounded-full',
getCategoryColorClasses(
lastSelectedCategory.color,
).bg,
)}
>
<CheckCircle2
className={cn(
'h-8 w-8',
getCategoryColorClasses(
lastSelectedCategory.color,
).text,
)}
/>
</div>
<span className="text-sm font-medium text-zinc-600 dark:text-zinc-400">
{lastSelectedCategory.name}
</span>
</div>
</div>
)}
{animationState !== 'success' &&
currentTransaction && (
<div
className={cn(
'rounded-2xl border border-zinc-200 bg-white p-6 shadow-xl shadow-zinc-200/50 transition-all duration-300 dark:border-zinc-800 dark:bg-zinc-900 dark:shadow-zinc-900/50',
animationState === 'exiting' &&
'translate-y-[-20px] scale-95 opacity-0',
animationState === 'entering' &&
'animate-card-enter',
animationState === 'idle' &&
'translate-y-0 scale-100 opacity-100',
)}
>
<div className="flex flex-col gap-4">
<div className="space-y-6">
<div className="flex items-start justify-between">
<div className="flex flex-1 flex-col gap-4">
<p className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
{formatDate(
currentTransaction.transaction_date,
)}
</p>
<h2 className="text-2xl whitespace-pre-wrap text-zinc-900 dark:text-zinc-100">
{currentTransaction.decryptedDescription ||
'Encrypted'}
</h2>
{currentTransaction.account && (
<div className="flex items-center gap-2">
{currentTransaction
.bank
?.logo && (
<img
src={
currentTransaction
.bank
.logo
}
alt={
currentTransaction
.bank
.name
}
className="h-5 w-5 rounded"
/>
)}
<EncryptedText
encryptedText={
currentTransaction
.account
.name
}
iv={
currentTransaction
.account
.name_iv
}
length={{
min: 5,
max: 20,
}}
className="text-sm text-zinc-600 dark:text-zinc-400"
/>
</div>
)}
</div>
</div>
{currentTransaction.decryptedNotes && (
<p className="text-sm text-muted-foreground">
{
currentTransaction.decryptedNotes
}
</p>
)}
</div>
<div className="mt-2 font-mono text-xl text-muted-foreground">
<AmountDisplay
amountInCents={
currentTransaction.amount
}
currencyCode={
currentTransaction.currency_code
}
variant="positive-highlight"
highlightPositive={
currentTransaction.amount >=
0
}
/>
</div>
</div>
</div>
)}
</div>
{animationState !== 'success' && currentTransaction && (
<div
className={cn(
'flex flex-col gap-4 px-6 pt-2 transition-all duration-300',
animationState === 'exiting' &&
'translate-y-[-10px] opacity-0',
animationState === 'entering' &&
'animate-command-enter',
animationState === 'idle' &&
'translate-y-0 opacity-100',
)}
>
<div className="flex flex-col gap-1">
<h3 className="font-medium">
Assign a new category
</h3>
<p className="flex items-center gap-1 text-sm text-muted-foreground">
Search, move{' '}
<Kbd>
<ArrowUp className="size-3" />
</Kbd>
<Kbd>
<ArrowDown className="size-3" />
</Kbd>
, and press <Kbd>⏎</Kbd>
</p>
</div>
<Command
className="rounded-xl border border-zinc-200 bg-white shadow-lg dark:border-zinc-800 dark:bg-zinc-900"
shouldFilter={true}
>
<CommandInput
ref={commandInputRef}
placeholder="Search categories..."
value={searchValue}
onValueChange={setSearchValue}
disabled={animationState !== 'idle'}
/>
<CommandList className="max-h-64">
<CommandEmpty>
No categories found.
</CommandEmpty>
<CommandGroup>
{sortedCategories.map(
(category) => {
const colorClasses =
getCategoryColorClasses(
category.color,
);
return (
<CommandItem
key={category.id}
value={
category.name
}
onSelect={() =>
handleCategorySelect(
category,
)
}
disabled={
animationState !==
'idle'
}
className="group cursor-pointer gap-3 p-2"
>
<div
className={cn(
'flex size-5 shrink-0 items-center justify-center rounded-full transition-transform duration-200 group-data-[selected=true]:scale-110',
colorClasses.bg,
)}
>
<CategoryIcon
category={
category
}
/>
</div>
<span className="flex-1 truncate font-medium">
{category.name}
</span>
</CommandItem>
);
},
)}
</CommandGroup>
</CommandList>
</Command>
</div>
)}
</div>
</main>
</div>
<AutomationRulesDialog
open={rulesDialogOpen}
onOpenChange={handleRulesDialogClose}
/>
<style>{`
@keyframes card-enter {
from {
opacity: 0;
transform: translateY(20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes command-enter {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes success-pop {
0% {
opacity: 0;
transform: scale(0.5);
}
50% {
transform: scale(1.1);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes bounce-in {
0% {
opacity: 0;
transform: scale(0.3);
}
50% {
transform: scale(1.05);
}
70% {
transform: scale(0.9);
}
100% {
opacity: 1;
transform: scale(1);
}
}
.animate-card-enter {
animation: card-enter 0.3s ease-out forwards;
}
.animate-command-enter {
animation: command-enter 0.3s ease-out 0.1s forwards;
opacity: 0;
}
.animate-success-pop {
animation: success-pop 0.4s ease-out forwards;
}
.animate-bounce-in {
animation: bounce-in 0.6s ease-out forwards;
}
`}</style>
</>
);
}