Bug/momsdeklaration skv (#449)
* fix(salary): show birthdate in masked personnummer, hide the 4-digit suffix Flip the personnummer display format from XXXXXXXX-NNNN to YYYYMMDD-XXXX so the sensitive 4-digit suffix is hidden while the (public) birthdate stays visible. Affects the employees list/detail, salary run, payslip PDF, payslip email, and the MCP server tools (list_employees, get_salary_run). Each call site now decrypts the stored personnummer before masking. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(transactions): allow deleting unbooked transactions from "Alla transaktioner" The history list only let users delete via the inbox card; once a category or mall was picked but the verifikation hadn't been created, the row showed "Ej bokförd" with no way to remove it. The API already permits delete while journal_entry_id is null, so the gap was purely a missing UI affordance. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(vat): populate ruta 20-24 for reverse charge + dishonest "Validera OK" Three connected issues caused Skatteverket to reject momsdeklarationer with FK004 even after our local "Validera"-knapp returned OK. 1. supplier-invoice-entries booked fiktiv moms (2614/2624/2634 + 2645/2647) on reverse-charge invoices but never the underlying basbelopp on 44xx/45xx. Ruta 30-32 filled up at SKV while ruta 20-24 stayed at 0 — SKV's FK004 ("silent netting prohibited", ML 13 kap kräver båda sidor). Fix: generateReverseChargeBasisLines in vat-entries.ts emits parallel 45xx/44xx debit + 4598 motkonto credit per rate group. Engine calls it from registration, cash, and credit-note paths. Skipped when the user booked the expense directly on a basis account to avoid double-counting. 4598 added to BAS reference (no migration needed; account_number is plain text on journal_entry_lines). 2. rutorToMomsuppgift rounded each ruta independently but computed summaMoms from the unrounded ruta49. SKV recomputes the sum from integer rutor on their side, so fractional öres caused ±1 SEK drift and SKV rejected with FK009. Fix: derive summaMoms from the already-rounded VAT-amount rutor. 3. "Validera"-knappen only confirmed SKV's internal arithmetic — a declaration with ruta 30-32 populated and ruta 20-24 empty validated fine until /utkast hit FK004. Users got a false green light. Fix: vat-declaration-checks.ts runs locally before the SKV call, blocks Validera/Spara when ERROR-level findings exist, and surfaces them in a separate "Lokala kontroller"-section. Success message reworded so SKV's OK is no longer presented as filing-ready. Tests: 4535/4536/4531/4425 lines + 4598 motkonto on EU/non-EU/byggtjänster RC, credit-note reversal, fractional-öres summaMoms, all four pre-flight codes (RC_BASIS_MISSING, RC_OUTPUT_MISSING, RC_INPUT_VAT_MISMATCH, SUMMA_MOMS_DRIFT). Backfill for already-posted entries follows in the next commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat: add skattekonto matching functionality - Enhance TransactionInboxCard to display a warning for potential 1930↔1630 transfers. - Implement match suggestions for skattekonto transactions in the backend. - Create SkattekontoMatchDialog component for linking skattekonto rows to existing journal entries. - Develop SkattekontoInboxCard component to handle skattekonto transactions in the inbox. - Introduce skattekonto-match utility functions for candidate matching and linking. - Update types to include match suggestions and enriched transaction responses. * refactor: reorganize skattekonto types and implement bank counterpart matching logic * docs: update CLAUDE.md to streamline integrations and clarify architecture details * refactor: enhance reverse charge logic to handle non-basis accounts and prevent double-counting --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
eb77ad50b5
commit
980f29dae8
@@ -21,8 +21,12 @@ import {
|
||||
ShieldAlert,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import type { VatPeriodType } from '@/types'
|
||||
import type { VatDeclarationRutor, VatPeriodType } from '@/types'
|
||||
import { formatRedovisare, formatRedovisningsperiod } from '@/lib/skatteverket/format'
|
||||
import {
|
||||
runVatDeclarationChecks,
|
||||
type VatDeclarationCheck,
|
||||
} from '@/lib/reports/vat-declaration-checks'
|
||||
|
||||
interface SkatteverketStatus {
|
||||
connected: boolean
|
||||
@@ -45,6 +49,13 @@ interface SkatteverketPanelProps {
|
||||
year: number
|
||||
period: number
|
||||
hasData: boolean
|
||||
/**
|
||||
* Calculated rutor for the current period. Used to run local pre-flight
|
||||
* checks before Skatteverket sees the payload — SKV only validates internal
|
||||
* arithmetic consistency, so we have to catch "ruta 30-32 present but
|
||||
* 20-24 empty" locally before letting the user submit.
|
||||
*/
|
||||
rutor?: VatDeclarationRutor | null
|
||||
}
|
||||
|
||||
function formatAmount(amount: number): string {
|
||||
@@ -58,7 +69,7 @@ export function SkatteverketPanel(props: SkatteverketPanelProps) {
|
||||
return <SkatteverketPanelInner {...props} />
|
||||
}
|
||||
|
||||
function SkatteverketPanelInner({ periodType, year, period, hasData }: SkatteverketPanelProps) {
|
||||
function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: SkatteverketPanelProps) {
|
||||
const [status, setStatus] = useState<SkatteverketStatus | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null)
|
||||
@@ -71,6 +82,14 @@ function SkatteverketPanelInner({ periodType, year, period, hasData }: Skattever
|
||||
tidpunkt?: string
|
||||
} | null>(null)
|
||||
|
||||
// Local sanity checks against the calculated declaration, run before any
|
||||
// SKV call. SKV's "OK" only confirms arithmetic — these checks confirm
|
||||
// the declaration looks plausible (no orphaned RC output, no missing
|
||||
// basis, no summaMoms drift).
|
||||
const localChecks: VatDeclarationCheck[] = rutor ? runVatDeclarationChecks(rutor) : []
|
||||
const localErrors = localChecks.filter((c) => c.status === 'ERROR')
|
||||
const localBlocked = localErrors.length > 0
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/extensions/ext/skatteverket/status')
|
||||
@@ -133,6 +152,13 @@ function SkatteverketPanelInner({ periodType, year, period, hasData }: Skattever
|
||||
}
|
||||
|
||||
const handleValidate = async () => {
|
||||
if (localBlocked) {
|
||||
setError(
|
||||
'Lokala kontroller hittade fel i bokföringen. Åtgärda dessa innan ' +
|
||||
'du skickar till Skatteverket.',
|
||||
)
|
||||
return
|
||||
}
|
||||
setActionLoading('validate')
|
||||
setError(null)
|
||||
setKontroller([])
|
||||
@@ -149,13 +175,19 @@ function SkatteverketPanelInner({ periodType, year, period, hasData }: Skattever
|
||||
const controls: KontrollResult[] = result.data?.kontrollResultat?.resultat || []
|
||||
setKontroller(controls)
|
||||
if (controls.length === 0) {
|
||||
setSuccess('Valideringen godkänd — inga fel eller varningar')
|
||||
// SKV's OK only confirms arithmetic — it does NOT confirm that the
|
||||
// declaration is materially correct. We say so explicitly so the
|
||||
// user doesn't read this as a green light for actual filing.
|
||||
setSuccess(
|
||||
'Skatteverket har inga tekniska invändningar mot deklarationen. ' +
|
||||
'Kontrollera siffrorna i förhandsgranskningen innan du skickar in.',
|
||||
)
|
||||
} else {
|
||||
const errors = controls.filter(k => k.status === 'ERROR')
|
||||
if (errors.length > 0) {
|
||||
setError(`${errors.length} valideringsfel hittades`)
|
||||
} else {
|
||||
setSuccess('Valideringen godkänd med varningar')
|
||||
setSuccess('Skatteverket har inga tekniska invändningar (med varningar)')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -167,6 +199,13 @@ function SkatteverketPanelInner({ periodType, year, period, hasData }: Skattever
|
||||
}
|
||||
|
||||
const handleSaveDraft = async () => {
|
||||
if (localBlocked) {
|
||||
setError(
|
||||
'Lokala kontroller hittade fel i bokföringen. Åtgärda dessa innan ' +
|
||||
'du sparar utkastet hos Skatteverket.',
|
||||
)
|
||||
return
|
||||
}
|
||||
setActionLoading('draft')
|
||||
setError(null)
|
||||
try {
|
||||
@@ -439,11 +478,42 @@ function SkatteverketPanelInner({ periodType, year, period, hasData }: Skattever
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Validation results */}
|
||||
{/* Local pre-flight check results — surfaced separately from SKV's
|
||||
kontroller so the user knows these are gnubok's own sanity checks,
|
||||
not Skatteverket's. ERRORs block the submit/validate buttons. */}
|
||||
{localChecks.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Lokala kontroller
|
||||
</p>
|
||||
{localChecks.map((c, i) => (
|
||||
<div
|
||||
key={`${c.code}-${i}`}
|
||||
className={`flex items-start gap-2 text-sm rounded-lg p-2.5 ${
|
||||
c.status === 'ERROR'
|
||||
? 'bg-destructive/5 text-destructive'
|
||||
: 'bg-amber-50 text-amber-800 dark:bg-amber-950/30 dark:text-amber-200'
|
||||
}`}
|
||||
>
|
||||
{c.status === 'ERROR' ? (
|
||||
<ShieldAlert className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
) : (
|
||||
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
)}
|
||||
<div>
|
||||
<span className="font-mono text-xs mr-1.5">{c.code}</span>
|
||||
{c.message}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Validation results from Skatteverket */}
|
||||
{kontroller.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Valideringsresultat
|
||||
Skatteverkets valideringsresultat
|
||||
</p>
|
||||
{kontroller.map((k, i) => (
|
||||
<div
|
||||
@@ -513,8 +583,9 @@ function SkatteverketPanelInner({ periodType, year, period, hasData }: Skattever
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleValidate}
|
||||
disabled={!hasData || actionLoading !== null}
|
||||
disabled={!hasData || localBlocked || actionLoading !== null}
|
||||
className="gap-1.5"
|
||||
title={localBlocked ? 'Åtgärda lokala kontrollfel innan validering' : undefined}
|
||||
>
|
||||
{actionLoading === 'validate' ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
@@ -528,8 +599,9 @@ function SkatteverketPanelInner({ periodType, year, period, hasData }: Skattever
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSaveDraft}
|
||||
disabled={!hasData || actionLoading !== null}
|
||||
disabled={!hasData || localBlocked || actionLoading !== null}
|
||||
className="gap-1.5"
|
||||
title={localBlocked ? 'Åtgärda lokala kontrollfel innan inlämning' : undefined}
|
||||
>
|
||||
{actionLoading === 'draft' ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import type { StoredSkattekontoTransaction } from '@/types/skatteverket'
|
||||
|
||||
interface MatchCandidate {
|
||||
journal_entry_id: string
|
||||
voucher_number: number | null
|
||||
voucher_series: string | null
|
||||
entry_date: string
|
||||
description: string
|
||||
status: 'draft' | 'posted' | 'reversed'
|
||||
matched_amount: number
|
||||
matched_side: 'debit' | 'credit'
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared dialog for linking a skattekonto_transactions row to an existing
|
||||
* journal entry. Used by both /skattekonto and /transactions so we don't
|
||||
* have two copies of the same dialog drifting apart.
|
||||
*
|
||||
* The dialog owns its own data fetch — pass the row + open flag and it
|
||||
* handles the rest. On successful match it calls onMatched(), letting the
|
||||
* caller refresh its data.
|
||||
*/
|
||||
export function SkattekontoMatchDialog({
|
||||
row,
|
||||
open,
|
||||
onClose,
|
||||
onMatched,
|
||||
}: {
|
||||
row: StoredSkattekontoTransaction | null
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onMatched: () => void
|
||||
}) {
|
||||
const { toast } = useToast()
|
||||
const [candidates, setCandidates] = useState<MatchCandidate[] | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [submittingId, setSubmittingId] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !row) {
|
||||
setCandidates(null)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
;(async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/extensions/ext/skatteverket/skattekonto/transaktioner/${row.id}/match-candidates`,
|
||||
)
|
||||
const json = await res.json()
|
||||
if (cancelled) return
|
||||
if (!res.ok) {
|
||||
throw new Error(json.error || 'Kunde inte söka kandidater')
|
||||
}
|
||||
setCandidates(json.data.candidates as MatchCandidate[])
|
||||
} catch (err) {
|
||||
if (cancelled) return
|
||||
toast({
|
||||
title: 'Kunde inte hämta kandidater',
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
variant: 'destructive',
|
||||
})
|
||||
onClose()
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [open, row, toast, onClose])
|
||||
|
||||
async function confirmMatch(journalEntryId: string) {
|
||||
if (!row) return
|
||||
setSubmittingId(journalEntryId)
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/extensions/ext/skatteverket/skattekonto/transaktioner/${row.id}/match`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ journal_entry_id: journalEntryId }),
|
||||
},
|
||||
)
|
||||
const json = await res.json()
|
||||
if (!res.ok) {
|
||||
throw new Error(json.error || 'Matchning misslyckades')
|
||||
}
|
||||
toast({ title: 'Transaktion kopplad till verifikat' })
|
||||
onMatched()
|
||||
onClose()
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Kunde inte koppla transaktionen',
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setSubmittingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={o => !o && onClose()}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Matcha mot befintligt verifikat</DialogTitle>
|
||||
<DialogDescription>
|
||||
{row && (
|
||||
<>
|
||||
{row.transaktionsdatum} • {row.transaktionstext} •{' '}
|
||||
<span className="tabular-nums">
|
||||
{formatCurrency(Number(row.belopp_skatteverket))}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{loading && (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||
Söker kandidater…
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!loading && candidates && candidates.length === 0 && (
|
||||
<div className="space-y-2 py-4 text-sm">
|
||||
<p>Hittade inga verifikat med en matchande rad på konto 1630.</p>
|
||||
<p className="text-muted-foreground">
|
||||
Kandidaten måste ha samma belopp och sida på 1630 inom ±14 dagar
|
||||
från transaktionsdatumet, och får inte redan vara kopplad till en
|
||||
annan skattekonto-transaktion. Använd <strong>Bokför</strong> för
|
||||
att skapa ett nytt verifikat istället.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && candidates && candidates.length > 0 && (
|
||||
<div className="max-h-[420px] overflow-y-auto rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Datum</TableHead>
|
||||
<TableHead>Verifikat</TableHead>
|
||||
<TableHead>Beskrivning</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{candidates.map(c => (
|
||||
<TableRow key={c.journal_entry_id}>
|
||||
<TableCell className="tabular-nums">{c.entry_date}</TableCell>
|
||||
<TableCell className="tabular-nums">
|
||||
{c.voucher_series && c.voucher_number
|
||||
? `${c.voucher_series}${c.voucher_number}`
|
||||
: '–'}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[260px] truncate">
|
||||
{c.description}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{c.status === 'posted' ? (
|
||||
<Badge variant="secondary">Bokförd</Badge>
|
||||
) : c.status === 'draft' ? (
|
||||
<Badge variant="outline">Utkast</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive">Makulerad</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => confirmMatch(c.journal_entry_id)}
|
||||
disabled={submittingId === c.journal_entry_id}
|
||||
>
|
||||
{submittingId === c.journal_entry_id ? 'Kopplar…' : 'Koppla'}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Avbryt
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { cn, formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { AlertCircle, ArrowUpRight, ArrowDownRight, Landmark, Link2, Loader2 } from 'lucide-react'
|
||||
import type {
|
||||
SkattekontoMatchSuggestion,
|
||||
StoredSkattekontoTransaction,
|
||||
} from '@/types/skatteverket'
|
||||
|
||||
/**
|
||||
* Skattekonto-rad in the /transactions inbox.
|
||||
*
|
||||
* Mirrors the visual rhythm of TransactionInboxCard (same icon circle, same
|
||||
* amount placement) but with SKV-specific actions: Bokför creates a draft via
|
||||
* the skatteverket extension; Matcha opens the shared dialog so users can
|
||||
* link the row to an already-booked manual transfer.
|
||||
*
|
||||
* The Skatteverket badge is the cue that this row is fundamentally different
|
||||
* from a bank tx — different counter-account (1630 vs 1930), different
|
||||
* categorization rules, no AI-suggested invoice matches.
|
||||
*/
|
||||
export default function SkattekontoInboxCard({
|
||||
row,
|
||||
matchSuggestion,
|
||||
processing,
|
||||
onBokfor,
|
||||
onMatch,
|
||||
onAnimationComplete,
|
||||
}: {
|
||||
row: StoredSkattekontoTransaction
|
||||
matchSuggestion?: SkattekontoMatchSuggestion | null
|
||||
processing: boolean
|
||||
onBokfor: (row: StoredSkattekontoTransaction) => void
|
||||
onMatch: (row: StoredSkattekontoTransaction) => void
|
||||
onAnimationComplete?: (id: string) => void
|
||||
}) {
|
||||
const amount = Number(row.belopp_skatteverket)
|
||||
const isIncome = amount > 0
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95, x: -16 }}
|
||||
transition={{ duration: 0.25, ease: [0.25, 0.46, 0.45, 0.94] }}
|
||||
onAnimationComplete={definition => {
|
||||
if (
|
||||
typeof definition === 'object' &&
|
||||
'opacity' in definition &&
|
||||
definition.opacity === 0
|
||||
) {
|
||||
onAnimationComplete?.(row.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
className={cn(
|
||||
'transition-colors',
|
||||
matchSuggestion ? 'border-warning' : 'border-warning/50',
|
||||
)}
|
||||
>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-3 min-w-0 flex-1">
|
||||
<div
|
||||
className={cn(
|
||||
'h-9 w-9 rounded-full flex items-center justify-center flex-shrink-0',
|
||||
isIncome
|
||||
? 'bg-success/10 text-success'
|
||||
: 'bg-destructive/10 text-destructive',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{isIncome ? (
|
||||
<ArrowUpRight className="h-5 w-5" />
|
||||
) : (
|
||||
<ArrowDownRight className="h-5 w-5" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="font-medium truncate">{row.transaktionstext}</p>
|
||||
<Badge variant="outline" className="gap-1 text-[10px]">
|
||||
<Landmark className="h-3 w-3" />
|
||||
Skatteverket
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatDate(row.transaktionsdatum)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right flex-shrink-0">
|
||||
<p
|
||||
className={cn(
|
||||
'font-medium tabular-nums',
|
||||
isIncome && 'text-success',
|
||||
)}
|
||||
>
|
||||
{isIncome ? '+' : ''}
|
||||
{formatCurrency(amount)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{matchSuggestion && (
|
||||
<div className="mt-3 flex items-start gap-2 rounded-md border border-warning/40 bg-warning/5 p-2 text-xs">
|
||||
<AlertCircle className="h-3.5 w-3.5 mt-0.5 flex-shrink-0 text-warning" />
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">
|
||||
Möjlig dublett av verifikat{' '}
|
||||
{matchSuggestion.voucher_series && matchSuggestion.voucher_number
|
||||
? `${matchSuggestion.voucher_series}${matchSuggestion.voucher_number}`
|
||||
: '(utkast)'}
|
||||
</p>
|
||||
<p className="text-muted-foreground truncate">
|
||||
{matchSuggestion.entry_date} • {matchSuggestion.description}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
Det här ser ut som samma kassaflöde — koppla istället för
|
||||
att bokföra om.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 mt-3 pt-3 border-t">
|
||||
{matchSuggestion ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="h-9 text-xs"
|
||||
onClick={() => onMatch(row)}
|
||||
disabled={processing}
|
||||
>
|
||||
<Link2 className="mr-1.5 h-3 w-3" />
|
||||
Koppla till verifikat
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-9 text-xs"
|
||||
onClick={() => onBokfor(row)}
|
||||
disabled={processing}
|
||||
>
|
||||
{processing ? (
|
||||
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
|
||||
) : null}
|
||||
Bokför ändå
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="h-9 text-xs"
|
||||
onClick={() => onBokfor(row)}
|
||||
disabled={processing}
|
||||
>
|
||||
{processing ? (
|
||||
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
|
||||
) : null}
|
||||
Bokför
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-9 text-xs"
|
||||
onClick={() => onMatch(row)}
|
||||
disabled={processing}
|
||||
>
|
||||
<Link2 className="mr-1.5 h-3 w-3" />
|
||||
Matcha mot verifikat
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -1,20 +1,46 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { cn, formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { getCategoryDisplayName } from '@/lib/tax/expense-warnings'
|
||||
import { Search, ArrowUpRight, ArrowDownRight, ArrowLeftRight, Check, Link2, FileText, Loader2 } from 'lucide-react'
|
||||
import {
|
||||
Search,
|
||||
ArrowUpRight,
|
||||
ArrowDownRight,
|
||||
ArrowLeftRight,
|
||||
Check,
|
||||
Landmark,
|
||||
Link2,
|
||||
FileText,
|
||||
Loader2,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import { TransactionAttachmentIndicator } from './TransactionAttachmentIndicator'
|
||||
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[]
|
||||
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
|
||||
@@ -22,16 +48,25 @@ interface TransactionHistoryListProps {
|
||||
|
||||
export default function TransactionHistoryList({
|
||||
transactions,
|
||||
skvRows = [],
|
||||
onOpenMatchDialog,
|
||||
onOpenCategoryDialog,
|
||||
onDelete,
|
||||
onSkvBokfor,
|
||||
onSkvMatch,
|
||||
hasMore,
|
||||
isLoadingMore,
|
||||
onLoadMore,
|
||||
}: TransactionHistoryListProps) {
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [filter, setFilter] = useState<HistoryFilter>('all')
|
||||
const [sourceFilter, setSourceFilter] = useState<SourceFilter>('all')
|
||||
|
||||
const filtered = transactions.filter((t) => {
|
||||
// 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 (it doesn't match either). Source filter narrows
|
||||
// further if the user picks one explicitly.
|
||||
const bankFiltered = transactions.filter((t) => {
|
||||
const matchesSearch = t.description.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
const matchesFilter =
|
||||
filter === 'all' ||
|
||||
@@ -40,6 +75,30 @@ export default function TransactionHistoryList({
|
||||
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 t of bankFiltered) {
|
||||
merged.push({ source: 'bank', date: t.date, data: t })
|
||||
}
|
||||
}
|
||||
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
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Search + filter pills */}
|
||||
@@ -68,6 +127,27 @@ export default function TransactionHistoryList({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showSourceFilter && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">Källa:</span>
|
||||
{(['all', 'bank', 'skatteverket'] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setSourceFilter(s)}
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded-full border px-3 py-1 transition-colors',
|
||||
sourceFilter === s
|
||||
? 'border-foreground bg-foreground text-background'
|
||||
: 'border-border text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{s === 'skatteverket' && <Landmark className="h-3 w-3" />}
|
||||
{s === 'all' ? 'Alla' : s === 'bank' ? 'Bank' : 'Skatteverket'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transaction list */}
|
||||
{filtered.length === 0 ? (
|
||||
<Card>
|
||||
@@ -83,124 +163,24 @@ export default function TransactionHistoryList({
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filtered.map((transaction) => (
|
||||
<Card
|
||||
key={transaction.id}
|
||||
data-tx-id={transaction.id}
|
||||
className="hover:border-primary/50 transition-colors"
|
||||
>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`h-9 w-9 rounded-full flex items-center justify-center flex-shrink-0 ${
|
||||
transaction.amount > 0
|
||||
? 'bg-success/10 text-success'
|
||||
: 'bg-destructive/10 text-destructive'
|
||||
}`}
|
||||
>
|
||||
{transaction.amount > 0 ? (
|
||||
<ArrowUpRight className="h-5 w-5" />
|
||||
) : (
|
||||
<ArrowDownRight className="h-5 w-5" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="font-medium">{transaction.description}</p>
|
||||
<TransactionAttachmentIndicator documentId={transaction.document_id} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{formatDate(transaction.date)}</span>
|
||||
{transaction.is_business !== null &&
|
||||
!(
|
||||
transaction.is_business &&
|
||||
transaction.category === 'uncategorized' &&
|
||||
transaction.journal_entry_id
|
||||
) && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<Badge
|
||||
variant={transaction.is_business ? 'default' : 'secondary'}
|
||||
>
|
||||
{transaction.is_business
|
||||
? getCategoryDisplayName(transaction.category)
|
||||
: 'Privat'}
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
{transaction.invoice_id && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<Badge variant="outline" className="text-primary border-primary">
|
||||
<Link2 className="h-3 w-3 mr-1" />
|
||||
Kopplad till faktura
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
{transaction.journal_entry_id ? (
|
||||
<>
|
||||
<span>·</span>
|
||||
<Badge variant="outline" className="text-success border-success">
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
Bokförd
|
||||
</Badge>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>·</span>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center rounded-md border border-warning px-2.5 py-0.5 text-xs font-semibold text-warning-foreground hover:bg-warning/10 transition-colors"
|
||||
onClick={() => onOpenCategoryDialog(transaction)}
|
||||
>
|
||||
Ej bokförd
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{transaction.potential_invoice && !transaction.invoice_id && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center rounded-md border border-primary px-2.5 py-0.5 text-xs font-semibold text-primary hover:bg-primary/10 transition-colors"
|
||||
onClick={() => onOpenMatchDialog(transaction)}
|
||||
>
|
||||
<FileText className="h-3 w-3 mr-1" />
|
||||
Möjlig match: Faktura {transaction.potential_invoice.invoice_number}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{!transaction.journal_entry_id && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="h-10 text-xs"
|
||||
onClick={() => onOpenCategoryDialog(transaction)}
|
||||
>
|
||||
Bokför
|
||||
</Button>
|
||||
)}
|
||||
<div className="text-right">
|
||||
<p className="font-medium tabular-nums">
|
||||
{transaction.amount > 0 ? '+' : ''}
|
||||
{formatCurrency(transaction.amount, transaction.currency)}
|
||||
</p>
|
||||
{transaction.currency !== 'SEK' && transaction.amount_sek && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatCurrency(transaction.amount_sek)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{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}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{hasMore && onLoadMore && !searchTerm && (
|
||||
<div className="flex justify-center pt-4">
|
||||
<Button
|
||||
@@ -224,3 +204,246 @@ export default function TransactionHistoryList({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BankHistoryRow({
|
||||
transaction,
|
||||
onOpenMatchDialog,
|
||||
onOpenCategoryDialog,
|
||||
onDelete,
|
||||
}: {
|
||||
transaction: TransactionWithInvoice
|
||||
onOpenMatchDialog: (transaction: TransactionWithInvoice) => void
|
||||
onOpenCategoryDialog: (transaction: TransactionWithInvoice) => void
|
||||
onDelete?: (id: string) => void
|
||||
}) {
|
||||
return (
|
||||
<Card data-tx-id={transaction.id} className="hover:border-primary/50 transition-colors">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`h-9 w-9 rounded-full flex items-center justify-center flex-shrink-0 ${
|
||||
transaction.amount > 0
|
||||
? 'bg-success/10 text-success'
|
||||
: 'bg-destructive/10 text-destructive'
|
||||
}`}
|
||||
>
|
||||
{transaction.amount > 0 ? (
|
||||
<ArrowUpRight className="h-5 w-5" />
|
||||
) : (
|
||||
<ArrowDownRight className="h-5 w-5" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="font-medium">{transaction.description}</p>
|
||||
<TransactionAttachmentIndicator documentId={transaction.document_id} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{formatDate(transaction.date)}</span>
|
||||
{transaction.is_business !== null &&
|
||||
!(
|
||||
transaction.is_business &&
|
||||
transaction.category === 'uncategorized' &&
|
||||
transaction.journal_entry_id
|
||||
) && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<Badge variant={transaction.is_business ? 'default' : 'secondary'}>
|
||||
{transaction.is_business
|
||||
? getCategoryDisplayName(transaction.category)
|
||||
: 'Privat'}
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
{transaction.invoice_id && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<Badge variant="outline" className="text-primary border-primary">
|
||||
<Link2 className="h-3 w-3 mr-1" />
|
||||
Kopplad till faktura
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
{transaction.journal_entry_id ? (
|
||||
<>
|
||||
<span>·</span>
|
||||
<Badge variant="outline" className="text-success border-success">
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
Bokförd
|
||||
</Badge>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>·</span>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center rounded-md border border-warning px-2.5 py-0.5 text-xs font-semibold text-warning-foreground hover:bg-warning/10 transition-colors"
|
||||
onClick={() => onOpenCategoryDialog(transaction)}
|
||||
>
|
||||
Ej bokförd
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{transaction.potential_invoice && !transaction.invoice_id && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center rounded-md border border-primary px-2.5 py-0.5 text-xs font-semibold text-primary hover:bg-primary/10 transition-colors"
|
||||
onClick={() => onOpenMatchDialog(transaction)}
|
||||
>
|
||||
<FileText className="h-3 w-3 mr-1" />
|
||||
Möjlig match: Faktura {transaction.potential_invoice.invoice_number}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{!transaction.journal_entry_id && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="h-10 text-xs"
|
||||
onClick={() => onOpenCategoryDialog(transaction)}
|
||||
>
|
||||
Bokför
|
||||
</Button>
|
||||
)}
|
||||
{!transaction.journal_entry_id && onDelete && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
onClick={() => onDelete(transaction.id)}
|
||||
aria-label="Ta bort transaktion"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<div className="text-right">
|
||||
<p className="font-medium tabular-nums">
|
||||
{transaction.amount > 0 ? '+' : ''}
|
||||
{formatCurrency(transaction.amount, transaction.currency)}
|
||||
</p>
|
||||
{transaction.currency !== 'SEK' && transaction.amount_sek && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatCurrency(transaction.amount_sek)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function SkattekontoHistoryRow({
|
||||
row,
|
||||
onBokfor,
|
||||
onMatch,
|
||||
}: {
|
||||
row: SkattekontoTransactionWithSuggestion
|
||||
onBokfor?: (row: StoredSkattekontoTransaction) => void
|
||||
onMatch?: (row: StoredSkattekontoTransaction) => void
|
||||
}) {
|
||||
const amount = Number(row.belopp_skatteverket)
|
||||
const isIncome = amount > 0
|
||||
const isBooked = !!row.journal_entry_id
|
||||
return (
|
||||
<Card className="hover:border-primary/50 transition-colors">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
'h-9 w-9 rounded-full flex items-center justify-center flex-shrink-0',
|
||||
isIncome ? 'bg-success/10 text-success' : 'bg-destructive/10 text-destructive',
|
||||
)}
|
||||
>
|
||||
{isIncome ? (
|
||||
<ArrowUpRight className="h-5 w-5" />
|
||||
) : (
|
||||
<ArrowDownRight className="h-5 w-5" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{row.transaktionstext}</p>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{formatDate(row.transaktionsdatum)}</span>
|
||||
<span>·</span>
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Landmark className="h-3 w-3" />
|
||||
Skatteverket
|
||||
</Badge>
|
||||
{isBooked ? (
|
||||
<>
|
||||
<span>·</span>
|
||||
<Badge variant="outline" className="text-success border-success">
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
Bokförd
|
||||
</Badge>
|
||||
</>
|
||||
) : row.match_suggestion ? (
|
||||
<>
|
||||
<span>·</span>
|
||||
<Badge variant="outline" className="border-warning text-warning">
|
||||
Möjlig dublett
|
||||
</Badge>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>·</span>
|
||||
<Badge variant="outline">Ej bokförd</Badge>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{!isBooked && onMatch && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={row.match_suggestion ? 'default' : 'outline'}
|
||||
className="h-10 text-xs"
|
||||
onClick={() => onMatch(row)}
|
||||
>
|
||||
<Link2 className="mr-1 h-3 w-3" />
|
||||
{row.match_suggestion ? 'Koppla' : 'Matcha'}
|
||||
</Button>
|
||||
)}
|
||||
{!isBooked && !row.match_suggestion && onBokfor && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="h-10 text-xs"
|
||||
onClick={() => onBokfor(row)}
|
||||
>
|
||||
Bokför
|
||||
</Button>
|
||||
)}
|
||||
{isBooked && (
|
||||
<Button asChild size="sm" variant="ghost" className="h-10 text-xs">
|
||||
<Link href={`/bookkeeping/${row.journal_entry_id}`}>Visa verifikat</Link>
|
||||
</Button>
|
||||
)}
|
||||
<div className="text-right">
|
||||
<p
|
||||
className={cn(
|
||||
'font-medium tabular-nums',
|
||||
isIncome && 'text-success',
|
||||
)}
|
||||
>
|
||||
{isIncome ? '+' : ''}
|
||||
{formatCurrency(amount)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { cn, formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { ArrowUpRight, ArrowDownRight, FileText, Loader2, Trash2 } from 'lucide-react'
|
||||
import { AlertCircle, ArrowUpRight, ArrowDownRight, FileText, Loader2, Trash2 } from 'lucide-react'
|
||||
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/info-tooltip'
|
||||
import { getAccountName, formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
|
||||
import { getTemplateById } from '@/lib/bookkeeping/booking-templates'
|
||||
@@ -19,6 +19,10 @@ interface TransactionInboxCardProps {
|
||||
transaction: TransactionWithInvoice
|
||||
suggestions?: SuggestedCategory[]
|
||||
templateSuggestions?: SuggestedTemplate[]
|
||||
/** When set, this bank tx looks like the bank side of a 1930↔1630
|
||||
* transfer that the user will later see on /skattekonto. Renders a
|
||||
* hint warning so the user doesn't book both sides separately. */
|
||||
skvCounterpartDate?: string
|
||||
processingId: string | null
|
||||
isBatchMode: boolean
|
||||
isSelected: boolean
|
||||
@@ -38,6 +42,7 @@ export default function TransactionInboxCard({
|
||||
transaction,
|
||||
suggestions,
|
||||
templateSuggestions,
|
||||
skvCounterpartDate,
|
||||
processingId,
|
||||
isBatchMode,
|
||||
isSelected,
|
||||
@@ -94,6 +99,19 @@ export default function TransactionInboxCard({
|
||||
onClick={showCheckbox ? () => onToggleSelect(transaction.id) : undefined}
|
||||
>
|
||||
<CardContent className="py-4">
|
||||
{skvCounterpartDate && (
|
||||
<div className="mb-3 flex items-start gap-2 rounded-md border border-warning/40 bg-warning/5 p-2 text-xs">
|
||||
<AlertCircle className="h-3.5 w-3.5 mt-0.5 flex-shrink-0 text-warning" />
|
||||
<p className="min-w-0">
|
||||
<span className="font-medium">Möjlig 1930↔1630-överföring.</span>{' '}
|
||||
Det finns en skattekonto-händelse den{' '}
|
||||
<span className="tabular-nums">{skvCounterpartDate}</span> som
|
||||
matchar — bokför detta verifikat först, koppla sedan
|
||||
skattekonto-raden mot samma verifikat istället för att bokföra
|
||||
två gånger.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
{/* Left: checkbox + icon + info */}
|
||||
<div className="flex items-start gap-3 min-w-0 flex-1">
|
||||
|
||||
Reference in New Issue
Block a user