'use client' import { useState, useEffect, useCallback } from 'react' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Textarea } from '@/components/ui/textarea' import { Label } from '@/components/ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { useToast } from '@/components/ui/use-toast' import { Plus, Trash2, AlertTriangle, Loader2, Lock, CalendarPlus } from 'lucide-react' import { useCanWrite } from '@/lib/hooks/use-can-write' import { ConfirmationDialog } from '@/components/ui/confirmation-dialog' import { JournalEntryReviewContent } from '@/components/bookkeeping/JournalEntryReviewContent' import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone' import AccountCombobox from '@/components/bookkeeping/AccountCombobox' import BookingTemplatePicker from '@/components/bookkeeping/BookingTemplatePicker' import CreatePeriodDialog from '@/components/bookkeeping/CreatePeriodDialog' import { ActivateAccountsDialog } from '@/components/bookkeeping/ActivateAccountsDialog' import { useSubmitWithAccountActivation, throwOnStructuredError, } from '@/lib/hooks/use-submit-with-account-activation' import { getErrorMessage } from '@/lib/errors/get-error-message' import { formatCurrency } from '@/lib/utils' import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes' import { useCompany } from '@/contexts/CompanyContext' import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone' import type { CreateJournalEntryLineInput, FiscalPeriod, BASAccount, JournalEntrySourceType, Currency } from '@/types' const CURRENCIES: { value: Currency; label: string }[] = [ { value: 'SEK', label: 'SEK' }, { value: 'EUR', label: 'EUR' }, { value: 'USD', label: 'USD' }, { value: 'GBP', label: 'GBP' }, { value: 'NOK', label: 'NOK' }, { value: 'DKK', label: 'DKK' }, ] export interface FormLine { account_number: string debit_amount: string credit_amount: string line_description: string currency?: string amount_in_currency?: number exchange_rate?: number } interface Props { onCreated?: () => void onEntryCreated?: (entryId: string) => void initialLines?: FormLine[] initialDate?: string initialDescription?: string initialNotes?: string sourceType?: JournalEntrySourceType sourceId?: string submitUrl?: string embedded?: boolean } const BLANK_LINE: FormLine = { account_number: '', debit_amount: '', credit_amount: '', line_description: '' } export default function JournalEntryForm({ onCreated, onEntryCreated, initialLines, initialDate, initialDescription, initialNotes, sourceType, sourceId, submitUrl, embedded, }: Props) { const { canWrite } = useCanWrite() const { toast } = useToast() const { company } = useCompany() const [periods, setPeriods] = useState([]) const [selectedPeriod, setSelectedPeriod] = useState('') const [entryDate, setEntryDate] = useState(initialDate ?? new Date().toISOString().split('T')[0]) const [description, setDescription] = useState(initialDescription ?? '') const [notes, setNotes] = useState(initialNotes ?? '') const [lines, setLines] = useState( initialLines ?? [{ ...BLANK_LINE }, { ...BLANK_LINE }] ) const [voucherSeries, setVoucherSeries] = useState('A') const [isSubmitting, setIsSubmitting] = useState(false) const [showReview, setShowReview] = useState(false) const [showNoDocWarning, setShowNoDocWarning] = useState(false) const [uploadedFiles, setUploadedFiles] = useState([]) const [accounts, setAccounts] = useState([]) const [entryCurrency, setEntryCurrency] = useState('SEK') const [exchangeRate, setExchangeRate] = useState('') const [isFetchingRate, setIsFetchingRate] = useState(false) const [foreignAmount, setForeignAmount] = useState('') const [periodMismatch, setPeriodMismatch] = useState<'no_period' | 'wrong_period' | null>(null) const [showCreatePeriod, setShowCreatePeriod] = useState(false) const isForeign = entryCurrency !== 'SEK' const isUploading = uploadedFiles.some((f) => f.status === 'uploading') const hasContent = description !== '' || notes !== '' || lines.some(l => l.account_number !== '' || l.debit_amount !== '' || l.credit_amount !== '') || uploadedFiles.length > 0 useUnsavedChanges(hasContent) async function fetchPeriods() { const res = await fetch('/api/bookkeeping/fiscal-periods') const { data } = await res.json() const fetched: FiscalPeriod[] = data || [] setPeriods(fetched) // Auto-select period matching the current entry date const match = fetched.find( (p) => entryDate >= p.period_start && entryDate <= p.period_end ) if (match) { setSelectedPeriod(match.id) setPeriodMismatch(null) } else if (fetched.length > 0) { setSelectedPeriod(fetched[0].id) setPeriodMismatch('no_period') } } async function fetchAccounts() { const res = await fetch('/api/bookkeeping/accounts') const { data } = await res.json() setAccounts(data || []) } useEffect(() => { fetchPeriods() fetchAccounts() // Fetch default voucher series from company settings if (!embedded) { fetch('/api/settings').then(r => r.json()).then(({ data }) => { if (data?.default_voucher_series) setVoucherSeries(data.default_voucher_series) }).catch(() => {/* keep 'A' */}) } }, []) // Auto-select period when entry date changes useEffect(() => { if (periods.length === 0) return const match = periods.find( (p) => entryDate >= p.period_start && entryDate <= p.period_end ) if (match) { setSelectedPeriod(match.id) setPeriodMismatch(null) } else { setPeriodMismatch('no_period') } }, [entryDate, periods]) // Fetch exchange rate from Riksbanken when currency changes const fetchRate = useCallback(async (currency: Currency) => { if (currency === 'SEK') return setIsFetchingRate(true) try { const res = await fetch(`/api/currency/rate?currency=${currency}&date=${entryDate}`) if (res.ok) { const { data } = await res.json() if (data?.rate) { setExchangeRate(String(data.rate)) } } } catch { // Non-critical — user can enter rate manually } finally { setIsFetchingRate(false) } }, [entryDate]) useEffect(() => { if (entryCurrency !== 'SEK') { fetchRate(entryCurrency) } }, [entryCurrency, fetchRate]) const addLine = () => { setLines([...lines, { ...BLANK_LINE }]) } const removeLine = (index: number) => { if (lines.length <= 2) return setLines(lines.filter((_, i) => i !== index)) } const updateLine = (index: number, field: keyof FormLine, value: string) => { const updated = [...lines] updated[index] = { ...updated[index], [field]: value } // If entering debit, clear credit and vice versa if (field === 'debit_amount' && value) { updated[index].credit_amount = '' } else if (field === 'credit_amount' && value) { updated[index].debit_amount = '' } // Auto-fill line description from account name when selecting an account if (field === 'account_number' && value) { const account = accounts.find((a) => a.account_number === value) if (account) { updated[index].line_description = account.account_name } // Auto-fill balancing amount when both amount fields are empty if (!updated[index].debit_amount && !updated[index].credit_amount) { const otherLines = updated.filter((_, i) => i !== index) const otherDebit = otherLines.reduce((sum, l) => sum + (parseFloat(l.debit_amount) || 0), 0) const otherCredit = otherLines.reduce((sum, l) => sum + (parseFloat(l.credit_amount) || 0), 0) const diff = Math.round((otherCredit - otherDebit) * 100) / 100 if (diff > 0) { updated[index].debit_amount = diff.toFixed(2) } else if (diff < 0) { updated[index].credit_amount = Math.abs(diff).toFixed(2) } } } setLines(updated) } // Only lines with both an account and a non-zero amount end up in the submit // payload (see the filter in handleConfirm). Compute totals and balance from // those same lines so the enable-gate matches what the API will actually see. const submittableLines = lines.filter((l) => { const d = parseFloat(l.debit_amount) || 0 const c = parseFloat(l.credit_amount) || 0 return !!l.account_number && (d > 0 || c > 0) }) const incompleteLineCount = lines.filter((l) => { const d = parseFloat(l.debit_amount) || 0 const c = parseFloat(l.credit_amount) || 0 const hasAmount = d > 0 || c > 0 const hasAccount = !!l.account_number // Row counts as incomplete if exactly one of (account, amount) is present. return hasAccount !== hasAmount }).length const totalDebit = submittableLines.reduce((sum, l) => sum + (parseFloat(l.debit_amount) || 0), 0) const totalCredit = submittableLines.reduce((sum, l) => sum + (parseFloat(l.credit_amount) || 0), 0) const isBalanced = Math.round((totalDebit - totalCredit) * 100) === 0 && totalDebit > 0 && submittableLines.length >= 2 && incompleteLineCount === 0 const rate = parseFloat(exchangeRate) || 0 // If user has manually entered a foreign amount, use that; otherwise derive from SEK total const parsedForeignInput = parseFloat(foreignAmount) || 0 const computedForeignAmount = isForeign && rate > 0 ? (parsedForeignInput > 0 ? parsedForeignInput : (totalDebit > 0 ? Math.round(totalDebit / rate * 100) / 100 : 0)) : 0 // The expected SEK equivalent based on foreign amount × rate const computedSekAmount = isForeign && rate > 0 && computedForeignAmount > 0 ? Math.round(computedForeignAmount * rate * 100) / 100 : 0 const handleTemplateApply = (templateLines: FormLine[], templateDescription: string) => { setLines(templateLines) if (!description) setDescription(templateDescription) } const handleReview = () => { if (!selectedPeriod || !description || !isBalanced || periodMismatch) return const hasDocuments = uploadedFiles.some((f) => f.status === 'uploaded') if (!embedded && !hasDocuments) { setShowNoDocWarning(true) return } setShowReview(true) } // Inner submit: builds payload, POSTs, throws a structured error on failure // (so the activation hook can intercept ACCOUNTS_NOT_IN_CHART). const postJournalEntry = useCallback(async () => { let currencyMetaApplied = false const entryLines: CreateJournalEntryLineInput[] = lines .filter((l) => l.account_number && (l.debit_amount || l.credit_amount)) .map((l) => { const base: CreateJournalEntryLineInput = { account_number: l.account_number, debit_amount: parseFloat(l.debit_amount) || 0, credit_amount: parseFloat(l.credit_amount) || 0, line_description: l.line_description || undefined, } if (l.currency) { base.currency = l.currency if (l.amount_in_currency != null) base.amount_in_currency = l.amount_in_currency if (l.exchange_rate != null) base.exchange_rate = l.exchange_rate } else if (isForeign && rate > 0 && l.account_number.startsWith('19') && !currencyMetaApplied) { base.currency = entryCurrency base.amount_in_currency = computedForeignAmount base.exchange_rate = rate currencyMetaApplied = true } return base }) const url = submitUrl ?? '/api/bookkeeping/journal-entries' const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fiscal_period_id: selectedPeriod, entry_date: entryDate, description, source_type: sourceType ?? 'manual', source_id: sourceId, voucher_series: voucherSeries || 'A', notes: notes || undefined, lines: entryLines, }), }) return (await throwOnStructuredError(res)) as { data?: { id?: string; voucher_series?: string; voucher_number?: number }; journal_entry_id?: string } }, [lines, isForeign, rate, entryCurrency, computedForeignAmount, submitUrl, selectedPeriod, entryDate, description, sourceType, sourceId, voucherSeries, notes]) const { runSubmit, dialog: activationDialog, confirm: confirmActivation, cancel: cancelActivation } = useSubmitWithAccountActivation(postJournalEntry) const handleConfirm = async () => { setIsSubmitting(true) try { const result = await runSubmit() const journalEntryId = result.data?.id ?? result.journal_entry_id if (journalEntryId && uploadedFiles.length > 0) { const filesToLink = uploadedFiles.filter((f) => f.status === 'uploaded' && f.id) let linkFailCount = 0 for (const file of filesToLink) { try { await fetch(`/api/documents/${file.id}/link`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ journal_entry_id: journalEntryId }), }) } catch { linkFailCount++ } } if (linkFailCount > 0) { toast({ title: 'Underlag kunde inte bifogas', description: `${linkFailCount} fil(er) kunde inte länkas till verifikationen. Försök igen via bokföringssidan.`, variant: 'destructive', }) } } toast({ title: 'Verifikation skapad', description: `Verifikation ${result.data?.voucher_series ?? ''}${result.data?.voucher_number ?? ''} har skapats.`, }) setShowReview(false) setDescription('') setNotes('') setUploadedFiles([]) setLines([{ ...BLANK_LINE }, { ...BLANK_LINE }]) setEntryCurrency('SEK') setExchangeRate('') setForeignAmount('') onCreated?.() if (journalEntryId) { onEntryCreated?.(journalEntryId) } } catch (err) { if (err instanceof Error && err.message === 'cancelled') { // User dismissed the activation dialog — no toast needed } else { const anyErr = err as { body?: unknown; status?: number } toast({ title: 'Kunde inte skapa verifikation', description: getErrorMessage(anyErr.body ?? err, { context: 'journal_entry', statusCode: anyErr.status }), variant: 'destructive', }) } } finally { setIsSubmitting(false) } } const formContent = (
{!(embedded && initialDate) && (
setEntryDate(e.target.value)} />
)}
setDescription(e.target.value)} placeholder="Verifikationstext..." />