198d3092c7
Picking a suggestion under "Tidigare motparter" in Bokför transaktion replaced
the page with "Något gick fel". handleOpenTemplateReview built the review state
from `{ id, name_sv } as BookingTemplate`, so `template.debit_account` was
undefined, reached QuickReviewDialog's required `defaultAccount: string`, and
threw on `accountOverride.startsWith('2')` during the first render.
Typed the dialog's template prop as a narrow ReviewTemplate whose optional
fields are actually optional, so the cast disappears and the compiler owns this
class of bug. Also carries the counterparty's learned accounts and VAT (the
preview showed the category fallback, not what the server books) and decides
"is this a counterparty booking" from the template id rather than the presence
of a line_pattern (single-line templates got an account/VAT editor the
categorize route discards).
Five more page-crashes of the same shape, adversarially verified:
- suppliers/[id] and supplier-invoices/[id] passed the error envelope OBJECT as
a toast description. The Toaster is a sibling of {children} in the ROOT
layout, so that throw escapes both segment error boundaries onto global-error.
- components/reports/views wrote the same object into a useState<string | null>
at 13 sites and rendered it bare.
- components/ui/toaster.tsx now coerces non-renderable values as a choke point.
- skattekonto read data.informationstext.length off Skatteverket's raw JSON,
where the field is not required.
- TicWorkspace read profile.statuses.length off a persisted jsonb blob. 17 of 17
prod rows predate the TIC v2 upgrade (#584) and lack the key, so that
workspace was in the error boundary for every company that had opened it.
Plus hardening: formatCurrency coerces a null currency to SEK (prod has 0 NULL
across 28 416 transactions, so defense not a live bug) and cleanSignatory
returns [] for a missing description.
Verified by rendering the real dialog against a throwaway /sandbox route: the
pre-fix prop shape reproduces the exact error boundary, the fixed one renders
D: 6570 Bankavgifter / K: 1930 Företagskonto and the matching verifikat.
No migrations.
643 lines
26 KiB
TypeScript
643 lines
26 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useCallback } from 'react'
|
|
import { useRouter } from 'next/navigation'
|
|
import { useTranslations } from 'next-intl'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
|
import { useToast } from '@/components/ui/use-toast'
|
|
import { ToastAction } from '@/components/ui/toast'
|
|
import { formatCurrency, formatDate } from '@/lib/utils'
|
|
import { linkDocuments, formatFailedDocumentNames } from '@/lib/documents/link-documents'
|
|
import { ArrowUpRight, ArrowDownRight, Check, Paperclip, ChevronDown, ChevronUp, AlertTriangle } from 'lucide-react'
|
|
import { getDefaultAccountForCategory } from '@/lib/bookkeeping/category-mapping'
|
|
import { isCounterpartyTemplateId } from '@/lib/bookkeeping/counterparty-templates'
|
|
import { getVatRate } from '@/lib/bookkeeping/vat-entries'
|
|
import type { ReviewTemplate } from '@/lib/transactions/quick-review-defaults'
|
|
import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
|
|
import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
|
|
import JournalEntryPreview from './JournalEntryPreview'
|
|
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
|
|
import LineDimensionFields from '@/components/dimensions/LineDimensionFields'
|
|
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
|
|
import DocumentViewerPane from '@/components/bookkeeping/DocumentViewerPane'
|
|
import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
|
|
import VatTreatmentSelect from './VatTreatmentSelect'
|
|
import { VAT_TREATMENT_OPTIONS } from './transaction-types'
|
|
import type { TransactionWithInvoice } from './transaction-types'
|
|
import type { TransactionCategory, VatTreatment, BASAccount, EntityType, LinePatternEntry } from '@/types'
|
|
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
|
|
|
interface QuickReviewDialogProps {
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
transaction: TransactionWithInvoice | null
|
|
category: TransactionCategory | null
|
|
categoryLabel: string
|
|
/** Empty string when there is no sensible default: never undefined. */
|
|
defaultAccount: string
|
|
defaultVat: VatTreatment | 'none'
|
|
entityType?: EntityType
|
|
template?: ReviewTemplate | null
|
|
templateId?: string
|
|
counterpartyLinePattern?: LinePatternEntry[] | null
|
|
/**
|
|
* Learned bag from the counterparty template (default_dimensions): prefills
|
|
* the picker so the user sees, and can change, what the booking will be
|
|
* tagged with.
|
|
*/
|
|
counterpartyDefaultDimensions?: Record<string, string> | null
|
|
onConfirm: (
|
|
id: string,
|
|
category: TransactionCategory,
|
|
vatTreatment: VatTreatment | undefined,
|
|
accountOverride: string | undefined,
|
|
templateId?: string,
|
|
dimensions?: Record<string, string>
|
|
) => Promise<string | null>
|
|
onChangeTemplate?: () => void
|
|
}
|
|
|
|
export default function QuickReviewDialog({
|
|
open,
|
|
onOpenChange,
|
|
transaction,
|
|
category,
|
|
categoryLabel,
|
|
defaultAccount,
|
|
defaultVat,
|
|
entityType,
|
|
template,
|
|
templateId,
|
|
counterpartyLinePattern,
|
|
counterpartyDefaultDimensions,
|
|
onConfirm,
|
|
onChangeTemplate,
|
|
}: QuickReviewDialogProps) {
|
|
const t = useTranslations('tx_quick_review')
|
|
const tCat = useTranslations('tx_categories')
|
|
const { toast } = useToast()
|
|
const router = useRouter()
|
|
// `?? ''` is deliberate belt-and-braces: the prop is a required string, but
|
|
// a caller that hands over a template-shaped object missing debit_account
|
|
// used to make this undefined and take the whole page down on the
|
|
// .startsWith() below. An empty account disables the confirm button; it
|
|
// never throws.
|
|
const [accountOverride, setAccountOverride] = useState(defaultAccount ?? '')
|
|
const [vatTreatment, setVatTreatment] = useState<VatTreatment | 'none'>(defaultVat)
|
|
const [accounts, setAccounts] = useState<BASAccount[]>([])
|
|
const [isProcessing, setIsProcessing] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
|
|
const [showUploadZone, setShowUploadZone] = useState(false)
|
|
const [showVatDropdown, setShowVatDropdown] = useState(false)
|
|
// Mirror of `transaction` so we can patch in a freshly-fetched SEK conversion
|
|
// before the user confirms: the verifikation must always be in SEK and the
|
|
// engine reads these fields straight off the transaction row.
|
|
const [enrichedTx, setEnrichedTx] = useState<TransactionWithInvoice | null>(transaction)
|
|
const [rateLoading, setRateLoading] = useState(false)
|
|
const [rateError, setRateError] = useState<string | null>(null)
|
|
// Dimension tagging (kostnadsställe/projekt): the picker renders only when
|
|
// company_settings.dimensions_enabled, same gate as BulkBookDialog. Seeded
|
|
// from the counterparty template's learned bag so the user sees what the
|
|
// booking will carry and can change it.
|
|
const [dimensionsEnabled, setDimensionsEnabled] = useState(false)
|
|
const [dims, setDims] = useState<Record<string, string>>(
|
|
() => ({ ...(counterpartyDefaultDimensions ?? {}) }),
|
|
)
|
|
|
|
const preAttachedDocumentId = transaction?.document_id ?? null
|
|
|
|
// Handle account changes: clear VAT for liability/equity accounts (class 2)
|
|
const handleAccountChange = useCallback((account: string) => {
|
|
setAccountOverride(account ?? '')
|
|
if (account?.startsWith('2')) {
|
|
setVatTreatment('none')
|
|
}
|
|
}, [])
|
|
|
|
// Fetch accounts on mount
|
|
useEffect(() => {
|
|
async function fetchAccounts() {
|
|
try {
|
|
const res = await fetch('/api/bookkeeping/accounts')
|
|
const { data } = await res.json()
|
|
if (data) {
|
|
setAccounts(data)
|
|
}
|
|
} catch {
|
|
// Non-critical
|
|
}
|
|
}
|
|
fetchAccounts()
|
|
}, [])
|
|
|
|
// Reset local mirror whenever the underlying transaction changes (the parent
|
|
// reuses the dialog instance across rows).
|
|
useEffect(() => {
|
|
setEnrichedTx(transaction)
|
|
setRateError(null)
|
|
setDims({ ...(counterpartyDefaultDimensions ?? {}) })
|
|
// Re-seeding on counterpartyDefaultDimensions alone would clobber in-
|
|
// flight edits; the bag only changes together with the transaction.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [transaction])
|
|
|
|
// Company settings gate the dimension affordance (dimensions_enabled).
|
|
// Fetched once per open; on failure the picker 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])
|
|
|
|
// Backfill the SEK conversion on demand. resolveSekAmount silently falls
|
|
// back to the raw foreign amount when amount_sek/exchange_rate are null,
|
|
// which means the user would see misleading "kr" values in the verifikation
|
|
// and the engine would post the wrong number to the books.
|
|
useEffect(() => {
|
|
if (!open || !transaction) return
|
|
const needsRate =
|
|
!!transaction.currency &&
|
|
transaction.currency !== 'SEK' &&
|
|
(transaction.amount_sek == null || transaction.exchange_rate == null)
|
|
if (!needsRate) return
|
|
|
|
let cancelled = false
|
|
setRateLoading(true)
|
|
setRateError(null)
|
|
;(async () => {
|
|
try {
|
|
const res = await fetch(`/api/transactions/${transaction.id}/refresh-exchange-rate`, {
|
|
method: 'POST',
|
|
})
|
|
const json = await res.json()
|
|
if (cancelled) return
|
|
if (!res.ok) {
|
|
setRateError(getUserErrorMessage(json?.error) || t('exchange_rate_fetch_failed'))
|
|
return
|
|
}
|
|
if (json?.data) {
|
|
setEnrichedTx({ ...json.data, ...{
|
|
potential_invoice: transaction.potential_invoice,
|
|
potential_supplier_invoice: transaction.potential_supplier_invoice,
|
|
} })
|
|
}
|
|
} catch {
|
|
if (!cancelled) setRateError(t('exchange_rate_fetch_failed'))
|
|
} finally {
|
|
if (!cancelled) setRateLoading(false)
|
|
}
|
|
})()
|
|
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [open, transaction, t])
|
|
|
|
if (!transaction || !category) return null
|
|
|
|
const tx = enrichedTx ?? transaction
|
|
const isIncome = tx.amount > 0
|
|
// Keyed off the template ID, not off the presence of a line pattern: a
|
|
// *learned* counterparty (one business line, no pattern) is still booked
|
|
// server-side from counterparty_template_id, so its accounts and VAT come
|
|
// from the stored template. Deciding this from counterparties that happen
|
|
// to have a multi-line pattern made single-line ones fall through to the
|
|
// category branch, which previewed the wrong accounts and offered an
|
|
// account/VAT editor whose values the categorize route discards.
|
|
const isCounterpartyTemplate = !!template?.id && isCounterpartyTemplateId(template.id)
|
|
const hasCounterpartyPattern = !!(counterpartyLinePattern && counterpartyLinePattern.length > 0)
|
|
const isTemplateBooking = !!templateId || isCounterpartyTemplate
|
|
const isLiabilityAccount = accountOverride?.startsWith('2') ?? false
|
|
// For non-SEK transactions, the verifikation and the headline must show
|
|
// the SEK-converted total: the mall/category booking always posts in SEK.
|
|
const sekAmount = resolveSekAmount(
|
|
tx.amount,
|
|
tx.amount_sek,
|
|
tx.currency,
|
|
tx.exchange_rate
|
|
)
|
|
const isForeign = !!(tx.currency && tx.currency !== 'SEK')
|
|
const sekConversionMissing = isForeign && (tx.amount_sek == null || tx.exchange_rate == null)
|
|
|
|
// Dimensions carried by the counterparty template's line pattern (dimensions
|
|
// PR7). Business lines may each carry a {sie_dim_no: code} bag: merge them
|
|
// into one compact display label ("KS01 · P001", dim-number order). This is
|
|
// display-only: booking applies the pattern's bags server-side.
|
|
const patternDims: Record<string, string> = {}
|
|
for (const line of counterpartyLinePattern ?? []) {
|
|
if (line.dimensions) Object.assign(patternDims, line.dimensions)
|
|
}
|
|
const patternDimsLabel = Object.entries(patternDims)
|
|
.filter(([, code]) => code)
|
|
.sort(([a], [b]) => Number(a) - Number(b))
|
|
.map(([, code]) => code)
|
|
.join(' · ')
|
|
|
|
async function handleConfirm() {
|
|
if (!category || !transaction) return
|
|
|
|
setIsProcessing(true)
|
|
setError(null)
|
|
try {
|
|
const resolvedVat = vatTreatment === 'none' ? undefined : vatTreatment
|
|
const catDefault = getDefaultAccountForCategory(category)
|
|
const override = accountOverride && accountOverride !== catDefault
|
|
? accountOverride
|
|
: undefined
|
|
|
|
// Cleared combobox values leave empty strings behind; strip them so an
|
|
// untouched picker sends no bag at all (learned template bags then apply
|
|
// server-side unchanged).
|
|
const cleanedDims = Object.fromEntries(
|
|
Object.entries(dims).filter(([, code]) => code && code.trim().length > 0),
|
|
)
|
|
const journalEntryId = await onConfirm(
|
|
transaction.id,
|
|
category,
|
|
resolvedVat,
|
|
override,
|
|
templateId,
|
|
Object.keys(cleanedDims).length > 0 ? cleanedDims : undefined,
|
|
)
|
|
|
|
// Attach the uploaded underlag to the verifikat the booking just created.
|
|
// BFL 5 kap 7 § requires the verifikation to reference its underlag and
|
|
// BFL 7 kap requires that underlag to be archived with it; the verifikat
|
|
// is already committed here, so a failed link can only be reported, not
|
|
// undone. The parent's "Bokförd" toast must not be the last word when a
|
|
// receipt never made it onto the books.
|
|
if (journalEntryId && uploadedFiles.length > 0) {
|
|
const targets = uploadedFiles
|
|
.filter((f) => f.status === 'uploaded' && f.id)
|
|
.map((f) => ({ documentId: f.id as string, fileName: f.fileName }))
|
|
const { failed } = await linkDocuments(targets, journalEntryId)
|
|
if (failed.length > 0) {
|
|
toast({
|
|
title: t('doc_link_failed_booked_title'),
|
|
description: t('doc_link_failed_booked_description', {
|
|
count: failed.length,
|
|
files: formatFailedDocumentNames(failed),
|
|
}),
|
|
variant: 'destructive',
|
|
action: (
|
|
<ToastAction
|
|
altText={t('doc_link_open_entry')}
|
|
onClick={() => router.push(`/bookkeeping/${journalEntryId}`)}
|
|
>
|
|
{t('doc_link_open_entry')}
|
|
</ToastAction>
|
|
),
|
|
})
|
|
// By this point the parent has already closed the dialog (onConfirm
|
|
// resolved before linkDocuments did), so this component's file state
|
|
// is invisible either way: the toast above, with its open-entry
|
|
// action, is the user's actual pointer to the underlag that did not
|
|
// attach. The early return just skips the redundant cleanup below.
|
|
return
|
|
}
|
|
}
|
|
|
|
setUploadedFiles([])
|
|
setShowUploadZone(false)
|
|
} catch {
|
|
setError(t('generic_error'))
|
|
} finally {
|
|
// Always reset isProcessing: without this, an onConfirm that resolves
|
|
// with null (e.g. server returned a structured 4xx error like
|
|
// ACCOUNTS_NOT_IN_CHART) leaves the dialog frozen because the
|
|
// <Dialog onOpenChange> below disables backdrop/ESC while processing.
|
|
setIsProcessing(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={isProcessing ? undefined : (o) => {
|
|
if (!o) {
|
|
setUploadedFiles([])
|
|
setShowUploadZone(false)
|
|
}
|
|
onOpenChange(o)
|
|
}}>
|
|
<DialogContent className={preAttachedDocumentId ? 'max-w-6xl max-h-[90vh] overflow-y-auto' : 'max-w-md sm:max-w-lg max-h-[85vh] overflow-y-auto'}>
|
|
<DialogHeader>
|
|
<DialogTitle>{t('title')}</DialogTitle>
|
|
<DialogDescription>
|
|
{isTemplateBooking ? t('description_template') : t('description_default')}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{/* When a document is pre-attached, show it side-by-side (receipt left,
|
|
review right). With no document the wrappers use display:contents so
|
|
the dialog collapses to the original single-column layout. */}
|
|
<div className={preAttachedDocumentId ? 'grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,520px)]' : 'contents'}>
|
|
{preAttachedDocumentId && (
|
|
<div className="h-[45vh] lg:sticky lg:top-0 lg:h-[72vh] lg:self-start">
|
|
<DocumentViewerPane documentId={preAttachedDocumentId} className="h-full" />
|
|
</div>
|
|
)}
|
|
<div className={preAttachedDocumentId ? 'space-y-4' : 'contents'}>
|
|
|
|
{/* Transaction summary */}
|
|
<div className="flex items-center gap-3 rounded-lg border p-3">
|
|
<div
|
|
className={`h-9 w-9 rounded-full flex items-center justify-center flex-shrink-0 ${isIncome ? 'text-success' : 'text-destructive'}`}
|
|
>
|
|
{isIncome ? (
|
|
<ArrowUpRight className="h-4 w-4" />
|
|
) : (
|
|
<ArrowDownRight className="h-4 w-4" />
|
|
)}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="font-medium text-sm break-all">{tx.description}</p>
|
|
<p className="text-xs text-muted-foreground">{formatDate(tx.date)}</p>
|
|
</div>
|
|
<div className="text-right flex-shrink-0">
|
|
{isForeign ? (
|
|
<>
|
|
<p className={`font-medium text-sm tabular-nums ${isIncome ? 'text-success' : ''}`}>
|
|
{isIncome ? '+' : ''}
|
|
{formatCurrency(tx.amount, tx.currency)}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground tabular-nums">
|
|
{rateLoading || sekConversionMissing
|
|
? t('amount_loading')
|
|
: t('amount_approx', { sign: isIncome ? '+' : '', sek: formatCurrency(sekAmount, 'SEK') })}
|
|
</p>
|
|
</>
|
|
) : (
|
|
<p className={`font-medium text-sm tabular-nums ${isIncome ? 'text-success' : ''}`}>
|
|
{isIncome ? '+' : ''}
|
|
{formatCurrency(sekAmount, 'SEK')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{isForeign && tx.exchange_rate != null && tx.exchange_rate_date && !sekConversionMissing && (
|
|
<p className="text-xs text-muted-foreground -mt-1">
|
|
{t('rate_footnote', {
|
|
rate: formatCurrency(tx.exchange_rate, 'SEK'),
|
|
currency: tx.currency,
|
|
date: formatDate(tx.exchange_rate_date),
|
|
})}
|
|
</p>
|
|
)}
|
|
|
|
{rateError && (
|
|
<div className="rounded-lg border border-destructive/30 bg-destructive/[0.05] px-3 py-2">
|
|
<p className="text-xs text-destructive leading-snug">{rateError}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Template or Category */}
|
|
<div>
|
|
<label className="text-sm font-medium text-muted-foreground">
|
|
{isCounterpartyTemplate ? t('label_counterparty_template') : template ? t('label_template') : t('label_category')}
|
|
</label>
|
|
<div className="mt-1 flex items-center gap-2">
|
|
<span className="text-sm font-medium text-foreground">
|
|
{template ? template.name_sv : categoryLabel}
|
|
</span>
|
|
{patternDimsLabel && (
|
|
<Badge variant="secondary" className="font-mono tabular-nums">
|
|
{patternDimsLabel}
|
|
</Badge>
|
|
)}
|
|
{onChangeTemplate && !hasCounterpartyPattern && (
|
|
<button
|
|
type="button"
|
|
className="text-xs text-primary hover:underline"
|
|
onClick={onChangeTemplate}
|
|
>
|
|
{t('change_template')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
{/* Only when there IS a single debit/credit pair to show: a
|
|
multi-line counterparty pattern has none, and a template that
|
|
never carried accounts would render "D: → K: ". */}
|
|
{!hasCounterpartyPattern && template?.debit_account && template?.credit_account && (
|
|
<p className="mt-1.5 text-xs font-mono text-muted-foreground">
|
|
D: {formatAccountWithName(template.debit_account)} → K: {formatAccountWithName(template.credit_account)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Template special rules */}
|
|
{template?.special_rules_sv && (
|
|
<div className="rounded-lg border border-warning/30 bg-warning/[0.03] px-3 py-2">
|
|
<p className="text-xs text-warning-foreground leading-snug">
|
|
{template.special_rules_sv}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Deductibility note */}
|
|
{template?.deductibility_note_sv && (
|
|
<div className="rounded-lg border border-primary/20 bg-primary/[0.03] px-3 py-2">
|
|
<p className="text-xs text-foreground leading-snug">
|
|
{template.deductibility_note_sv}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Reverse charge warning */}
|
|
{template?.requires_vat_registration_data && (
|
|
<div className="rounded-lg border border-warning/30 bg-warning/[0.03] px-3 py-2">
|
|
<div className="flex items-start gap-2">
|
|
<AlertTriangle className="h-3.5 w-3.5 text-warning-foreground flex-shrink-0 mt-0.5" />
|
|
<p className="text-xs text-warning-foreground leading-snug">
|
|
{t('reverse_charge_warning')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Journal entry preview: hidden until we have a SEK conversion;
|
|
otherwise we'd render a verifikation in the wrong currency. */}
|
|
{!sekConversionMissing && !rateLoading && (
|
|
<JournalEntryPreview
|
|
amount={tx.amount}
|
|
amountSek={sekAmount}
|
|
{...(hasCounterpartyPattern
|
|
? { linePattern: counterpartyLinePattern ?? undefined }
|
|
: isTemplateBooking && template?.debit_account && template?.credit_account
|
|
? {
|
|
templateDebitAccount: template.debit_account,
|
|
templateCreditAccount: template.credit_account,
|
|
// A counterparty template carries a treatment but no rate,
|
|
// and its legacy booking path emits an input-VAT leg from
|
|
// that treatment only (no basbelopp pair), so it gets the
|
|
// rate alone: passing the treatment too would preview
|
|
// reverse-charge lines the engine never books.
|
|
templateVatRate: isCounterpartyTemplate
|
|
? (template.vat_treatment ? getVatRate(template.vat_treatment) : 0)
|
|
: template.vat_rate,
|
|
...(isCounterpartyTemplate
|
|
? {}
|
|
: {
|
|
templateVatTreatment: template.vat_treatment,
|
|
templateSupplierType: template.reverse_charge_supplier_type,
|
|
}),
|
|
}
|
|
: { category, vatTreatment: isLiabilityAccount ? 'none' : vatTreatment, accountOverride, entityType }
|
|
)}
|
|
/>
|
|
)}
|
|
|
|
{/* Account & VAT: hidden for template bookings (accounts defined by the template) */}
|
|
{!isTemplateBooking && (
|
|
<>
|
|
<div>
|
|
<label className="text-sm font-medium text-muted-foreground">{t('label_account')}</label>
|
|
<div className="mt-1">
|
|
<AccountCombobox
|
|
value={accountOverride}
|
|
accounts={accounts}
|
|
onChange={handleAccountChange}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-sm font-medium text-muted-foreground">{t('label_vat_treatment')}</label>
|
|
<div className="mt-1">
|
|
{isLiabilityAccount ? (
|
|
<p className="text-sm text-muted-foreground">
|
|
{t('no_vat_liability_account')}
|
|
</p>
|
|
) : showVatDropdown ? (
|
|
<VatTreatmentSelect
|
|
value={vatTreatment}
|
|
onValueChange={setVatTreatment}
|
|
/>
|
|
) : (
|
|
<p className="text-sm">
|
|
{(() => {
|
|
const opt = VAT_TREATMENT_OPTIONS.find(o => o.value === vatTreatment)
|
|
return opt ? tCat(opt.labelKey) : t('no_vat_default')
|
|
})()}
|
|
{' '}
|
|
<button
|
|
type="button"
|
|
className="text-xs text-primary hover:underline"
|
|
onClick={() => setShowVatDropdown(true)}
|
|
>
|
|
{t('change')}
|
|
</button>
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Dimension tags (kostnadsställe/projekt): rendered for category,
|
|
library-template and legacy counterparty bookings. Multi-line
|
|
counterparty patterns are excluded: their per-line bags are
|
|
authoritative server-side and an edit here would be ignored. */}
|
|
{dimensionsEnabled && !hasCounterpartyPattern && (
|
|
<div>
|
|
<label className="text-sm font-medium text-muted-foreground">{t('label_dimensions')}</label>
|
|
<div className="mt-1">
|
|
<LineDimensionFields
|
|
dimensions={dims}
|
|
onChange={(sieDimNo, code) => {
|
|
setDims((prev) => {
|
|
const next = { ...prev }
|
|
if (code) next[sieDimNo] = code
|
|
else delete next[sieDimNo]
|
|
return next
|
|
})
|
|
}}
|
|
inputClassName="h-8"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* No pre-attached document: let the user upload one. (When a document
|
|
IS pre-attached it's shown in the left preview column instead.) */}
|
|
{!preAttachedDocumentId && (
|
|
<div className="rounded-lg border">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowUploadZone(!showUploadZone)}
|
|
className="flex items-center justify-between w-full px-3 py-2.5 text-sm hover:bg-muted/50 transition-colors"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<Paperclip className="h-4 w-4 text-muted-foreground" />
|
|
<span className="font-medium">{t('doc_label')}</span>
|
|
{uploadedFiles.filter((f) => f.status === 'uploaded').length > 0 && (
|
|
<span className="text-xs text-muted-foreground">
|
|
{t('doc_attached_count', { count: uploadedFiles.filter((f) => f.status === 'uploaded').length })}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{showUploadZone ? (
|
|
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
|
) : (
|
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
|
)}
|
|
</button>
|
|
{showUploadZone && (
|
|
<div className="px-3 pb-3">
|
|
<DocumentUploadZone
|
|
files={uploadedFiles}
|
|
onFilesChange={setUploadedFiles}
|
|
compact
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{error && (
|
|
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{/* Actions */}
|
|
<div className="flex gap-2 pt-2">
|
|
<Button
|
|
variant="outline"
|
|
className="flex-1"
|
|
onClick={() => onOpenChange(false)}
|
|
disabled={isProcessing}
|
|
>
|
|
{t('cancel')}
|
|
</Button>
|
|
<Button
|
|
className="flex-1"
|
|
onClick={handleConfirm}
|
|
disabled={
|
|
isProcessing ||
|
|
(!isTemplateBooking && !accountOverride) ||
|
|
rateLoading ||
|
|
sekConversionMissing
|
|
}
|
|
>
|
|
<Check className="mr-2 h-4 w-4" />
|
|
{isProcessing ? t('booking') : rateLoading ? t('fetching_rate') : t('book')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|