ec27228a8e
Em dashes (—) and en dashes (–) had spread across comments, docs, tests, and a few UI strings, reading as AI-generated boilerplate rather than house style. Replaced each with punctuation matching its context: colon for explanatory clauses, comma for asides, plain hyphen for numeric/legal ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for paired-dash asides. messages/en.json and messages/sv.json were fixed by hand together to keep sv/en in sync. Left untouched where the dash is the functional subject rather than decorative punctuation: date-range-parser.ts's separator regex, charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the agent system-prompt files that already instruct against em dashes, and a golden iXBRL test fixture compared byte-for-byte. Also fixes two bugs surfaced along the way: an off-by-one in ApiKeysPanel's scope-label split (a leftover from an earlier partial pass), and a charset-repair test that had lost the literal en-dash it exists to verify. Regenerated the agent atom seed migration (skills:generate) since 27 SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes, with an explicit carve-out for the functional-dash cases above. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
802 lines
32 KiB
TypeScript
802 lines
32 KiB
TypeScript
'use client'
|
||
|
||
import { useEffect, useMemo, useState } from 'react'
|
||
import { useTranslations } from 'next-intl'
|
||
import { createClient } from '@/lib/supabase/client'
|
||
import { useCompany } from '@/contexts/CompanyContext'
|
||
import { resolveAccount } from '@/lib/cash-accounts/resolve-account'
|
||
import type { CashAccount } from '@/types'
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from '@/components/ui/dialog'
|
||
import { Badge } from '@/components/ui/badge'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Input } from '@/components/ui/input'
|
||
import { Label } from '@/components/ui/label'
|
||
import { Skeleton } from '@/components/ui/skeleton'
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||
import { useToast } from '@/components/ui/use-toast'
|
||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||
import { applyTemplate } from '@/lib/bookkeeping/template-library'
|
||
import { formatCurrency, formatDate, cn } from '@/lib/utils'
|
||
import LineDimensionFields from '@/components/dimensions/LineDimensionFields'
|
||
import { Loader2, FileText, AlertTriangle, Check, Plus, Trash2, Paperclip } from 'lucide-react'
|
||
import type { BookingTemplateLibrary, BookingTemplateLibraryLine } from '@/types'
|
||
import type { TransactionWithInvoice } from './transaction-types'
|
||
|
||
interface BulkBookDialogProps {
|
||
open: boolean
|
||
onOpenChange: (open: boolean) => void
|
||
transactions: TransactionWithInvoice[]
|
||
onSuccess: () => void
|
||
}
|
||
|
||
type Mode = 'one_line_per_tx' | 'sum_per_account'
|
||
type Tab = 'template' | 'manual'
|
||
|
||
interface PreviewLine {
|
||
account_number: string
|
||
debit_amount: number
|
||
credit_amount: number
|
||
line_description: string | undefined
|
||
}
|
||
|
||
interface ManualLine {
|
||
id: string
|
||
account_number: string
|
||
debit_amount: string // form-state strings; parsed on send
|
||
credit_amount: string
|
||
line_description: string
|
||
}
|
||
|
||
function round2(n: number): number {
|
||
return Math.round(n * 100) / 100
|
||
}
|
||
|
||
function parseAmount(s: string): number {
|
||
if (!s) return 0
|
||
const cleaned = s.replace(/\s/g, '').replace(',', '.')
|
||
const n = Number.parseFloat(cleaned)
|
||
return Number.isFinite(n) ? n : 0
|
||
}
|
||
|
||
function newManualLineId(): string {
|
||
return `ml-${Math.random().toString(36).slice(2, 10)}`
|
||
}
|
||
|
||
export default function BulkBookDialog({
|
||
open,
|
||
onOpenChange,
|
||
transactions,
|
||
onSuccess,
|
||
}: BulkBookDialogProps) {
|
||
const { toast } = useToast()
|
||
const { company } = useCompany()
|
||
const supabase = useMemo(() => createClient(), [])
|
||
const t = useTranslations('tx_bulk_book')
|
||
|
||
const [tab, setTab] = useState<Tab>('template')
|
||
const [templates, setTemplates] = useState<BookingTemplateLibrary[]>([])
|
||
const [loadingTemplates, setLoadingTemplates] = useState(true)
|
||
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null)
|
||
// null = fetch pending; array = loaded (may be empty on error: falls back to '1930')
|
||
const [cashAccounts, setCashAccounts] = useState<CashAccount[] | null>(null)
|
||
const [mode, setMode] = useState<Mode>('one_line_per_tx')
|
||
const [description, setDescription] = useState('')
|
||
const [manualLines, setManualLines] = useState<ManualLine[]>([])
|
||
const [submitting, setSubmitting] = useState(false)
|
||
// Dimension tagging (kostnadsställe/projekt): the pair renders only when
|
||
// company_settings.dimensions_enabled, same gate as JournalEntryForm. One
|
||
// header-level default bag applies to both tabs; the server tags the
|
||
// generated voucher's lines with it.
|
||
const [dimensionsEnabled, setDimensionsEnabled] = useState(false)
|
||
const [defaultDims, setDefaultDims] = useState<Record<string, string>>({})
|
||
|
||
// Documents that will inherit onto the new verifikat. Computed from
|
||
// transactions.document_id; the RPC reads these and updates each doc's
|
||
// journal_entry_id atomically with the verifikat commit.
|
||
const docCount = useMemo(
|
||
() => transactions.filter((tx) => tx.document_id).length,
|
||
[transactions],
|
||
)
|
||
|
||
const txCount = transactions.length
|
||
const sharedDate = transactions[0]?.date
|
||
const sharedCurrency = transactions[0]?.currency ?? 'SEK'
|
||
const direction: 'income' | 'expense' = useMemo(() => {
|
||
if (transactions.length === 0) return 'income'
|
||
return transactions[0]!.amount > 0 ? 'income' : 'expense'
|
||
}, [transactions])
|
||
const txSumAbs = useMemo(
|
||
() => round2(transactions.reduce((s, tx) => s + Math.abs(tx.amount), 0)),
|
||
[transactions],
|
||
)
|
||
|
||
const selectedTemplate = useMemo(
|
||
() => templates.find((tpl) => tpl.id === selectedTemplateId) ?? null,
|
||
[templates, selectedTemplateId],
|
||
)
|
||
|
||
// Load templates when the dialog opens. RLS scopes to user's companies +
|
||
// system templates; no company_id filter needed.
|
||
useEffect(() => {
|
||
if (!open || !company) return
|
||
let cancelled = false
|
||
async function load() {
|
||
setLoadingTemplates(true)
|
||
try {
|
||
const { data } = await supabase
|
||
.from('booking_template_library')
|
||
.select('*')
|
||
.eq('is_active', true)
|
||
.order('is_system', { ascending: false })
|
||
.order('name', { ascending: true })
|
||
if (cancelled) return
|
||
setTemplates((data ?? []) as BookingTemplateLibrary[])
|
||
} finally {
|
||
if (!cancelled) setLoadingTemplates(false)
|
||
}
|
||
}
|
||
load()
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [open, company, supabase])
|
||
|
||
// Fetch cash accounts once when the dialog opens so the manual bank-leg
|
||
// pre-fill can resolve the correct ledger account per transaction.
|
||
useEffect(() => {
|
||
if (!open) return
|
||
setCashAccounts(null)
|
||
let cancelled = false
|
||
fetch('/api/cash-accounts')
|
||
.then((r) => {
|
||
if (!r.ok) throw new Error(`cash-accounts fetch failed: ${r.status}`)
|
||
return r.json()
|
||
})
|
||
.then((json) => {
|
||
if (cancelled) return
|
||
setCashAccounts((json.data ?? []) as CashAccount[])
|
||
})
|
||
.catch(() => {
|
||
// Fall back to empty list: resolveAccount will return '1930'
|
||
if (!cancelled) setCashAccounts([])
|
||
})
|
||
return () => { cancelled = true }
|
||
}, [open])
|
||
|
||
// Company settings gate the dimension affordance (dimensions_enabled).
|
||
// Fetched once per open; on failure the pair simply stays hidden.
|
||
useEffect(() => {
|
||
if (!open) return
|
||
let cancelled = false
|
||
fetch('/api/settings')
|
||
.then((r) => r.json())
|
||
.then(({ data }) => {
|
||
if (!cancelled) setDimensionsEnabled(data?.dimensions_enabled === true)
|
||
})
|
||
.catch(() => {
|
||
if (!cancelled) setDimensionsEnabled(false)
|
||
})
|
||
return () => { cancelled = true }
|
||
}, [open])
|
||
|
||
// Reset state when dialog closes so the next open starts clean.
|
||
useEffect(() => {
|
||
if (!open) {
|
||
setTab('template')
|
||
setSelectedTemplateId(null)
|
||
setMode('one_line_per_tx')
|
||
setDescription('')
|
||
setManualLines([])
|
||
setCashAccounts(null)
|
||
setDefaultDims({})
|
||
} else if (sharedDate) {
|
||
// Pre-fill description with a sensible default the user can edit.
|
||
setDescription(t('default_description', { date: sharedDate }))
|
||
}
|
||
}, [open, sharedDate, t])
|
||
|
||
// Pre-fill the bank side from the txs (one line per tx with the resolved
|
||
// ledger account and correct Dr/Cr direction). We intentionally do NOT
|
||
// pre-fill a counterpart account: swedish-compliance flagged that a
|
||
// hardcoded 3001/5800 prefill nudges users into submitting verifikat
|
||
// without a VAT line (26xx) for momsregistrerade affärshändelser. The
|
||
// bank side is the unambiguous part the user always wants; the
|
||
// counterpart (and any VAT split) is the user's responsibility.
|
||
// Gate on cashAccounts !== null so lines are only built after the account
|
||
// fetch resolves: this prevents the form from briefly showing '1930' when
|
||
// the resolved account differs.
|
||
useEffect(() => {
|
||
if (tab !== 'manual') return
|
||
if (manualLines.length > 0) return
|
||
if (transactions.length === 0) return
|
||
if (cashAccounts === null) return
|
||
const isIncome = direction === 'income'
|
||
const bankLines: ManualLine[] = transactions.map((tx) => {
|
||
const { account } = resolveAccount(
|
||
cashAccounts,
|
||
tx.cash_account_id ?? null,
|
||
tx.currency ?? 'SEK',
|
||
)
|
||
return {
|
||
id: newManualLineId(),
|
||
account_number: account,
|
||
debit_amount: isIncome ? Math.abs(tx.amount).toFixed(2).replace('.', ',') : '',
|
||
credit_amount: isIncome ? '' : Math.abs(tx.amount).toFixed(2).replace('.', ','),
|
||
line_description: (tx.description || '').slice(0, 40).trim(),
|
||
}
|
||
})
|
||
// One empty counterpart row to scaffold the next entry. Account
|
||
// left blank: user must choose, which avoids the no-VAT trap.
|
||
const counterpart: ManualLine = {
|
||
id: newManualLineId(),
|
||
account_number: '',
|
||
debit_amount: '',
|
||
credit_amount: '',
|
||
line_description: '',
|
||
}
|
||
setManualLines([...bankLines, counterpart])
|
||
}, [tab, manualLines.length, transactions, direction, cashAccounts])
|
||
|
||
// Live line preview: driven by either the template/mode pair (template
|
||
// tab) or the user-edited manual lines (manual tab). Same downstream
|
||
// invariants (balance + bank-leg match) apply to both paths.
|
||
const previewLines = useMemo<PreviewLine[]>(() => {
|
||
if (tab === 'manual') {
|
||
return manualLines
|
||
.map<PreviewLine>((ml) => ({
|
||
account_number: ml.account_number,
|
||
debit_amount: round2(parseAmount(ml.debit_amount)),
|
||
credit_amount: round2(parseAmount(ml.credit_amount)),
|
||
line_description: ml.line_description.trim() || undefined,
|
||
}))
|
||
.filter((l) => l.debit_amount > 0 || l.credit_amount > 0)
|
||
}
|
||
if (!selectedTemplate) return []
|
||
const templateLines = (selectedTemplate.lines ?? []) as BookingTemplateLibraryLine[]
|
||
const lines: PreviewLine[] = []
|
||
if (mode === 'sum_per_account') {
|
||
const applied = applyTemplate(templateLines, txSumAbs)
|
||
for (const fl of applied) {
|
||
const debit = parseFloat(fl.debit_amount || '0') || 0
|
||
const credit = parseFloat(fl.credit_amount || '0') || 0
|
||
if (debit === 0 && credit === 0) continue
|
||
lines.push({
|
||
account_number: fl.account_number,
|
||
debit_amount: round2(debit),
|
||
credit_amount: round2(credit),
|
||
line_description: fl.line_description,
|
||
})
|
||
}
|
||
} else {
|
||
for (const tx of transactions) {
|
||
const applied = applyTemplate(templateLines, Math.abs(tx.amount))
|
||
for (const fl of applied) {
|
||
const debit = parseFloat(fl.debit_amount || '0') || 0
|
||
const credit = parseFloat(fl.credit_amount || '0') || 0
|
||
if (debit === 0 && credit === 0) continue
|
||
const tag = (tx.description || '').slice(0, 40).trim()
|
||
lines.push({
|
||
account_number: fl.account_number,
|
||
debit_amount: round2(debit),
|
||
credit_amount: round2(credit),
|
||
line_description: tag
|
||
? `${fl.line_description ?? ''}, ${tag}`.trim()
|
||
: fl.line_description,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
return lines
|
||
}, [tab, manualLines, selectedTemplate, mode, transactions, txSumAbs])
|
||
|
||
const previewTotals = useMemo(() => {
|
||
const debit = previewLines.reduce((s, l) => s + l.debit_amount, 0)
|
||
const credit = previewLines.reduce((s, l) => s + l.credit_amount, 0)
|
||
return { debit: round2(debit), credit: round2(credit) }
|
||
}, [previewLines])
|
||
|
||
// Balance + bank-leg match are the two invariants the RPC will check; we
|
||
// surface them here so the user knows whether confirm will succeed.
|
||
const isBalanced = Math.abs(previewTotals.debit - previewTotals.credit) < 0.005
|
||
const bankLineNet = previewLines
|
||
.filter((l) => l.account_number >= '1900' && l.account_number <= '1999')
|
||
.reduce((s, l) => s + l.debit_amount - l.credit_amount, 0)
|
||
const expectedBankNet = direction === 'income' ? txSumAbs : -txSumAbs
|
||
const bankMatches = Math.abs(bankLineNet - expectedBankNet) < 0.005
|
||
|
||
// The active tab gates which selector must be valid. Both paths still
|
||
// need a non-empty description, ≥2 lines, balance, bank-leg match,
|
||
// and (for manual mode) valid 4-digit account numbers: without this,
|
||
// a 1-3-digit entry escapes the lexicographic bank-account range
|
||
// check ('193' < '1900' is true), bank match could pass, and the
|
||
// server's Zod schema rejects with a 400 only after submit.
|
||
const tabReady = tab === 'template' ? selectedTemplate !== null : manualLines.length > 0
|
||
const allAccountsValid = previewLines.every((l) => /^\d{4}$/.test(l.account_number))
|
||
const canConfirm =
|
||
!submitting &&
|
||
tabReady &&
|
||
description.trim().length > 0 &&
|
||
previewLines.length >= 2 &&
|
||
isBalanced &&
|
||
bankMatches &&
|
||
allAccountsValid
|
||
|
||
function setDefaultDimension(dimNo: string, code: string | null) {
|
||
setDefaultDims((prev) => {
|
||
const next = { ...prev }
|
||
const trimmed = code?.trim()
|
||
if (trimmed) next[dimNo] = trimmed
|
||
else delete next[dimNo]
|
||
return next
|
||
})
|
||
}
|
||
|
||
// Compact display, e.g. "KS01 · P001" (dim-number order) for the preview badge.
|
||
const dimsSummary = Object.entries(defaultDims)
|
||
.filter(([, v]) => v)
|
||
.sort(([a], [b]) => Number(a) - Number(b))
|
||
.map(([, v]) => v)
|
||
.join(' · ')
|
||
|
||
function updateManualLine(id: string, patch: Partial<Omit<ManualLine, 'id'>>) {
|
||
setManualLines((prev) => prev.map((l) => (l.id === id ? { ...l, ...patch } : l)))
|
||
}
|
||
|
||
function removeManualLine(id: string) {
|
||
setManualLines((prev) => prev.filter((l) => l.id !== id))
|
||
}
|
||
|
||
function addManualLine() {
|
||
setManualLines((prev) => [
|
||
...prev,
|
||
{
|
||
id: newManualLineId(),
|
||
account_number: '',
|
||
debit_amount: '',
|
||
credit_amount: '',
|
||
line_description: '',
|
||
},
|
||
])
|
||
}
|
||
|
||
async function handleConfirm() {
|
||
if (!canConfirm) return
|
||
setSubmitting(true)
|
||
try {
|
||
// Build the payload per the active tab. Template path uses the
|
||
// existing schema branch (template_id + mode). Manual path sends
|
||
// the user-edited lines directly.
|
||
// Header-level default dimensions ride as a top-level field on both
|
||
// branches; only sent when the user actually picked something.
|
||
const defaultDimensions =
|
||
dimensionsEnabled && Object.keys(defaultDims).length > 0
|
||
? { default_dimensions: defaultDims }
|
||
: {}
|
||
const payload =
|
||
tab === 'manual'
|
||
? {
|
||
tx_ids: transactions.map((tx) => tx.id),
|
||
entry_description: description.trim(),
|
||
manual_lines: previewLines.map((l) => ({
|
||
account_number: l.account_number,
|
||
debit_amount: l.debit_amount,
|
||
credit_amount: l.credit_amount,
|
||
currency: sharedCurrency,
|
||
line_description: l.line_description ?? undefined,
|
||
})),
|
||
...defaultDimensions,
|
||
}
|
||
: {
|
||
tx_ids: transactions.map((tx) => tx.id),
|
||
template_id: selectedTemplateId,
|
||
mode,
|
||
entry_description: description.trim(),
|
||
...defaultDimensions,
|
||
}
|
||
const response = await fetch('/api/transactions/bulk-book', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
})
|
||
if (!response.ok) {
|
||
const body = await response.json().catch(() => null)
|
||
toast({
|
||
title: t('error_title'),
|
||
description: getErrorMessage(body, { statusCode: response.status }),
|
||
variant: 'destructive',
|
||
})
|
||
return
|
||
}
|
||
const body = (await response.json()) as {
|
||
data: { voucher_series: string | null; voucher_number: number | null }
|
||
}
|
||
const voucherLabel =
|
||
body.data.voucher_series && body.data.voucher_number != null
|
||
? `${body.data.voucher_series}-${body.data.voucher_number}`
|
||
: t('unknown_voucher')
|
||
toast({
|
||
title: t('success_title'),
|
||
description: t('success_description', { count: txCount, voucher: voucherLabel }),
|
||
variant: 'success',
|
||
})
|
||
onSuccess()
|
||
onOpenChange(false)
|
||
} catch (err) {
|
||
toast({
|
||
title: t('error_title'),
|
||
description: getErrorMessage(err),
|
||
variant: 'destructive',
|
||
})
|
||
} finally {
|
||
setSubmitting(false)
|
||
}
|
||
}
|
||
|
||
if (transactions.length === 0) return null
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
<DialogContent className="sm:max-w-[680px] max-h-[85vh] overflow-y-auto">
|
||
<DialogHeader>
|
||
<DialogTitle>
|
||
{t('title', { count: txCount, date: sharedDate ? formatDate(sharedDate) : '' })}
|
||
</DialogTitle>
|
||
<DialogDescription>
|
||
{direction === 'income' ? t('description_income') : t('description_expense')}
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<div className="space-y-4">
|
||
{/* Selection summary */}
|
||
<div className="rounded-lg border bg-card p-3 flex items-center justify-between">
|
||
<div className="text-sm">
|
||
<p className="font-medium">
|
||
{t('summary_count', { count: txCount })}
|
||
</p>
|
||
<p className="text-xs text-muted-foreground tabular-nums">
|
||
{sharedDate ? formatDate(sharedDate) : ''}
|
||
</p>
|
||
</div>
|
||
<p className="font-medium tabular-nums">
|
||
{direction === 'income' ? '+' : '−'}
|
||
{formatCurrency(txSumAbs, sharedCurrency)}
|
||
</p>
|
||
</div>
|
||
|
||
{/* Tab: Mall (template) / Manuell (hand-built lines). Default
|
||
template; manual is the "I want to book it myself" escape
|
||
hatch the user asked for after PR #606. */}
|
||
<Tabs value={tab} onValueChange={(v) => setTab(v as Tab)} className="space-y-4">
|
||
<TabsList className="grid grid-cols-2 w-full">
|
||
<TabsTrigger value="template">{t('tab_template')}</TabsTrigger>
|
||
<TabsTrigger value="manual">{t('tab_manual')}</TabsTrigger>
|
||
</TabsList>
|
||
|
||
<TabsContent value="template" className="space-y-4 mt-0">
|
||
{/* Template picker */}
|
||
<div className="space-y-2">
|
||
<Label>{t('template_label')}</Label>
|
||
{loadingTemplates ? (
|
||
<div className="space-y-2">
|
||
<Skeleton className="h-12 w-full" />
|
||
<Skeleton className="h-12 w-full" />
|
||
</div>
|
||
) : templates.length === 0 ? (
|
||
<div className="rounded-lg border border-dashed bg-muted/30 p-4 text-center text-sm text-muted-foreground">
|
||
{t('no_templates')}
|
||
</div>
|
||
) : (
|
||
<ul className="space-y-1 max-h-[180px] overflow-y-auto">
|
||
{templates.map((tpl) => (
|
||
<li key={tpl.id}>
|
||
<button
|
||
type="button"
|
||
onClick={() => setSelectedTemplateId(tpl.id)}
|
||
className={cn(
|
||
'w-full rounded-lg border bg-card p-3 text-left transition-colors hover:bg-secondary/60',
|
||
selectedTemplateId === tpl.id
|
||
? 'border-foreground'
|
||
: 'border-border',
|
||
)}
|
||
>
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex items-center gap-2">
|
||
<FileText className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||
<span className="text-sm font-medium truncate">{tpl.name}</span>
|
||
{tpl.is_system && (
|
||
<span className="flex-shrink-0 text-[10px] text-muted-foreground">
|
||
{t('system_badge')}
|
||
</span>
|
||
)}
|
||
</div>
|
||
{tpl.description && (
|
||
<p className="mt-1 text-xs text-muted-foreground truncate">
|
||
{tpl.description}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
|
||
{/* Mode toggle: segmented control pattern (no RadioGroup primitive
|
||
in the design system; two outlined buttons act as a selectable
|
||
pair) */}
|
||
{selectedTemplate && (
|
||
<div className="space-y-2">
|
||
<Label>{t('mode_label')}</Label>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => setMode('one_line_per_tx')}
|
||
className={cn(
|
||
'rounded-lg border bg-card p-3 text-left transition-colors hover:bg-secondary/60',
|
||
mode === 'one_line_per_tx' ? 'border-foreground' : 'border-border',
|
||
)}
|
||
>
|
||
<p className="text-sm font-medium">{t('mode_per_tx')}</p>
|
||
<p className="mt-1 text-xs text-muted-foreground">{t('mode_per_tx_hint')}</p>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setMode('sum_per_account')}
|
||
className={cn(
|
||
'rounded-lg border bg-card p-3 text-left transition-colors hover:bg-secondary/60',
|
||
mode === 'sum_per_account' ? 'border-foreground' : 'border-border',
|
||
)}
|
||
>
|
||
<p className="text-sm font-medium">{t('mode_sum')}</p>
|
||
<p className="mt-1 text-xs text-muted-foreground">{t('mode_sum_hint')}</p>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</TabsContent>
|
||
|
||
<TabsContent value="manual" className="space-y-4 mt-0">
|
||
{/* Manual line editor. Lines are pre-filled from txs on first
|
||
switch to this tab (one line per tx on 1930 + counterpart
|
||
line on 3001/5800). User adjusts accounts, amounts, and
|
||
descriptions. Live balance + bank-leg checks below drive
|
||
the confirm button. */}
|
||
<div className="space-y-2">
|
||
<div className="flex items-center justify-between">
|
||
<Label>{t('manual_lines_label')}</Label>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={addManualLine}
|
||
>
|
||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||
{t('manual_add_line')}
|
||
</Button>
|
||
</div>
|
||
<div className="rounded-lg border bg-card overflow-hidden">
|
||
<table className="w-full text-xs tabular-nums">
|
||
<thead>
|
||
<tr className="border-b text-muted-foreground bg-muted/30">
|
||
<th className="px-2 py-2 text-left font-medium w-[90px]">{t('col_account')}</th>
|
||
<th className="px-2 py-2 text-left font-medium">{t('col_description')}</th>
|
||
<th className="px-2 py-2 text-right font-medium w-[110px]">{t('col_debit')}</th>
|
||
<th className="px-2 py-2 text-right font-medium w-[110px]">{t('col_credit')}</th>
|
||
<th className="w-8" />
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{manualLines.map((line) => (
|
||
<tr key={line.id} className="border-b border-border/40 last:border-b-0">
|
||
<td className="px-2 py-1">
|
||
<Input
|
||
value={line.account_number}
|
||
onChange={(e) =>
|
||
updateManualLine(line.id, { account_number: e.target.value.replace(/\D/g, '').slice(0, 4) })
|
||
}
|
||
placeholder="1930"
|
||
className="h-8 text-xs font-mono"
|
||
/>
|
||
</td>
|
||
<td className="px-2 py-1">
|
||
<Input
|
||
value={line.line_description}
|
||
onChange={(e) =>
|
||
updateManualLine(line.id, { line_description: e.target.value.slice(0, 200) })
|
||
}
|
||
placeholder={t('manual_description_placeholder')}
|
||
className="h-8 text-xs"
|
||
/>
|
||
</td>
|
||
<td className="px-2 py-1">
|
||
<Input
|
||
inputMode="decimal"
|
||
value={line.debit_amount}
|
||
onChange={(e) =>
|
||
updateManualLine(line.id, { debit_amount: e.target.value })
|
||
}
|
||
placeholder="0,00"
|
||
className="h-8 text-xs text-right"
|
||
/>
|
||
</td>
|
||
<td className="px-2 py-1">
|
||
<Input
|
||
inputMode="decimal"
|
||
value={line.credit_amount}
|
||
onChange={(e) =>
|
||
updateManualLine(line.id, { credit_amount: e.target.value })
|
||
}
|
||
placeholder="0,00"
|
||
className="h-8 text-xs text-right"
|
||
/>
|
||
</td>
|
||
<td className="px-1 py-1">
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||
onClick={() => removeManualLine(line.id)}
|
||
aria-label={t('manual_remove_line')}
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
</Button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</TabsContent>
|
||
</Tabs>
|
||
|
||
{/* Description: shared by both tabs once the user has either a
|
||
template selected or manual lines drafted. */}
|
||
{tabReady && (
|
||
<div className="space-y-2">
|
||
<Label htmlFor="bulk-description">{t('description_label')}</Label>
|
||
<Input
|
||
id="bulk-description"
|
||
value={description}
|
||
onChange={(e) => setDescription(e.target.value)}
|
||
maxLength={500}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Header default dims (kostnadsställe/projekt): one bag applied to
|
||
the whole verifikat, shared by both tabs. */}
|
||
{dimensionsEnabled && tabReady && (
|
||
<div className="space-y-1">
|
||
<LineDimensionFields
|
||
dimensions={defaultDims}
|
||
onChange={setDefaultDimension}
|
||
inputClassName="h-8"
|
||
/>
|
||
<p className="text-xs text-muted-foreground">{t('dimensions_hint')}</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Document inheritance hint: informs the user which receipts
|
||
follow the txs onto the combined verifikat. Zero is fine
|
||
(txs without docs don't break anything); we only render
|
||
when the count is non-zero to avoid clutter. */}
|
||
{docCount > 0 && tabReady && (
|
||
<div className="flex items-center gap-2 rounded-lg border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
|
||
<Paperclip className="h-3.5 w-3.5 flex-shrink-0" />
|
||
<span>{t('docs_inherit_hint', { count: docCount })}</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Live preview */}
|
||
{tabReady && previewLines.length > 0 && (
|
||
<div className="space-y-2">
|
||
<div className="flex items-center gap-2">
|
||
<Label>
|
||
{t('preview_label', { count: previewLines.length })}
|
||
</Label>
|
||
{dimensionsEnabled && dimsSummary && (
|
||
<Badge variant="secondary" className="font-mono tabular-nums">
|
||
{dimsSummary}
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
<div className="rounded-lg border bg-muted/30 overflow-hidden">
|
||
<table className="w-full text-xs tabular-nums">
|
||
<thead>
|
||
<tr className="border-b text-muted-foreground">
|
||
<th className="px-3 py-2 text-left font-medium">{t('col_account')}</th>
|
||
<th className="px-3 py-2 text-left font-medium">{t('col_description')}</th>
|
||
<th className="px-3 py-2 text-right font-medium">{t('col_debit')}</th>
|
||
<th className="px-3 py-2 text-right font-medium">{t('col_credit')}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{previewLines.slice(0, 30).map((line, i) => (
|
||
<tr key={i} className="border-b border-border/40 last:border-b-0">
|
||
<td className="px-3 py-1.5 font-mono">{line.account_number}</td>
|
||
<td className="px-3 py-1.5 text-muted-foreground truncate max-w-[240px]">
|
||
{line.line_description ?? '-'}
|
||
</td>
|
||
<td className="px-3 py-1.5 text-right">
|
||
{line.debit_amount > 0 ? formatCurrency(line.debit_amount) : ''}
|
||
</td>
|
||
<td className="px-3 py-1.5 text-right">
|
||
{line.credit_amount > 0 ? formatCurrency(line.credit_amount) : ''}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
{previewLines.length > 30 && (
|
||
<tr>
|
||
<td colSpan={4} className="px-3 py-1.5 text-center text-muted-foreground">
|
||
{t('preview_truncated', { remaining: previewLines.length - 30 })}
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
<tfoot>
|
||
<tr className="border-t bg-card font-medium">
|
||
<td colSpan={2} className="px-3 py-2 text-right">{t('total_label')}</td>
|
||
<td className="px-3 py-2 text-right">{formatCurrency(previewTotals.debit)}</td>
|
||
<td className="px-3 py-2 text-right">{formatCurrency(previewTotals.credit)}</td>
|
||
</tr>
|
||
</tfoot>
|
||
</table>
|
||
</div>
|
||
|
||
{/* Invariant indicators */}
|
||
<div className="space-y-1">
|
||
{isBalanced ? (
|
||
<div className="flex items-center gap-2 text-xs text-success">
|
||
<Check className="h-3.5 w-3.5" />
|
||
<span>{t('balance_ok')}</span>
|
||
</div>
|
||
) : (
|
||
<div className="flex items-center gap-2 text-xs text-destructive">
|
||
<AlertTriangle className="h-3.5 w-3.5" />
|
||
<span>{t('balance_off', {
|
||
delta: formatCurrency(Math.abs(previewTotals.debit - previewTotals.credit)),
|
||
})}</span>
|
||
</div>
|
||
)}
|
||
{bankMatches ? (
|
||
<div className="flex items-center gap-2 text-xs text-success">
|
||
<Check className="h-3.5 w-3.5" />
|
||
<span>{t('bank_ok')}</span>
|
||
</div>
|
||
) : (
|
||
<div className="flex items-center gap-2 text-xs text-destructive">
|
||
<AlertTriangle className="h-3.5 w-3.5" />
|
||
<span>{t('bank_off')}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
|
||
{t('cancel')}
|
||
</Button>
|
||
<Button onClick={handleConfirm} disabled={!canConfirm}>
|
||
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||
{t('confirm')}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
)
|
||
}
|