'use client' import { useState, useEffect } from 'react' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { useToast } from '@/components/ui/use-toast' import { Plus, Trash2, AlertTriangle } from 'lucide-react' 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 { getErrorMessage } from '@/lib/errors/get-error-message' import { formatCurrency } from '@/lib/utils' import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes' import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone' import type { CreateJournalEntryLineInput, FiscalPeriod, BASAccount, JournalEntrySourceType } from '@/types' export interface FormLine { account_number: string debit_amount: string credit_amount: string line_description: string } interface Props { onCreated?: () => void onEntryCreated?: (entryId: string) => void initialLines?: FormLine[] initialDate?: string initialDescription?: 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, sourceType, sourceId, submitUrl, embedded, }: Props) { const { toast } = useToast() 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 [lines, setLines] = useState( initialLines ?? [{ ...BLANK_LINE }, { ...BLANK_LINE }] ) const [isSubmitting, setIsSubmitting] = useState(false) const [showReview, setShowReview] = useState(false) const [showNoDocWarning, setShowNoDocWarning] = useState(false) const [uploadedFiles, setUploadedFiles] = useState([]) const [accounts, setAccounts] = useState([]) const isUploading = uploadedFiles.some((f) => f.status === 'uploading') const hasContent = description !== '' || lines.some(l => l.account_number !== '' || l.debit_amount !== '' || l.credit_amount !== '') || uploadedFiles.length > 0 useUnsavedChanges(hasContent) useEffect(() => { fetchPeriods() fetchAccounts() }, []) async function fetchPeriods() { const res = await fetch('/api/bookkeeping/fiscal-periods') const { data } = await res.json() setPeriods(data || []) if (data && data.length > 0) { setSelectedPeriod(data[0].id) } } async function fetchAccounts() { const res = await fetch('/api/bookkeeping/accounts') const { data } = await res.json() setAccounts(data || []) } 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) } const totalDebit = lines.reduce((sum, l) => sum + (parseFloat(l.debit_amount) || 0), 0) const totalCredit = lines.reduce((sum, l) => sum + (parseFloat(l.credit_amount) || 0), 0) const isBalanced = Math.round((totalDebit - totalCredit) * 100) === 0 && totalDebit > 0 const handleReview = () => { if (!selectedPeriod || !description || !isBalanced) return const hasDocuments = uploadedFiles.some((f) => f.status === 'uploaded') if (!embedded && !hasDocuments) { setShowNoDocWarning(true) return } setShowReview(true) } const handleConfirm = async () => { setIsSubmitting(true) const entryLines: CreateJournalEntryLineInput[] = lines .filter((l) => l.account_number && (l.debit_amount || l.credit_amount)) .map((l) => ({ 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, })) 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, lines: entryLines, }), }) const result = await res.json() if (result.error) { toast({ title: 'Kunde inte skapa verifikation', description: getErrorMessage(result, { context: 'journal_entry', statusCode: res.status }), variant: 'destructive', }) } else { // Link uploaded documents to the new journal entry (non-blocking) 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 (linkErr) { console.error('[JournalEntryForm] Failed to link document:', linkErr) 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) // Reset form setDescription('') setUploadedFiles([]) setLines([{ ...BLANK_LINE }, { ...BLANK_LINE }]) onCreated?.() if (journalEntryId) { onEntryCreated?.(journalEntryId) } } setIsSubmitting(false) } const formContent = (
{!(embedded && initialDate) && (
setEntryDate(e.target.value)} />
)}
setDescription(e.target.value)} placeholder="Verifikationstext..." />
{/* Entry lines */}
{lines.map((line, index) => ( ))}
Konto Beskrivning Debet Kredit
updateLine(index, 'account_number', num)} /> updateLine(index, 'line_description', e.target.value)} placeholder="Radtext..." className="h-8" /> updateLine(index, 'debit_amount', e.target.value)} placeholder="0,00" className="text-right h-8" min="0" step="0.01" /> updateLine(index, 'credit_amount', e.target.value)} placeholder="0,00" className="text-right h-8" min="0" step="0.01" />
Summa {totalDebit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} {totalCredit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
{/* Document attachments */} {!embedded && (
)} {!isBalanced && totalDebit > 0 && (

Differens: {formatCurrency(Math.abs(totalDebit - totalCredit))}

)}
{(!description || !selectedPeriod || isUploading) && (
{!description &&

Ange en beskrivning

} {!selectedPeriod &&

Välj en räkenskapsperiod

} {isUploading &&

Vänta tills filerna laddats upp

}
)}
p.id === selectedPeriod)?.name || ''} entryDate={entryDate} description={description} lines={lines} totalDebit={totalDebit} totalCredit={totalCredit} attachmentCount={uploadedFiles.filter((f) => f.status === 'uploaded').length} showBalanceBadge={!embedded} hideDate={!!embedded} /> {/* Warning dialog when no documents attached */} { setShowNoDocWarning(false) setShowReview(true) }} isSubmitting={false} title="Underlag saknas" warningText="Ingen verifikation har bifogats. Enligt bokföringslagen (BFL) krävs underlag för varje bokföringspost." confirmLabel="Fortsätt ändå" >

Inget underlag bifogat

Enligt bokföringslagen (BFL 5 kap. 6-7 §§) ska varje bokföringspost ha en verifikation som underlag. Du kan bifoga underlag nu eller fortsätta utan.

) if (embedded) { return formContent } return ( Ny verifikation {formContent} ) }