'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, Check, CheckCircle, AlertCircle, XCircle, Filter, } from 'lucide-react' import type { AccountMapping } from '@/lib/import/types' import { isValidBASRange } from '@/lib/import/account-mapper' import type { BASAccount } from '@/types' import { getAccountClassName } from '@/lib/bookkeeping/account-descriptions' import { defaultRateForVatTreatment, vatTreatmentsForAccountClass, type AccountVatTreatment, } from '@/lib/vat/account-vat-treatment' import { InfoTooltip, 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' | 'new_account' | '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(() => { 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) // Targets the dropdown can name: the caller's list (the company chart, or // chart + BAS). A mapped target outside it is an account the import will // CREATE (syncMappedAccounts inserts every missing target, class and type // derived from the number). Such a row is mapped and valid, but a Select // whose value matches no option renders blank, which is how a self-mapped // Fortnox account outside BAS looked unmapped and unmappable (issue #2212). // Every target outside this set is therefore rendered as an explicit // "created on import" option. const knownTargets = useMemo( () => new Set(basAccounts.map((a) => a.account_number)), [basAccounts], ) // Filter and search mappings const filteredMappings = useMemo(() => { let result = mappings // Apply filter switch (filter) { case 'unmapped': result = result.filter((m) => !m.targetAccount) break case 'new_account': result = result.filter((m) => m.targetAccount && !knownTargets.has(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, knownTargets]) // 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 newAccounts = mappings.filter((m) => m.targetAccount && !knownTargets.has(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, newAccounts, lowConfidence, manual, vatReview } }, [mappings, knownTargets]) // After the mapper's self-map rule, an unmapped row is always a number // outside 1000-8999: nothing can be created for it, so the user must pick a // target. Name them so the disabled Continue button is not the only signal. const unmappedAccounts = useMemo( () => mappings.filter((m) => !m.targetAccount).map((m) => m.sourceAccount), [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 (
Kontomappning Varje konto i SIE-filen kopplas till ett konto i din kontoplan. De flesta matchas automatiskt: granska de osäkra nedan.{' '} {t('mapping_new_accounts_note')} {/* Stats */}
0 ? 'secondary' : 'outline'} className="cursor-pointer" onClick={() => handleFilterChange('vat_review')} > {t('vat_review_filter', { count: stats.vatReview })} 0 ? 'destructive' : 'secondary'} className="cursor-pointer" onClick={() => handleFilterChange('unmapped')} > {stats.unmapped} ej mappade 0 ? 'secondary' : 'outline'} className="cursor-pointer" onClick={() => handleFilterChange('new_account')} > {t('new_account_filter', { count: stats.newAccounts })} 0 ? 'secondary' : 'outline'} className="cursor-pointer" onClick={() => handleFilterChange('low_confidence')} > {stats.lowConfidence} osäkra handleFilterChange('manual')} > {stats.manual} manuellt satta handleFilterChange('all')} > Visa alla ({mappings.length})
{unmappedAccounts.length > 0 && (

{t('unmapped_out_of_range_help', { accounts: unmappedAccounts.join(', ') })}

)} {/* Search and filter */}
handleSearchChange(e.target.value)} className="pl-9" />
{/* Mapping table */}
{/* Under table-fixed the header widths ARE the layout: cells never grow them. The budget is ~990px so the table fits a laptop content column at 100 % zoom (#2125: 1216px overflowed, with 144px spent on a four-digit source account); beyond that the wrapper scrolls and the confirm column stays sticky (#1668). 13px text and px-3 cells match the page-level list density. */} Källkonto Källnamn Målkonto {t('vat_treatment_column')} Konfidens {t('vat_treatment_confirm')} {paginatedMappings.map((mapping) => ( {mapping.sourceAccount} {mapping.sourceName ? ( ) : ( {t('source_name_missing')} )} {mapping.sourceAccount === mapping.targetAccount && ['3', '4', '5', '6'].includes(mapping.sourceAccount.charAt(0)) ? (
) : ( - )}
{mapping.targetAccount && ( )} {mapping.requiresVatTreatmentReview && !mapping.vatTreatmentReviewed && ( /* Icon-only: the column header carries the label and the tooltip repeats it on hover; the accessible name also says which row. */ {t('vat_treatment_confirm')} )}
))} {paginatedMappings.length === 0 && ( Inga konton matchar filtret )}
{/* Pagination */} {totalPages > 1 && (

Visar {((currentPage - 1) * PAGE_SIZE) + 1}-{Math.min(currentPage * PAGE_SIZE, filteredMappings.length)} av {filteredMappings.length}

Sida {currentPage} av {totalPages}
)}
{/* Actions */}
{stats.vatReview > 0 && stats.unmapped === 0 && ( )}
) } /** * The "created on import" options a row can select: its own number (when * that is in the auto-create range and not already in the chart) and, if the * row currently points at some other target the list cannot name, that * target too, so the current value always renders. Ordered so the identity * option comes first. */ function createdOptionsFor( mapping: AccountMapping, knownTargets: ReadonlySet, ): Array<{ value: string; name: string }> { const options: Array<{ value: string; name: string }> = [] if (!knownTargets.has(mapping.sourceAccount) && isValidBASRange(mapping.sourceAccount)) { options.push({ value: mapping.sourceAccount, name: mapping.sourceName || `Konto ${mapping.sourceAccount}`, }) } if ( mapping.targetAccount && mapping.targetAccount !== mapping.sourceAccount && !knownTargets.has(mapping.targetAccount) ) { options.push({ value: mapping.targetAccount, name: mapping.targetName || `Konto ${mapping.targetAccount}`, }) } return options } function TruncatedSourceName({ sourceName }: { sourceName: string }) { const [open, setOpen] = useState(false) return ( {sourceName} ) } function ConfidenceBadge({ confidence, isOverride, }: { confidence: number matchType: string // Keep for potential future use isOverride: boolean }) { if (isOverride) { return Manuell } if (confidence >= 0.9) { return Exakt } if (confidence >= 0.7) { return Trolig } return Osäker }