4e20c9dec4
* 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>
523 lines
20 KiB
TypeScript
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>
|
|
}
|
|
|