Files
accounted/components/import/AccountMappingStep.tsx
T
Jakob Wennberg 4e20c9dec4 fix(import): bulk-confirm the VAT-treatment review gate in account mapping (#1723)
* fix(import): bulk-confirm the VAT-treatment review gate in account mapping

A Fortnox chart routinely puts 70+ class 3/4 accounts behind the
vat-treatment review gate, and the only way through was one Bekräfta
click per row across paginated 50-row pages. A live migration
(2026-08-18) died exactly there, stuck at 50 kvar with Continue
disabled and no way to see why.

One outline button next to Continue now accepts the suggested default
for every remaining row, with the exact semantics of the per-row
button batched (defaults kept, rows marked reviewed). Wired in both
the import wizard and the Arcim migration workspace. Strings in sv+en.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(guards): two naive-ore-rounds that stacked past the ratchet baseline

#1700 and #1705 each added one Math.round(x*100)/100 and each passed
CI alone against baseline 630; the first branch containing both trips
the ratchet at 631. Convert both to roundOre (629, below baseline).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: place the roundOre import on its own line

The previous commit inserted it inside a multi-line import block,
breaking parsing in pdf-template.tsx.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: give next build an explicit 8 GB heap

The build worker OOMs on the runner's default Node heap since the
bundle crossed the default old-space ceiling (first branch containing
all of 2026-08-19's merges). Public-repo runners have 16 GB.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 21:26:04 +02:00

523 lines
20 KiB
TypeScript

'use client'
import { useState, useMemo } from 'react'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
ArrowRight,
Search,
CheckCircle,
AlertCircle,
XCircle,
Filter,
} from 'lucide-react'
import type { AccountMapping } from '@/lib/import/types'
import type { BASAccount } from '@/types'
import { getAccountClassName } from '@/lib/bookkeeping/account-descriptions'
import {
defaultRateForVatTreatment,
vatTreatmentsForAccountClass,
type AccountVatTreatment,
} from '@/lib/vat/account-vat-treatment'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/info-tooltip'
import { cn } from '@/lib/utils'
interface AccountMappingStepProps {
mappings: AccountMapping[]
basAccounts: BASAccount[]
onMappingChange: (sourceAccount: string, targetAccount: string, targetName: string) => void
onVatTreatmentChange: (
sourceAccount: string,
treatment: AccountVatTreatment | null,
rate: number | null,
) => void
/** Accept the suggested VAT treatment for every unreviewed row at once. */
onConfirmAllVatTreatments: () => void
onContinue: () => void
onBack: () => void
}
type FilterType = 'all' | 'unmapped' | 'vat_review' | 'low_confidence' | 'manual'
const PAGE_SIZE = 50
export default function AccountMappingStep({
mappings,
basAccounts,
onMappingChange,
onVatTreatmentChange,
onConfirmAllVatTreatments,
onContinue,
onBack,
}: AccountMappingStepProps) {
const t = useTranslations('chart_of_accounts')
const [searchTerm, setSearchTerm] = useState('')
// Default to showing unmapped accounts first (most actionable)
const [filter, setFilter] = useState<FilterType>(() => {
const hasUnmapped = mappings.some((m) => !m.targetAccount)
const hasVatReview = mappings.some((m) => m.requiresVatTreatmentReview && !m.vatTreatmentReviewed)
return hasUnmapped ? 'unmapped' : hasVatReview ? 'vat_review' : 'all'
})
const [currentPage, setCurrentPage] = useState(1)
// Filter and search mappings
const filteredMappings = useMemo(() => {
let result = mappings
// Apply filter
switch (filter) {
case 'unmapped':
result = result.filter((m) => !m.targetAccount)
break
case 'low_confidence':
result = result.filter((m) => m.targetAccount && m.confidence < 0.7)
break
case 'vat_review':
result = result.filter((m) => m.requiresVatTreatmentReview && !m.vatTreatmentReviewed)
break
case 'manual':
result = result.filter((m) => m.isOverride)
break
}
// Apply search
if (searchTerm) {
const term = searchTerm.toLowerCase()
result = result.filter(
(m) =>
m.sourceAccount.includes(term) ||
m.sourceName.toLowerCase().includes(term) ||
m.targetAccount?.includes(term) ||
m.targetName?.toLowerCase().includes(term)
)
}
return result
}, [mappings, filter, searchTerm])
// Pagination
const totalPages = Math.ceil(filteredMappings.length / PAGE_SIZE)
const paginatedMappings = useMemo(() => {
const start = (currentPage - 1) * PAGE_SIZE
return filteredMappings.slice(start, start + PAGE_SIZE)
}, [filteredMappings, currentPage])
// Reset page when filter or search changes
const handleFilterChange = (newFilter: FilterType) => {
setFilter(newFilter)
setCurrentPage(1)
}
const handleSearchChange = (term: string) => {
setSearchTerm(term)
setCurrentPage(1)
}
// Calculate stats
const stats = useMemo(() => {
const unmapped = mappings.filter((m) => !m.targetAccount).length
const lowConfidence = mappings.filter((m) => m.targetAccount && m.confidence < 0.7).length
const manual = mappings.filter((m) => m.isOverride).length
const vatReview = mappings.filter((m) => m.requiresVatTreatmentReview && !m.vatTreatmentReviewed).length
return { unmapped, lowConfidence, manual, vatReview }
}, [mappings])
const canContinue = stats.unmapped === 0 && stats.vatReview === 0
// Group BAS accounts by class for the dropdown
const accountsByClass = useMemo(() => {
const groups: { [key: string]: BASAccount[] } = {}
for (const account of basAccounts) {
const className = getAccountClassName(account.account_class)
if (!groups[className]) {
groups[className] = []
}
groups[className].push(account)
}
return groups
}, [basAccounts])
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Kontomappning</CardTitle>
<CardDescription>
Varje konto i SIE-filen kopplas till ett konto i din kontoplan.
De flesta matchas automatiskt: granska de osäkra nedan.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Stats */}
<div className="flex gap-4 flex-wrap">
<Badge
variant={filter === 'vat_review' ? 'default' : stats.vatReview > 0 ? 'secondary' : 'outline'}
className="cursor-pointer"
onClick={() => handleFilterChange('vat_review')}
>
<AlertCircle className="h-3 w-3 mr-1" />
{t('vat_review_filter', { count: stats.vatReview })}
</Badge>
<Badge
variant={filter === 'unmapped' ? 'destructive' : stats.unmapped > 0 ? 'destructive' : 'secondary'}
className="cursor-pointer"
onClick={() => handleFilterChange('unmapped')}
>
<XCircle className="h-3 w-3 mr-1" />
{stats.unmapped} ej mappade
</Badge>
<Badge
variant={filter === 'low_confidence' ? 'default' : stats.lowConfidence > 0 ? 'secondary' : 'outline'}
className="cursor-pointer"
onClick={() => handleFilterChange('low_confidence')}
>
<AlertCircle className="h-3 w-3 mr-1" />
{stats.lowConfidence} osäkra
</Badge>
<Badge
variant={filter === 'manual' ? 'default' : 'outline'}
className="cursor-pointer"
onClick={() => handleFilterChange('manual')}
>
<CheckCircle className="h-3 w-3 mr-1" />
{stats.manual} manuellt satta
</Badge>
<Badge
variant={filter === 'all' ? 'default' : 'outline'}
className="cursor-pointer"
onClick={() => handleFilterChange('all')}
>
Visa alla ({mappings.length})
</Badge>
</div>
{/* Search and filter */}
<div className="flex gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Sök konto..."
value={searchTerm}
onChange={(e) => handleSearchChange(e.target.value)}
className="pl-9"
/>
</div>
<Select value={filter} onValueChange={(v) => handleFilterChange(v as FilterType)}>
<SelectTrigger className="w-48">
<Filter className="h-4 w-4 mr-2" />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Visa alla</SelectItem>
<SelectItem value="unmapped">Ej mappade</SelectItem>
<SelectItem value="vat_review">{t('vat_review_filter', { count: stats.vatReview })}</SelectItem>
<SelectItem value="low_confidence">Osäkra</SelectItem>
<SelectItem value="manual">Manuellt satta</SelectItem>
</SelectContent>
</Select>
</div>
{/* Mapping table */}
<div className="overflow-hidden rounded-lg border">
<Table className="table-fixed">
<TableHeader>
<TableRow>
<TableHead className="w-36">Källkonto</TableHead>
<TableHead className="w-64 max-w-64">Källnamn</TableHead>
<TableHead className="w-12"></TableHead>
<TableHead className="w-64">Målkonto</TableHead>
<TableHead className="min-w-72">{t('vat_treatment_column')}</TableHead>
<TableHead className="w-24">Konfidens</TableHead>
<TableHead className="sticky right-0 z-20 w-32 min-w-32 border-l border-border bg-background text-right">
{t('vat_treatment_confirm')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginatedMappings.map((mapping) => (
<TableRow
key={mapping.sourceAccount}
className={cn('group', !mapping.targetAccount && 'bg-destructive/5')}
>
<TableCell className="font-mono">{mapping.sourceAccount}</TableCell>
<TableCell className="max-w-64 text-muted-foreground">
<TruncatedSourceName sourceName={mapping.sourceName} />
</TableCell>
<TableCell>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
</TableCell>
<TableCell>
<Select
value={mapping.targetAccount || 'none'}
onValueChange={(value) => {
const account = basAccounts.find((a) => a.account_number === value)
onMappingChange(
mapping.sourceAccount,
value === 'none' ? '' : value,
account?.account_name || ''
)
}}
>
<SelectTrigger className={!mapping.targetAccount ? 'border-destructive' : ''}>
<SelectValue placeholder="Välj konto..." />
</SelectTrigger>
<SelectContent className="max-h-80">
<SelectItem value="none">-- Välj konto --</SelectItem>
{Object.entries(accountsByClass).map(([className, accounts]) => (
<div key={className}>
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground bg-muted">
{className}
</div>
{accounts.map((account) => (
<SelectItem
key={account.account_number}
value={account.account_number}
>
<span className="font-mono mr-2">{account.account_number}</span>
{account.account_name}
</SelectItem>
))}
</div>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell>
{mapping.sourceAccount === mapping.targetAccount &&
['3', '4', '5', '6'].includes(mapping.sourceAccount.charAt(0)) ? (
<div className="flex min-w-72 gap-2">
<Select
value={mapping.defaultVatTreatment ?? 'none'}
onValueChange={(value) => {
const treatment = value === 'none'
? null
: value as AccountVatTreatment
const accountClass = Number(mapping.sourceAccount.charAt(0))
const rate = treatment
? mapping.defaultVatRate == null || mapping.vatTreatmentSuggested
? defaultRateForVatTreatment(treatment, accountClass)
: mapping.defaultVatRate
: mapping.defaultVatRate ?? null
onVatTreatmentChange(mapping.sourceAccount, treatment, rate)
}}
>
<SelectTrigger className={mapping.requiresVatTreatmentReview ? 'border-warning/60' : ''}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{t('vat_treatment_none')}</SelectItem>
{vatTreatmentsForAccountClass(
Number(mapping.sourceAccount.charAt(0))
).map((treatment) => (
<SelectItem key={treatment} value={treatment}>
{t(`vat_treatment_${treatment}`)}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={mapping.defaultVatRate === null || mapping.defaultVatRate === undefined
? 'none'
: String(mapping.defaultVatRate)}
onValueChange={(value) => onVatTreatmentChange(
mapping.sourceAccount,
mapping.defaultVatTreatment ?? null,
value === 'none' ? null : Number(value),
)}
>
<SelectTrigger className="w-24" aria-label={t('vat_rate_label')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{t('vat_rate_none')}</SelectItem>
<SelectItem value="0">0 %</SelectItem>
<SelectItem value="0.25">25 %</SelectItem>
<SelectItem value="0.12">12 %</SelectItem>
<SelectItem value="0.06">6 %</SelectItem>
</SelectContent>
</Select>
</div>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>
{mapping.targetAccount && (
<ConfidenceBadge
confidence={mapping.confidence}
matchType={mapping.matchType}
isOverride={mapping.isOverride}
/>
)}
</TableCell>
<TableCell
className={cn(
'sticky right-0 z-10 w-32 min-w-32 border-l border-border transition-colors',
mapping.targetAccount ? 'bg-background' : 'bg-destructive/5',
'group-hover:bg-muted/50 group-focus-within:bg-muted/50',
)}
>
{mapping.requiresVatTreatmentReview && !mapping.vatTreatmentReviewed && (
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 w-full sm:min-h-8"
aria-label={`${t('vat_treatment_confirm')}: ${mapping.sourceAccount}`}
onClick={() => onVatTreatmentChange(
mapping.sourceAccount,
mapping.defaultVatTreatment ?? null,
mapping.defaultVatRate ?? null,
)}
>
{t('vat_treatment_confirm')}
</Button>
)}
</TableCell>
</TableRow>
))}
{paginatedMappings.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground py-8">
Inga konton matchar filtret
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Visar {((currentPage - 1) * PAGE_SIZE) + 1}-{Math.min(currentPage * PAGE_SIZE, filteredMappings.length)} av {filteredMappings.length}
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
>
Föregående
</Button>
<div className="flex items-center gap-1 px-2">
<span className="text-sm">Sida {currentPage} av {totalPages}</span>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
>
Nästa
</Button>
</div>
</div>
)}
</CardContent>
</Card>
{/* Actions */}
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-between">
<Button variant="outline" className="min-h-11" onClick={onBack}>
Tillbaka
</Button>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
{stats.vatReview > 0 && stats.unmapped === 0 && (
<Button
variant="outline"
className="min-h-11"
onClick={onConfirmAllVatTreatments}
>
<CheckCircle className="mr-2 h-4 w-4" />
{t('vat_review_confirm_all', { count: stats.vatReview })}
</Button>
)}
<Button className="min-h-11" onClick={onContinue} disabled={!canContinue}>
{canContinue
? 'Fortsätt till granskning'
: stats.unmapped > 0
? `${stats.unmapped} konton saknar mappning`
: `${stats.vatReview} momskoder återstår att bekräfta`}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</div>
</div>
)
}
function TruncatedSourceName({ sourceName }: { sourceName: string }) {
const [open, setOpen] = useState(false)
return (
<Tooltip open={open} onOpenChange={setOpen}>
<TooltipTrigger asChild>
<button
type="button"
aria-expanded={open}
className="flex min-h-10 w-full min-w-0 items-center rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
onClick={() => setOpen(true)}
>
<span className="block min-w-0 truncate">{sourceName}</span>
</button>
</TooltipTrigger>
<TooltipContent
side="top"
className="max-w-xs break-words"
data-ph-mask=""
>
{sourceName}
</TooltipContent>
</Tooltip>
)
}
function ConfidenceBadge({
confidence,
isOverride,
}: {
confidence: number
matchType: string // Keep for potential future use
isOverride: boolean
}) {
if (isOverride) {
return <Badge variant="default">Manuell</Badge>
}
if (confidence >= 0.9) {
return <Badge variant="success">Exakt</Badge>
}
if (confidence >= 0.7) {
return <Badge variant="secondary">Trolig</Badge>
}
return <Badge variant="outline">Osäker</Badge>
}