'use client' import { useState } from 'react' import { useTranslations } from 'next-intl' import Link from 'next/link' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { DataListEmpty } from '@/components/ui/data-list' import { TH_CLASS, TD_CLASS, QUIET_LINK_CLASS } from '@/components/ui/dry-table' import { ContextPicker } from '@/components/common/ContextPicker' import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, } from '@/components/ui/dropdown-menu' import { cn, formatCurrency, formatDate } from '@/lib/utils' import { isImportedTransaction } from '@/lib/transactions/origin' import { getCategoryDisplayName } from '@/lib/tax/expense-warnings' import { ArrowLeftRight, FileText, Landmark, Link2, FileSearch, Loader2, MoreHorizontal, Paperclip, Trash2, } from 'lucide-react' import { TransactionAttachmentIndicator } from './TransactionAttachmentIndicator' import CorrectionAffordance from '@/components/bookkeeping/CorrectionAffordance' import { useCanWrite } from '@/lib/hooks/use-can-write' import type { JeUnderlagStatus } from '@/lib/transactions/underlag-status' import type { TransactionWithInvoice, HistoryFilter, SourceFilter } from './transaction-types' import type { SkattekontoTransactionWithSuggestion, StoredSkattekontoTransaction, } from '@/types/skatteverket' type HistoryRow = | { source: 'bank'; date: string; data: TransactionWithInvoice } | { source: 'skatteverket'; date: string; data: SkattekontoTransactionWithSuggestion } interface TransactionHistoryListProps { transactions: TransactionWithInvoice[] skvRows?: SkattekontoTransactionWithSuggestion[] searchTerm?: string sourceFilter: SourceFilter onSourceFilterChange: (sourceFilter: SourceFilter) => void /** Underlag status per journal_entry_id (computeJeUnderlagStatus): drives * the per-row "Underlag"/"Underlag saknas" badges on booked rows. */ jeUnderlagStatus?: Record onOpenMatchDialog: (transaction: TransactionWithInvoice) => void onOpenCategoryDialog: (transaction: TransactionWithInvoice) => void /** Open the attach-underlag dialog (pin an inbox doc / fresh upload). */ onOpenAttachDocument?: (transaction: TransactionWithInvoice) => void /** Open the match-against-existing-voucher dialog. Unbooked rows can end up * here (not in the inbox) when is_business is already set, e.g. after a * voucher was removed without a full uncategorize; without this item such * rows have no path back to voucher matching. */ onOpenMatchVoucher?: (transaction: TransactionWithInvoice) => void onDelete?: (id: string) => void onSkvBokfor?: (row: StoredSkattekontoTransaction) => void onSkvMatch?: (row: StoredSkattekontoTransaction) => void hasMore?: boolean isLoadingMore?: boolean onLoadMore?: () => void } /** * "Alla" view: every transaction (booked and not), rendered in the same * dry-table language as the inbox so the two modes read as one page. * Bokförd is the normal state (muted text); Ej bokförd is the exception chip. */ export default function TransactionHistoryList({ transactions, skvRows = [], searchTerm = '', sourceFilter, onSourceFilterChange, jeUnderlagStatus, onOpenMatchDialog, onOpenCategoryDialog, onOpenAttachDocument, onOpenMatchVoucher, onDelete, onSkvBokfor, onSkvMatch, hasMore, isLoadingMore, onLoadMore, }: TransactionHistoryListProps) { const t = useTranslations('tx_history') const [filter, setFilter] = useState('all') // The bank/private filter doesn't apply to SKV rows: they have no // is_business flag. So when the filter is 'business' or 'private' we // implicitly hide SKV. const bankFiltered = transactions.filter((tx) => { const matchesSearch = tx.description.toLowerCase().includes(searchTerm.toLowerCase()) const matchesFilter = filter === 'all' || (filter === 'business' && tx.is_business === true) || (filter === 'private' && tx.is_business === false) return matchesSearch && matchesFilter }) const skvFiltered = skvRows.filter((r) => { if (filter !== 'all') return false return r.transaktionstext.toLowerCase().includes(searchTerm.toLowerCase()) }) const merged: HistoryRow[] = [] if (sourceFilter !== 'skatteverket') { for (const tx of bankFiltered) { merged.push({ source: 'bank', date: tx.date, data: tx }) } } if (sourceFilter !== 'bank') { for (const r of skvFiltered) { merged.push({ source: 'skatteverket', date: r.transaktionsdatum, data: r }) } } merged.sort((a, b) => { if (a.date !== b.date) return b.date.localeCompare(a.date) return a.source === 'bank' ? -1 : 1 }) const showSourceFilter = sourceFilter !== 'all' || (skvRows.length > 0 && transactions.length > 0) const filtered = merged const FILTERS: Array<{ key: HistoryFilter; labelKey: string }> = [ { key: 'all', labelKey: 'filter_all' }, { key: 'business', labelKey: 'filter_business' }, { key: 'private', labelKey: 'filter_private' }, ] return (
{/* Business/private seg + source chip, mirroring the inbox toolbar. */}
{FILTERS.map(({ key, labelKey }) => ( ))}
{showSourceFilter && (
onSourceFilterChange(id as SourceFilter)} triggerLabel={ sourceFilter === 'all' ? t('source_all') : sourceFilter === 'bank' ? t('source_bank') : t('source_skatteverket') } items={[ { id: 'all', label: t('source_all') }, { id: 'bank', label: t('source_bank') }, { id: 'skatteverket', label: t('source_skatteverket') }, ]} />
)}
{filtered.length === 0 ? ( } title={t('empty_title')} description={searchTerm ? t('empty_search') : t('empty_filter')} /> ) : ( /* Negative margin + matching padding: keeps the columns flush with the page edges (mirrors the inbox table on the transactions page). */
{filtered.map((item) => item.source === 'bank' ? ( ) : ( ), )}
{t('th_date')} {t('th_description')} {t('th_amount')} {t('th_status')}
)} {hasMore && onLoadMore && !searchTerm && filtered.length > 0 && (
)}
) } function BankHistoryRow({ transaction, jeUnderlagStatus, onOpenMatchDialog, onOpenCategoryDialog, onOpenAttachDocument, onOpenMatchVoucher, onDelete, }: { transaction: TransactionWithInvoice jeUnderlagStatus?: Record onOpenMatchDialog: (transaction: TransactionWithInvoice) => void onOpenCategoryDialog: (transaction: TransactionWithInvoice) => void onOpenAttachDocument?: (transaction: TransactionWithInvoice) => void onOpenMatchVoucher?: (transaction: TransactionWithInvoice) => void onDelete?: (id: string) => void }) { const t = useTranslations('tx_history') // Viewers must not see write affordances. CorrectionAffordance opens a // dialog that stages a storno + correction journal entry; the API path // already 403s for viewers but rendering the trigger creates a confusing // dead end. const { canWrite } = useCanWrite() const isIncome = transaction.amount > 0 const isBooked = !!transaction.journal_entry_id // Only user-created rows are deletable; imported (bank sync / CSV) rows are // ignore-only. Mirrors the server guard in DELETE /api/transactions/[id]. const canDelete = !isBooked && !isImportedTransaction(transaction) const isLinkedToInvoice = !!transaction.invoice_id const hasInvoiceMatch = !isLinkedToInvoice && !!transaction.potential_invoice && !isBooked // Underlag status: see computeJeUnderlagStatus. Unknown/not-yet-loaded JE // renders neither badge (no false "saknas" flash while the enrichment loads). const jeStatus = transaction.journal_entry_id ? jeUnderlagStatus?.[transaction.journal_entry_id] : undefined const hasJeDoc = jeStatus === 'has' const missingUnderlag = isBooked && !transaction.document_id && jeStatus === 'missing' const showAttachItem = canWrite && !!onOpenAttachDocument // Same affordance as the inbox card: an unbooked row may need to be linked // to an already-booked voucher (e.g. the other leg of a transfer). const showMatchVoucherItem = canWrite && !isBooked && !!onOpenMatchVoucher const showOverflowMenu = hasInvoiceMatch || (canDelete && !!onDelete) || (isBooked && canWrite) || showAttachItem || showMatchVoucherItem const isPrivate = transaction.is_business === false const categoryLabel = transaction.is_business === true && !(transaction.category === 'uncategorized' && transaction.journal_entry_id) ? getCategoryDisplayName(transaction.category) : null return ( {formatDate(transaction.date)} {transaction.description} onOpenAttachDocument!(transaction) : undefined } /> {categoryLabel && ( {categoryLabel} )} {isLinkedToInvoice && ( {t('linked_to_invoice')} )} {hasInvoiceMatch && ( {t('possible_match_invoice', { number: transaction.potential_invoice!.invoice_number ?? '', })} )} {isIncome ? '+' : ''} {formatCurrency(transaction.amount, transaction.currency)} {isBooked ? ( <> {isPrivate ? t('private_badge') : t('posted')} {t('view_voucher_short')} ) : isPrivate ? ( {t('private_badge')} ) : ( <> {t('not_posted')} )} {showOverflowMenu && ( {/* mr-2 tucks the button in so the dots glyph sits under the middle of the STATUS header, not at the page edge. */} {hasInvoiceMatch && ( onOpenMatchDialog(transaction)}> {t('possible_match_invoice', { number: transaction.potential_invoice!.invoice_number ?? '', })} )} {showMatchVoucherItem && ( onOpenMatchVoucher!(transaction)}> {t('match_voucher')} )} {/* Attach underlag: available on both booked rows (the route propagates the doc onto the verifikation) and unbooked. */} {showAttachItem && ( onOpenAttachDocument!(transaction)}> {t('attach_document')} )} {isBooked && canWrite && transaction.journal_entry_id && ( {({ open, isLoading }) => ( open()} disabled={isLoading}> {isLoading ? t('fetching') : t('create_correction')} )} )} {canDelete && onDelete && ( <> {(hasInvoiceMatch || showAttachItem || showMatchVoucherItem) && } onDelete(transaction.id)} className="text-destructive focus:text-destructive" > {t('delete')} )} )} ) } function SkattekontoHistoryRow({ row, onBokfor, onMatch, }: { row: SkattekontoTransactionWithSuggestion onBokfor?: (row: StoredSkattekontoTransaction) => void onMatch?: (row: StoredSkattekontoTransaction) => void }) { const t = useTranslations('tx_history') const amount = Number(row.belopp_skatteverket) const isIncome = amount > 0 const isBooked = !!row.journal_entry_id return ( {formatDate(row.transaktionsdatum)} {row.transaktionstext} {t('skv_badge')} {!isBooked && row.match_suggestion && ( {t('possible_duplicate')} )} {isIncome ? '+' : ''} {formatCurrency(amount)} {isBooked ? ( <> {t('posted')} {t('view_voucher_short')} ) : ( <> {t('not_posted')} {onMatch && ( )} {!row.match_suggestion && onBokfor && ( )} )} ) }