53e343ee92
* feat: add Accounted MCP namespace * fix(bookkeeping): stop flagging verifikat whose underlag lives on a referenced supplier invoice The missing-underlag surfaces only accepted a document directly linked to the entry, so payment verifikat for supplier invoices (doc on the registration entry per design) and entries whose doc was pinned to the bank transaction before matching were falsely flagged; opening the entry showed the referenced doc and cleared the warning client-side, and it came back on reload. - verifikat_without_documents + transactions_without_documents now treat an entry as covered when a supplier invoice referencing it (registration or payment FK, or a supplier_invoice_payments row) carries a document anchored to a journal entry (BFL 5 kap 7 paragraf hänvisning till underlag; anchoring required because the WORM deletion guards key on document_attachments.journal_entry_id) - match-supplier-invoice routes (dashboard + v1) propagate the transaction's pinned document onto the payment verifikat, mirroring the categorize route; migration backfills rows already written (open unlocked periods, company-guarded, never steals a linked doc) - /api/documents/counts, the transactions-page badges, the bulk "Inget underlag krävs" count and the push-notification scheduler share the same reference-aware predicate, so every surface agrees with the RPC - counts route validates journal_entry_ids as UUIDs (they are interpolated into a PostgREST or-filter) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(transactions): align table columns flush with page edges Collapse the checkbox gutter column to zero width and hang the hover-revealed checkbox/expand chevron in the page margins, drop the outer padding so DATUM sits flush left and STATUS flush right, and tuck the overflow-menu dots under the middle of the STATUS header. Applied to both the inbox and history tables so they stay identical. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(arsredovisning): tie anlaggningstillgangar note to booked depreciation The ARL 5:8 roll-forward note recomputed depreciation from its own day-based linear formula (365.25/12 month length, non-inclusive day count, linear only), drifting ~20 kr per year per asset from the ledger-driven resultat- and balansrakning and misstating non-linear methods entirely. Note figures now come from posted depreciation_schedules rows (the same source disposeAsset reverses), falling back to the engine's computeAnnualDepreciation when nothing is posted; pre-onboarding opening balances iterate prior years through the engine. Adds a note-vs-trial-balance tie-out warning (accounts 1000-1299, over 1 kr) surfaced before download. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(stripe): move connect and sync surface from settings to import page Stripe's transaction feed is a continuous import source in the same category as the PSD2 bank connection, so its connect/sync surface now lives on the import page as a source card (mode=stripe), gated "kommer snart" on hosted like before; self-hosted keeps the full panel. - Import page: Stripe card after Koppla bank, renders the existing StripeSettingsPanel via the settings-panel registry - OAuth callback and panel cleanup return to /import?mode=stripe - Settings > Betalningar retired: nav item removed, route redirects, PaymentsSettingsContent deleted, legacy ?tab=payments mapped - New import.stripe_* strings in sv+en; dead settings_nav.payments removed Crons and sync logic unchanged; payment-link settings stay in the invoicing section. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(underlag): paginate missing-underlag cron and harden doc-surface queries Resolve PR review findings on bug/invalid-imports: - notification-scheduler: fetchAllRows on all 5 global reads; past 1000 rows the capped reads produced false "saknade underlag" notifications - bulk-missing: LOOKUP_CHUNK 300->150 so the twice-embedded .or() id list stays under the PostgREST URL limit - bulk-missing + transactions page: UUID-guard the .or()-interpolated id lists, matching documents/counts - match-supplier-invoice (dashboard + v1): log documentId/journalEntryId on the non-fatal doc-link warning - well-known/oauth-protected-resource: document the tool_namespace allow-list - messages/en: reword stripe_description - DECISIONS.md: record the asset ibAck tie-out and Tailwind !important calls Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(tic): convert registrationDate from Unix seconds to millisecond epoch in lookup and profile tests --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
538 lines
20 KiB
TypeScript
538 lines
20 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 { 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<string, JeUnderlagStatus>
|
|
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<HistoryFilter>('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 (
|
|
<div className="space-y-4">
|
|
{/* Business/private seg + source chip, mirroring the inbox toolbar. */}
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<div className="inline-flex shrink-0 gap-0.5 rounded-lg bg-muted/70 p-[3px]" role="tablist">
|
|
{FILTERS.map(({ key, labelKey }) => (
|
|
<button
|
|
key={key}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={filter === key}
|
|
onClick={() => setFilter(key)}
|
|
className={cn(
|
|
'rounded-md px-3.5 py-[5px] text-[12.5px] transition-colors duration-150',
|
|
filter === key
|
|
? 'border border-border bg-card font-medium text-foreground'
|
|
: 'text-muted-foreground hover:text-foreground',
|
|
)}
|
|
>
|
|
{t(labelKey)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
{showSourceFilter && (
|
|
<div className="ml-auto">
|
|
<ContextPicker
|
|
value={sourceFilter}
|
|
onChange={(id) => 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') },
|
|
]}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{filtered.length === 0 ? (
|
|
<DataListEmpty
|
|
icon={<ArrowLeftRight className="h-6 w-6" />}
|
|
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). */
|
|
<div className="-mx-5 overflow-x-auto px-5 md:-mx-8 md:px-8">
|
|
<table className="w-full border-collapse text-[13px]">
|
|
<thead>
|
|
<tr>
|
|
<th className={cn(TH_CLASS, 'w-0 !p-0')} aria-hidden="true"></th>
|
|
<th className={cn(TH_CLASS, '!pl-0')}>{t('th_date')}</th>
|
|
<th className={cn(TH_CLASS, 'w-full')}>{t('th_description')}</th>
|
|
<th className={cn(TH_CLASS, 'text-right')}>{t('th_amount')}</th>
|
|
<th className={cn(TH_CLASS, 'text-right !pr-0')}>{t('th_status')}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="stagger-enter">
|
|
{filtered.map((item) =>
|
|
item.source === 'bank' ? (
|
|
<BankHistoryRow
|
|
key={`bank-${item.data.id}`}
|
|
transaction={item.data}
|
|
jeUnderlagStatus={jeUnderlagStatus}
|
|
onOpenMatchDialog={onOpenMatchDialog}
|
|
onOpenCategoryDialog={onOpenCategoryDialog}
|
|
onOpenAttachDocument={onOpenAttachDocument}
|
|
onOpenMatchVoucher={onOpenMatchVoucher}
|
|
onDelete={onDelete}
|
|
/>
|
|
) : (
|
|
<SkattekontoHistoryRow
|
|
key={`skv-${item.data.id}`}
|
|
row={item.data}
|
|
onBokfor={onSkvBokfor}
|
|
onMatch={onSkvMatch}
|
|
/>
|
|
),
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
{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,
|
|
jeUnderlagStatus,
|
|
onOpenMatchDialog,
|
|
onOpenCategoryDialog,
|
|
onOpenAttachDocument,
|
|
onOpenMatchVoucher,
|
|
onDelete,
|
|
}: {
|
|
transaction: TransactionWithInvoice
|
|
jeUnderlagStatus?: Record<string, JeUnderlagStatus>
|
|
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 (
|
|
<tr
|
|
data-tx-id={transaction.id}
|
|
className="group transition-colors duration-150 hover:bg-secondary/35"
|
|
>
|
|
<td className={cn(TD_CLASS, 'w-0 !p-0')} aria-hidden="true"></td>
|
|
<td className={cn(TD_CLASS, '!pl-0 whitespace-nowrap tabular-nums text-muted-foreground')}>
|
|
{formatDate(transaction.date)}
|
|
</td>
|
|
<td className={cn(TD_CLASS, 'max-w-0 w-full')}>
|
|
<span className="flex min-w-0 items-center gap-2">
|
|
<span className="truncate">{transaction.description}</span>
|
|
<TransactionAttachmentIndicator
|
|
documentId={transaction.document_id}
|
|
journalEntryId={transaction.journal_entry_id}
|
|
hasJeDoc={hasJeDoc}
|
|
missing={missingUnderlag}
|
|
onAttach={
|
|
showAttachItem ? () => onOpenAttachDocument!(transaction) : undefined
|
|
}
|
|
/>
|
|
{categoryLabel && (
|
|
<span className="hidden shrink-0 text-xs text-muted-foreground lg:inline">
|
|
{categoryLabel}
|
|
</span>
|
|
)}
|
|
{isLinkedToInvoice && (
|
|
<span className="hidden shrink-0 items-center gap-1 text-xs text-muted-foreground md:inline-flex">
|
|
<Link2 className="h-3 w-3" />
|
|
{t('linked_to_invoice')}
|
|
</span>
|
|
)}
|
|
{hasInvoiceMatch && (
|
|
<Badge variant="secondary" className="hidden shrink-0 gap-1 font-normal md:inline-flex">
|
|
<FileText className="h-3 w-3" />
|
|
{t('possible_match_invoice', {
|
|
number: transaction.potential_invoice!.invoice_number ?? '',
|
|
})}
|
|
</Badge>
|
|
)}
|
|
</span>
|
|
</td>
|
|
<td
|
|
className={cn(
|
|
TD_CLASS,
|
|
'whitespace-nowrap text-right tabular-nums rr-mask',
|
|
isIncome && 'text-success',
|
|
)}
|
|
title={
|
|
transaction.currency !== 'SEK' && transaction.amount_sek != null
|
|
? formatCurrency(transaction.amount_sek)
|
|
: undefined
|
|
}
|
|
>
|
|
{isIncome ? '+' : ''}
|
|
{formatCurrency(transaction.amount, transaction.currency)}
|
|
</td>
|
|
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right !pr-0 py-[9px]')}>
|
|
<span className="inline-flex items-center justify-end gap-2">
|
|
{isBooked ? (
|
|
<>
|
|
<span className="text-muted-foreground">
|
|
{isPrivate ? t('private_badge') : t('posted')}
|
|
</span>
|
|
<Link
|
|
href={`/bookkeeping/${transaction.journal_entry_id}`}
|
|
className={QUIET_LINK_CLASS}
|
|
>
|
|
{t('view_voucher_short')}
|
|
</Link>
|
|
</>
|
|
) : isPrivate ? (
|
|
<span className="text-muted-foreground">{t('private_badge')}</span>
|
|
) : (
|
|
<>
|
|
<Badge variant="secondary" className="font-normal">
|
|
{t('not_posted')}
|
|
</Badge>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="h-7 px-3.5 text-xs"
|
|
onClick={() => onOpenCategoryDialog(transaction)}
|
|
>
|
|
{t('book')}
|
|
</Button>
|
|
</>
|
|
)}
|
|
{showOverflowMenu && (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
{/* mr-2 tucks the button in so the dots glyph sits under
|
|
the middle of the STATUS header, not at the page edge. */}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="mr-2 h-7 w-7 text-muted-foreground hover:text-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>
|
|
)}
|
|
{showMatchVoucherItem && (
|
|
<DropdownMenuItem onSelect={() => onOpenMatchVoucher!(transaction)}>
|
|
<FileSearch className="h-3.5 w-3.5" />
|
|
{t('match_voucher')}
|
|
</DropdownMenuItem>
|
|
)}
|
|
{/* Attach underlag: available on both booked rows (the route
|
|
propagates the doc onto the verifikation) and unbooked. */}
|
|
{showAttachItem && (
|
|
<DropdownMenuItem onSelect={() => onOpenAttachDocument!(transaction)}>
|
|
<Paperclip className="h-3.5 w-3.5" />
|
|
{t('attach_document')}
|
|
</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 || showAttachItem || showMatchVoucherItem) && <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>
|
|
)}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<tr className="group transition-colors duration-150 hover:bg-secondary/35">
|
|
<td className={cn(TD_CLASS, 'w-0 !p-0')} aria-hidden="true"></td>
|
|
<td className={cn(TD_CLASS, '!pl-0 whitespace-nowrap tabular-nums text-muted-foreground')}>
|
|
{formatDate(row.transaktionsdatum)}
|
|
</td>
|
|
<td className={cn(TD_CLASS, 'max-w-0 w-full')}>
|
|
<span className="flex min-w-0 items-center gap-2">
|
|
<span className="truncate">{row.transaktionstext}</span>
|
|
<Badge variant="outline" className="h-4 shrink-0 gap-1 px-1.5 py-0 text-[10px] font-normal">
|
|
<Landmark className="h-3 w-3" />
|
|
{t('skv_badge')}
|
|
</Badge>
|
|
{!isBooked && row.match_suggestion && (
|
|
<Badge variant="warning" className="h-4 shrink-0 px-1.5 py-0 text-[10px]">
|
|
{t('possible_duplicate')}
|
|
</Badge>
|
|
)}
|
|
</span>
|
|
</td>
|
|
<td
|
|
className={cn(
|
|
TD_CLASS,
|
|
'whitespace-nowrap text-right tabular-nums rr-mask',
|
|
isIncome && 'text-success',
|
|
)}
|
|
>
|
|
{isIncome ? '+' : ''}
|
|
{formatCurrency(amount)}
|
|
</td>
|
|
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right !pr-0 py-[9px]')}>
|
|
<span className="inline-flex items-center justify-end gap-2">
|
|
{isBooked ? (
|
|
<>
|
|
<span className="text-muted-foreground">{t('posted')}</span>
|
|
<Link href={`/bookkeeping/${row.journal_entry_id}`} className={QUIET_LINK_CLASS}>
|
|
{t('view_voucher_short')}
|
|
</Link>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Badge variant="secondary" className="font-normal">
|
|
{t('not_posted')}
|
|
</Badge>
|
|
{onMatch && (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="h-7 px-3.5 text-xs"
|
|
onClick={() => onMatch(row)}
|
|
>
|
|
{row.match_suggestion ? t('link') : t('match')}
|
|
</Button>
|
|
)}
|
|
{!row.match_suggestion && onBokfor && (
|
|
<button type="button" className={QUIET_LINK_CLASS} onClick={() => onBokfor(row)}>
|
|
{t('book')}
|
|
</button>
|
|
)}
|
|
</>
|
|
)}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
)
|
|
}
|