Add some toast notifications

This commit is contained in:
Víctor Falcón 2025-11-11 11:39:28 +00:00
parent 5c39060123
commit e13a19bdde
44 changed files with 764 additions and 378 deletions

View File

@ -42,7 +42,6 @@ This application is a Laravel application and its main Laravel ecosystems packag
- Do not change the application's dependencies without approval.
## Frontend Bundling
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `bun run build`, `bun run dev`, or `composer run dev`. Ask them.
- NEVER run `bun run build` by yourself, ask the user to do it.
## Replies

View File

@ -40,6 +40,7 @@
"lucide-react": "^0.553.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.0.1",
"tailwindcss": "^4.0.0",
"tw-animate-css": "^1.4.0",
@ -985,6 +986,8 @@
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"ssf": ["ssf@0.11.2", "", { "dependencies": { "frac": "~1.1.2" } }, "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g=="],

View File

@ -64,6 +64,7 @@
"lucide-react": "^0.553.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.0.1",
"tailwindcss": "^4.0.0",
"tw-animate-css": "^1.4.0",

View File

@ -1,6 +1,6 @@
@import 'tailwindcss';
@import "tw-animate-css";
@import 'tw-animate-css';
@source '../views';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';

View File

@ -4,6 +4,7 @@ import { createInertiaApp } from '@inertiajs/react';
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { Toaster } from 'sonner';
import { EncryptionKeyProvider } from './contexts/encryption-key-context';
import { SyncProvider } from './contexts/sync-context';
import { initializeTheme } from './hooks/use-appearance';
@ -20,15 +21,21 @@ createInertiaApp({
),
setup({ el, App, props }) {
const root = createRoot(el);
const initialPageProps = props.initialPage
?.props as Partial<SharedData> | undefined;
const initialPageProps = props.initialPage?.props as
| Partial<SharedData>
| undefined;
const initialIsAuthenticated = Boolean(initialPageProps?.auth?.user);
root.render(
<StrictMode>
<EncryptionKeyProvider>
<SyncProvider initialIsAuthenticated={initialIsAuthenticated}>
<SyncProvider
initialIsAuthenticated={initialIsAuthenticated}
>
<App {...props} />
<div className="[&_[data-sonner-toaster]]:!top-4 [&_[data-sonner-toaster]]:!left-1/2 [&_[data-sonner-toaster]]:!-translate-x-1/2 [&_[data-sonner-toaster]]:md:!top-auto [&_[data-sonner-toaster]]:md:!right-4 [&_[data-sonner-toaster]]:md:!bottom-4 [&_[data-sonner-toaster]]:md:!left-auto [&_[data-sonner-toaster]]:md:!translate-x-0">
<Toaster richColors />
</div>
</SyncProvider>
</EncryptionKeyProvider>
</StrictMode>,

View File

@ -1,3 +1,4 @@
import { index as transactionsIndex } from '@/actions/App/Http/Controllers/TransactionController';
import { Breadcrumbs } from '@/components/breadcrumbs';
import { Icon } from '@/components/icon';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
@ -30,10 +31,16 @@ import { UserMenuContent } from '@/components/user-menu-content';
import { useInitials } from '@/hooks/use-initials';
import { cn, isSameUrl, resolveUrl } from '@/lib/utils';
import { dashboard } from '@/routes';
import { index as transactionsIndex } from '@/actions/App/Http/Controllers/TransactionController';
import { type BreadcrumbItem, type NavItem, type SharedData } from '@/types';
import { Link, usePage } from '@inertiajs/react';
import { BookOpen, Folder, LayoutGrid, Menu, Receipt, Search } from 'lucide-react';
import {
BookOpen,
Folder,
LayoutGrid,
Menu,
Receipt,
Search,
} from 'lucide-react';
import AppLogo from './app-logo';
import AppLogoIcon from './app-logo-icon';

View File

@ -19,7 +19,10 @@ export function AppSidebarHeader({
</div>
<div className="flex items-center gap-2">
<ImportTransactionsButton />
<Separator orientation="vertical" className="data-[orientation=vertical]:h-6" />
<Separator
orientation="vertical"
className="data-[orientation=vertical]:h-6"
/>
<SyncStatusButton />
<EncryptionKeyButton />
</div>

View File

@ -1,3 +1,4 @@
import { index as transactionsIndex } from '@/actions/App/Http/Controllers/TransactionController';
import { NavFooter } from '@/components/nav-footer';
import { NavMain } from '@/components/nav-main';
import { NavUser } from '@/components/nav-user';
@ -11,7 +12,6 @@ import {
SidebarMenuItem,
} from '@/components/ui/sidebar';
import { dashboard } from '@/routes';
import { index as transactionsIndex } from '@/actions/App/Http/Controllers/TransactionController';
import { type NavItem } from '@/types';
import { Link } from '@inertiajs/react';
import { BookOpen, Folder, LayoutGrid, Receipt } from 'lucide-react';

View File

@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import { decrypt, importKey } from '@/lib/crypto';
import { getStoredKey } from '@/lib/key-storage';
import { importKey, decrypt } from '@/lib/crypto';
import { useEffect, useMemo, useState } from 'react';
type Length = number | { min: number; max: number } | null;
@ -14,8 +14,7 @@ interface EncryptedTextProps {
type DisplayState = 'encrypted' | 'decrypted' | 'loading';
const ENCRYPTED_CHARSET =
'0123456789$%&#@!ABCDEFGHIJKLMNOPQRSTUVWXYZ';
const ENCRYPTED_CHARSET = '0123456789$%&#@!ABCDEFGHIJKLMNOPQRSTUVWXYZ';
function resolveTargetLength(length: Length, fallback: number): number {
if (typeof length === 'number') {
@ -126,7 +125,10 @@ export function EncryptedText(props: EncryptedTextProps) {
if (displayState === 'loading') {
const widthInCharacters = Math.max(targetLength, 3) / 2;
const loadingClassName = ['inline-block animate-pulse rounded bg-muted/60', className]
const loadingClassName = [
'inline-block animate-pulse rounded bg-muted/60',
className,
]
.filter(Boolean)
.join(' ');

View File

@ -1,4 +1,3 @@
import { CloudAlert, CloudCheck, CloudOff, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Tooltip,
@ -8,6 +7,7 @@ import {
} from '@/components/ui/tooltip';
import { useSyncContext } from '@/contexts/sync-context';
import { formatDistanceToNow } from 'date-fns';
import { CloudAlert, CloudCheck, CloudOff, RefreshCw } from 'lucide-react';
export function SyncStatusButton() {
const { syncStatus, lastSyncTime, isOnline, sync, error } =
@ -71,7 +71,6 @@ export function SyncStatusButton() {
<p>{getTooltipText()}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider >
</TooltipProvider>
);
}

View File

@ -63,7 +63,6 @@ export function BulkActionsBar({
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuGroup>
<DropdownMenuItem
onClick={onReEvaluateRules}
disabled={isUpdating}

View File

@ -1,4 +1,5 @@
import { CategorySelect } from '@/components/transactions/category-select';
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import { encrypt, importKey } from '@/lib/crypto';
import { getStoredKey } from '@/lib/key-storage';
import { transactionSyncService } from '@/services/transaction-sync';
@ -6,6 +7,7 @@ import { type Account, type Bank } from '@/types/account';
import { type Category } from '@/types/category';
import { type DecryptedTransaction } from '@/types/transaction';
import { useState } from 'react';
import { toast } from 'sonner';
interface CategoryCellProps {
transaction: DecryptedTransaction;
@ -22,9 +24,17 @@ export function CategoryCell({
banks,
onUpdate,
}: CategoryCellProps) {
const { isKeySet } = useEncryptionKey();
const [isUpdating, setIsUpdating] = useState(false);
async function handleCategoryChange(value: string) {
if (!isKeySet) {
toast.error(
'Please unlock your encryption key to update transactions',
);
return;
}
const categoryId = value === 'null' ? null : parseInt(value);
setIsUpdating(true);

View File

@ -10,6 +10,7 @@ import {
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import { encrypt, importKey } from '@/lib/crypto';
import { getStoredKey } from '@/lib/key-storage';
import { transactionSyncService } from '@/services/transaction-sync';
@ -17,6 +18,7 @@ import { type Category } from '@/types/category';
import { type DecryptedTransaction } from '@/types/transaction';
import { format, parseISO } from 'date-fns';
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
interface EditTransactionDialogProps {
transaction: DecryptedTransaction | null;
@ -33,6 +35,7 @@ export function EditTransactionDialog({
onOpenChange,
onSuccess,
}: EditTransactionDialogProps) {
const { isKeySet } = useEncryptionKey();
const [categoryId, setCategoryId] = useState<string>('null');
const [notes, setNotes] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
@ -54,6 +57,13 @@ export function EditTransactionDialog({
return;
}
if (!isKeySet) {
toast.error(
'Please unlock your encryption key to update transactions',
);
return;
}
setIsSubmitting(true);
try {
const selectedCategoryId =

View File

@ -1,11 +1,11 @@
import { useEffect, useState } from 'react';
import { accountSyncService } from '@/services/account-sync';
import { type Account } from '@/types/account';
import { EncryptedText } from '@/components/encrypted-text';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Button } from '@/components/ui/button';
import { EncryptedText } from '@/components/encrypted-text';
import { accountSyncService } from '@/services/account-sync';
import { type Account } from '@/types/account';
import { Building2 } from 'lucide-react';
import { useEffect, useState } from 'react';
interface ImportStepAccountProps {
selectedAccountId: number | null;
@ -39,7 +39,9 @@ export function ImportStepAccount({
if (loading) {
return (
<div className="flex items-center justify-center py-8">
<p className="text-sm text-muted-foreground">Loading accounts...</p>
<p className="text-sm text-muted-foreground">
Loading accounts...
</p>
</div>
);
}
@ -82,9 +84,7 @@ export function ImportStepAccount({
<Building2 className="h-5 w-5 text-muted-foreground" />
</div>
)}
<div
className="flex flex-1 flex-col gap-1"
>
<div className="flex flex-1 flex-col gap-1">
<span className="font-medium">
<EncryptedText
encryptedText={account.name}
@ -93,7 +93,8 @@ export function ImportStepAccount({
/>
</span>
<span className="text-sm text-muted-foreground">
{account.bank.name} {account.currency_code}
{account.bank.name} {' '}
{account.currency_code}
</span>
</div>
</Label>
@ -102,14 +103,10 @@ export function ImportStepAccount({
</RadioGroup>
<div className="flex justify-end">
<Button
onClick={onNext}
disabled={!selectedAccountId}
>
<Button onClick={onNext} disabled={!selectedAccountId}>
Next
</Button>
</div>
</div>
);
}

View File

@ -1,5 +1,6 @@
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import {
Select,
SelectContent,
@ -7,9 +8,13 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { DateFormat, type ColumnMapping, type ColumnOption, type ParsedRow } from '@/types/import';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { parseDate, parseAmount } from '@/lib/file-parser';
import { parseAmount, parseDate } from '@/lib/file-parser';
import {
DateFormat,
type ColumnMapping,
type ColumnOption,
type ParsedRow,
} from '@/types/import';
interface ImportStepMappingProps {
columnOptions: ColumnOption[];
@ -43,7 +48,10 @@ export function ImportStepMapping({
const previewTransactions = parsedData.slice(0, 3).map((row) => {
const date = columnMapping.transaction_date
? parseDate(row[columnMapping.transaction_date] as string | number, dateFormat)
? parseDate(
row[columnMapping.transaction_date] as string | number,
dateFormat,
)
: null;
const description = columnMapping.description
? String(row[columnMapping.description] || '')
@ -53,14 +61,21 @@ export function ImportStepMapping({
: null;
return {
date: date ? date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) : 'Invalid date',
date: date
? date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
: 'Invalid date',
description: description || 'No description',
amount: amount !== null
? new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currencyCode,
}).format(amount)
: 'Invalid amount',
amount:
amount !== null
? new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currencyCode,
}).format(amount)
: 'Invalid amount',
};
});
@ -69,7 +84,8 @@ export function ImportStepMapping({
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="date-column">
Transaction Date <span className="text-destructive">*</span>
Transaction Date{' '}
<span className="text-destructive">*</span>
</Label>
<Select
value={columnMapping.transaction_date || ''}
@ -82,7 +98,10 @@ export function ImportStepMapping({
</SelectTrigger>
<SelectContent>
{columnOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<SelectItem
key={option.value}
value={option.value}
>
<div className="flex flex-col">
<span>{option.label}</span>
</div>
@ -107,7 +126,10 @@ export function ImportStepMapping({
</SelectTrigger>
<SelectContent>
{columnOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<SelectItem
key={option.value}
value={option.value}
>
<div className="flex flex-col">
<span>{option.label}</span>
</div>
@ -123,14 +145,19 @@ export function ImportStepMapping({
</Label>
<Select
value={columnMapping.amount || ''}
onValueChange={(value) => onMappingChange('amount', value)}
onValueChange={(value) =>
onMappingChange('amount', value)
}
>
<SelectTrigger id="amount-column">
<SelectValue placeholder="Select amount column" />
</SelectTrigger>
<SelectContent>
{columnOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<SelectItem
key={option.value}
value={option.value}
>
{option.label}
</SelectItem>
))}
@ -140,15 +167,26 @@ export function ImportStepMapping({
{isValid && previewTransactions.length > 0 && (
<div className="space-y-4 rounded-lg border bg-muted/30 p-4">
<Label className="text-xs opacity-50 pl-2 font-light uppercase tracking-widest">Preview (first 3 rows)</Label>
<Label className="pl-2 text-xs font-light tracking-widest uppercase opacity-50">
Preview (first 3 rows)
</Label>
<div className="space-y-2 pt-2">
{previewTransactions.map((transaction, index) => (
<div key={index} className="flex items-center justify-between rounded-md bg-background p-3 text-sm">
<div
key={index}
className="flex items-center justify-between rounded-md bg-background p-3 text-sm"
>
<div className="flex flex-1 items-center gap-3">
<span className="text-muted-foreground">{transaction.date}</span>
<span className="flex-1 truncate">{transaction.description}</span>
<span className="text-muted-foreground">
{transaction.date}
</span>
<span className="flex-1 truncate">
{transaction.description}
</span>
</div>
<span className="font-mono font-medium">{transaction.amount}</span>
<span className="font-mono font-medium">
{transaction.amount}
</span>
</div>
))}
</div>
@ -160,14 +198,19 @@ export function ImportStepMapping({
<Label>Date Format</Label>
<RadioGroup
value={dateFormat}
onValueChange={(value) => onDateFormatChange(value as DateFormat)}
onValueChange={(value) =>
onDateFormatChange(value as DateFormat)
}
>
<div className="flex items-center space-x-2">
<RadioGroupItem
value={DateFormat.YearMonthDay}
id="format-ymd"
/>
<Label htmlFor="format-ymd" className="cursor-pointer font-normal">
<Label
htmlFor="format-ymd"
className="cursor-pointer font-normal"
>
YYYY-MM-DD (e.g., 2024-12-31)
</Label>
</div>
@ -176,7 +219,10 @@ export function ImportStepMapping({
value={DateFormat.MonthDayYear}
id="format-mdy"
/>
<Label htmlFor="format-mdy" className="cursor-pointer font-normal">
<Label
htmlFor="format-mdy"
className="cursor-pointer font-normal"
>
MM-DD-YYYY (e.g., 12-31-2024)
</Label>
</div>
@ -185,7 +231,10 @@ export function ImportStepMapping({
value={DateFormat.DayMonthYear}
id="format-dmy"
/>
<Label htmlFor="format-dmy" className="cursor-pointer font-normal">
<Label
htmlFor="format-dmy"
className="cursor-pointer font-normal"
>
DD-MM-YYYY (e.g., 31-12-2024)
</Label>
</div>
@ -205,4 +254,3 @@ export function ImportStepMapping({
</div>
);
}

View File

@ -1,5 +1,5 @@
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
@ -72,7 +72,8 @@ export function ImportStepPreview({
{stats.newCount === 0 && stats.duplicateCount > 0 && (
<div className="rounded-lg border border-amber-500/20 bg-amber-500/10 p-4">
<p className="text-sm text-amber-700 dark:text-amber-300">
All transactions appear to be duplicates. No new transactions will be imported.
All transactions appear to be duplicates. No new
transactions will be imported.
</p>
</div>
)}
@ -84,13 +85,18 @@ export function ImportStepPreview({
<TableHead>Date</TableHead>
<TableHead>Description</TableHead>
<TableHead className="text-right">Amount</TableHead>
<TableHead className="text-center">Status</TableHead>
<TableHead className="text-center">
Status
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{transactions.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center text-muted-foreground">
<TableCell
colSpan={4}
className="text-center text-muted-foreground"
>
No valid transactions found
</TableCell>
</TableRow>
@ -99,11 +105,15 @@ export function ImportStepPreview({
<TableRow
key={index}
className={
transaction.isDuplicate ? 'opacity-60' : ''
transaction.isDuplicate
? 'opacity-60'
: ''
}
>
<TableCell className="whitespace-nowrap">
{formatDate(transaction.transaction_date)}
{formatDate(
transaction.transaction_date,
)}
</TableCell>
<TableCell className="max-w-[200px] truncate">
{transaction.description}
@ -113,7 +123,9 @@ export function ImportStepPreview({
</TableCell>
<TableCell className="text-center">
{transaction.isDuplicate ? (
<Badge variant="secondary">Duplicate</Badge>
<Badge variant="secondary">
Duplicate
</Badge>
) : (
<Badge variant="default">New</Badge>
)}
@ -126,7 +138,11 @@ export function ImportStepPreview({
</div>
<div className="flex justify-between">
<Button variant="outline" onClick={onBack} disabled={isImporting}>
<Button
variant="outline"
onClick={onBack}
disabled={isImporting}
>
Back
</Button>
<Button
@ -141,4 +157,3 @@ export function ImportStepPreview({
</div>
);
}

View File

@ -1,7 +1,7 @@
import { useCallback, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Upload, FileSpreadsheet, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { FileSpreadsheet, Upload, X } from 'lucide-react';
import { useCallback, useState } from 'react';
interface ImportStepUploadProps {
file: File | null;
@ -28,18 +28,21 @@ export function ImportStepUpload({
setIsDragging(false);
}, []);
const isValidFile = useCallback((file: File | null | undefined): boolean => {
if (!file || !file.name) {
return false;
}
const validExtensions = ['.csv', '.xls', '.xlsx'];
const lastDotIndex = file.name.lastIndexOf('.');
if (lastDotIndex === -1) {
return false;
}
const extension = file.name.toLowerCase().slice(lastDotIndex);
return validExtensions.includes(extension);
}, []);
const isValidFile = useCallback(
(file: File | null | undefined): boolean => {
if (!file || !file.name) {
return false;
}
const validExtensions = ['.csv', '.xls', '.xlsx'];
const lastDotIndex = file.name.lastIndexOf('.');
if (lastDotIndex === -1) {
return false;
}
const extension = file.name.toLowerCase().slice(lastDotIndex);
return validExtensions.includes(extension);
},
[],
);
const handleDrop = useCallback(
(e: React.DragEvent) => {
@ -51,7 +54,7 @@ export function ImportStepUpload({
onFileSelect(droppedFile);
}
},
[onFileSelect, isValidFile]
[onFileSelect, isValidFile],
);
const handleFileInput = useCallback(
@ -61,7 +64,7 @@ export function ImportStepUpload({
onFileSelect(selectedFile);
}
},
[onFileSelect, isValidFile]
[onFileSelect, isValidFile],
);
const formatFileSize = (bytes: number): string => {
@ -76,7 +79,7 @@ export function ImportStepUpload({
className={cn(
'flex min-h-[200px] flex-col items-center justify-center rounded-lg border-2 border-dashed p-8 transition-colors',
isDragging && 'border-primary bg-accent',
!isDragging && 'border-border hover:border-primary/50'
!isDragging && 'border-border hover:border-primary/50',
)}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
@ -117,7 +120,9 @@ export function ImportStepUpload({
size="icon"
onClick={(e) => {
e.preventDefault();
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const input = document.querySelector(
'input[type="file"]',
) as HTMLInputElement;
if (input) input.value = '';
onFileSelect(undefined as unknown as File);
}}
@ -139,4 +144,3 @@ export function ImportStepUpload({
</div>
);
}

View File

@ -1,15 +1,17 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Upload } from 'lucide-react';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { ImportTransactionsDrawer } from './import-transactions-drawer';
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import { type Account, type Bank } from '@/types/account';
import { type Category } from '@/types/category';
import { Upload } from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
import { ImportTransactionsDrawer } from './import-transactions-drawer';
interface ImportTransactionsButtonProps {
categories: Category[];
@ -22,8 +24,19 @@ export function ImportTransactionsButton({
accounts,
banks,
}: ImportTransactionsButtonProps) {
const { isKeySet } = useEncryptionKey();
const [drawerOpen, setDrawerOpen] = useState(false);
const handleOpenDrawer = () => {
if (!isKeySet) {
toast.error(
'Please unlock your encryption key to import transactions',
);
return;
}
setDrawerOpen(true);
};
return (
<>
<TooltipProvider>
@ -32,14 +45,16 @@ export function ImportTransactionsButton({
<Button
variant="ghost"
className="h-9"
onClick={() => setDrawerOpen(true)}
onClick={handleOpenDrawer}
aria-label="Import transactions"
>
<Upload className="h-5 w-5" />
Import
</Button>
</TooltipTrigger>
<TooltipContent>Import transactions from CSV/Excel</TooltipContent>
<TooltipContent>
Import transactions from CSV/Excel
</TooltipContent>
</Tooltip>
</TooltipProvider>
@ -53,4 +68,3 @@ export function ImportTransactionsButton({
</>
);
}

View File

@ -30,7 +30,9 @@ import {
type ColumnMapping,
type ImportState,
} from '@/types/import';
import { Check, Loader2 } from 'lucide-react';
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
import { ImportStepAccount } from './import-step-account';
import { ImportStepMapping } from './import-step-mapping';
import { ImportStepPreview } from './import-step-preview';
@ -288,15 +290,23 @@ export function ImportTransactionsDrawer({
setIsImporting(true);
setError(null);
const newTransactions = state.transactions.filter(
(t) => !t.isDuplicate,
);
const total = newTransactions.length;
const toastId = toast('Importing transactions...', {
icon: <Loader2 className="h-4 w-4 animate-spin" />,
duration: Infinity,
});
onOpenChange(false);
try {
if (!selectedAccount) {
throw new Error('Selected account not found');
}
const newTransactions = state.transactions.filter(
(t) => !t.isDuplicate,
);
const transactionsToImport = await Promise.all(
newTransactions.map(async (transaction) => {
const { encrypted, iv } =
@ -321,7 +331,8 @@ export function ImportTransactionsDrawer({
}),
);
const createdTransactions = await transactionSyncService.createMany(transactionsToImport);
const createdTransactions =
await transactionSyncService.createMany(transactionsToImport);
const keyString = getStoredKey();
if (keyString) {
@ -340,7 +351,9 @@ export function ImportTransactionsDrawer({
(a) => a.id === transaction.account_id,
);
const category = transaction.category_id
? categories.find((c) => c.id === transaction.category_id)
? categories.find(
(c) => c.id === transaction.category_id,
)
: null;
const decryptedTransaction = {
@ -371,11 +384,14 @@ export function ImportTransactionsDrawer({
finalNotesIv = result.noteIv;
}
await transactionSyncService.update(transaction.id, {
category_id: result.categoryId,
notes: finalNotes,
notes_iv: finalNotesIv,
});
await transactionSyncService.update(
transaction.id,
{
category_id: result.categoryId,
notes: finalNotes,
notes_iv: finalNotesIv,
},
);
}
}
}
@ -383,8 +399,20 @@ export function ImportTransactionsDrawer({
sync();
onOpenChange(false);
toast.success(
`${total} transaction${total !== 1 ? 's' : ''} imported`,
{
id: toastId,
icon: <Check className="h-4 w-4" />,
},
);
} catch (err) {
toast.error(
err instanceof Error
? err.message
: 'Failed to import transactions',
{ id: toastId },
);
setError(
err instanceof Error
? err.message

View File

@ -1,7 +1,9 @@
import { type ReactNode, useState } from 'react';
import * as Icons from 'lucide-react';
import { format } from 'date-fns';
import * as Icons from 'lucide-react';
import { type ReactNode, useState } from 'react';
import { EncryptedText } from '@/components/encrypted-text';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@ -10,10 +12,8 @@ import {
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Badge } from '@/components/ui/badge';
import { EncryptedText } from '@/components/encrypted-text';
import { type Category, getCategoryColorClasses } from '@/types/category';
import { type Account } from '@/types/account';
import { type Category, getCategoryColorClasses } from '@/types/category';
import { type TransactionFilters } from '@/types/transaction';
interface TransactionFiltersProps {
@ -116,12 +116,15 @@ export function TransactionFilters({
<div className="space-y-2">
<Label>Date</Label>
<div className="pt-2 grid grid-cols-2 gap-2">
<div className="grid grid-cols-2 gap-2 pt-2">
<Input
type="date"
value={
filters.dateFrom
? format(filters.dateFrom, 'yyyy-MM-dd')
? format(
filters.dateFrom,
'yyyy-MM-dd',
)
: ''
}
onChange={(e) =>
@ -138,7 +141,10 @@ export function TransactionFilters({
type="date"
value={
filters.dateTo
? format(filters.dateTo, 'yyyy-MM-dd')
? format(
filters.dateTo,
'yyyy-MM-dd',
)
: ''
}
onChange={(e) =>
@ -156,7 +162,7 @@ export function TransactionFilters({
<div className="space-y-2">
<Label>Amount</Label>
<div className="pt-2 grid grid-cols-2 gap-2">
<div className="grid grid-cols-2 gap-2 pt-2">
<Input
type="number"
step="0.01"
@ -190,15 +196,22 @@ export function TransactionFilters({
<div className="space-y-2">
<Label>Categories</Label>
<div className="pt-2 flex flex-wrap gap-2">
<div className="flex flex-wrap gap-2 pt-2">
<Badge
variant={isUncategorizedSelected ? 'default' : 'outline'}
className={`flex cursor-pointer items-center gap-1 py-1.5 ${isUncategorizedSelected
? 'border-transparent bg-muted text-foreground dark:bg-muted/40'
: ''
}`}
variant={
isUncategorizedSelected
? 'default'
: 'outline'
}
className={`flex cursor-pointer items-center gap-1 py-1.5 ${
isUncategorizedSelected
? 'border-transparent bg-muted text-foreground dark:bg-muted/40'
: ''
}`}
onClick={() =>
handleCategoryToggle(UNCATEGORIZED_CATEGORY_ID)
handleCategoryToggle(
UNCATEGORIZED_CATEGORY_ID,
)
}
>
<Icons.CircleHelp className="h-4 w-4 opacity-80" />
@ -214,21 +227,28 @@ export function TransactionFilters({
</Badge>
{categories.map((category) => {
const isSelected =
filters.categoryIds.includes(category.id);
const IconComponent = resolveIconComponent(
category.icon,
);
filters.categoryIds.includes(
category.id,
);
const IconComponent =
resolveIconComponent(category.icon);
const colorClasses =
getCategoryColorClasses(category.color);
getCategoryColorClasses(
category.color,
);
return (
<Badge
key={category.id}
variant={
isSelected ? 'default' : 'outline'
isSelected
? 'default'
: 'outline'
}
className={`flex cursor-pointer items-center gap-1 py-1.5 ${isSelected ? `${colorClasses.bg} ${colorClasses.text} border-transparent` : ''}`}
className={`flex cursor-pointer items-center gap-1 py-1.5 ${isSelected ? `${colorClasses.bg} ${colorClasses.text} border-transparent` : ''}`}
onClick={() =>
handleCategoryToggle(category.id)
handleCategoryToggle(
category.id,
)
}
>
<IconComponent
@ -251,19 +271,25 @@ export function TransactionFilters({
<div className="space-y-2">
<Label>Accounts</Label>
<div className="pt-2 flex flex-wrap gap-2">
<div className="flex flex-wrap gap-2 pt-2">
{accounts.map((account) => {
const isSelected =
filters.accountIds.includes(account.id);
filters.accountIds.includes(
account.id,
);
return (
<Badge
key={account.id}
variant={
isSelected ? 'default' : 'outline'
isSelected
? 'default'
: 'outline'
}
className="cursor-pointer py-1 px-2"
className="cursor-pointer px-2 py-1"
onClick={() =>
handleAccountToggle(account.id)
handleAccountToggle(
account.id,
)
}
>
<EncryptedText
@ -325,4 +351,3 @@ function resolveIconComponent(iconName?: string): Icons.LucideIcon {
return Icons.Circle as Icons.LucideIcon;
}

View File

@ -1,20 +1,20 @@
import React, {
createContext,
useContext,
useState,
useEffect,
useCallback,
type ReactNode,
} from 'react';
import { useOnlineStatus } from '@/hooks/use-online-status';
import { checkDatabaseVersion } from '@/lib/db-migration-helper';
import { accountSyncService } from '@/services/account-sync';
import { automationRuleSyncService } from '@/services/automation-rule-sync';
import { bankSyncService } from '@/services/bank-sync';
import { categorySyncService } from '@/services/category-sync';
import { transactionSyncService } from '@/services/transaction-sync';
import type { Page } from '@inertiajs/core';
import { router } from '@inertiajs/react';
import { useOnlineStatus } from '@/hooks/use-online-status';
import { categorySyncService } from '@/services/category-sync';
import { accountSyncService } from '@/services/account-sync';
import { bankSyncService } from '@/services/bank-sync';
import { transactionSyncService } from '@/services/transaction-sync';
import { automationRuleSyncService } from '@/services/automation-rule-sync';
import { checkDatabaseVersion } from '@/lib/db-migration-helper';
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
type ReactNode,
} from 'react';
export type SyncStatus = 'idle' | 'syncing' | 'success' | 'error';
@ -91,14 +91,19 @@ export function SyncProvider({
setError(null);
try {
const [categoriesResult, accountsResult, banksResult, automationRulesResult, transactionsResult] =
await Promise.all([
categorySyncService.sync(),
accountSyncService.sync(),
bankSyncService.sync(),
automationRuleSyncService.sync(),
transactionSyncService.sync(),
]);
const [
categoriesResult,
accountsResult,
banksResult,
automationRulesResult,
transactionsResult,
] = await Promise.all([
categorySyncService.sync(),
accountSyncService.sync(),
bankSyncService.sync(),
automationRuleSyncService.sync(),
transactionSyncService.sync(),
]);
const allErrors = [
...categoriesResult.errors,
@ -121,9 +126,7 @@ export function SyncProvider({
}
} catch (err) {
console.error('Sync failed:', err);
setError(
err instanceof Error ? err.message : 'Unknown sync error',
);
setError(err instanceof Error ? err.message : 'Unknown sync error');
setSyncStatus('error');
setTimeout(() => {
@ -193,4 +196,3 @@ export function useSyncContext() {
}
return context;
}

View File

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useEffect, useState } from 'react';
export function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(() => {
@ -21,4 +21,3 @@ export function useOnlineStatus() {
return isOnline;
}

View File

@ -1,12 +1,12 @@
import { index as accountsIndex } from '@/actions/App/Http/Controllers/Settings/AccountController';
import { index as automationRulesIndex } from '@/actions/App/Http/Controllers/Settings/AutomationRuleController';
import { index as categoriesIndex } from '@/actions/App/Http/Controllers/Settings/CategoryController';
import Heading from '@/components/heading';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { cn, isSameUrl, resolveUrl } from '@/lib/utils';
import { edit as editAppearance } from '@/routes/appearance';
import { index as accountsIndex } from '@/actions/App/Http/Controllers/Settings/AccountController';
import { index as categoriesIndex } from '@/actions/App/Http/Controllers/Settings/CategoryController';
import { index as automationRulesIndex } from '@/actions/App/Http/Controllers/Settings/AutomationRuleController';
import { edit as editAccount } from '@/routes/account';
import { edit as editAppearance } from '@/routes/appearance';
import { edit as editDeleteAccount } from '@/routes/delete-account';
import { type NavItem } from '@/types';
import { Link } from '@inertiajs/react';

View File

@ -1,7 +1,7 @@
function ensureCryptoAvailable(): void {
if (!window.crypto || !window.crypto.subtle) {
throw new Error(
'Web Crypto API is not available. Please ensure you are using HTTPS or localhost.'
'Web Crypto API is not available. Please ensure you are using HTTPS or localhost.',
);
}
}
@ -14,13 +14,13 @@ export async function getKeyFromPassword(password: string): Promise<CryptoKey> {
encoder.encode(password),
'PBKDF2',
false,
['deriveBits', 'deriveKey']
['deriveBits', 'deriveKey'],
);
}
export async function getAESKeyFromPBKDF(
key: CryptoKey,
salt: Uint8Array
salt: Uint8Array,
): Promise<CryptoKey> {
ensureCryptoAvailable();
return await window.crypto.subtle.deriveKey(
@ -33,13 +33,13 @@ export async function getAESKeyFromPBKDF(
key,
{ name: 'AES-GCM', length: 256 },
true,
['encrypt', 'decrypt']
['encrypt', 'decrypt'],
);
}
export async function encrypt(
plaintext: string,
key: CryptoKey
key: CryptoKey,
): Promise<{ encrypted: string; iv: string }> {
ensureCryptoAvailable();
const encoder = new TextEncoder();
@ -53,7 +53,7 @@ export async function encrypt(
iv,
},
key,
data
data,
);
return {
@ -65,7 +65,7 @@ export async function encrypt(
export async function decrypt(
encrypted: string,
key: CryptoKey,
iv: string
iv: string,
): Promise<string> {
ensureCryptoAvailable();
const encryptedBuffer = base64ToBuffer(encrypted);
@ -77,7 +77,7 @@ export async function decrypt(
iv: ivBuffer,
},
key,
encryptedBuffer
encryptedBuffer,
);
const decoder = new TextDecoder();
@ -122,7 +122,6 @@ export async function importKey(keyString: string): Promise<CryptoKey> {
keyBuffer,
{ name: 'AES-GCM', length: 256 },
true,
['encrypt', 'decrypt']
['encrypt', 'decrypt'],
);
}

View File

@ -41,4 +41,3 @@ export async function checkDatabaseVersion(): Promise<{
};
}
}

View File

@ -1,8 +1,8 @@
import Dexie, { type EntityTable } from 'dexie';
import type { Account, Bank } from '@/types/account';
import type { Category } from '@/types/category';
import type { AutomationRule } from '@/types/automation-rule';
import type { Transaction } from '@/services/transaction-sync';
import type { Account, Bank } from '@/types/account';
import type { AutomationRule } from '@/types/automation-rule';
import type { Category } from '@/types/category';
import Dexie, { type EntityTable } from 'dexie';
export interface SyncMetadata {
key: string;
@ -38,4 +38,3 @@ db.version(3).stores({
});
export { db };

View File

@ -1,5 +1,10 @@
import {
DateFormat,
type ColumnMapping,
type ParsedRow,
type ParsedTransaction,
} from '@/types/import';
import * as XLSX from 'xlsx';
import { DateFormat, type ParsedRow, type ColumnMapping, type ParsedTransaction } from '@/types/import';
function detectHeaderRow(columns: unknown[][]): number {
if (!columns || columns.length === 0) {
@ -7,21 +12,28 @@ function detectHeaderRow(columns: unknown[][]): number {
}
const firstRowWithValue = columns.map((column) =>
column.findIndex((cell) => cell !== undefined && cell !== null && String(cell).length > 1)
column.findIndex(
(cell) =>
cell !== undefined && cell !== null && String(cell).length > 1,
),
);
const percentages = [0.95, 0.75];
for (const minPercentage of percentages) {
const uniqueRows = [...new Set(firstRowWithValue)].sort((a, b) => a - b);
const uniqueRows = [...new Set(firstRowWithValue)].sort(
(a, b) => a - b,
);
for (const rowNumber of uniqueRows) {
if (rowNumber === -1) continue;
const columnsWithValues = columns.filter((column) => {
return column[rowNumber] !== undefined &&
column[rowNumber] !== null &&
String(column[rowNumber]).length > 1;
return (
column[rowNumber] !== undefined &&
column[rowNumber] !== null &&
String(column[rowNumber]).length > 1
);
}).length;
if (columnsWithValues / columns.length >= minPercentage) {
@ -33,7 +45,12 @@ function detectHeaderRow(columns: unknown[][]): number {
return 0;
}
export async function parseFile(file: File): Promise<{ headers: string[]; data: ParsedRow[]; columns: unknown[][]; headerRowIndex: number }> {
export async function parseFile(file: File): Promise<{
headers: string[];
data: ParsedRow[];
columns: unknown[][];
headerRowIndex: number;
}> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
@ -49,48 +66,99 @@ export async function parseFile(file: File): Promise<{ headers: string[]; data:
const firstSheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[firstSheetName];
const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1 }) as unknown[][];
const jsonData = XLSX.utils.sheet_to_json(worksheet, {
header: 1,
}) as unknown[][];
if (jsonData.length === 0) {
reject(new Error('File is empty'));
return;
}
const maxColumns = Math.max(...jsonData.map(row => Array.isArray(row) ? row.length : 0));
const maxColumns = Math.max(
...jsonData.map((row) =>
Array.isArray(row) ? row.length : 0,
),
);
const columns: unknown[][] = [];
for (let colIndex = 0; colIndex < maxColumns; colIndex++) {
const columnData = jsonData.map(row =>
Array.isArray(row) ? row[colIndex] : undefined
const columnData = jsonData.map((row) =>
Array.isArray(row) ? row[colIndex] : undefined,
);
columns.push(columnData);
}
const headerRowIndex = detectHeaderRow(columns);
const letters = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z'];
const letters = [
'A',
'B',
'C',
'D',
'E',
'F',
'G',
'H',
'I',
'J',
'K',
'L',
'M',
'N',
'O',
'P',
'Q',
'R',
'S',
'T',
'U',
'V',
'W',
'X',
'Y',
'Z',
];
const headers = columns.map((column, index) => {
const headerValue = column[headerRowIndex];
const headerStr = String(headerValue || '').trim();
if (headerStr && headerStr.length > 1 && isNaN(Number(headerStr))) {
if (
headerStr &&
headerStr.length > 1 &&
isNaN(Number(headerStr))
) {
return headerStr;
}
return letters[index] || `Column ${index + 1}`;
});
const dataRows = jsonData.slice(headerRowIndex + 1) as unknown[][];
const dataRows = jsonData.slice(
headerRowIndex + 1,
) as unknown[][];
const parsedData: ParsedRow[] = dataRows
.filter(row => Array.isArray(row) && row.some(cell => cell !== null && cell !== undefined && cell !== ''))
.map(row => {
.filter(
(row) =>
Array.isArray(row) &&
row.some(
(cell) =>
cell !== null &&
cell !== undefined &&
cell !== '',
),
)
.map((row) => {
const obj: ParsedRow = {};
headers.forEach((header, index) => {
if (header) {
const value = row[index];
obj[header] = (value === null || value === undefined) ? null : value as string | number;
obj[header] =
value === null || value === undefined
? null
: (value as string | number);
}
});
return obj;
@ -110,12 +178,19 @@ export async function parseFile(file: File): Promise<{ headers: string[]; data:
});
}
export function autoDetectDateFormat(data: ParsedRow[], dateColumnName: string): DateFormat | null {
export function autoDetectDateFormat(
data: ParsedRow[],
dateColumnName: string,
): DateFormat | null {
if (!data || data.length === 0 || !dateColumnName) {
return null;
}
const formats = [DateFormat.YearMonthDay, DateFormat.DayMonthYear, DateFormat.MonthDayYear];
const formats = [
DateFormat.YearMonthDay,
DateFormat.DayMonthYear,
DateFormat.MonthDayYear,
];
const sampleSize = Math.min(10, data.length);
const scores: Record<DateFormat, number> = {
[DateFormat.YearMonthDay]: 0,
@ -141,7 +216,7 @@ export function autoDetectDateFormat(data: ParsedRow[], dateColumnName: string):
return null;
}
const bestFormat = formats.find(format => scores[format] === maxScore);
const bestFormat = formats.find((format) => scores[format] === maxScore);
if (maxScore >= sampleSize * 0.8) {
return bestFormat || null;
@ -161,16 +236,43 @@ export function autoDetectColumns(headers: string[]): ColumnMapping {
return mapping;
}
const lowerHeaders = headers.map(h => {
const lowerHeaders = headers.map((h) => {
if (h === null || h === undefined) {
return '';
}
return String(h).toLowerCase();
});
const datePatterns = ['date', 'transaction date', 'fecha', 'transaction_date', 'trans date', 'trans_date', 'f. valor'];
const descriptionPatterns = ['description', 'desc', 'descripcion', 'concept', 'concepto', 'details', 'detalles', 'memo', 'descripción'];
const amountPatterns = ['amount', 'monto', 'value', 'valor', 'total', 'importe', 'quantity', 'cantidad'];
const datePatterns = [
'date',
'transaction date',
'fecha',
'transaction_date',
'trans date',
'trans_date',
'f. valor',
];
const descriptionPatterns = [
'description',
'desc',
'descripcion',
'concept',
'concepto',
'details',
'detalles',
'memo',
'descripción',
];
const amountPatterns = [
'amount',
'monto',
'value',
'valor',
'total',
'importe',
'quantity',
'cantidad',
];
for (let i = 0; i < lowerHeaders.length; i++) {
const header = lowerHeaders[i];
@ -180,15 +282,21 @@ export function autoDetectColumns(headers: string[]): ColumnMapping {
continue;
}
if (!mapping.transaction_date && datePatterns.some(p => header.includes(p))) {
if (
!mapping.transaction_date &&
datePatterns.some((p) => header.includes(p))
) {
mapping.transaction_date = originalHeader;
}
if (!mapping.description && descriptionPatterns.some(p => header.includes(p))) {
if (
!mapping.description &&
descriptionPatterns.some((p) => header.includes(p))
) {
mapping.description = originalHeader;
}
if (!mapping.amount && amountPatterns.some(p => header.includes(p))) {
if (!mapping.amount && amountPatterns.some((p) => header.includes(p))) {
mapping.amount = originalHeader;
}
}
@ -196,7 +304,10 @@ export function autoDetectColumns(headers: string[]): ColumnMapping {
return mapping;
}
export function parseDate(dateStr: string | number, format: DateFormat): Date | null {
export function parseDate(
dateStr: string | number,
format: DateFormat,
): Date | null {
if (!dateStr) {
return null;
}
@ -209,19 +320,26 @@ export function parseDate(dateStr: string | number, format: DateFormat): Date |
}
let str = String(dateStr).trim();
str = str.replace(/\//g, '-').replace(/\./g, '-').replace(/[^\d-]/g, '');
str = str
.replace(/\//g, '-')
.replace(/\./g, '-')
.replace(/[^\d-]/g, '');
let year: number | undefined, month: number | undefined, day: number | undefined;
let year: number | undefined,
month: number | undefined,
day: number | undefined;
if (str.length === 5) {
const dateRegex = /^(\d{1,2})-(\d{1,2})$/;
const dateArray = dateRegex.exec(str);
if (dateArray) {
month = Number(dateArray[format === DateFormat.DayMonthYear ? 2 : 1]);
month = Number(
dateArray[format === DateFormat.DayMonthYear ? 2 : 1],
);
day = Number(dateArray[format === DateFormat.DayMonthYear ? 1 : 2]);
}
} else {
const parts = str.split('-').filter(p => p.length > 0);
const parts = str.split('-').filter((p) => p.length > 0);
if (parts.length === 3) {
switch (format) {
@ -255,7 +373,12 @@ export function parseDate(dateStr: string | number, format: DateFormat): Date |
const date = new Date(year, month - 1, day);
if (isNaN(date.getTime()) || date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
if (
isNaN(date.getTime()) ||
date.getFullYear() !== year ||
date.getMonth() !== month - 1 ||
date.getDate() !== day
) {
return null;
}
@ -276,9 +399,12 @@ export function parseAmount(amountStr: string | number): number | null {
const dotPos = str.lastIndexOf('.');
const commaPos = str.lastIndexOf(',');
const decimalSep = dotPos > commaPos && dotPos !== -1 ? dotPos :
commaPos > dotPos && commaPos !== -1 ? commaPos :
-1;
const decimalSep =
dotPos > commaPos && dotPos !== -1
? dotPos
: commaPos > dotPos && commaPos !== -1
? commaPos
: -1;
if (decimalSep !== -1) {
const integerPart = str.substring(0, decimalSep).replace(/[^\d]/g, '');
@ -300,14 +426,17 @@ export function parseAmount(amountStr: string | number): number | null {
export function validateTransaction(
row: ParsedRow,
mapping: ColumnMapping,
dateFormat: DateFormat
dateFormat: DateFormat,
): { isValid: boolean; errors: string[] } {
const errors: string[] = [];
if (!mapping.transaction_date || !row[mapping.transaction_date]) {
errors.push('Missing transaction date');
} else {
const date = parseDate(row[mapping.transaction_date] as string | number, dateFormat);
const date = parseDate(
row[mapping.transaction_date] as string | number,
dateFormat,
);
if (!date) {
errors.push('Invalid date format');
}
@ -317,7 +446,11 @@ export function validateTransaction(
errors.push('Missing description');
}
if (!mapping.amount || row[mapping.amount] === null || row[mapping.amount] === undefined) {
if (
!mapping.amount ||
row[mapping.amount] === null ||
row[mapping.amount] === undefined
) {
errors.push('Missing amount');
} else {
const amount = parseAmount(row[mapping.amount] as string | number);
@ -335,7 +468,7 @@ export function validateTransaction(
export function convertRowsToTransactions(
rows: ParsedRow[],
mapping: ColumnMapping,
dateFormat: DateFormat
dateFormat: DateFormat,
): ParsedTransaction[] {
const results: ParsedTransaction[] = [];
@ -346,7 +479,10 @@ export function convertRowsToTransactions(
continue;
}
const date = parseDate(row[mapping.transaction_date!] as string | number, dateFormat);
const date = parseDate(
row[mapping.transaction_date!] as string | number,
dateFormat,
);
const amount = parseAmount(row[mapping.amount!] as string | number);
const description = String(row[mapping.description!] || '').trim();
@ -366,4 +502,3 @@ export function convertRowsToTransactions(
return results;
}

View File

@ -7,7 +7,10 @@ interface ImportConfig {
const STORAGE_KEY_PREFIX = 'import_config_account_';
export function saveImportConfig(accountId: number, config: ImportConfig): void {
export function saveImportConfig(
accountId: number,
config: ImportConfig,
): void {
try {
const key = `${STORAGE_KEY_PREFIX}${accountId}`;
localStorage.setItem(key, JSON.stringify(config));
@ -46,4 +49,3 @@ export function clearImportConfig(accountId: number): void {
console.error('Failed to clear import configuration:', error);
}
}

View File

@ -23,4 +23,3 @@ export function clearKey(): void {
export function isKeyPersistent(): boolean {
return localStorage.getItem(ENCRYPTION_KEY_NAME) !== null;
}

View File

@ -107,9 +107,15 @@ export function evaluateRules(
consoleDebug('[Rule Engine] Rule JSON:', rule.rules_json);
const normalizedRulesJson = normalizeRuleJson(rule.rules_json);
consoleDebug('[Rule Engine] Normalized Rule JSON:', normalizedRulesJson);
consoleDebug(
'[Rule Engine] Normalized Rule JSON:',
normalizedRulesJson,
);
const result = jsonLogic.apply(normalizedRulesJson, transactionData);
const result = jsonLogic.apply(
normalizedRulesJson,
transactionData,
);
consoleDebug(`[Rule Engine] Rule #${rule.id} result:`, result);

View File

@ -1,7 +1,12 @@
import axios from 'axios';
import { db } from './dexie-db';
export type StoreName = 'categories' | 'accounts' | 'banks' | 'automation_rules' | 'transactions';
export type StoreName =
| 'categories'
| 'accounts'
| 'banks'
| 'automation_rules'
| 'transactions';
export interface IndexedDBRecord {
id: number | string;
@ -203,7 +208,9 @@ export class SyncManager {
const existing = await table.get(id);
if (!existing) {
throw new Error(`Record ${id} not found in ${this.options.storeName}`);
throw new Error(
`Record ${id} not found in ${this.options.storeName}`,
);
}
const timestamp = new Date().toISOString();
@ -236,16 +243,15 @@ export class SyncManager {
async getAll<T extends IndexedDBRecord>(): Promise<T[]> {
const table = db[this.options.storeName];
return await table.toArray() as T[];
return (await table.toArray()) as T[];
}
async getById<T extends IndexedDBRecord>(id: number): Promise<T | null> {
const table = db[this.options.storeName];
return (await table.get(id) as T) || null;
return ((await table.get(id)) as T) || null;
}
isSyncing(): boolean {
return this.syncInProgress;
}
}

View File

@ -1,5 +1,4 @@
// Components
import TextLink from '@/components/text-link';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import AuthLayout from '@/layouts/auth-layout';
@ -39,7 +38,7 @@ export default function VerifyEmail({ status }: { status?: string }) {
href={logout()}
as="button"
onClick={handleLogout}
className="mx-auto block text-sm text-blue-600 hover:text-blue-800 underline"
className="mx-auto block text-sm text-blue-600 underline hover:text-blue-800"
>
Log out
</Link>

View File

@ -98,7 +98,8 @@ function AutomationRuleActions({ rule }: { rule: AutomationRule }) {
export default function AutomationRules() {
const { isKeySet } = useEncryptionKey();
const rawRules = useLiveQuery(() => db.automation_rules.toArray(), []) || [];
const rawRules =
useLiveQuery(() => db.automation_rules.toArray(), []) || [];
const rules = rawRules.map((rule) => ({
...rule,
rules_json:

View File

@ -153,11 +153,7 @@ export default function Categories() {
{
id: 'actions',
enableHiding: false,
cell: ({ row }) => (
<CategoryActions
category={row.original}
/>
),
cell: ({ row }) => <CategoryActions category={row.original} />,
},
];

View File

@ -8,9 +8,10 @@ import {
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table';
import { useLiveQuery } from 'dexie-react-hooks';
import { isWithinInterval, parseISO } from 'date-fns';
import { useLiveQuery } from 'dexie-react-hooks';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { toast } from 'sonner';
import { index as transactionsIndex } from '@/actions/App/Http/Controllers/TransactionController';
import HeadingSmall from '@/components/heading-small';
@ -171,7 +172,10 @@ export default function Transactions({ categories, accounts, banks }: Props) {
try {
key = await importKey(keyString);
} catch (error) {
console.error('Failed to import encryption key:', error);
console.error(
'Failed to import encryption key:',
error,
);
}
}
@ -189,7 +193,10 @@ export default function Transactions({ categories, accounts, banks }: Props) {
transaction.description_iv,
);
if (transaction.notes && transaction.notes_iv) {
if (
transaction.notes &&
transaction.notes_iv
) {
decryptedNotes = await decrypt(
transaction.notes,
key,
@ -205,7 +212,9 @@ export default function Transactions({ categories, accounts, banks }: Props) {
}
}
const account = accountsMap.get(transaction.account_id);
const account = accountsMap.get(
transaction.account_id,
);
const category = transaction.category_id
? categoriesMap.get(transaction.category_id)
: null;
@ -404,115 +413,127 @@ export default function Transactions({ categories, accounts, banks }: Props) {
return filteredTransactions.slice(0, displayedCount);
}, [filteredTransactions, displayedCount]);
const handleReEvaluateRules = useCallback(async (transaction: DecryptedTransaction) => {
consoleDebug('=== Re-evaluating rules for single transaction ===');
consoleDebug('Transaction:', {
id: transaction.id,
description: transaction.decryptedDescription,
amount: transaction.amount,
currentCategory: transaction.category?.name || 'None',
});
const handleReEvaluateRules = useCallback(
async (transaction: DecryptedTransaction) => {
consoleDebug('=== Re-evaluating rules for single transaction ===');
consoleDebug('Transaction:', {
id: transaction.id,
description: transaction.decryptedDescription,
amount: transaction.amount,
currentCategory: transaction.category?.name || 'None',
});
setIsReEvaluating(true);
try {
const keyString = getStoredKey();
if (!keyString || !isKeySet) {
consoleDebug('❌ Encryption key not set');
console.error('Encryption key not set');
return;
}
consoleDebug('✓ Encryption key found');
const key = await importKey(keyString);
const rules = await automationRuleSyncService.getAll();
consoleDebug(`Found ${rules.length} automation rules`);
if (rules.length === 0) {
consoleDebug('❌ No rules to evaluate');
return;
}
consoleDebug('Evaluating rules against transaction...');
const result = evaluateRules(
transaction,
rules,
categories,
accounts,
banks,
);
consoleDebug('Rule evaluation result:', result);
if (result) {
consoleDebug('✓ Rule matched! Applying changes...');
let finalNotes = transaction.notes;
let finalNotesIv = transaction.notes_iv;
if (result.note && result.noteIv) {
consoleDebug('Adding note from rule');
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;
consoleDebug('Combined existing notes with rule note');
} else {
finalNotes = result.note;
finalNotesIv = result.noteIv;
consoleDebug('Set rule note as new note');
}
}
const updateData = {
category_id: result.categoryId,
notes: finalNotes,
notes_iv: finalNotesIv,
};
consoleDebug('Updating transaction with:', updateData);
await transactionSyncService.update(transaction.id, updateData);
consoleDebug('✓ Transaction updated in IndexedDB');
const selectedCategory = result.categoryId
? categories.find((c) => c.id === result.categoryId) || null
: null;
let decryptedNotes = transaction.decryptedNotes;
if (finalNotes && finalNotesIv) {
decryptedNotes = await decrypt(
finalNotes,
key,
finalNotesIv,
setIsReEvaluating(true);
try {
const keyString = getStoredKey();
if (!keyString || !isKeySet) {
consoleDebug('❌ Encryption key not set');
console.error('Encryption key not set');
toast.error(
'Please unlock your encryption key to re-evaluate rules',
);
return;
}
consoleDebug('✓ Encryption key found');
const key = await importKey(keyString);
const rules = await automationRuleSyncService.getAll();
consoleDebug(`Found ${rules.length} automation rules`);
if (rules.length === 0) {
consoleDebug('❌ No rules to evaluate');
return;
}
const updatedTransaction = {
...transaction,
category_id: result.categoryId,
category: selectedCategory,
notes: finalNotes,
notes_iv: finalNotesIv,
decryptedNotes,
};
consoleDebug('Updating UI state with:', {
id: updatedTransaction.id,
newCategory: selectedCategory?.name || 'None',
hasNotes: !!decryptedNotes,
});
consoleDebug('Evaluating rules against transaction...');
const result = evaluateRules(
transaction,
rules,
categories,
accounts,
banks,
);
updateTransaction(updatedTransaction);
consoleDebug('✓ UI state updated successfully');
} else {
consoleDebug('❌ No rules matched this transaction');
consoleDebug('Rule evaluation result:', result);
if (result) {
consoleDebug('✓ Rule matched! Applying changes...');
let finalNotes = transaction.notes;
let finalNotesIv = transaction.notes_iv;
if (result.note && result.noteIv) {
consoleDebug('Adding note from rule');
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;
consoleDebug(
'Combined existing notes with rule note',
);
} else {
finalNotes = result.note;
finalNotesIv = result.noteIv;
consoleDebug('Set rule note as new note');
}
}
const updateData = {
category_id: result.categoryId,
notes: finalNotes,
notes_iv: finalNotesIv,
};
consoleDebug('Updating transaction with:', updateData);
await transactionSyncService.update(
transaction.id,
updateData,
);
consoleDebug('✓ Transaction updated in IndexedDB');
const selectedCategory = result.categoryId
? categories.find((c) => c.id === result.categoryId) ||
null
: null;
let decryptedNotes = transaction.decryptedNotes;
if (finalNotes && finalNotesIv) {
decryptedNotes = await decrypt(
finalNotes,
key,
finalNotesIv,
);
}
const updatedTransaction = {
...transaction,
category_id: result.categoryId,
category: selectedCategory,
notes: finalNotes,
notes_iv: finalNotesIv,
decryptedNotes,
};
consoleDebug('Updating UI state with:', {
id: updatedTransaction.id,
newCategory: selectedCategory?.name || 'None',
hasNotes: !!decryptedNotes,
});
updateTransaction(updatedTransaction);
consoleDebug('✓ UI state updated successfully');
} else {
consoleDebug('❌ No rules matched this transaction');
}
} catch (error) {
consoleDebug('❌ Error during re-evaluation:', error);
console.error('Failed to re-evaluate rules:', error);
} finally {
setIsReEvaluating(false);
consoleDebug('=== Re-evaluation complete ===');
}
} catch (error) {
consoleDebug('❌ Error during re-evaluation:', error);
console.error('Failed to re-evaluate rules:', error);
} finally {
setIsReEvaluating(false);
consoleDebug('=== Re-evaluation complete ===');
}
}, [isKeySet, categories, accounts, banks, updateTransaction]);
},
[isKeySet, categories, accounts, banks, updateTransaction],
);
async function handleBulkReEvaluateRules() {
const selectedIds = Object.keys(rowSelection);
@ -530,6 +551,9 @@ export default function Transactions({ categories, accounts, banks }: Props) {
if (!keyString || !isKeySet) {
consoleDebug('❌ Encryption key not set');
console.error('Encryption key not set');
toast.error(
'Please unlock your encryption key to re-evaluate rules',
);
return;
}
consoleDebug('✓ Encryption key found');
@ -777,6 +801,13 @@ export default function Transactions({ categories, accounts, banks }: Props) {
}
async function handleBulkCategoryChange(categoryId: number | null) {
if (!isKeySet) {
toast.error(
'Please unlock your encryption key to update transactions',
);
return;
}
const selectedIds = Object.keys(rowSelection);
if (selectedIds.length === 0) {
return;

View File

@ -45,4 +45,3 @@ class AccountSyncService {
}
export const accountSyncService = new AccountSyncService();

View File

@ -41,4 +41,3 @@ class BankSyncService {
}
export const bankSyncService = new BankSyncService();

View File

@ -45,4 +45,3 @@ class CategorySyncService {
}
export const categorySyncService = new CategorySyncService();

View File

@ -165,7 +165,7 @@ class TransactionSyncService {
async deleteMany(ids: string[]): Promise<void> {
const timestamp = new Date().toISOString();
for (const id of ids) {
const transaction = await this.getById(id);
if (!transaction) {

View File

@ -48,4 +48,3 @@ export function formatAccountType(type: AccountType): string {
};
return typeMap[type] || type;
}

View File

@ -60,4 +60,3 @@ export function formatRuleActions(rule: AutomationRule): string {
return 'Add note';
}

View File

@ -55,25 +55,75 @@ export function getCategoryColorClasses(color: CategoryColor): {
text: string;
} {
const colorMap: Record<CategoryColor, { bg: string; text: string }> = {
red: { bg: 'bg-red-100 dark:bg-red-700', text: 'text-red-700 dark:text-red-100' },
orange: { bg: 'bg-orange-100 dark:bg-orange-700', text: 'text-orange-700 dark:text-orange-100' },
amber: { bg: 'bg-amber-100 dark:bg-amber-700', text: 'text-amber-700 dark:text-amber-100' },
yellow: { bg: 'bg-yellow-100 dark:bg-yellow-700', text: 'text-yellow-700 dark:text-yellow-100' },
lime: { bg: 'bg-lime-100 dark:bg-lime-700', text: 'text-lime-700 dark:text-lime-100' },
green: { bg: 'bg-green-100 dark:bg-green-700', text: 'text-green-700 dark:text-green-100' },
emerald: { bg: 'bg-emerald-100 dark:bg-emerald-700', text: 'text-emerald-700 dark:text-emerald-100' },
teal: { bg: 'bg-teal-100 dark:bg-teal-700', text: 'text-teal-700 dark:text-teal-100' },
cyan: { bg: 'bg-cyan-100 dark:bg-cyan-700', text: 'text-cyan-700 dark:text-cyan-100' },
sky: { bg: 'bg-sky-100 dark:bg-sky-700', text: 'text-sky-700 dark:text-sky-100' },
blue: { bg: 'bg-blue-100 dark:bg-blue-700', text: 'text-blue-700 dark:text-blue-100' },
indigo: { bg: 'bg-indigo-100 dark:bg-indigo-700', text: 'text-indigo-700 dark:text-indigo-100' },
violet: { bg: 'bg-violet-100 dark:bg-violet-700', text: 'text-violet-700 dark:text-violet-100' },
purple: { bg: 'bg-purple-100 dark:bg-purple-700', text: 'text-purple-700 dark:text-purple-100' },
fuchsia: { bg: 'bg-fuchsia-100 dark:bg-fuchsia-700', text: 'text-fuchsia-700 dark:text-fuchsia-100' },
pink: { bg: 'bg-pink-100 dark:bg-pink-700', text: 'text-pink-700 dark:text-pink-100' },
rose: { bg: 'bg-rose-100 dark:bg-rose-700', text: 'text-rose-700 dark:text-rose-100' },
red: {
bg: 'bg-red-100 dark:bg-red-700',
text: 'text-red-700 dark:text-red-100',
},
orange: {
bg: 'bg-orange-100 dark:bg-orange-700',
text: 'text-orange-700 dark:text-orange-100',
},
amber: {
bg: 'bg-amber-100 dark:bg-amber-700',
text: 'text-amber-700 dark:text-amber-100',
},
yellow: {
bg: 'bg-yellow-100 dark:bg-yellow-700',
text: 'text-yellow-700 dark:text-yellow-100',
},
lime: {
bg: 'bg-lime-100 dark:bg-lime-700',
text: 'text-lime-700 dark:text-lime-100',
},
green: {
bg: 'bg-green-100 dark:bg-green-700',
text: 'text-green-700 dark:text-green-100',
},
emerald: {
bg: 'bg-emerald-100 dark:bg-emerald-700',
text: 'text-emerald-700 dark:text-emerald-100',
},
teal: {
bg: 'bg-teal-100 dark:bg-teal-700',
text: 'text-teal-700 dark:text-teal-100',
},
cyan: {
bg: 'bg-cyan-100 dark:bg-cyan-700',
text: 'text-cyan-700 dark:text-cyan-100',
},
sky: {
bg: 'bg-sky-100 dark:bg-sky-700',
text: 'text-sky-700 dark:text-sky-100',
},
blue: {
bg: 'bg-blue-100 dark:bg-blue-700',
text: 'text-blue-700 dark:text-blue-100',
},
indigo: {
bg: 'bg-indigo-100 dark:bg-indigo-700',
text: 'text-indigo-700 dark:text-indigo-100',
},
violet: {
bg: 'bg-violet-100 dark:bg-violet-700',
text: 'text-violet-700 dark:text-violet-100',
},
purple: {
bg: 'bg-purple-100 dark:bg-purple-700',
text: 'text-purple-700 dark:text-purple-100',
},
fuchsia: {
bg: 'bg-fuchsia-100 dark:bg-fuchsia-700',
text: 'text-fuchsia-700 dark:text-fuchsia-100',
},
pink: {
bg: 'bg-pink-100 dark:bg-pink-700',
text: 'text-pink-700 dark:text-pink-100',
},
rose: {
bg: 'bg-rose-100 dark:bg-rose-700',
text: 'text-rose-700 dark:text-rose-100',
},
};
return colorMap[color];
}

View File

@ -47,5 +47,3 @@ export interface ImportState {
dateFormatDetected: boolean;
transactions: ParsedTransaction[];
}

View File

@ -34,4 +34,3 @@ export interface TransactionFilters {
accountIds: number[];
searchText: string;
}