Add/ai native supp (#385)

* feat(branding): implement dynamic branding in service worker and reports

* feat(auth): enhance API key scopes and add bookkeeping write scope

- Updated transaction write scope description to include additional tools.
- Enhanced reports read scope description to reflect new functionality.
- Introduced bookkeeping write scope with relevant description.
- Updated SCOPE_GROUPS to include bookkeeping domain.
- Modified TOOL_SCOPE_MAP to include new bookkeeping operations.
- Updated validateApiKey function to return api_key_id and api_key_name for better actor attribution.

feat(tests): add unit tests for MCP resource registry

- Created tests for data resources to ensure all required fields are present.
- Added tests for resource query parsing and retrieval.

feat(resources): implement MCP resources for company and accounting data

- Added capabilities resource to expose API key capabilities based on granted scopes.
- Implemented chart of accounts resource to retrieve active BAS chart.
- Created company current resource to fetch active company details.
- Developed active fiscal period resource to check posting eligibility.
- Implemented recent activity resource to fetch latest journal entries, invoices, and transactions.
- Added VAT treatments resource to provide available VAT rates per customer type.

feat(pending-operations): introduce risk tiers for operations

- Added risk level classification for pending operations to determine auto-commit eligibility.
- Implemented functions to classify operation risk levels and identify high-risk operations.

feat(migrations): add actor model and risk tier to pending operations

- Updated pending_operations table to include actor type and risk level columns.
- Enhanced audit_log to mirror actor information for compliance.
- Modified validate_and_increment_api_key function to return actor details.
- Expanded operation types in pending_operations to include new high-risk operations.

* feat: add auto-commit functionality for low-risk pending operations

- Implemented shouldAutoCommit function to determine eligibility for auto-commit based on operation type, actor type, and company settings.
- Created commitPendingOperation function to handle execution of pending operations with consistent status updates.
- Added tests for shouldAutoCommit to cover various scenarios including high-risk operations, user actors, company opt-in status, and monetary thresholds.
- Introduced new columns in company_settings for agent_auto_commit_enabled and agent_auto_commit_max_amount to allow companies to opt-in for auto-commit functionality.
- Added SQL migration to update the database schema for new auto-commit settings.

* feat(idempotency): implement idempotency key handling for safe retries and cleanup

* feat: expand API key scopes and pending operations for bookkeeping

- Added 'suppliers:write' scope to API key scopes for supplier invoice management.
- Updated SCOPE_GROUPS to include the new 'suppliers:write' scope.
- Introduced new pending operation types for bookkeeping: close_period, lock_period, run_year_end, set_opening_balances, run_currency_revaluation, explain_voucher_gap, uncategorize_transaction, approve_supplier_invoice, credit_supplier_invoice, and convert_invoice.
- Implemented corresponding commit functions for the new operations in the pending operations module.
- Enhanced PendingOperation type to include actor model and risk level attributes.
- Added tests for new functionality, ensuring proper behavior and constraints in the database.

* feat: implement unlockPeriod functionality and related tests

* feat: add agent auto-commit settings and related functionality

* feat: add attention resource with comprehensive summary of outstanding tasks

* feat: enhance pending operations with 'committing' status and immutability checks, improve idempotency handling, and add original voucher reference for credit notes
This commit is contained in:
Mattsson
2026-05-04 11:12:29 +02:00
committed by GitHub
parent 5e1b0f791d
commit bb855d2ddc
46 changed files with 6507 additions and 971 deletions
+128 -4
View File
@@ -18,6 +18,8 @@ import {
Receipt,
CheckCircle2,
XCircle,
Bot,
Sparkles,
} from 'lucide-react'
import type { PendingOperation, PendingOperationStatus } from '@/types'
@@ -166,14 +168,21 @@ function OperationPreview({ op }: { op: PendingOperation }) {
}
}
type SourceFilter = 'all' | 'agent' | 'auto_committed' | 'high_risk'
const AUTO_COMMIT_BANNER_DISMISS_KEY = 'gnubok.pending.autoCommitBannerDismissedAt'
export default function PendingOperationsPage() {
const [operations, setOperations] = useState<PendingOperation[]>([])
const [isLoading, setIsLoading] = useState(true)
const [activeTab, setActiveTab] = useState<PendingOperationStatus>('pending')
const [sourceFilter, setSourceFilter] = useState<SourceFilter>('all')
const [expandedId, setExpandedId] = useState<string | null>(null)
const [selectedOp, setSelectedOp] = useState<PendingOperation | null>(null)
const [showCommitDialog, setShowCommitDialog] = useState(false)
const [isCommitting, setIsCommitting] = useState(false)
const [recentAutoCommits, setRecentAutoCommits] = useState<PendingOperation[]>([])
const [bannerDismissedAt, setBannerDismissedAt] = useState<number | null>(null)
const { toast } = useToast()
const { dialogProps, confirm } = useDestructiveConfirm()
@@ -193,6 +202,38 @@ export default function PendingOperationsPage() {
fetchOperations()
}, [fetchOperations])
useEffect(() => {
const stored = typeof window !== 'undefined'
? window.localStorage.getItem(AUTO_COMMIT_BANNER_DISMISS_KEY)
: null
setBannerDismissedAt(stored ? Number(stored) : null)
fetch('/api/pending-operations?status=committed&limit=50')
.then((r) => r.json())
.then((json) => {
const ops: PendingOperation[] = json.data ?? []
const cutoff = Date.now() - 24 * 60 * 60 * 1000
setRecentAutoCommits(
ops.filter((o) => o.auto_committed_at && new Date(o.auto_committed_at).getTime() > cutoff)
)
})
.catch(() => { /* silent */ })
}, [])
function dismissAutoCommitBanner() {
const now = Date.now()
setBannerDismissedAt(now)
if (typeof window !== 'undefined') {
window.localStorage.setItem(AUTO_COMMIT_BANNER_DISMISS_KEY, String(now))
}
}
const newAutoCommits = recentAutoCommits.filter((o) =>
bannerDismissedAt == null ||
(o.auto_committed_at != null &&
new Date(o.auto_committed_at).getTime() > bannerDismissedAt)
)
async function handleCommit() {
if (!selectedOp) return
setIsCommitting(true)
@@ -239,6 +280,20 @@ export default function PendingOperationsPage() {
create_invoice: '',
}
const filteredOperations = operations.filter((op) => {
switch (sourceFilter) {
case 'agent':
return op.actor_type === 'api_key' || op.actor_type === 'mcp_oauth' || op.actor_type === 'cron'
case 'auto_committed':
return Boolean(op.auto_committed_at)
case 'high_risk':
return op.risk_level === 'high'
case 'all':
default:
return true
}
})
return (
<div className="space-y-6">
<PageHeader
@@ -246,6 +301,49 @@ export default function PendingOperationsPage() {
description="Operationer från din AI-agent som väntar på godkännande"
/>
{newAutoCommits.length > 0 && (
<Card className="border-sage/40 bg-sage/5">
<CardContent className="flex items-start justify-between gap-4 py-3">
<div className="flex items-start gap-3">
<Sparkles className="h-4 w-4 mt-0.5 text-sage-foreground" />
<div className="text-sm">
<p className="font-medium">
{newAutoCommits.length === 1
? '1 åtgärd auto-godkändes'
: `${newAutoCommits.length} åtgärder auto-godkändes`}{' '}
senaste dygnet
</p>
<p className="text-xs text-muted-foreground mt-0.5">
Granska vad agenten utförde utan din direkta godkännande.
</p>
</div>
</div>
<div className="flex gap-2 flex-shrink-0">
<Button
size="sm"
variant="outline"
className="h-8 px-3 text-xs"
onClick={() => {
setActiveTab('committed')
setSourceFilter('auto_committed')
dismissAutoCommitBanner()
}}
>
Visa
</Button>
<Button
size="sm"
variant="ghost"
className="h-8 px-3 text-xs"
onClick={dismissAutoCommitBanner}
>
Dölj
</Button>
</div>
</CardContent>
</Card>
)}
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as PendingOperationStatus)}>
<TabsList>
<TabsTrigger value="pending">Väntande</TabsTrigger>
@@ -254,13 +352,22 @@ export default function PendingOperationsPage() {
</TabsList>
</Tabs>
<Tabs value={sourceFilter} onValueChange={(v) => setSourceFilter(v as SourceFilter)}>
<TabsList>
<TabsTrigger value="all">Alla</TabsTrigger>
<TabsTrigger value="agent">Från agent</TabsTrigger>
<TabsTrigger value="auto_committed">Auto-godkända</TabsTrigger>
<TabsTrigger value="high_risk">Hög risk</TabsTrigger>
</TabsList>
</Tabs>
{isLoading ? (
<Card>
<CardContent className="flex items-center justify-center py-16">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</CardContent>
</Card>
) : operations.length === 0 ? (
) : filteredOperations.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted mb-4">
@@ -282,7 +389,7 @@ export default function PendingOperationsPage() {
</Card>
) : (
<div className="space-y-2">
{operations.map((op) => {
{filteredOperations.map((op) => {
const config = operationLabels[op.operation_type] || { label: op.operation_type, icon: ClipboardCheck, variant: 'default' as const }
const isExpanded = expandedId === op.id
@@ -297,9 +404,26 @@ export default function PendingOperationsPage() {
onClick={() => setExpandedId(isExpanded ? null : op.id)}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<Badge variant={config.variant}>{config.label}</Badge>
{op.status === 'committed' && (
{op.risk_level === 'high' && (
<Badge variant="outline" className="border-terracotta/40 text-terracotta">
Hög risk
</Badge>
)}
{op.actor_type && op.actor_type !== 'user' && (
<Badge variant="outline" className="text-xs">
<Bot className="h-3 w-3 mr-1" />
{op.actor_label || op.actor_type}
</Badge>
)}
{op.auto_committed_at && (
<Badge variant="outline" className="bg-sage/10 border-sage/40 text-sage-foreground">
<Sparkles className="h-3 w-3 mr-1" />
Auto-godkänd
</Badge>
)}
{op.status === 'committed' && !op.auto_committed_at && (
<Badge variant="default" className="bg-emerald-500/10 text-emerald-600 border-emerald-200">
<CheckCircle2 className="h-3 w-3 mr-1" />
Godkänd
+7 -1
View File
@@ -1,7 +1,13 @@
'use client'
import { ApiKeysPanel } from '@/components/settings/ApiKeysPanel'
import { AgentAutoCommitSettings } from '@/components/settings/AgentAutoCommitSettings'
export default function ApiSettingsPage() {
return <ApiKeysPanel />
return (
<div className="space-y-8">
<AgentAutoCommitSettings />
<ApiKeysPanel />
</div>
)
}