feat(categorize): dimension bags end-to-end + runtime template learning (#1273)

* feat(categorize): dimension bags end-to-end + runtime template learning

The categorize path could not tag: categorize-core accepted a dimensions
bag but no route or UI ever passed one, and runtime template learning
dropped the bag entirely (only SIE import produced dimension-carrying
patterns).

- CategorizeTransactionSchema gains dimensions; the dashboard route, v1
  single and v1 batch-categorize apply it to the mapping result's
  business lines (explicit bag wins over a learned counterparty bag).
- categorization_templates.default_dimensions (migration 20260728091000)
  records the bag of the latest tagged booking; latest-explicit-wins, an
  untagged booking never erases it. Applied on the legacy single-line
  template path and the mirrored-refund path; multi-line SIE patterns
  keep their authoritative per-entry bags.
- QuickReviewDialog gets a LineDimensionFields picker (dimensions_enabled
  gate, same as BulkBookDialog), prefilled from the counterparty
  suggestion's learned bag; hidden for multi-line patterns whose per-line
  bags would ignore an edit.

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

* Renumber migration above 20260728120000 (out-of-order vs prod after #1271)

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-29 09:39:23 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 951b33363b
commit 222e581476
16 changed files with 403 additions and 9 deletions
+76 -2
View File
@@ -17,6 +17,7 @@ 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'
@@ -38,12 +39,19 @@ interface QuickReviewDialogProps {
template?: BookingTemplate | 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
templateId?: string,
dimensions?: Record<string, string>
) => Promise<string | null>
onChangeTemplate?: () => void
}
@@ -60,6 +68,7 @@ export default function QuickReviewDialog({
template,
templateId,
counterpartyLinePattern,
counterpartyDefaultDimensions,
onConfirm,
onChangeTemplate,
}: QuickReviewDialogProps) {
@@ -81,6 +90,14 @@ export default function QuickReviewDialog({
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
@@ -113,8 +130,28 @@ export default function QuickReviewDialog({
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
@@ -203,7 +240,20 @@ export default function QuickReviewDialog({
? accountOverride
: undefined
const journalEntryId = await onConfirm(transaction.id, category, resolvedVat, override, templateId)
// 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
@@ -464,6 +514,30 @@ export default function QuickReviewDialog({
</>
)}
{/* 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 && !isCounterpartyTemplate && (
<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 && (
+3 -1
View File
@@ -20,7 +20,9 @@ export type CategorizeHandler = (
vatTreatment?: VatTreatment,
accountOverride?: string,
templateId?: string,
inboxItemId?: string
inboxItemId?: string,
// Dimensions bag {sie_dim_no: code} for the business lines of the booking.
dimensions?: Record<string, string>
) => Promise<string | null>
export type MatchInvoiceHandler = (