'use client' import { useEffect, useMemo, useState } from 'react' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { useToast } from '@/components/ui/use-toast' import { Loader2 } from 'lucide-react' import { createClient } from '@/lib/supabase/client' import { useCompany } from '@/contexts/CompanyContext' import type { StoredAccount } from '../types' interface AccountPickerDialogProps { open: boolean onOpenChange: (open: boolean) => void connectionId: string bankName: string accounts: StoredAccount[] // True when the connection is still in pending_selection — closing without // saving is allowed but the user is reminded that no sync runs until they // confirm. isInitialSelection: boolean onSaved: () => void } interface ChartAccount { account_number: string account_name: string } const LOOKBACK_OPTIONS = [ { days: 90, label: 'Senaste 90 dagar (PSD2 standard, rekommenderas)' }, { days: 180, label: 'Senaste 180 dagar' }, { days: 365, label: 'Senaste 365 dagar' }, ] as const // Suggested BAS account per currency. The mapping engine falls back to 1930 // when ledger_account is unset, so the SEK case is just an explicit hint. // Foreign-currency accounts default to the BAS-recommended numbers; if the // company hasn't created them yet, the user must pick or seed them first. const CURRENCY_DEFAULTS: Record = { SEK: '1930', EUR: '1932', USD: '1933', GBP: '1934', } export function AccountPickerDialog({ open, onOpenChange, connectionId, bankName, accounts, isInitialSelection, onSaved, }: AccountPickerDialogProps) { const { toast } = useToast() // Memoise so the client has a stable reference across re-renders. Without this, // listing `supabase` in the SIE-fetch effect's deps would re-fire that query on // every checkbox tick or parent re-render. const supabase = useMemo(() => createClient(), []) const { company } = useCompany() const [selected, setSelected] = useState>(new Set()) const [isSaving, setIsSaving] = useState(false) const [lookbackDays, setLookbackDays] = useState(90) const [sieLastDate, setSieLastDate] = useState(null) const [showCustomLookback, setShowCustomLookback] = useState(false) const [chartAccounts, setChartAccounts] = useState([]) const [ledgerByUid, setLedgerByUid] = useState>({}) useEffect(() => { if (open) { const initial = new Set( accounts.filter(a => a.enabled !== false).map(a => a.uid) ) setSelected(initial) setShowCustomLookback(false) // Pre-populate ledger picks from existing StoredAccount values, falling // back to currency-based suggestions for accounts the user hasn't mapped yet. const initialLedger: Record = {} for (const a of accounts) { const fromStored = a.ledger_account const fromDefault = CURRENCY_DEFAULTS[a.currency] ?? '' initialLedger[a.uid] = fromStored ?? fromDefault } setLedgerByUid(initialLedger) } }, [open, accounts]) // Fetch the latest SIE import end date so we can anchor the backfill to // "day after last SIE entry" (SpeedLedger pattern). Only matters on the // initial activation flow — selection edits don't re-run sync. useEffect(() => { if (!open || !isInitialSelection || !company?.id) { setSieLastDate(null) return } let cancelled = false ;(async () => { const { data } = await supabase .from('sie_imports') .select('fiscal_year_end') .eq('company_id', company.id) .eq('status', 'completed') .order('fiscal_year_end', { ascending: false }) .limit(1) .maybeSingle() if (cancelled) return const fye = (data as { fiscal_year_end?: string } | null)?.fiscal_year_end || null setSieLastDate(fye) if (fye) { const dayAfter = new Date(fye) dayAfter.setDate(dayAfter.getDate() + 1) const days = Math.ceil((Date.now() - dayAfter.getTime()) / (24 * 60 * 60 * 1000)) setLookbackDays(Math.min(365, Math.max(30, days))) } else { setLookbackDays(90) } })() return () => { cancelled = true } }, [open, isInitialSelection, company?.id, supabase]) // Load 19xx accounts from the chart for the per-account ledger combobox. // Class 19 = bank/cash on the BAS chart. useEffect(() => { if (!open || !company?.id) return let cancelled = false ;(async () => { const { data } = await supabase .from('chart_of_accounts') .select('account_number, account_name') .eq('company_id', company.id) .like('account_number', '19%') .order('account_number', { ascending: true }) if (cancelled) return setChartAccounts((data as ChartAccount[] | null) || []) })() return () => { cancelled = true } }, [open, company?.id, supabase]) const allSelected = accounts.length > 0 && selected.size === accounts.length const noneSelected = selected.size === 0 const sortedAccounts = useMemo( () => [...accounts].sort((a, b) => (a.name || a.iban || '').localeCompare(b.name || b.iban || '')), [accounts] ) // Detect cases where the user routed two enabled accounts with different // currencies to the same BAS account — usually a mistake, but allowed. const currencyConflicts = useMemo(() => { const byLedger = new Map>() for (const a of accounts) { if (!selected.has(a.uid)) continue const ledger = ledgerByUid[a.uid] if (!ledger) continue if (!byLedger.has(ledger)) byLedger.set(ledger, new Set()) byLedger.get(ledger)!.add(a.currency) } return Array.from(byLedger.entries()) .filter(([, currencies]) => currencies.size > 1) .map(([ledger, currencies]) => ({ ledger, currencies: Array.from(currencies) })) }, [accounts, selected, ledgerByUid]) function toggle(uid: string) { setSelected(prev => { const next = new Set(prev) if (next.has(uid)) next.delete(uid) else next.add(uid) return next }) } function selectAll() { setSelected(new Set(accounts.map(a => a.uid))) } function selectNone() { setSelected(new Set()) } async function handleSave() { if (noneSelected) { toast({ title: 'Välj minst ett konto', description: 'Avmarkera alla konton och koppla bort banken istället om inga konton ska synkas.', variant: 'destructive', }) return } // Block save when any enabled account has no ledger picked. The currency // defaults cover SEK/EUR/USD/GBP; other currencies require an explicit pick. const missingLedger = accounts.filter(a => selected.has(a.uid) && !ledgerByUid[a.uid]) if (missingLedger.length > 0) { toast({ title: 'Välj bokföringskonto', description: `Saknar bokföringskonto för: ${missingLedger.map(a => a.name || a.iban || a.uid).join(', ')}`, variant: 'destructive', }) return } setIsSaving(true) try { // Send a mapping entry per selected account. Account_mappings doesn't // include disabled accounts — their existing ledger_account stays untouched. const account_mappings = Array.from(selected).map(uid => ({ uid, ledger_account: ledgerByUid[uid] || null, })) const response = await fetch('/api/extensions/ext/enable-banking/accounts', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ connection_id: connectionId, enabled_uids: Array.from(selected), account_mappings, ...(isInitialSelection ? { initial_lookback_days: lookbackDays } : {}), }), }) const data = await response.json() if (!response.ok) { throw new Error(data.error || 'Kunde inte spara kontoval') } if (isInitialSelection && data.initial_sync) { const { imported, returned_min_date, returned_max_date } = data.initial_sync as { imported: number returned_min_date: string | null returned_max_date: string | null } const range = returned_min_date && returned_max_date ? ` från ${returned_min_date} till ${returned_max_date}` : '' toast({ title: 'Konton sparade', description: `Importerade ${imported} transaktioner${range}.`, }) } else if (isInitialSelection && data.initial_sync_error) { toast({ title: 'Konton sparade — bakgrundssync misslyckades', description: 'Vi försöker igen vid nästa körning. Bankanslutningen är aktiv.', variant: 'destructive', }) } else { toast({ title: 'Kontoval sparat', description: `${data.enabled_count} av ${data.total_count} konton kommer synkas.`, }) } onOpenChange(false) onSaved() } catch (error) { toast({ title: 'Fel', description: error instanceof Error ? error.message : 'Kunde inte spara kontoval', variant: 'destructive', }) } finally { setIsSaving(false) } } const dayAfterSie = useMemo(() => { if (!sieLastDate) return null const d = new Date(sieLastDate) d.setDate(d.getDate() + 1) return d.toISOString().split('T')[0] }, [sieLastDate]) return ( Välj konton att synka — {bankName} {isInitialSelection ? 'Banken har gett åtkomst till följande konton. Avmarkera de konton du inte vill synka transaktioner från, och välj vilket bokföringskonto varje konto ska bokföras mot. Inga transaktioner hämtas innan du sparar.' : 'Justera vilka konton som ska synkas och vilka bokföringskonton de bokförs mot. Konton du avmarkerar slutar synkas från nästa körning; redan importerade transaktioner ligger kvar.'} {isInitialSelection && (
{sieLastDate && dayAfterSie ? (

Vi hittade en SIE-import som täcker fram till{' '} {sieLastDate}. Vi hämtar bankhistorik från{' '} {dayAfterSie}{' '} så att inget överlappar din tidigare bokföring.

{showCustomLookback && ( )}
) : (

PSD2-bankregler begränsar oftast historiken till 90 dagar bakåt. Vi visar exakt vad banken returnerade efter sparat val. För äldre data, använd SIE- eller CSV-import.

)}
)}
{selected.size} av {accounts.length} valda
·
{currencyConflicts.length > 0 && (
Varning: samma bokföringskonto används för flera valutor — {currencyConflicts.map(c => ` ${c.ledger} (${c.currencies.join(', ')})`).join(';')}. Det fungerar tekniskt men gör årsskifte med valutaomvärdering svårare.
)}
{sortedAccounts.map(account => { const isChecked = selected.has(account.uid) const ledger = ledgerByUid[account.uid] || '' const ledgerExistsInChart = chartAccounts.some(c => c.account_number === ledger) return (
{/* Toggle area: label + Checkbox (a Radix Checkbox renders as its own
) })}
) }