whisper-money/resources/js/components/transactions/transaction-columns.tsx

339 lines
13 KiB
TypeScript

import { formatDate } from '@/utils/date';
import { __ } from '@/utils/i18n';
import { ColumnDef } from '@tanstack/react-table';
import { getYear, parseISO } from 'date-fns';
import { ArrowDown, MoreHorizontal } from 'lucide-react';
import { AccountName } from '@/components/accounts/account-name';
import { BankLogo } from '@/components/bank-logo';
import { EncryptedText } from '@/components/encrypted-text';
import { LabelBadges } from '@/components/shared/label-combobox';
import { CategoryCell } from '@/components/transactions/category-cell';
import { EncryptedTransactionDescription } from '@/components/transactions/encrypted-transaction-description';
import { AmountDisplay } from '@/components/ui/amount-display';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { type Account, type Bank } from '@/types/account';
import { type Category } from '@/types/category';
import { type Label } from '@/types/label';
import { type DecryptedTransaction } from '@/types/transaction';
interface CreateColumnsOptions {
categories: Category[];
accounts: Account[];
banks: Bank[];
labels: Label[];
locale: string;
onEdit: (transaction: DecryptedTransaction) => void;
onDelete: (transaction: DecryptedTransaction) => void;
onUpdate: (transaction: DecryptedTransaction) => void;
onReEvaluateRules: (transaction: DecryptedTransaction) => void;
}
export function createTransactionColumns({
categories,
accounts,
banks,
labels,
locale,
onEdit,
onDelete,
onUpdate,
onReEvaluateRules,
}: CreateColumnsOptions): ColumnDef<DecryptedTransaction>[] {
return [
{
id: 'select',
meta: {
cellClassName: 'hidden md:table-cell',
},
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && 'indeterminate')
}
className="ml-2"
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
aria-label={__('Select all')}
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
className="ml-2"
onCheckedChange={(value) => row.toggleSelected(!!value)}
onClick={(e) => e.stopPropagation()}
aria-label={__('Select row')}
/>
),
enableSorting: false,
enableHiding: false,
},
{
id: 'transaction_date',
accessorKey: 'transaction_date',
meta: {
label: __('Date'),
cellClassName: 'max-w-[95px] whitespace-normal',
},
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === 'asc')
}
>
{__('Date')}
<ArrowDown className="h-2 w-2 opacity-25" />
</Button>
);
},
cell: ({ row }) => {
const date = parseISO(row.getValue('transaction_date'));
const currentYear = getYear(new Date());
const transactionYear = getYear(date);
const formatString =
transactionYear === currentYear ? 'MMM d' : 'MMM d, yy';
const formatted = formatDate(date, formatString, locale);
// Capitalize first letter (important for Spanish dates)
const capitalized =
formatted.charAt(0).toUpperCase() + formatted.slice(1);
return (
<div className="pl-3 whitespace-nowrap">{capitalized}</div>
);
},
enableHiding: true,
},
{
accessorKey: 'category_id',
meta: {
label: __('Category'),
cellClassName:
'max-w-[170px] !sm:max-w-[170px] md:max-w-[190px] !min-w-[170px] whitespace-normal',
},
header: () => __('Category'),
cell: ({ row }) => {
return (
<CategoryCell
transaction={row.original}
categories={categories}
accounts={accounts}
banks={banks}
onUpdate={onUpdate}
className="relative -top-0.5 max-w-[150px] md:max-w-[180px]"
withoutChevronIcon
/>
);
},
enableHiding: true,
},
{
id: 'account',
accessorKey: 'account',
header: () => __('Account'),
meta: {
label: __('Account'),
cellClassName: '!min-w-[125px] whitespace-normal',
},
cell: ({ row }) => {
const transaction = row.original;
if (!transaction.account) {
return <div className="text-muted-foreground"></div>;
}
return (
<div className="flex items-center gap-2">
<BankLogo
src={transaction.bank?.logo}
name={transaction.bank?.name}
className="h-5 w-5"
/>
<AccountName
account={transaction.account}
length={{ min: 5, max: 15 }}
className="truncate"
/>
</div>
);
},
enableHiding: true,
},
{
accessorKey: 'decryptedDescription',
meta: {
label: __('Description'),
cellClassName:
'max-w-[200px] sm:max-w-[400px] md:max-w-[400px] lg:max-w-[550px] xl:max-w-full xl:w-full',
},
header: () => __('Description'),
cell: ({ row, table }) => {
const transaction = row.original;
const columnVisibility = table.getState().columnVisibility;
const showLabels = columnVisibility.labels !== false;
const showNotes = columnVisibility.notes !== false;
const transactionLabels = (transaction.label_ids || [])
.map((id) => labels.find((l) => l.id === id))
.filter(Boolean) as Label[];
const hasLabels = transactionLabels.length > 0;
const hasNotes =
transaction.decryptedNotes ||
(transaction.notes && transaction.notes_iv);
return (
<div className="flex flex-col gap-0.5">
<div className="flex flex-row justify-between gap-1">
<div className="flex-grow truncate">
<EncryptedTransactionDescription
encryptedText={transaction.description}
iv={transaction.description_iv}
length={{ min: 20, max: 80 }}
/>
</div>
{showLabels && hasLabels && (
<LabelBadges
labels={transactionLabels}
max={3}
/>
)}
</div>
{showNotes && hasNotes && (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted-foreground">
<div className="truncate text-muted-foreground/80">
{transaction.decryptedNotes ? (
<span>
{transaction.decryptedNotes}
</span>
) : (
<EncryptedText
encryptedText={
transaction.notes || ''
}
iv={transaction.notes_iv || ''}
length={{ min: 10, max: 30 }}
/>
)}
</div>
</div>
)}
</div>
);
},
enableHiding: false,
},
{
accessorKey: 'amount',
meta: { label: __('Amount') },
header: () => {
return <div className="w-full text-right">{__('Amount')}</div>;
},
cell: ({ row }) => {
const amountInCents = row.getValue('amount') as number;
const amount = amountInCents / 100;
const currencyCode = row.original.currency_code;
return (
<div className="pl-4 text-right">
<AmountDisplay
amountInCents={amountInCents}
currencyCode={currencyCode}
variant="positive-highlight"
highlightPositive={amount >= 0}
monospace
/>
</div>
);
},
enableHiding: true,
},
{
id: 'actions',
enableHiding: false,
meta: {
cellClassName:
'!w-[45px] !max-w-[45px] !min-w-[45px] whitespace-normal',
},
cell: ({ row }) => {
const transaction = row.original;
return (
<div className="relative -top-0.5 flex w-[35px] items-center justify-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-[24px] w-8 p-0"
onClick={(e) => e.stopPropagation()}
>
<span className="sr-only">
{__('Open menu')}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>
{__('Actions')}
</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => onEdit(transaction)}
>
{__('Edit')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
onReEvaluateRules(transaction)
}
>
{__('Re-evaluate rules')}
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={() => onDelete(transaction)}
>
{__('Delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
},
// Virtual columns for visibility control only (not rendered as table columns)
{
id: 'labels',
accessorKey: 'label_ids',
meta: { label: __('Labels'), isVirtual: true },
header: () => null,
cell: () => null,
enableHiding: true,
},
{
id: 'notes',
accessorKey: 'decryptedNotes',
meta: { label: __('Notes'), isVirtual: true },
header: () => null,
cell: () => null,
enableHiding: true,
},
];
}