Files
accounted/components/transactions/TransactionHistoryList.tsx
T
MattssonandClaude Opus 4.8 64991eb3c9 Add/transaction deletion (#695)
* feat(salary): add remove-employee button to draft salary runs

The DELETE /api/salary/runs/{id}/employees/{employeeId} endpoint already
existed (draft-only, cascades to the employee's line items) but had no UI
trigger, so a mistakenly added employee could only be cleared by deleting
the whole draft. Add a trash-icon action column to the "Anställda" table,
gated on draft status + write permission to match the endpoint's guard,
with a confirm prompt and success/error toast.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(settings): prevent horizontal overflow on mobile

The company settings invite form was a non-wrapping fixed-width flex row that overflowed narrow viewports, forcing the full-screen settings modal to scroll on the x-axis. Stack the form vertically on mobile (sm:flex-row at and above the sm breakpoint) and add the missing min-w-0 guard to the modal content pane.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(bookkeeping): move journal entry filters into a filter dialog

The ledger toolbar showed every filter inline (fiscal year, sort, series,
date range, missing-documents toggle), which felt cluttered. Keep only the
search field visible and move the rest into a "Filtrera" dialog with an
active-filter count badge.

- JournalEntryList now owns the fiscal-year scope, restored from the same
  localStorage key FiscalYearSelector writes, so the page no longer renders
  the selector separately.
- Filters apply live and the dialog stays open; "Rensa alla filter" clears them.
- Export STORAGE_KEY_PREFIX / ALL_YEARS_VALUE from FiscalYearSelector so the
  list reuses the persisted selection without duplicating the key.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(transactions): implement imported transaction guard for deletion

- Added a guard to prevent deletion of transactions that are imported via bank sync or file uploads.
- Introduced `isImportedTransaction` utility to determine if a transaction is user-created or imported.
- Updated DELETE endpoint to return a 409 status for attempts to delete imported transactions.
- Enhanced transaction history and inbox components to reflect the new deletion rules.
- Added tests for transaction origin determination and deletion behavior.
- Updated UI components to include a confirmation dialog for clearing journal entry forms.
- Localized new strings for clearing form functionality in English and Swedish.

* feat(transactions): enhance transaction deletion guard and improve fiscal year visibility

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 15:25:27 +02:00

521 lines
17 KiB
TypeScript

'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 { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
DataList,
DataListHeader,
DataListRow,
DataListPrimary,
DataListMeta,
DataListMetaSeparator,
DataListEmpty,
} from '@/components/ui/data-list'
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
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 {
ArrowUpRight,
ArrowDownRight,
ArrowLeftRight,
Check,
ChevronDown,
Landmark,
Link2,
FileText,
Loader2,
MoreHorizontal,
Trash2,
} from 'lucide-react'
import { TransactionAttachmentIndicator } from './TransactionAttachmentIndicator'
import CorrectionAffordance from '@/components/bookkeeping/CorrectionAffordance'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { TransactionWithInvoice, HistoryFilter } from './transaction-types'
import type {
SkattekontoTransactionWithSuggestion,
StoredSkattekontoTransaction,
} from '@/types/skatteverket'
type SourceFilter = 'all' | 'bank' | 'skatteverket'
type HistoryRow =
| { source: 'bank'; date: string; data: TransactionWithInvoice }
| { source: 'skatteverket'; date: string; data: SkattekontoTransactionWithSuggestion }
interface TransactionHistoryListProps {
transactions: TransactionWithInvoice[]
skvRows?: SkattekontoTransactionWithSuggestion[]
searchTerm?: string
onOpenMatchDialog: (transaction: TransactionWithInvoice) => void
onOpenCategoryDialog: (transaction: TransactionWithInvoice) => void
onDelete?: (id: string) => void
onSkvBokfor?: (row: StoredSkattekontoTransaction) => void
onSkvMatch?: (row: StoredSkattekontoTransaction) => void
hasMore?: boolean
isLoadingMore?: boolean
onLoadMore?: () => void
}
export default function TransactionHistoryList({
transactions,
skvRows = [],
searchTerm = '',
onOpenMatchDialog,
onOpenCategoryDialog,
onDelete,
onSkvBokfor,
onSkvMatch,
hasMore,
isLoadingMore,
onLoadMore,
}: TransactionHistoryListProps) {
const t = useTranslations('tx_history')
const [filter, setFilter] = useState<HistoryFilter>('all')
const [sourceFilter, setSourceFilter] = useState<SourceFilter>('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 = skvRows.length > 0 && transactions.length > 0
const filtered = merged
const showHeader = showSourceFilter
return (
<div className="space-y-4">
{/* Business/private tabs */}
<Tabs value={filter} onValueChange={(v) => setFilter(v as HistoryFilter)}>
<TabsList>
<TabsTrigger value="all">{t('filter_all')}</TabsTrigger>
<TabsTrigger value="business">{t('filter_business')}</TabsTrigger>
<TabsTrigger value="private">{t('filter_private')}</TabsTrigger>
</TabsList>
</Tabs>
<DataList>
{showHeader && (
<DataListHeader>
<span className="text-xs uppercase tracking-wider text-muted-foreground">
{t('source_label')}
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-7 gap-1.5 px-2 text-xs">
{sourceFilter === 'all'
? t('source_all')
: sourceFilter === 'bank'
? t('source_bank')
: t('source_skatteverket')}
<ChevronDown className="h-3 w-3 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[12rem]">
<DropdownMenuRadioGroup
value={sourceFilter}
onValueChange={(v) => setSourceFilter(v as SourceFilter)}
>
<DropdownMenuRadioItem value="all">{t('source_all')}</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="bank">{t('source_bank')}</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="skatteverket">{t('source_skatteverket')}</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</DataListHeader>
)}
{filtered.length === 0 ? (
<DataListEmpty
icon={<ArrowLeftRight className="h-6 w-6" />}
title={t('empty_title')}
description={searchTerm ? t('empty_search') : t('empty_filter')}
/>
) : (
filtered.map((item) =>
item.source === 'bank' ? (
<BankHistoryRow
key={`bank-${item.data.id}`}
transaction={item.data}
onOpenMatchDialog={onOpenMatchDialog}
onOpenCategoryDialog={onOpenCategoryDialog}
onDelete={onDelete}
/>
) : (
<SkattekontoHistoryRow
key={`skv-${item.data.id}`}
row={item.data}
onBokfor={onSkvBokfor}
onMatch={onSkvMatch}
/>
),
)
)}
</DataList>
{hasMore && onLoadMore && !searchTerm && filtered.length > 0 && (
<div className="flex justify-center">
<Button variant="outline" onClick={onLoadMore} disabled={isLoadingMore}>
{isLoadingMore ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('loading_more')}
</>
) : (
t('load_more')
)}
</Button>
</div>
)}
</div>
)
}
function BankHistoryRow({
transaction,
onOpenMatchDialog,
onOpenCategoryDialog,
onDelete,
}: {
transaction: TransactionWithInvoice
onOpenMatchDialog: (transaction: TransactionWithInvoice) => void
onOpenCategoryDialog: (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
// Primary status badge — pick the most informative one.
const statusBadge = (() => {
if (isBooked) {
return (
<Badge variant="success" className="h-4 gap-1 px-1.5 py-0 text-[10px]">
<Check className="h-3 w-3" />
{t('posted')}
</Badge>
)
}
return (
<Badge variant="warning" className="h-4 px-1.5 py-0 text-[10px]">
{t('not_posted')}
</Badge>
)
})()
const categoryLabel =
transaction.is_business !== null &&
!(
transaction.is_business &&
transaction.category === 'uncategorized' &&
transaction.journal_entry_id
)
? transaction.is_business
? getCategoryDisplayName(transaction.category)
: t('private_badge')
: null
return (
<DataListRow
data-tx-id={transaction.id}
leading={
<span
className={cn(
'inline-flex h-5 w-5 items-center justify-center',
isIncome ? 'text-success' : 'text-foreground/60'
)}
aria-hidden
>
{isIncome ? (
<ArrowUpRight className="h-4 w-4" />
) : (
<ArrowDownRight className="h-4 w-4" />
)}
</span>
}
trailing={
<>
<div className="text-right">
<p
className={cn(
'font-medium tabular-nums leading-none',
isIncome && 'text-success'
)}
>
{isIncome ? '+' : ''}
{formatCurrency(transaction.amount, transaction.currency)}
</p>
{transaction.currency !== 'SEK' && transaction.amount_sek != null && (
<p className="mt-1 text-[11px] text-muted-foreground tabular-nums">
{formatCurrency(transaction.amount_sek)}
</p>
)}
</div>
{!isBooked && (
<Button
size="sm"
variant="default"
className="h-8 px-3 text-xs"
onClick={() => onOpenCategoryDialog(transaction)}
>
{t('book')}
</Button>
)}
{isBooked && (
<Button asChild size="sm" variant="ghost" className="h-8 px-3 text-xs">
<Link href={`/bookkeeping/${transaction.journal_entry_id}`}>
{t('view_voucher_short')}
</Link>
</Button>
)}
{(hasInvoiceMatch || (canDelete && onDelete) || (isBooked && canWrite)) && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground"
aria-label="Fler alternativ"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{hasInvoiceMatch && (
<DropdownMenuItem onSelect={() => onOpenMatchDialog(transaction)}>
<FileText className="h-3.5 w-3.5" />
{t('possible_match_invoice', {
number: transaction.potential_invoice!.invoice_number ?? '',
})}
</DropdownMenuItem>
)}
{isBooked && canWrite && transaction.journal_entry_id && (
<CorrectionAffordance journalEntryId={transaction.journal_entry_id}>
{({ open, isLoading }) => (
<DropdownMenuItem onSelect={() => open()} disabled={isLoading}>
{isLoading ? t('fetching') : t('create_correction')}
</DropdownMenuItem>
)}
</CorrectionAffordance>
)}
{canDelete && onDelete && (
<>
{hasInvoiceMatch && <DropdownMenuSeparator />}
<DropdownMenuItem
onSelect={() => onDelete(transaction.id)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
{t('delete')}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</>
}
>
<div className="flex items-center gap-1.5 min-w-0">
<DataListPrimary>{transaction.description}</DataListPrimary>
<TransactionAttachmentIndicator documentId={transaction.document_id} />
</div>
<DataListMeta>
<span className="tabular-nums">{formatDate(transaction.date)}</span>
<DataListMetaSeparator />
{statusBadge}
{categoryLabel && (
<>
<DataListMetaSeparator />
<span>{categoryLabel}</span>
</>
)}
{isLinkedToInvoice && (
<>
<DataListMetaSeparator />
<span className="inline-flex items-center gap-1">
<Link2 className="h-3 w-3" />
{t('linked_to_invoice')}
</span>
</>
)}
{hasInvoiceMatch && (
<>
<DataListMetaSeparator />
<span className="inline-flex items-center gap-1 text-primary">
<FileText className="h-3 w-3" />
{t('possible_match_invoice', {
number: transaction.potential_invoice!.invoice_number ?? '',
})}
</span>
</>
)}
</DataListMeta>
</DataListRow>
)
}
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
const statusBadge = (() => {
if (isBooked) {
return (
<Badge variant="success" className="h-4 gap-1 px-1.5 py-0 text-[10px]">
<Check className="h-3 w-3" />
{t('posted')}
</Badge>
)
}
if (row.match_suggestion) {
return (
<Badge variant="warning" className="h-4 px-1.5 py-0 text-[10px]">
{t('possible_duplicate')}
</Badge>
)
}
return (
<Badge variant="outline" className="h-4 px-1.5 py-0 text-[10px]">
{t('not_posted')}
</Badge>
)
})()
return (
<DataListRow
leading={
<span
className={cn(
'inline-flex h-5 w-5 items-center justify-center',
isIncome ? 'text-success' : 'text-foreground/60'
)}
aria-hidden
>
{isIncome ? (
<ArrowUpRight className="h-4 w-4" />
) : (
<ArrowDownRight className="h-4 w-4" />
)}
</span>
}
trailing={
<>
<div className="text-right">
<p
className={cn(
'font-medium tabular-nums leading-none',
isIncome && 'text-success'
)}
>
{isIncome ? '+' : ''}
{formatCurrency(amount)}
</p>
</div>
{!isBooked && onMatch && (
<Button
size="sm"
variant={row.match_suggestion ? 'default' : 'outline'}
className="h-8 px-3 text-xs"
onClick={() => onMatch(row)}
>
<Link2 className="mr-1 h-3 w-3" />
{row.match_suggestion ? t('link') : t('match')}
</Button>
)}
{!isBooked && !row.match_suggestion && onBokfor && (
<Button
size="sm"
variant="default"
className="h-8 px-3 text-xs"
onClick={() => onBokfor(row)}
>
{t('book')}
</Button>
)}
{isBooked && (
<Button asChild size="sm" variant="ghost" className="h-8 px-3 text-xs">
<Link href={`/bookkeeping/${row.journal_entry_id}`}>
{t('view_voucher_short')}
</Link>
</Button>
)}
</>
}
>
<DataListPrimary>{row.transaktionstext}</DataListPrimary>
<DataListMeta>
<span className="tabular-nums">{formatDate(row.transaktionsdatum)}</span>
<DataListMetaSeparator />
<span className="inline-flex items-center gap-1">
<Landmark className="h-3 w-3" />
{t('skv_badge')}
</span>
<DataListMetaSeparator />
{statusBadge}
</DataListMeta>
</DataListRow>
)
}