Files
accounted/components/transactions/SkattekontoInboxCard.tsx
T
Jakob WennbergandClaude Sonnet 5 ec27228a8e style: remove em/en dashes repo-wide, add CLAUDE.md rule against them (#890)
Em dashes (—) and en dashes (–) had spread across comments, docs, tests,
and a few UI strings, reading as AI-generated boilerplate rather than
house style. Replaced each with punctuation matching its context: colon
for explanatory clauses, comma for asides, plain hyphen for numeric/legal
ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for
paired-dash asides. messages/en.json and messages/sv.json were fixed by
hand together to keep sv/en in sync.

Left untouched where the dash is the functional subject rather than
decorative punctuation: date-range-parser.ts's separator regex,
charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE
encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the
agent system-prompt files that already instruct against em dashes, and
a golden iXBRL test fixture compared byte-for-byte.

Also fixes two bugs surfaced along the way: an off-by-one in
ApiKeysPanel's scope-label split (a leftover from an earlier partial
pass), and a charset-repair test that had lost the literal en-dash it
exists to verify.

Regenerated the agent atom seed migration (skills:generate) since 27
SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes,
with an explicit carve-out for the functional-dash cases above.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 15:58:06 +02:00

172 lines
5.4 KiB
TypeScript

'use client'
import { useTranslations } from 'next-intl'
import { motion } from 'framer-motion'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
DataListRow,
DataListPrimary,
DataListMeta,
DataListMetaSeparator,
} from '@/components/ui/data-list'
import { cn, formatCurrency, formatDate } from '@/lib/utils'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
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. 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.
*/
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 t = useTranslations('tx_skattekonto_card')
const amount = Number(row.belopp_skatteverket)
const isIncome = amount > 0
const duplicateLabel =
matchSuggestion?.voucher_series && matchSuggestion?.voucher_number
? t('duplicate_title_with_voucher', {
label: formatVoucher({
voucher_series: matchSuggestion.voucher_series,
voucher_number: matchSuggestion.voucher_number,
}),
})
: t('duplicate_title_draft')
return (
<motion.div
layout
initial={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.97, 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)
}
}}
>
<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>
{matchSuggestion ? (
<>
<Button
size="sm"
variant="default"
className="h-8 px-3 text-xs"
onClick={() => onMatch(row)}
disabled={processing}
>
<Link2 className="mr-1 h-3 w-3" />
{t('link_to_voucher')}
</Button>
<Button
size="sm"
variant="ghost"
className="h-8 px-3 text-xs"
onClick={() => onBokfor(row)}
disabled={processing}
>
{processing && <Loader2 className="mr-1 h-3 w-3 animate-spin" />}
{t('book_anyway')}
</Button>
</>
) : (
<>
<Button
size="sm"
variant="default"
className="h-8 px-3 text-xs"
onClick={() => onBokfor(row)}
disabled={processing}
>
{processing && <Loader2 className="mr-1 h-3 w-3 animate-spin" />}
{t('book')}
</Button>
<Button
size="sm"
variant="outline"
className="h-8 px-3 text-xs"
onClick={() => onMatch(row)}
disabled={processing}
>
<Link2 className="mr-1 h-3 w-3" />
{t('match_to_voucher')}
</Button>
</>
)}
</>
}
>
<div className="flex items-center gap-1.5 min-w-0">
<DataListPrimary>{row.transaktionstext}</DataListPrimary>
</div>
<DataListMeta>
<span className="tabular-nums">{formatDate(row.transaktionsdatum)}</span>
<DataListMetaSeparator />
<span className="inline-flex items-center gap-1 text-muted-foreground">
<Landmark className="h-3 w-3" />
{t('skv_badge')}
</span>
{matchSuggestion && (
<>
<DataListMetaSeparator />
<Badge variant="warning" className="h-4 gap-1 px-1.5 py-0 text-[10px]">
<AlertCircle className="h-3 w-3" />
{duplicateLabel}
</Badge>
</>
)}
</DataListMeta>
</DataListRow>
</motion.div>
)
}