feat(dimensions): PR7 producers — auto-tagged documents (invoices, supplier invoices, bulk-book, templates, MCP) (#868)

* feat(dimensions): PR7 producers — invoices/supplier invoices carry dims, generators propagate, BulkBook + templates + MCP bags

Source documents now carry dimension tags and every entry generator
propagates them onto journal lines (dev_docs/dimensions_implementation_plan.md PR7):

- invoices/supplier_invoices.default_dimensions + per-item dimensions
  (migration 20260702200000; jsonb DEFAULT '{}' + object CHECK)
- invoice-entries: issuance/payment/cash/credit propagate — item bags merge
  over the invoice default per revenue line (account+bag aggregation
  identity), payment vouchers re-propagate the linked invoice's bag onto
  every leg incl. FX result lines; ROT/RUT 1513 carries the item bag
- supplier-invoice-entries: registration/payment/cash/privately-paid/credit
  propagate with the same merge rules (expense buckets keyed account+bag)
- bulk_book_transactions RPC persists per-line bags + derives
  cost_center/project mirrors in SQL (migration 20260702201000; malformed
  bags rejected with BULK_BOOK_INVALID_DIMENSIONS); route merges the header
  default into template/manual lines
- counterparty templates: LinePatternEntry.dimensions learned from SIE
  voucher history (kept only when every occurrence agrees), applied to
  business lines on booking; QuickReviewDialog shows a dims badge
- categorize: staged dimensions bag tags business lines only (bank/VAT
  untagged); credit/convert/inbox copy paths carry bags forward
- propose-payment/send-lines stamp the invoice default so the editable
  payment grid books what the preview shows; mark-paid override lines
  accept dimensions
- UI: InvoiceEditor + NewSupplierInvoiceForm header KS/Projekt pair with
  per-row override; BulkBookDialog header default pair (both tabs)
- MCP: default_dimensions/items[].dimensions on create_invoice +
  create_supplier_invoice_from_inbox, dimensions on categorize_transaction,
  per-line bags on bulk_book_transactions — resolve-don't-select via the
  shared registry helpers, resolutions echoed

32 new propagation unit tests + 4 pg-real tests for the RPC migration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: use roundOre in new dims rounding assertions (ratchet)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: copy dimension bag per payment line, document dimensionsBagKey normalization contract (review)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-02 21:21:01 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 816b1769c8
commit 755e0f7e47
41 changed files with 3574 additions and 112 deletions
+153 -8
View File
@@ -24,7 +24,7 @@ import { useToast } from '@/components/ui/use-toast'
import { formatCurrency } from '@/lib/utils'
import { getVatRules, getAvailableVatRates } from '@/lib/invoices/vat-rules'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
import { Loader2, Plus, Trash2, ArrowLeft, Send, Eye, Landmark, Lock, AlertTriangle, MoreVertical, CalendarClock } from 'lucide-react'
import { Loader2, Plus, Trash2, ArrowLeft, Send, Eye, Landmark, Lock, AlertTriangle, MoreVertical, CalendarClock, Tags } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuTrigger,
@@ -54,6 +54,7 @@ import {
} from '@/lib/invoices/rot-rut-rules'
import AccrualPeriodControl from '@/components/bookkeeping/AccrualPeriodControl'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import LineDimensionFields from '@/components/dimensions/LineDimensionFields'
import { DEFAULT_DEFERRED_REVENUE_ACCOUNT } from '@/lib/bookkeeping/accruals/account-suggestions'
import { countCalendarMonths } from '@/lib/bookkeeping/accruals/compute'
import type { Customer, Currency, CreateInvoiceInput, CreateCustomerInput, InvoiceDocumentType, Article, Invoice, InvoiceItem, BASAccount } from '@/types'
@@ -85,6 +86,20 @@ function RequiredMark() {
return <span className="text-destructive ml-0.5" aria-hidden="true">*</span>
}
// True when a dimensions bag ({sie_dim_no: code}) carries at least one value.
function hasDimensionValues(dims: Record<string, string> | null | undefined): boolean {
return !!dims && Object.keys(dims).length > 0
}
// Compact display of a dimensions bag, e.g. "KS01 · P001" (dim-number order).
function compactDims(dims: Record<string, string>): string {
return Object.entries(dims)
.filter(([, v]) => v)
.sort(([a], [b]) => Number(a) - Number(b))
.map(([, v]) => v)
.join(' · ')
}
export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'create' }) {
// Edit mode pre-fills the form from an existing draft and saves via PATCH.
const isEditMode = props.mode === 'edit'
@@ -128,6 +143,10 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
accrual_period_start: z.string().nullable().optional(),
accrual_period_end: z.string().nullable().optional(),
accrual_balance_account: z.string().nullable().optional(),
// Per-item dimensions bag ({sie_dim_no: code}, dimensions PR7). Stored
// as-is; the server merges it over the invoice's default_dimensions on
// the item's revenue line at booking time.
dimensions: z.record(z.string(), z.string()).nullable().optional(),
}).superRefine((item, ctx) => {
if (item.accrual_balance_account != null) {
const start = item.accrual_period_start
@@ -221,6 +240,17 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
// override, plus which rows currently show that picker.
const [revenueAccounts, setRevenueAccounts] = useState<BASAccount[]>([])
const [accountOverrideRows, setAccountOverrideRows] = useState<Set<number>>(new Set())
// Dimension tagging (kostnadsställe/projekt, dimensions PR7). Affordances
// render only when company_settings.dimensions_enabled — a UI-visibility
// gate; a draft that already carries bags still round-trips untouched when
// the toggle is off. defaultDims is the invoice-level default; per-item
// overrides live on the form items and open via the row ⋮ menu (same
// open/close bookkeeping as accountOverrideRows).
const [dimensionsEnabled, setDimensionsEnabled] = useState(false)
const [defaultDims, setDefaultDims] = useState<Record<string, string>>(
initial?.default_dimensions ?? {},
)
const [dimensionOverrideRows, setDimensionOverrideRows] = useState<Set<number>>(new Set())
// True only when the user had zero invoices when this page loaded. The
// post-create flow uses this to offer a one-shot "upload a logo?" prompt
// — issue #520. Self-limits: once count > 0 it stays false.
@@ -279,6 +309,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
accrual_period_start: item.accrual_period_start ?? null,
accrual_period_end: item.accrual_period_end ?? null,
accrual_balance_account: item.accrual_balance_account ?? null,
dimensions: hasDimensionValues(item.dimensions) ? item.dimensions ?? null : null,
})),
}
: {
@@ -306,6 +337,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
accrual_period_start: null,
accrual_period_end: null,
accrual_balance_account: null,
dimensions: null,
}],
},
})
@@ -452,7 +484,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
if (!company?.id) return
const { data } = await supabase
.from('company_settings')
.select('invoice_default_notes, default_our_reference, clearing_number, account_number, bankgiro, accounting_method, ore_rounding, logo_url, vat_registered')
.select('invoice_default_notes, default_our_reference, clearing_number, account_number, bankgiro, accounting_method, ore_rounding, logo_url, vat_registered, dimensions_enabled')
.eq('company_id', company.id)
.single()
if (data?.invoice_default_notes) {
@@ -479,6 +511,8 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
if (typeof data?.vat_registered === 'boolean') {
setVatRegistered(data.vat_registered)
}
// Gates the dimension affordances (header default + per-row override).
setDimensionsEnabled(data?.dimensions_enabled === true)
}
// First-invoice detection (issue #520): captured at page load so the
@@ -711,6 +745,57 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
}
}
// Open/close the optional per-item dimensions override (⋮ menu). Closing
// clears the bag so the row falls back to the invoice's default_dimensions.
function toggleItemDimensions(index: number) {
const isOpen = dimensionOverrideRows.has(index) || hasDimensionValues(watchItems[index]?.dimensions)
if (isOpen) {
setValue(`items.${index}.dimensions`, null, { shouldDirty: true })
setDimensionOverrideRows((prev) => {
const next = new Set(prev)
next.delete(index)
return next
})
} else {
setDimensionOverrideRows((prev) => new Set(prev).add(index))
}
}
function updateItemDimension(index: number, dimNo: string, code: string | null) {
const current = { ...(watchItems[index]?.dimensions ?? {}) }
const trimmed = code?.trim()
if (trimmed) current[dimNo] = trimmed
else delete current[dimNo]
setValue(
`items.${index}.dimensions`,
Object.keys(current).length > 0 ? current : null,
{ shouldDirty: true },
)
// Keep the sub-row open after the user clears the last value — it closes
// only via the ⋮ menu (same lifecycle as the account override).
setDimensionOverrideRows((prev) => (prev.has(index) ? prev : new Set(prev).add(index)))
}
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
})
}
// Per-item bags ride the payload only when they carry values — the server
// treats an absent bag as "inherit the invoice's default_dimensions".
function pruneItemDimensions<T extends { dimensions?: Record<string, string> | null }>(
items: T[],
): T[] {
return items.map((item) =>
hasDimensionValues(item.dimensions) ? item : { ...item, dimensions: undefined },
)
}
// Self-billing path: no review dialog, no PDF, no send — it arrives already
// booked. POST straight to the dedicated endpoint and open the verifikat.
async function handleSelfBilledSubmit(data: FormData) {
@@ -834,7 +919,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
// null in the form state, but null personal-data fields shouldn't ride
// along on every regular invoice.
const anyDeduction = pendingData.items.some((i) => i.deduction_type)
const sanitizedItems = pendingData.items.map((item) => {
const sanitizedItems = pruneItemDimensions(pendingData.items).map((item) => {
if (item.deduction_type) return item
const {
deduction_type: _dt,
@@ -846,9 +931,12 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
} = item
return rest
})
const sanitizedPayload: CreateInvoiceInput = {
const sanitizedPayload: CreateInvoiceInput & { default_dimensions: Record<string, string> } = {
...(pendingData as CreateInvoiceInput),
ore_rounding: oreRounding,
// Invoice-level default dims — always sent so an edited draft can clear
// them; {} means "no defaults".
default_dimensions: defaultDims,
items: sanitizedItems as CreateInvoiceInput['items'],
...(anyDeduction
? {}
@@ -907,7 +995,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
setIsSavingDraft(true)
const anyDeduction = data.items.some((i) => i.deduction_type)
const sanitizedItems = data.items.map((item) => {
const sanitizedItems = pruneItemDimensions(data.items).map((item) => {
if (item.deduction_type) return item
const {
deduction_type: _dt,
@@ -919,10 +1007,11 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
} = item
return rest
})
const payload: CreateInvoiceInput = {
const payload: CreateInvoiceInput & { default_dimensions: Record<string, string> } = {
...(data as CreateInvoiceInput),
save_as_draft: true,
ore_rounding: oreRounding,
default_dimensions: defaultDims,
items: sanitizedItems as CreateInvoiceInput['items'],
...(anyDeduction
? {}
@@ -964,7 +1053,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
setIsSubmitting(true)
const anyDeduction = data.items.some((i) => i.deduction_type)
const sanitizedItems = data.items.map((item) => {
const sanitizedItems = pruneItemDimensions(data.items).map((item) => {
if (item.deduction_type) return item
const {
deduction_type: _dt,
@@ -976,9 +1065,10 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
} = item
return rest
})
const payload: CreateInvoiceInput = {
const payload: CreateInvoiceInput & { default_dimensions: Record<string, string> } = {
...(data as CreateInvoiceInput),
ore_rounding: oreRounding,
default_dimensions: defaultDims,
items: sanitizedItems as CreateInvoiceInput['items'],
...(anyDeduction
? {}
@@ -1343,6 +1433,17 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
</DropdownMenuItem>
</>
)}
{dimensionsEnabled && watchItems[index]?.line_type !== 'text' && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => toggleItemDimensions(index)} className="py-2">
<Tags className="h-4 w-4" />
{(dimensionOverrideRows.has(index) || hasDimensionValues(watchItems[index]?.dimensions))
? t('row_menu_remove_dimensions')
: t('row_menu_set_dimensions')}
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
className="py-2 text-destructive focus:text-destructive"
@@ -1662,6 +1763,28 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
</div>
)}
{/* Per-item dimensions override (dimensions PR7) — opened
via the row's ⋮ menu. The bag is stored as-is; the
server merges it over the invoice's default_dimensions
for this item's revenue line at booking time. */}
{dimensionsEnabled && isInvoiceDoc && watchItems[index]?.line_type !== 'text' &&
(dimensionOverrideRows.has(index) || hasDimensionValues(watchItems[index]?.dimensions)) && (
<div className="md:col-span-12 mt-2 md:mt-3">
<div className="max-w-md">
<LineDimensionFields
dimensions={watchItems[index]?.dimensions ?? undefined}
onChange={(dimNo, code) => updateItemDimension(index, dimNo, code)}
inputClassName="h-8"
/>
</div>
{hasDimensionValues(defaultDims) && (
<p className="mt-1 text-xs text-muted-foreground">
{t('row_dimensions_inherit_hint', { dims: compactDims(defaultDims) })}
</p>
)}
</div>
)}
{/* Mobile summary row */}
<div className="flex justify-between text-sm pt-1 border-t border-border/40 md:hidden">
<span className="text-muted-foreground">{t('row_label', { index: index + 1 })}</span>
@@ -1696,6 +1819,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
accrual_period_start: null,
accrual_period_end: null,
accrual_balance_account: null,
dimensions: null,
})
}
>
@@ -1731,6 +1855,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
accrual_period_start: null,
accrual_period_end: null,
accrual_balance_account: null,
dimensions: null,
})
}
>
@@ -1922,6 +2047,26 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
)}
/>
</div>
{/* Invoice-level default dims (kostnadsställe/projekt) —
written to every generated journal line; per-item bags
(row ⋮ menu) merge on top. Renders only when dimensions
are enabled for the company and the doc actually books. */}
{dimensionsEnabled && isInvoiceDoc && (
<>
<Separator />
<div className="space-y-1">
<LineDimensionFields
dimensions={defaultDims}
onChange={setDefaultDimension}
inputClassName="h-9"
/>
<p className="text-xs text-muted-foreground">
{t('dimensions_default_hint')}
</p>
</div>
</>
)}
</>
)}
</CardContent>
@@ -152,6 +152,7 @@ export default function PaymentBookingDialog({
exchange_rate: invoice.exchange_rate,
vat_treatment: invoice.vat_treatment,
items: invoice.items,
default_dimensions: invoice.default_dimensions,
},
accountingMethod,
entityType,
@@ -245,6 +246,12 @@ export default function PaymentBookingDialog({
debit_amount: parseFloat(l.debit_amount) || 0,
credit_amount: parseFloat(l.credit_amount) || 0,
line_description: l.line_description || undefined,
// Dimensions PR7: the proposal re-propagates the invoice default;
// whatever the grid holds is what gets booked.
dimensions:
l.dimensions && Object.keys(l.dimensions).length > 0
? l.dimensions
: undefined,
}))
const response = await fetch(`/api/invoices/${invoice.id}/mark-paid`, {
@@ -122,6 +122,7 @@ export default function SendInvoiceDialog({
exchange_rate: invoice.exchange_rate,
vat_treatment: invoice.vat_treatment,
items: invoice.items,
default_dimensions: invoice.default_dimensions,
},
entityType,
})
@@ -26,9 +26,10 @@ import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import BankTransactionPicker from '@/components/transactions/BankTransactionPicker'
import AccrualPeriodControl from '@/components/bookkeeping/AccrualPeriodControl'
import LineDimensionFields from '@/components/dimensions/LineDimensionFields'
import { suggestBalanceAccount } from '@/lib/bookkeeping/accruals/account-suggestions'
import { countCalendarMonths } from '@/lib/bookkeeping/accruals/compute'
import { ArrowLeft, Plus, Trash2, ChevronDown, Loader2, Lock, AlertCircle, MessageCircle, Link2, CalendarClock } from 'lucide-react'
import { ArrowLeft, Plus, Trash2, ChevronDown, Loader2, Lock, AlertCircle, MessageCircle, Link2, CalendarClock, Tags } from 'lucide-react'
import type { Supplier, BASAccount, VatTreatment, EntityType, InvoiceExtractionResult, FiscalPeriod } from '@/types'
interface LineItem {
@@ -44,6 +45,10 @@ interface LineItem {
accrual_period_start?: string
accrual_period_end?: string
accrual_balance_account?: string
// Per-item dimensions bag ({sie_dim_no: code}, dimensions PR7). Defined
// (possibly empty) while the row's dimensions panel is open; the server
// merges it over the invoice's default_dimensions at booking time.
dimensions?: Record<string, string>
}
// The existing invoice surfaced on a duplicate-number conflict, used to drive
@@ -309,6 +314,12 @@ export default function NewSupplierInvoiceForm({
// Öresavrundning is display-only; defaults to the company-wide setting and is
// overridable per invoice via the toggle in the totals section.
const [oreRounding, setOreRounding] = useState<boolean>(true)
// Dimension tagging (kostnadsställe/projekt). Affordances render only when
// company_settings.dimensions_enabled — the same UI-visibility gate as
// JournalEntryForm. defaultDims is the invoice-level default bag; per-item
// bags live on the form's items and merge over it server-side.
const [dimensionsEnabled, setDimensionsEnabled] = useState(false)
const [defaultDims, setDefaultDims] = useState<Record<string, string>>({})
const [periods, setPeriods] = useState<FiscalPeriod[]>([])
const [periodsLoaded, setPeriodsLoaded] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
@@ -661,8 +672,9 @@ export default function NewSupplierInvoiceForm({
setAccountingMethod(data.accounting_method)
}
if (typeof data?.ore_rounding === 'boolean') setOreRounding(data.ore_rounding)
setDimensionsEnabled(data?.dimensions_enabled === true)
} catch {
// Default to enskild_firma / accrual
// Default to enskild_firma / accrual, dimension affordances hidden
}
}
@@ -735,6 +747,68 @@ export default function NewSupplierInvoiceForm({
}
}
// --- Dimension tagging (kostnadsställe/projekt, dimensions PR7) ---
// Mirrors the accrual pattern: a defined (possibly empty) bag on the item
// means the row's panel is open; closing clears the bag entirely so the
// payload never carries a stale override.
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
})
}
function isDimOpen(index: number): boolean {
return watchedItems?.[index]?.dimensions != null
}
function toggleDimensions(index: number) {
if (isDimOpen(index)) {
setValue(`items.${index}.dimensions`, undefined, { shouldDirty: true })
} else {
setValue(`items.${index}.dimensions`, {}, { shouldDirty: true })
}
}
function updateItemDimension(index: number, dimNo: string, code: string | null) {
const current = { ...(getValues(`items.${index}.dimensions`) ?? {}) }
const trimmed = code?.trim()
if (trimmed) current[dimNo] = trimmed
else delete current[dimNo]
setValue(`items.${index}.dimensions`, current, { shouldDirty: true })
}
// Compact display of the invoice default, e.g. "KS01 · P001" (dim-number order).
const defaultDimsSummary = Object.entries(defaultDims)
.filter(([, v]) => v)
.sort(([a], [b]) => Number(a) - Number(b))
.map(([, v]) => v)
.join(' · ')
function renderDimensionsPanel(index: number) {
const item = watchedItems?.[index]
if (!item || item.dimensions == null) return null
return (
<div className="rounded-lg border bg-muted/30 p-3 space-y-1">
<div className="max-w-md">
<LineDimensionFields
dimensions={item.dimensions}
onChange={(dimNo, code) => updateItemDimension(index, dimNo, code)}
inputClassName="h-8"
/>
</div>
{defaultDimsSummary && (
<p className="text-xs text-muted-foreground">
{t('row_dimensions_inherit_hint', { dims: defaultDimsSummary })}
</p>
)}
</div>
)
}
function renderAccrualPanel(index: number, idPrefix: string) {
const item = watchedItems?.[index]
if (!item || item.accrual_balance_account == null) return null
@@ -861,6 +935,9 @@ export default function NewSupplierInvoiceForm({
notes: data.notes || undefined,
paid_with_private_funds: data.paid_with_private_funds,
ore_rounding: oreRounding,
// Invoice-level default dimensions (kostnadsställe/projekt) — only sent
// when the user actually picked something.
...(Object.keys(defaultDims).length > 0 ? { default_dimensions: defaultDims } : {}),
items: data.items.map((item) => ({
description: item.description,
amount: item.amount,
@@ -879,6 +956,11 @@ export default function NewSupplierInvoiceForm({
accrual_balance_account: item.accrual_balance_account || undefined,
}
: {}),
// Per-item dimensions override — only when the bag carries values
// (an open-but-empty panel means "inherit the invoice default").
...(item.dimensions && Object.keys(item.dimensions).length > 0
? { dimensions: item.dimensions }
: {}),
})),
}
}
@@ -1437,6 +1519,22 @@ export default function NewSupplierInvoiceForm({
)}
</div>
{/* Invoice-level default dims (kostnadsställe/projekt) — applied to
every generated journal line; per-row bags in Kontering merge on
top. Renders only when dimensions are enabled for the company. */}
{dimensionsEnabled && (
<div className="space-y-1">
<div className="max-w-md">
<LineDimensionFields
dimensions={defaultDims}
onChange={setDefaultDimension}
inputClassName="h-9"
/>
</div>
<p className="text-xs text-muted-foreground">{t('dimensions_default_hint')}</p>
</div>
)}
{showNoPeriodWarning && (
<div
role="alert"
@@ -1555,7 +1653,7 @@ export default function NewSupplierInvoiceForm({
<tbody>
{fields.map((field, index) => (
<Fragment key={field.id}>
<tr className={cn('align-top', canUseAccrual && isAccrualOpen(index) ? 'border-0' : 'border-b last:border-0')}>
<tr className={cn('align-top', (canUseAccrual && isAccrualOpen(index)) || (dimensionsEnabled && isDimOpen(index)) ? 'border-0' : 'border-b last:border-0')}>
<td className="py-2 pr-2">
<Controller
name={`items.${index}.account_number`}
@@ -1625,6 +1723,24 @@ export default function NewSupplierInvoiceForm({
</td>
<td className="py-2 pt-3">
<div className="flex items-center">
{dimensionsEnabled && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => toggleDimensions(index)}
aria-label={t('row_dimensions_aria', { index: index + 1 })}
aria-pressed={isDimOpen(index)}
title={t('row_dimensions_title')}
>
<Tags
className={cn(
'h-4 w-4',
isDimOpen(index) ? 'text-foreground' : 'text-muted-foreground',
)}
/>
</Button>
)}
{canUseAccrual && (
<Button
type="button"
@@ -1652,12 +1768,19 @@ export default function NewSupplierInvoiceForm({
</td>
</tr>
{canUseAccrual && isAccrualOpen(index) && (
<tr className="border-b last:border-0">
<tr className={cn(dimensionsEnabled && isDimOpen(index) ? 'border-0' : 'border-b last:border-0')}>
<td colSpan={6} className="pb-3">
{renderAccrualPanel(index, `accrual-desktop-${index}`)}
</td>
</tr>
)}
{dimensionsEnabled && isDimOpen(index) && (
<tr className="border-b last:border-0">
<td colSpan={6} className="pb-3">
{renderDimensionsPanel(index)}
</td>
</tr>
)}
</Fragment>
))}
</tbody>
@@ -1671,6 +1794,24 @@ export default function NewSupplierInvoiceForm({
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">{t('row_label', { index: index + 1 })}</span>
<div className="flex items-center">
{dimensionsEnabled && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => toggleDimensions(index)}
aria-label={t('row_dimensions_aria', { index: index + 1 })}
aria-pressed={isDimOpen(index)}
title={t('row_dimensions_title')}
>
<Tags
className={cn(
'h-4 w-4',
isDimOpen(index) ? 'text-foreground' : 'text-muted-foreground',
)}
/>
</Button>
)}
{canUseAccrual && (
<Button
type="button"
@@ -1770,6 +1911,7 @@ export default function NewSupplierInvoiceForm({
</div>
{canUseAccrual && isAccrualOpen(index) &&
renderAccrualPanel(index, `accrual-mobile-${index}`)}
{dimensionsEnabled && isDimOpen(index) && renderDimensionsPanel(index)}
</div>
))}
</div>
+73 -3
View File
@@ -14,6 +14,7 @@ import {
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'
@@ -23,6 +24,7 @@ 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'
@@ -88,6 +90,12 @@ export default function BulkBookDialog({
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
@@ -162,6 +170,22 @@ export default function BulkBookDialog({
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) {
@@ -171,6 +195,7 @@ export default function BulkBookDialog({
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 }))
@@ -303,6 +328,23 @@ export default function BulkBookDialog({
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)))
}
@@ -331,6 +373,12 @@ export default function BulkBookDialog({
// 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'
? {
@@ -343,12 +391,14 @@ export default function BulkBookDialog({
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',
@@ -625,6 +675,19 @@ export default function BulkBookDialog({
</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
@@ -639,9 +702,16 @@ export default function BulkBookDialog({
{/* Live preview */}
{tabReady && previewLines.length > 0 && (
<div className="space-y-2">
<Label>
{t('preview_label', { count: previewLines.length })}
</Label>
<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>
@@ -2,6 +2,7 @@
import { useState, useEffect, useCallback } from 'react'
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'
@@ -171,6 +172,20 @@ export default function QuickReviewDialog({
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
@@ -311,6 +326,11 @@ export default function QuickReviewDialog({
<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 && !isCounterpartyTemplate && (
<button
type="button"