diff --git a/app/api/invoices/[id]/convert/route.ts b/app/api/invoices/[id]/convert/route.ts index c9f01f12..d9e7f795 100644 --- a/app/api/invoices/[id]/convert/route.ts +++ b/app/api/invoices/[id]/convert/route.ts @@ -90,6 +90,8 @@ export async function POST( notes: proforma.notes, document_type: 'invoice', converted_from_id: id, + // Dimensions PR7: the converted invoice books with the proforma's bag. + default_dimensions: proforma.default_dimensions ?? {}, }) .select() .single() @@ -98,7 +100,7 @@ export async function POST( return NextResponse.json({ error: invoiceError.message }, { status: 500 }) } - const items = (proforma.items || []).map((item: { sort_order: number; line_type?: 'product' | 'text'; description: string; quantity: number; unit: string; unit_price: number; line_total: number }) => ({ + const items = (proforma.items || []).map((item: { sort_order: number; line_type?: 'product' | 'text'; description: string; quantity: number; unit: string; unit_price: number; line_total: number; dimensions?: Record }) => ({ invoice_id: invoice.id, sort_order: item.sort_order, line_type: item.line_type ?? 'product', @@ -107,6 +109,7 @@ export async function POST( unit: item.unit, unit_price: item.unit_price, line_total: item.line_total, + dimensions: item.dimensions ?? {}, })) if (items.length > 0) { diff --git a/app/api/invoices/route.ts b/app/api/invoices/route.ts index 373dc2f4..8065bc1c 100644 --- a/app/api/invoices/route.ts +++ b/app/api/invoices/route.ts @@ -315,6 +315,9 @@ async function createCreditNote( our_reference: originalInvoice.our_reference, notes: input.reason || `Krediterar faktura ${originalInvoice.invoice_number}`, credited_invoice_id: input.credited_invoice_id, + // Copy the original's dimension bag so the credit-note verifikat nets + // against the same dimension cells in reports (dimensions PR7). + default_dimensions: originalInvoice.default_dimensions ?? {}, status: 'sent', }) .select() @@ -328,7 +331,7 @@ async function createCreditNote( }) } - const creditNoteItems = (originalInvoice.items || []).map((item: { sort_order: number; line_type?: 'product' | 'text'; description: string; quantity: number; unit: string; unit_price: number; line_total: number; vat_rate?: number; vat_amount?: number; revenue_account?: string | null; article_id?: string | null; accrual_period_start?: string | null; accrual_period_end?: string | null; accrual_balance_account?: string | null }) => ({ + const creditNoteItems = (originalInvoice.items || []).map((item: { sort_order: number; line_type?: 'product' | 'text'; description: string; quantity: number; unit: string; unit_price: number; line_total: number; vat_rate?: number; vat_amount?: number; revenue_account?: string | null; article_id?: string | null; accrual_period_start?: string | null; accrual_period_end?: string | null; accrual_balance_account?: string | null; dimensions?: Record }) => ({ invoice_id: creditNote.id, sort_order: item.sort_order, line_type: item.line_type ?? 'product', @@ -353,6 +356,9 @@ async function createCreditNote( accrual_period_start: item.accrual_period_start ?? null, accrual_period_end: item.accrual_period_end ?? null, accrual_balance_account: item.accrual_balance_account ?? null, + // Same reasoning as revenue_account: the reversal must carry the exact + // per-item bag the original booked with (dimensions PR7). + dimensions: item.dimensions ?? {}, })) const { error: itemsError } = await supabase.from('invoice_items').insert(creditNoteItems) diff --git a/app/api/supplier-invoices/[id]/credit/route.ts b/app/api/supplier-invoices/[id]/credit/route.ts index 4a9bb854..3ccf463c 100644 --- a/app/api/supplier-invoices/[id]/credit/route.ts +++ b/app/api/supplier-invoices/[id]/credit/route.ts @@ -59,6 +59,9 @@ export const POST = withRouteContext( remaining_amount: 0, is_credit_note: true, credited_invoice_id: id, + // Copy the original's dimension bag so the reversal nets against the + // same dimension cells in reports (dimensions PR7). + default_dimensions: original.default_dimensions ?? {}, }) .select() .single() @@ -86,6 +89,9 @@ export const POST = withRouteContext( // Preserve the self-assessed RC rate so the credit-note verifikat // reverses fiktiv moms at the same rate the original was booked at. reverse_charge_rate: item.reverse_charge_rate, + // Dims copied for display parity; the journal reversal reads the + // ORIGINAL items below (dimensions PR7). + dimensions: item.dimensions ?? {}, })) await supabase.from('supplier_invoice_items').insert(creditItems) diff --git a/app/api/supplier-invoices/route.ts b/app/api/supplier-invoices/route.ts index ad39d80a..619df8f2 100644 --- a/app/api/supplier-invoices/route.ts +++ b/app/api/supplier-invoices/route.ts @@ -182,6 +182,9 @@ export const POST = withRouteContext( ? (item.accrual_balance_account ?? suggestBalanceAccount('expense', item.account_number)) : null, + // Dimensions PR7: per-item bag, merged over default_dimensions on the + // expense line at booking (supplier-invoice-entries.ts). + dimensions: item.dimensions ?? {}, } }) @@ -250,6 +253,8 @@ export const POST = withRouteContext( notes: body.notes || null, // Display-only öresavrundning override; null = off (no retroactive rounding). ore_rounding: body.ore_rounding ?? null, + // Dimensions PR7: invoice-level bag; generators apply it to every line. + default_dimensions: body.default_dimensions ?? {}, }) .select() .single() diff --git a/app/api/transactions/bulk-book/route.ts b/app/api/transactions/bulk-book/route.ts index cb1d9a9a..8296f854 100644 --- a/app/api/transactions/bulk-book/route.ts +++ b/app/api/transactions/bulk-book/route.ts @@ -4,6 +4,7 @@ import { validateBody } from '@/lib/api/validate' import { BulkBookSchema } from '@/lib/api/schemas' import { errorResponseFromCode } from '@/lib/errors/get-structured-error' import { applyTemplate } from '@/lib/bookkeeping/template-library' +import { mergeDimensionBags } from '@/lib/bookkeeping/dimension-resolver' import { eventBus } from '@/lib/events/bus' import { ensureInitialized } from '@/lib/init' import type { BookingTemplateLibraryLine, Transaction } from '@/types' @@ -34,6 +35,9 @@ interface ComputedLine { currency: string line_description?: string sort_order?: number + // Dimensions PR7: bag persisted by the RPC onto journal_entry_lines + // (with cost_center/project mirrors derived server-side). + dimensions?: Record } function round2(n: number): number { @@ -120,6 +124,8 @@ export const POST = withRouteContext( currency: l.currency, line_description: l.line_description, sort_order: i, + // Dimensions PR7: per-line bag wins over the header default. + dimensions: mergeDimensionBags(body.default_dimensions, l.dimensions), })), } } else if (body.template_id && body.mode && body.entry_description) { @@ -199,6 +205,8 @@ export const POST = withRouteContext( currency, line_description: formLine.line_description || undefined, sort_order: sortOrder++, + // Dimensions PR7: header default applies to all template lines. + dimensions: body.default_dimensions, }) } } else { @@ -221,6 +229,8 @@ export const POST = withRouteContext( ? `${formLine.line_description ?? ''} – ${txTag}`.trim() : formLine.line_description || undefined, sort_order: sortOrder++, + // Dimensions PR7: header default applies to all template lines. + dimensions: body.default_dimensions, }) } } diff --git a/components/invoices/InvoiceEditor.tsx b/components/invoices/InvoiceEditor.tsx index 08765c3a..20c50ce7 100644 --- a/components/invoices/InvoiceEditor.tsx +++ b/components/invoices/InvoiceEditor.tsx @@ -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 } +// True when a dimensions bag ({sie_dim_no: code}) carries at least one value. +function hasDimensionValues(dims: Record | 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 { + 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([]) const [accountOverrideRows, setAccountOverrideRows] = useState>(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>( + initial?.default_dimensions ?? {}, + ) + const [dimensionOverrideRows, setDimensionOverrideRows] = useState>(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 | 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 } = { ...(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 } = { ...(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 } = { ...(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 )} + {dimensionsEnabled && watchItems[index]?.line_type !== 'text' && ( + <> + + toggleItemDimensions(index)} className="py-2"> + + {(dimensionOverrideRows.has(index) || hasDimensionValues(watchItems[index]?.dimensions)) + ? t('row_menu_remove_dimensions') + : t('row_menu_set_dimensions')} + + + )} )} + {/* 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)) && ( +
+
+ updateItemDimension(index, dimNo, code)} + inputClassName="h-8" + /> +
+ {hasDimensionValues(defaultDims) && ( +

+ {t('row_dimensions_inherit_hint', { dims: compactDims(defaultDims) })} +

+ )} +
+ )} + {/* Mobile summary row */}
{t('row_label', { index: index + 1 })} @@ -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 )} />
+ + {/* 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 && ( + <> + +
+ +

+ {t('dimensions_default_hint')} +

+
+ + )} )} diff --git a/components/invoices/PaymentBookingDialog.tsx b/components/invoices/PaymentBookingDialog.tsx index 03af8595..006223b3 100644 --- a/components/invoices/PaymentBookingDialog.tsx +++ b/components/invoices/PaymentBookingDialog.tsx @@ -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`, { diff --git a/components/invoices/SendInvoiceDialog.tsx b/components/invoices/SendInvoiceDialog.tsx index ae706c54..31bde1e9 100644 --- a/components/invoices/SendInvoiceDialog.tsx +++ b/components/invoices/SendInvoiceDialog.tsx @@ -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, }) diff --git a/components/supplier-invoices/NewSupplierInvoiceForm.tsx b/components/supplier-invoices/NewSupplierInvoiceForm.tsx index ec54c8c4..67ff2375 100644 --- a/components/supplier-invoices/NewSupplierInvoiceForm.tsx +++ b/components/supplier-invoices/NewSupplierInvoiceForm.tsx @@ -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 } // 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(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>({}) const [periods, setPeriods] = useState([]) 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 ( +
+
+ updateItemDimension(index, dimNo, code)} + inputClassName="h-8" + /> +
+ {defaultDimsSummary && ( +

+ {t('row_dimensions_inherit_hint', { dims: defaultDimsSummary })} +

+ )} +
+ ) + } + 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({ )} + {/* 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 && ( +
+
+ +
+

{t('dimensions_default_hint')}

+
+ )} + {showNoPeriodWarning && (
{fields.map((field, index) => ( - +
+ {dimensionsEnabled && ( + + )} {canUseAccrual && ( + )} {canUseAccrual && (
))}
diff --git a/components/transactions/BulkBookDialog.tsx b/components/transactions/BulkBookDialog.tsx index 6264f82f..ec09e58c 100644 --- a/components/transactions/BulkBookDialog.tsx +++ b/components/transactions/BulkBookDialog.tsx @@ -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([]) 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>({}) // 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>) { 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({ )} + {/* Header default dims (kostnadsställe/projekt) — one bag applied to + the whole verifikat, shared by both tabs. */} + {dimensionsEnabled && tabReady && ( +
+ +

{t('dimensions_hint')}

+
+ )} + {/* 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 && (
- +
+ + {dimensionsEnabled && dimsSummary && ( + + {dimsSummary} + + )} +
diff --git a/components/transactions/QuickReviewDialog.tsx b/components/transactions/QuickReviewDialog.tsx index 079a01f9..83159db4 100644 --- a/components/transactions/QuickReviewDialog.tsx +++ b/components/transactions/QuickReviewDialog.tsx @@ -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 = {} + 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({ {template ? template.name_sv : categoryLabel} + {patternDimsLabel && ( + + {patternDimsLabel} + + )} {onChangeTemplate && !isCounterpartyTemplate && (