b387a77bfd
* 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>
813 lines
29 KiB
TypeScript
813 lines
29 KiB
TypeScript
'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>
|
||
)
|
||
}
|