'use client' import { useEffect, useMemo, useState } from 'react' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { Label } from '@/components/ui/label' import { Switch } from '@/components/ui/switch' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table' import { ArrowLeft, ArrowRight, Columns3 } from 'lucide-react' import { formatCurrency } from '@/lib/utils' import { getCSVPreview, normalizeMinusSign, suggestColumnMapping } from '@/lib/import/bank-file/formats/generic-csv' import type { GenericCSVColumnMapping } from '@/lib/import/bank-file/types' const HEADER_KEYWORDS = [ 'datum', 'bokföringsdag', 'bokforingsdag', 'transaktionsdatum', 'reskontradatum', 'beskrivning', 'belopp', 'transaktion', 'text', 'mottagare', 'saldo', 'valuta', 'amount', 'description', 'date', 'title', 'balance', ] interface BankFileColumnMappingStepProps { rawFileContent: string onConfirm: (mapping: GenericCSVColumnMapping) => void onBack: () => void } export default function BankFileColumnMappingStep({ rawFileContent, onConfirm, onBack, }: BankFileColumnMappingStepProps) { const [dateCol, setDateCol] = useState(-1) const [descCol, setDescCol] = useState(-1) const [amountCol, setAmountCol] = useState(-1) const [referenceCol, setReferenceCol] = useState(-1) const [counterpartyCol, setCounterpartyCol] = useState(-1) const [balanceCol, setBalanceCol] = useState(-1) // Auto-detect the most likely delimiter by counting field splits on the first line. // Runs once per file. Users can still override via the dropdown. const detectedDelimiter = useMemo(() => { const firstLine = rawFileContent.split(/\r?\n/).find((l) => l.trim() !== '') ?? '' const candidates: Array<{ d: string; count: number }> = [ { d: ',', count: getCSVPreview(firstLine, ',', 1)[0]?.length ?? 0 }, { d: ';', count: getCSVPreview(firstLine, ';', 1)[0]?.length ?? 0 }, { d: '\t', count: getCSVPreview(firstLine, '\t', 1)[0]?.length ?? 0 }, ] const best = candidates.reduce((a, b) => (b.count > a.count ? b : a)) return best.count > 1 ? best.d : ',' }, [rawFileContent]) const [delimiter, setDelimiter] = useState(detectedDelimiter) const [decimalSep, setDecimalSep] = useState<',' | '.'>(',') const [dateFormat, setDateFormat] = useState('YYYY-MM-DD') // Re-parse headers and preview whenever delimiter or file content changes. // Pull a generous slice (30 rows) so we can scan past metadata preambles like // Northmill's 5-line Kontonummer/Saldo/Kontohavare/Org.Nr/Period header. const parsedRows = useMemo( () => getCSVPreview(rawFileContent, delimiter, 30), [rawFileContent, delimiter] ) // Auto-detect the header row index by scanning for a row whose cells // contain known column-name keywords (bokföringsdag, beskrivning, belopp, …). // A row needs ≥ 2 keyword hits to qualify, which excludes metadata rows where // only the label cell happens to match (e.g. "Saldo,251495,41,SEK"). // Falls back to row 0 if nothing qualifies: preserves prior behavior for // simple files where the header truly is the first row. const DATE_PATTERNS = [/^\d{4}-\d{2}-\d{2}$/, /^\d{2}[./]\d{2}[./]\d{4}$/, /^\d{8}$/] const detectedHeaderRow = useMemo(() => { let best: { idx: number; score: number; cells: number } | null = null for (let i = 0; i < Math.min(parsedRows.length, 20); i++) { const row = parsedRows[i] if (!row || row.length < 2) continue const hits = row.filter((cell) => { const c = cell.trim().toLowerCase() return c.length > 0 && HEADER_KEYWORDS.some((kw) => c === kw || c.includes(kw)) }).length if (hits < 2) continue if (!best || hits > best.score || (hits === best.score && row.length > best.cells)) { best = { idx: i, score: hits, cells: row.length } } } return best?.idx ?? 0 }, [parsedRows]) // Detect whether the file actually has a header row at all: if the auto-detect // landed on row 0 but row 0 already looks like data (any cell is a date), assume // no header. Otherwise trust the detection. const detectedHasHeader = useMemo(() => { const headerRow = parsedRows[detectedHeaderRow] if (!headerRow) return true const looksLikeData = headerRow.some((cell) => DATE_PATTERNS.some((re) => re.test(cell.trim())) ) return !looksLikeData }, [parsedRows, detectedHeaderRow]) const [hasHeaderOverride, setHasHeaderOverride] = useState(null) const hasHeader = hasHeaderOverride ?? detectedHasHeader // skip_rows = number of rows to skip before transaction data starts. // When hasHeader: skip past the header row (detectedHeaderRow + 1). // When no header: skip nothing: data starts at row 0. const skipRows = hasHeader ? detectedHeaderRow + 1 : 0 const columnHeaders = useMemo(() => { if (hasHeader && parsedRows[detectedHeaderRow]) return parsedRows[detectedHeaderRow] const count = parsedRows[0]?.length ?? 0 return Array.from({ length: count }, (_, i) => `Kolumn ${i + 1}`) }, [parsedRows, hasHeader, detectedHeaderRow]) const dataRows = hasHeader ? parsedRows.slice(detectedHeaderRow + 1) : parsedRows // Auto-guess date/description/amount/balance columns. Matches header labels // first (so the trailing Saldo column is never picked as the amount), then // falls back to value heuristics. Initial defaults only: user can override. useEffect(() => { if (dateCol !== -1 || descCol !== -1 || amountCol !== -1) return if (dataRows.length === 0) return const suggestion = suggestColumnMapping(hasHeader ? columnHeaders : null, dataRows) if (suggestion.date >= 0) setDateCol(suggestion.date) if (suggestion.description >= 0) setDescCol(suggestion.description) if (suggestion.amount >= 0) setAmountCol(suggestion.amount) if (suggestion.balance >= 0) setBalanceCol(suggestion.balance) // eslint-disable-next-line react-hooks/exhaustive-deps }, [dataRows, hasHeader, columnHeaders]) const isValid = dateCol >= 0 && descCol >= 0 && amountCol >= 0 const handleConfirm = () => { const mapping: GenericCSVColumnMapping = { date: dateCol, description: descCol, amount: amountCol, ...(referenceCol >= 0 && { reference: referenceCol }), ...(counterpartyCol >= 0 && { counterparty: counterpartyCol }), ...(balanceCol >= 0 && { balance: balanceCol }), delimiter, decimal_separator: decimalSep, skip_rows: skipRows, date_format: dateFormat, } onConfirm(mapping) } const columnOptions = columnHeaders.map((h, i) => ({ label: `${i + 1}: ${h}`, value: i })) return (
Kolumnmappning Vi kunde inte identifiera bankformatet automatiskt. Mappa kolumnerna manuellt. {/* Header row toggle */}

{hasHeader && detectedHeaderRow > 0 ? `Hoppar över ${detectedHeaderRow} metadatarader. Rubrikraden upptäcktes på rad ${detectedHeaderRow + 1}.` : 'Slå av om filen saknar rubrikrad och första raden redan innehåller transaktionsdata.'}

{/* Delimiter, decimal, and date format settings */}
{/* Required column mappings */}

Obligatoriska kolumner

{/* Optional column mappings */}

Valfria kolumner

{/* Live preview */} {isValid && dataRows.length > 0 && ( Förhandsgranskning Så tolkas dina data med den valda mappningen
Datum Beskrivning Belopp {dataRows.slice(0, 5).map((row, i) => { const amountStr = row[amountCol] || '0' const normalizedAmountStr = normalizeMinusSign(amountStr) const amount = decimalSep === ',' ? parseFloat(normalizedAmountStr.replace(/\s/g, '').replace(',', '.')) : parseFloat(normalizedAmountStr.replace(/\s/g, '')) return ( {row[dateCol] || '-'} {row[descCol] || '-'} = 0 ? 'text-success' : 'text-destructive' }`} > {/* SEK is safe here ONLY because generic-csv hardcodes currency: 'SEK' (formats/generic-csv.ts). If the mapper ever gains a currency column, pass it. */} {!isNaN(amount) ? formatCurrency(amount) : amountStr} ) })}
)} {/* Navigation */}
) }