Add some toast notifications
This commit is contained in:
parent
5c39060123
commit
e13a19bdde
|
|
@ -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
|
||||
|
|
|
|||
3
bun.lock
3
bun.lock
|
|
@ -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=="],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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(' ');
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -63,7 +63,6 @@ export function BulkActionsBar({
|
|||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuGroup>
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={onReEvaluateRules}
|
||||
disabled={isUpdating}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -41,4 +41,3 @@ export async function checkDatabaseVersion(): Promise<{
|
|||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,4 +23,3 @@ export function clearKey(): void {
|
|||
export function isKeyPersistent(): boolean {
|
||||
return localStorage.getItem(ENCRYPTION_KEY_NAME) !== null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -153,11 +153,7 @@ export default function Categories() {
|
|||
{
|
||||
id: 'actions',
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => (
|
||||
<CategoryActions
|
||||
category={row.original}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => <CategoryActions category={row.original} />,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -45,4 +45,3 @@ class AccountSyncService {
|
|||
}
|
||||
|
||||
export const accountSyncService = new AccountSyncService();
|
||||
|
||||
|
|
|
|||
|
|
@ -41,4 +41,3 @@ class BankSyncService {
|
|||
}
|
||||
|
||||
export const bankSyncService = new BankSyncService();
|
||||
|
||||
|
|
|
|||
|
|
@ -45,4 +45,3 @@ class CategorySyncService {
|
|||
}
|
||||
|
||||
export const categorySyncService = new CategorySyncService();
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -48,4 +48,3 @@ export function formatAccountType(type: AccountType): string {
|
|||
};
|
||||
return typeMap[type] || type;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -60,4 +60,3 @@ export function formatRuleActions(rule: AutomationRule): string {
|
|||
|
||||
return 'Add note';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -47,5 +47,3 @@ export interface ImportState {
|
|||
dateFormatDetected: boolean;
|
||||
transactions: ParsedTransaction[];
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -34,4 +34,3 @@ export interface TransactionFilters {
|
|||
accountIds: number[];
|
||||
searchText: string;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue