1675 lines
63 KiB
TypeScript
1675 lines
63 KiB
TypeScript
import { useLocale } from '@/hooks/use-locale';
|
|
import { usePollJobStatus } from '@/hooks/use-poll-job-status';
|
|
import { __ } from '@/utils/i18n';
|
|
import { Head, router, usePage } from '@inertiajs/react';
|
|
import {
|
|
Cell,
|
|
Row,
|
|
SortingState,
|
|
VisibilityState,
|
|
flexRender,
|
|
getCoreRowModel,
|
|
useReactTable,
|
|
} from '@tanstack/react-table';
|
|
import { VirtualItem, Virtualizer } from '@tanstack/react-virtual';
|
|
import axios from 'axios';
|
|
import { format, getYear, parseISO } from 'date-fns';
|
|
import { ChevronRight } from 'lucide-react';
|
|
import {
|
|
createElement,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
import { toast } from 'sonner';
|
|
|
|
import {
|
|
bulk as reEvaluateBulk,
|
|
single as reEvaluateSingle,
|
|
status as reEvaluateStatus,
|
|
} from '@/actions/App/Http/Controllers/ReEvaluateTransactionRulesController';
|
|
import { index as transactionsIndex } from '@/actions/App/Http/Controllers/TransactionController';
|
|
import {
|
|
AutomateCategorizationDialog,
|
|
type AutomateCategorizationCandidate,
|
|
} from '@/components/automation-rules/automate-categorization-dialog';
|
|
import { PostSaveApplyRulePrompt } from '@/components/automation-rules/post-save-apply-rule-prompt';
|
|
import HeadingSmall from '@/components/heading-small';
|
|
import { BulkActionsBar } from '@/components/transactions/bulk-actions-bar';
|
|
import { EditTransactionDialog } from '@/components/transactions/edit-transaction-dialog';
|
|
import { TransactionActionsMenu } from '@/components/transactions/transaction-actions-menu';
|
|
import { createTransactionColumns } from '@/components/transactions/transaction-columns';
|
|
import { TransactionFilters as TransactionFiltersComponent } from '@/components/transactions/transaction-filters';
|
|
import { AiSparkleIcon } from '@/components/ui/ai-sparkle-icon';
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from '@/components/ui/alert-dialog';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import {
|
|
ContextMenu,
|
|
ContextMenuContent,
|
|
ContextMenuItem,
|
|
ContextMenuLabel,
|
|
ContextMenuTrigger,
|
|
} from '@/components/ui/context-menu';
|
|
import { DataTable } from '@/components/ui/data-table';
|
|
import { DataTablePagination } from '@/components/ui/data-table-pagination';
|
|
import { DataTableViewOptions } from '@/components/ui/data-table-view-options';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { Spinner } from '@/components/ui/spinner';
|
|
import { TableCell, TableRow } from '@/components/ui/table';
|
|
import AppSidebarLayout from '@/layouts/app/app-sidebar-layout';
|
|
import {
|
|
isCursorPaginatedResponse,
|
|
type CursorPaginatedResponse,
|
|
} from '@/lib/cursor-pagination';
|
|
import { consoleDebug } from '@/lib/debug';
|
|
import { isNewSince } from '@/lib/new-transactions';
|
|
import { captureEvent } from '@/lib/posthog';
|
|
import { getBulkDeleteConfirmationText } from '@/lib/transaction-delete-confirmation';
|
|
import { mergeReEvaluatedTransaction } from '@/lib/transaction-re-evaluation';
|
|
import { cn } from '@/lib/utils';
|
|
import { status as categorizationStatus } from '@/routes/ai/categorization';
|
|
import { store as storeConsent } from '@/routes/ai/consent';
|
|
import { transactionSyncService } from '@/services/transaction-sync';
|
|
import { type BreadcrumbItem, type SharedData } from '@/types';
|
|
import { type Account, type Bank } from '@/types/account';
|
|
import { type AutomationRule } from '@/types/automation-rule';
|
|
import {
|
|
type AiConsentResponse,
|
|
type CategorizationProgress,
|
|
} from '@/types/categorization';
|
|
import { type Category } from '@/types/category';
|
|
import { type Label } from '@/types/label';
|
|
import {
|
|
type DecryptedTransaction,
|
|
type TransactionFilters as Filters,
|
|
type ServerTransaction,
|
|
} from '@/types/transaction';
|
|
|
|
const breadcrumbs: BreadcrumbItem[] = [
|
|
{
|
|
title: 'Transactions',
|
|
href: transactionsIndex.url(),
|
|
},
|
|
];
|
|
|
|
interface AppliedFilters {
|
|
date_from: string | null;
|
|
date_to: string | null;
|
|
amount_min: number | null;
|
|
amount_max: number | null;
|
|
category_ids: string[];
|
|
account_ids: string[];
|
|
label_ids: string[];
|
|
creditor_name: string;
|
|
debtor_name: string;
|
|
category_source: string | null;
|
|
search: string;
|
|
sort: string;
|
|
}
|
|
|
|
interface Props {
|
|
transactions: CursorPaginatedResponse<ServerTransaction>;
|
|
appliedFilters: AppliedFilters;
|
|
categories: Category[];
|
|
accounts: Account[];
|
|
banks: Bank[];
|
|
labels: Label[];
|
|
automationRules: AutomationRule[];
|
|
hasAiConsent: boolean;
|
|
lastVisitAt: string | null;
|
|
}
|
|
|
|
const COLUMN_VISIBILITY_KEY = 'transactions-column-visibility';
|
|
|
|
function serverToClientFilters(applied: AppliedFilters): Filters {
|
|
return {
|
|
dateFrom: applied.date_from
|
|
? new Date(applied.date_from + 'T00:00:00')
|
|
: null,
|
|
dateTo: applied.date_to
|
|
? new Date(applied.date_to + 'T00:00:00')
|
|
: null,
|
|
amountMin: applied.amount_min,
|
|
amountMax: applied.amount_max,
|
|
categoryIds: applied.category_ids,
|
|
accountIds: applied.account_ids,
|
|
labelIds: applied.label_ids,
|
|
creditorName: applied.creditor_name,
|
|
debtorName: applied.debtor_name,
|
|
searchText: applied.search,
|
|
aiCategorizedOnly: applied.category_source === 'ai',
|
|
};
|
|
}
|
|
|
|
function clientFiltersToQueryParams(
|
|
filters: Filters,
|
|
sort: string,
|
|
): Record<string, string> {
|
|
const params: Record<string, string> = {};
|
|
|
|
if (filters.dateFrom) {
|
|
params.date_from = format(filters.dateFrom, 'yyyy-MM-dd');
|
|
}
|
|
if (filters.dateTo) {
|
|
params.date_to = format(filters.dateTo, 'yyyy-MM-dd');
|
|
}
|
|
if (filters.amountMin !== null) {
|
|
params.amount_min = filters.amountMin.toString();
|
|
}
|
|
if (filters.amountMax !== null) {
|
|
params.amount_max = filters.amountMax.toString();
|
|
}
|
|
if (filters.categoryIds.length > 0) {
|
|
params.category_ids = filters.categoryIds.join(',');
|
|
}
|
|
if (filters.accountIds.length > 0) {
|
|
params.account_ids = filters.accountIds.join(',');
|
|
}
|
|
if (filters.labelIds.length > 0) {
|
|
params.label_ids = filters.labelIds.join(',');
|
|
}
|
|
if (filters.creditorName) {
|
|
params.creditor_name = filters.creditorName;
|
|
}
|
|
if (filters.debtorName) {
|
|
params.debtor_name = filters.debtorName;
|
|
}
|
|
if (filters.searchText) {
|
|
params.search = filters.searchText;
|
|
}
|
|
if (filters.aiCategorizedOnly) {
|
|
params.category_source = 'ai';
|
|
}
|
|
if (sort !== '-transaction_date') {
|
|
params.sort = sort;
|
|
}
|
|
|
|
return params;
|
|
}
|
|
|
|
function clientFiltersToBackendFilters(
|
|
filters: Filters,
|
|
): Record<string, unknown> {
|
|
const result: Record<string, unknown> = {};
|
|
|
|
if (filters.dateFrom) {
|
|
result.date_from = format(filters.dateFrom, 'yyyy-MM-dd');
|
|
}
|
|
if (filters.dateTo) {
|
|
result.date_to = format(filters.dateTo, 'yyyy-MM-dd');
|
|
}
|
|
if (filters.amountMin !== null && filters.amountMin !== undefined) {
|
|
result.amount_min = filters.amountMin;
|
|
}
|
|
if (filters.amountMax !== null && filters.amountMax !== undefined) {
|
|
result.amount_max = filters.amountMax;
|
|
}
|
|
if (filters.categoryIds.length > 0) {
|
|
result.category_ids = filters.categoryIds;
|
|
}
|
|
if (filters.accountIds.length > 0) {
|
|
result.account_ids = filters.accountIds;
|
|
}
|
|
if (filters.labelIds.length > 0) {
|
|
result.label_ids = filters.labelIds;
|
|
}
|
|
if (filters.creditorName) {
|
|
result.creditor_name = filters.creditorName;
|
|
}
|
|
if (filters.debtorName) {
|
|
result.debtor_name = filters.debtorName;
|
|
}
|
|
if (filters.searchText) {
|
|
result.search = filters.searchText;
|
|
}
|
|
if (filters.aiCategorizedOnly) {
|
|
result.category_source = 'ai';
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function toDecryptedTransaction(tx: ServerTransaction): DecryptedTransaction {
|
|
return {
|
|
...tx,
|
|
transaction_date: String(tx.transaction_date).slice(0, 10),
|
|
decryptedDescription: tx.description_iv ? '' : tx.description,
|
|
decryptedNotes: tx.notes_iv ? null : tx.notes || null,
|
|
bank: tx.account?.bank || undefined,
|
|
labels: tx.labels || [],
|
|
label_ids: tx.labels?.map((l) => l.id) || [],
|
|
};
|
|
}
|
|
|
|
interface TransactionRowProps {
|
|
row: Row<DecryptedTransaction>;
|
|
virtualRow: VirtualItem;
|
|
rowVirtualizer: Virtualizer<HTMLDivElement, Element>;
|
|
isNew: boolean;
|
|
onEdit: (transaction: DecryptedTransaction) => void;
|
|
onReEvaluateRules: (transaction: DecryptedTransaction) => void;
|
|
onDelete: (transaction: DecryptedTransaction) => void;
|
|
}
|
|
|
|
function TransactionRowComponent({
|
|
row,
|
|
virtualRow,
|
|
rowVirtualizer,
|
|
isNew,
|
|
onEdit,
|
|
onReEvaluateRules,
|
|
onDelete,
|
|
}: TransactionRowProps) {
|
|
const transaction = row.original;
|
|
const [contextMenuOpen, setContextMenuOpen] = useState(false);
|
|
|
|
const handleRowClick = (event: React.MouseEvent<HTMLTableRowElement>) => {
|
|
const target = event.target as HTMLElement;
|
|
const isInteractive = target.closest(
|
|
'button, [role="checkbox"], [role="combobox"], [role="menuitem"], [role="option"], [cmdk-item], a, input, select, textarea, [data-radix-popper-content-wrapper]',
|
|
);
|
|
if (isInteractive) {
|
|
return;
|
|
}
|
|
onEdit(transaction);
|
|
};
|
|
|
|
return (
|
|
<ContextMenu key={row.id} onOpenChange={setContextMenuOpen}>
|
|
<ContextMenuTrigger asChild>
|
|
<TableRow
|
|
ref={rowVirtualizer.measureElement}
|
|
data-state={
|
|
(row.getIsSelected() || contextMenuOpen) && 'selected'
|
|
}
|
|
data-index={virtualRow.index}
|
|
className={cn(
|
|
'cursor-pointer',
|
|
isNew && 'bg-blue-500/5 dark:bg-blue-500/10',
|
|
)}
|
|
onClick={handleRowClick}
|
|
>
|
|
{row
|
|
.getVisibleCells()
|
|
.filter((cell: Cell<DecryptedTransaction, unknown>) => {
|
|
const meta = cell.column.columnDef.meta as
|
|
| {
|
|
isVirtual?: boolean;
|
|
cellClassName?: string;
|
|
cellStyle?: React.CSSProperties;
|
|
}
|
|
| undefined;
|
|
return !meta?.isVirtual;
|
|
})
|
|
.map(
|
|
(
|
|
cell: Cell<DecryptedTransaction, unknown>,
|
|
cellIndex: number,
|
|
) => {
|
|
const meta = cell.column.columnDef.meta as
|
|
| {
|
|
cellClassName?: string;
|
|
cellStyle?: React.CSSProperties;
|
|
}
|
|
| undefined;
|
|
return (
|
|
<TableCell
|
|
key={cell.id}
|
|
className={cn(
|
|
meta?.cellClassName,
|
|
'pt-2.5 pb-2',
|
|
isNew &&
|
|
cellIndex === 0 &&
|
|
'border-l-2 border-l-blue-500',
|
|
)}
|
|
style={meta?.cellStyle}
|
|
>
|
|
{flexRender(
|
|
cell.column.columnDef.cell,
|
|
cell.getContext(),
|
|
)}
|
|
</TableCell>
|
|
);
|
|
},
|
|
)}
|
|
</TableRow>
|
|
</ContextMenuTrigger>
|
|
<ContextMenuContent>
|
|
<ContextMenuLabel>{__('Actions')}</ContextMenuLabel>
|
|
<ContextMenuItem onClick={() => onEdit(transaction)}>
|
|
{__('Edit')}
|
|
</ContextMenuItem>
|
|
<ContextMenuItem onClick={() => onReEvaluateRules(transaction)}>
|
|
{__('Re-evaluate rules')}
|
|
</ContextMenuItem>
|
|
<ContextMenuItem
|
|
onClick={() => onDelete(transaction)}
|
|
variant="destructive"
|
|
>
|
|
{__('Delete')}
|
|
</ContextMenuItem>
|
|
</ContextMenuContent>
|
|
</ContextMenu>
|
|
);
|
|
}
|
|
|
|
function getInitialColumnVisibility(): VisibilityState {
|
|
const defaultVisibility = {
|
|
transaction_date: true,
|
|
account: true,
|
|
creditor_name: false,
|
|
debtor_name: false,
|
|
labels: true,
|
|
notes: false,
|
|
};
|
|
if (typeof window === 'undefined') {
|
|
return defaultVisibility;
|
|
}
|
|
|
|
try {
|
|
const stored = localStorage.getItem(COLUMN_VISIBILITY_KEY);
|
|
if (stored) {
|
|
return { ...defaultVisibility, ...JSON.parse(stored) };
|
|
}
|
|
} catch (error) {
|
|
console.error(
|
|
'Failed to load column visibility from localStorage:',
|
|
error,
|
|
);
|
|
}
|
|
return defaultVisibility;
|
|
}
|
|
|
|
function DateHeader({ date, colSpan }: { date: string; colSpan: number }) {
|
|
const parsedDate = parseISO(date);
|
|
const currentYear = getYear(new Date());
|
|
const transactionYear = getYear(parsedDate);
|
|
const formatString =
|
|
transactionYear === currentYear ? 'MMM d' : 'MMM d, yy';
|
|
|
|
return (
|
|
<tr className="hidden">
|
|
<td
|
|
colSpan={colSpan}
|
|
className="px-4 pt-2 text-sm text-muted-foreground"
|
|
>
|
|
{format(parsedDate, formatString)}
|
|
</td>
|
|
</tr>
|
|
);
|
|
}
|
|
|
|
export default function Transactions({
|
|
transactions: serverTransactions,
|
|
appliedFilters,
|
|
categories,
|
|
accounts,
|
|
banks,
|
|
labels: initialLabels,
|
|
automationRules,
|
|
hasAiConsent,
|
|
lastVisitAt,
|
|
}: Props) {
|
|
const locale = useLocale();
|
|
const { auth, features } = usePage<SharedData>().props;
|
|
const [aiConsentGiven, setAiConsentGiven] = useState(false);
|
|
const [aiConsentSaving, setAiConsentSaving] = useState(false);
|
|
const showAiConsentBanner =
|
|
auth.hasProPlan &&
|
|
features.aiConsentSettings &&
|
|
!hasAiConsent &&
|
|
!aiConsentGiven;
|
|
const [categorizingIds, setCategorizingIds] = useState<Set<string>>(
|
|
() => new Set(),
|
|
);
|
|
const categorizationToastRef = useRef<string | number | undefined>(
|
|
undefined,
|
|
);
|
|
const { start: startCategorizationPoll } = usePollJobStatus();
|
|
|
|
const [labels, setLabels] = useState<Label[]>(() => initialLabels);
|
|
|
|
useEffect(() => {
|
|
setLabels(initialLabels);
|
|
}, [initialLabels]);
|
|
|
|
const handleLabelCreated = useCallback((label: Label) => {
|
|
setLabels((previousLabels) => {
|
|
const nextLabels = previousLabels.filter(
|
|
(existingLabel) => existingLabel.id !== label.id,
|
|
);
|
|
|
|
return [...nextLabels, label].sort((a, b) =>
|
|
a.name.localeCompare(b.name),
|
|
);
|
|
});
|
|
}, []);
|
|
|
|
// Convert server transactions to DecryptedTransaction for column compatibility
|
|
const [allTransactions, setAllTransactions] = useState<
|
|
DecryptedTransaction[]
|
|
>(() => serverTransactions.data.map(toDecryptedTransaction));
|
|
const [nextCursor, setNextCursor] = useState<string | null>(
|
|
serverTransactions.next_cursor,
|
|
);
|
|
|
|
// Reset is handled explicitly via onSuccess in navigateWithFilters,
|
|
// and append is handled via onSuccess in handleLoadMore.
|
|
// No useEffect watching serverTransactions — avoids race conditions.
|
|
|
|
// Filter state from server-applied filters
|
|
const [filters, setFilters] = useState<Filters>(() =>
|
|
serverToClientFilters(appliedFilters),
|
|
);
|
|
const [sortParam, setSortParam] = useState(
|
|
appliedFilters.sort || '-transaction_date',
|
|
);
|
|
|
|
// Frozen at mount so per-row "new" marks stay stable for the whole visit.
|
|
// The marker is server-side (per user), so it's shared across devices; the
|
|
// controller advances it on load using the newest created_at it served.
|
|
const [lastVisitAtMount] = useState<string | null>(() => lastVisitAt);
|
|
|
|
// Sync filter state when appliedFilters prop changes
|
|
useEffect(() => {
|
|
setFilters(serverToClientFilters(appliedFilters));
|
|
setSortParam(appliedFilters.sort || '-transaction_date');
|
|
}, [appliedFilters]);
|
|
|
|
// Derive sorting state for TanStack Table from sort param
|
|
const sorting = useMemo<SortingState>(() => {
|
|
const desc = sortParam.startsWith('-');
|
|
const column = sortParam.replace(/^-/, '');
|
|
return [{ id: column, desc }];
|
|
}, [sortParam]);
|
|
|
|
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
|
|
getInitialColumnVisibility(),
|
|
);
|
|
const [rowSelection, setRowSelection] = useState({});
|
|
const [editTransaction, setEditTransaction] =
|
|
useState<DecryptedTransaction | null>(null);
|
|
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
|
const [deleteTransaction, setDeleteTransaction] =
|
|
useState<DecryptedTransaction | null>(null);
|
|
const [isBulkDeleteMode, setIsBulkDeleteMode] = useState(false);
|
|
const [updateBalanceOnDelete, setUpdateBalanceOnDelete] = useState(true);
|
|
const [bulkDeleteConfirmation, setBulkDeleteConfirmation] = useState('');
|
|
const [isDeleting, setIsDeleting] = useState(false);
|
|
const [isBulkDeleting, setIsBulkDeleting] = useState(false);
|
|
const [isBulkUpdating, setIsBulkUpdating] = useState(false);
|
|
const [isReEvaluating, setIsReEvaluating] = useState(false);
|
|
const [automateDialogOpen, setAutomateDialogOpen] = useState(false);
|
|
const [automateCandidate, setAutomateCandidate] =
|
|
useState<AutomateCategorizationCandidate | null>(null);
|
|
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
|
const [isSelectingAll, setIsSelectingAll] = useState(false);
|
|
const [isNavigating, setIsNavigating] = useState(false);
|
|
|
|
// Debounce timer ref for search
|
|
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
// Navigate to server with new filters/sort
|
|
const navigateWithFilters = useCallback(
|
|
(newFilters: Filters, newSort: string, debounceMs = 0) => {
|
|
if (searchTimerRef.current) {
|
|
clearTimeout(searchTimerRef.current);
|
|
}
|
|
|
|
const doNavigate = () => {
|
|
const params = clientFiltersToQueryParams(newFilters, newSort);
|
|
setIsNavigating(true);
|
|
setRowSelection({});
|
|
setIsSelectingAll(false);
|
|
router.visit(transactionsIndex({ query: params }).url, {
|
|
preserveScroll: true,
|
|
preserveState: true,
|
|
onSuccess: (page) => {
|
|
const txns = (page.props as { transactions?: unknown })
|
|
.transactions;
|
|
|
|
if (
|
|
!isCursorPaginatedResponse<ServerTransaction>(txns)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
setAllTransactions(
|
|
txns.data.map(toDecryptedTransaction),
|
|
);
|
|
setNextCursor(txns.next_cursor);
|
|
},
|
|
onFinish: () => setIsNavigating(false),
|
|
});
|
|
};
|
|
|
|
if (debounceMs > 0) {
|
|
searchTimerRef.current = setTimeout(doNavigate, debounceMs);
|
|
} else {
|
|
doNavigate();
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
// Handle filter changes from the filter component
|
|
const handleFiltersChange = useCallback(
|
|
(newFilters: Filters) => {
|
|
setFilters(newFilters);
|
|
|
|
// Debounce search, immediate for other filters
|
|
const isSearchChange = newFilters.searchText !== filters.searchText;
|
|
const onlySearchChanged =
|
|
isSearchChange &&
|
|
newFilters.dateFrom === filters.dateFrom &&
|
|
newFilters.dateTo === filters.dateTo &&
|
|
newFilters.amountMin === filters.amountMin &&
|
|
newFilters.amountMax === filters.amountMax &&
|
|
JSON.stringify(newFilters.categoryIds) ===
|
|
JSON.stringify(filters.categoryIds) &&
|
|
JSON.stringify(newFilters.accountIds) ===
|
|
JSON.stringify(filters.accountIds) &&
|
|
JSON.stringify(newFilters.labelIds) ===
|
|
JSON.stringify(filters.labelIds) &&
|
|
newFilters.creditorName === filters.creditorName &&
|
|
newFilters.debtorName === filters.debtorName;
|
|
|
|
navigateWithFilters(
|
|
newFilters,
|
|
sortParam,
|
|
onlySearchChanged ? 300 : 0,
|
|
);
|
|
},
|
|
[filters, sortParam, navigateWithFilters],
|
|
);
|
|
|
|
// Handle sort changes from table column headers
|
|
const handleSortingChange = useCallback(
|
|
(updater: SortingState | ((prev: SortingState) => SortingState)) => {
|
|
const newSorting =
|
|
typeof updater === 'function' ? updater(sorting) : updater;
|
|
if (newSorting.length === 0) {
|
|
return;
|
|
}
|
|
const { id, desc } = newSorting[0];
|
|
const newSort = desc ? `-${id}` : id;
|
|
setSortParam(newSort);
|
|
navigateWithFilters(filters, newSort);
|
|
},
|
|
[sorting, filters, navigateWithFilters],
|
|
);
|
|
|
|
// Refresh the transaction list from the server (resets accumulated pages)
|
|
const refreshTransactions = useCallback(() => {
|
|
router.reload({
|
|
only: ['transactions'],
|
|
onSuccess: (page) => {
|
|
const txns = (page.props as { transactions?: unknown })
|
|
.transactions;
|
|
|
|
if (!isCursorPaginatedResponse<ServerTransaction>(txns)) {
|
|
return;
|
|
}
|
|
|
|
setAllTransactions(txns.data.map(toDecryptedTransaction));
|
|
setNextCursor(txns.next_cursor);
|
|
},
|
|
});
|
|
}, []);
|
|
|
|
// Clear the spinner for any row AI has finished categorizing.
|
|
useEffect(() => {
|
|
setCategorizingIds((previous) => {
|
|
if (previous.size === 0) {
|
|
return previous;
|
|
}
|
|
|
|
let changed = false;
|
|
const next = new Set(previous);
|
|
for (const transaction of allTransactions) {
|
|
if (next.has(transaction.id) && transaction.category_id) {
|
|
next.delete(transaction.id);
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
return changed ? next : previous;
|
|
});
|
|
}, [allTransactions]);
|
|
|
|
const pollCategorizationStatus = useCallback(
|
|
(jobId: string, fallbackTotal: number) => {
|
|
let pendingTicks = 0;
|
|
|
|
const dismiss = () => {
|
|
setCategorizingIds(new Set());
|
|
toast.dismiss(categorizationToastRef.current);
|
|
categorizationToastRef.current = undefined;
|
|
};
|
|
|
|
startCategorizationPoll(async () => {
|
|
let data;
|
|
try {
|
|
({ data } = await axios.get<CategorizationProgress>(
|
|
categorizationStatus(jobId).url,
|
|
));
|
|
} catch {
|
|
dismiss();
|
|
return 'stop';
|
|
}
|
|
|
|
// The job never started (e.g. no queue worker running) — give up
|
|
// instead of polling forever.
|
|
if (data.status === 'pending' && ++pendingTicks > 15) {
|
|
dismiss();
|
|
return 'stop';
|
|
}
|
|
|
|
const total = data.total || fallbackTotal;
|
|
categorizationToastRef.current = toast.loading(
|
|
__('Categorizing :processed of :total transactions…', {
|
|
processed: data.processed,
|
|
total,
|
|
}),
|
|
{ id: categorizationToastRef.current },
|
|
);
|
|
|
|
// Pull whatever AI has applied so far into the visible rows.
|
|
if (data.status === 'processing' || data.status === 'done') {
|
|
refreshTransactions();
|
|
}
|
|
|
|
if (data.status === 'done' || data.status === 'failed') {
|
|
setCategorizingIds(new Set());
|
|
if (data.status === 'failed') {
|
|
toast.error(
|
|
__('AI categorization failed. Please try again.'),
|
|
{ id: categorizationToastRef.current },
|
|
);
|
|
} else if (data.applied > 0) {
|
|
toast.success(
|
|
__('AI categorized :count transactions', {
|
|
count: data.applied,
|
|
}),
|
|
{ id: categorizationToastRef.current },
|
|
);
|
|
} else {
|
|
toast.dismiss(categorizationToastRef.current);
|
|
}
|
|
categorizationToastRef.current = undefined;
|
|
return 'stop';
|
|
}
|
|
|
|
return 'continue';
|
|
}, 2000);
|
|
},
|
|
[startCategorizationPoll, refreshTransactions],
|
|
);
|
|
|
|
const handleEnableAi = useCallback(async () => {
|
|
setAiConsentSaving(true);
|
|
try {
|
|
const { data } = await axios.post<AiConsentResponse>(
|
|
storeConsent.url(),
|
|
);
|
|
setAiConsentGiven(true);
|
|
|
|
if (data.categorization) {
|
|
// Spin every currently-visible uncategorized row while the
|
|
// backfill runs; the prune effect clears each as it lands.
|
|
setCategorizingIds(
|
|
new Set(
|
|
allTransactions
|
|
.filter((transaction) => !transaction.category_id)
|
|
.map((transaction) => transaction.id),
|
|
),
|
|
);
|
|
categorizationToastRef.current = toast.loading(
|
|
__('Categorizing :processed of :total transactions…', {
|
|
processed: 0,
|
|
total: data.categorization.total,
|
|
}),
|
|
);
|
|
pollCategorizationStatus(
|
|
data.categorization.job_id,
|
|
data.categorization.total,
|
|
);
|
|
} else {
|
|
toast.success(__('AI categorization enabled'));
|
|
}
|
|
} catch {
|
|
toast.error(__('Something went wrong.'));
|
|
} finally {
|
|
setAiConsentSaving(false);
|
|
}
|
|
}, [allTransactions, pollCategorizationStatus]);
|
|
|
|
// Load More with cursor pagination (fetch directly to avoid cursor in URL)
|
|
const { component, version } = usePage();
|
|
const handleLoadMore = useCallback(async () => {
|
|
if (!nextCursor || isLoadingMore) {
|
|
return;
|
|
}
|
|
|
|
setIsLoadingMore(true);
|
|
try {
|
|
const params = clientFiltersToQueryParams(filters, sortParam);
|
|
params.cursor = nextCursor;
|
|
const url = transactionsIndex({ query: params }).url;
|
|
|
|
const response = await fetch(url, {
|
|
headers: {
|
|
'X-Inertia': 'true',
|
|
'X-Inertia-Version': version ?? '',
|
|
'X-Inertia-Partial-Data': 'transactions',
|
|
'X-Inertia-Partial-Component': component,
|
|
Accept: 'text/html, application/xhtml+xml',
|
|
},
|
|
});
|
|
|
|
const json = await response.json();
|
|
const next = json.props.transactions;
|
|
|
|
if (!isCursorPaginatedResponse<ServerTransaction>(next)) {
|
|
return;
|
|
}
|
|
|
|
setAllTransactions((prev) => [
|
|
...prev,
|
|
...next.data.map(toDecryptedTransaction),
|
|
]);
|
|
setNextCursor(next.next_cursor);
|
|
} catch (error) {
|
|
console.error('Failed to load more transactions:', error);
|
|
} finally {
|
|
setIsLoadingMore(false);
|
|
}
|
|
}, [nextCursor, isLoadingMore, filters, sortParam, component, version]);
|
|
|
|
// Auto-load more when the sentinel becomes visible
|
|
const loadMoreRef = useRef<HTMLDivElement | null>(null);
|
|
useEffect(() => {
|
|
const el = loadMoreRef.current;
|
|
if (!el || !nextCursor) {
|
|
return;
|
|
}
|
|
|
|
const observer = new IntersectionObserver(
|
|
(entries) => {
|
|
if (entries[0]?.isIntersecting) {
|
|
handleLoadMore();
|
|
}
|
|
},
|
|
{ rootMargin: '200px' },
|
|
);
|
|
|
|
observer.observe(el);
|
|
return () => observer.disconnect();
|
|
}, [nextCursor, handleLoadMore]);
|
|
|
|
// Persist column visibility
|
|
useEffect(() => {
|
|
try {
|
|
localStorage.setItem(
|
|
COLUMN_VISIBILITY_KEY,
|
|
JSON.stringify(columnVisibility),
|
|
);
|
|
} catch (error) {
|
|
console.error(
|
|
'Failed to save column visibility to localStorage:',
|
|
error,
|
|
);
|
|
}
|
|
}, [columnVisibility]);
|
|
|
|
const updateTransaction = useCallback(
|
|
(updatedTransaction: DecryptedTransaction) => {
|
|
setAllTransactions((previous) =>
|
|
previous.map((transaction) => {
|
|
if (transaction.id !== updatedTransaction.id) {
|
|
return transaction;
|
|
}
|
|
|
|
return {
|
|
...transaction,
|
|
...updatedTransaction,
|
|
account:
|
|
updatedTransaction.account === undefined
|
|
? transaction.account
|
|
: updatedTransaction.account,
|
|
bank:
|
|
updatedTransaction.bank === undefined
|
|
? transaction.bank
|
|
: updatedTransaction.bank,
|
|
category:
|
|
updatedTransaction.category === undefined
|
|
? (transaction.category ?? null)
|
|
: updatedTransaction.category,
|
|
};
|
|
}),
|
|
);
|
|
},
|
|
[],
|
|
);
|
|
|
|
const handleReEvaluateRules = useCallback(
|
|
async (transaction: DecryptedTransaction) => {
|
|
consoleDebug('=== Re-evaluating rules for single transaction ===');
|
|
|
|
setIsReEvaluating(true);
|
|
try {
|
|
const response = await axios.post<{
|
|
data: DecryptedTransaction;
|
|
}>(reEvaluateSingle({ transaction: transaction.id }).url);
|
|
|
|
const updated = response.data.data;
|
|
|
|
updateTransaction(
|
|
mergeReEvaluatedTransaction(transaction, updated),
|
|
);
|
|
} catch (error) {
|
|
console.error('Failed to re-evaluate rules:', error);
|
|
} finally {
|
|
setIsReEvaluating(false);
|
|
}
|
|
},
|
|
[updateTransaction],
|
|
);
|
|
|
|
async function handleBulkReEvaluateRules() {
|
|
consoleDebug('=== Re-evaluating rules for bulk transactions ===');
|
|
|
|
if (selectedIds.length === 0 && !isSelectingAll) {
|
|
return;
|
|
}
|
|
|
|
setIsReEvaluating(true);
|
|
const toastId = toast.loading(`Re-evaluating 0 of ... transactions...`);
|
|
|
|
try {
|
|
const payload = isSelectingAll
|
|
? { filters: clientFiltersToBackendFilters(filters) }
|
|
: { transaction_ids: selectedIds };
|
|
|
|
const bulkResponse = await axios.post<{ job_id: string }>(
|
|
reEvaluateBulk().url,
|
|
payload,
|
|
);
|
|
|
|
const jobId = bulkResponse.data.job_id;
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
const poll = async () => {
|
|
try {
|
|
const statusResponse = await axios.get<{
|
|
status: string;
|
|
processed: number;
|
|
total: number;
|
|
updated: number;
|
|
}>(reEvaluateStatus({ jobId }).url);
|
|
|
|
const { status, processed, total, updated } =
|
|
statusResponse.data;
|
|
|
|
toast.loading(
|
|
`Re-evaluating ${processed} of ${total} transactions...`,
|
|
{ id: toastId },
|
|
);
|
|
|
|
if (status === 'done') {
|
|
toast.dismiss(toastId);
|
|
toast.success(() => (
|
|
<div>
|
|
{`Re-evaluation complete!`}
|
|
<br />
|
|
{`${updated} transaction(s) updated.`}
|
|
</div>
|
|
));
|
|
resolve();
|
|
} else if (status === 'failed') {
|
|
reject(new Error('Job failed'));
|
|
} else {
|
|
setTimeout(poll, 1000);
|
|
}
|
|
} catch (error) {
|
|
reject(error);
|
|
}
|
|
};
|
|
|
|
poll();
|
|
});
|
|
|
|
setRowSelection({});
|
|
setIsSelectingAll(false);
|
|
refreshTransactions();
|
|
} catch (error) {
|
|
console.error('Failed to re-evaluate rules:', error);
|
|
toast.error(__('Failed to re-evaluate rules. Please try again.'), {
|
|
id: toastId,
|
|
});
|
|
} finally {
|
|
setIsReEvaluating(false);
|
|
}
|
|
}
|
|
|
|
const showAutomatizeToast = useCallback(
|
|
(
|
|
transaction: DecryptedTransaction,
|
|
category: Category,
|
|
source: 'transaction_table' | 'edit_transaction_modal',
|
|
) => {
|
|
const nextAutomateCandidate = { transaction, category };
|
|
|
|
setAutomateCandidate(nextAutomateCandidate);
|
|
toast.success(__('Transaction categorized'), {
|
|
closeButton: true,
|
|
duration: 12000,
|
|
action: {
|
|
label: createElement(
|
|
'span',
|
|
{
|
|
title: __(
|
|
'Automatize the categorization of future transactions like this one',
|
|
),
|
|
},
|
|
__('Automatize'),
|
|
),
|
|
onClick: () => {
|
|
captureEvent(
|
|
'automation_rule_toast_automatize_clicked',
|
|
{ source },
|
|
);
|
|
setAutomateCandidate(nextAutomateCandidate);
|
|
setAutomateDialogOpen(true);
|
|
},
|
|
},
|
|
});
|
|
},
|
|
[],
|
|
);
|
|
|
|
const columns = useMemo(
|
|
() =>
|
|
createTransactionColumns({
|
|
categories,
|
|
accounts,
|
|
banks,
|
|
labels,
|
|
locale,
|
|
onEdit: setEditTransaction,
|
|
onDelete: setDeleteTransaction,
|
|
onUpdate: updateTransaction,
|
|
onCategorized: showAutomatizeToast,
|
|
onReEvaluateRules: handleReEvaluateRules,
|
|
isDateHidden: columnVisibility.transaction_date === false,
|
|
categorizingIds,
|
|
}),
|
|
[
|
|
accounts,
|
|
banks,
|
|
categories,
|
|
labels,
|
|
locale,
|
|
updateTransaction,
|
|
showAutomatizeToast,
|
|
handleReEvaluateRules,
|
|
columnVisibility,
|
|
categorizingIds,
|
|
],
|
|
);
|
|
|
|
const table = useReactTable({
|
|
data: allTransactions,
|
|
columns,
|
|
manualSorting: true,
|
|
onSortingChange: handleSortingChange,
|
|
getRowId: (row) => row.id.toString(),
|
|
getCoreRowModel: getCoreRowModel(),
|
|
onColumnVisibilityChange: setColumnVisibility,
|
|
onRowSelectionChange: setRowSelection,
|
|
enableRowSelection: true,
|
|
state: {
|
|
sorting,
|
|
columnVisibility,
|
|
rowSelection,
|
|
},
|
|
});
|
|
|
|
async function handleDelete() {
|
|
if (!deleteTransaction) {
|
|
return;
|
|
}
|
|
|
|
setIsDeleting(true);
|
|
try {
|
|
await transactionSyncService.delete(deleteTransaction.id, {
|
|
updateBalance:
|
|
updateBalanceOnDelete &&
|
|
manualAccountIds.has(deleteTransaction.account_id),
|
|
});
|
|
setAllTransactions((previous) =>
|
|
previous.filter(
|
|
(transaction) => transaction.id !== deleteTransaction.id,
|
|
),
|
|
);
|
|
setDeleteTransaction(null);
|
|
setIsBulkDeleteMode(false);
|
|
setRowSelection({});
|
|
} catch (error) {
|
|
console.error('Failed to delete transaction:', error);
|
|
} finally {
|
|
setIsDeleting(false);
|
|
}
|
|
}
|
|
|
|
async function handleBulkCategoryChange(categoryId: number | null) {
|
|
if (selectedIds.length === 0 && !isSelectingAll) {
|
|
return;
|
|
}
|
|
|
|
setIsBulkUpdating(true);
|
|
try {
|
|
if (isSelectingAll) {
|
|
const toastId = toast.loading(__('Updating transactions...'));
|
|
const response = await axios.patch<{ count: number }>(
|
|
'/transactions/bulk',
|
|
{
|
|
filters: clientFiltersToBackendFilters(filters),
|
|
category_id: categoryId,
|
|
},
|
|
);
|
|
toast.dismiss(toastId);
|
|
toast.success(
|
|
__(`Updated :count transactions`, {
|
|
count: response.data.count,
|
|
}),
|
|
);
|
|
setRowSelection({});
|
|
setIsSelectingAll(false);
|
|
refreshTransactions();
|
|
} else {
|
|
const categoriesMap = new Map(
|
|
categories.map((category) => [category.id, category]),
|
|
);
|
|
const selectedCategory = categoryId
|
|
? categoriesMap.get(categoryId) || null
|
|
: null;
|
|
|
|
await transactionSyncService.updateMany(selectedIds, {
|
|
category_id: categoryId,
|
|
});
|
|
|
|
setAllTransactions((previous) =>
|
|
previous.map((transaction) => {
|
|
if (selectedIds.includes(transaction.id.toString())) {
|
|
return {
|
|
...transaction,
|
|
category_id: categoryId,
|
|
category: selectedCategory,
|
|
};
|
|
}
|
|
return transaction;
|
|
}),
|
|
);
|
|
|
|
toast.success(
|
|
__(`Updated :count transactions`, {
|
|
count: selectedIds.length,
|
|
}),
|
|
);
|
|
|
|
setRowSelection({});
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to update transactions:', error);
|
|
toast.error(__('Failed to update transactions'));
|
|
} finally {
|
|
setIsBulkUpdating(false);
|
|
}
|
|
}
|
|
|
|
const selectedIds = useMemo(
|
|
() => Object.keys(rowSelection),
|
|
[rowSelection],
|
|
);
|
|
|
|
const selectedCount = useMemo(() => selectedIds.length, [selectedIds]);
|
|
|
|
const manualAccountIds = useMemo(
|
|
() =>
|
|
new Set(
|
|
accounts
|
|
.filter((account) => account.banking_connection_id === null)
|
|
.map((account) => account.id),
|
|
),
|
|
[accounts],
|
|
);
|
|
|
|
const bulkDeleteTouchesManualAccount = useMemo(
|
|
() =>
|
|
isSelectingAll
|
|
? manualAccountIds.size > 0
|
|
: allTransactions.some(
|
|
(transaction) =>
|
|
selectedIds.includes(transaction.id) &&
|
|
manualAccountIds.has(transaction.account_id),
|
|
),
|
|
[isSelectingAll, manualAccountIds, allTransactions, selectedIds],
|
|
);
|
|
|
|
const bulkDeleteConfirmationText = useMemo(
|
|
() => getBulkDeleteConfirmationText(selectedCount),
|
|
[selectedCount],
|
|
);
|
|
|
|
function handleBulkDeleteClick() {
|
|
if (selectedIds.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const firstSelectedTransaction = allTransactions.find(
|
|
(t) => t.id.toString() === selectedIds[0],
|
|
);
|
|
|
|
if (firstSelectedTransaction) {
|
|
setIsBulkDeleteMode(true);
|
|
setDeleteTransaction(firstSelectedTransaction);
|
|
}
|
|
}
|
|
|
|
async function handleBulkDelete() {
|
|
if (selectedIds.length === 0) {
|
|
return;
|
|
}
|
|
|
|
setIsBulkDeleting(true);
|
|
try {
|
|
await transactionSyncService.deleteMany(selectedIds, {
|
|
updateBalance: updateBalanceOnDelete,
|
|
});
|
|
setAllTransactions((previous) =>
|
|
previous.filter(
|
|
(transaction) => !selectedIds.includes(transaction.id),
|
|
),
|
|
);
|
|
setDeleteTransaction(null);
|
|
setIsBulkDeleteMode(false);
|
|
setBulkDeleteConfirmation('');
|
|
setRowSelection({});
|
|
} catch (error) {
|
|
console.error('Failed to delete transactions:', error);
|
|
} finally {
|
|
setIsBulkDeleting(false);
|
|
}
|
|
}
|
|
|
|
async function handleBulkLabelsChange(labelIds: string[]) {
|
|
if (selectedIds.length === 0 && !isSelectingAll) {
|
|
return;
|
|
}
|
|
|
|
setIsBulkUpdating(true);
|
|
try {
|
|
if (isSelectingAll) {
|
|
const toastId = toast.loading(__('Updating transactions...'));
|
|
const response = await axios.patch<{ count: number }>(
|
|
'/transactions/bulk',
|
|
{
|
|
filters: clientFiltersToBackendFilters(filters),
|
|
label_ids: labelIds,
|
|
},
|
|
);
|
|
toast.dismiss(toastId);
|
|
toast.success(
|
|
__(`Updated :count transactions`, {
|
|
count: response.data.count,
|
|
}),
|
|
);
|
|
setRowSelection({});
|
|
setIsSelectingAll(false);
|
|
refreshTransactions();
|
|
} else {
|
|
const selectedLabels = labels.filter((l) =>
|
|
labelIds.includes(l.id),
|
|
);
|
|
|
|
await transactionSyncService.updateMany(selectedIds, {
|
|
label_ids: labelIds,
|
|
});
|
|
|
|
setAllTransactions((previous) =>
|
|
previous.map((transaction) => {
|
|
if (selectedIds.includes(transaction.id.toString())) {
|
|
if (labelIds.length === 0) {
|
|
return {
|
|
...transaction,
|
|
labels: [],
|
|
};
|
|
}
|
|
|
|
const existingLabels = transaction.labels || [];
|
|
const mergedLabels = [
|
|
...existingLabels,
|
|
...selectedLabels.filter(
|
|
(l) =>
|
|
!existingLabels.some(
|
|
(el) => el.id === l.id,
|
|
),
|
|
),
|
|
];
|
|
|
|
return {
|
|
...transaction,
|
|
labels: mergedLabels,
|
|
};
|
|
}
|
|
return transaction;
|
|
}),
|
|
);
|
|
|
|
toast.success(
|
|
__(`Updated :count transactions`, {
|
|
count: selectedIds.length,
|
|
}),
|
|
);
|
|
|
|
setRowSelection({});
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to update transactions with labels:', error);
|
|
toast.error(__('Failed to update transactions with labels'));
|
|
} finally {
|
|
setIsBulkUpdating(false);
|
|
}
|
|
}
|
|
|
|
function handleClearSelection() {
|
|
setRowSelection({});
|
|
setIsSelectingAll(false);
|
|
}
|
|
|
|
function handleSelectAll() {
|
|
setIsSelectingAll(true);
|
|
}
|
|
|
|
const renderTransactionRow = useCallback(
|
|
(
|
|
row: Row<DecryptedTransaction>,
|
|
virtualRow: VirtualItem,
|
|
rowVirtualizer: Virtualizer<HTMLDivElement, Element>,
|
|
) => {
|
|
return (
|
|
<TransactionRowComponent
|
|
key={row.id}
|
|
row={row}
|
|
virtualRow={virtualRow}
|
|
rowVirtualizer={rowVirtualizer}
|
|
isNew={isNewSince(row.original, lastVisitAtMount)}
|
|
onEdit={setEditTransaction}
|
|
onReEvaluateRules={handleReEvaluateRules}
|
|
onDelete={setDeleteTransaction}
|
|
/>
|
|
);
|
|
},
|
|
[handleReEvaluateRules, lastVisitAtMount],
|
|
);
|
|
|
|
return (
|
|
<AppSidebarLayout breadcrumbs={breadcrumbs}>
|
|
<Head title={__('Transactions')} />
|
|
|
|
<div className="space-y-6 p-6">
|
|
<HeadingSmall
|
|
title={__('Transactions')}
|
|
description={__('View and manage your transactions')}
|
|
/>
|
|
|
|
<div className="space-y-4">
|
|
<TransactionFiltersComponent
|
|
filters={filters}
|
|
onFiltersChange={handleFiltersChange}
|
|
categories={categories}
|
|
labels={labels}
|
|
accounts={accounts}
|
|
enableSavedFilters={true}
|
|
actions={
|
|
<div className="flex w-full items-center justify-between gap-2">
|
|
<TransactionActionsMenu
|
|
categories={categories}
|
|
accounts={accounts}
|
|
banks={banks}
|
|
automationRules={automationRules}
|
|
onAddTransaction={() =>
|
|
setCreateDialogOpen(true)
|
|
}
|
|
transactions={allTransactions}
|
|
onReEvaluateComplete={() => {
|
|
setRowSelection({});
|
|
refreshTransactions();
|
|
}}
|
|
onImportComplete={() =>
|
|
refreshTransactions()
|
|
}
|
|
filters={filters}
|
|
/>
|
|
|
|
<DataTableViewOptions table={table} />
|
|
</div>
|
|
}
|
|
/>
|
|
|
|
{isNavigating ? (
|
|
<div className="space-y-4">
|
|
<div className="overflow-hidden rounded-md border">
|
|
<div className="grid grid-cols-4 gap-4 border-b p-4">
|
|
<Skeleton className="h-5 w-8" />
|
|
<Skeleton className="h-5 w-24" />
|
|
<Skeleton className="h-5 w-64" />
|
|
<Skeleton className="h-5 w-20 justify-self-end" />
|
|
</div>
|
|
<div className="divide-y">
|
|
<div className="bg-muted/50 px-4 py-2">
|
|
<Skeleton className="h-4 w-32" />
|
|
</div>
|
|
{Array.from({ length: 6 }).map(
|
|
(_, index) => (
|
|
<div
|
|
key={index}
|
|
className="grid grid-cols-4 gap-4 p-4"
|
|
>
|
|
<Skeleton className="h-4 w-8" />
|
|
<Skeleton className="h-4 w-28" />
|
|
<div className="space-y-2">
|
|
<Skeleton className="h-4 w-full" />
|
|
<Skeleton className="h-3 w-48" />
|
|
</div>
|
|
<Skeleton className="h-4 w-20 justify-self-end" />
|
|
</div>
|
|
),
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<Skeleton className="h-5 w-32" />
|
|
<Skeleton className="h-9 w-48" />
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<DataTable
|
|
table={table}
|
|
columns={columns}
|
|
emptyMessage={__('No transactions found.')}
|
|
renderRow={renderTransactionRow}
|
|
getRowDate={(row) => row.transaction_date}
|
|
renderDateHeader={(date, colSpan) => (
|
|
<DateHeader date={date} colSpan={colSpan} />
|
|
)}
|
|
topRow={
|
|
showAiConsentBanner ? (
|
|
<div className="flex flex-col gap-3 bg-gradient-to-r from-violet-50 via-sky-50 to-rose-50 px-4 py-4 sm:flex-row sm:items-center dark:from-violet-950/30 dark:via-sky-950/20 dark:to-rose-950/20">
|
|
<div className="flex gap-3">
|
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-md bg-white/80 shadow-sm dark:bg-white/10">
|
|
<AiSparkleIcon className="h-5 w-5" />
|
|
</div>
|
|
<div className="max-w-80">
|
|
<p className="font-medium">
|
|
{__(
|
|
'Let AI categorize your transactions',
|
|
)}
|
|
</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
{__(
|
|
'Give your consent and our AI will suggest categories for your transactions automatically.',
|
|
)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
onClick={handleEnableAi}
|
|
disabled={aiConsentSaving}
|
|
variant="secondary"
|
|
className="max-w-[calc(var(--spacing)_*_86)] bg-white px-6!"
|
|
>
|
|
{__('Enable AI')}
|
|
<ChevronRight />
|
|
</Button>
|
|
</div>
|
|
) : null
|
|
}
|
|
/>
|
|
|
|
<DataTablePagination
|
|
displayedCount={allTransactions.length}
|
|
rowCountLabel={__('transactions loaded')}
|
|
>
|
|
{nextCursor && (
|
|
<div ref={loadMoreRef}>
|
|
<Button
|
|
onClick={handleLoadMore}
|
|
disabled={isLoadingMore}
|
|
variant="outline"
|
|
>
|
|
{isLoadingMore ? (
|
|
<>
|
|
<Spinner />
|
|
{__('Loading')}
|
|
</>
|
|
) : (
|
|
<>{__('Load more')}</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</DataTablePagination>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<EditTransactionDialog
|
|
transaction={editTransaction}
|
|
categories={categories}
|
|
accounts={accounts}
|
|
banks={banks}
|
|
labels={labels}
|
|
automationRules={automationRules}
|
|
open={!!editTransaction}
|
|
onOpenChange={(open) => !open && setEditTransaction(null)}
|
|
onSuccess={updateTransaction}
|
|
onCategorized={showAutomatizeToast}
|
|
onLabelCreated={handleLabelCreated}
|
|
mode="edit"
|
|
/>
|
|
|
|
<EditTransactionDialog
|
|
transaction={null}
|
|
categories={categories}
|
|
accounts={accounts}
|
|
banks={banks}
|
|
labels={labels}
|
|
automationRules={automationRules}
|
|
open={createDialogOpen}
|
|
onOpenChange={setCreateDialogOpen}
|
|
onSuccess={() => refreshTransactions()}
|
|
onLabelCreated={handleLabelCreated}
|
|
mode="create"
|
|
/>
|
|
|
|
<AutomateCategorizationDialog
|
|
open={automateDialogOpen}
|
|
candidate={automateCandidate}
|
|
categories={categories}
|
|
onOpenChange={setAutomateDialogOpen}
|
|
/>
|
|
|
|
<PostSaveApplyRulePrompt />
|
|
|
|
<AlertDialog
|
|
open={!!deleteTransaction && !isBulkDeleteMode}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
setDeleteTransaction(null);
|
|
setUpdateBalanceOnDelete(true);
|
|
}
|
|
}}
|
|
>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>
|
|
{__('Delete Transaction')}
|
|
</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
{__(
|
|
'Are you sure you want to delete this transaction? This action cannot be undone.',
|
|
)}
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
{deleteTransaction !== null &&
|
|
manualAccountIds.has(deleteTransaction.account_id) && (
|
|
<label className="flex cursor-pointer items-start gap-3 rounded-md border border-border bg-muted/40 p-3 text-sm">
|
|
<Checkbox
|
|
checked={updateBalanceOnDelete}
|
|
onCheckedChange={(checked) =>
|
|
setUpdateBalanceOnDelete(
|
|
checked === true,
|
|
)
|
|
}
|
|
className="mt-0.5"
|
|
/>
|
|
<span className="text-muted-foreground">
|
|
{__(
|
|
'Update the current balance of the manual account to reflect this change.',
|
|
)}
|
|
</span>
|
|
</label>
|
|
)}
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel disabled={isDeleting}>
|
|
{__('Cancel')}
|
|
</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={handleDelete}
|
|
disabled={isDeleting}
|
|
className="bg-red-600 hover:bg-red-700"
|
|
>
|
|
{isDeleting ? __('Deleting...') : __('Delete')}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
|
|
<Dialog
|
|
open={!!deleteTransaction && isBulkDeleteMode}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
setDeleteTransaction(null);
|
|
setIsBulkDeleteMode(false);
|
|
setBulkDeleteConfirmation('');
|
|
setUpdateBalanceOnDelete(true);
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>{__('Delete Transactions')}</DialogTitle>
|
|
<DialogDescription>
|
|
{__(
|
|
'This action cannot be undone. To confirm, type',
|
|
)}{' '}
|
|
<span className="font-semibold text-foreground">
|
|
{bulkDeleteConfirmationText}
|
|
</span>
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<Input
|
|
value={bulkDeleteConfirmation}
|
|
onChange={(e) =>
|
|
setBulkDeleteConfirmation(e.target.value)
|
|
}
|
|
placeholder={bulkDeleteConfirmationText}
|
|
disabled={isBulkDeleting}
|
|
autoFocus
|
|
/>
|
|
{bulkDeleteTouchesManualAccount && (
|
|
<label className="flex cursor-pointer items-start gap-3 rounded-md border border-border bg-muted/40 p-3 text-sm">
|
|
<Checkbox
|
|
checked={updateBalanceOnDelete}
|
|
onCheckedChange={(checked) =>
|
|
setUpdateBalanceOnDelete(checked === true)
|
|
}
|
|
className="mt-0.5"
|
|
/>
|
|
<span className="text-muted-foreground">
|
|
{__(
|
|
'Update the current balance of the affected manual accounts to reflect this change.',
|
|
)}
|
|
</span>
|
|
</label>
|
|
)}
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => {
|
|
setDeleteTransaction(null);
|
|
setIsBulkDeleteMode(false);
|
|
setBulkDeleteConfirmation('');
|
|
}}
|
|
disabled={isBulkDeleting}
|
|
>
|
|
{__('Cancel')}
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
onClick={handleBulkDelete}
|
|
disabled={
|
|
isBulkDeleting ||
|
|
bulkDeleteConfirmation !==
|
|
bulkDeleteConfirmationText
|
|
}
|
|
>
|
|
{isBulkDeleting ? __('Deleting...') : __('Delete')}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<BulkActionsBar
|
|
selectedCount={selectedCount}
|
|
isSelectingAll={isSelectingAll}
|
|
categories={categories}
|
|
labels={labels}
|
|
onCategoryChange={handleBulkCategoryChange}
|
|
onLabelsChange={handleBulkLabelsChange}
|
|
onLabelCreated={handleLabelCreated}
|
|
onDelete={handleBulkDeleteClick}
|
|
onReEvaluateRules={handleBulkReEvaluateRules}
|
|
onSelectAll={handleSelectAll}
|
|
onClear={handleClearSelection}
|
|
isUpdating={isBulkUpdating || isReEvaluating}
|
|
/>
|
|
</AppSidebarLayout>
|
|
);
|
|
}
|