feat: custom inbound mail domains, rot/rut payout file, invoice email texts, security hardening (#878)
* fix(security): guard MCP test keys, RLS role gate + voucher RPC guards, /api MFA gate, deps - MCP: force dry-run / block writes for test-mode API keys in tools/call (extensions/general/mcp-server) - DB: current_user_can_write role gate on write policies (40 tables) + tenant guards, SET search_path, REVOKE anon on commit_journal_entry / next_voucher_number / detect_voucher_gaps (migration 20260702093000) - Middleware: MFA (AAL2) gate on cookie-authenticated /api routes via apiPathSkipsMfaGate - Deps: npm audit fix clears mailparser/linkify-it/nodemailer/svix/uuid highs; xlsx -> SheetJS 0.20.3 Adds unit + pg-real tests. Does not touch in-progress ROT/RUT or invoice-email-texts work. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(invoices): rot/rut begäran om utbetalning — HUS XML (V6), payout tracking + settlement, MCP tool Generates Skatteverkets begäran-om-utbetalning file (schema V6) from paid ROT/RUT invoices — no submission API exists, the file is uploaded manually at skatteverket.se. Headless by design for now: API routes + MCP tool (gnubok_generate_rot_rut_file), no UI surfaces. - lib/invoices/rot-rut-file.ts: pure XML generator with deterministic per-invoice blockers (hours, work type, personnummer, property info, mixed rot+rut, XSD limits) + 31 January deadline warnings - rot_rut_payout_requests(+items) tables: one active begäran per invoice (DB triggers incl. reactivation guard), RLS, audit, pg-real tests - Settlement: POST /settle books debit 1930 / credit 1513 via the engine (source_type rot_rut_payout); partial payouts → partially_paid - Work-type lists corrected against Begaran.xsd: IT-tjänster is rut-only, snöskottning/tillsyn/tvätt added (schablontjänster utfört-only) - Fix: invoice-level fastighetsbeteckning was validated but never persisted — now stamped onto rot lines in build-invoice-write; API accepts bostadsrätt pair (lägenhetsnr + BRF orgnr, editor UI deferred) - invoice_items.brf_org_number migration + MCP scope invoices:write Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(invoices): per-company editable invoice email texts Add an "E-posttexter" section under Settings -> Fakturering where the subject, greeting, body and sign-off of the standard invoice email can be customized per company in Swedish and English. Fields pre-fill with the standard texts and only diffs from the standard are stored (company_settings.invoice_email_texts JSONB), so future improvements to the stock wording still reach companies that have not customized. Each field has a reset-to-standard button; cleared fields snap back. Texts support a fixed placeholder set (invoice number, customer name, first name, company, due date, amount) substituted at send time in a single pass; unknown placeholders stay literal. Custom texts are HTML-escaped after substitution, newlines become <br> in the HTML variant, and subject lines are flattened to a single header line. Overrides apply to standard invoices only - credit notes, proforma and delivery notes keep the stock texts. All send paths (UI, v1 API, MCP approval, recurring) pick the texts up via the existing settings row. The Zod schema half of this change (InvoiceEmailTextsSchema in lib/api/schemas.ts) was inadvertently included in 8291f745. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(documents): accept PDFs with preamble before %PDF- header, surface content rejections as 400 detectFileMagic required the %PDF- signature at byte 0 (BOM aside), rejecting genuine PDFs that carry a leading newline or junk bytes — files every ISO 32000 reader opens fine. Now scan the first 1024 bytes for the signature, matching real-reader behavior. Image types stay strict at offset 0 to keep the anti-placeholder defense tight. Magic-byte rejections were also mislabeled as DOC_UPLOAD_STORAGE_FAILED (500 'Filen kunde inte sparas'), blaming storage for a client-side file problem. Both upload routes now map them to a new DOC_UPLOAD_INVALID_CONTENT (400) with an accurate message. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(bookkeeping): full keyboard flow for manual journal entry Enter now drives the whole verifikat flow: verifikationstext drops into the first row missing an account, konto commits advance to debet, Enter on an empty debet hops to kredit, and an entered amount jumps to the next row. Once the voucher balances, Enter opens the review (unchanged gate) and the auto-focused confirm posts it — including through the no-underlag warning dialog. Escape in the inline review goes back to the form. Also fixes an Enter footgun in AccountCombobox: a bare Enter on a freshly focused field no longer selects the first account in the list — selection now requires typing or arrow navigation; otherwise Enter re-commits the current value or bubbles to the form-level handler. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: add custom inbound domains management for companies - Implemented functionality to allow companies to claim and manage their own inbound email domains via Resend's API. - Created a new table `company_inbound_domains` to store domain information, including status and DNS records. - Added necessary RLS policies to restrict access based on user roles (owner/admin). - Developed functions for domain normalization, validation, claiming, verification, and removal. - Implemented webhook handling for domain status updates from Resend. - Added comprehensive tests for RLS, constraints, and triggers related to the new domain management feature. * fix: address PR #878 review findings and CI failures - migrations: drop the ai_usage_tracking policy block from the role-gate migration — the table was removed by 20260504120000_remove_ai_subsystem and only lingers on staging as drift; a from-scratch chain (pg-real, Supabase preview) failed on it - invoice-inbox: never flip a custom domain to verified off a domain.updated webhook alone — confirm the receiving capability with Resend first (fail-closed); normalize both sides of the orphan-adoption domain match - rot/rut: block files where begärt belopp exceeds what the buyer paid (DEDUCTION_EXCEEDS_PAYMENT); tighten brf_org_number validation to real orgnr shapes; parameterize the settlement bank account (19xx, default 1930) - rot/rut routes: log acting user on financial mutations, stop swallowing item mirror errors, narrow response projections (no customer ids through the invoice join); document the deliberate inline-XML decision - documents: stop echoing raw storage-layer error messages to clients Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: round-2 CI + compliance findings on PR #878 - migrations: the role-gate migration targeted automation_webhooks, which 20260515170000_webhooks_v2 renamed to webhooks on the canonical chain (staging kept the old name — drift); gate public.webhooks instead, dropping legacy schema-sync policy names defensively. Restore the 20260623130000 owner fallback in next_voucher_number that the stale copied-verbatim body silently reverted (caught by engine.pg locally). Full migration chain verified from scratch against supabase/postgres:15. - mcp: bump the tools/list payload ceiling 44K -> 45K — main's #877 qualified-identifier schemas plus this branch's rot/rut tool crossed the ceiling only in combination; documented in the test's history log. - rot/rut: refuse partial settlement before Skatteverkets beslut is recorded (would bypass the PATCH lifecycle and strand the request); block zero-kronor ärenden (ZERO_DEDUCTION); require sekelsiffra 16 on 12-digit brf orgnr in both schema validation and normalizeBrfOrgNr Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: rename branch migrations off main's colliding versions After the merge with main, two versions were shared by two files each (20260702100000: rot_rut_payout_requests vs company_settings_dimensions_ enabled; 20260702130000: invoice_email_texts vs pending_operations_add_ create_dimension_value). psql-based CI applies by filename and doesn't care, but Supabase branching records migrations by version (PK) — the second file with the same version breaks the preview with a schema_migrations_pkey duplicate. Neither branch migration is version- recorded on staging or prod, so renaming to fresh 20260703 versions is safe; nothing between the old and new positions depends on these objects. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(security): scope the /api MFA-gate bypass to real Bearer-auth surfaces Any Authorization header — attacker-controlled — used to skip the AAL2 gate for every /api route, so a stolen-password AAL1 cookie session could reach cookie-authenticated routes (which ignore the header) by attaching `Authorization: x`. The skip is now scoped to the surfaces whose auth contract IS the header (/api/v1 API keys, the MCP endpoint's OAuth tokens); pure Bearer callers elsewhere (cron secret, signed webhooks) carry no cookie session and were never touched by the gate, which only fires for cookie users. Superagent P2 on PR #878. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: normalize path separators in dimension statutory guard scan The route scan compared walked file paths against a POSIX-path allowlist, so the suite failed on Windows (backslash separators) while passing on Linux CI. Normalize the scanned paths to forward slashes. 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
678f2ccffd
commit
237b77a366
@@ -49,6 +49,11 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const internalInputRef = useRef<HTMLInputElement>(null)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
// Whether the user has typed or arrow-navigated since the field was focused.
|
||||
// Enter only selects the highlighted item after an actual interaction — a
|
||||
// bare Enter on a freshly-focused field must not grab the first account in
|
||||
// the list (it either re-commits the current value or bubbles to the form).
|
||||
const hasInteractedRef = useRef(false)
|
||||
|
||||
// Attach the internal ref (used for focus bookkeeping) and forward the element
|
||||
// to any external callback ref the parent passed.
|
||||
@@ -140,8 +145,14 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (!isOpen) {
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
hasInteractedRef.current = true
|
||||
setIsOpen(true)
|
||||
e.preventDefault()
|
||||
} else if (e.key === 'Enter' && /^\d{4}$/.test(search)) {
|
||||
// Dropdown closed but a full account number sits in the field — treat
|
||||
// Enter as a re-commit so focus advances to the amount field.
|
||||
e.preventDefault()
|
||||
onCommit?.(search)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -149,16 +160,27 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
hasInteractedRef.current = true
|
||||
setHighlightedIndex((prev) => Math.min(prev + 1, flatList.length - 1))
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
hasInteractedRef.current = true
|
||||
setHighlightedIndex((prev) => Math.max(prev - 1, 0))
|
||||
break
|
||||
case 'Enter':
|
||||
e.preventDefault()
|
||||
if (flatList[highlightedIndex]) {
|
||||
if (hasInteractedRef.current && flatList[highlightedIndex]) {
|
||||
e.preventDefault()
|
||||
selectAccount(flatList[highlightedIndex].account_number)
|
||||
} else if (/^\d{4}$/.test(search)) {
|
||||
// Committed number, no new interaction — advance without re-selecting.
|
||||
e.preventDefault()
|
||||
setIsOpen(false)
|
||||
onCommit?.(search)
|
||||
} else {
|
||||
// Nothing actively chosen — close the list and let the event bubble
|
||||
// so the form-level Enter (open review when balanced) can take over.
|
||||
setIsOpen(false)
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
@@ -170,6 +192,7 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value
|
||||
hasInteractedRef.current = true
|
||||
setSearch(newValue)
|
||||
// Emit any 4-digit numeric value to the parent. Unknown BAS numbers are
|
||||
// accepted optimistically — the submit-time ActivateAccountsDialog lets
|
||||
@@ -193,6 +216,7 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o
|
||||
}
|
||||
|
||||
const handleFocus = () => {
|
||||
hasInteractedRef.current = false
|
||||
setIsOpen(true)
|
||||
}
|
||||
|
||||
|
||||
@@ -165,11 +165,16 @@ export default function JournalEntryForm({
|
||||
// user typed in the combobox so we can prefill the dialog.
|
||||
const [creatingAccountForLine, setCreatingAccountForLine] = useState<number | null>(null)
|
||||
const [createAccountPrefill, setCreateAccountPrefill] = useState<string>('')
|
||||
// Per-row refs to the debit inputs so we can auto-advance focus there once an
|
||||
// account is committed on a row. Two layouts render simultaneously (mobile
|
||||
// cards + desktop table); we focus whichever one is actually visible.
|
||||
// Per-row refs to the account/debit/credit inputs so the keyboard flow can
|
||||
// advance focus with Enter: konto → debet → kredit → nästa rads konto. Two
|
||||
// layouts render simultaneously (mobile cards + desktop table); we focus
|
||||
// whichever one is actually visible.
|
||||
const desktopAccountRefs = useRef<(HTMLInputElement | null)[]>([])
|
||||
const mobileAccountRefs = useRef<(HTMLInputElement | null)[]>([])
|
||||
const desktopDebitRefs = useRef<(HTMLInputElement | null)[]>([])
|
||||
const mobileDebitRefs = useRef<(HTMLInputElement | null)[]>([])
|
||||
const desktopCreditRefs = useRef<(HTMLInputElement | null)[]>([])
|
||||
const mobileCreditRefs = useRef<(HTMLInputElement | null)[]>([])
|
||||
// Confirm button in the inline (bare) review, focused on open so Enter posts.
|
||||
const bareConfirmRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
@@ -512,18 +517,37 @@ export default function JournalEntryForm({
|
||||
updateLine(index, side === 'debit' ? 'debit_amount' : 'credit_amount', fill.toFixed(2))
|
||||
}
|
||||
|
||||
// Move focus to a row's debit input. Deferred a frame so it runs after any
|
||||
// Move focus to a row's input. Deferred a frame so it runs after any
|
||||
// re-render (e.g. the auto-appended trailing row). offsetParent is null for
|
||||
// display:none elements, so this picks whichever layout is currently visible.
|
||||
const focusDebit = useCallback((index: number) => {
|
||||
requestAnimationFrame(() => {
|
||||
const d = desktopDebitRefs.current[index]
|
||||
const m = mobileDebitRefs.current[index]
|
||||
const target = d && d.offsetParent !== null ? d : m && m.offsetParent !== null ? m : (d ?? m)
|
||||
target?.focus()
|
||||
target?.select?.()
|
||||
})
|
||||
}, [])
|
||||
const focusRowInput = useCallback(
|
||||
(
|
||||
desktop: React.RefObject<(HTMLInputElement | null)[]>,
|
||||
mobile: React.RefObject<(HTMLInputElement | null)[]>,
|
||||
index: number
|
||||
) => {
|
||||
requestAnimationFrame(() => {
|
||||
const d = desktop.current?.[index]
|
||||
const m = mobile.current?.[index]
|
||||
const target = d && d.offsetParent !== null ? d : m && m.offsetParent !== null ? m : (d ?? m)
|
||||
target?.focus()
|
||||
target?.select?.()
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
const focusAccount = useCallback(
|
||||
(index: number) => focusRowInput(desktopAccountRefs, mobileAccountRefs, index),
|
||||
[focusRowInput]
|
||||
)
|
||||
const focusDebit = useCallback(
|
||||
(index: number) => focusRowInput(desktopDebitRefs, mobileDebitRefs, index),
|
||||
[focusRowInput]
|
||||
)
|
||||
const focusCredit = useCallback(
|
||||
(index: number) => focusRowInput(desktopCreditRefs, mobileCreditRefs, index),
|
||||
[focusRowInput]
|
||||
)
|
||||
|
||||
// Keep exactly one trailing blank row so the user never has to click "Lägg
|
||||
// till rad": once the last row is started (account or amount), append a fresh
|
||||
@@ -676,6 +700,44 @@ export default function JournalEntryForm({
|
||||
if (canSubmitReview()) handleReview()
|
||||
}
|
||||
|
||||
// Enter-to-advance inside the konteringsrader: konto → debet → kredit →
|
||||
// nästa rads konto. Navigation only fires while the entry is NOT
|
||||
// submittable — once the voucher balances, Enter falls through to the
|
||||
// form-level handler above and opens the review instead, so a single Enter
|
||||
// never both moves focus and submits.
|
||||
const handleAmountKeyDown =
|
||||
(index: number, side: 'debit' | 'credit') =>
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key !== 'Enter' || canSubmitReview()) return
|
||||
e.preventDefault()
|
||||
// An amount on this side finishes the row (debit clears credit and vice
|
||||
// versa) → jump to the next row's account. An empty debit means the row
|
||||
// books on the credit side → hop across first.
|
||||
if (side === 'debit' && !(parseFloat(lines[index].debit_amount) > 0)) {
|
||||
focusCredit(index)
|
||||
} else {
|
||||
focusAccount(index + 1)
|
||||
}
|
||||
}
|
||||
|
||||
// Enter in a radbeskrivning continues to that row's amount.
|
||||
const handleLineDescKeyDown =
|
||||
(index: number) => (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key !== 'Enter' || canSubmitReview()) return
|
||||
e.preventDefault()
|
||||
focusDebit(index)
|
||||
}
|
||||
|
||||
// Enter in the verifikationstext drops into the first row still missing an
|
||||
// account, so the top-to-bottom keyboard flow never needs the mouse.
|
||||
const handleHeaderDescKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key !== 'Enter' || canSubmitReview()) return
|
||||
const idx = lines.findIndex((l) => !l.account_number)
|
||||
if (idx === -1) return
|
||||
e.preventDefault()
|
||||
focusAccount(idx)
|
||||
}
|
||||
|
||||
// Inner submit: builds payload, POSTs, throws a structured error on failure
|
||||
// (so the activation hook can intercept ACCOUNTS_NOT_IN_CHART).
|
||||
const postJournalEntry = useCallback(async () => {
|
||||
@@ -929,7 +991,17 @@ export default function JournalEntryForm({
|
||||
// summary instead of stacking a second dialog over the form dialog. The
|
||||
// no-underlag caveat folds in here so there's a single confirm step.
|
||||
const reviewPanel = (
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
className="space-y-4"
|
||||
// The host dialog swallows Escape (accidental-close guard), so Escape
|
||||
// here is free to mean "back to the form" — keyboard mirror of ←.
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape' && !isSubmitting) {
|
||||
e.stopPropagation()
|
||||
setShowReview(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@@ -1022,6 +1094,7 @@ export default function JournalEntryForm({
|
||||
<Input
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
onKeyDown={handleHeaderDescKeyDown}
|
||||
placeholder={t('description_placeholder')}
|
||||
className="mt-1 h-8"
|
||||
/>
|
||||
@@ -1223,6 +1296,7 @@ export default function JournalEntryForm({
|
||||
onChange={(num) => updateLine(index, 'account_number', num)}
|
||||
onCommit={() => focusDebit(index)}
|
||||
onCreateAccount={(prefill) => handleOpenCreateAccount(index, prefill)}
|
||||
inputRef={(el) => { mobileAccountRefs.current[index] = el }}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
@@ -1238,6 +1312,7 @@ export default function JournalEntryForm({
|
||||
<Input
|
||||
value={line.line_description}
|
||||
onChange={(e) => updateLine(index, 'line_description', e.target.value)}
|
||||
onKeyDown={handleLineDescKeyDown(index)}
|
||||
placeholder={t('line_description_placeholder')}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
@@ -1248,6 +1323,7 @@ export default function JournalEntryForm({
|
||||
type="number"
|
||||
value={line.debit_amount}
|
||||
onChange={(e) => updateLine(index, 'debit_amount', e.target.value)}
|
||||
onKeyDown={handleAmountKeyDown(index, 'debit')}
|
||||
onDoubleClick={() => handleFillBalance(index, 'debit')}
|
||||
title={t('fill_balance_tooltip')}
|
||||
placeholder="0,00"
|
||||
@@ -1260,9 +1336,11 @@ export default function JournalEntryForm({
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">{t('col_credit')}</Label>
|
||||
<Input
|
||||
ref={(el) => { mobileCreditRefs.current[index] = el }}
|
||||
type="number"
|
||||
value={line.credit_amount}
|
||||
onChange={(e) => updateLine(index, 'credit_amount', e.target.value)}
|
||||
onKeyDown={handleAmountKeyDown(index, 'credit')}
|
||||
onDoubleClick={() => handleFillBalance(index, 'credit')}
|
||||
title={t('fill_balance_tooltip')}
|
||||
placeholder="0,00"
|
||||
@@ -1348,6 +1426,7 @@ export default function JournalEntryForm({
|
||||
onChange={(num) => updateLine(index, 'account_number', num)}
|
||||
onCommit={() => focusDebit(index)}
|
||||
onCreateAccount={(prefill) => handleOpenCreateAccount(index, prefill)}
|
||||
inputRef={(el) => { desktopAccountRefs.current[index] = el }}
|
||||
className="h-8"
|
||||
/>
|
||||
</td>
|
||||
@@ -1355,6 +1434,7 @@ export default function JournalEntryForm({
|
||||
<Input
|
||||
value={line.line_description}
|
||||
onChange={(e) => updateLine(index, 'line_description', e.target.value)}
|
||||
onKeyDown={handleLineDescKeyDown(index)}
|
||||
placeholder={t('line_description_placeholder')}
|
||||
className="h-8"
|
||||
/>
|
||||
@@ -1372,6 +1452,7 @@ export default function JournalEntryForm({
|
||||
type="number"
|
||||
value={line.debit_amount}
|
||||
onChange={(e) => updateLine(index, 'debit_amount', e.target.value)}
|
||||
onKeyDown={handleAmountKeyDown(index, 'debit')}
|
||||
onDoubleClick={() => handleFillBalance(index, 'debit')}
|
||||
title={t('fill_balance_tooltip')}
|
||||
placeholder="0,00"
|
||||
@@ -1383,9 +1464,11 @@ export default function JournalEntryForm({
|
||||
</td>
|
||||
<td className="py-1.5 px-1">
|
||||
<Input
|
||||
ref={(el) => { desktopCreditRefs.current[index] = el }}
|
||||
type="number"
|
||||
value={line.credit_amount}
|
||||
onChange={(e) => updateLine(index, 'credit_amount', e.target.value)}
|
||||
onKeyDown={handleAmountKeyDown(index, 'credit')}
|
||||
onDoubleClick={() => handleFillBalance(index, 'credit')}
|
||||
title={t('fill_balance_tooltip')}
|
||||
placeholder="0,00"
|
||||
@@ -1504,7 +1587,9 @@ export default function JournalEntryForm({
|
||||
entityType={company?.entity_type}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">{t('fill_balance_hint')}</p>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
{t('fill_balance_hint')} {t('keyboard_hint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Document attachments — hidden when editing a draft; underlag is
|
||||
@@ -1666,6 +1751,7 @@ export default function JournalEntryForm({
|
||||
setShowReview(true)
|
||||
}}
|
||||
isSubmitting={false}
|
||||
autoFocusConfirm
|
||||
title={t('no_doc_dialog_title')}
|
||||
warningText={t('no_doc_dialog_warning')}
|
||||
confirmLabel={t('no_doc_confirm')}
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
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 { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { AlertTriangle, Check, Copy, Loader2, RefreshCw, Trash2 } from 'lucide-react'
|
||||
import { formatDateLong } from '@/lib/utils'
|
||||
import type { CompanyInboundDomain, InboundDomainDnsRecord } from '@/types'
|
||||
|
||||
const BASE = '/api/extensions/ext/invoice-inbox/inbox/domain'
|
||||
|
||||
const STATUS_BADGE: Record<
|
||||
CompanyInboundDomain['status'],
|
||||
{ label: string; variant: 'secondary' | 'success' | 'destructive' }
|
||||
> = {
|
||||
pending: { label: 'Väntar på DNS', variant: 'secondary' },
|
||||
verified: { label: 'Verifierad', variant: 'success' },
|
||||
failed: { label: 'Misslyckades', variant: 'destructive' },
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
// Settings dialog for a company's own inbound domain. Claims the domain via
|
||||
// the extension API, renders the DNS records the user must publish, and
|
||||
// re-checks verification on demand. Everything mail-routing happens
|
||||
// server-side — this surface only manages the claim lifecycle.
|
||||
export default function InboxCustomDomainDialog({ open, onOpenChange }: Props) {
|
||||
const { toast } = useToast()
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [domain, setDomain] = useState<CompanyInboundDomain | null>(null)
|
||||
const [domainInput, setDomainInput] = useState('')
|
||||
const [isClaiming, setIsClaiming] = useState(false)
|
||||
const [isChecking, setIsChecking] = useState(false)
|
||||
const [isRemoving, setIsRemoving] = useState(false)
|
||||
|
||||
const fetchDomain = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const res = await fetch(BASE)
|
||||
const json = await res.json()
|
||||
if (res.ok) setDomain(json.data ?? null)
|
||||
} catch {
|
||||
// Leave the previous state; the dialog shows the claim form on null.
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) fetchDomain()
|
||||
}, [open, fetchDomain])
|
||||
|
||||
const handleClaim = useCallback(async () => {
|
||||
if (!domainInput.trim()) return
|
||||
setIsClaiming(true)
|
||||
try {
|
||||
const res = await fetch(BASE, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domain: domainInput }),
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json.error ?? 'Kunde inte lägga till domänen')
|
||||
setDomain(json.data)
|
||||
setDomainInput('')
|
||||
toast({
|
||||
title: 'Domän tillagd',
|
||||
description: 'Lägg till DNS-posterna nedan hos din domänleverantör.',
|
||||
})
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Kunde inte lägga till domänen',
|
||||
description: err instanceof Error ? err.message : 'Försök igen.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsClaiming(false)
|
||||
}
|
||||
}, [domainInput, toast])
|
||||
|
||||
const handleVerify = useCallback(async () => {
|
||||
setIsChecking(true)
|
||||
try {
|
||||
const res = await fetch(`${BASE}/verify`, { method: 'POST' })
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json.error ?? 'Kontrollen misslyckades')
|
||||
setDomain(json.data)
|
||||
toast(
|
||||
json.data.status === 'verified'
|
||||
? { title: 'Domänen är verifierad', description: 'E-post till domänen landar nu i dokumentinkorgen.' }
|
||||
: { title: 'Inte verifierad än', description: 'DNS-ändringar kan ta upp till någon timme att slå igenom.' }
|
||||
)
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Kontrollen misslyckades',
|
||||
description: err instanceof Error ? err.message : 'Försök igen.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsChecking(false)
|
||||
}
|
||||
}, [toast])
|
||||
|
||||
const handleRemove = useCallback(async () => {
|
||||
if (!domain) return
|
||||
if (!confirm(`Ta bort ${domain.domain}? E-post till domänen slutar landa i Accounted.`)) return
|
||||
setIsRemoving(true)
|
||||
try {
|
||||
const res = await fetch(BASE, { method: 'DELETE' })
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json.error ?? 'Borttagningen misslyckades')
|
||||
setDomain(null)
|
||||
toast({ title: 'Domänen borttagen' })
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Borttagningen misslyckades',
|
||||
description: err instanceof Error ? err.message : 'Försök igen.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsRemoving(false)
|
||||
}
|
||||
}, [domain, toast])
|
||||
|
||||
const handleCopy = useCallback(
|
||||
(value: string) => {
|
||||
navigator.clipboard.writeText(value).catch(() => {})
|
||||
toast({ title: 'Kopierat' })
|
||||
},
|
||||
[toast]
|
||||
)
|
||||
|
||||
const records: InboundDomainDnsRecord[] = domain?.dns_records ?? []
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Egen domän för inkorgen</DialogTitle>
|
||||
<DialogDescription>
|
||||
Ta emot leverantörsfakturor direkt på bolagets egen adress, t.ex.
|
||||
faktura@dittbolag.se — utan vidarebefordran.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</div>
|
||||
) : !domain ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3 rounded-lg border border-border p-4 text-sm">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5 text-muted-foreground" />
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">Viktigt om din domän redan tar emot e-post</p>
|
||||
<p className="text-muted-foreground">
|
||||
Domänens MX-poster pekas om till Accounted. Om domänen redan används för
|
||||
e-post (Google Workspace, Microsoft 365) slutar din vanliga e-post att
|
||||
fungera — använd då en underdomän, t.ex.{' '}
|
||||
<code className="font-mono text-xs">faktura.dittbolag.se</code>, eller
|
||||
fortsätt vidarebefordra till din vanliga inkorgsadress.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={domainInput}
|
||||
onChange={(e) => setDomainInput(e.target.value)}
|
||||
placeholder="faktura.dittbolag.se"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleClaim()
|
||||
}}
|
||||
/>
|
||||
<Button onClick={handleClaim} disabled={isClaiming || !domainInput.trim()}>
|
||||
{isClaiming ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : null}
|
||||
Lägg till
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<code className="font-mono text-sm truncate">{domain.domain}</code>
|
||||
<Badge variant={STATUS_BADGE[domain.status].variant}>
|
||||
{STATUS_BADGE[domain.status].label}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button variant="outline" size="sm" onClick={handleVerify} disabled={isChecking}>
|
||||
{isChecking ? (
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
|
||||
)}
|
||||
Kontrollera igen
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRemove}
|
||||
disabled={isRemoving}
|
||||
aria-label="Ta bort domän"
|
||||
>
|
||||
{isRemoving ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{domain.status === 'verified' ? (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-border p-4 text-sm">
|
||||
<Check className="h-4 w-4 shrink-0 mt-0.5 text-muted-foreground" />
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">
|
||||
Klart — ge dina leverantörer{' '}
|
||||
<code className="font-mono text-xs">faktura@{domain.domain}</code>
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
Alla adresser på domänen fungerar; allt landar i dokumentinkorgen.
|
||||
{domain.verified_at ? ` Verifierad ${formatDateLong(domain.verified_at)}.` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Lägg till posterna nedan hos din domänleverantör (Loopia, one.com,
|
||||
Cloudflare …) och klicka sedan på Kontrollera igen. Ändringar kan ta upp
|
||||
till någon timme att slå igenom.
|
||||
</p>
|
||||
{records.length > 0 ? (
|
||||
<div className="rounded-lg border border-border overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="px-3 py-2 text-left text-[11px] font-medium uppercase tracking-wider text-muted-foreground">Typ</th>
|
||||
<th className="px-3 py-2 text-left text-[11px] font-medium uppercase tracking-wider text-muted-foreground">Namn</th>
|
||||
<th className="px-3 py-2 text-left text-[11px] font-medium uppercase tracking-wider text-muted-foreground">Värde</th>
|
||||
<th className="px-3 py-2 text-right text-[11px] font-medium uppercase tracking-wider text-muted-foreground">Prio</th>
|
||||
<th className="px-3 py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.map((r, i) => (
|
||||
<tr key={`${r.type}-${r.name}-${i}`} className="border-b border-border last:border-0">
|
||||
<td className="px-3 py-2 font-mono text-xs">{r.type}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs break-all">{r.name}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs break-all">{r.value}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-right tabular-nums">
|
||||
{r.priority ?? '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => handleCopy(r.value)}
|
||||
aria-label={`Kopiera ${r.type}-värde`}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Inga DNS-poster tillgängliga — klicka på Kontrollera igen.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -44,6 +44,8 @@ import type { InvoiceExtractionResult } from '@/types'
|
||||
import BookDirectlyDialog from '@/components/extensions/general/BookDirectlyDialog'
|
||||
import NewSupplierInvoiceDialog from '@/components/supplier-invoices/NewSupplierInvoiceDialog'
|
||||
import BulkBookInboxDialog from '@/components/extensions/general/BulkBookInboxDialog'
|
||||
// InboxCustomDomainDialog (egen domän) is built but gated off — see
|
||||
// INBOX_CUSTOM_DOMAINS_ENABLED in extensions/general/invoice-inbox/index.ts.
|
||||
import TransactionMatchPicker from '@/components/inbox/TransactionMatchPicker'
|
||||
import { useAgentSheet } from '@/components/agent/AgentSheetProvider'
|
||||
|
||||
|
||||
@@ -138,6 +138,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
work_type: z.string().nullable().optional(),
|
||||
housing_designation: z.string().nullable().optional(),
|
||||
apartment_number: z.string().nullable().optional(),
|
||||
brf_org_number: z.string().nullable().optional(),
|
||||
// Periodisering (förutbetald intäkt). Active when balance account is
|
||||
// non-null; both period dates are then required (refine below).
|
||||
accrual_period_start: z.string().nullable().optional(),
|
||||
@@ -199,7 +200,10 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
self_billing_agreement_ref: z.string().optional(),
|
||||
received_date: z.string().optional(),
|
||||
// Invoice-level ROT/RUT claim info. Personnummer is plaintext on
|
||||
// the wire; the API encrypts it before storage.
|
||||
// the wire; the API encrypts it before storage. The API additionally
|
||||
// accepts the bostadsrätt pair (deduction_apartment_number +
|
||||
// deduction_brf_org_number) — no editor UI for it yet, rot i
|
||||
// bostadsrätt data enters via API/MCP until the payout-file UI ships.
|
||||
deduction_personnummer: z.string().optional(),
|
||||
deduction_housing_designation: z.string().optional(),
|
||||
items: z.array(itemSchema).min(1, t('validation_min_one_row')),
|
||||
@@ -262,6 +266,10 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
// draft. Starts true for create (always derive), false for edit (skip once).
|
||||
const didInitialCustomerSync = useRef(!isEditMode)
|
||||
|
||||
// Edit mode: the claim card's property fields are restored from the first
|
||||
// rot line (they're stamped onto every rot line server-side at save time).
|
||||
const initialRotLine = initial?.items?.find((i) => i.deduction_type === 'rot') ?? null
|
||||
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
@@ -291,7 +299,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
self_billing_agreement_ref: '',
|
||||
received_date: '',
|
||||
deduction_personnummer: '',
|
||||
deduction_housing_designation: '',
|
||||
deduction_housing_designation: initialRotLine?.housing_designation ?? '',
|
||||
items: (initial.items ?? []).map((item) => ({
|
||||
line_type: (item.line_type ?? 'product') as 'product' | 'text',
|
||||
description: item.description,
|
||||
@@ -306,6 +314,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
work_type: item.work_type ?? null,
|
||||
housing_designation: item.housing_designation ?? null,
|
||||
apartment_number: item.apartment_number ?? null,
|
||||
brf_org_number: item.brf_org_number ?? null,
|
||||
accrual_period_start: item.accrual_period_start ?? null,
|
||||
accrual_period_end: item.accrual_period_end ?? null,
|
||||
accrual_balance_account: item.accrual_balance_account ?? null,
|
||||
@@ -334,6 +343,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
work_type: null,
|
||||
housing_designation: null,
|
||||
apartment_number: null,
|
||||
brf_org_number: null,
|
||||
accrual_period_start: null,
|
||||
accrual_period_end: null,
|
||||
accrual_balance_account: null,
|
||||
@@ -927,6 +937,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
work_type: _wt,
|
||||
housing_designation: _hd,
|
||||
apartment_number: _an,
|
||||
brf_org_number: _bn,
|
||||
...rest
|
||||
} = item
|
||||
return rest
|
||||
@@ -1003,6 +1014,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
work_type: _wt,
|
||||
housing_designation: _hd,
|
||||
apartment_number: _an,
|
||||
brf_org_number: _bn,
|
||||
...rest
|
||||
} = item
|
||||
return rest
|
||||
@@ -1061,6 +1073,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
work_type: _wt,
|
||||
housing_designation: _hd,
|
||||
apartment_number: _an,
|
||||
brf_org_number: _bn,
|
||||
...rest
|
||||
} = item
|
||||
return rest
|
||||
@@ -1816,6 +1829,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
work_type: null,
|
||||
housing_designation: null,
|
||||
apartment_number: null,
|
||||
brf_org_number: null,
|
||||
accrual_period_start: null,
|
||||
accrual_period_end: null,
|
||||
accrual_balance_account: null,
|
||||
@@ -1852,6 +1866,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
work_type: null,
|
||||
housing_designation: null,
|
||||
apartment_number: null,
|
||||
brf_org_number: null,
|
||||
accrual_period_start: null,
|
||||
accrual_period_end: null,
|
||||
accrual_balance_account: null,
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import {
|
||||
INVOICE_EMAIL_DEFAULT_TEXTS,
|
||||
INVOICE_EMAIL_PLACEHOLDER_KEYS,
|
||||
} from '@/lib/email/invoice-templates'
|
||||
import type { CompanySettings, InvoiceEmailTextOverrides, InvoiceEmailTexts } from '@/types'
|
||||
|
||||
interface InvoiceEmailTextsSettingsProps {
|
||||
settings: CompanySettings
|
||||
onUpdate: (updates: Partial<CompanySettings>) => void
|
||||
}
|
||||
|
||||
type Lang = 'sv' | 'en'
|
||||
type Field = keyof InvoiceEmailTextOverrides
|
||||
|
||||
const LANGS: Lang[] = ['sv', 'en']
|
||||
|
||||
const FIELD_CONFIG: Array<{ field: Field; labelKey: string; multiline?: boolean }> = [
|
||||
{ field: 'subject', labelKey: 'subject_label' },
|
||||
{ field: 'greeting', labelKey: 'greeting_label' },
|
||||
{ field: 'body', labelKey: 'body_label', multiline: true },
|
||||
{ field: 'signoff', labelKey: 'signoff_label' },
|
||||
]
|
||||
|
||||
// The editor always shows the EFFECTIVE text (override or standard), never an
|
||||
// empty field — users see and edit the mail that actually goes out.
|
||||
type DisplayTexts = Record<Lang, Record<Field, string>>
|
||||
|
||||
function buildDisplay(stored: InvoiceEmailTexts | null | undefined): DisplayTexts {
|
||||
const result = {} as DisplayTexts
|
||||
for (const lang of LANGS) {
|
||||
result[lang] = {} as Record<Field, string>
|
||||
for (const { field } of FIELD_CONFIG) {
|
||||
const value = stored?.[lang]?.[field]
|
||||
result[lang][field] =
|
||||
typeof value === 'string' && value.trim() !== ''
|
||||
? value
|
||||
: INVOICE_EMAIL_DEFAULT_TEXTS[lang][field]
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Cleared fields have no meaning of their own — snap them back to standard.
|
||||
function normalize(display: DisplayTexts): DisplayTexts {
|
||||
const result = {} as DisplayTexts
|
||||
for (const lang of LANGS) {
|
||||
result[lang] = {} as Record<Field, string>
|
||||
for (const { field } of FIELD_CONFIG) {
|
||||
const value = display[lang][field]
|
||||
result[lang][field] =
|
||||
value.trim() === '' ? INVOICE_EMAIL_DEFAULT_TEXTS[lang][field] : value
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Store only changes: a field equal to the standard text is NOT an override,
|
||||
// so future improvements to the standard wording reach every company that
|
||||
// hasn't customized. Empty result → null (column reads "all defaults").
|
||||
function toOverrides(display: DisplayTexts): InvoiceEmailTexts | null {
|
||||
const result: InvoiceEmailTexts = {}
|
||||
for (const lang of LANGS) {
|
||||
const langOverrides: InvoiceEmailTextOverrides = {}
|
||||
for (const { field } of FIELD_CONFIG) {
|
||||
const value = display[lang][field].trim()
|
||||
if (value !== '' && value !== INVOICE_EMAIL_DEFAULT_TEXTS[lang][field]) {
|
||||
langOverrides[field] = value
|
||||
}
|
||||
}
|
||||
if (Object.keys(langOverrides).length > 0) result[lang] = langOverrides
|
||||
}
|
||||
return Object.keys(result).length > 0 ? result : null
|
||||
}
|
||||
|
||||
export function InvoiceEmailTextsSettings({ settings, onUpdate }: InvoiceEmailTextsSettingsProps) {
|
||||
const t = useTranslations('settings_email_texts')
|
||||
const { toast } = useToast()
|
||||
const { canWrite } = useCanWrite()
|
||||
const [texts, setTexts] = useState<DisplayTexts>(() => buildDisplay(settings.invoice_email_texts))
|
||||
// Serialized last-persisted overrides — skips no-op PUTs on blur without
|
||||
// edits. toOverrides() builds keys in a fixed order, so comparison is stable.
|
||||
const lastSavedRef = useRef<string>(
|
||||
JSON.stringify(toOverrides(buildDisplay(settings.invoice_email_texts))),
|
||||
)
|
||||
|
||||
const setField = (lang: Lang, field: Field, value: string) => {
|
||||
setTexts((prev) => ({ ...prev, [lang]: { ...prev[lang], [field]: value } }))
|
||||
}
|
||||
|
||||
// Whole-object save: a JSONB column update replaces the stored value, and
|
||||
// the inactive language tab is unmounted, so per-field PATCHes can't work.
|
||||
const persist = useCallback(async (display: DisplayTexts) => {
|
||||
const overrides = toOverrides(display)
|
||||
const serialized = JSON.stringify(overrides)
|
||||
if (serialized === lastSavedRef.current) return
|
||||
try {
|
||||
const response = await fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ invoice_email_texts: overrides }),
|
||||
})
|
||||
if (!response.ok) throw new Error()
|
||||
lastSavedRef.current = serialized
|
||||
onUpdate({ invoice_email_texts: overrides })
|
||||
} catch {
|
||||
toast({ title: t('toast_save_failed'), variant: 'destructive' })
|
||||
}
|
||||
}, [onUpdate, toast, t])
|
||||
|
||||
const handleBlur = () => {
|
||||
const normalized = normalize(texts)
|
||||
setTexts(normalized)
|
||||
void persist(normalized)
|
||||
}
|
||||
|
||||
const resetField = (lang: Lang, field: Field) => {
|
||||
const next = {
|
||||
...texts,
|
||||
[lang]: { ...texts[lang], [field]: INVOICE_EMAIL_DEFAULT_TEXTS[lang][field] },
|
||||
}
|
||||
setTexts(next)
|
||||
void persist(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('heading')}
|
||||
</h2>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t('description')}</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="sv">
|
||||
<TabsList>
|
||||
<TabsTrigger value="sv">{t('tab_sv')}</TabsTrigger>
|
||||
<TabsTrigger value="en">{t('tab_en')}</TabsTrigger>
|
||||
</TabsList>
|
||||
{LANGS.map((lang) => (
|
||||
<TabsContent key={lang} value={lang} className="mt-4 space-y-4">
|
||||
{lang === 'en' && (
|
||||
<p className="text-xs text-muted-foreground">{t('en_tab_hint')}</p>
|
||||
)}
|
||||
{FIELD_CONFIG.map(({ field, labelKey, multiline }) => {
|
||||
const id = `invoice-email-${field}-${lang}`
|
||||
const modified =
|
||||
texts[lang][field].trim() !== INVOICE_EMAIL_DEFAULT_TEXTS[lang][field]
|
||||
const common = {
|
||||
id,
|
||||
value: texts[lang][field],
|
||||
onBlur: handleBlur,
|
||||
disabled: !canWrite,
|
||||
}
|
||||
return (
|
||||
<div key={field} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor={id}>{t(labelKey)}</Label>
|
||||
{modified && canWrite && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => resetField(lang, field)}
|
||||
className="text-xs text-muted-foreground transition-colors duration-150 hover:text-foreground"
|
||||
>
|
||||
{t('reset_label')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{multiline ? (
|
||||
<Textarea
|
||||
{...common}
|
||||
rows={4}
|
||||
onChange={(e) => setField(lang, field, e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
{...common}
|
||||
onChange={(e) => setField(lang, field, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
<div className="space-y-1">
|
||||
{/* Legend is rendered from code, not messages/*.json — ICU message
|
||||
syntax treats literal braces as interpolation. */}
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{t('placeholders_help')}</span>
|
||||
{INVOICE_EMAIL_PLACEHOLDER_KEYS.map((key) => (
|
||||
<code key={key} className="rounded bg-muted px-1 text-xs">{`{${key}}`}</code>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('firstname_note')}</p>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { BankDetailsForm, validateBankFields } from '@/components/settings/BankDetailsForm'
|
||||
import { InvoiceSettingsForm } from '@/components/settings/InvoiceSettingsForm'
|
||||
import { InvoiceEmailTextsSettings } from '@/components/settings/InvoiceEmailTextsSettings'
|
||||
import { InvoicePreviewCard } from '@/components/settings/InvoicePreviewCard'
|
||||
import { PdfPrintSettings } from '@/components/settings/PdfPrintSettings'
|
||||
import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper'
|
||||
@@ -73,6 +74,11 @@ export function InvoicingSettingsContent() {
|
||||
<div className="border-t border-border pt-8">
|
||||
<PdfPrintSettings settings={settings} onUpdate={updateSettings} />
|
||||
</div>
|
||||
|
||||
{/* Invoice email texts — autosaves on blur */}
|
||||
<div className="border-t border-border pt-8">
|
||||
<InvoiceEmailTextsSettings settings={settings} onUpdate={updateSettings} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user