Files
accounted/components/bookkeeping/JournalEntryForm.tsx
T
Jakob Wennberg b387a77bfd chore: remove Sentry, consolidate migrations, add test coverage (#244)
* chore: remove Sentry, consolidate migrations, add test coverage

Remove @sentry/nextjs and all Sentry integration code — error tracking
now handled by Recapt. Consolidate 22 incremental migrations into a
single schema sync migration. Add 6 new test suites (auth, invoice
matching, VAT rules, opening balances) and extend report tests with
edge cases. Update Docker image name to gnubok, sync crontabs and
extension presets, fix CSP missing space, simplify journal entry
missing-document dialog.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove viewer bank import migration never applied to production

20260413150000_viewer_bank_import_permissions.sql (PR #234) was merged
to main but never applied to the production database. It references
current_active_company_id() which does not exist in production either.
This breaks fresh installs and Supabase preview branches because the
migration runs before the consolidated schema sync.

Remove it so the migration chain matches production. The viewer bank
import RLS policies should be re-added in a future migration alongside
the helper functions they depend on.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: correct delete policies for tables without company_id column

Seven tables in the generic delete-policy loop don't have a direct
company_id column, causing fresh installs to fail with "column
company_id does not exist". Fix by moving them out of the loop:

- invoice_items, journal_entry_lines, receipt_line_items,
  supplier_invoice_items → join through parent table
- extension_toggles, notification_settings, push_subscriptions →
  user-scoped (auth.uid() = user_id)

All policies match their existing production definitions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 10:52:00 +02:00

813 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 { 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<FiscalPeriod[]>([])
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<FormLine[]>(
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<UploadedFile[]>([])
const [accounts, setAccounts] = useState<BASAccount[]>([])
const [entryCurrency, setEntryCurrency] = useState<Currency>('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)
}
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 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)
}
const handleConfirm = async () => {
setIsSubmitting(true)
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,
}
// Attach currency metadata from pre-populated data (e.g. transaction flow)
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
}
// Attach currency metadata from the entry-level currency selector (manual flow)
// Applied to the first bank/cash account (class 19xx) only
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,
}),
})
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 {
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('')
setNotes('')
setUploadedFiles([])
setLines([{ ...BLANK_LINE }, { ...BLANK_LINE }])
setEntryCurrency('SEK')
setExchangeRate('')
setForeignAmount('')
onCreated?.()
if (journalEntryId) {
onEntryCreated?.(journalEntryId)
}
}
setIsSubmitting(false)
}
const formContent = (
<div className="space-y-4">
<div className={`grid gap-4 grid-cols-1 ${
embedded && initialDate
? 'sm:grid-cols-2'
: embedded
? 'sm:grid-cols-3'
: 'sm:grid-cols-[1fr_auto_1fr_3.5rem]'
}`}>
<div>
<Label>Räkenskapsår</Label>
<Select value={selectedPeriod} onValueChange={setSelectedPeriod}>
<SelectTrigger className="mt-1">
<SelectValue placeholder="Välj period" />
</SelectTrigger>
<SelectContent>
{periods.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{!(embedded && initialDate) && (
<div>
<Label>Datum</Label>
<Input
type="date"
value={entryDate}
onChange={(e) => setEntryDate(e.target.value)}
/>
</div>
)}
<div>
<Label>Beskrivning</Label>
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Verifikationstext..."
/>
</div>
<div className={embedded ? 'hidden' : 'col-span-full'}>
<Label>Intern anteckning <span className="text-muted-foreground font-normal">(valfritt)</span></Label>
<Textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="T.ex. anledning till bokning, referens till mejl, etc."
className="mt-1 resize-none"
rows={2}
maxLength={2000}
/>
</div>
{!embedded && (
<div>
<Label>Serie</Label>
<Input
value={voucherSeries}
onChange={(e) => {
const v = e.target.value.toUpperCase().replace(/[^A-Z]/g, '').slice(0, 1)
setVoucherSeries(v || 'A')
}}
className="mt-1 text-center font-mono"
maxLength={1}
/>
</div>
)}
</div>
{/* Period mismatch warning */}
{periodMismatch === 'no_period' && (
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/10 p-3">
<AlertTriangle className="h-5 w-5 text-warning-foreground mt-0.5 shrink-0" />
<div className="flex-1 text-sm text-warning-foreground">
<p className="font-medium">Inget räkenskapsår matchar datumet {entryDate}</p>
<p className="mt-0.5">Skapa ett räkenskapsår som täcker detta datum för att kunna bokföra.</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setShowCreatePeriod(true)}
className="shrink-0"
>
<CalendarPlus className="h-3.5 w-3.5 mr-1.5" />
Skapa räkenskapsår
</Button>
</div>
)}
{/* Currency section */}
<div className="flex flex-wrap items-end gap-3">
<div className="w-24">
<Label className="text-xs text-muted-foreground">Valuta</Label>
<Select value={entryCurrency} onValueChange={(v) => {
setEntryCurrency(v as Currency)
if (v === 'SEK') {
setExchangeRate('')
setForeignAmount('')
}
}}>
<SelectTrigger className="mt-1 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CURRENCIES.map((c) => (
<SelectItem key={c.value} value={c.value}>{c.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{isForeign && (
<>
<div className="w-40">
<Label className="text-xs text-muted-foreground">
Omräkningskurs (1 {entryCurrency} = ? SEK)
</Label>
<div className="relative mt-1">
<Input
type="number"
value={exchangeRate}
onChange={(e) => setExchangeRate(e.target.value)}
placeholder="0,0000"
className="h-8 pr-8"
step="0.0001"
min="0"
/>
{isFetchingRate && (
<Loader2 className="absolute right-2 top-1.5 h-4 w-4 animate-spin text-muted-foreground" />
)}
</div>
</div>
<div className="w-40">
<Label className="text-xs text-muted-foreground">
Belopp i {entryCurrency}
</Label>
<Input
type="number"
value={foreignAmount || (computedForeignAmount > 0 && !parsedForeignInput ? computedForeignAmount.toFixed(2) : '')}
onChange={(e) => setForeignAmount(e.target.value)}
placeholder="0,00"
className="mt-1 h-8"
step="0.01"
min="0"
/>
</div>
{rate > 0 && computedForeignAmount > 0 && (
<p className="text-xs text-muted-foreground pb-1">
{computedForeignAmount.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} {entryCurrency} × {rate.toLocaleString('sv-SE', { minimumFractionDigits: 4 })} = {computedSekAmount.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} SEK
</p>
)}
</>
)}
</div>
{/* Entry lines — mobile cards */}
<div className="sm:hidden space-y-3">
{lines.map((line, index) => (
<div key={index} className="rounded-lg border bg-card p-3 space-y-2">
<div className="flex items-start gap-2">
<div className="flex-1">
<AccountCombobox
value={line.account_number}
accounts={accounts}
onChange={(num) => updateLine(index, 'account_number', num)}
/>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => removeLine(index)}
disabled={lines.length <= 2}
className="h-8 w-8 p-0 min-h-[44px] min-w-[44px] shrink-0 -mr-1 -mt-1"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
<Input
value={line.line_description}
onChange={(e) => updateLine(index, 'line_description', e.target.value)}
placeholder="Radtext..."
/>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Debet</Label>
<Input
type="number"
value={line.debit_amount}
onChange={(e) => updateLine(index, 'debit_amount', e.target.value)}
placeholder="0,00"
className="text-right"
inputMode="decimal"
min="0"
step="0.01"
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Kredit</Label>
<Input
type="number"
value={line.credit_amount}
onChange={(e) => updateLine(index, 'credit_amount', e.target.value)}
placeholder="0,00"
className="text-right"
inputMode="decimal"
min="0"
step="0.01"
/>
</div>
</div>
</div>
))}
{/* Mobile totals */}
<div className="flex justify-between items-center px-1 pt-2 font-semibold text-sm">
<span>Summa</span>
<div className="flex gap-4">
<span className={isBalanced ? 'text-success' : 'text-destructive'}>
D: {totalDebit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
</span>
<span className={isBalanced ? 'text-success' : 'text-destructive'}>
K: {totalCredit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
</span>
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={addLine}
className="flex-1"
>
<Plus className="h-3 w-3 mr-1" />
Lägg till rad
</Button>
<BookingTemplatePicker
onApply={handleTemplateApply}
entityType={company?.entity_type}
/>
</div>
</div>
{/* Entry lines — desktop table */}
<div className="hidden sm:block">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 w-28">Konto</th>
<th className="py-2 px-1">Beskrivning</th>
<th className="py-2 w-32 px-1 text-right">Debet</th>
<th className="py-2 w-32 px-1 text-right">Kredit</th>
<th className="py-2 w-10"></th>
</tr>
</thead>
<tbody>
{lines.map((line, index) => (
<tr key={index} className="border-b">
<td className="py-1.5">
<AccountCombobox
value={line.account_number}
accounts={accounts}
onChange={(num) => updateLine(index, 'account_number', num)}
/>
</td>
<td className="py-1.5 px-1">
<Input
value={line.line_description}
onChange={(e) => updateLine(index, 'line_description', e.target.value)}
placeholder="Radtext..."
className="h-8"
/>
</td>
<td className="py-1.5 px-1">
<Input
type="number"
value={line.debit_amount}
onChange={(e) => updateLine(index, 'debit_amount', e.target.value)}
placeholder="0,00"
className="text-right h-8"
inputMode="decimal"
min="0"
step="0.01"
/>
</td>
<td className="py-1.5 px-1">
<Input
type="number"
value={line.credit_amount}
onChange={(e) => updateLine(index, 'credit_amount', e.target.value)}
placeholder="0,00"
className="text-right h-8"
inputMode="decimal"
min="0"
step="0.01"
/>
</td>
<td className="py-1.5">
<Button
variant="ghost"
size="sm"
onClick={() => removeLine(index)}
disabled={lines.length <= 2}
className="h-8 w-8 p-0 min-h-[44px] min-w-[44px]"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="font-semibold">
<td colSpan={2} className="py-2 px-1">
Summa
</td>
<td
className={`py-2 px-1 text-right ${
isBalanced ? 'text-success' : 'text-destructive'
}`}
>
{totalDebit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
</td>
<td
className={`py-2 px-1 text-right ${
isBalanced ? 'text-success' : 'text-destructive'
}`}
>
{totalCredit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
</td>
<td></td>
</tr>
</tfoot>
</table>
<div className="flex gap-2 mt-2">
<Button
variant="outline"
size="sm"
onClick={addLine}
>
<Plus className="h-3 w-3 mr-1" />
Lägg till rad
</Button>
<BookingTemplatePicker
onApply={handleTemplateApply}
entityType={company?.entity_type}
/>
</div>
</div>
{/* Document attachments */}
{!embedded && (
<div>
<Label className="mb-2 block">Underlag</Label>
<DocumentUploadZone
files={uploadedFiles}
onFilesChange={setUploadedFiles}
/>
</div>
)}
{!isBalanced && totalDebit > 0 && (
<p className="text-sm text-destructive">
Differens: {formatCurrency(Math.abs(totalDebit - totalCredit))}
</p>
)}
<div className="flex flex-col items-end gap-1">
<Button
onClick={handleReview}
disabled={!isBalanced || !description || !selectedPeriod || !!periodMismatch || isSubmitting || isUploading || !canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
>
{!canWrite && <Lock className="mr-2 h-4 w-4" />}
Granska & skapa
</Button>
{(!description || !selectedPeriod || isUploading || periodMismatch) && (
<div className="text-xs text-muted-foreground space-y-0.5 text-right">
{!description && <p>Ange en beskrivning</p>}
{!selectedPeriod && <p>Välj en räkenskapsperiod</p>}
{periodMismatch === 'no_period' && <p>Skapa ett räkenskapsår som matchar datumet</p>}
{isUploading && <p>Vänta tills filerna laddats upp</p>}
</div>
)}
</div>
<ConfirmationDialog
open={showReview}
onOpenChange={setShowReview}
onConfirm={handleConfirm}
isSubmitting={isSubmitting}
title="Granska verifikation"
warningText={embedded ? '' : 'En verifikation skapas och kan inte ändras efteråt. Korrigeringar görs genom storno.'}
>
<JournalEntryReviewContent
periodName={periods.find((p) => p.id === selectedPeriod)?.name || ''}
entryDate={entryDate}
description={description}
notes={notes || undefined}
voucherSeries={!embedded ? voucherSeries : undefined}
lines={lines}
totalDebit={totalDebit}
totalCredit={totalCredit}
attachmentCount={uploadedFiles.filter((f) => f.status === 'uploaded').length}
showBalanceBadge={!embedded}
hideDate={!!embedded}
/>
</ConfirmationDialog>
{/* Warning dialog when no documents attached */}
<ConfirmationDialog
open={showNoDocWarning}
onOpenChange={setShowNoDocWarning}
onConfirm={() => {
setShowNoDocWarning(false)
setShowReview(true)
}}
isSubmitting={false}
title="Underlag saknas"
warningText="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."
confirmLabel="Bokför utan underlag"
>
<div className="text-sm text-muted-foreground">
Granska uppgifterna innan du bekräftar.
</div>
</ConfirmationDialog>
<CreatePeriodDialog
open={showCreatePeriod}
onOpenChange={setShowCreatePeriod}
entryDate={entryDate}
periods={periods}
onCreated={fetchPeriods}
/>
</div>
)
if (embedded) {
return formContent
}
return (
<Card>
<CardHeader>
<CardTitle>Ny verifikation</CardTitle>
</CardHeader>
<CardContent>
{formContent}
</CardContent>
</Card>
)
}