fix(reconciliation): Bankavstämning phase 0 — correctness + feedback batch (+ nav IA regrouping) (#879)

* feat(nav): interaction-mode sidebar grouping — Arbeta/Analys/Data/Skatt & bokslut

Nav IA redesign phase 0 (dev_docs/nav_ia_redesign.md): same routes,
regrouped by what the user is doing. CLAUDE.md restructured around Hard
Rules (doc references updated); pending-page explainer removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reconciliation): correctness + feedback batch for Bankavstämning (phase 0)

Engine: fetchAllRows pagination on status/run/RPC fetches (silent 1000-row
cap corrupted totals), optimistic-lock guards on manualLink + apply,
unlink audit rows attributed to the acting user (was: company UUID),
selected_matches partial apply intersected with a fresh match run.

View: silent in-place refresh instead of a full-page skeleton per action,
checkbox-gated apply with confidence badges (fuzzy unticked) in chunks of
500, honest result toasts, dry-run errors surfaced, ranked per-row picker
candidates pinned to the applied date window, currency-correct amounts
(bank side in account currency, GL side SEK), voucher links, translated
source types, colored differens, dirty-date-filter guard.

Discovery: year-end preflight 404 href fixed (/reconciliation/bank never
existed), ⌘K palette entry, real links from the transactions page.

v1: status registry schema now matches the actual ReconciliationStatus
payload, errors documented as a count, false ~0.85-threshold pitfall
replaced, route test mocks the real shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-03 11:21:38 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 59ecaee650
commit ea236cbcdf
20 changed files with 1124 additions and 563 deletions
+1
View File
@@ -57,6 +57,7 @@ const PAGE_ENTRIES: Entry[] = [
{ id: 'rapport-moms', label: 'Visa rapport: Momsdeklaration', icon: BarChart3, href: '/reports/vat-declaration', keywords: 'rapport moms vat deklaration' },
{ id: 'rapport-huvudbok', label: 'Visa rapport: Huvudbok', icon: BookOpen, href: '/reports/huvudbok', keywords: 'rapport huvudbok ledger general konto saldo transaktioner per konto kontoutdrag kontoanalys kontokort kontohistorik balance account statement transactions' },
{ id: 'rapport-kundreskontra', label: 'Visa rapport: Kundreskontra', icon: Users, href: '/reports/kundreskontra', keywords: 'rapport kundreskontra ar kundfordringar' },
{ id: 'rapport-bankavstamning', label: 'Bankavstämning', hint: 'Stäm av bank mot bokföring', icon: ArrowLeftRight, href: '/reports/bank-reconciliation', keywords: 'avstämning stäm av bank matcha banktransaktioner reconcile reconciliation 1930' },
{ id: 'importera', label: 'Importera', icon: Upload, href: '/import' },
{ id: 'granskning', label: 'Granskning', icon: ClipboardCheck, href: '/pending', keywords: 'pending review' },
{ id: 'löner', label: 'Löner', icon: HandCoins, href: '/salary' },
+97 -63
View File
@@ -33,6 +33,10 @@ import {
Tags,
ChevronsUpDown,
Sparkles,
Percent,
Landmark,
CalendarClock,
FileCheck,
} from 'lucide-react'
import { getBranding } from '@/lib/branding/service'
import { ENABLED_EXTENSION_IDS as _ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
@@ -105,20 +109,29 @@ type NavLabelKey =
| 'import'
| 'salary'
| 'employees'
| 'vat_declaration'
| 'skattekonto'
| 'deadlines'
| 'year_end'
| 'help'
| 'settings'
// New nav layout (May 2026):
// Nav layout (July 2026 — interaction-mode grouping, dev_docs/nav_ia_redesign.md
// Phase 0): same routes as before, regrouped by what the user is doing.
// top-of-sidebar — CompanySwitcher (active company / org context).
// top section — flat, no dropdown: Hem (agent), Underlag,
// Transaktioner, Granskning.
// four dropdowns — Försäljning, Inköp, Redovisning, Personal.
// top section — flat, no dropdown: Hem, Assistent. Hem doubles as
// the "what needs me" surface until a dedicated
// /inbox exists.
// four dropdowns — Arbeta (produce: the bookkeeping funnel first,
// then invoices, supplier invoices, payroll),
// Analys (KPI + reports), Data (master-data
// registers + import/export), Skatt & bokslut
// (statutory: VAT, tax account, deadlines, year-end).
// bottom-left popover — signed-in user's name + initial, opens upward
// to Inställningar, Hjälp, Support, Logga ut.
// Help + Settings are NOT in `navItems` anymore; they live in the account
// popover. KPI moved from main to redovisning. Pending stays visible at all
// times — the inline badge carries the count.
type GroupKey = 'top' | 'försäljning' | 'inköp' | 'redovisning' | 'personal'
// Help + Settings are NOT in `navItems`; they live in the account popover.
// Pending (Granskning) stays visible at all times — the badge carries the count.
type GroupKey = 'top' | 'arbeta' | 'analys' | 'data' | 'skatt'
interface NavItem {
href: string
@@ -143,30 +156,42 @@ const navItems: NavItem[] = [
// Top section — flat list, always visible, no header
{ href: '/', labelKey: 'home', icon: Home, group: 'top' },
{ href: '/chat', labelKey: 'assistant', icon: Sparkles, group: 'top' },
{ href: '/e/general/invoice-inbox', labelKey: 'invoice_inbox', icon: Inbox, group: 'top' },
{ href: '/transactions', labelKey: 'transactions', icon: ArrowLeftRight, group: 'top' },
{ href: '/pending', labelKey: 'review', icon: ClipboardCheck, group: 'top' },
// Försäljning dropdown
{ href: '/invoices', labelKey: 'invoices', icon: ReceiptText, group: 'försäljning' },
{ href: '/customers', labelKey: 'customers', icon: Users, group: 'försäljning' },
{ href: '/articles', labelKey: 'articles', icon: Tag, group: 'försäljning' },
// Inköp dropdown
{ href: '/supplier-invoices', labelKey: 'supplier_invoices', icon: Wallet, group: 'inköp' },
{ href: '/suppliers', labelKey: 'suppliers', icon: Building2, group: 'inköp' },
// Redovisning dropdown
{ href: '/kpi', labelKey: 'kpi', icon: TrendingUp, group: 'redovisning' },
{ href: '/bookkeeping', labelKey: 'bookkeeping', icon: BookOpen, group: 'redovisning' },
{ href: '/chart-of-accounts', labelKey: 'chart_of_accounts', icon: ListTree, group: 'redovisning' },
{ href: '/dimensions', labelKey: 'dimensions', icon: Tags, group: 'redovisning', requiresDimensions: true },
{ href: '/assets', labelKey: 'assets', icon: Package, group: 'redovisning' },
{ href: '/reports', labelKey: 'reports', icon: BarChart3, group: 'redovisning' },
{ href: '/import', labelKey: 'import', icon: Upload, group: 'redovisning' },
// Personal — "Beta" badge while we validate the end-to-end salary + AGI flow.
// Arbeta — everything the user produces. The bookkeeping funnel leads
// (Bokföring · Underlag · Transaktioner · Granskning — kept as separate
// rows until the unified workspace lands), then the transactional flows.
// Löner: "Beta" badge while we validate the end-to-end salary + AGI flow.
// employerOnly: shown to aktiebolag and to any employer (pays_salaries), so an
// enskild firma that hires staff gets payroll. Owner self-payroll stays
// blocked at the engine/DB layer (EF owner takes egna uttag, not lön). #782
{ href: '/salary', labelKey: 'salary', icon: HandCoins, group: 'personal', employerOnly: true, betaBadge: true },
{ href: '/salary/employees', labelKey: 'employees', icon: Users, group: 'personal', employerOnly: true, betaBadge: true },
{ href: '/bookkeeping', labelKey: 'bookkeeping', icon: BookOpen, group: 'arbeta' },
{ href: '/e/general/invoice-inbox', labelKey: 'invoice_inbox', icon: Inbox, group: 'arbeta' },
{ href: '/transactions', labelKey: 'transactions', icon: ArrowLeftRight, group: 'arbeta' },
{ href: '/pending', labelKey: 'review', icon: ClipboardCheck, group: 'arbeta' },
{ href: '/invoices', labelKey: 'invoices', icon: ReceiptText, group: 'arbeta' },
{ href: '/supplier-invoices', labelKey: 'supplier_invoices', icon: Wallet, group: 'arbeta' },
{ href: '/salary', labelKey: 'salary', icon: HandCoins, group: 'arbeta', employerOnly: true, betaBadge: true },
// Analys — read the numbers. KPI stays a separate row until the fused
// Rapporter surface (nav_ia_redesign §F) is built.
{ href: '/kpi', labelKey: 'kpi', icon: TrendingUp, group: 'analys' },
{ href: '/reports', labelKey: 'reports', icon: BarChart3, group: 'analys' },
// Data — master-data registers + data plumbing. Anställda is a register
// (you edit an employee rarely, you run payroll monthly), so it lives here
// while the Löner flow stays in Arbeta.
{ href: '/customers', labelKey: 'customers', icon: Users, group: 'data' },
{ href: '/suppliers', labelKey: 'suppliers', icon: Building2, group: 'data' },
{ href: '/articles', labelKey: 'articles', icon: Tag, group: 'data' },
{ href: '/salary/employees', labelKey: 'employees', icon: Users, group: 'data', employerOnly: true, betaBadge: true },
{ href: '/assets', labelKey: 'assets', icon: Package, group: 'data' },
{ href: '/chart-of-accounts', labelKey: 'chart_of_accounts', icon: ListTree, group: 'data' },
{ href: '/dimensions', labelKey: 'dimensions', icon: Tags, group: 'data', requiresDimensions: true },
{ href: '/import', labelKey: 'import', icon: Upload, group: 'data' },
// Skatt & bokslut — everything submitted to the state. Rescues the
// previously nav-orphaned /skattekonto and /deadlines, and promotes the
// VAT declaration out of the report catalog.
{ href: '/reports/vat-declaration', labelKey: 'vat_declaration', icon: Percent, group: 'skatt' },
{ href: '/skattekonto', labelKey: 'skattekonto', icon: Landmark, group: 'skatt' },
{ href: '/deadlines', labelKey: 'deadlines', icon: CalendarClock, group: 'skatt' },
{ href: '/bookkeeping/year-end', labelKey: 'year_end', icon: FileCheck, group: 'skatt' },
]
// Map known extension hrefs to nav translation keys so sidebar labels translate.
@@ -178,10 +203,10 @@ function extensionLabelKey(href: string): string | null {
}
const groupLabelKey: Record<Exclude<GroupKey, 'top'>, string> = {
försäljning: 'group_sales',
inköp: 'group_purchases',
redovisning: 'group_accounting',
personal: 'group_personnel',
arbeta: 'group_work',
analys: 'group_analysis',
data: 'group_data',
skatt: 'group_tax',
}
// Best single-character initial we can show in the bottom-left account
@@ -227,10 +252,10 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
// Personal hidden).
type ExpandableGroup = Exclude<GroupKey, 'top'>
const [manualCollapsed, setManualCollapsed] = useState<Record<ExpandableGroup, boolean>>({
försäljning: false,
inköp: false,
redovisning: false,
personal: false,
arbeta: false,
analys: false,
data: false,
skatt: false,
})
const toggleGroup = (g: ExpandableGroup) =>
setManualCollapsed((prev) => ({ ...prev, [g]: !prev[g] }))
@@ -257,6 +282,15 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
if (href === '/salary') {
return pathname === '/salary' || pathname.startsWith('/salary/runs')
}
// Bokslut (/bookkeeping/year-end) and Moms (/reports/vat-declaration)
// have their own rows under Skatt & bokslut — carve them out of their
// parent routes so exactly one row lights up.
if (href === '/bookkeeping') {
return pathname.startsWith('/bookkeeping') && !pathname.startsWith('/bookkeeping/year-end')
}
if (href === '/reports') {
return pathname.startsWith('/reports') && !pathname.startsWith('/reports/vat-declaration')
}
return pathname.startsWith(href)
}
@@ -385,10 +419,10 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
)
const sidebarGroups: { key: ExpandableGroup; items: NavItem[] }[] = [
{ key: 'försäljning', items: filteredItems.filter((i) => i.group === 'försäljning') },
{ key: 'inköp', items: filteredItems.filter((i) => i.group === 'inköp') },
{ key: 'redovisning', items: filteredItems.filter((i) => i.group === 'redovisning') },
{ key: 'personal', items: filteredItems.filter((i) => i.group === 'personal') },
{ key: 'arbeta', items: filteredItems.filter((i) => i.group === 'arbeta') },
{ key: 'analys', items: filteredItems.filter((i) => i.group === 'analys') },
{ key: 'data', items: filteredItems.filter((i) => i.group === 'data') },
{ key: 'skatt', items: filteredItems.filter((i) => i.group === 'skatt') },
]
// A group is expanded when the user hasn't manually collapsed it OR
@@ -430,17 +464,12 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
<CompanySwitcher />
</div>
<nav className="px-3" aria-label={tNav('main_navigation')}>
{/* Top section: flat, no header. Hem, Underlag, Transaktioner, Granskning. */}
{/* Top section: flat, no header. Hem, Assistent. */}
<div className="mb-4 space-y-px">
{topItems.map((item) => {
const active = isActive(item.href)
const enabled = isItemEnabled(item.href)
const badge =
item.href === '/transactions' && liveUncategorizedTransactionCount > 0
? liveUncategorizedTransactionCount
: item.href === '/pending' && pendingOperationsCount > 0
? pendingOperationsCount
: null
const badge: number | null = null
const decorBadge = renderBadge(item, 'sidebar')
const content = (
<>
@@ -487,7 +516,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
})}
</div>
{/* Collapsible groups: Försäljning, Inköp, Redovisning, Personal */}
{/* Collapsible groups: Arbeta, Analys, Data, Skatt & bokslut */}
{sidebarGroups
.filter(({ items }) => items.length > 0)
.map(({ key, items }) => {
@@ -512,6 +541,12 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
const Icon = item.icon
const active = isActive(item.href)
const enabled = isItemEnabled(item.href) && !item.comingSoon
const badge =
item.href === '/transactions' && liveUncategorizedTransactionCount > 0
? liveUncategorizedTransactionCount
: item.href === '/pending' && pendingOperationsCount > 0
? pendingOperationsCount
: null
const decorBadge = renderBadge(item, 'sidebar')
const content = (
<>
@@ -524,7 +559,11 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
)}
/>
<span className="flex-1">{tNav(item.labelKey)}</span>
{decorBadge}
{decorBadge ? decorBadge : badge !== null && (
<span className="ml-auto min-w-[18px] h-[18px] flex items-center justify-center rounded-full bg-primary/15 text-primary text-[10px] font-semibold px-1">
{badge > 99 ? '99+' : badge}
</span>
)}
</>
)
const baseClass = cn(
@@ -557,11 +596,11 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
</div>
)
})}
{/* Extension nav items land in Redovisning since the
current extensions (TIC workspace, etc.) are
accounting-adjacent. Future categorised extensions
can opt into a different group via their manifest. */}
{key === 'redovisning' &&
{/* Extension nav items land in Arbeta since extension
workspaces are work surfaces. Future categorised
extensions can opt into a different group via
their manifest. */}
{key === 'arbeta' &&
visibleExtensionNavItems.map((item) => {
const Icon = resolveIcon(item.icon)
const active = isActive(item.href)
@@ -795,17 +834,12 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
{/* Navigation */}
<div className="px-2">
{/* Top items (Hem, Underlag, Transaktioner, Granskning) */}
{/* Top items (Hem, Assistent) */}
<div className="space-y-0.5">
{topItems.map((item) => {
const active = isActive(item.href)
const enabled = isItemEnabled(item.href)
const badge =
item.href === '/transactions' && liveUncategorizedTransactionCount > 0
? liveUncategorizedTransactionCount
: item.href === '/pending' && pendingOperationsCount > 0
? pendingOperationsCount
: null
const badge: number | null = null
const decorBadge = renderBadge(item, 'mobile')
const content = (
<>
@@ -846,7 +880,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
})}
</div>
{/* AR / AP / Personal / Accounting groups (mobile) */}
{/* Arbeta / Analys / Data / Skatt & bokslut groups (mobile) */}
{sidebarGroups.filter(({ items }) => items.length > 0).map(({ key, items }) => (
<div key={key}>
<div className="flex items-center gap-3 my-1.5 px-3">
File diff suppressed because it is too large Load Diff