From 3ec0884de45762e764daa2aa93d4a30ecd44d2cc Mon Sep 17 00:00:00 2001 From: Emil Date: Mon, 23 Feb 2026 12:59:21 +0100 Subject: [PATCH] refactor: make JournalEntryForm reusable with embedded mode Add props for initialLines, initialDate, initialDescription, sourceType, sourceId, submitUrl, embedded, and onEntryCreated to support embedding the form in dialogs. Auto-fill line description from account name. Improve AccountCombobox to only emit valid account numbers and reset on blur. Co-Authored-By: Claude Opus 4.6 --- components/bookkeeping/AccountCombobox.tsx | 20 +- components/bookkeeping/JournalEntryForm.tsx | 443 +++++++++++--------- 2 files changed, 257 insertions(+), 206 deletions(-) diff --git a/components/bookkeeping/AccountCombobox.tsx b/components/bookkeeping/AccountCombobox.tsx index eaca2ede..08e62428 100644 --- a/components/bookkeeping/AccountCombobox.tsx +++ b/components/bookkeeping/AccountCombobox.tsx @@ -139,7 +139,10 @@ export default function AccountCombobox({ value, accounts, onChange }: AccountCo const handleInputChange = (e: React.ChangeEvent) => { const newValue = e.target.value setSearch(newValue) - onChange(newValue) + // Only emit valid account numbers to parent + if (/^\d{4}$/.test(newValue) && accounts.some(a => a.account_number === newValue)) { + onChange(newValue) + } if (!isOpen) { setIsOpen(true) } @@ -149,6 +152,15 @@ export default function AccountCombobox({ value, accounts, onChange }: AccountCo setIsOpen(true) } + const handleBlur = () => { + // Small delay to allow dropdown click to fire first + setTimeout(() => { + if (!accounts.some(a => a.account_number === search)) { + setSearch(value) + } + }, 150) + } + // Find matching account for helper text const matchedAccount = useMemo(() => { if (!value || value.length !== 4) return null @@ -162,16 +174,16 @@ export default function AccountCombobox({ value, accounts, onChange }: AccountCo value={search} onChange={handleInputChange} onFocus={handleFocus} + onBlur={handleBlur} onKeyDown={handleKeyDown} placeholder="1930" className="font-mono h-8" - maxLength={4} autoComplete="off" /> - {/* Account name helper text (md+ screens only) */} + {/* Account name helper text */} {matchedAccount && ( -

+

{matchedAccount.account_name}

)} diff --git a/components/bookkeeping/JournalEntryForm.tsx b/components/bookkeeping/JournalEntryForm.tsx index d8ac5e08..1384ad0c 100644 --- a/components/bookkeeping/JournalEntryForm.tsx +++ b/components/bookkeeping/JournalEntryForm.tsx @@ -12,29 +12,48 @@ import { JournalEntryReviewContent } from '@/components/bookkeeping/JournalEntry import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone' import AccountCombobox from '@/components/bookkeeping/AccountCombobox' import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone' -import type { CreateJournalEntryLineInput, FiscalPeriod, BASAccount } from '@/types' +import type { CreateJournalEntryLineInput, FiscalPeriod, BASAccount, JournalEntrySourceType } from '@/types' -interface Props { - onCreated?: () => void -} - -interface FormLine { +export interface FormLine { account_number: string debit_amount: string credit_amount: string line_description: string } -export default function JournalEntryForm({ onCreated }: Props) { +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(new Date().toISOString().split('T')[0]) - const [description, setDescription] = useState('') - const [lines, setLines] = useState([ - { account_number: '', debit_amount: '', credit_amount: '', line_description: '' }, - { account_number: '', debit_amount: '', credit_amount: '', line_description: '' }, - ]) + 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 [uploadedFiles, setUploadedFiles] = useState([]) @@ -63,10 +82,7 @@ export default function JournalEntryForm({ onCreated }: Props) { } const addLine = () => { - setLines([ - ...lines, - { account_number: '', debit_amount: '', credit_amount: '', line_description: '' }, - ]) + setLines([...lines, { ...BLANK_LINE }]) } const removeLine = (index: number) => { @@ -85,12 +101,20 @@ export default function JournalEntryForm({ onCreated }: Props) { updated[index].debit_amount = '' } + // Auto-fill line description from account name when selecting an account + if (field === 'account_number' && value && !updated[index].line_description) { + const account = accounts.find((a) => a.account_number === value) + if (account) { + updated[index].line_description = account.account_name + } + } + 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.abs(totalDebit - totalCredit) < 0.01 && totalDebit > 0 + const isBalanced = Math.round((totalDebit - totalCredit) * 100) === 0 && totalDebit > 0 const handleReview = () => { if (!selectedPeriod || !description || !isBalanced) return @@ -109,14 +133,17 @@ export default function JournalEntryForm({ onCreated }: Props) { line_description: l.line_description || undefined, })) - const res = await fetch('/api/bookkeeping/journal-entries', { + 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: 'manual', + source_type: sourceType ?? 'manual', + source_id: sourceId, lines: entryLines, }), }) @@ -131,8 +158,8 @@ export default function JournalEntryForm({ onCreated }: Props) { }) } else { // Link uploaded documents to the new journal entry (non-blocking) - const journalEntryId = result.data?.id - if (journalEntryId) { + const journalEntryId = result.data?.id ?? result.journal_entry_id + if (journalEntryId && uploadedFiles.length > 0) { const filesToLink = uploadedFiles.filter((f) => f.status === 'uploaded' && f.id) for (const file of filesToLink) { try { @@ -149,163 +176,160 @@ export default function JournalEntryForm({ onCreated }: Props) { toast({ title: 'Verifikation skapad', - description: `Verifikation ${result.data?.voucher_series}${result.data?.voucher_number} har skapats.`, + description: `Verifikation ${result.data?.voucher_series ?? ''}${result.data?.voucher_number ?? ''} har skapats.`, }) setShowReview(false) // Reset form setDescription('') setUploadedFiles([]) - setLines([ - { account_number: '', debit_amount: '', credit_amount: '', line_description: '' }, - { account_number: '', debit_amount: '', credit_amount: '', line_description: '' }, - ]) + setLines([{ ...BLANK_LINE }, { ...BLANK_LINE }]) onCreated?.() + if (journalEntryId) { + onEntryCreated?.(journalEntryId) + } } setIsSubmitting(false) } - return ( - - - Ny verifikation - - -
-
- - -
-
- - setEntryDate(e.target.value)} - /> -
-
- - setDescription(e.target.value)} - placeholder="Verifikationstext..." - /> -
-
- - {/* Entry lines */} + const formContent = ( +
+
- - - - - - - - - - - - {lines.map((line, index) => ( - - - - - - - - ))} - - - - - - - - - -
KontoBeskrivningDebetKredit
- 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 })} -
- -
+
+ + setEntryDate(e.target.value)} + /> +
+
+ + setDescription(e.target.value)} + placeholder="Verifikationstext..." + /> +
+
- {/* Document attachments */} + {/* Entry lines */} +
+ + + + + + + + + + + + {lines.map((line, index) => ( + + + + + + + + ))} + + + + + + + + + +
KontoBeskrivningDebetKredit
+ 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: {Math.abs(totalDebit - totalCredit).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} kr -

- )} + {!isBalanced && totalDebit > 0 && ( +

+ Differens: {Math.abs(totalDebit - totalCredit).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} kr +

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

Ange en beskrivning

} - {!selectedPeriod &&

Välj en räkenskapsperiod

} - {isUploading &&

Vänta tills filerna laddats upp

} -
- )} -
- - + + {(!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} + /> + + + ) + + if (embedded) { + return formContent + } + + return ( + + + Ny verifikation + + + {formContent} )