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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user