diff --git a/app/globals.css b/app/globals.css index cc1dd1d4..64a510b8 100644 --- a/app/globals.css +++ b/app/globals.css @@ -616,6 +616,18 @@ summary, .stagger-enter > *:nth-child(9) { animation-delay: 320ms; } .stagger-enter > *:nth-child(n+10) { animation-delay: 360ms; } +/* Quiet settle for a freshly committed row (invoice editor line entry): a + brief beige wash that fades to transparent, confirming where the row + landed without moving anything. The global reduced-motion rule above + collapses it to an instant no-op. */ +@keyframes row-settle { + from { background-color: hsl(var(--secondary) / 0.7); } + to { background-color: transparent; } +} +.row-settle { + animation: row-settle 0.6s ease-out both; +} + /* Row exit (booking/ignore/delete in dry-table lists). The page keeps the row rendered for a 350ms window (see finishBooking on /transactions) and marks it .row-exit; the row fades fast, then the space closes by diff --git a/components/agent/AgentTrigger.tsx b/components/agent/AgentTrigger.tsx index e8acf569..fb483c07 100644 --- a/components/agent/AgentTrigger.tsx +++ b/components/agent/AgentTrigger.tsx @@ -218,8 +218,11 @@ export default function AgentTrigger({ hidden = false }: { hidden?: boolean }) { // Mobile: sit above the bottom nav (h-16 = 64px) AND the iOS home // indicator (env(safe-area-inset-bottom)). Still needed after the FAB // went desktop-only: the collapsed handle above renders on mobile too. - // Desktop: standard 20px lift, no mobile nav to worry about. - className={`fixed right-4 z-30 ${visibilityClass} h-12 max-w-[calc(100vw-2rem)] items-stretch rounded-full bg-foreground text-background shadow-lg bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)] md:bottom-4`} + // Desktop: standard 20px lift, no mobile nav to worry about, except when + // the page declares a bottom action bar (body[data-page-bottom-bar], + // set by e.g. the standalone invoice editor): lift above it so the FAB + // never covers the bar's primary button. + className={`fixed right-4 z-30 ${visibilityClass} h-12 max-w-[calc(100vw-2rem)] items-stretch rounded-full bg-foreground text-background shadow-lg bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)] md:bottom-4 md:[body[data-page-bottom-bar]_&]:bottom-20`} > - )} -
+
+
+
+ {!bare && ( + + )} {titleText} {numberPreview && !isSelfBilled && ( - - ({numberPreview}) + + {numberPreview} )} - {!bare &&

{subtitleText}

}
+ + {isCopyMode && copyInitial && ( +
+ +

+ {t('copy_notice', { number: copyInitial.source_invoice_number })} +

+
+ )} + + {hasBankDetails === false && !isSelfBilled && ( +
+ +

{t('bank_missing_warning')}

+ +
+ )}
- {isCopyMode && copyInitial && ( -
- -

- {t('copy_notice', { number: copyInitial.source_invoice_number })} -

-
- )} - - {!isEditMode && !isCopyMode && ( - setMode(v as 'invoice' | 'self_billed')}> - - {t('mode_invoice')} - {t('mode_self_billed')} - - - )} - - {hasBankDetails === false && !isSelfBilled && ( -
- -

{t('bank_missing_warning')}

- -
- )} - -
-
- {/* Main content */} -
- {/* Customer selection */} - - - {isSelfBilled ? <>{ts('customer_label')} : <>{t('customer_card_title')}} - {isSelfBilled && {ts('issuer_card_description')}} - - - ( - - )} - /> - - {errors.customer_id && ( -

{errors.customer_id.message}

+ +
+ {/* ===== Kund ===== */} +
+ + {isSelfBilled ? ts('customer_label') : t('customer_card_title')} + + {selectedCustomer && ( + + ✓ {t('customer_done')} + )} + + {isSelfBilled && ( +

{ts('issuer_card_description')}

+ )} + ( + + )} + /> + {selectedCustomer && ( +
+ {[ + [selectedCustomer.address_line1, selectedCustomer.postal_code, selectedCustomer.city] + .filter(Boolean) + .join(', '), + selectedCustomer.org_number ? `Org.nr ${selectedCustomer.org_number}` : '', + selectedCustomer.email ?? '', + ] + .filter(Boolean) + .map((line) => ( +
{line}
+ ))} +
+ )} + {errors.customer_id && ( +

{errors.customer_id.message}

+ )} + - {isSelfBilled && ( -
-
- - - {errors.external_invoice_number && ( -

{errors.external_invoice_number.message}

- )} -
-
- - -
+ {isSelfBilled && ( +
+
+ + + {errors.external_invoice_number && ( +

{errors.external_invoice_number.message}

+ )}
+
+ + +
+
+ )} +
+ + {/* ===== Fakturarader ===== */} +
+ + {t('items_card_title')} + + {productRowCount > 0 && ( + + {t('rows_count', { count: productRowCount })} + )} + +
+
+
+ {/* Header row: offset by the drag-grip gutter (w-8). */} +
+
+
{t('description_label')}
+
{t('quantity_label')}
+
{t('unit_price_label')}
+ {vatRegistered &&
{t('vat_label')}
} +
{t('amount_label')}
+
+
+
- - - - {/* Invoice items */} - - - {t('items_card_title')} - - -
- - {fields.map((field, index) => { - const isTextRow = watchItems[index]?.line_type === 'text' - const lineTotal = (watchItems[index]?.quantity || 0) * (watchItems[index]?.unit_price || 0) - const lineVat = vatRegistered && !isTextRow - ? Math.round(lineTotal * (watchItems[index]?.vat_rate ?? 25) / 100 * 100) / 100 - : 0 - // Free-text / blank row: just a description field (may be left - // empty for a spacer) and a delete button. - if (isTextRow) { - return ( - -
-
-
- - -
- -
-
-
- ) - } - // Per-row action button. On real invoices it's a ⋮ menu that - // holds both the ROT/RUT skattereduktion choice and delete; - // proformas/delivery notes have no deduction model, so they - // keep a plain trash button (a one-item menu would be noise). - const renderRowActions = (triggerClassName: string) => - isInvoiceDoc ? ( - - - - - - {t('deduction_menu_label')} - { - const next = v === 'none' ? null : (v as 'rot' | 'rut') - setValue(`items.${index}.deduction_type`, next, { shouldDirty: true }) - // The arbetstyp lists are per kind: a ROT code - // must not survive a switch to RUT (the select - // would show it as empty while the payload kept - // the wrong code). - if ( - next !== null && - deductionTypeForWorkType(watchItems[index]?.work_type) !== next - ) { - setValue(`items.${index}.work_type`, null) - } - if (next === null) { - setValue(`items.${index}.work_type`, null) - setValue(`items.${index}.labor_hours`, null) - setValue(`items.${index}.housing_designation`, null) - setValue(`items.${index}.apartment_number`, null) - } else if (watchItems[index]?.accrual_balance_account != null) { - // ROT/RUT och periodisering kombineras aldrig - // på samma rad: avdraget vinner. - setValue(`items.${index}.accrual_period_start`, null) - setValue(`items.${index}.accrual_period_end`, null) - setValue(`items.${index}.accrual_balance_account`, null) - } - }} - > - {t('deduction_none')} - {t('deduction_rot')} - {t('deduction_rut')} - - {canUseAccrual && !watchItems[index]?.deduction_type && ( - <> - - toggleAccrual(index)} className="py-2"> - - {watchItems[index]?.accrual_balance_account != null - ? ta('row_menu_remove') - : ta('row_menu_add')} - - - )} - {watchItems[index]?.line_type !== 'text' && ( - <> - - toggleAccountOverride(index)} className="py-2"> - - {(accountOverrideRows.has(index) || watchItems[index]?.revenue_account) - ? t('row_menu_remove_account') - : t('row_menu_set_account')} - - - )} - {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')} - - - )} - - + {fields.map((field, index) => { + const item = watchItems[index] + const isTextRow = item?.line_type === 'text' + const rowDescription = item?.description?.trim() + const removeLabel = t('remove_row_aria_named', { + description: rowDescription || t('row_label', { index: index + 1 }), + }) + if (isTextRow) { + return ( + remove(index)} > - - {t('remove_row')} - - - - ) : ( - - ) - return ( - -
- {/* Article picker (artikelregister). Optional: leave on - "Egen rad" to type a free-text line. Selecting an - article pre-fills description, unit, price, VAT and any - revenue-account override. */} -
-
- - ( - applyArticle(index, v)} - freeTextLabel={t('article_free_text')} - placeholder={t('article_placeholder')} - emptyLabel={t('article_search_empty')} - ariaLabel={t('article_label')} - /> - )} - /> -
- {canWrite && ( - - )} -
- - {/* Description + mobile delete button */} -
-
- - - {errors.items?.[index]?.description && ( -

- {errors.items[index].description?.message} -

- )} -
- {renderRowActions('shrink-0 min-h-[44px] min-w-[44px] -mr-2 -mt-1 md:hidden')} -
- - {/* Antal, Enhet, à-pris */} -
-
- - - {errors.items?.[index]?.quantity && ( -

- {errors.items[index].quantity?.message} -

- )} -
-
- - ( - - )} - /> - {errors.items?.[index]?.unit && ( -

- {errors.items[index].unit?.message} -

- )} -
-
- - -
-
- - {/* Moms: hidden entirely when the company is not - momsregistrerad (no VAT may be charged). */} - {vatRegistered && ( -
- - ( - - )} - /> -
- )} - - {/* Desktop row actions (⋮ menu or trash). An invisible - label spacer mirrors the field columns (same Label + - space-y-2), so the button sits on the input row, not - high against the labels, nor low at the row bottom. */} -
- -
- {renderRowActions('')} -
-
- - {/* ROT/RUT-avdrag strip: only when a deduction is active - on this row (chosen via the ⋮ menu). A leading tag shows - which reduction applies; the work-type + hours are - required for the Skatteverket claim. Rows with no - deduction render nothing here and stay clean. */} - {isInvoiceDoc && watchItems[index]?.deduction_type && ( -
-
- - {watchItems[index]?.deduction_type === 'rot' ? 'ROT 30%' : 'RUT 50%'} - - { - const opts = - watchItems[index]?.deduction_type === 'rot' - ? ROT_WORK_TYPES - : RUT_WORK_TYPES - return ( - + + - ) : null - })()} -
- {(errors.items?.[index]?.work_type || errors.items?.[index]?.labor_hours) && ( -

- {errors.items?.[index]?.work_type?.message ?? errors.items?.[index]?.labor_hours?.message} -

- )} - {/* Labor-only disclosure (Skatteverket fakturamodellen). - 30%/50% applies to the full line total: the seller - must ensure the line is 100% labor; material has - to be invoiced separately. */} -
- -

{t('deduction_labor_only_warning')}

-
-
- )} +
+
+ + ) + } - {/* Periodisering (förutbetald intäkt): activated via the - row's ⋮ menu. Intäkten krediteras 29xx vid bokning och - löses upp månadsvis över perioden; momsen påverkas inte. */} - {canUseAccrual && watchItems[index]?.accrual_balance_account != null && ( -
- { - setValue(`items.${index}.accrual_period_start`, next.start, { shouldDirty: true }) - setValue(`items.${index}.accrual_period_end`, next.end, { shouldDirty: true }) - setValue(`items.${index}.accrual_balance_account`, next.balanceAccount, { shouldDirty: true }) - }} - onRemove={() => toggleAccrual(index)} - /> - {errors.items?.[index]?.accrual_period_end && ( -

- {errors.items[index].accrual_period_end?.message} -

- )} -
- )} + const lineTotal = (item?.quantity || 0) * (item?.unit_price || 0) + const rowErrors = errors.items?.[index] + const rowErrorMsg = + rowErrors?.description?.message ?? + rowErrors?.quantity?.message ?? + rowErrors?.unit?.message ?? + (rowErrors?.unit_price ? t('validation_price_invalid') : undefined) + const articleStripOpen = articlePickerRows.has(index) + const accountStripOpen = + isInvoiceDoc && (accountOverrideRows.has(index) || Boolean(item?.revenue_account)) + const dimensionStripOpen = + dimensionsEnabled && + isInvoiceDoc && + (dimensionOverrideRows.has(index) || hasDimensionValues(item?.dimensions)) + const accrualStripOpen = canUseAccrual && item?.accrual_balance_account != null + const showSaveAsArticle = canWrite && !item?.article_id && Boolean(rowDescription) - {/* Optional posting-account override (engångsartikel). When - unset the engine derives the revenue account from the VAT - rate; reverse-charge/export lines ignore the override. */} - {isInvoiceDoc && watchItems[index]?.line_type !== 'text' && - (accountOverrideRows.has(index) || watchItems[index]?.revenue_account) && ( -
-
-
- - ( - field.onChange(v || null)} - /> + return ( + +
+
+ - {errors.items?.[index]?.revenue_account && ( -

- {errors.items[index].revenue_account?.message} -

+
+ + ( + + )} + /> +
+ + {vatRegistered && ( + ( + + )} + /> )} +
+ {formatCurrency(lineTotal, watchCurrency)} +
+ + + + + + + toggleArticlePicker(index)} className="py-2"> + + {t('row_menu_pick_article')} + + {isInvoiceDoc && ( + <> + + {t('deduction_menu_label')} + { + const next = v === 'none' ? null : (v as 'rot' | 'rut') + setValue(`items.${index}.deduction_type`, next, { shouldDirty: true }) + // The arbetstyp lists are per kind: a ROT code + // must not survive a switch to RUT (the select + // would show it as empty while the payload kept + // the wrong code). + if ( + next !== null && + deductionTypeForWorkType(item?.work_type) !== next + ) { + setValue(`items.${index}.work_type`, null) + } + if (next === null) { + setValue(`items.${index}.work_type`, null) + setValue(`items.${index}.labor_hours`, null) + setValue(`items.${index}.housing_designation`, null) + setValue(`items.${index}.apartment_number`, null) + } else if (item?.accrual_balance_account != null) { + // ROT/RUT och periodisering kombineras aldrig + // på samma rad: avdraget vinner. + setValue(`items.${index}.accrual_period_start`, null) + setValue(`items.${index}.accrual_period_end`, null) + setValue(`items.${index}.accrual_balance_account`, null) + } + }} + > + {t('deduction_none')} + {t('deduction_rot')} + {t('deduction_rut')} + + + )} + {canUseAccrual && !item?.deduction_type && ( + <> + + toggleAccrual(index)} className="py-2"> + + {item?.accrual_balance_account != null + ? ta('row_menu_remove') + : ta('row_menu_add')} + + + )} + {isInvoiceDoc && ( + <> + + toggleAccountOverride(index)} className="py-2"> + + {(accountOverrideRows.has(index) || item?.revenue_account) + ? t('row_menu_remove_account') + : t('row_menu_set_account')} + + + )} + {dimensionsEnabled && isInvoiceDoc && ( + <> + + toggleItemDimensions(index)} className="py-2"> + + {(dimensionOverrideRows.has(index) || hasDimensionValues(item?.dimensions)) + ? t('row_menu_remove_dimensions') + : t('row_menu_set_dimensions')} + + + )} + + + +
+ + {rowErrorMsg &&

{rowErrorMsg}

} + + {showSaveAsArticle && ( +
+ +
+ )} + + {/* Article re-link strip (row ⋮ menu): article + selection stays reachable on every committed + row; "Egen rad" detaches the link. */} + {articleStripOpen && ( +
+ + ( + { + applyArticle(index, v) + toggleArticlePicker(index) + }} + freeTextLabel={t('article_free_text')} + placeholder={t('article_placeholder')} + emptyLabel={t('article_search_empty')} + ariaLabel={t('article_label')} + /> + )} + /> +
+ )} + + {/* ROT/RUT-avdrag strip: only when a deduction is + active on this row (chosen via the ⋮ menu). */} + {isInvoiceDoc && item?.deduction_type && ( +
+
+ + {item?.deduction_type === 'rot' ? 'ROT 30%' : 'RUT 50%'} + + { + const opts = + item?.deduction_type === 'rot' ? ROT_WORK_TYPES : RUT_WORK_TYPES + return ( + + ) + }} + /> + { + if (v === '' || v == null) return null + const n = Number(v) + return Number.isFinite(n) ? n : null + }, + })} + /> + {(() => { + const amt = computeDeduction({ + unit_price: item?.unit_price || 0, + quantity: item?.quantity || 0, + deduction_type: item?.deduction_type, + vat_rate: vatRegistered + ? (item?.vat_rate ?? (vatRules?.rate || 25)) + : 0, + }) + return amt > 0 ? ( + + −{formatCurrency(amt, watchCurrency)} + + ) : null + })()} +
+ {(errors.items?.[index]?.work_type || errors.items?.[index]?.labor_hours) && ( +

+ {errors.items?.[index]?.work_type?.message ?? errors.items?.[index]?.labor_hours?.message} +

+ )} + {/* Labor-only disclosure (Skatteverket + fakturamodellen), muted: the page's single + ochre line is the next-step line. */} +
+ +

{t('deduction_labor_only_warning')}

+
+
+ )} + + {/* Periodisering (förutbetald intäkt): activated via + the row's ⋮ menu. */} + {accrualStripOpen && ( +
+ { + setValue(`items.${index}.accrual_period_start`, next.start, { shouldDirty: true }) + setValue(`items.${index}.accrual_period_end`, next.end, { shouldDirty: true }) + setValue(`items.${index}.accrual_balance_account`, next.balanceAccount, { shouldDirty: true }) + }} + onRemove={() => toggleAccrual(index)} + /> + {errors.items?.[index]?.accrual_period_end && ( +

+ {errors.items[index].accrual_period_end?.message} +

+ )} +
+ )} + + {/* Optional posting-account override (engångsartikel). */} + {accountStripOpen && ( +
+
+ + ( + accountField.onChange(v || null)} + /> + )} + /> + {errors.items?.[index]?.revenue_account && ( +

+ {errors.items[index].revenue_account?.message} +

+ )} +
+

{t('revenue_account_hint')}

+
+ )} + + {/* Per-item dimensions override (row ⋮ menu). */} + {dimensionStripOpen && ( +
+
+ updateItemDimension(index, dimNo, code)} + inputClassName="h-8" + /> +
+ {hasDimensionValues(defaultDims) && ( +

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

+ )} +
+ )}
-

{t('revenue_account_hint')}

+
+ ) + })} + + + {/* Unified entry row: never part of the field array until + committed (sort_order and the index-keyed override sets + stay stable). Ghost cells preview the append defaults. */} +
+ - {/* 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) && ( + {/* Suggestion popover: anchored below the whole table wrap so it + never clips inside the horizontal scroll container. */} + {entryOpen && ( +
+ {/* The hint is a sibling of the listbox (listbox children + must be options); the input references it via + aria-describedby. */} +
+ {entryMatches.map((a, i) => ( + + ))} +
+
+ {entryMatches.length > 0 ? t('entry_hint_matches') : t('entry_hint_free')} +
+
+ )} +
+ + {itemsRootMsg &&

{itemsRootMsg}

} + + {!isSelfBilled && ( + + )} + + {/* Taxed-where-performed disclosure, muted: the page's single + ochre line is the next-step line (design decision d). */} + {showTaxedWherePerformedHint && ( +

+ {t('vat_taxed_where_performed_hint')} +

+ )} +
+ + {/* ===== ROT/RUT claim info ===== */} + {isInvoiceDoc && hasAnyDeduction && ( +
+ {t('deduction_card_title')} +

{t('deduction_card_description')}

+
+
+ + +

+ {/* Stored pn exists only as ciphertext: an empty field on + edit keeps it server-side instead of failing validation. + Otherwise, a kundkort with a personnummer covers an + empty field via the server-side fallback. */} + {initial?.deduction_personnummer_last4 + ? t('deduction_personnummer_kept_hint', { last4: initial.deduction_personnummer_last4 }) + : customerHasPersonalNumber + ? t('deduction_personnummer_customer_hint') + : t('deduction_personnummer_hint')} +

+
+ {hasAnyRotLine && ( +
+ + +

{t('deduction_housing_hint')}

+
+ )} + {capWarnings.length > 0 && ( +
+ {capWarnings.map((w) => ( +

{w}

+ ))} +
+ )} +
+
+ )} + + {/* ===== Förval ===== */} +
+ {t('section_forval')} +
+ {chipTexts.join(' · ')} + + +
+
+
+
+ {!isSelfBilled && ( +
+ + ( + + )} + /> +
+ )} + +
+ + ( + + )} + /> +
+ +
+ +
+ + {errors.invoice_date && ( +

{errors.invoice_date.message}

+ )} +
+
+ +
+ +
+ + {errors.due_date && ( +

{errors.due_date.message}

+ )} +
+
+ + {isSelfBilled && ( +
+ +
+ + {errors.received_date && ( +

{errors.received_date.message}

+ )} +
+
+ )} + + {watchDocumentType === 'invoice' && !isSelfBilled && ( +
+ + +
+ )} + + {!isSelfBilled && ( + <> +
+ +
+ ( + + )} + /> +
+
+
+ +
+ ( + + )} + /> +
+
+ + {/* Online payment link: manual paste or the Stripe auto + toggle. Only real invoices; hidden unless the company + opted in, except when the draft already carries a link. */} + {watchDocumentType === 'invoice' && (paymentLinksEnabled || hasExistingPaymentLink) && ( +
+ + + {errors.payment_link_url ? ( +

{errors.payment_link_url.message}

+ ) : (

- {t('row_dimensions_inherit_hint', { dims: compactDims(defaultDims) })} + {stripeConnected ? t('payment_link_hint_auto') : t('payment_link_hint')}

)} + {stripeConnected && !watchPaymentLinkUrl?.trim() && ( +
+ setValue('payment_link_auto', v, { shouldDirty: true })} + /> + +
+ )}
)} - {/* Mobile summary row */} -
- {t('row_label', { index: index + 1 })} - {formatCurrency(lineTotal + lineVat, watchCurrency)} -
-
- - ) - })} - - -
- - {/* Free-text / blank row: explanatory text under an item, or - an empty spacer. Carries no amounts and never books. Not - offered for a received självfaktura: that is a faithful - revenue-only transcription, and the self-billed endpoint - (SelfBillingInvoiceItemSchema) has no line_type and rejects - zero-amount rows. */} - {!isSelfBilled && ( - - )} -
- - {/* Attention is one ochre sentence, not a banner (UI convention - 6). Silent for the normal 0% case; renders only when a - Swedish rate is actually picked for a customer whose default - is 0%, where it is lawful for taxed-where-performed supplies - only. */} - {showTaxedWherePerformedHint && ( - {t('vat_taxed_where_performed_hint')} - )} -
- - - - {/* ROT/RUT-avdrag claim info. Surfaces only when any item has - a deduction_type set: keeps the form quiet for the 90%+ - of users who don't sell ROT/RUT-eligible services. */} - {isInvoiceDoc && hasAnyDeduction && ( - - - {t('deduction_card_title')} - {t('deduction_card_description')} - - -
- - -

- {/* Stored pn exists only as ciphertext: an empty field on - edit keeps it server-side instead of failing validation. - Otherwise, a kundkort with a personnummer covers an - empty field via the server-side fallback. */} - {initial?.deduction_personnummer_last4 - ? t('deduction_personnummer_kept_hint', { last4: initial.deduction_personnummer_last4 }) - : customerHasPersonalNumber - ? t('deduction_personnummer_customer_hint') - : t('deduction_personnummer_hint')} -

-
- {hasAnyRotLine && ( -
- - -

- {t('deduction_housing_hint')} -

-
- )} - {capWarnings.length > 0 && ( -
- {capWarnings.map((w) => ( -

{w}

- ))} -
- )} -
-
- )} - - {/* Notes */} - - - {t('notes_card_title')} - - -