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:
co-authored by
Claude Fable 5
parent
951b33363b
commit
222e581476
@@ -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 && (
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
Reference in New Issue
Block a user