feat(agent): 'Vad din agent vet' page rendering the ledger context (P2) (#935)
* feat(agent): 'Vad din agent vet' page rendering the ledger context (P2) The human-facing surface for the openwiki ledger-context: a read-only page that renders the exact payload the AI agent reads (Accounted://ledger/context + the briefing digest) as a legible profile of how this company books. - Route app/(dashboard)/agent-knowledge (server component) calls the shared buildLedgerContext(supabase, companyId) directly: one payload, two renderers, no new API or data path. - Sections mirror the payload 1:1: coverage/freshness strip, counterparty patterns (monochrome confidence bars + seen/agree evidence), supplier patterns, explicit rules shown as authoritative instructions distinct from observed patterns, account usage, VAT profile, conventions. - Nav entry in the Analys group (icon Brain), ungated so it doubles as an upsell; flip requiredCapability to paywall. - Design per .claude/rules/design.md (PageHeader, Card, Table, Badge, AccountNumber BAS tooltips); sv + en strings (agentKnowledge namespace). VAT/BAS labels stay Swedish in both locales per i18n rules. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(agent): deep entity-resolved analysis + radial graph for the knowledge page Reworks the 'Vad din agent vet' page from tables into a radial-hub graph driven by a new full-history deep analysis, per founder feedback. - fix(rpc): median_booking_lag_days now measures real posting promptness via committed_at, not entry_date (which the bank flow sets to the transaction date, giving a ~0 tautology: 151/152 on prod). migration 20260708120000. - feat(rpc): get_ledger_deep_context (migration 20260708130000): full-history, deterministic, read-side. Merges counterparties by normalize_counterparty_key (e.g. Claude = 14 bookings across 12 name variants, weekly, 9 710 kr, always 5420), mines booked verifikat for SEK spend (coalesce amount_sek), detects recurrence cadence, dominant account + share, plus supplier entities. Storno excluded, corrections kept; 19xx/26xx excluded from the dominant contra. - LedgerGraph: radial SVG (company center, accounts inner ring, payees outer ring), hover/focus reveals variants + spend + cadence + account. Keyboard focusable nodes with per-node accessible names + a screen-reader data table. - Page fetches the deep context alongside the light context; coverage strip gains tracked-payee / recurring / tracked-spend stats. sv + en strings. - 14 pg tests (light + deep) green; both RPCs applied to prod + version-matched. Reviewed by an adversarial multi-lens pass (accounting/SQL, frontend/a11y, prod-fact verification); all four verified findings fixed (SEK currency, storno-lag guard, keyboard a11y, spacing tokens). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(agent): gentle mount fade-in for the radial map (reduced-motion safe) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(agent): render the page for a rules-only company (empty-state edge case) isEmpty ignored explicit_rules, so a company with configured mapping rules but no posted transactions hit the 'hasn't learned anything' empty state and lost its rules section. Rules are independent of bookings. (CodeRabbit) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(agent): show Kompetens (skills) + Fakta (memory) on the knowledge page The 'Vad din agent vet' page now shows the full picture of what the agent knows: alongside the booking map, a compact read-only view of its Kompetens (the Swedish accounting/tax knowledge atoms it ships with, grouped by tier as chips with active/dormant state) and the Fakta it remembers (top learned facts with kind + source), each linking to /settings/assistant for full management. Server-rendered via a new buildAgentCompetence() that mirrors GET /api/agent/skills + /api/agent/memory. Also renders in the no-bookings case so a new company still sees its agent's competence. sv + en strings. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(agent): restructure knowledge page - hero graph + tabbed detail Declutters the page per feedback: the booking map is the always-visible hero, and the supporting detail (Kompetens · Minne · Regler & profil) moves into tabs so only one view shows at a time instead of a long card stack. Split AgentCompetenceSections into standalone CompetenceCard + FactsCard for the tabs; removed the top stat row on request. sv + en. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(agent): Reconciliation Aurora rewrite of the ledger knowledge graph Full rewrite of LedgerGraph: node area = sqrt(spend), colour = cadence, shape = supplier/counterparty, confidence = depth-of-field; on-mount descriptor-collapse animation with xN badge; cadence pulse veins; deterministic seeded layout; framer-motion only (no new deps); keyboard navigation, reduced-motion and sr-only support. Build-verified; 3-lens adversarial review findings fixed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(agent): sample-size-honest confidence in the ledger knowledge graph dominant_account_share was raw cnt/total, so a counterparty with a single booking rendered as '100% säkerhet': fake certainty by construction (the data_quality_master Item-C / P3 finding). New migration replaces get_ledger_deep_context with a Laplace-smoothed share (cnt+1)/(total+2) (1/1 -> 0.67, 3/3 -> 0.80) and exposes the raw evidence as dominant_account_count / dominant_account_total. The detail card now shows 'Bokförd hit i k av n fall' under the confidence bar; the existing focus buckets, stroke widths and percent labels inherit the honest value unchanged. pg-real test updated to guard the n=1 case. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -46,9 +46,12 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-07-07] Reconciled ledger-context prereqs INTO dev_docs/bank_transaction_ai_normalization.md (§14): plan is the strategic superset; ledger-context RPC gets interim normalizeCounterpartyName() now, re-keys to entity_id at Phase 2/Layer F. Closed 4 gaps: RPC in Layer F substrate list, supplier-side digest patterns, storno/correction exclusion (§13+§14), pending_operations audit+FK for agent-suggestion attribution.
|
||||
[2026-07-08] Ledger-context prereq trifecta folded into the P1 branch pre-merge (normalize_counterparty_key SQL mirror of normalizeCounterpartyName + supplier_patterns CTE + storno filter + evidence{seen,agree,share,last_booked} format) instead of follow-up PRs: shipping first then fixing would break the payload shape consumers had just learned. Storno filter deliberately asymmetric: account_usage excludes source_type='storno' only; counterparty CTE has NO source_type filter because correctEntry() relinks transactions.journal_entry_id to the correction (the join self-heals) and excluding 'correction' would drop exactly the human-corrected booking. Faithful-mirror discipline: bare "KORT " prefix is NOT stripped (TS doesn't either); hardening the prefix list must change the TS+SQL pair together (pg test pins this). Payload caps trimmed 20/20 -> 15/15 + supplier 10 to hold the 12 KB budget with evidence objects.
|
||||
[2026-07-08] Ledger-context dominant-contra VAT bug, found by the switch-on check (calling gnubok_get_agent_briefing on real prod data, not synthetic tests): counterparty patterns for foreign SaaS (Google/ngrok/Supabase) showed dominant_account 2614 (reverse-charge output VAT) instead of 5420 (software expense). Cause: the dominant_account CTE excluded only 19xx, so on a reverse-charge booking (expense + 2645 + 2614 + 1930) the three non-bank accounts tie and the account_number ASC tiebreak picks the low VAT number 2614. Fix (migration 20260708110000): also exclude 26xx (always moms in BAS, never characterizes a counterparty); 23xx/24xx/25xx/27xx stay eligible so loan/tax counterparties (e.g. ALMI) still surface their real account. supplier_patterns unaffected (aggregates supplier_invoice_items.account_number = expense only). Regression pg test asserts 5420 over 2614; verified it fails on the old function.
|
||||
[2026-07-08] Built the P2 "Vad din agent vet" page (/agent-knowledge, Analys nav group, icon Brain): the human render of the same ledger-context payload the AI agent reads. RSC calling buildLedgerContext(supabase, companyId) directly (one payload, two renderers, no new API/data path), sections mirror the payload 1:1 (coverage strip, counterparty patterns with monochrome confidence bars + seen/agree evidence, supplier patterns, explicit rules distinguished as instructions, account usage, VAT profile, conventions). Chose a STANDALONE page over a 3rd tab in /settings/assistant: faithful to the plan's page spec + its sales-demo intent (Fortnox shows your data, this shows your patterns). UNGATED (shows the user their own derived booking stats, no live AI compute, doubles as an upsell) rather than CAPABILITY.ai-gated: set requiredCapability on the nav item to paywall. VAT treatment labels + BAS account names stay Swedish in both locales (regulatory domain terms, per .claude/rules/i18n.md). Verified: build compiles the route (client/server boundary resolved), lint+tsc clean on new files, i18n sv/en parity (54 keys). Data correctness already proven live on prod via the briefing + RPC.
|
||||
[2026-07-08] Deep entity-resolved ledger analysis + "Vad din agent vet" graph. (1) Fixed median_booking_lag_days: was median(entry_date - transaction date) = ~0 tautology (bank flow dates the voucher to the transaction; verified 151/152 entries entry_date==txn date on prod); switched to committed_at (real posting lag, median 90 for Arcim). migration 20260708120000, applied to prod. (2) New get_ledger_deep_context RPC (migration 20260708130000): full-history, deterministic, read-side deep analysis merging counterparties by normalize_counterparty_key (delivers "Claude = 14 bookings across 12 name variants, weekly, 9710 kr, always 5420"), mining booked verifikat for spend (sum abs bank amount), recurrence cadence (median gap of distinct dates), dominant account (19xx+26xx excluded) + share, supplier entities from supplier_invoices. Chose read-side compute-on-read (no new tables/backfill job/embeddings) as the pragmatic v1 over the full persistent counterparty_entity substrate (bank_transaction_ai_normalization.md) which stays deferred: name-key merge alone delivers the wow; cross-name merges needing the bank-account hard key or fuzzy/embeddings are the deferred layer. (3) Page reworked from tables to a radial-hub SVG graph (company center, accounts inner ring, payees outer ring, hover reveals variants/spend/cadence/account+share). Both RPCs applied to prod + version-matched; light get_ledger_usage_stats stays the agent digest, deep RPC feeds the page. Then restructured the page (hero graph + Kompetens/Minne/Regler tabs) and added a read-only Kompetens (atoms) + Fakta (memory) overview mirroring /settings/assistant.
|
||||
[2026-07-08] Bedrock prod outage + Docker build failure both root-caused to dependabot #884 (a1fad319, 2026-07-06) bumping @anthropic-ai/bedrock-sdk 0.29.1->0.32.0. Runtime: 0.32.0 streaming returns an empty event stream ("request ended without sending any chunks", no HTTP status) - proven NOT a creds/region issue (prod diagnostic logged AKIA key + eu-west-1). Two prior sessions mis-diagnosed it as an AWS_* env collision and shipped/reverted #937 (BEDROCK_AWS_* rename) with no effect. "Works locally, fails on prod/CI" because local node_modules was stale at 0.29.1 while prod/Docker build fresh from the lockfile (0.32.0). Fix: pin back to ^0.29.1 + regenerate lockfile. FOLLOW-UP: add a dependabot ignore/exact-pin so it does not re-bump to 0.32.x and re-break both.
|
||||
[2026-07-08] One reconciliation PR adopts 3 prod-orphaned migrations (20260707113729 enrichment + 20260708120000/130000 ledger-stats RPCs) plus their pg-tests/fixtures onto main, instead of waiting on #927+#935 to merge: prod ledger was 3 versions ahead of the repo, leaving the default Supabase branch MIGRATIONS_FAILED and blocking every preview branch from being created. SQL committed byte-identical under the exact apply-time versions -> no-op on prod (idempotent), clean on fresh replays, and a no-op on #927/#935's next rebase. Carries #935's DB layer only (migrations + pg-tests + fixtures), not its UI/lib/i18n. Root anti-pattern: all three applied to prod via MCP apply_migration without committing the file (CLAUDE.md "never leave the remote DB ahead of the repo").
|
||||
[2026-07-08] Pinned @anthropic-ai/bedrock-sdk to exact 0.29.1 (dependabot #884 auto-bumped it to 0.32.0, which broke Bedrock streaming in prod: empty stream / "request ended without sending any chunks"). Guarded three ways against accidental re-bump: exact pin in package.json, dependabot ignore, and a pinned-dep check in scripts/checks/no-new-antipatterns.mjs (check:guards). Unpin only once 0.32.x streaming is verified against Bedrock.
|
||||
[2026-07-09] "Vad din agent vet" hero reworked from the flat radial-hub map into "Reconciliation Aurora" (LedgerGraph.tsx), a cinematic dark SVG panel, on the founder's explicit "maximum wow" directive (a design judge-panel of 4 divergent concepts picked it over circle-packing / flow-pulses / living-constellation). Encodings: node area = √spend, colour = cadence (weekly/monthly/irregular, the one semantic axis), shape = supplier vs counterparty, confidence = optical depth-of-field (crisp = sure, blurred = unsure). Signature on-mount act: the top ~5 name-merged payees' raw bank descriptors scatter in and collapse into one node with a counting-up ×N badge. Two deliberate calls: (a) a self-contained ALWAYS-DARK panel that bends design.md's flat/achromatic-surface rule (justified: it is a demo hero, self-contained, premium-not-neon, and DOF/glow only read on dark); (b) ZERO new deps: rejected d3-hierarchy (circle-pack) and d3-force (constellation) because a fixed 3-level tree is ~40 lines of deterministic polar trig and force physics would reshuffle the layout every load, breaking the repeatable live demo; framer-motion@12 (already installed) drives the entrance/merge, CSS keyframes drive idle pulse/breathe (single clock, no 40 JS loops), and confidence buckets into 3-4 shared SVG blur filters attached only after each node's entrance spring settles (avoids per-frame feGaussianBlur re-raster). Adversarial 3-lens review run; 3 low findings fixed (StrictMode CountUp freeze, filter-on-animating-subtree, center-out inverse permutation).
|
||||
[2026-07-09] Invoice online payment ships as a manual paste-link MVP (invoices.payment_link_url + email button + PDF QR/link) instead of a full Stripe Connect integration: a day of work as a demand probe vs a week for Connect (OAuth onboarding, pay page, webhook auto-booking to 1686). Same column/UI is the upgrade path: Connect would auto-fill payment_link_url later, so nothing is throwaway. Field is PSP-agnostic ("Betalningslänk", any https URL) since the effort is identical and it also covers PayPal/Zettle. Derived documents (credit note, proforma convert, recurring) deliberately do NOT copy the link: a pasted link encodes one amount for one invoice. MCP tools/list token ceiling bumped 45K->45.5K (headroom was <10 tokens; ledger entry in payload-size.bench.test.ts).
|
||||
[2026-07-09] Issue #916 (disconnect orphans ledger accounts): release claims by demoting cash_accounts rows to manual (bank_connection_id = null), never deleting: transactions.cash_account_id and ledger history reference the rows, and upsertFromPsd2 promotes a manual holder in place on reconnect so the bank lands back on its original BAS slot. Orphans predating the fix self-heal via a revoked-status filter in the allocator + collision guard (not data repair). When a promote collides with a duplicate row for the same connection+uid (callback mirrored onto an overflow slot pre-fix), the duplicate is deleted only if it has zero linked transactions, otherwise demoted: preserves FK links while freeing the slot. Picker-save rejections now render inline in the picker instead of routing to the sync-progress modal, whose parent-unmount-on-close made every save outcome invisible.
|
||||
[2026-07-09] #917 fix scoped to the current-year suggestion: "Sedan räkenskapsårets början" now resolves from the fiscal_periods row containing today, but the "Föregående räkenskapsårets start" custom option still derives from the recurring fiscal_year_start_month: the issue only covers the current-year date and a first-year company has no previous period row to resolve against.
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getTranslations } from 'next-intl/server'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { getActiveCompanyId, getCompanyDisplayName } from '@/lib/company/context'
|
||||
import { buildLedgerContext } from '@/lib/agent-context/ledger-context'
|
||||
import { buildDeepEntities } from '@/lib/agent-context/ledger-deep'
|
||||
import { buildAgentCompetence } from '@/lib/agent-context/agent-competence'
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
import { AgentKnowledgeView } from '@/components/agent-knowledge/AgentKnowledgeView'
|
||||
|
||||
// Derived per request from live bookings; never cache a stale profile.
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/**
|
||||
* "Vad din agent vet" (P2): the human-facing render of the same ledger-context
|
||||
* the AI agent reads before booking. Fetches server-side via the shared
|
||||
* lib/agent-context aggregation (one payload, two renderers) and shows it as a
|
||||
* readable profile of how this company books. Read-only, no interactive
|
||||
* controls, so a plain Server Component.
|
||||
*/
|
||||
export default async function AgentKnowledgePage() {
|
||||
const supabase = await createClient()
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
if (!user) redirect('/login')
|
||||
|
||||
const companyId = await getActiveCompanyId(supabase, user.id)
|
||||
if (!companyId) redirect('/onboarding')
|
||||
|
||||
const [t, context, deep, competence, companyName] = await Promise.all([
|
||||
getTranslations('agentKnowledge'),
|
||||
buildLedgerContext(supabase, companyId),
|
||||
buildDeepEntities(supabase, companyId),
|
||||
buildAgentCompetence(supabase, companyId),
|
||||
getCompanyDisplayName(supabase, companyId),
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<PageHeader title={t('title')} description={t('description')} />
|
||||
<AgentKnowledgeView
|
||||
context={context}
|
||||
deep={deep}
|
||||
competence={competence}
|
||||
companyName={companyName ?? ''}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { getTranslations } from 'next-intl/server'
|
||||
import Link from 'next/link'
|
||||
import { Pin, ArrowUpRight } from 'lucide-react'
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import type { AgentCompetence, AtomTier, FactKind, FactSource } from '@/lib/agent-context/agent-competence'
|
||||
|
||||
/**
|
||||
* Read-only views of the agent's competence (domain-knowledge atoms) and top
|
||||
* learned facts, for the "Vad din agent vet" overview. Each is a standalone
|
||||
* Card so it can sit in its own tab. Full editable management lives in
|
||||
* /settings/assistant; each links there.
|
||||
*/
|
||||
|
||||
const TIER_ORDER: AtomTier[] = ['horizontal', 'vertical', 'modifier']
|
||||
|
||||
export async function CompetenceCard({ competence }: { competence: AgentCompetence }) {
|
||||
const t = await getTranslations('agentKnowledge')
|
||||
const { atoms } = competence
|
||||
const activeAtoms = atoms.filter((a) => a.active).length
|
||||
const tierLabel = (tier: AtomTier) =>
|
||||
tier === 'horizontal' ? t('tier_horizontal') : tier === 'vertical' ? t('tier_vertical') : t('tier_modifier')
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t('comp_title')}</CardTitle>
|
||||
<CardDescription>{t('comp_desc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6 pt-0">
|
||||
{atoms.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t('comp_empty')}</p>
|
||||
) : (
|
||||
<>
|
||||
{TIER_ORDER.map((tier) => {
|
||||
const items = atoms.filter((a) => a.tier === tier)
|
||||
if (items.length === 0) return null
|
||||
return (
|
||||
<div key={tier} className="space-y-2">
|
||||
<h3 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{tierLabel(tier)}
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{items.map((a) => (
|
||||
<Badge
|
||||
key={a.id}
|
||||
variant={a.active ? 'secondary' : 'outline'}
|
||||
className={a.active ? '' : 'text-muted-foreground'}
|
||||
title={a.description}
|
||||
>
|
||||
{a.title}
|
||||
{!a.active && tier !== 'horizontal' && (
|
||||
<span className="ml-1.5 opacity-70">· {t('badge_dormant')}</span>
|
||||
)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="flex items-center justify-between pt-1 text-xs text-muted-foreground">
|
||||
<span className="tabular-nums">{t('comp_count', { total: atoms.length, active: activeAtoms })}</span>
|
||||
<ManageLink href="/settings/assistant?view=skills" label={t('comp_manage')} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export async function FactsCard({ competence }: { competence: AgentCompetence }) {
|
||||
const t = await getTranslations('agentKnowledge')
|
||||
const { facts, factsActiveTotal } = competence
|
||||
const kindLabel = (k: FactKind) =>
|
||||
k === 'fact' ? t('kind_fact') : k === 'preference' ? t('kind_preference') : k === 'pattern' ? t('kind_pattern') : t('kind_correction')
|
||||
const sourceLabel = (s: FactSource) =>
|
||||
s === 'composer' ? t('source_composer') : s === 'user_taught' ? t('source_user_taught') : s === 'agent_learned' ? t('source_agent_learned') : t('source_derived')
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t('facts_title')}</CardTitle>
|
||||
<CardDescription>{t('facts_desc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 pt-0">
|
||||
{facts.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t('facts_empty')}</p>
|
||||
) : (
|
||||
<>
|
||||
<ul className="space-y-3">
|
||||
{facts.map((f) => (
|
||||
<li key={f.id} className="flex items-start gap-2">
|
||||
{f.is_pinned ? (
|
||||
<Pin className="mt-1 h-3.5 w-3.5 shrink-0 fill-current text-muted-foreground" aria-label={t('facts_pinned')} />
|
||||
) : (
|
||||
<span className="mt-1 h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||
)}
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<p className="text-sm text-foreground">{f.content}</p>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{kindLabel(f.kind)} · {sourceLabel(f.source)}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex items-center justify-between pt-1 text-xs text-muted-foreground">
|
||||
<span className="tabular-nums">
|
||||
{factsActiveTotal > facts.length ? t('facts_more', { n: factsActiveTotal - facts.length }) : ''}
|
||||
</span>
|
||||
<ManageLink href="/settings/assistant" label={t('facts_manage')} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function ManageLink({ href, label }: { href: string; label: string }) {
|
||||
return (
|
||||
<Link href={href} className="inline-flex items-center gap-1 text-foreground underline underline-offset-2 hover:text-muted-foreground">
|
||||
{label}
|
||||
<ArrowUpRight className="h-3 w-3" />
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import { getTranslations } from 'next-intl/server'
|
||||
import { Brain } from 'lucide-react'
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from '@/components/ui/table'
|
||||
import { AccountNumber } from '@/components/ui/account-number'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { formatDateLong } from '@/lib/utils'
|
||||
import type { LedgerContext } from '@/lib/agent-context/ledger-context'
|
||||
import type { DeepLedgerContext } from '@/lib/agent-context/ledger-deep'
|
||||
import type { AgentCompetence } from '@/lib/agent-context/agent-competence'
|
||||
import { LedgerGraph } from './LedgerGraph'
|
||||
import { CompetenceCard, FactsCard } from './AgentCompetenceSections'
|
||||
import { KnowledgeTabs } from './KnowledgeTabs'
|
||||
|
||||
// Swedish VAT (moms) treatment codes stay Swedish in both locales, like BAS
|
||||
// account names and momsdeklaration labels (.claude/rules/i18n.md).
|
||||
const VAT_LABELS: Record<string, string> = {
|
||||
standard_25: 'Moms 25%',
|
||||
standard_12: 'Moms 12%',
|
||||
standard_6: 'Moms 6%',
|
||||
reduced_12: 'Moms 12%',
|
||||
reduced_6: 'Moms 6%',
|
||||
reverse_charge: 'Omvänd moms',
|
||||
reverse_charge_eu: 'Omvänd moms (EU)',
|
||||
reverse_charge_services: 'Omvänd moms (tjänster)',
|
||||
eu_reverse_charge_services: 'Omvänd moms (EU-tjänster)',
|
||||
eu_goods: 'EU-varor',
|
||||
export: 'Export',
|
||||
exempt: 'Momsfri',
|
||||
no_vat: 'Ingen moms',
|
||||
}
|
||||
|
||||
function vatLabel(code: string | null): string | null {
|
||||
if (!code) return null
|
||||
return VAT_LABELS[code] ?? code
|
||||
}
|
||||
|
||||
export async function AgentKnowledgeView({
|
||||
context,
|
||||
deep,
|
||||
competence,
|
||||
companyName,
|
||||
}: {
|
||||
context: LedgerContext
|
||||
deep: DeepLedgerContext
|
||||
competence: AgentCompetence
|
||||
companyName: string
|
||||
}) {
|
||||
const t = await getTranslations('agentKnowledge')
|
||||
|
||||
const { meta, explicit_rules, vat_profile, conventions } = context
|
||||
|
||||
const entities = [...deep.counterparty_entities, ...deep.supplier_entities]
|
||||
|
||||
const isEmpty = meta.coverage.posted_entries_window === 0 && entities.length === 0 && explicit_rules.length === 0
|
||||
|
||||
if (isEmpty) {
|
||||
// No bookings yet, but the agent still ships with competence and may
|
||||
// already remember facts: show those rather than a dead end.
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<EmptyState
|
||||
icon={Brain}
|
||||
title={t('empty_title')}
|
||||
description={t('empty_description')}
|
||||
actionLabel={t('empty_action')}
|
||||
actionHref="/transactions"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<CompetenceCard competence={competence} />
|
||||
<FactsCard competence={competence} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const methodLabel =
|
||||
conventions.accounting_method === 'accrual'
|
||||
? t('method_accrual')
|
||||
: conventions.accounting_method === 'cash'
|
||||
? t('method_cash')
|
||||
: t('method_unknown')
|
||||
|
||||
const periodLabel =
|
||||
vat_profile.moms_period === 'monthly'
|
||||
? t('period_monthly')
|
||||
: vat_profile.moms_period === 'quarterly'
|
||||
? t('period_quarterly')
|
||||
: vat_profile.moms_period === 'yearly'
|
||||
? t('period_yearly')
|
||||
: (vat_profile.moms_period ?? t('unknown'))
|
||||
|
||||
// "Regler & profil" tab: user-authored rules + observed VAT + conventions.
|
||||
const configContent = (
|
||||
<>
|
||||
{explicit_rules.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t('rules_title')}</CardTitle>
|
||||
<CardDescription>{t('rules_description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('col_rule')}</TableHead>
|
||||
<TableHead>{t('col_match')}</TableHead>
|
||||
<TableHead>{t('col_account')}</TableHead>
|
||||
<TableHead>{t('col_vat')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{explicit_rules.map((r, i) => (
|
||||
<TableRow key={r.rule_name + r.match + i}>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{r.rule_name}</span>
|
||||
<Badge variant="default">{t('src_rule')}</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">{r.match}</TableCell>
|
||||
<TableCell>
|
||||
{r.account_number
|
||||
? <AccountNumber number={r.account_number} showName size="sm" />
|
||||
: <span className="text-muted-foreground">-</span>}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{vatLabel(r.vat_treatment) ?? '-'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t('vat_title')}</CardTitle>
|
||||
<CardDescription>{t('vat_description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 pt-0">
|
||||
<Row label={t('vat_registered_label')}>
|
||||
<Badge variant={vat_profile.registered ? 'success' : 'outline'}>
|
||||
{vat_profile.registered ? t('yes') : t('no')}
|
||||
</Badge>
|
||||
</Row>
|
||||
<Row label={t('vat_period_label')}>
|
||||
<span className="text-sm">{periodLabel}</span>
|
||||
</Row>
|
||||
<Row label={t('vat_treatments_label')}>
|
||||
{vat_profile.treatments_used_12m.length === 0 ? (
|
||||
<span className="text-sm text-muted-foreground">{t('vat_no_treatments')}</span>
|
||||
) : (
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
{vat_profile.treatments_used_12m.map((code) => (
|
||||
<Badge key={code} variant="outline">{vatLabel(code)}</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Row>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t('conv_title')}</CardTitle>
|
||||
<CardDescription>{t('conv_description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 pt-0">
|
||||
<Row label={t('conv_method_label')}>
|
||||
<span className="text-sm">{methodLabel}</span>
|
||||
</Row>
|
||||
<Row label={t('conv_series_label')}>
|
||||
{conventions.voucher_series_in_use.length === 0 ? (
|
||||
<span className="text-sm text-muted-foreground">-</span>
|
||||
) : (
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
{conventions.voucher_series_in_use.map((s) => (
|
||||
<Badge key={s} variant="secondary" className="font-mono">{s}</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Row>
|
||||
<Row label={t('conv_salary_label')}>
|
||||
<Badge variant={conventions.salary_run_active ? 'success' : 'outline'}>
|
||||
{conventions.salary_run_active ? t('yes') : t('no')}
|
||||
</Badge>
|
||||
</Row>
|
||||
{conventions.typical_booking_lag_days !== null && (
|
||||
<Row label={t('conv_lag_label')}>
|
||||
<span className="text-sm tabular-nums">{t('meta_lag_value', { days: conventions.typical_booking_lag_days })}</span>
|
||||
</Row>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
const tabs = [
|
||||
{ value: 'competence', label: t('tab_competence'), content: <CompetenceCard competence={competence} /> },
|
||||
{ value: 'memory', label: t('tab_memory'), content: <FactsCard competence={competence} /> },
|
||||
{ value: 'config', label: t('tab_config'), content: configContent },
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* The cinematic hero: a self-contained dark panel with its own header */}
|
||||
<LedgerGraph deep={deep} companyName={companyName} />
|
||||
|
||||
{/* Supporting detail, tabbed so it doesn't stack into a long scroll */}
|
||||
<KnowledgeTabs tabs={tabs} />
|
||||
|
||||
<p className="text-right text-xs text-muted-foreground">
|
||||
{t('footer_basis', { entries: meta.coverage.posted_entries_window, date: formatDateLong(meta.computed_at) })}
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm text-muted-foreground">{label}</span>
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
|
||||
/**
|
||||
* Client wrapper that organizes the page's supporting detail into tabs so the
|
||||
* booking map stays the hero and the rest doesn't stack into a long scroll.
|
||||
* Content is server-rendered upstream and handed in as ReactNode slots.
|
||||
*/
|
||||
export function KnowledgeTabs({
|
||||
tabs,
|
||||
}: {
|
||||
tabs: { value: string; label: string; content: ReactNode }[]
|
||||
}) {
|
||||
if (tabs.length === 0) return null
|
||||
return (
|
||||
<Tabs defaultValue={tabs[0].value} className="space-y-6">
|
||||
<TabsList>
|
||||
{tabs.map((t) => (
|
||||
<TabsTrigger key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{tabs.map((t) => (
|
||||
<TabsContent key={t.value} value={t.value} className="space-y-8 focus-visible:outline-none">
|
||||
{t.content}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,847 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { motion, AnimatePresence, useReducedMotion, animate } from 'framer-motion'
|
||||
import { RotateCw } from 'lucide-react'
|
||||
import { getAccountDescription } from '@/lib/bookkeeping/account-descriptions'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import type { DeepEntity, DeepLedgerContext } from '@/lib/agent-context/ledger-deep'
|
||||
|
||||
/**
|
||||
* "Reconciliation Aurora" - the cinematic hero of the "Vad din agent vet" page.
|
||||
*
|
||||
* A self-contained dark SVG panel. The company is a luminous hub; the BAS
|
||||
* accounts it books to form an inner ring of wedges (each wedge sized by the
|
||||
* money that flows through it); the counterparties/suppliers live in the outer
|
||||
* band, each constrained to its dominant account's angular slice so threads can
|
||||
* never cross between accounts (the anti-hairball guarantee).
|
||||
*
|
||||
* Four orthogonal channels, so no two compete:
|
||||
* - node AREA = total spend (radius = k·√kr)
|
||||
* - node COLOUR = booking cadence (weekly / monthly / irregular) - the one
|
||||
* semantic axis; everything else stays achromatic
|
||||
* - node SHAPE = kind (supplier = filled, counterparty = open ring)
|
||||
* - node FOCUS = the agent's confidence in the account mapping, rendered as
|
||||
* optical depth of field (sure = crisp/forward, unsure = soft)
|
||||
*
|
||||
* On mount it performs the signature act: for the most-merged payees, the raw
|
||||
* bank descriptors ("CLAUDE.AI", "Anthropic PBC", "claude.ai*sub") fly in as
|
||||
* ghost chips and magnetically collapse into one named node with a "×N" badge -
|
||||
* the agent resolving chaos into knowledge, live, in front of the customer.
|
||||
*
|
||||
* Deterministic (seeded jitter, fixed input order) so the demo looks identical
|
||||
* on every load. Motion is CSS-driven (single-clock idle, GPU dash pulses) with
|
||||
* framer only orchestrating the entrance, the merge and the hover card. Fully
|
||||
* keyboard-navigable; a visually-hidden table carries the payload for readers;
|
||||
* honours prefers-reduced-motion (jumps straight to the settled state).
|
||||
*/
|
||||
|
||||
const W = 1000
|
||||
const H = 1000
|
||||
const CX = W / 2
|
||||
const CY = H / 2
|
||||
const R_HUB = 34
|
||||
const R_ACCOUNT = 178
|
||||
const R_PAYEE_MIN = 258
|
||||
const R_PAYEE_MAX = 432
|
||||
const MAX_ACCOUNTS = 9
|
||||
const MAX_PER_ACCOUNT = 6
|
||||
const WEDGE_GAP = 0.07 // radians of padding between account wedges
|
||||
|
||||
// This panel is its own dark world regardless of the app theme, so the depth of
|
||||
// field, the glow and the cadence hues all read. Achromatic chrome; colour only
|
||||
// ever means cadence.
|
||||
const INK = '#0a0a0c'
|
||||
const PAPER = '#ecebe6'
|
||||
const HAIR = 'rgba(236,235,230,0.13)'
|
||||
const HAIR_STRONG = 'rgba(236,235,230,0.30)'
|
||||
const MUTED = 'rgba(236,235,230,0.52)'
|
||||
const CAD: Record<Cadence, string> = {
|
||||
weekly: '#e0895f', // terracotta - fast, recurring
|
||||
monthly: '#d7a648', // ochre - monthly
|
||||
irregular: '#83a98d', // sage - one-off / irregular
|
||||
}
|
||||
// Depth-of-field buckets: stdDeviation in viewBox units (~0.64× on screen).
|
||||
const DOF = [0, 1.7, 3.4, 5.2]
|
||||
|
||||
type Cadence = 'weekly' | 'monthly' | 'irregular'
|
||||
|
||||
interface Payee {
|
||||
id: string
|
||||
entity: DeepEntity
|
||||
accountNumber: string
|
||||
x: number
|
||||
y: number
|
||||
r: number
|
||||
cadence: Cadence
|
||||
bucket: number // depth-of-field bucket index into DOF
|
||||
thread: string // svg path from hub to node, bowed through the account anchor
|
||||
labelRight: boolean
|
||||
revealDelay: number
|
||||
merge: boolean // show the ghost-descriptor collapse on mount
|
||||
chips: string[]
|
||||
}
|
||||
interface Account {
|
||||
number: string
|
||||
name: string | null
|
||||
x: number
|
||||
y: number
|
||||
midAngle: number
|
||||
arc: string
|
||||
revealDelay: number
|
||||
}
|
||||
interface Model {
|
||||
accounts: Account[]
|
||||
payees: Payee[]
|
||||
truncated: boolean
|
||||
totals: { tx: number; payees: number; accounts: number }
|
||||
}
|
||||
|
||||
function polar(cx: number, cy: number, r: number, a: number) {
|
||||
return { x: cx + r * Math.cos(a), y: cy + r * Math.sin(a) }
|
||||
}
|
||||
function arcPath(cx: number, cy: number, r: number, a0: number, a1: number) {
|
||||
const s = polar(cx, cy, r, a0)
|
||||
const e = polar(cx, cy, r, a1)
|
||||
const large = a1 - a0 > Math.PI ? 1 : 0
|
||||
return `M ${s.x.toFixed(2)} ${s.y.toFixed(2)} A ${r} ${r} 0 ${large} 1 ${e.x.toFixed(2)} ${e.y.toFixed(2)}`
|
||||
}
|
||||
function lerp(a: number, b: number, t: number) {
|
||||
return a + (b - a) * t
|
||||
}
|
||||
// Deterministic [0,1) hash of a string (xmur3 → mulberry32), so seeded jitter is
|
||||
// byte-identical on every render: the live demo never reshuffles.
|
||||
function rand01(seed: string): number {
|
||||
let h = 1779033703 ^ seed.length
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
h = Math.imul(h ^ seed.charCodeAt(i), 3432918353)
|
||||
h = (h << 13) | (h >>> 19)
|
||||
}
|
||||
h = Math.imul(h ^ (h >>> 16), 2246822507)
|
||||
h = Math.imul(h ^ (h >>> 13), 3266489909)
|
||||
h ^= h >>> 16
|
||||
return (h >>> 0) / 4294967296
|
||||
}
|
||||
|
||||
function cadenceOf(e: DeepEntity): Cadence {
|
||||
const cd = e.cadence_days
|
||||
if (cd === null || e.occurrences < 3) return 'irregular'
|
||||
if (cd >= 4 && cd <= 10) return 'weekly'
|
||||
if (cd > 10 && cd <= 45) return 'monthly'
|
||||
return 'irregular'
|
||||
}
|
||||
function bucketOf(share: number | null): number {
|
||||
if (share === null) return 3
|
||||
if (share >= 0.85) return 0
|
||||
if (share >= 0.7) return 1
|
||||
if (share >= 0.5) return 2
|
||||
return 3
|
||||
}
|
||||
// Center-out slot order: biggest spender sits at the wedge's angular centre.
|
||||
function centerOut(n: number): number[] {
|
||||
const mid = (n - 1) / 2
|
||||
return Array.from({ length: n }, (_, i) => i).sort(
|
||||
(a, b) => Math.abs(a - mid) - Math.abs(b - mid),
|
||||
)
|
||||
}
|
||||
|
||||
function buildModel(deep: DeepLedgerContext): Model {
|
||||
const all: DeepEntity[] = [
|
||||
...(deep?.counterparty_entities ?? []),
|
||||
...(deep?.supplier_entities ?? []),
|
||||
].filter((e) => e.dominant_account_number)
|
||||
|
||||
const totalTx = all.reduce((s, e) => s + e.occurrences, 0)
|
||||
|
||||
const byAccount = new Map<string, DeepEntity[]>()
|
||||
for (const e of all) {
|
||||
const acc = e.dominant_account_number as string
|
||||
const arr = byAccount.get(acc) ?? []
|
||||
arr.push(e)
|
||||
byAccount.set(acc, arr)
|
||||
}
|
||||
|
||||
// Weight a wedge by the money that flows through it (fall back to volume).
|
||||
const activity = (items: DeepEntity[]) => {
|
||||
const spend = items.reduce((s, i) => s + Math.max(i.total_amount, 0), 0)
|
||||
return spend > 0 ? spend : items.reduce((s, i) => s + i.occurrences, 0)
|
||||
}
|
||||
|
||||
let groups = [...byAccount.entries()].map(([number, items]) => ({
|
||||
number,
|
||||
items: items.slice().sort((a, b) => b.total_amount - a.total_amount),
|
||||
weight: activity(items),
|
||||
}))
|
||||
// Rank by weight to pick what to show, then lay out in a fixed order (account
|
||||
// number) so wedges never swap places between loads.
|
||||
groups.sort((a, b) => b.weight - a.weight)
|
||||
const totalAccounts = groups.length
|
||||
groups = groups.slice(0, MAX_ACCOUNTS)
|
||||
let truncated = totalAccounts > groups.length
|
||||
for (const g of groups) {
|
||||
if (g.items.length > MAX_PER_ACCOUNT) {
|
||||
truncated = true
|
||||
g.items = g.items.slice(0, MAX_PER_ACCOUNT)
|
||||
}
|
||||
}
|
||||
groups.sort((a, b) => a.number.localeCompare(b.number))
|
||||
|
||||
const shownWeight = groups.reduce((s, g) => s + g.weight, 0) || 1
|
||||
const maxSpend = Math.max(...groups.flatMap((g) => g.items.map((i) => i.total_amount)), 1)
|
||||
|
||||
// The most name-merged payees earn the on-mount "descriptors collapse" moment.
|
||||
const mergeIds = new Set(
|
||||
all
|
||||
.filter((e) => e.variant_count >= 3)
|
||||
.sort((a, b) => b.variant_count - a.variant_count)
|
||||
.slice(0, 5)
|
||||
.map((e) => `${e.dominant_account_number}:${e.key}`),
|
||||
)
|
||||
|
||||
const accounts: Account[] = []
|
||||
const payees: Payee[] = []
|
||||
|
||||
const spans = 2 * Math.PI - WEDGE_GAP * groups.length
|
||||
let angle = -Math.PI / 2 + WEDGE_GAP / 2
|
||||
groups.forEach((g, gi) => {
|
||||
const width = spans * (g.weight / shownWeight)
|
||||
const mid = angle + width / 2
|
||||
const anchor = polar(CX, CY, R_ACCOUNT, mid)
|
||||
|
||||
accounts.push({
|
||||
number: g.number,
|
||||
name: getAccountDescription(g.number)?.name ?? null,
|
||||
x: anchor.x,
|
||||
y: anchor.y,
|
||||
midAngle: mid,
|
||||
arc: arcPath(CX, CY, R_ACCOUNT, angle + width * 0.04, angle + width * 0.96),
|
||||
revealDelay: 0.2 + gi * 0.06,
|
||||
})
|
||||
|
||||
const n = g.items.length
|
||||
const inner = width * 0.16
|
||||
const order = centerOut(n) // order[rank] = slot for that rank (rank 0 = biggest → centre)
|
||||
g.items.forEach((e, rank) => {
|
||||
const slot = order[rank]
|
||||
const t = n === 1 ? 0.5 : slot / (n - 1)
|
||||
const pa = angle + inner + t * (width - 2 * inner)
|
||||
const spendFrac = Math.sqrt(Math.max(e.total_amount, 0) / maxSpend)
|
||||
const radius = lerp(R_PAYEE_MIN, R_PAYEE_MAX, rand01(e.key)) + spendFrac * 14
|
||||
const pos = polar(CX, CY, Math.min(radius, R_PAYEE_MAX + 10), pa)
|
||||
const id = `${g.number}:${e.key}`
|
||||
payees.push({
|
||||
id,
|
||||
entity: e,
|
||||
accountNumber: g.number,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
r: 7 + 19 * spendFrac,
|
||||
cadence: cadenceOf(e),
|
||||
bucket: bucketOf(e.dominant_account_share),
|
||||
thread: `M ${CX} ${CY} Q ${anchor.x.toFixed(2)} ${anchor.y.toFixed(2)} ${pos.x.toFixed(2)} ${pos.y.toFixed(2)}`,
|
||||
labelRight: Math.cos(pa) >= 0,
|
||||
revealDelay: 0.55 + gi * 0.05 + rank * 0.045,
|
||||
merge: mergeIds.has(id),
|
||||
chips: e.variants.slice(0, 6),
|
||||
})
|
||||
angle += 0
|
||||
})
|
||||
angle += width + WEDGE_GAP
|
||||
})
|
||||
|
||||
return {
|
||||
accounts,
|
||||
payees,
|
||||
truncated,
|
||||
totals: { tx: totalTx, payees: payees.length, accounts: accounts.length },
|
||||
}
|
||||
}
|
||||
|
||||
// Weekly beats faster than monthly; irregular drifts slow. Seconds per pulse.
|
||||
const PULSE_DUR: Record<Cadence, number> = { weekly: 1.15, monthly: 2.7, irregular: 4.3 }
|
||||
|
||||
export function LedgerGraph({ deep, companyName }: { deep: DeepLedgerContext; companyName: string }) {
|
||||
const t = useTranslations('agentKnowledge')
|
||||
const reduce = useReducedMotion() ?? false
|
||||
const model = useMemo(() => buildModel(deep), [deep])
|
||||
|
||||
const [hover, setHover] = useState<string | null>(null)
|
||||
const [runKey, setRunKey] = useState(0)
|
||||
// Track which run has finished its intro rather than a bare boolean, so a
|
||||
// replay (runKey++) resets to "unresolved" by derivation, without a
|
||||
// setState-in-effect. Chips fly in, then collapse into their node.
|
||||
const [resolvedRun, setResolvedRun] = useState(-1)
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => setResolvedRun(runKey), reduce ? 0 : 1300)
|
||||
return () => clearTimeout(id)
|
||||
}, [runKey, reduce])
|
||||
const resolved = reduce || resolvedRun === runKey
|
||||
|
||||
const payeeById = useMemo(() => new Map(model.payees.map((p) => [p.id, p])), [model])
|
||||
|
||||
function payeeCaption(p: Payee): string {
|
||||
const e = p.entity
|
||||
const nm = getAccountDescription(e.dominant_account_number ?? '')?.name
|
||||
return [
|
||||
e.name,
|
||||
t('cap_bookings', { n: e.occurrences }),
|
||||
e.variant_count > 1 ? t('cap_variants', { n: e.variant_count }) : null,
|
||||
t(`cadence_${p.cadence}`),
|
||||
formatCurrency(e.total_amount),
|
||||
`${e.dominant_account_number}${nm ? ` ${nm}` : ''}${
|
||||
e.dominant_account_share !== null ? ` · ${Math.round(e.dominant_account_share * 100)}%` : ''
|
||||
}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
}
|
||||
|
||||
if (model.payees.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl border p-16 text-center text-sm"
|
||||
style={{ background: INK, borderColor: HAIR, color: MUTED }}
|
||||
>
|
||||
{t('none_cp')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Which ids are "lit" given the current hover (a payee lights its account and
|
||||
// the reverse); everything else recedes into the depth of field.
|
||||
const active = new Set<string>()
|
||||
if (hover) {
|
||||
active.add(hover)
|
||||
if (hover.startsWith('acc:')) {
|
||||
const num = hover.slice(4)
|
||||
model.payees.forEach((p) => p.accountNumber === num && active.add(p.id))
|
||||
} else {
|
||||
const p = payeeById.get(hover)
|
||||
if (p) active.add(`acc:${p.accountNumber}`)
|
||||
}
|
||||
}
|
||||
const lit = (id: string) => !hover || active.has(id)
|
||||
|
||||
const hoveredPayee = hover && !hover.startsWith('acc:') ? payeeById.get(hover) ?? null : null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative overflow-hidden rounded-xl border"
|
||||
style={{
|
||||
borderColor: HAIR_STRONG,
|
||||
background: `radial-gradient(120% 120% at 50% 42%, #17171b 0%, ${INK} 62%)`,
|
||||
}}
|
||||
>
|
||||
<style>{keyframes}</style>
|
||||
|
||||
{/* header */}
|
||||
<div className="flex items-start justify-between gap-4 px-5 pt-5 md:px-7 md:pt-6">
|
||||
<div>
|
||||
<h2
|
||||
className="font-display text-lg tracking-tight md:text-xl"
|
||||
style={{ color: PAPER }}
|
||||
>
|
||||
{t('graph_title')}
|
||||
</h2>
|
||||
<p className="mt-1 max-w-md text-sm leading-relaxed" style={{ color: MUTED }}>
|
||||
{t('graph_description')}
|
||||
</p>
|
||||
</div>
|
||||
{!reduce && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRunKey((k) => k + 1)}
|
||||
className="inline-flex shrink-0 items-center gap-2 rounded-md border px-3 py-1.5 text-xs transition-colors"
|
||||
style={{ borderColor: HAIR_STRONG, color: MUTED }}
|
||||
>
|
||||
<RotateCw className="h-3.5 w-3.5" />
|
||||
{t('graph_replay')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* stage */}
|
||||
<div className="relative mx-auto aspect-square w-full max-w-[680px]">
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
className="block h-full w-full"
|
||||
role="img"
|
||||
aria-label={t('graph_aria', { payees: model.payees.length, accounts: model.accounts.length })}
|
||||
>
|
||||
<defs>
|
||||
{DOF.map((sd, i) => (
|
||||
<filter key={i} id={`dof-${i}`} x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation={sd} />
|
||||
</filter>
|
||||
))}
|
||||
<radialGradient id="aurora-hub" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stopColor={PAPER} stopOpacity="0.22" />
|
||||
<stop offset="100%" stopColor={PAPER} stopOpacity="0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
<g key={runKey}>
|
||||
{/* account wedge arcs (the spine) */}
|
||||
{model.accounts.map((a) => (
|
||||
<g key={`acc:${a.number}`} opacity={lit(`acc:${a.number}`) ? 1 : 0.14}>
|
||||
<motion.path
|
||||
d={a.arc}
|
||||
fill="none"
|
||||
stroke={HAIR_STRONG}
|
||||
strokeWidth={1.4}
|
||||
strokeLinecap="round"
|
||||
initial={reduce ? false : { pathLength: 0, opacity: 0 }}
|
||||
animate={{ pathLength: 1, opacity: 1 }}
|
||||
transition={{ duration: 0.7, delay: a.revealDelay, ease: 'easeInOut' }}
|
||||
/>
|
||||
<AccountHit account={a} caption={accountCaption(a)} onHover={setHover} lit={lit(`acc:${a.number}`)} />
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* threads: base vein + travelling cadence pulse */}
|
||||
{model.payees.map((p) => (
|
||||
<g key={`thread:${p.id}`} opacity={lit(p.id) ? 1 : 0.08}>
|
||||
<motion.path
|
||||
d={p.thread}
|
||||
fill="none"
|
||||
stroke={PAPER}
|
||||
strokeOpacity={0.16}
|
||||
strokeWidth={0.9 + 1.1 * (p.entity.dominant_account_share ?? 0.6)}
|
||||
initial={reduce ? false : { pathLength: 0 }}
|
||||
animate={{ pathLength: 1 }}
|
||||
transition={{ duration: 0.6, delay: p.revealDelay, ease: 'easeOut' }}
|
||||
/>
|
||||
<path
|
||||
d={p.thread}
|
||||
fill="none"
|
||||
stroke={CAD[p.cadence]}
|
||||
strokeWidth={2.4}
|
||||
strokeLinecap="round"
|
||||
pathLength={1}
|
||||
className={reduce ? undefined : 'aurora-pulse'}
|
||||
style={{
|
||||
strokeDasharray: '0.035 1',
|
||||
opacity: resolved && lit(p.id) ? 0.9 : 0,
|
||||
transition: 'opacity .6s ease',
|
||||
animationDuration: `${PULSE_DUR[p.cadence]}s`,
|
||||
animationDelay: `${rand01(p.id + 'd') * -PULSE_DUR[p.cadence]}s`,
|
||||
}}
|
||||
/>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* payee nodes, blurry buckets first so the confident ones sit on top */}
|
||||
{[...model.payees]
|
||||
.sort((a, b) => b.bucket - a.bucket)
|
||||
.map((p) => (
|
||||
<PayeeGlyph
|
||||
key={p.id}
|
||||
p={p}
|
||||
reduce={reduce}
|
||||
resolved={resolved}
|
||||
lit={lit(p.id)}
|
||||
focused={hover === p.id}
|
||||
caption={payeeCaption(p)}
|
||||
onHover={setHover}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* company hub */}
|
||||
<g>
|
||||
<circle cx={CX} cy={CY} r={R_HUB * 2.6} fill="url(#aurora-hub)" />
|
||||
<circle
|
||||
cx={CX}
|
||||
cy={CY}
|
||||
r={R_HUB}
|
||||
fill={PAPER}
|
||||
className={reduce ? undefined : 'aurora-breathe'}
|
||||
style={{ transformOrigin: `${CX}px ${CY}px` }}
|
||||
/>
|
||||
<text
|
||||
x={CX}
|
||||
y={CY}
|
||||
fill={INK}
|
||||
fontSize={20}
|
||||
fontWeight={600}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
>
|
||||
{initialsOf(companyName)}
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{/* tally: the "understood" count-up */}
|
||||
<div
|
||||
className="pointer-events-none absolute bottom-3 left-4 text-xs tabular-nums md:bottom-4 md:left-6"
|
||||
style={{ color: MUTED }}
|
||||
>
|
||||
<span style={{ color: PAPER }}>
|
||||
<CountUp target={model.totals.tx} run={runKey} reduce={reduce} />
|
||||
</span>{' '}
|
||||
{t('graph_tally', {
|
||||
payees: model.totals.payees,
|
||||
accounts: model.totals.accounts,
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* hover detail card, anchored to the node */}
|
||||
<AnimatePresence>
|
||||
{hoveredPayee && (
|
||||
<DetailCard key={hoveredPayee.id} p={hoveredPayee} t={t} />
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* legend */}
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-x-5 gap-y-2 border-t px-5 py-3 text-xs md:px-7"
|
||||
style={{ borderColor: HAIR, color: MUTED }}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Dot fill={CAD.weekly} /> {t('cadence_weekly')}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Dot fill={CAD.monthly} /> {t('cadence_monthly')}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Dot fill={CAD.irregular} /> {t('cadence_irregular')}
|
||||
</span>
|
||||
<span className="opacity-70">{t('legend_size')}</span>
|
||||
<span className="opacity-70">{t('legend_focus')}</span>
|
||||
{model.truncated && <span className="italic opacity-70">{t('graph_truncated')}</span>}
|
||||
</div>
|
||||
|
||||
{/* screen-reader alternative: the full payload as a plain list */}
|
||||
<ul className="sr-only">
|
||||
{model.payees.map((p) => (
|
||||
<li key={`sr:${p.id}`}>{payeeCaption(p)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function accountCaption(a: Account): string {
|
||||
return `${a.number}${a.name ? ` · ${a.name}` : ''}`
|
||||
}
|
||||
|
||||
// Enlarged invisible hit target + keyboard focus for an account arc.
|
||||
function AccountHit({
|
||||
account,
|
||||
caption,
|
||||
onHover,
|
||||
lit,
|
||||
}: {
|
||||
account: Account
|
||||
caption: string
|
||||
onHover: (id: string | null) => void
|
||||
lit: boolean
|
||||
}) {
|
||||
const id = `acc:${account.number}`
|
||||
const inside = polar(CX, CY, R_ACCOUNT - 20, account.midAngle)
|
||||
return (
|
||||
<g
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-label={caption}
|
||||
onMouseEnter={() => onHover(id)}
|
||||
onMouseLeave={() => onHover(null)}
|
||||
onFocus={() => onHover(id)}
|
||||
onBlur={() => onHover(null)}
|
||||
className="cursor-pointer outline-none [&:focus-visible>circle]:opacity-100"
|
||||
>
|
||||
<title>{caption}</title>
|
||||
<circle cx={account.x} cy={account.y} r={22} fill="transparent" />
|
||||
<circle cx={account.x} cy={account.y} r={26} fill="none" stroke={PAPER} strokeWidth={1.25} opacity={0} />
|
||||
<text
|
||||
x={inside.x}
|
||||
y={inside.y}
|
||||
fill={lit ? PAPER : MUTED}
|
||||
fontSize={13}
|
||||
fontWeight={500}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
style={{ fontFamily: 'var(--font-geist-mono, ui-monospace, monospace)' }}
|
||||
>
|
||||
{account.number}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
||||
function PayeeGlyph({
|
||||
p,
|
||||
reduce,
|
||||
resolved,
|
||||
lit,
|
||||
focused,
|
||||
caption,
|
||||
onHover,
|
||||
}: {
|
||||
p: Payee
|
||||
reduce: boolean
|
||||
resolved: boolean
|
||||
lit: boolean
|
||||
focused: boolean
|
||||
caption: string
|
||||
onHover: (id: string | null) => void
|
||||
}) {
|
||||
const colour = CAD[p.cadence]
|
||||
const showChips = !reduce && p.merge && !resolved
|
||||
// The depth-of-field blur only attaches once the entrance spring settles, so
|
||||
// feGaussianBlur never re-rasterizes per frame while the node is moving (and
|
||||
// the blur "racking in" as the node comes to rest is the intended focus pull).
|
||||
const [settled, setSettled] = useState(reduce)
|
||||
// Only the biggest spenders keep a resting label; the rest reveal on focus.
|
||||
const bigLabel = p.r >= 14
|
||||
const label = p.entity.name.length > 16 ? p.entity.name.slice(0, 15) + '…' : p.entity.name
|
||||
const lx = p.labelRight ? p.r + 8 : -(p.r + 8)
|
||||
|
||||
return (
|
||||
// Positioning lives on a plain <g> (SVG transform attribute) so it can never
|
||||
// be clobbered by framer's CSS transform on the scaling child below. Hover
|
||||
// "racks focus" onto a node by dropping it to the crisp filter bucket.
|
||||
<g
|
||||
transform={`translate(${p.x} ${p.y})`}
|
||||
filter={`url(#dof-${focused || !settled ? 0 : p.bucket})`}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-label={caption}
|
||||
onMouseEnter={() => onHover(p.id)}
|
||||
onMouseLeave={() => onHover(null)}
|
||||
onFocus={() => onHover(p.id)}
|
||||
onBlur={() => onHover(null)}
|
||||
className="cursor-pointer outline-none"
|
||||
>
|
||||
<title>{caption}</title>
|
||||
{/* always-full-size transparent hit target */}
|
||||
<circle cx={0} cy={0} r={Math.max(p.r + 8, 16)} fill="transparent" />
|
||||
|
||||
{/* opacity layer: entrance fade + hover dimming. Animates opacity only, so
|
||||
framer never sets a CSS transform that would fight the attribute. */}
|
||||
<motion.g
|
||||
initial={reduce ? false : { opacity: 0 }}
|
||||
animate={{ opacity: lit ? 1 : 0.12 }}
|
||||
transition={{ duration: reduce ? 0.2 : 0.4, delay: reduce ? 0 : p.merge ? 0.1 : p.revealDelay }}
|
||||
>
|
||||
{/* the money shot: raw bank descriptors that collapse inward. Kept OUT
|
||||
of the scaling group so they show at full size during the scatter. */}
|
||||
<AnimatePresence>
|
||||
{showChips &&
|
||||
p.chips.map((chip, i) => {
|
||||
const ang = rand01(p.id + chip) * Math.PI * 2
|
||||
const rad = 34 + rand01(chip + String(i)) * 52
|
||||
const ox = Math.cos(ang) * rad
|
||||
const oy = Math.sin(ang) * rad
|
||||
return (
|
||||
<motion.text
|
||||
key={chip + i}
|
||||
fontSize={12}
|
||||
fill={MUTED}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
initial={{ opacity: 0, x: ox * 1.55, y: oy * 1.55 }}
|
||||
animate={{ opacity: 0.7, x: ox, y: oy }}
|
||||
exit={{ opacity: 0, x: 0, y: 0, scale: 0.4 }}
|
||||
transition={{ duration: 0.5, delay: 0.1 + i * 0.05, ease: 'easeOut' }}
|
||||
>
|
||||
{chip.length > 18 ? chip.slice(0, 17) + '…' : chip}
|
||||
</motion.text>
|
||||
)
|
||||
})}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* scale layer: the node grows in around its centre (fill-box) */}
|
||||
<motion.g
|
||||
initial={reduce ? false : { scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
onAnimationComplete={() => setSettled(true)}
|
||||
transition={
|
||||
reduce
|
||||
? { duration: 0 }
|
||||
: { type: 'spring', stiffness: 150, damping: 17, delay: p.merge ? 1.32 : p.revealDelay }
|
||||
}
|
||||
style={{ transformBox: 'fill-box', transformOrigin: 'center' }}
|
||||
>
|
||||
{/* variant echo ring: faint concentric hint that this node is a merge */}
|
||||
{p.entity.variant_count > 2 && (
|
||||
<circle cx={0} cy={0} r={p.r + 5} fill="none" stroke={colour} strokeWidth={0.75} strokeOpacity={0.35} />
|
||||
)}
|
||||
{focused && <circle cx={0} cy={0} r={p.r + 6} fill="none" stroke={PAPER} strokeWidth={1.5} />}
|
||||
|
||||
{/* the glyph: supplier = filled, counterparty = open */}
|
||||
<circle
|
||||
cx={0}
|
||||
cy={0}
|
||||
r={p.r}
|
||||
fill={p.entity.kind === 'supplier' ? colour : INK}
|
||||
fillOpacity={p.entity.kind === 'supplier' ? 0.85 : 1}
|
||||
stroke={colour}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
|
||||
{/* "×N" merge badge */}
|
||||
{p.entity.variant_count > 1 && (resolved || reduce) && (
|
||||
<g transform={`translate(${p.r * 0.72} ${-p.r * 0.72})`}>
|
||||
<circle r={8.5} fill={INK} stroke={colour} strokeWidth={1} />
|
||||
<text fill={PAPER} fontSize={9} fontWeight={600} textAnchor="middle" dominantBaseline="central">
|
||||
{p.merge ? (
|
||||
<CountUp target={p.entity.variant_count} run={resolved ? 1 : 0} reduce={reduce} prefix="×" duration={0.5} />
|
||||
) : (
|
||||
`×${p.entity.variant_count}`
|
||||
)}
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
</motion.g>
|
||||
|
||||
{bigLabel && (
|
||||
<text
|
||||
x={lx}
|
||||
y={0}
|
||||
fill={lit ? PAPER : MUTED}
|
||||
fontSize={12}
|
||||
dominantBaseline="central"
|
||||
textAnchor={p.labelRight ? 'start' : 'end'}
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
)}
|
||||
</motion.g>
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
||||
function DetailCard({ p, t }: { p: Payee; t: ReturnType<typeof useTranslations> }) {
|
||||
const e = p.entity
|
||||
const nm = getAccountDescription(e.dominant_account_number ?? '')?.name
|
||||
const share = e.dominant_account_share !== null ? Math.round(e.dominant_account_share * 100) : null
|
||||
// Anchor to the node: viewBox coords → % of the square stage. Flip sides so it
|
||||
// never spills off the edge.
|
||||
const left = (p.x / W) * 100
|
||||
const top = (p.y / H) * 100
|
||||
const right = p.x < CX
|
||||
return (
|
||||
<motion.div
|
||||
className="pointer-events-none absolute z-10 w-60 rounded-lg border p-3 backdrop-blur-sm"
|
||||
style={{
|
||||
left: `${left}%`,
|
||||
top: `${top}%`,
|
||||
transform: `translate(${right ? '14px' : 'calc(-100% - 14px)'}, -50%)`,
|
||||
background: 'rgba(14,14,17,0.92)',
|
||||
borderColor: HAIR_STRONG,
|
||||
color: PAPER,
|
||||
}}
|
||||
initial={{ opacity: 0, scale: 0.94 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.96 }}
|
||||
transition={{ type: 'spring', stiffness: 320, damping: 26 }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-block h-2.5 w-2.5 shrink-0 rounded-full" style={{ background: CAD[p.cadence] }} />
|
||||
<span className="truncate font-medium">{e.name}</span>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1.5 text-xs" style={{ color: MUTED }}>
|
||||
<div className="flex justify-between gap-3">
|
||||
<span>{t('cap_bookings', { n: e.occurrences })}</span>
|
||||
<span style={{ color: PAPER }}>{t(`cadence_${p.cadence}`)}</span>
|
||||
</div>
|
||||
{e.variant_count > 1 && (
|
||||
<div className="flex justify-between gap-3">
|
||||
<span>{t('card_variants')}</span>
|
||||
<span style={{ color: PAPER }}>×{e.variant_count}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between gap-3">
|
||||
<span>{t('legend_size')}</span>
|
||||
<span className="tabular-nums" style={{ color: PAPER }}>{formatCurrency(e.total_amount)}</span>
|
||||
</div>
|
||||
<div className="mt-2 border-t pt-2" style={{ borderColor: HAIR }}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="font-mono" style={{ color: PAPER }}>
|
||||
{e.dominant_account_number}
|
||||
{nm ? ` ${nm}` : ''}
|
||||
</span>
|
||||
{share !== null && <span className="tabular-nums">{t('card_confidence', { share })}</span>}
|
||||
</div>
|
||||
{share !== null && (
|
||||
<div className="mt-1.5 h-1 overflow-hidden rounded-full" style={{ background: HAIR }}>
|
||||
<div className="h-full rounded-full" style={{ width: `${share}%`, background: CAD[p.cadence] }} />
|
||||
</div>
|
||||
)}
|
||||
{e.dominant_account_count != null && e.dominant_account_total != null && (
|
||||
<div className="mt-1 tabular-nums opacity-80">
|
||||
{t('card_evidence', { k: e.dominant_account_count, n: e.dominant_account_total })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{e.variants.length > 1 && (
|
||||
<div className="pt-1 leading-relaxed opacity-80">
|
||||
{e.variants.slice(0, 4).join(' · ')}
|
||||
{e.variant_count > 4 ? ' …' : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
function CountUp({
|
||||
target,
|
||||
run,
|
||||
reduce,
|
||||
prefix = '',
|
||||
duration = 1,
|
||||
}: {
|
||||
target: number
|
||||
run: number
|
||||
reduce: boolean
|
||||
prefix?: string
|
||||
duration?: number
|
||||
}) {
|
||||
const [v, setV] = useState(0)
|
||||
// Re-run whenever `run` bumps (mount / replay / resolve). No ref-guard: it
|
||||
// would early-return on React StrictMode's second effect setup in dev and
|
||||
// freeze the number at 0 (the cleanup already stops any prior animation).
|
||||
useEffect(() => {
|
||||
if (reduce) return
|
||||
const controls = animate(0, target, {
|
||||
duration,
|
||||
ease: 'easeOut',
|
||||
onUpdate: (x) => setV(Math.round(x)),
|
||||
})
|
||||
return () => controls.stop()
|
||||
}, [target, run, reduce, duration])
|
||||
const shown = reduce ? target : v
|
||||
return <>{prefix}{shown.toLocaleString('sv-SE')}</>
|
||||
}
|
||||
|
||||
function Dot({ fill }: { fill: string }) {
|
||||
return <span className="inline-block h-2.5 w-2.5 rounded-full" style={{ background: fill }} />
|
||||
}
|
||||
|
||||
function initialsOf(name: string): string {
|
||||
return (
|
||||
name
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((w) => w[0]?.toUpperCase() ?? '')
|
||||
.join('') || '•'
|
||||
)
|
||||
}
|
||||
|
||||
// One clock for all continuous life: GPU-friendly CSS keyframes, frozen for
|
||||
// prefers-reduced-motion users.
|
||||
const keyframes = `
|
||||
@keyframes aurora-pulse { to { stroke-dashoffset: -1; } }
|
||||
@keyframes aurora-breathe { 0%,100% { transform: scale(1); } 50% { transform: scale(1.03); } }
|
||||
.aurora-pulse { animation-name: aurora-pulse; animation-timing-function: linear; animation-iteration-count: infinite; }
|
||||
.aurora-breathe { animation: aurora-breathe 5.5s ease-in-out infinite; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.aurora-pulse, .aurora-breathe { animation: none !important; }
|
||||
}
|
||||
`
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
CalendarClock,
|
||||
CalendarRange,
|
||||
FileCheck,
|
||||
Brain,
|
||||
FileSpreadsheet,
|
||||
ScrollText,
|
||||
} from 'lucide-react'
|
||||
@@ -98,6 +99,7 @@ type NavLabelKey =
|
||||
| 'dashboard'
|
||||
| 'home'
|
||||
| 'assistant'
|
||||
| 'agent_knowledge'
|
||||
| 'kpi'
|
||||
| 'invoice_inbox'
|
||||
| 'invoices'
|
||||
@@ -189,6 +191,9 @@ const navItems: NavItem[] = [
|
||||
// Rapporter surface (nav_ia_redesign §F) is built.
|
||||
{ href: '/kpi', labelKey: 'kpi', icon: TrendingUp, group: 'analys' },
|
||||
{ href: '/reports', labelKey: 'reports', icon: BarChart3, group: 'analys' },
|
||||
// "Vad din agent vet": read-only profile of how this company books, the
|
||||
// human render of the agent's ledger-context (dev_docs/ledger_context_resource.md).
|
||||
{ href: '/agent-knowledge', labelKey: 'agent_knowledge', icon: Brain, 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.
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
// Server-side read of the agent's competence (the domain-knowledge "atoms" it
|
||||
// ships with) and its top learned facts (memory), for the read-only overview
|
||||
// on the "Vad din agent vet" page. Mirrors GET /api/agent/skills and
|
||||
// GET /api/agent/memory so the two surfaces stay consistent; the full editable
|
||||
// management lives in /settings/assistant.
|
||||
|
||||
export type AtomTier = 'horizontal' | 'vertical' | 'modifier'
|
||||
|
||||
export interface AgentAtom {
|
||||
id: string
|
||||
tier: AtomTier
|
||||
title: string
|
||||
description: string
|
||||
/** horizontal atoms apply to every company; vertical/modifier only when the
|
||||
* composer selected them into this company's profile. */
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export type FactKind = 'fact' | 'preference' | 'pattern' | 'correction'
|
||||
export type FactSource = 'composer' | 'user_taught' | 'agent_learned' | 'derived'
|
||||
|
||||
export interface AgentFact {
|
||||
id: string
|
||||
kind: FactKind
|
||||
content: string
|
||||
source: FactSource
|
||||
is_pinned: boolean
|
||||
}
|
||||
|
||||
export interface AgentCompetence {
|
||||
atoms: AgentAtom[]
|
||||
facts: AgentFact[]
|
||||
/** Total active facts (before the overview cap), so the count is honest. */
|
||||
factsActiveTotal: number
|
||||
}
|
||||
|
||||
const FACTS_LIMIT = 12
|
||||
|
||||
export async function buildAgentCompetence(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
): Promise<AgentCompetence> {
|
||||
const [atomsRes, profileRes, factsRes, factsCountRes] = await Promise.all([
|
||||
// The atom registry is global product content, not tenant data.
|
||||
supabase
|
||||
.from('agent_atom_registry')
|
||||
.select('id, tier, title, description')
|
||||
.eq('is_active', true)
|
||||
.eq('mcp_exposed', true)
|
||||
.is('parent_atom_id', null)
|
||||
.order('tier', { ascending: true })
|
||||
.order('title', { ascending: true }),
|
||||
supabase
|
||||
.from('agent_profiles')
|
||||
.select('vertical_atoms, modifier_atoms')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle(),
|
||||
supabase
|
||||
.from('agent_memory')
|
||||
.select('id, kind, content, source, is_pinned')
|
||||
.eq('company_id', companyId)
|
||||
.eq('is_active', true)
|
||||
.order('is_pinned', { ascending: false })
|
||||
.order('relevance_score', { ascending: false })
|
||||
.order('last_accessed_at', { ascending: false, nullsFirst: false })
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(FACTS_LIMIT),
|
||||
supabase
|
||||
.from('agent_memory')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('company_id', companyId)
|
||||
.eq('is_active', true),
|
||||
])
|
||||
|
||||
if (atomsRes.error) throw new Error(`agent skills failed: ${atomsRes.error.message}`)
|
||||
if (factsRes.error) throw new Error(`agent memory failed: ${factsRes.error.message}`)
|
||||
|
||||
const verticalActive = new Set((profileRes.data?.vertical_atoms as string[] | null) ?? [])
|
||||
const modifierActive = new Set((profileRes.data?.modifier_atoms as string[] | null) ?? [])
|
||||
|
||||
const atoms: AgentAtom[] = (atomsRes.data ?? []).map((a) => {
|
||||
const tier = a.tier as AtomTier
|
||||
const active =
|
||||
tier === 'horizontal' ? true : tier === 'vertical' ? verticalActive.has(a.id) : modifierActive.has(a.id)
|
||||
return { id: a.id, tier, title: a.title, description: a.description, active }
|
||||
})
|
||||
|
||||
const facts: AgentFact[] = (factsRes.data ?? []).map((f) => ({
|
||||
id: f.id,
|
||||
kind: f.kind as FactKind,
|
||||
content: f.content,
|
||||
source: f.source as FactSource,
|
||||
is_pinned: f.is_pinned,
|
||||
}))
|
||||
|
||||
return { atoms, facts, factsActiveTotal: factsCountRes.count ?? facts.length }
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
// Deep, entity-resolved analysis behind the "Vad din agent vet" page. Merges
|
||||
// counterparties across name variants, mines the booked verifikat for real
|
||||
// spend, and detects recurrence. Backed by the get_ledger_deep_context RPC
|
||||
// (full history, deterministic). See migration 20260708130000.
|
||||
|
||||
export interface DeepEntity {
|
||||
/** Display label (the modal raw variant). */
|
||||
name: string
|
||||
/** Identity key: normalized counterparty key, or supplier id. */
|
||||
key: string
|
||||
/** Up to 8 distinct raw labels that merged into this entity. */
|
||||
variants: string[]
|
||||
/** True count of distinct raw labels merged. */
|
||||
variant_count: number
|
||||
/** Number of bookings. */
|
||||
occurrences: number
|
||||
/** Total paid, gross SEK (abs bank amount / invoice total). */
|
||||
total_amount: number
|
||||
first_seen: string
|
||||
last_seen: string
|
||||
/** Median gap between distinct booking dates, days. null if < 2 dates. */
|
||||
cadence_days: number | null
|
||||
dominant_account_number: string | null
|
||||
/**
|
||||
* Laplace-smoothed consistency (cnt+1)/(total+2), 0..1: sample-size-honest,
|
||||
* so a single booking reads 0.67, never 1.0 (migration 20260710090000).
|
||||
*/
|
||||
dominant_account_share: number | null
|
||||
/** Raw evidence: bookings on the dominant account. */
|
||||
dominant_account_count: number | null
|
||||
/** Raw evidence: all counted contra lines for the entity. */
|
||||
dominant_account_total: number | null
|
||||
dominant_vat?: string | null
|
||||
kind: 'counterparty' | 'supplier'
|
||||
}
|
||||
|
||||
export interface DeepLedgerContext {
|
||||
counterparty_entities: DeepEntity[]
|
||||
supplier_entities: DeepEntity[]
|
||||
}
|
||||
|
||||
interface DeepRow {
|
||||
counterparty_entities: Omit<DeepEntity, 'kind'>[]
|
||||
supplier_entities: Omit<DeepEntity, 'kind'>[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-history deep analysis. `fromDate = null` means all history; pass a date
|
||||
* to bound the lookback on large tenants (the cache layer is deferred).
|
||||
*/
|
||||
export async function buildDeepEntities(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
fromDate: string | null = null,
|
||||
): Promise<DeepLedgerContext> {
|
||||
const { data, error } = await supabase.rpc('get_ledger_deep_context', {
|
||||
p_company_id: companyId,
|
||||
p_from_date: fromDate,
|
||||
})
|
||||
if (error) {
|
||||
throw new Error(`ledger deep context failed: ${error.message}`)
|
||||
}
|
||||
const row = (data ?? { counterparty_entities: [], supplier_entities: [] }) as DeepRow
|
||||
return {
|
||||
counterparty_entities: (row.counterparty_entities ?? []).map((e) => ({ ...e, kind: 'counterparty' as const })),
|
||||
supplier_entities: (row.supplier_entities ?? []).map((e) => ({ ...e, kind: 'supplier' as const })),
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,7 @@
|
||||
"dashboard": "Overview",
|
||||
"home": "Home",
|
||||
"assistant": "Assistant",
|
||||
"agent_knowledge": "What your agent knows",
|
||||
"bureau": "Bureau",
|
||||
"kpi": "KPIs",
|
||||
"invoice_inbox": "Documents",
|
||||
@@ -128,6 +129,116 @@
|
||||
"ext_tic": "Company profile",
|
||||
"ext_invoice_inbox": "Document inbox"
|
||||
},
|
||||
"agentKnowledge": {
|
||||
"title": "What your agent knows",
|
||||
"description": "How your company books, derived from your own accounting. This is the context your assistant reads before categorizing or creating vouchers.",
|
||||
"meta_window": "Period",
|
||||
"meta_posted_entries": "Posted vouchers",
|
||||
"meta_lag": "Typical booking lag",
|
||||
"meta_lag_value": "{days} days",
|
||||
"meta_computed": "Updated",
|
||||
"empty_title": "Your assistant hasn't learned anything yet",
|
||||
"empty_description": "As you book transactions, the assistant learns how your company codes each counterparty, supplier and account.",
|
||||
"empty_action": "Book transactions",
|
||||
"graph_title": "Booking map",
|
||||
"graph_description": "How the assistant sees your counterparties and suppliers flow into the chart of accounts. Colour shows rhythm, size the amount, and sharpness how sure the booking is. Hover a node for details.",
|
||||
"graph_hint": "Hover a node to see how it's booked.",
|
||||
"graph_account_caption": "{count} counterparties",
|
||||
"graph_payee_caption": "seen {seen}× · {share}% agree",
|
||||
"graph_aria": "Radial map: {payees} counterparties linked to {accounts} accounts.",
|
||||
"graph_truncated": "showing the most frequent",
|
||||
"legend_account": "Account",
|
||||
"legend_counterparty": "Counterparty",
|
||||
"legend_supplier": "Supplier",
|
||||
"legend_edge": "Line strength = confidence",
|
||||
"meta_entities": "Tracked payees",
|
||||
"meta_recurring": "Recurring",
|
||||
"meta_tracked_spend": "Tracked amount",
|
||||
"cap_bookings": "{n} bookings",
|
||||
"cap_variants": "{n} variants",
|
||||
"cadence_weekly": "~weekly",
|
||||
"cadence_monthly": "~monthly",
|
||||
"cadence_quarterly": "~quarterly",
|
||||
"cadence_irregular": "irregular",
|
||||
"graph_replay": "Replay",
|
||||
"graph_tally": "transactions · {payees} counterparties · {accounts} accounts, understood",
|
||||
"legend_size": "Size = amount",
|
||||
"legend_focus": "Sharpness = confidence",
|
||||
"card_variants": "Name variants",
|
||||
"card_confidence": "{share}% confidence",
|
||||
"card_evidence": "Booked here in {k} of {n} cases",
|
||||
"comp_title": "Competence",
|
||||
"comp_desc": "Knowledge areas of Swedish bookkeeping and tax the assistant builds on. Core competence applies to everyone; industry and company adaptation is chosen for your company.",
|
||||
"comp_empty": "No knowledge areas yet.",
|
||||
"comp_count": "{total} knowledge areas · {active} active",
|
||||
"comp_manage": "Read more",
|
||||
"tier_horizontal": "Core competence",
|
||||
"tier_vertical": "Tailored to your industry",
|
||||
"tier_modifier": "Your company situation",
|
||||
"badge_dormant": "Dormant",
|
||||
"facts_title": "Facts the assistant remembers",
|
||||
"facts_desc": "Notes the assistant uses to give you the right advice. Pinned facts are always included.",
|
||||
"facts_empty": "No memories yet.",
|
||||
"facts_more": "+{n} more",
|
||||
"facts_manage": "Manage memory",
|
||||
"facts_pinned": "Pinned",
|
||||
"kind_fact": "Fact",
|
||||
"kind_preference": "Preference",
|
||||
"kind_pattern": "Pattern",
|
||||
"kind_correction": "Correction",
|
||||
"source_composer": "Loaded at startup",
|
||||
"source_user_taught": "You taught me",
|
||||
"source_agent_learned": "I noted",
|
||||
"source_derived": "Derived",
|
||||
"tab_competence": "Competence",
|
||||
"tab_memory": "Memory",
|
||||
"tab_config": "Rules & profile",
|
||||
"footer_basis": "Based on {entries} posted vouchers · updated {date}",
|
||||
"cp_title": "Booking patterns by counterparty",
|
||||
"cp_description": "The most frequent counterparties from the bank feed and how they are usually booked. The evidence is historical frequency, not a guarantee: the assistant suggests, you approve.",
|
||||
"sup_title": "Patterns by supplier",
|
||||
"sup_description": "Derived from supplier invoices: the dominant expense account and VAT treatment per supplier.",
|
||||
"rules_title": "Your rules",
|
||||
"rules_description": "Rules you set yourself. They are instructions and take precedence over observed patterns.",
|
||||
"acc_title": "Account usage",
|
||||
"acc_description": "Accounts actually in use, the 20 most frequent over the last 12 months.",
|
||||
"vat_title": "VAT profile",
|
||||
"vat_description": "Observed VAT handling.",
|
||||
"conv_title": "Conventions",
|
||||
"conv_description": "How your company is set up.",
|
||||
"col_counterparty": "Counterparty",
|
||||
"col_supplier": "Supplier",
|
||||
"col_account": "Account",
|
||||
"col_vat": "VAT",
|
||||
"col_evidence": "Confidence",
|
||||
"col_last": "Last",
|
||||
"col_rule": "Rule",
|
||||
"col_match": "Match",
|
||||
"col_postings": "Postings",
|
||||
"col_last_used": "Last used",
|
||||
"ev_counts": "{agree} of {seen} times",
|
||||
"src_observed": "Observed",
|
||||
"src_template": "Learned",
|
||||
"src_rule": "Rule",
|
||||
"none_cp": "No counterparty patterns yet.",
|
||||
"vat_registered_label": "VAT registered",
|
||||
"vat_period_label": "Reporting period",
|
||||
"vat_treatments_label": "VAT types used",
|
||||
"vat_no_treatments": "None yet",
|
||||
"conv_method_label": "Accounting method",
|
||||
"conv_series_label": "Voucher series",
|
||||
"conv_salary_label": "Payroll run active",
|
||||
"conv_lag_label": "Typical booking lag",
|
||||
"method_accrual": "Accrual (invoice) method",
|
||||
"method_cash": "Cash method",
|
||||
"method_unknown": "Not set",
|
||||
"period_monthly": "Monthly",
|
||||
"period_quarterly": "Quarterly",
|
||||
"period_yearly": "Yearly",
|
||||
"unknown": "Not set",
|
||||
"yes": "Yes",
|
||||
"no": "No"
|
||||
},
|
||||
"auth": {
|
||||
"login_title": "Sign in",
|
||||
"login_subtitle": "Sign in to manage your finances",
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
"dashboard": "Översikt",
|
||||
"home": "Hem",
|
||||
"assistant": "Assistent",
|
||||
"agent_knowledge": "Vad din agent vet",
|
||||
"bureau": "Byrå",
|
||||
"kpi": "Nyckeltal",
|
||||
"invoice_inbox": "Underlag",
|
||||
@@ -128,6 +129,116 @@
|
||||
"ext_tic": "Företagsprofil",
|
||||
"ext_invoice_inbox": "Dokumentinkorg"
|
||||
},
|
||||
"agentKnowledge": {
|
||||
"title": "Vad din agent vet",
|
||||
"description": "Så här bokför ditt företag, härlett ur din egen bokföring. Det här är sammanhanget din assistent läser innan den kategoriserar eller skapar verifikationer.",
|
||||
"meta_window": "Period",
|
||||
"meta_posted_entries": "Bokförda verifikationer",
|
||||
"meta_lag": "Typisk bokföringstid",
|
||||
"meta_lag_value": "{days} dagar",
|
||||
"meta_computed": "Uppdaterad",
|
||||
"empty_title": "Din assistent har inte lärt sig något än",
|
||||
"empty_description": "När du bokför transaktioner lär sig assistenten hur ditt företag konterar varje motpart, leverantör och konto.",
|
||||
"empty_action": "Bokför transaktioner",
|
||||
"graph_title": "Konteringskarta",
|
||||
"graph_description": "Så här ser assistenten dina motparter och leverantörer flöda in i kontoplanen. Färgen visar rytmen, storleken beloppet och skärpan hur säker konteringen är. Peka på en nod för detaljer.",
|
||||
"graph_hint": "Peka på en nod för att se hur den bokförs.",
|
||||
"graph_account_caption": "{count} motparter",
|
||||
"graph_payee_caption": "sedd {seen} ggr · {share}% lika",
|
||||
"graph_aria": "Radiell karta: {payees} motparter kopplade till {accounts} konton.",
|
||||
"graph_truncated": "visar de vanligaste",
|
||||
"legend_account": "Konto",
|
||||
"legend_counterparty": "Motpart",
|
||||
"legend_supplier": "Leverantör",
|
||||
"legend_edge": "Linjestyrka = träffsäkerhet",
|
||||
"meta_entities": "Spårade motparter",
|
||||
"meta_recurring": "Återkommande",
|
||||
"meta_tracked_spend": "Spårat belopp",
|
||||
"cap_bookings": "{n} bokningar",
|
||||
"cap_variants": "{n} varianter",
|
||||
"cadence_weekly": "~veckovis",
|
||||
"cadence_monthly": "~månadsvis",
|
||||
"cadence_quarterly": "~kvartalsvis",
|
||||
"cadence_irregular": "oregelbundet",
|
||||
"graph_replay": "Spela upp igen",
|
||||
"graph_tally": "transaktioner · {payees} motparter · {accounts} konton, förstådda",
|
||||
"legend_size": "Storlek = belopp",
|
||||
"legend_focus": "Skärpa = säkerhet",
|
||||
"card_variants": "Namnvarianter",
|
||||
"card_confidence": "{share}% säkerhet",
|
||||
"card_evidence": "Bokförd hit i {k} av {n} fall",
|
||||
"comp_title": "Kompetens",
|
||||
"comp_desc": "Kunskapsområden om svensk bokföring och skatt som assistenten bygger på. Kärnkompetensen gäller alla; bransch- och bolagsanpassningen väljs utifrån ditt företag.",
|
||||
"comp_empty": "Inga kunskapsområden ännu.",
|
||||
"comp_count": "{total} kunskapsområden · {active} aktiva",
|
||||
"comp_manage": "Läs mer",
|
||||
"tier_horizontal": "Kärnkompetens",
|
||||
"tier_vertical": "Anpassat för din bransch",
|
||||
"tier_modifier": "Din bolagssituation",
|
||||
"badge_dormant": "Vilande",
|
||||
"facts_title": "Fakta assistenten minns",
|
||||
"facts_desc": "Anteckningar assistenten använder för att ge dig rätt råd. Fästa fakta skickas alltid med.",
|
||||
"facts_empty": "Inga minnen ännu.",
|
||||
"facts_more": "+{n} till",
|
||||
"facts_manage": "Hantera minne",
|
||||
"facts_pinned": "Fäst",
|
||||
"kind_fact": "Fakta",
|
||||
"kind_preference": "Preferens",
|
||||
"kind_pattern": "Mönster",
|
||||
"kind_correction": "Korrigering",
|
||||
"source_composer": "Inläst vid uppstart",
|
||||
"source_user_taught": "Du lärde mig",
|
||||
"source_agent_learned": "Jag noterade",
|
||||
"source_derived": "Härlett",
|
||||
"tab_competence": "Kompetens",
|
||||
"tab_memory": "Minne",
|
||||
"tab_config": "Regler & profil",
|
||||
"footer_basis": "Baserat på {entries} bokförda verifikat · uppdaterad {date}",
|
||||
"cp_title": "Konteringsmönster per motpart",
|
||||
"cp_description": "De vanligaste motparterna från bankflödet och hur de brukar bokföras. Underlaget är historisk frekvens, inte en garanti: assistenten föreslår, du godkänner.",
|
||||
"sup_title": "Mönster per leverantör",
|
||||
"sup_description": "Härlett ur leverantörsfakturor: dominerande kostnadskonto och momstyp per leverantör.",
|
||||
"rules_title": "Dina regler",
|
||||
"rules_description": "Regler du själv satt. De är instruktioner och går före observerade mönster.",
|
||||
"acc_title": "Kontoanvändning",
|
||||
"acc_description": "Konton som faktiskt används, de 20 vanligaste de senaste 12 månaderna.",
|
||||
"vat_title": "Momsprofil",
|
||||
"vat_description": "Observerad momshantering.",
|
||||
"conv_title": "Konventioner",
|
||||
"conv_description": "Hur ditt företag är inställt.",
|
||||
"col_counterparty": "Motpart",
|
||||
"col_supplier": "Leverantör",
|
||||
"col_account": "Konto",
|
||||
"col_vat": "Moms",
|
||||
"col_evidence": "Träffsäkerhet",
|
||||
"col_last": "Senast",
|
||||
"col_rule": "Regel",
|
||||
"col_match": "Matchning",
|
||||
"col_postings": "Bokningar",
|
||||
"col_last_used": "Senast använt",
|
||||
"ev_counts": "{agree} av {seen} ggr",
|
||||
"src_observed": "Observerat",
|
||||
"src_template": "Inlärt",
|
||||
"src_rule": "Regel",
|
||||
"none_cp": "Inga motpartsmönster än.",
|
||||
"vat_registered_label": "Momsregistrerad",
|
||||
"vat_period_label": "Redovisningsperiod",
|
||||
"vat_treatments_label": "Momstyper som använts",
|
||||
"vat_no_treatments": "Inga än",
|
||||
"conv_method_label": "Bokföringsmetod",
|
||||
"conv_series_label": "Verifikationsserier",
|
||||
"conv_salary_label": "Lönekörning aktiv",
|
||||
"conv_lag_label": "Typisk bokföringstid",
|
||||
"method_accrual": "Fakturametod",
|
||||
"method_cash": "Kontantmetod",
|
||||
"method_unknown": "Ej angiven",
|
||||
"period_monthly": "Månadsvis",
|
||||
"period_quarterly": "Kvartalsvis",
|
||||
"period_yearly": "Årsvis",
|
||||
"unknown": "Ej angiven",
|
||||
"yes": "Ja",
|
||||
"no": "Nej"
|
||||
},
|
||||
"auth": {
|
||||
"login_title": "Logga in",
|
||||
"login_subtitle": "Logga in för att hantera din ekonomi",
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
-- Honest confidence for get_ledger_deep_context (data_quality_master.md Item C).
|
||||
--
|
||||
-- The raw dominant_account_share (cnt/total) reads "100% säkerhet" for an
|
||||
-- entity with a single booking: fake certainty by construction (n=1 => 1.0).
|
||||
-- This replace keeps the field name but makes it the Laplace-smoothed share
|
||||
-- (cnt + 1) / (total + 2), the rule-of-succession estimate, so sample size
|
||||
-- shrinks the displayed confidence: 1/1 -> 0.67, 3/3 -> 0.80, 12/13 -> 0.87.
|
||||
-- The raw evidence is now exposed alongside it as dominant_account_count (cnt
|
||||
-- on the dominant account) and dominant_account_total (all counted contra
|
||||
-- lines), so the UI can say "bokförd hit i 12 av 13 fall".
|
||||
--
|
||||
-- Everything else is byte-identical to 20260708130000_ledger_deep_context.sql
|
||||
-- (see that file's header for the full semantics: variant merging, cadence,
|
||||
-- storno/19xx/26xx exclusions, SECURITY INVOKER + RLS scoping).
|
||||
--
|
||||
-- pg-test: tests/pg/ledger-deep-context-rpc.pg.test.ts
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.get_ledger_deep_context(
|
||||
p_company_id uuid,
|
||||
p_from_date date DEFAULT NULL
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
SECURITY INVOKER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
SELECT jsonb_build_object(
|
||||
'counterparty_entities',
|
||||
(
|
||||
WITH booked AS (
|
||||
SELECT
|
||||
public.normalize_counterparty_key(t.merchant_name) AS k,
|
||||
t.merchant_name,
|
||||
t.journal_entry_id,
|
||||
t.date,
|
||||
-- SEK: amount_sek carries the converted value on foreign-currency
|
||||
-- rows and is null on SEK rows, so coalesce keeps everything in SEK
|
||||
-- (mirrors the supplier side's coalesce(total_sek, total)).
|
||||
abs(coalesce(t.amount_sek, t.amount)) AS amount
|
||||
FROM public.transactions t
|
||||
JOIN public.journal_entries je ON je.id = t.journal_entry_id
|
||||
WHERE t.company_id = p_company_id
|
||||
AND t.journal_entry_id IS NOT NULL
|
||||
AND je.status = 'posted'
|
||||
AND je.source_type <> 'storno'
|
||||
AND t.merchant_name IS NOT NULL
|
||||
AND trim(t.merchant_name) <> ''
|
||||
AND (p_from_date IS NULL OR t.date >= p_from_date)
|
||||
),
|
||||
keyed AS (SELECT * FROM booked WHERE k <> ''),
|
||||
-- Median gap between distinct booking dates -> recurrence cadence.
|
||||
distinct_dates AS (SELECT DISTINCT k, date FROM keyed),
|
||||
gaps AS (
|
||||
SELECT k, (date - lag(date) OVER (PARTITION BY k ORDER BY date)) AS gap
|
||||
FROM distinct_dates
|
||||
),
|
||||
recur AS (
|
||||
SELECT k, round(percentile_cont(0.5) WITHIN GROUP (ORDER BY gap))::int AS cadence_days
|
||||
FROM gaps WHERE gap IS NOT NULL GROUP BY k
|
||||
),
|
||||
-- Dominant contra account + its share, over the entity's verifikat lines.
|
||||
acct_counts AS (
|
||||
SELECT b.k, l.account_number, count(*)::bigint AS cnt
|
||||
FROM keyed b
|
||||
JOIN public.journal_entry_lines l ON l.journal_entry_id = b.journal_entry_id
|
||||
WHERE l.account_number NOT LIKE '19%'
|
||||
AND l.account_number NOT LIKE '26%'
|
||||
GROUP BY b.k, l.account_number
|
||||
),
|
||||
acct_totals AS (SELECT k, sum(cnt) AS total FROM acct_counts GROUP BY k),
|
||||
dominant_account AS (
|
||||
SELECT DISTINCT ON (ac.k) ac.k, ac.account_number, ac.cnt, at.total
|
||||
FROM acct_counts ac JOIN acct_totals at ON at.k = ac.k
|
||||
ORDER BY ac.k, ac.cnt DESC, ac.account_number
|
||||
),
|
||||
agg AS (
|
||||
SELECT
|
||||
k,
|
||||
mode() WITHIN GROUP (ORDER BY merchant_name) AS display_name,
|
||||
count(*)::bigint AS occurrences,
|
||||
count(DISTINCT merchant_name)::int AS variant_count,
|
||||
(array_agg(DISTINCT merchant_name))[1:8] AS variants,
|
||||
sum(amount) AS total_amount,
|
||||
min(date) AS first_seen,
|
||||
max(date) AS last_seen
|
||||
FROM keyed GROUP BY k
|
||||
)
|
||||
SELECT coalesce(
|
||||
jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'name', a.display_name,
|
||||
'key', a.k,
|
||||
'variants', to_jsonb(a.variants),
|
||||
'variant_count', a.variant_count,
|
||||
'occurrences', a.occurrences,
|
||||
'total_amount', round(a.total_amount)::bigint,
|
||||
'first_seen', a.first_seen,
|
||||
'last_seen', a.last_seen,
|
||||
'cadence_days', r.cadence_days,
|
||||
'dominant_account_number', da.account_number,
|
||||
-- Laplace-smoothed: n=1 no longer reads as 100%.
|
||||
'dominant_account_share',
|
||||
CASE WHEN da.total > 0 THEN round((da.cnt + 1)::numeric / (da.total + 2), 2) ELSE NULL END,
|
||||
'dominant_account_count', da.cnt,
|
||||
'dominant_account_total', da.total
|
||||
)
|
||||
ORDER BY a.occurrences DESC, a.total_amount DESC, a.display_name
|
||||
),
|
||||
'[]'::jsonb
|
||||
)
|
||||
FROM (SELECT * FROM agg ORDER BY occurrences DESC, total_amount DESC, display_name LIMIT 40) a
|
||||
LEFT JOIN recur r ON r.k = a.k
|
||||
LEFT JOIN dominant_account da ON da.k = a.k
|
||||
),
|
||||
'supplier_entities',
|
||||
(
|
||||
WITH sinv AS (
|
||||
SELECT si.id, si.supplier_id, s.name AS supplier_name,
|
||||
si.invoice_date, si.vat_treatment,
|
||||
coalesce(si.total_sek, si.total, 0) AS amount
|
||||
FROM public.supplier_invoices si
|
||||
JOIN public.suppliers s ON s.id = si.supplier_id
|
||||
WHERE si.company_id = p_company_id
|
||||
AND si.status NOT IN ('reversed', 'credited')
|
||||
AND si.is_credit_note = false
|
||||
AND (p_from_date IS NULL OR si.invoice_date >= p_from_date)
|
||||
),
|
||||
distinct_dates AS (SELECT DISTINCT supplier_id, invoice_date FROM sinv),
|
||||
gaps AS (
|
||||
SELECT supplier_id,
|
||||
(invoice_date - lag(invoice_date) OVER (PARTITION BY supplier_id ORDER BY invoice_date)) AS gap
|
||||
FROM distinct_dates
|
||||
),
|
||||
recur AS (
|
||||
SELECT supplier_id, round(percentile_cont(0.5) WITHIN GROUP (ORDER BY gap))::int AS cadence_days
|
||||
FROM gaps WHERE gap IS NOT NULL GROUP BY supplier_id
|
||||
),
|
||||
acct_counts AS (
|
||||
SELECT v.supplier_id, i.account_number, count(DISTINCT v.id)::bigint AS cnt
|
||||
FROM sinv v JOIN public.supplier_invoice_items i ON i.supplier_invoice_id = v.id
|
||||
GROUP BY v.supplier_id, i.account_number
|
||||
),
|
||||
acct_totals AS (SELECT supplier_id, sum(cnt) AS total FROM acct_counts GROUP BY supplier_id),
|
||||
dominant_account AS (
|
||||
SELECT DISTINCT ON (ac.supplier_id) ac.supplier_id, ac.account_number, ac.cnt, at.total
|
||||
FROM acct_counts ac JOIN acct_totals at ON at.supplier_id = ac.supplier_id
|
||||
ORDER BY ac.supplier_id, ac.cnt DESC, ac.account_number
|
||||
),
|
||||
agg AS (
|
||||
SELECT
|
||||
supplier_id,
|
||||
max(supplier_name) AS supplier_name,
|
||||
count(*)::bigint AS occurrences,
|
||||
sum(amount) AS total_amount,
|
||||
min(invoice_date) AS first_seen,
|
||||
max(invoice_date) AS last_seen,
|
||||
mode() WITHIN GROUP (ORDER BY vat_treatment) AS dominant_vat
|
||||
FROM sinv GROUP BY supplier_id
|
||||
)
|
||||
SELECT coalesce(
|
||||
jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'name', a.supplier_name,
|
||||
'key', a.supplier_id::text,
|
||||
'variants', to_jsonb(ARRAY[a.supplier_name]),
|
||||
'variant_count', 1,
|
||||
'occurrences', a.occurrences,
|
||||
'total_amount', round(a.total_amount)::bigint,
|
||||
'first_seen', a.first_seen,
|
||||
'last_seen', a.last_seen,
|
||||
'cadence_days', r.cadence_days,
|
||||
'dominant_account_number', da.account_number,
|
||||
-- Laplace-smoothed: n=1 no longer reads as 100%.
|
||||
'dominant_account_share',
|
||||
CASE WHEN da.total > 0 THEN round((da.cnt + 1)::numeric / (da.total + 2), 2) ELSE NULL END,
|
||||
'dominant_account_count', da.cnt,
|
||||
'dominant_account_total', da.total,
|
||||
'dominant_vat', a.dominant_vat
|
||||
)
|
||||
ORDER BY a.occurrences DESC, a.total_amount DESC, a.supplier_name
|
||||
),
|
||||
'[]'::jsonb
|
||||
)
|
||||
FROM (SELECT * FROM agg ORDER BY occurrences DESC, total_amount DESC, supplier_name LIMIT 20) a
|
||||
LEFT JOIN recur r ON r.supplier_id = a.supplier_id
|
||||
LEFT JOIN dominant_account da ON da.supplier_id = a.supplier_id
|
||||
)
|
||||
);
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.get_ledger_deep_context(uuid, date) FROM PUBLIC, anon;
|
||||
GRANT EXECUTE ON FUNCTION public.get_ledger_deep_context(uuid, date) TO authenticated, service_role;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -69,6 +69,8 @@ type DeepEntity = {
|
||||
cadence_days: number | null
|
||||
dominant_account_number: string | null
|
||||
dominant_account_share: number | null
|
||||
dominant_account_count: number | null
|
||||
dominant_account_total: number | null
|
||||
dominant_vat?: string | null
|
||||
}
|
||||
type Deep = { counterparty_entities: DeepEntity[]; supplier_entities: DeepEntity[] }
|
||||
@@ -129,7 +131,10 @@ describe('get_ledger_deep_context', () => {
|
||||
expect(klarna!.variant_count).toBe(3)
|
||||
expect(klarna!.variants.length).toBeGreaterThanOrEqual(3)
|
||||
expect(klarna!.dominant_account_number).toBe('5420')
|
||||
expect(klarna!.dominant_account_share).toBe(1)
|
||||
// Laplace-smoothed (3+1)/(3+2): consistent history, but n=3 is not certainty.
|
||||
expect(klarna!.dominant_account_share).toBe(0.8)
|
||||
expect(klarna!.dominant_account_count).toBe(3)
|
||||
expect(klarna!.dominant_account_total).toBe(3)
|
||||
expect(klarna!.total_amount).toBe(300)
|
||||
expect(klarna!.first_seen).toBe('2026-04-01')
|
||||
expect(klarna!.last_seen).toBe('2026-06-01')
|
||||
@@ -145,6 +150,11 @@ describe('get_ledger_deep_context', () => {
|
||||
expect(sl!.variant_count).toBe(1)
|
||||
expect(sl!.cadence_days).toBeNull()
|
||||
expect(sl!.dominant_account_number).toBe('5810')
|
||||
// The P3 bug this guards: a single booking must NOT read as 100%.
|
||||
// Laplace (1+1)/(1+2) = 0.67, with the raw 1-of-1 evidence exposed.
|
||||
expect(sl!.dominant_account_share).toBe(0.67)
|
||||
expect(sl!.dominant_account_count).toBe(1)
|
||||
expect(sl!.dominant_account_total).toBe(1)
|
||||
})
|
||||
|
||||
it('aggregates supplier entities with spend and dominant account', async () => {
|
||||
@@ -154,6 +164,10 @@ describe('get_ledger_deep_context', () => {
|
||||
expect(telia!.occurrences).toBe(2)
|
||||
expect(telia!.total_amount).toBe(1000)
|
||||
expect(telia!.dominant_account_number).toBe('6212')
|
||||
// Laplace-smoothed (2+1)/(2+2).
|
||||
expect(telia!.dominant_account_share).toBe(0.75)
|
||||
expect(telia!.dominant_account_count).toBe(2)
|
||||
expect(telia!.dominant_account_total).toBe(2)
|
||||
expect(telia!.dominant_vat).toBe('standard_25')
|
||||
expect(telia!.cadence_days).toBeGreaterThanOrEqual(30)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user