feat(ux): Bucket A defaults pass: remove choices the system already knows the answer to (#1443)
* feat(booking): batch VAT seeds from category default, period derives from entry date BatchCategorySelector and BulkBookInboxDialog hardcoded standard_25 as the initial VAT treatment, overriding the server's per-category derivation and claiming 25% moms on VAT-exempt bank fees. Both now default to an explicit 'Enligt kategori' option that omits vat_treatment so the server derives it (exempt bank/card fees, 12% representation). Reverse charge is never derived. The embedded JournalEntryForm period Select is replaced by the same derived read-only text the standalone variant already uses: the period is a total function of the entry date, and the Select allowed picking a period that disagreed with it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(booking): prefill cost account from counterparty history; period text in Bokfor direkt BookDirectlyDialog and the supplier-invoice form left the cost account deliberately blank even when the company's own confirmed history for the counterparty (categorization_templates) or supplier.default_expense_account knew the answer. Both now prefill from a counterparty-template hit (new ?counterparty= single-match mode on the settings route, same tiered matcher as the booking flows), only into still-empty fields, only from expense-shaped templates, with a provenance line. No generic fallback: a miss leaves the field blank exactly as before. Bokfor direkt's period Select is replaced by text derived from the entry date; the silent periods[0] fallback becomes a blocking explanation, since borrowing an arbitrary period could book into the wrong one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(ux): single-company login skips the picker; filing surfaces default to filable periods /select-company auto-forwards when the user is a member of exactly one company with nothing else to decide (no new TIC engagements, no pending invite, enrichment fresh); the in-app 'Lagg till foretag' links pass ?choose=1 to keep the picker deliberately reachable. Byra/multi-company users are untouched. The VAT declaration now opens on the most recently ENDED month/quarter (lib/vat/period-defaults, tested) instead of the current one, which can never be filed and forced a step-back click on every filing visit; the periodicity switch resets the same way. Helarsmoms FyPicker gains preferLatestEnded and opens on the latest ended rakenskapsar instead of the newest started one. The 'momsperiod saknas' dead end now collects the answer inline through the same PUT /api/settings validation instead of bouncing to settings: until the period exists the deadline engine generates zero VAT deadlines, silently, so every extra hop kept a compliance hole open. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(granskning): approve pill commits directly for low and medium risk The Godkann pill on /pending only opened a ConfirmationDialog demanding a second Godkann, regardless of tier. The review row already states source, title and risk and offers Detaljer, so for low/medium the pill now commits directly; high risk keeps the dialog, whose warning sentence carries information the row does not. Chat-side bulk approve is deferred: it needs ApprovalCard's state lifted (assistant-redesign seam 8.8), see DECISIONS.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reports): map inline momsperiod save errors through getErrorMessage Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(review): repair the dead login auto-forward and nine review findings The big one: setActiveCompany ends with a cookie write that throws during Server Component render (sealed cookie store), so the /select-company auto-forward silently never fired; the write is now best-effort since the cookie is write-only compat and the DB write is already verified. Also: supplier-switch un-plants history-prefilled accounts so the new supplier's own default applies; prefill routes through handleAccountChange so konto default moms rides along; batch 'Ingen moms' books exempt instead of the derived 25%; monthly VAT default tracks the actual 12th/17th filing deadline (over-40M stays M-1); inline momsperiod setup uses EmptyState, gates on vat_number (the PUT would 400 without it), keeps keyboard focus and announces errors; cost-account shape guard tightened to P&L accounts; attn tone on the new warning lines. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: retrigger workflows; the Actions outage swallowed the rebase push event Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: retrigger after outage (events dropped, not delayed) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: retrigger after GitHub Actions recovery Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(review): address CodeRabbit and compliance-bot findings Direct commit now prunes the op from the bulk selection (a stale id kept inflating the bulk bar and rode into bulk-commit) and the detail-panel Godkann gets the same risk gate as the row pill. The automatic account fill in the supplier-invoice form is requested, not applied inline: the applying effect waits for both the BAS chart and the request with fresh closures, so a fill can no longer land before the chart and leave a VAT-free konto on the 25% row default. Test dates use local-time constructors (ISO strings parse as UTC midnight and shift a day in negative-offset timezones). Stale ML 11 kap citation dropped from a comment. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: retrigger; push event dropped again Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
63d520719a
commit
c0a106e591
@@ -12,7 +12,6 @@ import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Loader2, Plus, Trash2, AlertTriangle, Search, Check, BookmarkPlus } from 'lucide-react'
|
||||
@@ -33,6 +32,8 @@ import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
|
||||
import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
|
||||
import { resolveAccount } from '@/lib/cash-accounts/resolve-account'
|
||||
import { renderChannelContextNotes } from '@/lib/documents/channel-context-notes'
|
||||
import { formatCounterpartyName } from '@/lib/bookkeeping/counterparty-templates'
|
||||
import { AttnLine } from '@/components/ui/attn-line'
|
||||
import type { BASAccount, BookingTemplateLibrary, CashAccount, FiscalPeriod, InboxChannelContext, InvoiceExtractionResult } from '@/types'
|
||||
|
||||
interface InboxItem {
|
||||
@@ -243,6 +244,46 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, docUrl =
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, item.id])
|
||||
|
||||
// Cost-account prefill from the company's own booking history for this
|
||||
// supplier (counterparty templates). Fills only the first line's still-empty
|
||||
// account: never a generic seed (the old silent-'5010' incident is the
|
||||
// reason there is no fallback), never over anything the user typed, and only
|
||||
// for expense-shaped templates (cost on debit, settlement on credit) so an
|
||||
// income template can't plant a revenue account on a purchase.
|
||||
const [accountSuggestion, setAccountSuggestion] = useState<{ account: string; counterparty: string } | null>(null)
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setAccountSuggestion(null)
|
||||
const supplier = item.extracted_data?.supplier?.name?.trim()
|
||||
if (!supplier) return
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/settings/counterparty-templates?counterparty=${encodeURIComponent(supplier)}`
|
||||
)
|
||||
if (!res.ok) return
|
||||
const json = await res.json()
|
||||
if (cancelled) return
|
||||
const match = json?.data
|
||||
const debit: string | undefined = match?.template?.debit_account
|
||||
const credit: string | undefined = match?.template?.credit_account
|
||||
if (!match || (match.confidence ?? 0) < 0.5) return
|
||||
// P&L cost on debit (4xxx-8xxx), settlement on credit: keeps private
|
||||
// and balance-sheet templates (2013, 1630, 12xx) out of a cost field.
|
||||
if (!debit || !/^[4-8]/.test(debit) || !credit || !credit.startsWith('19')) return
|
||||
setLines((current) => {
|
||||
if (!current[0] || current[0].account_number) return current
|
||||
return current.map((l, i) => (i === 0 ? { ...l, account_number: debit } : l))
|
||||
})
|
||||
setAccountSuggestion({ account: debit, counterparty: match.template.counterparty_name })
|
||||
} catch {
|
||||
// Prefill is best-effort; the field simply stays blank.
|
||||
}
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
}, [open, item.id, item.extracted_data?.supplier?.name])
|
||||
|
||||
// Fetch the underlag's SEK rate for a foreign-currency document so candidate
|
||||
// transactions can be ranked against the SEK-equivalent total (and not the
|
||||
// raw foreign number). SEK / unsupported currencies skip the fetch.
|
||||
@@ -376,18 +417,18 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, docUrl =
|
||||
return () => { cancelled = true }
|
||||
}, [open])
|
||||
|
||||
// Auto-select fiscal period matching the entry date
|
||||
// Derive the fiscal period from the entry date. Periods never overlap, so
|
||||
// this is a total function of the date; when the date falls outside every
|
||||
// period the id clears and submit is blocked with an explanation. The old
|
||||
// else-branch silently borrowed periods[0], which could book into the wrong
|
||||
// period with only the DB period trigger left to catch it.
|
||||
useEffect(() => {
|
||||
if (periods.length === 0) return
|
||||
const match = periods.find(
|
||||
(p) => entryDate >= p.period_start && entryDate <= p.period_end
|
||||
)
|
||||
if (match) {
|
||||
setPeriodId(match.id)
|
||||
} else if (!periodId && periods.length > 0) {
|
||||
setPeriodId(periods[0].id)
|
||||
}
|
||||
}, [entryDate, periods, periodId])
|
||||
setPeriodId(match ? match.id : '')
|
||||
}, [entryDate, periods])
|
||||
|
||||
// Fetch unmatched transactions whenever the dialog opens: the picker
|
||||
// is always visible now (selection is optional).
|
||||
@@ -503,15 +544,22 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, docUrl =
|
||||
[],
|
||||
)
|
||||
|
||||
const derivedPeriod = useMemo(
|
||||
() => periods.find((p) => p.id === periodId) ?? null,
|
||||
[periods, periodId],
|
||||
)
|
||||
const derivedPeriodBlocked = !!(derivedPeriod?.locked_at || derivedPeriod?.is_closed)
|
||||
|
||||
const disabledReason = useMemo(() => {
|
||||
if (isSubmitting) return null
|
||||
if (!entryDate) return 'Välj datum'
|
||||
if (!periodId) return 'Välj räkenskapsperiod'
|
||||
if (!periodId) return 'Datumet matchar ingen öppen räkenskapsperiod'
|
||||
if (derivedPeriodBlocked) return 'Räkenskapsperioden är låst eller stängd'
|
||||
if (description.trim().length === 0) return 'Fyll i beskrivning'
|
||||
if (lines.some((l) => l.account_number.trim().length === 0)) return 'Alla rader behöver ett konto'
|
||||
if (!totals.balanced) return 'Debet och kredit måste vara lika'
|
||||
return null
|
||||
}, [isSubmitting, entryDate, periodId, description, lines, totals.balanced])
|
||||
}, [isSubmitting, entryDate, periodId, derivedPeriodBlocked, description, lines, totals.balanced])
|
||||
|
||||
const canSubmit = !isSubmitting && disabledReason === null
|
||||
|
||||
@@ -619,31 +667,25 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, docUrl =
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5 md:col-span-2">
|
||||
<Label htmlFor="bd-period">Räkenskapsperiod</Label>
|
||||
<Select
|
||||
value={periodId}
|
||||
onValueChange={setPeriodId}
|
||||
disabled={isSubmitting || periods.length === 0}
|
||||
>
|
||||
<SelectTrigger id="bd-period">
|
||||
<SelectValue placeholder="Välj period" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{periods.map((p) => {
|
||||
const lockState = p.locked_at
|
||||
? 'låst'
|
||||
: p.is_closed
|
||||
? 'stängd'
|
||||
: null
|
||||
return (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.period_start}: {p.period_end}
|
||||
{lockState && ` (${lockState})`}
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Label>Räkenskapsperiod</Label>
|
||||
{/* Derived from the entry date (periods never overlap): text,
|
||||
not a picker, so it can never disagree with the date. */}
|
||||
{periods.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground pt-2">Hämtar perioder …</p>
|
||||
) : derivedPeriod ? (
|
||||
<p className="text-sm pt-2 tabular-nums">
|
||||
{derivedPeriod.period_start}: {derivedPeriod.period_end}
|
||||
{(derivedPeriod.locked_at || derivedPeriod.is_closed) && (
|
||||
<span className="text-attn">
|
||||
{' '}({derivedPeriod.locked_at ? 'låst' : 'stängd'})
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<AttnLine className="pt-2">
|
||||
Datumet ligger utanför öppna räkenskapsperioder. Ändra datumet eller skapa perioden under Bokföring.
|
||||
</AttnLine>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -792,6 +834,13 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, docUrl =
|
||||
lägg till en rad för omvänd skattskyldighet manuellt.
|
||||
</p>
|
||||
)}
|
||||
{accountSuggestion && lines[0]?.account_number === accountSuggestion.account && (
|
||||
<p className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<span aria-hidden className="inline-block h-1.5 w-1.5 rounded-full bg-success" />
|
||||
Konto {accountSuggestion.account} föreslaget från tidigare bokföringar av{' '}
|
||||
{formatCounterpartyName(accountSuggestion.counterparty)}
|
||||
</p>
|
||||
)}
|
||||
<div className="rounded-lg border overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/40">
|
||||
|
||||
@@ -71,7 +71,11 @@ const CATEGORY_OPTIONS: { value: string; label: string }[] = [
|
||||
// here by `reduced_12` / `reduced_6`: there is deliberately no `standard_12` /
|
||||
// `standard_6` (no such treatment exists; the backend would reject it). Keep
|
||||
// this list in sync with the union, not with rate labels.
|
||||
const VAT_OPTIONS: { value: VatTreatment; label: string }[] = [
|
||||
const VAT_OPTIONS: { value: VatTreatment | 'auto'; label: string }[] = [
|
||||
// 'auto' sends no explicit treatment: the bulk-book route derives the
|
||||
// default from the picked category (exempt for bank/card fees, 12%
|
||||
// representation, else 25%). Reverse charge is never derived; see below.
|
||||
{ value: 'auto', label: 'Enligt kategori' },
|
||||
{ value: 'standard_25', label: 'Moms 25%' },
|
||||
{ value: 'reduced_12', label: 'Moms 12%' },
|
||||
{ value: 'reduced_6', label: 'Moms 6%' },
|
||||
@@ -88,7 +92,7 @@ export default function BulkBookInboxDialog({ open, onOpenChange, items, onSucce
|
||||
const { toast } = useToast()
|
||||
const t = useTranslations('inbox_bulk_book')
|
||||
const [category, setCategory] = useState<string>('')
|
||||
const [vatTreatment, setVatTreatment] = useState<VatTreatment>('standard_25')
|
||||
const [vatTreatment, setVatTreatment] = useState<VatTreatment | 'auto'>('auto')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const bookable = useMemo(() => items.filter(isBookable), [items])
|
||||
@@ -101,15 +105,16 @@ export default function BulkBookInboxDialog({ open, onOpenChange, items, onSucce
|
||||
[items],
|
||||
)
|
||||
|
||||
// Reset to the safe default (25% svensk moms) each time the dialog opens.
|
||||
// Reset to the category-derived default each time the dialog opens.
|
||||
// Currency is deliberately NOT used to preselect omvänd skattskyldighet: a
|
||||
// foreign currency does not imply a foreign seller: a Swedish supplier can
|
||||
// invoice in EUR and still debit 25% moms. Reverse charge is a property of
|
||||
// the seller (utländsk, utan svenskt momsnr), never of the currency, so
|
||||
// defaulting to it from currency alone would silently mis-book domestic VAT.
|
||||
// The advisory rendered under the Moms picker spells this out to the user.
|
||||
// the seller (utländsk, utan svenskt momsnr), never of the currency, and the
|
||||
// server-side derivation never produces it either, so it is only ever an
|
||||
// explicit user choice. The advisory rendered under the Moms picker spells
|
||||
// this out to the user.
|
||||
useEffect(() => {
|
||||
if (open) setVatTreatment('standard_25')
|
||||
if (open) setVatTreatment('auto')
|
||||
}, [open])
|
||||
|
||||
// Underlag subtotals, split per currency. This used to be a single scalar
|
||||
@@ -140,7 +145,7 @@ export default function BulkBookInboxDialog({ open, onOpenChange, items, onSucce
|
||||
body: JSON.stringify({
|
||||
item_ids: bookable.map((it) => it.id),
|
||||
category,
|
||||
vat_treatment: vatTreatment,
|
||||
...(vatTreatment !== 'auto' ? { vat_treatment: vatTreatment } : {}),
|
||||
}),
|
||||
})
|
||||
const json = await res.json().catch(() => ({}))
|
||||
@@ -217,7 +222,7 @@ export default function BulkBookInboxDialog({ open, onOpenChange, items, onSucce
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Select value={vatTreatment} onValueChange={(v) => setVatTreatment(v as VatTreatment)}>
|
||||
<Select value={vatTreatment} onValueChange={(v) => setVatTreatment(v as VatTreatment | 'auto')}>
|
||||
<SelectTrigger id="bulk-vat">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
Reference in New Issue
Block a user