feat: event log, pending operations, and MCP staging (#135)

* feat: event log, pending operations, and MCP staging

- Event log system: persist bus events to event_log table for external
  automation platforms. Batch insert for transaction.synced. Daily
  cleanup cron at 02:00 UTC.
- Pending operations: MCP write tools (categorize, create customer,
  create invoice) now stage to pending_operations instead of executing
  directly. Users review and commit/reject from /pending in the web UI.
- Granskning page: card-based review UI with expandable previews,
  commit/reject dialogs. Only shown in nav when pending ops exist.
- Commit route re-executes using core lib functions (no extension
  imports). Guards against stale state (double-commit, deleted entities).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: stage new MCP write tools after main merge

Add staging for 4 new write tools from #133:
- mark_invoice_paid, send_invoice, mark_invoice_sent,
  match_transaction_invoice
- Expand pending_operations CHECK constraint
- Add commit executors with full execution logic
- Add UI labels and generic preview component
- Remove confirm parameter from categorize (single-call staging)
- Fix UUID in pending op title (fetch transaction description)
- Hide Granskning nav when no pending ops

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address PR review feedback

- Fix TS build error: use `select('*, customer:customers(*)')` for
  match_transaction_invoice to avoid array type inference
- Add status guard to commitSendInvoice (prevents duplicate sends)
- Replace auth.admin.getUserById with user email from session auth
- Restore optimistic lock check in commitMatchTransactionInvoice
- Fix tool description typo: expense_software → expense_office

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-03-26 10:07:36 +01:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 4d2152946a
commit f3ae3cd361
23 changed files with 2744 additions and 465 deletions
+18 -7
View File
@@ -25,6 +25,7 @@ import {
FileInput,
Wallet,
TrendingUp,
ClipboardCheck,
} from 'lucide-react'
import { resolveIcon } from '@/lib/extensions/icon-resolver'
import type { EntityType } from '@/types'
@@ -39,6 +40,7 @@ interface DashboardNavProps {
companyName: string
entityType: EntityType
uncategorizedTransactionCount?: number
pendingOperationsCount?: number
isSandbox?: boolean
extensionNavItems?: ExtensionNavItem[]
}
@@ -66,6 +68,7 @@ const navItems: NavItem[] = [
{ href: '/suppliers', label: 'Leverantörer', icon: Building2, group: 'inköp', hidden: true },
{ href: '/supplier-invoices', label: 'Leverantörsfakturor', icon: FileInput, group: 'inköp', hidden: true },
// General accounting
{ href: '/pending', label: 'Granskning', icon: ClipboardCheck, group: 'redovisning' },
{ href: '/transactions', label: 'Transaktioner', icon: ArrowLeftRight, group: 'redovisning' },
{ href: '/bookkeeping', label: 'Bokföring', icon: BookOpen, group: 'redovisning' },
{ href: '/reports', label: 'Rapporter', icon: BarChart3, group: 'redovisning' },
@@ -82,7 +85,7 @@ const groupLabels: Record<string, string> = {
övrigt: 'Övrigt',
}
export default function DashboardNav({ companyName, entityType, uncategorizedTransactionCount = 0, isSandbox = false, extensionNavItems = [] }: DashboardNavProps) {
export default function DashboardNav({ companyName, entityType, uncategorizedTransactionCount = 0, pendingOperationsCount = 0, isSandbox = false, extensionNavItems = [] }: DashboardNavProps) {
const pathname = usePathname()
const router = useRouter()
const supabase = createClient()
@@ -123,10 +126,14 @@ export default function DashboardNav({ companyName, entityType, uncategorizedTra
}, 200)
}
// Filter nav items by entity type and hidden flag
const filteredItems = navItems.filter(item =>
!item.hidden && (!item.modes || item.modes.includes(entityType))
)
// Filter nav items by entity type, hidden flag, and conditional visibility
const filteredItems = navItems.filter(item => {
if (item.hidden) return false
if (item.modes && !item.modes.includes(entityType)) return false
// Only show Granskning when there are pending operations
if (item.href === '/pending' && pendingOperationsCount === 0) return false
return true
})
const mainItems = filteredItems.filter(i => i.group === 'main')
const övrigtItems = filteredItems.filter(i => i.group === 'övrigt')
@@ -202,7 +209,9 @@ export default function DashboardNav({ companyName, entityType, uncategorizedTra
const active = isActive(item.href)
const badge = item.href === '/transactions' && uncategorizedTransactionCount > 0
? uncategorizedTransactionCount
: null
: item.href === '/pending' && pendingOperationsCount > 0
? pendingOperationsCount
: null
return (
<Link
key={item.href}
@@ -442,7 +451,9 @@ export default function DashboardNav({ companyName, entityType, uncategorizedTra
const active = isActive(item.href)
const badge = item.href === '/transactions' && uncategorizedTransactionCount > 0
? uncategorizedTransactionCount
: null
: item.href === '/pending' && pendingOperationsCount > 0
? pendingOperationsCount
: null
return (
<Link
key={item.href}