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:
co-authored by
Claude Fable 5
parent
816b1769c8
commit
755e0f7e47
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user