Agent v1 bundle: TIC v2 onboarding, in-app assistant gating, sidebar nav, MCP fixes (#584)

* fix(sie-import): accept tab as field separator (Bollbok exports)

The SIE 4 spec allows either space or tab between fields, but
splitSIELine() only treated space (0x20) as a separator. Bollbok
exports tab-separated lines for every record except #RAR, which
silently swallowed all #IB / #UB / #KONTO / #KTYP / #VER / #TRANS
records — imports appeared empty even though the file was well-formed.

Also adds a parser-side diagnostic that emits a warning when raw #IB
or #VER lines are present in the input but parsing produced none. The
previous silent failure is how this bug stayed hidden; the warning
gives the import preview something visible to surface next time.

Verified against two real reproducer files (Sean / Erik Hellqvist):
  erik h 2025.SE (UTF-8): 166 accounts, 66 IB, 4 UB, 11 RES, 95 vouchers, 198 TRANS.
  erik h 2026.SE (CP437): 166 accounts, 66 IB, 4 UB, 0 vouchers.
Both now parse with zero warnings/errors.

Tests:
  + 8 Bollbok-shape tab-separated fixtures (2025 + 2026 quoting variants).
  + 4 silent-failure diagnostic-warning tests.
  All 74 sie-parser tests pass; 155/155 in lib/import; 64/64 downstream callers.

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

* fix(sie-import): address PR #513 review — strip #KTYP quotes, suppress redundant aggregate warning

Two non-blocking P2 findings from Greptile review on PR #513:

1. #KTYP handler stored fields[2] directly, so Bollbok 2026 exports
   (#KTYP\t1510\t"T") stored '"T"' with literal quotes instead of 'T'.
   Latent defect — accountType is unused downstream today, but my tab-
   separator fix made the quoted-value path reachable. Now routes through
   parseStringField so both Bollbok 2025 (unquoted T) and 2026 (quoted "T")
   land as 'T'.

2. The aggregate "kontrollera fältavskiljare och teckenkodning" warning
   fired alongside per-record 'error'-severity issues for malformed #IB /
   #VER records, producing a misleading hint when the parser had already
   pinpointed the structural problem. Now suppressed when an error-severity
   issue with the same tag already exists.

Test coverage:
  + accountType asserted to be 'T' (not '"T"') in both 2025 + 2026 shapes.
  + VER aggregate-warning test now uses #VER lines without { } blocks
    (silent loss, no per-record error) — the canonical case the diagnostic
    is designed for.
  + New suppression test: bare #VER produces per-record errors AND the
    aggregate warning is absent.

75/75 sie-parser tests pass; 156/156 in lib/import.

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

* wip: agent chat + composer + memory + document extraction

In-progress work on this branch beyond the SIE-import fixes:
- Specialized accountant agent (composer + intents + chat loop)
- Persistent agent_conversations/messages, agent_profiles, agent_memory
- /chat surface + /onboarding/agent + /settings/agent-memory
- document-extraction extension with status hooks
- MCP server staging refactor + new skills (atoms, bank reconciliation,
  customer onboarding, kreditfaktura)
- pending_operations rejection feedback (category + reason) + realtime
- TIC company profile cached snapshot on companies
- 17 migrations (all additive — see prior conversation analysis)

Parked while branch waits for review/merge. Migrations are already
applied to prod.

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

* refactor(tic): migrate company-data client from api-core v1 to Lens v2

Swaps the seven TIC company-data endpoints we call from the api-core
paths (`/datasets/companies/{companyId}/...`, `/search/companies`) to
the Lens equivalents (`/companies/{id}/...`, `/search-public/companies`).
Hard cutover; proxy pattern preserved.

Schema shifts handled inside the extension so consumers (TicWorkspace,
Step2CompanyDetails) don't need changes:

- `/companies/{id}/bank-accounts` now returns Bankgirot only — map to
  the existing `{ type, accountNumber, bic }` shape, drop terminated.
- `/companies/{id}/industries` returns a discriminated array — filter
  to `companyIndustryCodeType === 'sni2007'` to preserve v1 behavior.
- `/companies/{id}/phone-numbers` renamed the field to
  `phoneNumberFormatted` (fall back to `e164PhoneNumber`).
- `/companies/{id}/documents` replaces `/financial-report-summaries`;
  filter `type === 'annualReport'` and read nested
  `financialReportMetadata` to rebuild the legacy summary shape.
- `isCeased` is now a top-level boolean; `activityStatus` is an enum.
  Translate enum -> 'ceased' for the workspace's existing check.

BankID identity flow (id.tic.io) is untouched — separate TIC product.

Note: deploy gated on the TIC proxy being flipped to lens-api.tic.io
with an `x-api-key` Lens key.

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

* feat(tic): expose v2 onboarding & workspace data

Adds six new Lens (v2) fetchers on top of the migration that already
landed in this branch, surfacing the data through /lookup and /profile.

New fetchers in lib/tic-client.ts:
- getFiscalYears          /companies/{id}/fiscal-years
- getAccountingPeriods    /companies/{id}/accounting-periods
- getPayrolls             /companies/{id}/payrolls
- getSignatory            /companies/{id}/signatory
- getRepresentatives      /companies/{id}/representatives
- getCompanyStatus        /companies/{id}/status

/lookup gains a fiscalYear field (current fiscal-year configuration)
so onboarding Step 2 can skip manual MM-DD entry. CompanyLookupResult
extended with optional fiscalYear; consumers without it keep working.

/profile gains five new sections on TICCompanyProfile:
- fiscalYear + fiscalYearHistory   current + deduped period list
- signatory                        firmateckning descriptions
- board + representatives          board-composition summary + active
                                   officers (positionEnd in future)
- payrolls                         payroll2 array newest-first, with
                                   deviation vs annual-report
- statuses                         current+historical status entries
                                   with red/yellow/green/neutral color

TicWorkspace renders the new data as four cards (Status, Fiscal year +
Signatory, Board + Representatives, Payroll history) plus a Badge
mapping for the traffic-light status color.

Tests: 52 -> 60 passing. Added unit tests for the new fetchers' v2
paths, fiscal-year auto-fill in /lookup, and full v2 profile coverage.

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

* feat(onboarding,agent): lean on TIC v2 to skip Steps 1 & 3 and sharpen Opus

Three small wins that unlock more of the v2 cutover. No new endpoints — the
data was already in the snapshot, just not flowing where it should.

Step 1 (entity_type) — deep-link path only:
- /lookup now returns `legalEntityType` and `registrationDate` (added to
  CompanyLookupResult).
- /onboarding/page.tsx does a server-side /lookup prefetch when
  ?org_number= is present (BankID picker path), maps "AB"/"EF" to the
  EntityType enum, and seeds Step 1's radio. Falls through silently for
  unsupported codes (HB, KB, …) and on TIC errors.
- WelcomeOnboarding hydrates ticLookup state from the server prefetch so
  Step 2's debounced client fetch and Step 3's first-year inference both
  have data on first render — no flash.

Step 3 (is_first_fiscal_year) — every path:
- deriveFirstYearDefaults() parses ticLookup.registrationDate and returns
  { isFirstFiscalYear, firstYearStart } when registered <12 months ago.
  Step 3's initialData picks it up; the user only confirms the end date.
- Settings value wins when present so existing users with a saved choice
  don't get overridden.

Composer prompt:
- redactTic allowlist was the bottleneck — it stripped beneficialOwners,
  signatory, board, representatives, payrolls, statuses, fiscalYear
  before Opus ever saw the JSON. Existing filterRedundantQuestions
  ownership logic was effectively dead because the data path was severed.
  Expanded allowlist to include those v2 sections; kept bankAccounts/
  email/phone/fiscalYearHistory/financialReports out (token cost > signal).
- SYSTEM_PROMPT now documents each v2 section and the rules Opus should
  apply: payroll signal switches from "registration.payroll" to "actual
  payrolls[] filings" (kills the false-positive swedish-payroll selection
  for newly registered employers); beneficialOwners[] becomes the
  authoritative ownership source (single owner → FMB modifier; multiple →
  multi-owner); statuses[] isCeased/red triggers an uncertainty_note.

Tests: 4112 unchanged. Build: green. No schema or migration changes.

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

* fix(agent): onboarding polish + composer signal fixes from first-run feedback

UX:
- AgentOnboarding: drop the 10s "Hoppa över — fortsätt med standardval"
  escape hatch. The fallback path runs automatically on timeout; the
  manual skip just teased users into a degraded build.
- ReviewCard step 2 title: "Stämma av detaljerna" → "Stäm av detaljerna"
  (imperative form matches the rest of the steps).
- Drop em-dashes from user-visible Swedish strings in AgentOnboarding +
  ReviewCard (fallback labels, subtitles, placeholder, error message,
  final CTA). Em-dashes survive in code comments only.
- "Fråga min revisor" → "Fråga min assistent" everywhere it surfaced:
  AgentTrigger, AgentSparkleButton, ReviewCard preview, ReviewCard
  fallback comment, general.help intent buttonLabel + prompt text.
- AgentTrigger / AgentSparkleButton / EmptyState.AgentHelpLink /
  TransactionInboxCard ask-button all gated on identity.isVerified.
  Pre-onboarding users no longer see the floating FAB or per-page
  Sparkle buttons. AgentSheetProvider.identity gained an isVerified
  field; (dashboard)/layout.tsx selects agent_profiles.verified_at and
  passes it through.

TIC verksamhetsbeskrivning:
- tic/index.ts /profile: /companies/{id}/purposes returns every
  historical verksamhetsföremål filing. Picking [0] was returning the
  oldest "äga och förvalta" holding-company boilerplate for companies
  whose later filings narrowed the purpose ("tillhandahålla
  företagskrediter och finansiella teknologilösningar"). Sort the
  array by lastUpdatedAtUtc desc and take the most recent non-empty
  purpose.

Composer banking signal:
- loadBankingSummary now reads journal_entry_id alongside
  description/amount/date and returns per-counterparty `direction`
  ('in' | 'out' | 'mixed') and `has_unbooked` (any row not yet booked).
  Aggregate `unbooked_count` accompanies the rollup.
- buildUserPrompt emits each counterparty as
  `Name: 12 345 kr (ut, OBOKFÖRD)` so Opus can tell income from cost
  on sight and tell which counterparties are still open questions.
- SYSTEM_PROMPT now explicitly forbids verification questions about
  counterparties whose direction is unambiguous AND status is 'bokförd'.
  Should kill the regressions from the first agent build:
  * "Konsult, J 98 565 kr — intäkt eller kostnad?" when the amount is
    clearly negative.
  * "ALMI AB 493 000 kr — lån eller bidrag?" when the transaction is
    already categorized.

Tests: 4112 unchanged. Build: green.

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

* fix(agent,ui): representation needs deltagare+syfte, drop duplicate doc icon

Representation booking:
- transaction-categorization prompt now requires the agent to capture
  participants (name + company) AND purpose before staging a
  representation categorization. SKV's representationsregler + ML 8 kap
  require the verifikation to document who attended and what the
  meeting was about; without that the avdrag is denied and the post
  should be booked as non-deductible / personalkostnad.
- The agent confirms back in plain text (audit trail in the chat),
  writes the deltagare + syfte to gnubok_remember_fact (long-term),
  THEN stages. Saknas deltagare/syfte: explicitly tell the user the
  avdrag won't go through and offer the non-deductible alternative.
- Known gap (followup, not this commit): the staged op's journal entry
  description doesn't yet carry the deltagare text. Until we add a
  `notes` field to gnubok_categorize_transaction, the audit trail
  lives in chat + agent_memory only.

TransactionInboxCard duplicate attachment indicator:
- Drop the FileCheck2 "open document" button from the trailing slot.
  TransactionAttachmentIndicator (Paperclip) next to the description
  already opens the underlag on click. Two icons doing the same thing
  was noise. Cleaned up the unused state (isOpeningDoc, hasAttachment,
  handleOpenAttachment) and dropped now-unused imports (FileCheck2,
  useToast).

Tests: 4112. Build: green.

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

* feat(agent,nav): notes on verifikation + redesigned sidebar

Audit-trail notes for representation:
- gnubok_categorize_transaction gains an optional `notes` string.
  Threaded through stagePendingOperation → commitCategorizeTransaction →
  createTransactionJournalEntry, which now appends notes to the entry's
  description (capped at 500 chars). The verifikation an external auditor
  reads now carries deltagare + syfte directly — not just chat history /
  agent_memory.
- transaction-categorization prompt updated: representation flow now
  REQUIRES the agent to pass deltagare+syfte via the notes parameter.
  Without it the booking is non-deductible / personalkostnad per SKV.

DashboardNav redesign:
- Top section: flat, no header — Hem (/chat), Underlag (was
  Dokumentinkorg), Transaktioner, Granskning. Always visible; the inline
  badge on /pending shows the count when there are pending ops.
- Mid section: four collapsible dropdowns (Försäljning, Inköp,
  Redovisning, Personal). Each auto-expands when the active route lives
  inside it. KPI moved from main to Redovisning. Extension nav items
  (TIC workspace, etc.) fold into Redovisning.
- Bottom-left: new account popover (DropdownMenu, opens upward) holding
  CompanySwitcher, Inställningar, Hjälp, Support, Logga ut. Replaces
  the old top company-switcher card + the bottom Support/Logout block.
- Mobile drawer mirrors the new structure: top items as flat list,
  same four dropdown groups, separate "Tillägg" section when
  extensions exist, "Mitt konto" section at the bottom.
- i18n: invoice_inbox label renamed "Dokumentinkorg" → "Underlag"
  ("Documents" in en). New keys: mitt_konto, group_extensions.

Tests: 4112. Build: green.

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

* fix(nav): unhide Leverantörer under Inköp

The /suppliers entry existed in navItems but was marked hidden — leftover
from when the supplier list lived elsewhere in the IA. Removing the
hidden flag puts Leverantörer in the Inköp dropdown alongside
Leverantörsfakturor.

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

* fix(nav): CompanySwitcher back to top-left, user account moves bottom-left

The previous pass collapsed both concepts into the bottom popover. They
mean different things: the company is the org context everything below
operates against (top-of-sidebar, scannable); the user is the
account-holder (bottom-of-sidebar, where settings/logout live).

- (dashboard)/layout.tsx: fetch profiles.full_name alongside the
  existing identity queries; pass userName + userEmail into
  DashboardNav.
- DashboardNav: restore CompanySwitcher at the top of the sidebar
  (pre-redesign placement). Bottom-left popover trigger now shows the
  signed-in user's name + single-letter initial (accountInitial helper
  falls back to email's first char, then "?"). Popover header carries
  full name + email; items unchanged (Inställningar, Hjälp, Support,
  Logga ut). CompanySwitcher removed from inside the popover — nested
  dropdowns were awkward and the top placement is where it belongs.

Tests: 4112. Build: green.

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

* fix(pending): trim the agent context strip

The row-level AgentContextStrip on /pending was rendering the model
name (eu.anthropic.claude-sonnet-4-6) and the full atoms array
(horizontal/swedish-vat, vertical/konsult-it, …) inline, which made
each row 60–80 chars of mostly-the-same metadata. Reviewers never
scan that text; they scan amounts and decide approve/reject.

Now the strip shows only the conversation deep-link
(Konversation #<short id>) — the one piece that's actually useful for
diving into context. Model + atoms remain available in agent_metadata
for debugging surfaces; they're just not in the list view.

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

* fix(agent): shared ground rules + paragraph breaks after tool calls

Two regressions surfaced in real usage. Both are systemic.

Shared agent ground rules:
- /chat surface (general.help) was happily inventing four-digit BAS
  account numbers ("Debet 6212 - Molntjänster…", "Kredit 2614 - Ingående
  moms…") and proposing booking decisions on invoices it had never
  seen, with no follow-up questions about currency/scope/etc.
- transaction-categorization had those rules baked into its prompt;
  general-help / bokslut-step / invoice-draft / supplier-invoice-review
  / verifikation-draft / vat-review never inherited them.
- Extracted lib/agent/intents/shared-rules.ts with five cross-cutting
  rules: underlag first (check inbox + ask user to upload to
  Dokumentinkorgen when missing), ask follow-ups when ambiguous, never
  write four-digit BAS account numbers in chat (category names only),
  cite atoms / load skills (don't guess), check counterparty history
  before proposing.
- Injected renderAgentGroundRules() into all six intents above.
  transaction-categorization left alone — it has more detailed inline
  rules tied to its specific underlag-flow.

Paragraph break after tool calls:
- text_delta from the model often resumes after a tool call without a
  leading newline ("kategoriseras." → gnubok_query_journal runs → "Inget
  historik hittades…" appended directly). Markdown rendered the
  concatenation as one paragraph.
- AgentChat text_delta handler now inserts \n\n when (a) the buffer
  ends with text content, (b) the incoming delta starts with text
  content, (c) at least one tool call has run, and (d) the buffer
  doesn't already end with a blank line.

Tests: 4112. Build: green.

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

* fix(nav): default-open dropdown groups; closing is per-user

Dropdowns started collapsed which meant first-time users had to open
each group to discover what's inside. Inverted the state: default open,
user can collapse, active route still forces a group open.

- manualExpanded → manualCollapsed (semantics flip)
- toggleGroup unchanged externally; flips the bit
- isGroupExpanded returns !manualCollapsed[g] || hasActiveChild

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

* feat(agent): rate-safe v1→v2 TIC upgrade, counterparty defaults, profile settings

Three pre-ship quality wins.

Rate-limit-safe TIC v2 upgrade:
- The /profile endpoint fans out to ~13 Lens calls; the account has a
  ~3000/mo ceiling. Force-refreshing every pre-v2 (v1) snapshot across
  the customer base would blow the budget.
- ensureTicSnapshot gains an `upgradeV1` flag. A cached snapshot still
  inside the 7-day window is re-fetched only when (a) the caller passes
  upgradeV1 AND (b) the snapshot is v1-shaped (missing the v2-only
  `statuses` key). Gated to the two agent-onboarding call sites — a
  deliberate, once-per-company action and the only consumer of the v2
  sections. Workspace + signup keep the natural 7-day staleness, so the
  v1→v2 migration is lazy and bounded to companies actually building an
  agent.

Known-counterparty defaults (shared-rules):
- Agent now proposes a sensible default for well-known counterparties
  instead of asking the same question monthly: Almi → lån, Tillväxtverket/
  Vinnova/EU-stöd → bidrag, Skatteverket → skatt/avgift or återbäring,
  Bolagsverket → avgift, Försäkringskassan → ersättning, EF private
  withdrawal → eget uttag. Stated as an assumption the user can correct,
  not a hard rule — underlag/history still wins.

Företagsprofil settings page:
- New /settings/agent-profile (Företagsprofil / "Company profile"):
  view + edit the agent's company profile after onboarding — assistant
  name + avatar, the profile summary the agent reasons from, and a
  read-only chip view of loaded specialities (atoms). Backed by the
  existing GET/PATCH /api/agent/profile.
- New GET /api/agent/atom-titles?ids= resolves atom slugs → human titles
  for the chips (registry is globally-readable reference data).
- Added to SettingsSidebar; i18n keys agent_profile (sv "Företagsprofil"
  / en "Company profile").

Note: /chat already redirects unverified users to / (chat layout guard),
and / renders WelcomeGate → /onboarding/agent. No redirect work needed.
AgentSetupBanner.tsx is orphaned dead code (WelcomeGate superseded it).

Tests: 4112. Build: green. Both new routes compile.

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

* feat(nav,agent): Hem=Översikt + separate Assistent button; memory dedup

Nav restructure:
- "Hem" now points to / (Översikt dashboard) again, not /chat. The agent
  chat gets its own top-level nav entry "Assistent" (Sparkles icon) → /chat.
  Mobile bottom nav mirrors this (Hem / Assistent / Transaktioner).
- / restored to render DashboardContent (the Översikt) for built-agent
  users instead of redirecting to /chat. Users who haven't built their
  assistant yet still get WelcomeGate (the build-agent checklist); once
  verified, / shows the dashboard. Chat is reachable anytime via its nav
  entry. Restored main's dashboard data-fetch; added an agent_profiles
  verified_at probe to drive the WelcomeGate branch.
- i18n: nav.assistant ("Assistent" / "Assistant").

agent_memory dedup (gnubok_remember_fact):
- The agent re-remembers the same fact constantly (e.g. "Vercel = omvänd
  skattskyldighet" on every Vercel categorization), which would bloat
  agent_memory with paraphrases over months.
- Before insert, compare the incoming fact against the 300 most-recent
  active memories by word-set Jaccard similarity (lowercased, punctuation-
  stripped, stopwords dropped). A near-duplicate (≥0.82) is treated as
  already-known: bump its relevance toward the new score + refresh
  updated_at instead of writing a new row. Embedding-free, zero added
  latency beyond one bounded SELECT.

Tests: 4112. Build: green.

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

* fix(agent,nav): företagsprofil=Bolagsuppgifter, avatar nav icon, dedupe greeting

Företagsprofil settings page (the right content this time):
- Replaced the agent atoms/summary panel with CompanyProfileView — a
  read-only "Bolagsuppgifter" view of the cached TIC company snapshot
  (name, org-nr, form, address, F-skatt/Moms/Arbetsgivare, SNI, bank,
  verksamhet, employees, latest financials, status traffic-lights,
  fiscal year, firmateckning, företrädare). Server component reads the
  companies.tic_snapshot column directly — no extension import, stays
  inside the core-build boundary.
- Route renamed /settings/agent-profile → /settings/company-profile.
  Removed the old AgentProfilePanel + the now-unused /api/agent/atom-titles
  endpoint.

"Assistent" nav icon = the agent's chosen avatar:
- DashboardNav reads agent identity from AgentSheetProvider and renders
  the onboarding-chosen avatar for the /chat ("Assistent") entry across
  desktop sidebar, mobile drawer, and mobile bottom nav. Falls back to
  the Sparkles glyph pre-onboarding (no avatar yet).

Nav cleanup:
- Dropped the beta badge from Underlag.
- Filtered the TIC workspace (/e/general/tic, "Företagsprofil") out of
  the nav — the same Bolagsuppgifter now lives under Inställningar →
  Företagsprofil, so it shouldn't appear in two places.

Doubled intake greeting fix:
- /chat/intake fires an invoke with no conversation_id, then swaps the
  URL to /chat/[id] the instant the `conversation` event lands — which
  can beat the greeting being persisted. /chat/[id] then hydrated with 0
  messages and, because the auto-fire guard keyed on (id && messages>0),
  fired a SECOND invoke on the same conversation → two greetings.
  Guard now keys on conversation-id presence alone: a set id means
  resume, never bootstrap. Closes the race.

Tests: 4112. Build: green.

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

* fix(agent): paragraph-break-after-tool split words mid-stream

The earlier "insert \n\n when text resumes after a tool call" heuristic
re-evaluated on EVERY text_delta (any delta not starting/ending with
whitespace, once a tool had run). Streaming deltas arrive in sub-word
chunks, so it injected breaks between fragments of the same word:
"minnes\n\nno\n\nterna", "kund\n\nrep\n\nresentation".

Replace the per-delta heuristic with a consume-once ref:
- tool_use sets breakBeforeNextTextRef = true
- the next text_delta consumes it: prepends \n\n exactly once (only when
  the buffer has content, doesn't already end in whitespace, and the
  delta doesn't start with whitespace), then clears the flag

So the break fires once per tool→text resume, never mid-word.

Build: green.

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

* fix(agent): much shorter replies, representation headcount + VAT cap, dot separator

Brevity (system-prompt Svarsformat — affects every reply):
- Hard "korthet är regel nummer ett": aim for 2-4 sentences, lead with
  the answer/action, no warm-up ("Här är vad som gäller…"), don't derive
  VAT in prose, don't restate what the approval card shows, one question
  at a time. The agent was writing textbook-length essays.

Representation rule now in shared-rules (so verifikation-draft, vat-review,
etc. all get it — previously only transaction-categorization had it, which
is why the verifikation flow guessed 25% VAT and skipped the cap):
- Require ANTAL deltagare (headcount), not just one name — the moms
  deduction is per person (underlag cap 300 kr/person ex moms).
- Use the receipt's ACTUAL VAT rate (usually 12% on food), never assume
  25%.
- Meal representation isn't income-tax deductible (post-2017); whole cost
  booked as non-deductible representation.

Verifikation description separator:
- createTransactionJournalEntry appended notes with an em-dash
  ("Utlägg Eatnam — Deltagare:…"), violating house style. Switched to a
  middle dot " · ". journal_entries has no separate notes column — the
  description IS the BFL verifikationstext / audit field, so deltagare +
  syfte correctly live there.

Tests: 4112. Build: green.

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

* fix(settings): tidy Bolagsuppgifter — no status colours, clean firmateckning

From first-look feedback on the Företagsprofil page:

- Status: dropped the coloured traffic-light badges (red/yellow/green).
  Per the design system semantic colour is data-only, never chrome, so
  status now renders as plain label + date. Also filtered to dated
  entries only — Bolagsverket emits flags like "Har aldrig varit verksam"
  with no date that read as noise next to the real status. Ceased status
  gets muted destructive text (the one chrome colour the system keeps).

- Firmateckning: the source text carries ">" list markers and crams
  several rules onto one line, and repeats "Firman tecknas av styrelsen"
  across rows. cleanSignatory() strips the markers, normalises whitespace,
  splits run-on "Firman tecknas …" clauses onto separate lines, and the
  render dedupes — so each rule reads as its own sentence.

Build: green.

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

* fix(mcp): inbox items expose all terminal links + processed flag

The Eatnam receipt was booked against its bank transaction (so the inbox
row had matched_transaction_id + created_journal_entry_id set), yet the
agent reported it as loose/unmatched and a duplicate risk. Root cause:
gnubok_list_inbox_items only selected and returned matched_supplier_id +
created_supplier_invoice_id — the supplier-invoice path. The
transaction-match and direct-journal-entry paths were invisible, so any
receipt cleared via /transactions looked unprocessed.

- list_inbox_items now selects + returns matched_transaction_id and
  created_journal_entry_id alongside the supplier fields, plus a derived
  `processed` boolean (true when ANY of the three terminal links is set).
- New unprocessed_only=true input filters to items with no terminal link
  — the "what still needs handling" view that prevents the agent from
  flagging already-booked docs as duplicates. (Fetches a wider window
  then filters client-side so limit applies post-filter.)
- Description updated to document the processed semantics, within the
  280-char tool-description budget.

The DB linkage itself already worked: /transactions attach-document sets
matched_transaction_id, and commitCategorizeTransaction stamps
created_journal_entry_id. This was purely a read/surface gap.

Tests: 4112 (+ MCP description guard). Build: green.

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

* fix(mcp): repair stage-but-never-commit tools + consolidate tool surface

- post_annual_depreciation AND reverse_entry were never in the pending_operations operation_type CHECK, so both staged then died with check_violation at INSERT. Add the CHECK migration, a commitPostAnnualDepreciation executor (reusing commitAnnualPostings), risk tier, and the PendingOperationType union member.
- Salary tools de-risked: calculate_salary_run calls runSalaryCalculation() directly (no self-fetch/forged cookie); create_salary_run uses a transactional create-run helper with compensating delete; generate_agi actually generates + persists the declaration.
- import_sie parses + validates at stage time with a content-rich preview (company, fiscal year, voucher/account counts, balance) instead of a blind byte count.
- batch-match-invoices passed user.id where companyId was expected (silently matched zero).
- VAT report+widget merged behind render_ui; gnubok_search_tools ranks by relevance; gnubok_feedback readOnlyHint corrected; tools/list instruction text fixed; income decision-tree + GL/query_journal cross-refs added.

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

* feat(agent): load skill atom bodies from the DB so they survive the build

Skill bodies were read from disk at runtime (.claude/skills/**/SKILL.md); on Vercel the dynamic readFile path isn't traced into the lambda and on Docker .claude/ is excluded, so atoms loaded EMPTY in production — a despecialized agent. Inline the bodies into agent_atom_registry instead:
- Migration adds body + mcp_exposed columns; a build-time generator (scripts/generate-skill-bodies.ts) emits a deterministic dollar-quoted seed migration with a content-hash manifest + --check CI guard.
- Read sites (mcp-server atoms.ts, chat system-prompt.ts, composer prewarm) read body from the DB, with a dev-only disk fallback. mcp_exposed curates which atoms the MCP exposes (swarm-* never become atoms).
- The seed script + generator share scripts/lib/atom-discovery.ts; estimated_tokens now reflects SKILL.md only.

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

* feat(agent): safe the in-app assistant — gating, FAB de-confliction, rate limit, friendly errors

- Hide all agent entry points until verified_at: the Assistent nav tab (sidebar + mobile) and the agent-memory settings tab now match the floating FAB's gate.
- FAB de-confliction: /kpi -> kpi.explain and /bookkeeping/year-end -> bokslut.step so the floating button opens the SAME assistant as the page button (no two-agents-on-one-page).
- Generous per-user rate limit (30/min, 1000/day) on /api/agent/invoke, /onboarding/stream, /composer via a new agent_rate_counters table + check_and_increment_agent_quota RPC; fails open. Bounds runaway Bedrock spend without touching normal users.
- Friendly errors: Bedrock 429/timeout/5xx normalized to Swedish (friendlyModelError) in run-turn + the invoke route; the chat client surfaces the server's friendly message instead of a raw HTTP status.
- /chat/new validates ?intent= against the registry so bad deep-links fall back to general.help instead of rendering a broken-looking error.

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

* fix(agent): keep /chat read-only — redirect categorization + swap the "categorize" suggestion for a VAT-report question

general.help (the /chat assistant) is read-only, but it still gave per-transaction bokföringsförslag in prose and asked "godkänner du dessa?" — an analysis the user can't act on (no write tool, no per-tx underlag). Strengthen the prompt to redirect categorization/bokföring to the per-transaction flow (open the transaction -> "Fråga om denna transaktion", where the agent sees the underlag and stages a real ApprovalCard); a short overview is still allowed. Add a guard test locking in no-write-tools + the redirect language. Swap the /chat empty-state "Hjälp mig kategorisera" chip (which lured users into exactly this dead-end) for a VAT-report question the read-only assistant can actually answer.

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

* refactor(pending): declutter the review queue rows + header

Fold the conversation deep-link onto the actor label (drop the separate
"Konversation #xxxx" strip and its icon), hide the quick-pick when there's
only one operation type (it duplicated "Markera alla"), and drop the "(0)"
from the disabled bulk-approve button.

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

* feat(vat): enhance VAT handling by integrating document validation and improving error messaging

* feat(settings): add assistant knowledge surface + consolidate settings tabs

Expose the agent's skill atoms (agent_atom_registry) in a read-only surface
beside the existing memory view, and tighten the settings tab bar from 14 to
10 tabs.

- New GET /api/agent/skills + AgentSkillsPanel: lists active, mcp_exposed
  atoms grouped by tier (Kärnkompetens / bransch / bolagssituation), flags
  which are active for the company from agent_profiles, and lazy-loads each
  SKILL.md body on expand.
- New /settings/assistant tab with a Minne/Kompetens toggle (?view=skills);
  /settings/agent-memory and /settings/agent-skills redirect into it.
- Merge Företagsprofil (TIC snapshot) into the Företag tab via
  CompanyProfileSection; /settings/company-profile redirects.
- Merge Skatteverket-anslutningen into the Skatt tab — OAuth returnTo and the
  callback toast now target /settings/tax; /settings/skatteverket redirects.
- Drop the Säkerhetsbackup tab (already under Importera/Exportera).

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

* fix(inbox): keep booked underlag out of the unmatched queue + widen match window

- categorize: after booking an inbox underlag onto a verifikat, backfill the
  inbox row's matched_transaction_id + created_journal_entry_id so it stops
  showing as unmatched (mirrors the /attach-document paperclip path).
- TransactionMatchPicker: bias the candidate window forward (60d before →
  180d after the invoice date) so late payments aren't dropped before scoring,
  and widen the ranking date tolerance to 120d so the true match floats to the
  top instead of collapsing to "Svag match". Fix "okatigoriserade" typo.

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

* wip: bundle in-progress branch work + agent onboarding chat optimizations

Captures the uncommitted work-in-progress on this branch so it lives on the
remote. Heterogeneous changeset — bundled as one commit since the work was
already entangled across files.

Headline change in this commit (from this session):
- Remove the double interview in agent onboarding. Phase B's verification-
  question form stepper is gone — the Phase C chat (onboarding.intake) now
  owns the entire interview and reads the composer's verification_questions
  server-side as its question bank.
- ReviewCard collapses from 3 steps to 2 (meet → review-and-confirm) with
  value-first ordering: profile + "vad jag kan hjälpa dig med" + facts +
  optional seed note. CTA reads "Möt {namn}" to signal the chat follows.
- ChatIntakeStarter handoff subcopy updated to match reality (assistant
  greets first; user can leave anytime).
- Stamp agent_profiles.intake_completed_at server-side in
  app/api/agent/invoke/route.ts on the first user-typed reply in any
  onboarding.intake conversation (idempotent IS NULL guard, best-effort).
  Closes the previously dead-write column and unlocks the opportunistic-
  follow-up hook the migration anticipated.

Plus in-progress branch work being carried forward (not introduced here):
agent runtime + intent prompts, composer + atom-discovery scripts, MCP
server skills surface, onboarding flow components, dashboard/inbox tweaks,
two new agent_atom_registry migrations, additional agent-chat tests.

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

* refactor(agent): drop inline "Fråga assistenten" affordances — rely on the FAB

The bottom-right "Fråga {namn}" FAB (AgentTrigger) is already route-aware
and picks the right intent per page, so duplicating it as inline page-
header buttons and empty-state links is noise. Removed:

- EmptyState `agentHelp` link ("Eller fråga {namn} hur du kommer igång")
  + the AgentHelpLink component + agent_default_name/agent_ask_link i18n
  keys + the agentHelp props on EmptyInvoices/EmptyCustomers/EmptyTransactions.
- AgentSparkleButton on /bookkeeping (verifikation.draft) and /kpi
  (kpi.explain) page headers.

The FAB stays — when verified, it appears on those routes and routes to
the right intent automatically.

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

* fix(agent): gate the last two ungated "Fråga assistenten" affordances

Both surfaces previously called useAgentSheet directly without checking
identity.isVerified, so they appeared pre-onboarding (everywhere else the
FAB / sparkle buttons / /chat / Assistent nav are all gated on verified_at).

- Settings page header: remove the "Fråga {namn}" pill entirely. The FAB
  covers /settings routes route-aware (settings.help) — no need for a
  duplicate inline trigger.
- Invoice inbox transaction picker: hide the "Fråga assistenten" button
  when the agent isn't built. Done at the parent (InvoiceInboxWorkspace)
  by passing onAskAssistant only when identity.isVerified is true; the
  child renders the button only when the callback is present.

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

* feat(tic,onboarding,agent): single-call TIC lookup + director-aware narrative voice

- TIC: collapse the company lookup from 6 endpoint calls to 1
  (search-public already exposes sniCodes, bank accounts, emails, phones,
  and registration flags). Derive fiscal-year MM-DD from
  mostRecentFinancialSummary; newly-registered companies fall through to
  the client's first-year defaults.
- Onboarding: BankID picker no longer auto-provisions companies. Every
  pick routes through the wizard with orgnr (and entity_type via the
  CompanyRoles match) prefilled; F-skatt/VAT/address get confirmed in
  steps 2-4 instead of being auto-fetched. createCompanyFromOnboarding
  reuses CompanyLookupResult and adds a defensive top-level catch so
  server-action errors surface to the UI instead of being redacted.
- Agent composer: loadUserDirectorship() checks BankID CompanyRoles for
  a director-like position (ceo/boardMember/chairman/externalSignatory,
  active) before the narrative uses second-person ownership voice
  ("Du driver…"); unknown users get neutral third-person voice so we
  never put ownership words in the user's mouth.

Tests cover loadUserDirectorship, narrative voice, tic-fetch path,
onboarding page, and updated TIC client + lookup/profile suites.

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

* fix(tic): extend agent-onboarding TIC budget to 10s + backfill stranded org_numbers

The 5s TIC fetch timeout aborted client-side before the upstream Lens
fan-out (~13 calls) could complete, but the in-flight upstream calls
still counted against quota — actions.ts already documents ~530 wasted
calls from this in May. Same bug still applied to the agent-onboarding
stream path. Adds an optional `timeoutMs` to `ensureTicSnapshot` so
deliberate wait-screen callers (agent onboarding stream) can run with
10s while background/dev callers stay on the conservative 5s default.

Page-level server fetch (page.tsx) intentionally stays at 5s to avoid
blocking TTFB without a visible progress affordance.

Backfill migration mirrors `company_settings.org_number` to
`companies.org_number` for the 105 cases where it's safe (after dedup
+ conflict filtering). 56 of those are on active companies — unblocks
duplicate guards, SIE/SRU exports, and TIC fallback chain. Zero TIC
API calls — pure data move. Idempotent.

Also sweeps a pre-existing SSRF guard on the stream route's origin
derivation that was sitting unstaged in the working tree — it lives in
the same diff hunks as the TIC budget change and couldn't be split cleanly.

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

* wip: bundle in-progress branch work

Sweep up uncommitted agent/MCP/RLS work-in-progress so the branch is fully
backed up to origin. Not reviewed in detail — committed as-is to preserve
working state alongside the TIC fixes in the previous commit.

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

* fix(agent): tag the "Bygg din bokföringsassistent" CTA as Beta

Adds a Beta badge next to the assistant-setup heading on the dashboard
banner, dashboard inline card, and onboarding checklist row. Also drops
the stale "Gratis i 30 dagar" subline from the dashboard card.

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

* fix(build,migrations): PendingOperationType salary ops + resolve migration version collisions

PR #584 went red on three things:

1. core-only build / Vercel: `lib/pending-operations/commit.ts:2666` switched on
   'create_salary_run' and 'generate_agi' but `PendingOperationType` was missing
   both literals. Add them to the union.

2. Supabase preview: migration version 20260526120000 collided with main's
   newly-merged 20260526120000_fix_replace_sie_import_hard_delete.sql.
   Bump the branch's pair to 20260526120050 / 20260526120051 — still ahead of
   20260526120100_restvardeavskrivning so ordering is preserved.

3. 20260527170000 was used twice on this branch
   (_agent_rls_with_check + _journal_entry_no_doc_required). Bump the second
   to 20260527170100 so the pair stays orderable and Supabase doesn't choke
   on the duplicate schema_migrations PK.

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

* fix(ci): reword comment so core-only guard stops flagging it

The "Check no core imports from extensions" step greps for the literal
\`from '@/extensions/\` across lib/, app/api/, components/. A comment in
lib/agent/composer/tic-fetch.ts quoted the exact pattern verbatim to
explain *why* the file does a self-fetch instead of importing the TIC
extension directly — which the grep matched even though no actual
import exists.

Rewrite the line to keep the same meaning without the literal pattern.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Emil <emilmattsson14@gmail.com>
This commit is contained in:
Jakob Wennberg
2026-05-28 11:27:01 +02:00
committed by GitHub
parent a9b43ebeb7
commit f53725b20a
243 changed files with 48104 additions and 1543 deletions
+5
View File
@@ -11,6 +11,11 @@ jobs:
with:
node-version: 20
- run: npm ci
- name: Verify skill bodies are in sync with the seed migration
# Fails if a .claude/skills/**/SKILL.md changed without regenerating the
# seed migration (npm run skills:generate). Keeps prod skill content from
# silently drifting out of sync. No DB needed — reads files + manifest.
run: npm run skills:check
- name: Reset extensions config
run: echo '{"extensions":[]}' > extensions.config.json
- run: npm run setup:extensions
+2
View File
@@ -66,3 +66,5 @@ supabase/.temp/
# The empty defaults in lib/extensions/_generated/ are committed so core compiles
# out of the box without running the generator.
supabase/.branches/
scripts\remap-krister-bas96-to-bas2025.ts
+4
View File
@@ -21,6 +21,8 @@ npm run lint # ESLint
npm test # Run all Vitest tests
npx vitest run <dir> # Run tests in a specific directory
npm run setup:extensions # Regenerate extension registry from extensions.config.json
npm run skills:generate # Regenerate agent_atom_registry seed migration from .claude/skills/**/SKILL.md (after editing a SKILL.md)
npm run skills:check # CI guard: fail if a SKILL.md changed without regenerating the seed migration
```
---
@@ -307,6 +309,8 @@ export async function POST(request: Request) {
7. Apply via Supabase MCP `apply_migration`
8. Always end with `NOTIFY pgrst, 'reload schema'` when altering table structure
**Agent skill bodies (`agent_atom_registry`)**: skill content is authored in `.claude/skills/**/SKILL.md` and inlined into the DB `body` column at runtime (not read from disk — that doesn't bundle on Vercel/Docker). After editing any SKILL.md, run `npm run skills:generate` to emit a new `*_seed_agent_atom_bodies.sql` migration and commit it; `npm run skills:check` (wired into CI) fails the build if you forget. The MCP server exposes only atoms with `mcp_exposed = true` (swarm-* audit skills are never atoms).
---
## Skills, Git & CI
+3 -1
View File
@@ -137,12 +137,14 @@ export default function BookkeepingPage() {
<PageHeader
title={t('title')}
action={
<Button variant="outline" asChild className="w-full sm:w-auto">
<div className="flex gap-2 w-full sm:w-auto">
<Button variant="outline" asChild className="w-full sm:w-auto">
<Link href="/bookkeeping/year-end">
<Lock className="mr-2 h-4 w-4" />
{t('year_end')}
</Link>
</Button>
</div>
}
/>
+16 -7
View File
@@ -16,6 +16,7 @@ import {
} from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { ArrowLeft, Lock } from 'lucide-react'
import AgentSparkleButton from '@/components/agent/AgentSparkleButton'
import { cn } from '@/lib/utils'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
@@ -200,14 +201,22 @@ export default function YearEndPage() {
return (
<div className="space-y-8">
<div className="flex items-center justify-between">
<div className="flex items-center justify-between gap-3 flex-wrap">
<h1 className="font-display text-3xl md:text-4xl tracking-tight">Årsbokslut</h1>
<Button variant="outline" asChild>
<Link href="/bookkeeping">
<ArrowLeft className="mr-2 h-4 w-4" />
Bokföring
</Link>
</Button>
<div className="flex gap-2">
<AgentSparkleButton
intentId="bokslut.step"
intentArgs={{ step_id: null }}
contextRef="bokslut:overview"
size="default"
/>
<Button variant="outline" asChild>
<Link href="/bookkeeping">
<ArrowLeft className="mr-2 h-4 w-4" />
Bokföring
</Link>
</Button>
</div>
</div>
{periods === null && !periodsError && (
+65
View File
@@ -0,0 +1,65 @@
import { createClient } from '@/lib/supabase/server'
import { notFound, redirect } from 'next/navigation'
import { getActiveCompanyId } from '@/lib/company/context'
import ChatConversationView from '@/components/agent/ChatConversationView'
export const dynamic = 'force-dynamic'
interface PageProps {
params: Promise<{ id: string }>
}
// /chat/[id] — server-renders the conversation row + ordered messages, then
// hydrates the client AgentChat with them so the user can continue typing
// against the existing conversation_id. The agent loop on the server picks
// up via /api/agent/invoke with conversation_id supplied.
export default async function ChatConversationPage({ params }: PageProps) {
const { id } = await params
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 { data: conversation } = await supabase
.from('agent_conversations')
.select('id, intent_id, context_ref, title, pinned, archived, last_message_at')
.eq('id', id)
.eq('company_id', companyId)
.maybeSingle()
if (!conversation) notFound()
const { data: messages } = await supabase
.from('agent_messages')
.select('role, content, hidden, created_at')
.eq('conversation_id', id)
.order('created_at', { ascending: true })
return (
<ChatConversationView
conversationId={id}
intentId={conversation.intent_id}
contextRef={conversation.context_ref}
title={conversation.title ?? intentLabel(conversation.intent_id)}
rawMessages={(messages ?? []) as { role: string; content: unknown; hidden?: boolean }[]}
/>
)
}
function intentLabel(intentId: string): string {
switch (intentId) {
case 'general.help':
return 'Fråga din assistent'
case 'transaction.categorization':
return 'Hjälp med transaktion'
case 'invoice.draft':
return 'Hjälp med faktura'
case 'supplier_invoice.review':
return 'Granska leverantörsfaktura'
default:
return intentId
}
}
+24
View File
@@ -0,0 +1,24 @@
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import { getActiveCompanyId } from '@/lib/company/context'
import ChatIntakeStarter from '@/components/agent/ChatIntakeStarter'
export const dynamic = 'force-dynamic'
// /chat/intake — Phase C bootstrap surface. ReviewCard navigates here after
// Phase B "kör" succeeds. The client component mounts AgentChat with
// intent='onboarding.intake' in fresh-start mode; AgentChat auto-fires the
// first invoke which creates the conversation row, and we swap the URL to
// /chat/[id] when the new id streams back.
//
// Plan ref: dev_docs/specialized-agent-plan.md §7 Phase C.
export default async function ChatIntakePage() {
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')
return <ChatIntakeStarter />
}
+54
View File
@@ -0,0 +1,54 @@
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import { getActiveCompanyId } from '@/lib/company/context'
import ChatSidebar from '@/components/agent/ChatSidebar'
export const dynamic = 'force-dynamic'
// Two-pane chat layout: sidebar with conversations on the left, active
// conversation (or empty state) in the main panel. Both /chat and /chat/[id]
// share this layout so the sidebar doesn't unmount on conversation switches.
export default async function ChatLayout({ children }: { children: React.ReactNode }) {
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')
// Block the chat surface until the agent is built. Without this a user
// who deep-links to /chat (bookmark, ⌘K, "+ Ny" elsewhere) lands on an
// empty conversations list with no Anna to talk to. The home route at /
// renders NewUserChecklist for the same state, so we forward there
// instead of duplicating the welcome screen here.
const { data: agent } = await supabase
.from('agent_profiles')
.select('verified_at')
.eq('company_id', companyId)
.maybeSingle()
if (!agent?.verified_at) redirect('/')
const { data: conversations } = await supabase
.from('agent_conversations')
.select(
'id, intent_id, context_ref, title, pinned, archived, last_message_at, last_message_preview, created_at',
)
.eq('company_id', companyId)
.eq('archived', false)
.order('pinned', { ascending: false })
.order('last_message_at', { ascending: false, nullsFirst: false })
.limit(100)
return (
// MainContainer hands /chat a full-bleed h-full wrapper, so we don't
// need negative margins to break out of any chrome padding.
//
// dvh handles mobile browser chrome shrinking on scroll. Mobile: subtract
// the bottom nav (h-16 = 64px) + safe-area-inset-bottom so the chat
// pane fills the visible viewport exactly. Desktop: full viewport.
<div className="flex h-[calc(100dvh-4rem-env(safe-area-inset-bottom,0px))] md:h-screen">
<ChatSidebar initialConversations={conversations ?? []} />
<div className="flex-1 min-w-0 flex flex-col bg-background">{children}</div>
</div>
)
}
+36
View File
@@ -0,0 +1,36 @@
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import { getActiveCompanyId } from '@/lib/company/context'
import ChatNewStarter from '@/components/agent/ChatNewStarter'
import { getIntent } from '@/lib/agent/intents/registry'
export const dynamic = 'force-dynamic'
interface PageProps {
searchParams: Promise<{ intent?: string; prompt?: string }>
}
// /chat/new — generic conversation bootstrap. Reads ?intent= and ?prompt=
// from the URL and mounts AgentChat in fresh mode; AgentChat creates the
// conversation server-side on first invoke and the client swaps the URL
// to /chat/[id] when the id streams back. Mirrors /chat/intake but with
// caller-chosen intent/seed, so suggestion chips and ⌘K can route here
// inline instead of opening the slide-in sheet.
export default async function ChatNewPage({ searchParams }: PageProps) {
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 sp = await searchParams
const requested = typeof sp.intent === 'string' && sp.intent.trim() ? sp.intent.trim() : 'general.help'
// Validate against the registry — a bogus ?intent= would otherwise render the
// chat shell and then fail at invoke with a 400, which reads as "Anna is broken"
// rather than "bad link". Fall back to general help instead.
const intent = getIntent(requested) ? requested : 'general.help'
const prompt = typeof sp.prompt === 'string' ? sp.prompt : ''
return <ChatNewStarter intentId={intent} seedUserMessage={prompt} />
}
+10
View File
@@ -0,0 +1,10 @@
import ChatEmptyState from '@/components/agent/ChatEmptyState'
export const dynamic = 'force-dynamic'
// Empty state for /chat. The sidebar (in the layout) shows the list; this
// view appears when no specific conversation is selected. Offers an obvious
// entry to start a fresh general.help conversation.
export default function ChatIndexPage() {
return <ChatEmptyState />
}
+7 -1
View File
@@ -30,6 +30,7 @@ import CustomerForm from '@/components/customers/CustomerForm'
import { BankDetailsSetupDialog } from '@/components/invoices/BankDetailsSetupDialog'
import { FirstInvoiceLogoPrompt } from '@/components/invoices/FirstInvoiceLogoPrompt'
import { useCompany } from '@/contexts/CompanyContext'
import AgentSparkleButton from '@/components/agent/AgentSparkleButton'
import {
ROT_WORK_TYPES,
RUT_WORK_TYPES,
@@ -599,7 +600,7 @@ export default function NewInvoicePage() {
<Button variant="ghost" size="icon" onClick={() => router.back()} aria-label={t('back')}>
<ArrowLeft className="h-5 w-5" />
</Button>
<div>
<div className="flex-1 min-w-0">
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">
{titleText}
{numberPreview && (
@@ -610,6 +611,11 @@ export default function NewInvoicePage() {
</h1>
<p className="text-muted-foreground">{subtitleText}</p>
</div>
<AgentSparkleButton
intentId="invoice.draft"
intentArgs={{ customer_id: watchCustomerId ?? null }}
contextRef={watchCustomerId ? `customer:${watchCustomerId}` : 'invoice:new'}
/>
</div>
{hasBankDetails === false && (
+7 -5
View File
@@ -83,11 +83,13 @@ export default function KpiPage() {
<div className="space-y-8">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">{t('title')}</h1>
<KPISettingsDialog
preferences={preferences}
onSave={handleSavePreferences}
saving={isSavingPrefs}
/>
<div className="flex gap-2">
<KPISettingsDialog
preferences={preferences}
onSave={handleSavePreferences}
saving={isSavingPrefs}
/>
</div>
</div>
<FiscalYearSelector
+95 -59
View File
@@ -4,6 +4,9 @@ import { headers } from 'next/headers'
import DashboardNav from '@/components/dashboard/DashboardNav'
import { MainContainer } from '@/components/dashboard/MainContainer'
import CompanyTabSync from '@/components/dashboard/CompanyTabSync'
import { AgentSheetProvider } from '@/components/agent/AgentSheetProvider'
import AgentTrigger from '@/components/agent/AgentTrigger'
import CommandPalette from '@/components/common/CommandPalette'
import { SandboxBanner } from '@/components/dashboard/SandboxBanner'
import { getExtensionNavItems } from '@/lib/extensions/sectors'
import { CompanyProvider } from '@/contexts/CompanyContext'
@@ -84,26 +87,28 @@ export default async function DashboardLayout({
isSandbox: false,
}}
>
<CompanyTabSync />
<div className="min-h-screen bg-background">
<DashboardNav
companyName={getBranding().appName.toLowerCase()}
entityType="enskild_firma"
uncategorizedTransactionCount={0}
pendingOperationsCount={0}
isSandbox={false}
extensionNavItems={getExtensionNavItems()}
/>
<main
id="main-content"
className="safe-area-main-padding md:!pb-0 md:pl-64"
role="main"
>
<div className="max-w-5xl mx-auto px-5 py-8 md:px-8 md:py-10">
{children}
</div>
</main>
</div>
<AgentSheetProvider>
<CompanyTabSync />
<div className="min-h-screen bg-background">
<DashboardNav
companyName={getBranding().appName.toLowerCase()}
entityType="enskild_firma"
uncategorizedTransactionCount={0}
pendingOperationsCount={0}
isSandbox={false}
extensionNavItems={getExtensionNavItems()}
/>
<main
id="main-content"
className="safe-area-main-padding md:!pb-0 md:pl-64"
role="main"
>
<div className="max-w-5xl mx-auto px-5 py-8 md:px-8 md:py-10">
{children}
</div>
</main>
</div>
</AgentSheetProvider>
</CompanyProvider>
)
}
@@ -136,27 +141,35 @@ export default async function DashboardLayout({
return (
<CompanyProvider value={companyContextValue}>
<CompanyTabSync />
<div className="min-h-screen bg-background">
<DashboardNav
companyName={getBranding().appName.toLowerCase()}
entityType="enskild_firma"
uncategorizedTransactionCount={0}
pendingOperationsCount={0}
isSandbox={false}
extensionNavItems={getExtensionNavItems()}
/>
<main id="main-content" className="safe-area-main-padding md:!pb-0 md:pl-64" role="main">
<div className="max-w-5xl mx-auto px-5 py-8 md:px-8 md:py-10">
{children}
</div>
</main>
</div>
<AgentSheetProvider>
<CompanyTabSync />
<div className="min-h-screen bg-background">
<DashboardNav
companyName={getBranding().appName.toLowerCase()}
entityType="enskild_firma"
uncategorizedTransactionCount={0}
pendingOperationsCount={0}
isSandbox={false}
extensionNavItems={getExtensionNavItems()}
/>
<main id="main-content" className="safe-area-main-padding md:!pb-0 md:pl-64" role="main">
<div className="max-w-5xl mx-auto px-5 py-8 md:px-8 md:py-10">
{children}
</div>
</main>
</div>
</AgentSheetProvider>
</CompanyProvider>
)
}
const [{ data: settings }, { count: uncategorizedCount }, { count: pendingOpsCount }] = await Promise.all([
const [
{ data: settings },
{ count: uncategorizedCount },
{ count: pendingOpsCount },
{ data: agentProfileIdentity },
{ data: userProfile },
] = await Promise.all([
supabase
.from('company_settings')
.select('company_name, onboarding_complete, entity_type, is_sandbox')
@@ -172,6 +185,17 @@ export default async function DashboardLayout({
.select('*', { count: 'exact', head: true })
.eq('company_id', companyId)
.eq('status', 'pending'),
// Agent identity — name + avatar — surfaced on the FAB and chat
// surfaces. Null when no agent_profile exists yet (banner CTA path).
supabase
.from('agent_profiles')
.select('display_name, avatar_id, verified_at')
.eq('company_id', companyId)
.maybeSingle(),
// The signed-in user's profile — shown in the bottom-left account
// popover (full_name + initial) so it's clear which user is logged
// in, distinct from the active company shown at the top.
supabase.from('profiles').select('full_name').eq('id', user.id).maybeSingle(),
])
// If onboarding incomplete, still render the dashboard — the page component
@@ -203,28 +227,40 @@ export default async function DashboardLayout({
return (
<CompanyProvider value={companyContextValue}>
<CompanyTabSync />
<div className="min-h-screen bg-background">
{/* Skip to content link for keyboard/screen reader users */}
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[100] focus:px-4 focus:py-2 focus:bg-primary focus:text-primary-foreground focus:rounded-lg focus:text-sm focus:font-medium"
>
Hoppa till innehåll
</a>
{isSandbox && <SandboxBanner />}
<DashboardNav
companyName={settings?.company_name || 'Min verksamhet'}
entityType={entityType}
uncategorizedTransactionCount={uncategorizedCount ?? 0}
pendingOperationsCount={pendingOpsCount ?? 0}
isSandbox={isSandbox}
extensionNavItems={getExtensionNavItems()}
/>
<main id="main-content" className="safe-area-main-padding md:!pb-0 md:pl-64" role="main">
<MainContainer companyId={companyId}>{children}</MainContainer>
</main>
</div>
<AgentSheetProvider
identity={{
displayName: agentProfileIdentity?.display_name ?? null,
avatarId: agentProfileIdentity?.avatar_id ?? null,
isVerified: Boolean(agentProfileIdentity?.verified_at),
}}
>
<CompanyTabSync />
<div className="min-h-screen bg-background">
{/* Skip to content link for keyboard/screen reader users */}
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[100] focus:px-4 focus:py-2 focus:bg-primary focus:text-primary-foreground focus:rounded-lg focus:text-sm focus:font-medium"
>
Hoppa till innehåll
</a>
{isSandbox && <SandboxBanner />}
<DashboardNav
companyName={settings?.company_name || 'Min verksamhet'}
entityType={entityType}
uncategorizedTransactionCount={uncategorizedCount ?? 0}
pendingOperationsCount={pendingOpsCount ?? 0}
isSandbox={isSandbox}
extensionNavItems={getExtensionNavItems()}
userName={userProfile?.full_name ?? null}
userEmail={user.email ?? null}
/>
<main id="main-content" className="safe-area-main-padding md:!pb-0 md:pl-64" role="main">
<MainContainer companyId={companyId}>{children}</MainContainer>
</main>
<AgentTrigger />
<CommandPalette />
</div>
</AgentSheetProvider>
</CompanyProvider>
)
}
+37
View File
@@ -2,12 +2,18 @@ import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import { cookies } from 'next/headers'
import DashboardContent from '@/components/dashboard/DashboardContent'
import WelcomeGate from '@/components/onboarding/WelcomeGate'
import { getActiveCompanyId } from '@/lib/company/context'
import { getDisplayTotal } from '@/lib/invoices/rounding'
import type { Deadline, ReceiptQueueSummary, OnboardingProgress } from '@/types'
export const dynamic = 'force-dynamic'
// Home route = Översikt (DashboardContent). The agent chat has its own nav
// entry at /chat, so / no longer forwards there. New users who haven't built
// their assistant yet get WelcomeGate (the build-agent checklist) instead of
// the dashboard; once the agent is verified, / renders the normal Översikt.
export default async function DashboardPage() {
const supabase = await createClient()
@@ -76,6 +82,7 @@ export default async function DashboardPage() {
{ count: staleUncategorizedCount },
{ count: uncategorizedCount },
{ count: skatteverketTokenCount },
{ data: agentProfile },
{ data: noDocRequiredEntries },
] = await Promise.all([
supabase.from('company_settings').select('*').eq('company_id', companyId).single(),
@@ -106,6 +113,7 @@ export default async function DashboardPage() {
// carry the active company_id; either filter would work — we use user_id
// because that's what the token-store reads/writes against.
supabase.from('skatteverket_tokens').select('*', { count: 'exact', head: true }).eq('user_id', user.id),
supabase.from('agent_profiles').select('verified_at').eq('company_id', companyId).maybeSingle(),
supabase.from('journal_entry_no_doc_required').select('journal_entry_id').eq('company_id', companyId),
])
@@ -114,6 +122,34 @@ export default async function DashboardPage() {
redirect('/onboarding')
}
const agentBuilt = Boolean(agentProfile?.verified_at)
// "Has the company already been used?" Any real business data means we must
// NOT hijack the dashboard with the full-screen onboarding gate — existing
// and migrated users get the normal Översikt with a build-assistant prompt
// in the hero slot (see DashboardContent's agentBuilt branch) instead.
const hasData =
(transactionCount || 0) > 0 ||
(sieImportCount || 0) > 0 ||
(invoiceCount || 0) > 0 ||
(receiptCount || 0) > 0 ||
(customerCount || 0) > 0 ||
(postedEntriesCount || 0) > 0
// Only a genuinely empty company without an assistant sees the full
// onboarding checklist (where building the assistant is the last step).
// Everyone else falls through to the dashboard below.
if (!agentBuilt && !hasData) {
return (
<WelcomeGate
companyId={companyId}
hasBookkeepingImported={(sieImportCount || 0) > 0}
hasBankConnected={(transactionCount || 0) > 0}
hasSkatteverketConnected={(skatteverketTokenCount || 0) > 0}
/>
)
}
const onboardingProgress: OnboardingProgress = {
hasCustomers: (customerCount || 0) > 0,
hasInvoices: (invoiceCount || 0) > 0,
@@ -252,6 +288,7 @@ export default async function DashboardPage() {
return (
<DashboardContent
companyId={companyId}
agentBuilt={agentBuilt}
summary={{
ytd: ytdTotals,
mtd: mtdTotals,
+343 -34
View File
@@ -26,9 +26,19 @@ import {
DropdownMenuLabel,
} from '@/components/ui/dropdown-menu'
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { useToast } from '@/components/ui/use-toast'
import { formatCurrency } from '@/lib/utils'
import { formatCurrency, formatDate } from '@/lib/utils'
import { createClient } from '@/lib/supabase/client'
import {
ClipboardCheck,
ArrowLeftRight,
@@ -37,8 +47,16 @@ import {
Bot,
BookOpen,
ChevronDown,
Loader2,
Lock,
MessageSquare,
AlertTriangle,
} from 'lucide-react'
import type { PendingOperation, PendingOperationStatus } from '@/types'
import type {
PendingOperation,
PendingOperationStatus,
PendingOperationRejectionCategory,
} from '@/types'
import { AttachDocumentPreview } from '@/components/bookkeeping/AttachDocumentPreview'
import { MatchTransactionInvoicePreview } from '@/components/bookkeeping/MatchTransactionInvoicePreview'
@@ -82,9 +100,12 @@ function bulkActionLabel(operationType: string, count: number, t: (key: string)
return `${count} × ${fallback}`
}
// Full-sentence warning for the single-op confirmation dialog. Phrased so the
// user sees the consequence of clicking Godkänn, not a generic verifikation note.
// Full-sentence warning for the single-op confirmation dialog AND the inline
// list-view warning when risk is medium/high. The list-view truncates beyond
// one line; the dialog shows it in full. Order roughly low → high risk so
// reviewers scanning the source see the destructive paths grouped together.
const singleActionWarnings: Record<string, string> = {
// Low/medium risk — light verifikation work
create_transaction: 'Genom att klicka godkänn så skapar du en transaktion.',
create_customer: 'Genom att klicka godkänn så skapar du en kund.',
create_invoice: 'Genom att klicka godkänn så skapas ett fakturautkast (det skickas inte).',
@@ -95,15 +116,60 @@ const singleActionWarnings: Record<string, string> = {
send_invoice: 'Genom att klicka godkänn så skickas fakturan till kunden.',
mark_invoice_paid: 'Genom att klicka godkänn så bokförs en betalning på fakturan.',
mark_invoice_sent: 'Genom att klicka godkänn så märks fakturan som skickad och en verifikation skapas.',
create_voucher: 'Genom att klicka godkänn så bokförs verifikationen med ett nytt verifikationsnummer.',
correct_entry: 'Genom att klicka godkänn så bokförs en storno och en ny korrigerad verifikation i samma period (BFL 5 kap 5§).',
reverse_entry: 'Genom att klicka godkänn så makuleras verifikationen via en storno i samma period.',
// High risk — period/year-end/voucher edits. These are the ones the reviewer
// really needs the warning for, so we keep them concrete: name the
// irreversibility or compliance consequence, not the generic risk-level.
lock_period: 'Genom att klicka godkänn så låses perioden — inga nya verifikationer kan bokföras tills den låses upp.',
unlock_period: 'Genom att klicka godkänn så låses perioden upp. Använd endast för rättelser; lås igen efter.',
close_period: 'Genom att klicka godkänn så stängs perioden permanent (BFL). Stängningen kan inte ångras.',
run_year_end: 'Genom att klicka godkänn så körs bokslut: resultatkonton nollställs, perioden låses, nästa period skapas.',
set_opening_balances: 'Genom att klicka godkänn så bokförs ingående balans i nästa period.',
run_currency_revaluation: 'Genom att klicka godkänn så bokförs valutaomvärdering (3960/7960).',
create_voucher: 'Genom att klicka godkänn så bokförs verifikationen med ett nytt löpnummer.',
correct_entry: 'Genom att klicka godkänn så stornas originalverifikationen och en rättelse bokförs (BFL 5 kap 5§).',
reverse_entry: 'Genom att klicka godkänn så stornas verifikationen — originalet behålls synligt (BFL 5 kap).',
credit_invoice: 'Genom att klicka godkänn så skapas en kreditfaktura och originalverifikationen stornas.',
credit_supplier_invoice: 'Genom att klicka godkänn så krediteras leverantörsfakturan och registreringsverifikationen stornas.',
approve_supplier_invoice: 'Genom att klicka godkänn så attesteras leverantörsfakturan och blir betalningsbar.',
convert_invoice: 'Genom att klicka godkänn så konverteras proformafakturan till en riktig faktura med F-nummer.',
import_sie: 'Genom att klicka godkänn så importeras SIE-filen: räkenskapsperiod, ingående balans och verifikationer skapas.',
explain_voucher_gap: 'Genom att klicka godkänn så dokumenteras förklaringen för verifikationsluckan (BFNAR 2013:2).',
post_annual_depreciation: 'Genom att klicka godkänn så bokförs planenlig avskrivning — en verifikation per tillgång.',
}
function singleActionWarning(operationType: string): string {
return singleActionWarnings[operationType] ?? ''
}
// Period status carried inside preview_data when stagePendingOperation can
// resolve it. Shape mirrors PeriodStatusForDate in lib/core/bookkeeping/period-service.ts.
interface PeriodStatusShape {
period_id: string | null
status: 'open' | 'locked' | 'closed'
lock_date: string | null
}
function getPeriodStatus(op: PendingOperation): PeriodStatusShape | null {
const raw = (op.preview_data as Record<string, unknown>)?.period_status
if (!raw || typeof raw !== 'object') return null
const obj = raw as Record<string, unknown>
const status = obj.status
if (status !== 'open' && status !== 'locked' && status !== 'closed') return null
return {
period_id: typeof obj.period_id === 'string' ? obj.period_id : null,
status,
lock_date: typeof obj.lock_date === 'string' ? obj.lock_date : null,
}
}
const REJECTION_CATEGORY_LABELS: Record<PendingOperationRejectionCategory, string> = {
wrong_category: 'Fel kategori / konto',
wrong_amount: 'Fel belopp',
duplicate: 'Dubblett',
wrong_period: 'Fel period',
other: 'Annat',
}
function formatRelativeTime(dateStr: string): string {
const now = new Date()
const date = new Date(dateStr)
@@ -365,7 +431,9 @@ function renderPrimitive(value: unknown): string {
}
function GenericPreview({ data }: { data: Record<string, unknown> }) {
const entries = Object.entries(data).filter(([, v]) => v != null && v !== '')
// Skip period_status here — it's surfaced in the dedicated banner, not the
// generic key-value dump (otherwise the approver sees the same fact twice).
const entries = Object.entries(data).filter(([k, v]) => v != null && v !== '' && k !== 'period_status')
return (
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
{entries.map(([key, value]) => (
@@ -406,6 +474,34 @@ function OperationPreview({ op }: { op: PendingOperation }) {
return body
}
/**
* Inline period-lock banner. Renders when the staged operation touches a
* period that's already locked or closed — the server's commit-time trigger
* will reject it, so we tell the approver up front rather than letting them
* click and see a generic "Misslyckades" toast. The fiscal_period_id link
* goes to the periods management page where unlocking is possible.
*/
function PeriodLockBanner({ period }: { period: PeriodStatusShape }) {
const lockedThrough = period.lock_date ? formatDate(period.lock_date) : null
return (
<div className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm">
<Lock className="h-4 w-4 text-destructive mt-0.5 shrink-0" />
<div className="flex-1">
<p className="font-medium text-destructive">
{period.status === 'closed'
? 'Perioden är stängd permanent (BFL) — kan inte ändras.'
: `Perioden är låst${lockedThrough ? ` t.o.m. ${lockedThrough}` : ''}.`}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{period.status === 'closed'
? 'Använd en omprövning i en öppen period i stället.'
: 'Lås upp perioden via Bokföring → Räkenskapsperioder, ändra entry-datum, eller avvisa.'}
</p>
</div>
</div>
)
}
type SourceFilter = 'all' | 'agent' | 'high_risk'
const sourceFilterLabels = (
@@ -425,6 +521,7 @@ export default function PendingOperationsPage() {
const [isLoading, setIsLoading] = useState(true)
const [activeTab, setActiveTab] = useState<PendingOperationStatus>('pending')
const [sourceFilter, setSourceFilter] = useState<SourceFilter>('all')
const [conversationFilter, setConversationFilter] = useState<string | null>(null)
const [counts, setCounts] = useState<StatusCounts>({
pending: null,
committed: null,
@@ -437,8 +534,22 @@ export default function PendingOperationsPage() {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [showBulkDialog, setShowBulkDialog] = useState(false)
const [isBulkCommitting, setIsBulkCommitting] = useState(false)
// Reject dialog state — separate from the generic destructive-confirm so we
// can ask for a category + free-text reason that feeds back to the agent.
const [rejectOp, setRejectOp] = useState<PendingOperation | null>(null)
const [rejectCategory, setRejectCategory] = useState<PendingOperationRejectionCategory | ''>('')
const [rejectReason, setRejectReason] = useState('')
const [isRejecting, setIsRejecting] = useState(false)
const { toast } = useToast()
const { dialogProps, confirm } = useDestructiveConfirm()
// Read ?conversation= once on mount so deep-links from the agent context
// strip filter the list automatically.
useEffect(() => {
if (typeof window === 'undefined') return
const url = new URL(window.location.href)
const conv = url.searchParams.get('conversation')
if (conv) setConversationFilter(conv)
}, [])
const fetchOperations = useCallback(async () => {
setIsLoading(true)
@@ -479,10 +590,34 @@ export default function PendingOperationsPage() {
fetchAllCounts()
}, [fetchAllCounts])
// Realtime subscription: refetch when ANY pending_operations row changes for
// this company. RLS scopes the channel automatically — we don't see other
// tenants' events. We refetch the whole list (rather than patching state
// in-place) so server-side filtering, sorting, and computed fields stay in
// sync with whatever the API route returned. The counts endpoint isn't
// pushed by the same trigger, so we also refresh counts on every change.
useEffect(() => {
const supabase = createClient()
const channel = supabase
.channel('pending_operations:list')
.on(
'postgres_changes',
{ event: '*', schema: 'public', table: 'pending_operations' },
() => {
fetchOperations()
fetchAllCounts()
}
)
.subscribe()
return () => {
void supabase.removeChannel(channel)
}
}, [fetchOperations, fetchAllCounts])
// Clear selection when filters/tab change
useEffect(() => {
setSelectedIds(new Set())
}, [activeTab, sourceFilter])
}, [activeTab, sourceFilter, conversationFilter])
async function handleCommit() {
if (!selectedOp) return
@@ -552,27 +687,51 @@ export default function PendingOperationsPage() {
setIsBulkCommitting(false)
}
async function handleReject(op: PendingOperation) {
const ok = await confirm({
title: 'Avvisa operation?',
description: `"${op.title}" kommer att avvisas.`,
confirmLabel: 'Avvisa',
variant: 'destructive',
})
if (!ok) return
function openRejectDialog(op: PendingOperation) {
setRejectOp(op)
setRejectCategory('')
setRejectReason('')
}
async function handleReject() {
if (!rejectOp) return
setIsRejecting(true)
try {
const res = await fetch(`/api/pending-operations/${op.id}/reject`, { method: 'POST' })
if (!res.ok) throw new Error('Misslyckades')
toast({ title: 'Avvisad', description: op.title })
const body =
rejectCategory || rejectReason.trim()
? {
...(rejectCategory ? { rejection_category: rejectCategory } : {}),
...(rejectReason.trim() ? { rejection_reason: rejectReason.trim() } : {}),
}
: undefined
const res = await fetch(`/api/pending-operations/${rejectOp.id}/reject`, {
method: 'POST',
...(body
? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }
: {}),
})
if (!res.ok) {
const json = await res.json().catch(() => ({}))
throw new Error(json.error || 'Misslyckades')
}
toast({ title: 'Avvisad', description: rejectOp.title })
setRejectOp(null)
fetchOperations()
fetchAllCounts()
} catch {
toast({ title: 'Kunde inte avvisa', variant: 'destructive' })
} catch (err) {
toast({
title: 'Kunde inte avvisa',
description: err instanceof Error ? err.message : 'Okänt fel',
variant: 'destructive',
})
}
setIsRejecting(false)
}
const filteredOperations = operations.filter((op) => {
if (conversationFilter && op.agent_metadata?.conversation_id !== conversationFilter) {
return false
}
switch (sourceFilter) {
case 'agent':
return op.actor_type === 'api_key' || op.actor_type === 'mcp_oauth' || op.actor_type === 'cron'
@@ -585,8 +744,19 @@ export default function PendingOperationsPage() {
})
const showBulkControls = activeTab === 'pending'
// Pending ops that meet two criteria: not high risk AND the period covering
// them is open. We exclude locked/closed periods from bulk because they will
// be rejected at commit time anyway — silently letting the user "select all"
// and watching some fail is a worse UX than excluding them up front.
const bulkEligible = useMemo(
() => filteredOperations.filter((op) => op.status === 'pending' && op.risk_level !== 'high'),
() =>
filteredOperations.filter((op) => {
if (op.status !== 'pending') return false
if (op.risk_level === 'high') return false
const period = getPeriodStatus(op)
if (period && period.status !== 'open') return false
return true
}),
[filteredOperations]
)
const bulkEligibleIds = useMemo(() => bulkEligible.map((op) => op.id), [bulkEligible])
@@ -594,6 +764,9 @@ export default function PendingOperationsPage() {
bulkEligibleIds.length > 0 && bulkEligibleIds.every((id) => selectedIds.has(id))
const someSelected = bulkEligibleIds.some((id) => selectedIds.has(id))
const pendingTotal = filteredOperations.filter((op) => op.status === 'pending').length
const excludedFromBulk = pendingTotal - bulkEligible.length
function toggleSelected(id: string) {
setSelectedIds((prev) => {
const next = new Set(prev)
@@ -654,6 +827,33 @@ export default function PendingOperationsPage() {
description={t('subtitle')}
/>
{conversationFilter && (
<div className="flex items-center justify-between rounded-md border bg-muted/30 px-3 py-2 text-sm">
<div className="flex items-center gap-2">
<MessageSquare className="h-4 w-4 text-muted-foreground" />
<span>
{t('conversation_filter_label')}{' '}
<span className="font-mono">#{conversationFilter.slice(0, 8)}</span>
</span>
</div>
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-xs"
onClick={() => {
setConversationFilter(null)
if (typeof window !== 'undefined') {
const url = new URL(window.location.href)
url.searchParams.delete('conversation')
window.history.replaceState({}, '', url.toString())
}
}}
>
{t('conversation_filter_clear')}
</Button>
</div>
)}
<div className="flex flex-wrap items-center justify-between gap-3">
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as PendingOperationStatus)}>
<TabsList>
@@ -703,11 +903,19 @@ export default function PendingOperationsPage() {
<label htmlFor="select-all" className="text-sm cursor-pointer">
{selectedCount > 0
? t('selected_count', { count: selectedCount })
: t('select_all_count', { count: bulkEligible.length })}
: excludedFromBulk > 0
? t('select_all_count_partial', {
eligible: bulkEligible.length,
total: pendingTotal,
excluded: excludedFromBulk,
})
: t('select_all_count', { count: bulkEligible.length })}
</label>
</div>
{typeCounts.length > 0 && selectedCount === 0 && (
{/* Only worth showing when there's more than one type to pick from —
with a single type it just duplicates "Markera alla". */}
{typeCounts.length >= 2 && selectedCount === 0 && (
<div className="flex flex-wrap items-center gap-1">
<span className="text-xs text-muted-foreground">{t('quick_pick')}</span>
{typeCounts.map(([type, count]) => {
@@ -745,7 +953,9 @@ export default function PendingOperationsPage() {
disabled={selectedCount === 0 || isBulkCommitting}
onClick={() => setShowBulkDialog(true)}
>
{t('approve_selected', { count: selectedCount })}
{selectedCount > 0
? t('approve_selected', { count: selectedCount })
: t('approve_selected_none')}
</Button>
</div>
</DataListHeader>
@@ -776,9 +986,16 @@ export default function PendingOperationsPage() {
? { label: t(entry.labelKey), icon: entry.icon, variant: entry.variant }
: { label: op.operation_type, icon: ClipboardCheck, variant: 'default' as const }
const isExpanded = expandedId === op.id
const canBulkSelect = showBulkControls && op.status === 'pending' && op.risk_level !== 'high'
const period = getPeriodStatus(op)
const periodLocked = period != null && period.status !== 'open'
const canBulkSelect =
showBulkControls && op.status === 'pending' && op.risk_level !== 'high' && !periodLocked
const isSelected = selectedIds.has(op.id)
const isAgent = op.actor_type && op.actor_type !== 'user'
const conversationId = op.agent_metadata?.conversation_id ?? null
const warningSentence = singleActionWarning(op.operation_type)
const showHighRiskWarning =
op.risk_level === 'high' && warningSentence && op.status === 'pending'
return (
<DataListRow
@@ -803,8 +1020,11 @@ export default function PendingOperationsPage() {
<Button
size="sm"
className="h-8 px-3 text-xs"
disabled={periodLocked}
title={periodLocked ? 'Perioden är låst' : undefined}
onClick={(e) => {
e.stopPropagation()
if (periodLocked) return
setSelectedOp(op)
setShowCommitDialog(true)
}}
@@ -817,7 +1037,7 @@ export default function PendingOperationsPage() {
className="h-8 px-3 text-xs"
onClick={(e) => {
e.stopPropagation()
handleReject(op)
openRejectDialog(op)
}}
>
{t('reject')}
@@ -825,7 +1045,18 @@ export default function PendingOperationsPage() {
</>
) : undefined
}
expandedContent={<OperationPreview op={op} />}
expandedContent={
<>
{/* Period-lock banner sits ABOVE the preview so the reviewer
sees the blocker as soon as they expand the row. */}
{periodLocked && period && op.status === 'pending' && (
<div className="mb-3">
<PeriodLockBanner period={period} />
</div>
)}
<OperationPreview op={op} />
</>
}
>
<DataListPrimary>{op.title}</DataListPrimary>
<DataListMeta>
@@ -835,7 +1066,19 @@ export default function PendingOperationsPage() {
<DataListMetaSeparator />
<span className="inline-flex items-center gap-1">
<Bot className="h-3 w-3" />
{op.actor_label || op.actor_type}
{/* The actor label doubles as the deep-link into the
originating conversation — no separate strip needed. */}
{conversationId ? (
<a
href={`/pending?conversation=${conversationId}`}
className="hover:underline"
onClick={(e) => e.stopPropagation()}
>
{op.actor_label || op.actor_type}
</a>
) : (
op.actor_label || op.actor_type
)}
</span>
</>
)}
@@ -847,6 +1090,18 @@ export default function PendingOperationsPage() {
</Badge>
)}
</DataListMeta>
{showHighRiskWarning && (
<p className="mt-1 flex items-start gap-1 text-xs text-destructive">
<AlertTriangle className="h-3 w-3 mt-0.5 shrink-0" />
<span>{warningSentence}</span>
</p>
)}
{op.status === 'rejected' && op.rejection_category && (
<p className="mt-1 text-xs text-muted-foreground">
Avvisad: {REJECTION_CATEGORY_LABELS[op.rejection_category]}
{op.rejection_reason ? ` — "${op.rejection_reason}"` : ''}
</p>
)}
</DataListRow>
)
})
@@ -892,8 +1147,62 @@ export default function PendingOperationsPage() {
</div>
</ConfirmationDialog>
{/* Reject confirmation dialog */}
<DestructiveConfirmDialog {...dialogProps} />
{/* Reject dialog — category + free-text reason. Both optional so the user
can still reject quickly without filling anything in. */}
<Dialog open={rejectOp != null} onOpenChange={(open) => { if (!open) setRejectOp(null) }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Avvisa operation</DialogTitle>
<DialogDescription>
{rejectOp?.title}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="reject-category">
Anledning (valfritt)
</label>
<Select
value={rejectCategory}
onValueChange={(v) => setRejectCategory(v as PendingOperationRejectionCategory)}
>
<SelectTrigger id="reject-category">
<SelectValue placeholder="Välj kategori" />
</SelectTrigger>
<SelectContent>
{(Object.keys(REJECTION_CATEGORY_LABELS) as PendingOperationRejectionCategory[]).map((cat) => (
<SelectItem key={cat} value={cat}>{REJECTION_CATEGORY_LABELS[cat]}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="reject-reason">
Notering (valfritt)
</label>
<Textarea
id="reject-reason"
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
placeholder="T.ex. fel kund matchades, beloppet stämmer inte med fakturan…"
rows={3}
maxLength={2000}
/>
<p className="text-xs text-muted-foreground">
Synlig för agenten via gnubok_get_recent_rejections hjälper den att korrigera nästa förslag.
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setRejectOp(null)} disabled={isRejecting}>
Avbryt
</Button>
<Button variant="destructive" onClick={handleReject} disabled={isRejecting}>
{isRejecting ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Avvisa'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
+7 -1
View File
@@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Download, FileSpreadsheet, AlertCircle, ChevronDown, ChevronRight, ArrowRight } from 'lucide-react'
import AgentSparkleButton from '@/components/agent/AgentSparkleButton'
import { formatDate } from '@/lib/utils'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import { AccountNumber } from '@/components/ui/account-number'
@@ -1352,7 +1353,7 @@ function VatDeclarationView() {
return (
<div className="space-y-4">
<div className="flex justify-end">
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
@@ -1361,6 +1362,11 @@ function VatDeclarationView() {
<FileSpreadsheet className="h-4 w-4 mr-2" />
Ladda ner Excel
</Button>
<AgentSparkleButton
intentId="vat.review"
intentArgs={{ period_type: periodType, year, period }}
contextRef={`vat:${year}-${periodType}-${period}`}
/>
</div>
{/* Period selection */}
<Card>
@@ -0,0 +1,8 @@
import { redirect } from 'next/navigation'
// Merged into the "Assistenten" tab. Kept as a permanent redirect so existing
// links (e.g. AgentChat's "what I remember" affordance) and old bookmarks land
// in the right place.
export default function AgentMemorySettingsPage() {
redirect('/settings/assistant')
}
@@ -0,0 +1,7 @@
import { redirect } from 'next/navigation'
// Merged into the "Assistenten" tab (Kompetens view). Kept as a permanent
// redirect for old links.
export default function AgentSkillsSettingsPage() {
redirect('/settings/assistant?view=skills')
}
@@ -0,0 +1,43 @@
'use client'
import { useSearchParams, useRouter } from 'next/navigation'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { AgentMemoryPanel } from '@/components/settings/AgentMemoryPanel'
import { AgentSkillsPanel } from '@/components/settings/AgentSkillsPanel'
// "Assistenten" — what the assistant remembers about this company (Minne,
// editable) and the domain knowledge it ships with (Kompetens, read-only).
// A toggle keeps both one click away instead of stacked, so the competence
// view isn't buried below the memory list.
type View = 'memory' | 'skills'
export default function AssistantSettingsPage() {
const searchParams = useSearchParams()
const router = useRouter()
const view: View = searchParams.get('view') === 'skills' ? 'skills' : 'memory'
function setView(next: string) {
// 'memory' is the default — keep its URL clean (no query string).
router.replace(next === 'skills' ? '/settings/assistant?view=skills' : '/settings/assistant', {
scroll: false,
})
}
return (
<Tabs value={view} onValueChange={setView} className="space-y-6">
<TabsList>
<TabsTrigger value="memory">Minne</TabsTrigger>
<TabsTrigger value="skills">Kompetens</TabsTrigger>
</TabsList>
{/* Radix unmounts the inactive panel, so each panel's data is fetched
lazily the first time its tab is opened. */}
<TabsContent value="memory">
<AgentMemoryPanel />
</TabsContent>
<TabsContent value="skills">
<AgentSkillsPanel />
</TabsContent>
</Tabs>
)
}
@@ -0,0 +1,7 @@
import { redirect } from 'next/navigation'
// Företagsprofil (TIC-snapshot Bolagsuppgifter) now lives as a section on the
// Företag tab. Kept as a permanent redirect for old bookmarks and deep links.
export default function CompanyProfileSettingsPage() {
redirect('/settings/company')
}
@@ -4,6 +4,7 @@ import { useRouter } from 'next/navigation'
import { CompanyDangerZone } from '@/components/settings/CompanyDangerZone'
import { CompanyInfoForm } from '@/components/settings/CompanyInfoForm'
import { CompanyMembersSection } from '@/components/settings/CompanyMembersSection'
import { CompanyProfileSection } from '@/components/settings/CompanyProfileSection'
import { FiscalPeriodEditor } from '@/components/settings/FiscalPeriodEditor'
import { LogoUpload } from '@/components/settings/LogoUpload'
import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper'
@@ -60,6 +61,8 @@ export default function CompanySettingsPage() {
<FiscalPeriodEditor />
<CompanyProfileSection />
<CompanyDangerZone />
</div>
)
+2
View File
@@ -14,6 +14,8 @@ const TAB_TO_ROUTE: Record<string, string> = {
team: '/settings/team',
banking: '/settings/banking',
templates: '/settings/templates',
'agent-memory': '/settings/assistant',
assistant: '/settings/assistant',
account: '/settings/account',
api: '/settings/api',
}
+4 -39
View File
@@ -1,42 +1,7 @@
'use client'
import { useEffect } from 'react'
import { useTranslations } from 'next-intl'
import { useSearchParams, useRouter } from 'next/navigation'
import { useToast } from '@/components/ui/use-toast'
import { SkatteverketConnectPanel } from '@/components/settings/SkatteverketConnectPanel'
import { redirect } from 'next/navigation'
// Skatteverket-anslutningen ligger numera som en sektion under Skatt-fliken.
// Behålls som permanent omdirigering för gamla bokmärken och djuplänkar.
export default function SkatteverketSettingsPage() {
const t = useTranslations('settings_skatteverket')
const searchParams = useSearchParams()
const router = useRouter()
const { toast } = useToast()
useEffect(() => {
const connected = searchParams.get('skv_connected')
const error = searchParams.get('skv_error')
if (connected === 'true') {
toast({
title: t('connected_title'),
description: t('connected_description'),
})
router.replace('/settings/skatteverket')
} else if (error) {
let msg: string
try { msg = decodeURIComponent(error) } catch { msg = error }
toast({
title: t('connect_failed_title'),
description: msg,
variant: 'destructive',
})
router.replace('/settings/skatteverket')
}
}, [searchParams, router, toast, t])
return (
<div className="space-y-8">
<SkatteverketConnectPanel />
</div>
)
redirect('/settings/tax')
}
+61 -3
View File
@@ -1,13 +1,65 @@
'use client'
import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { useSearchParams, useRouter } from 'next/navigation'
import { TaxSettingsForm } from '@/components/settings/TaxSettingsForm'
import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper'
import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton'
import { SkatteverketConnectPanel } from '@/components/settings/SkatteverketConnectPanel'
import { useSettings } from '@/components/settings/useSettings'
import { useToast } from '@/components/ui/use-toast'
import { useCompany } from '@/contexts/CompanyContext'
import { createClient } from '@/lib/supabase/client'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import type { CompanySettings } from '@/types'
export default function TaxSettingsPage() {
const { settings, isLoading, updateSettings } = useSettings()
const { company } = useCompany()
const t = useTranslations('settings_skatteverket')
const searchParams = useSearchParams()
const router = useRouter()
const { toast } = useToast()
const [isSandbox, setIsSandbox] = useState(false)
const hasSkatteverketExtension = ENABLED_EXTENSION_IDS.has('skatteverket')
// Sandbox companies don't connect to the real Skatteverket — hide the panel,
// matching the old Skatteverket tab's visibility gate.
useEffect(() => {
if (!company?.id) return
const supabase = createClient()
supabase
.from('company_settings')
.select('is_sandbox')
.eq('company_id', company.id)
.single()
.then(({ data }) => {
if (data?.is_sandbox) setIsSandbox(true)
})
}, [company?.id])
// Skatteverket OAuth callback — the connect flow returns to /settings/tax with
// a status query param (returnTo set in SkatteverketConnectPanel).
useEffect(() => {
const connected = searchParams.get('skv_connected')
const error = searchParams.get('skv_error')
if (connected === 'true') {
toast({ title: t('connected_title'), description: t('connected_description') })
router.replace('/settings/tax')
} else if (error) {
let msg: string
try {
msg = decodeURIComponent(error)
} catch {
msg = error
}
toast({ title: t('connect_failed_title'), description: msg, variant: 'destructive' })
router.replace('/settings/tax')
}
}, [searchParams, router, toast, t])
if (isLoading || !settings) return <SettingsLoadingSkeleton />
@@ -36,9 +88,15 @@ export default function TaxSettingsPage() {
}
}
const showSkatteverket = hasSkatteverketExtension && !isSandbox
return (
<SettingsFormWrapper onSave={handleSave} className="space-y-0">
<TaxSettingsForm settings={settings} />
</SettingsFormWrapper>
<div className="space-y-8">
<SettingsFormWrapper onSave={handleSave} className="space-y-0">
<TaxSettingsForm settings={settings} />
</SettingsFormWrapper>
{showSkatteverket && <SkatteverketConnectPanel />}
</div>
)
}
+1 -1
View File
@@ -235,7 +235,7 @@ export default function SkattekontoPage() {
ansluta med BankID i inställningarna.
</p>
<Button asChild>
<Link href="/settings/skatteverket">
<Link href="/settings/tax">
<ExternalLink className="mr-2 h-4 w-4" />
Anslut Skatteverket
</Link>
@@ -13,6 +13,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { ArrowLeft, CheckCircle, CreditCard, FileText, Trash2, Lock, Undo2, Info } from 'lucide-react'
import AgentSparkleButton from '@/components/agent/AgentSparkleButton'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { formatDate } from '@/lib/utils'
import Link from 'next/link'
@@ -243,6 +244,12 @@ export default function SupplierInvoiceDetailPage() {
{/* Actions */}
<div className="flex flex-wrap gap-2">
<AgentSparkleButton
intentId="supplier_invoice.review"
intentArgs={{ supplier_invoice_id: invoice.id }}
contextRef={`supplier_invoice:${invoice.id}`}
size="default"
/>
{invoice.status === 'registered' && !invoice.is_credit_note && (
<>
<Button
+46 -8
View File
@@ -8,6 +8,7 @@ import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import AiFilledIndicator from '@/components/ui/ai-filled-indicator'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
@@ -23,7 +24,7 @@ import { cn, formatCurrency } from '@/lib/utils'
import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import BankTransactionPicker from '@/components/transactions/BankTransactionPicker'
import { ArrowLeft, Plus, Trash2, ChevronDown, Loader2, Lock, AlertCircle, Sparkles, Link2 } from 'lucide-react'
import { ArrowLeft, Plus, Trash2, ChevronDown, Loader2, Lock, AlertCircle, MessageCircle, Link2 } from 'lucide-react'
import type { Supplier, BASAccount, VatTreatment, EntityType, InvoiceExtractionResult } from '@/types'
interface LineItem {
@@ -274,6 +275,28 @@ export default function NewSupplierInvoicePage() {
const watchedCurrency = watch('currency')
const watchedPaidPrivately = watch('paid_with_private_funds')
const watchedReverseCharge = watch('reverse_charge')
// Watched values used to decide whether the AI-filled indicator should
// still be visible. Once the user edits a field, its value no longer
// matches what the extractor wrote, and the dot fades out.
const watchedInvoiceNumber = watch('supplier_invoice_number')
const watchedInvoiceDate = watch('invoice_date')
const watchedDueDate = watch('due_date')
const watchedPaymentReference = watch('payment_reference')
// Returns true when the field currently matches whatever the AI wrote
// when the form first loaded. Edits diverge it, hiding the dot.
function stillFromAi(value: string | null | undefined, original: string | null | undefined): boolean {
if (!original) return false
return (value ?? '') === (original ?? '')
}
const aiFlags = {
invoiceNumber: stillFromAi(watchedInvoiceNumber, originalExtracted?.invoice?.invoiceNumber ?? null),
invoiceDate: stillFromAi(watchedInvoiceDate, originalExtracted?.invoice?.invoiceDate ?? null),
dueDate: stillFromAi(watchedDueDate, originalExtracted?.invoice?.dueDate ?? null),
paymentReference: stillFromAi(
watchedPaymentReference,
originalExtracted?.invoice?.paymentReference ?? null,
),
}
const isEF = entityType === 'enskild_firma'
@@ -414,8 +437,8 @@ export default function NewSupplierInvoicePage() {
// Auto-fetch Riksbanken exchange rate when currency switches to non-SEK and
// the user hasn't typed a custom rate yet. Re-fetches when the invoice
// date changes too. Never overwrites a user-entered rate.
const watchedInvoiceDate = watch('invoice_date')
// date changes too. Never overwrites a user-entered rate. Reuses the
// watchedInvoiceDate declared above for the AI-filled-indicator flag.
// The "user has manually edited the rate" flag is scoped *per currency*.
// Switching from EUR (rate 11.8 edited by hand) to USD must re-fetch — the
// EUR rate is meaningless for a USD invoice. Tracking last-fetched currency
@@ -968,7 +991,7 @@ export default function NewSupplierInvoicePage() {
<CardContent className="py-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div className="flex items-start gap-3">
<Sparkles className="h-5 w-5 text-primary shrink-0 mt-0.5" />
<MessageCircle className="h-5 w-5 text-primary shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium">
{t('ai_suggested_supplier', { name: extractedData?.supplier?.name ?? '' })}
@@ -1054,7 +1077,10 @@ export default function NewSupplierInvoicePage() {
/>
</div>
<div className="space-y-2">
<Label>{t('supplier_invoice_number_label')}<RequiredMark /></Label>
<div className="flex items-center justify-between">
<Label>{t('supplier_invoice_number_label')}<RequiredMark /></Label>
<AiFilledIndicator active={aiFlags.invoiceNumber} label="AI-fyllt" />
</div>
{(() => {
const { ref: rhfRef, ...rest } = register('supplier_invoice_number')
return (
@@ -1075,17 +1101,29 @@ export default function NewSupplierInvoicePage() {
watchedPaidPrivately ? 'sm:grid-cols-1' : 'sm:grid-cols-3',
)}>
<div className="space-y-2">
<Label>{t('invoice_date_label')}<RequiredMark /></Label>
<div className="flex items-center justify-between">
<Label>{t('invoice_date_label')}<RequiredMark /></Label>
<AiFilledIndicator active={aiFlags.invoiceDate} label="AI-fyllt" />
</div>
<Input type="date" {...register('invoice_date')} />
</div>
{!watchedPaidPrivately && (
<>
<div className="space-y-2">
<Label>{t('due_date_label')}<RequiredMark /></Label>
<div className="flex items-center justify-between">
<Label>{t('due_date_label')}<RequiredMark /></Label>
<AiFilledIndicator active={aiFlags.dueDate} label="AI-fyllt" />
</div>
<Input type="date" {...register('due_date')} />
</div>
<div className="space-y-2">
<Label>{t('payment_reference_label')}</Label>
<div className="flex items-center justify-between">
<Label>{t('payment_reference_label')}</Label>
<AiFilledIndicator
active={aiFlags.paymentReference}
label="AI-fyllt"
/>
</div>
<Input placeholder={t('payment_reference_placeholder')} {...register('payment_reference')} />
</div>
</>
+8 -8
View File
@@ -437,9 +437,13 @@ export default function TransactionsPage() {
return () => { cancelled = true }
}, [])
// Auto-open categorize panel when arriving via /transactions?highlight=<id>
// (used by the inbox "Bokför transaktionen" link). Runs once per distinct
// highlight id so closing the panel doesn't re-trigger it.
// Scroll the targeted row into view when arriving via
// /transactions?highlight=<id>. Callers are inbox "Öppna transaktionen",
// payment-booking dialog, and supplier-invoice cross-link — all "go look
// at this row", not "start booking". The legacy auto-open-template-picker
// behavior was removed in v5: booking happens in the inbox workspace now.
// Runs once per distinct highlight id so closing/scrolling away doesn't
// re-trigger it.
useEffect(() => {
if (!highlightId) return
if (handledHighlightRef.current === highlightId) return
@@ -459,11 +463,6 @@ export default function TransactionsPage() {
}
})
})
if (tx.is_business === null && !tx.journal_entry_id) {
setTemplatePickerTransaction(tx)
setTemplatePickerOpen(true)
}
}, [highlightId, transactions])
// Auto-fetch suggestions when transactions load
@@ -1995,6 +1994,7 @@ export default function TransactionsPage() {
</div>
</DialogContent>
</Dialog>
</div>
)
}
@@ -0,0 +1,111 @@
import { describe, it, expect, vi } from 'vitest'
import { findCompanyRoleByOrgNumber } from '../page'
import type { EnrichmentCompanyRole } from '@/lib/company-lookup/types'
// `findCompanyRoleByOrgNumber` replaces the old prefetchLookup at /onboarding.
// It reads bankid_enrichment.company_roles (populated by TIC Identity API at
// BankID-completion time) and matches the role by orgnr. This costs zero
// Lens calls — Identity API is on a different TIC product/quota. These tests
// pin the behaviour because regressing this would silently re-add Lens spend
// to every BankID signup.
function makeRole(overrides: Partial<EnrichmentCompanyRole> = {}): EnrichmentCompanyRole {
return {
companyId: 12345,
companyRegistrationNumber: '5560125790',
legalName: 'Acme AB',
legalEntityType: 'Aktiebolag',
positionTypes: ['boardMember'],
positionDescriptions: ['Styrelseledamot'],
positionStart: '2020-01-01',
positionEnd: null,
companyStatus: 'isActive',
...overrides,
}
}
function mockSupabase(rolesData: EnrichmentCompanyRole[] | null) {
return {
from: vi.fn().mockReturnValue({
select: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
maybeSingle: vi.fn().mockResolvedValue({
data: rolesData === null ? null : { company_roles: rolesData },
error: null,
}),
}),
}),
}),
}
}
describe('findCompanyRoleByOrgNumber', () => {
it('returns the matching role for a clean 10-digit orgnr', async () => {
const supabase = mockSupabase([
makeRole({ companyRegistrationNumber: '5560125790', legalName: 'Acme AB' }),
makeRole({ companyRegistrationNumber: '5567890123', legalName: 'Other AB' }),
])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await findCompanyRoleByOrgNumber(supabase as any, 'user-1', '5560125790')
expect(result).toEqual({ legalName: 'Acme AB', legalEntityType: 'Aktiebolag' })
})
it('matches orgnrs with hyphens stripped (TIC returns "556012-5790")', async () => {
// CompanyRoles may carry the orgnr in formatted form; the function under
// test cleans the registered form before comparing to the cleaned input.
const supabase = mockSupabase([
makeRole({ companyRegistrationNumber: '556012-5790', legalName: 'Acme AB' }),
])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await findCompanyRoleByOrgNumber(supabase as any, 'user-1', '5560125790')
expect(result).toEqual({ legalName: 'Acme AB', legalEntityType: 'Aktiebolag' })
})
it('returns null when no enrichment row exists (user signed up without BankID)', async () => {
const supabase = mockSupabase(null)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await findCompanyRoleByOrgNumber(supabase as any, 'user-1', '5560125790')
expect(result).toBeNull()
})
it('returns null when enrichment row has an empty company_roles array', async () => {
const supabase = mockSupabase([])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await findCompanyRoleByOrgNumber(supabase as any, 'user-1', '5560125790')
expect(result).toBeNull()
})
it('returns null when none of the roles match the requested orgnr', async () => {
const supabase = mockSupabase([
makeRole({ companyRegistrationNumber: '5567890123', legalName: 'Other AB' }),
])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await findCompanyRoleByOrgNumber(supabase as any, 'user-1', '5560125790')
expect(result).toBeNull()
})
it('preserves the TIC `legalEntityType` exactly so mapEntityType can classify v2 strings', async () => {
// Important: TIC v2 returns full Swedish names like "Aktiebolag" and
// "Enskild firma" (not the v1 "AB"/"EF" abbreviations). The canonical
// mapEntityType in lib/company-lookup/entity-type-map.ts handles both
// sets — but only if we pass the raw string through unchanged.
const supabase = mockSupabase([
makeRole({ companyRegistrationNumber: '8001011231', legalEntityType: 'Enskild firma' }),
])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await findCompanyRoleByOrgNumber(supabase as any, 'user-1', '8001011231')
expect(result?.legalEntityType).toBe('Enskild firma')
})
})
+252
View File
@@ -0,0 +1,252 @@
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import { headers } from 'next/headers'
import { getActiveCompanyId } from '@/lib/company/context'
import { ensureTicSnapshot } from '@/lib/agent/composer/tic-fetch'
import AgentOnboarding from '@/components/onboarding/agent/AgentOnboarding'
export const dynamic = 'force-dynamic'
// /onboarding/agent — Phase A (real-timed build) and Phase B (review) of the
// specialized accountant agent build sequence.
//
// Plan refs: dev_docs/specialized-agent-plan.md §7 (Build-sequence UX),
// §15 Phase 2 (Build-sequence UX).
export default async function AgentOnboardingPage() {
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')
// Trigger the TIC live-fetch + cache before the field-resolving query
// below. ensureTicSnapshot is fast on cache-hit (single SELECT) and
// best-effort on miss — it never throws. Phase A still runs through the
// streaming endpoint; this just lets the initial Phase B render show the
// SNI/verksamhetsbeskrivning when the user returns to the page after
// stream completion.
const hdrs = await headers()
const cookieHeader = hdrs.get('cookie') ?? ''
const host = hdrs.get('host') ?? 'localhost:3000'
const proto = hdrs.get('x-forwarded-proto') ?? (host.startsWith('localhost') ? 'http' : 'https')
const origin = `${proto}://${host}`
// upgradeV1: this is the one place the v2-only sections (statuses,
// beneficialOwners, payrolls, …) materially drive the composer, and it's
// a deliberate once-per-company action — safe to spend the TIC calls to
// bring a pre-v2 snapshot up to date.
await ensureTicSnapshot({ supabase, companyId, cookieHeader, origin, upgradeV1: true })
// Fetch the small handful of fields we render directly into Phase B so the
// user sees real values (not "Laddar…") the moment the stream finishes.
// company_settings is a separate fetch because it carries the onboarding-
// form data (moms_period, fiscal_year_start_month, f_skatt, city, …) that
// never makes it onto `companies` proper.
const [{ data: company }, { data: profile }, { data: existingProfile }, { data: settings }] =
await Promise.all([
supabase
.from('companies')
.select('name, entity_type, org_number, tic_snapshot')
.eq('id', companyId)
.single(),
supabase.from('profiles').select('full_name').eq('id', user.id).single(),
supabase
.from('agent_profiles')
.select('company_id, profile_summary, verified_at')
.eq('company_id', companyId)
.maybeSingle(),
supabase
.from('company_settings')
.select(
'city, address_line1, postal_code, f_skatt, vat_registered, moms_period, fiscal_year_start_month, employee_count, has_employees',
)
.eq('company_id', companyId)
.maybeSingle(),
])
if (!company) redirect('/onboarding')
const firstName = profile?.full_name?.split(' ')[0] ?? null
// Pre-render-friendly snapshot of company info — used to seed Phase B fields
// before the stream completes so the layout doesn't jump.
const initialFields = buildInitialFields(company, settings)
// Atom titles — slug-derived labels look ugly ("Konsult It",
// "Single Shareholder Ab Fmb"). Fetch the registry titles once and pass them
// to the review card so chips render as authored.
const { data: atomRows } = await supabase
.from('agent_atom_registry')
.select('id, title')
.eq('is_active', true)
.is('parent_atom_id', null) // skill titles only; reference children never appear as profile chips
const atomTitles: Record<string, string> = {}
for (const row of (atomRows ?? []) as { id: string; title: string }[]) {
atomTitles[row.id] = row.title
}
return (
<AgentOnboarding
companyId={companyId}
companyName={company.name}
firstName={firstName}
initialFields={initialFields}
atomTitles={atomTitles}
alreadyVerified={Boolean(existingProfile?.verified_at)}
existingSummary={existingProfile?.profile_summary ?? null}
/>
)
}
interface InitialFields {
entity_type_label: string
// Multiple SNI codes — most companies have one but some are
// multi-vertical (e.g. konsult + lagerförsäljning).
sni_codes: { code: string; name: string }[]
// Verksamhetsbeskrivning (purpose) from Bolagsverket via TIC.
purpose: string | null
city: string | null
fiscal_period: string | null
vat_period: string | null
f_skatt: string | null
employees: string | null
}
interface CompanySettingsForPhaseB {
city: string | null
address_line1: string | null
postal_code: string | null
f_skatt: boolean | null
vat_registered: boolean | null
moms_period: string | null
fiscal_year_start_month: number | null
employee_count: number | null
has_employees: boolean | null
}
function buildInitialFields(
company: {
name: string
entity_type: string
org_number: string | null
tic_snapshot: Record<string, unknown> | null
},
settings: CompanySettingsForPhaseB | null,
): InitialFields {
const tic = (company.tic_snapshot ?? null) as Record<string, unknown> | null
const entityLabel =
company.entity_type === 'aktiebolag'
? 'AB'
: company.entity_type === 'enskild_firma'
? 'Enskild firma'
: company.entity_type
// Tier the resolution: TIC snapshot (if cached) wins because it's the
// authoritative Bolagsverket data; company_settings is the user-entered
// fallback from onboarding. Either can be missing.
let sniCodes: { code: string; name: string }[] = []
let purpose: string | null = null
let city: string | null = null
let fSkatt: string | null = null
let vatPeriod: string | null = null
let fiscalPeriod: string | null = null
let employees: string | null = null
if (tic) {
const sni = (tic.sniCodes as { code: string; name: string }[] | undefined) ?? []
if (Array.isArray(sni)) sniCodes = sni
const tp = tic.purpose
if (typeof tp === 'string' && tp.trim().length > 0) purpose = tp.trim()
const addr = tic.address as { city: string | null } | null
if (addr?.city) city = addr.city
const reg = tic.registration as { fTax?: boolean } | undefined
if (reg) fSkatt = reg.fTax ? 'Aktivt' : 'Saknas'
if (tic.employeeRange) employees = tic.employeeRange as string
// v2 caches `fiscalYear` (current fiscal-year configuration) on the
// snapshot. Prefer it over the user-entered value below so onboarding
// can show the registered fiscal period from Bolagsverket without
// making the user re-enter it.
const ticFiscal = tic.fiscalYear as { startMonthDay?: string | null } | null
const startMonth = parseTicStartMonth(ticFiscal?.startMonthDay)
if (startMonth != null) fiscalPeriod = fiscalYearLabel(startMonth)
}
if (settings) {
if (!city && settings.city) city = settings.city
if (!fSkatt && settings.f_skatt != null) {
fSkatt = settings.f_skatt ? 'Aktivt' : 'Saknas'
}
if (settings.moms_period) {
vatPeriod = momsPeriodLabel(settings.moms_period)
}
// settings.fiscal_year_start_month is the user-confirmed value — only
// overwrite the TIC-derived label if the user has explicitly set it
// (i.e. when there was no TIC fiscalYear AND they entered it manually).
if (!fiscalPeriod && settings.fiscal_year_start_month != null) {
fiscalPeriod = fiscalYearLabel(settings.fiscal_year_start_month)
}
if (!employees && settings.employee_count != null) {
employees = String(settings.employee_count)
} else if (!employees && settings.has_employees != null) {
employees = settings.has_employees ? 'Ja' : 'Nej'
}
}
return {
entity_type_label: entityLabel,
sni_codes: sniCodes,
purpose,
city,
fiscal_period: fiscalPeriod,
vat_period: vatPeriod,
f_skatt: fSkatt,
employees,
}
}
// Parse TIC v2's `startMonthDay` ("MM-DD") into a month number 1-12. Returns
// null when the field is missing or malformed so the caller falls back to
// company_settings.fiscal_year_start_month.
function parseTicStartMonth(value: string | null | undefined): number | null {
if (!value) return null
const match = /^(\d{1,2})-\d{1,2}$/.exec(value)
if (!match) return null
const month = Number(match[1])
if (!Number.isInteger(month) || month < 1 || month > 12) return null
return month
}
function momsPeriodLabel(period: string): string {
switch (period) {
case 'monthly':
return 'Månadsmoms'
case 'quarterly':
return 'Kvartalsmoms'
case 'yearly':
return 'Årsmoms'
default:
return period
}
}
// "fiscal_year_start_month=1" → "januaridecember".
function fiscalYearLabel(startMonth: number): string {
const months = [
'januari',
'februari',
'mars',
'april',
'maj',
'juni',
'juli',
'augusti',
'september',
'oktober',
'november',
'december',
]
if (startMonth < 1 || startMonth > 12) return ''
const startIdx = startMonth - 1
const endIdx = (startIdx + 11) % 12
return `${months[startIdx]}${months[endIdx]}`
}
+60 -3
View File
@@ -1,9 +1,46 @@
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import WelcomeOnboarding from '@/components/dashboard/WelcomeOnboarding'
import type { EntityType } from '@/types'
import type { EnrichmentCompanyRole } from '@/lib/company-lookup/types'
import { mapEntityType as mapTicEntityType } from '@/lib/company-lookup/entity-type-map'
export const dynamic = 'force-dynamic'
// Look up the user's CompanyRoles enrichment (from BankID auth) and find the
// role whose orgnr matches the incoming `?org_number=`. CompanyRoles lives on
// the TIC Identity API — separate product, separate quota from the Lens
// `/lookup` endpoint — so this is free: no Lens calls.
//
// Returns enough to pre-fill Step 1's entity-type radio + Step 2's
// company_name field. The rest (address, F-skatt, VAT) is captured by the
// user in Steps 24. F-skatt/VAT defaults can't be safely guessed without
// Bolagsverket data (ML 17 kap 24§ violation if we default a momsregistrerat
// bolag to false), so we make the user confirm in Step 4.
//
// Exported for unit testing.
export async function findCompanyRoleByOrgNumber(
supabase: Awaited<ReturnType<typeof createClient>>,
userId: string,
orgNumber: string,
): Promise<{ legalName: string; legalEntityType: string } | null> {
const { data } = await supabase
.from('bankid_enrichment')
.select('company_roles')
.eq('user_id', userId)
.maybeSingle()
const roles = (data?.company_roles ?? []) as EnrichmentCompanyRole[]
if (!Array.isArray(roles) || roles.length === 0) return null
const match = roles.find(
(r) => r.companyRegistrationNumber.replace(/[\s-]/g, '') === orgNumber,
)
if (!match) return null
return { legalName: match.legalName, legalEntityType: match.legalEntityType }
}
export default async function OnboardingPage({
searchParams,
}: {
@@ -46,12 +83,29 @@ export default async function OnboardingPage({
const firstName = profile?.full_name?.split(' ')[0] || null
// The BankID picker routes here with ?org_number=… when TIC /lookup fails
// or the entity type isn't one-click-provisionable. Strip formatting so
// whatever Step2 displays matches what the rest of the flow will store.
// The BankID picker routes here with ?org_number=… for every pick. Strip
// formatting so whatever Step 2 displays matches what the rest of the flow
// will store.
const { org_number: rawOrgNumber } = await searchParams
const initialOrgNumber = rawOrgNumber ? rawOrgNumber.replace(/[\s-]/g, '') : undefined
// BankID prefill: look up the CompanyRoles row (no Lens call) to pre-fill
// Step 1's entity_type radio and Step 2's company_name. If no role matches,
// the user fills everything manually — same fallback as a non-BankID
// signup. `preverifiedOrgNumber` tells Step 2 to skip the client-side
// /lookup since CompanyRoles already confirms existence.
let initialEntityType: EntityType | undefined
let initialLegalName: string | undefined
let preverifiedOrgNumber: string | undefined
if (initialOrgNumber) {
const match = await findCompanyRoleByOrgNumber(supabase, user.id, initialOrgNumber)
if (match) {
initialEntityType = mapTicEntityType(match.legalEntityType) ?? undefined
initialLegalName = match.legalName
preverifiedOrgNumber = initialOrgNumber
}
}
return (
<WelcomeOnboarding
firstName={firstName}
@@ -59,6 +113,9 @@ export default async function OnboardingPage({
skipWelcome
hasExistingCompanies={hasCompanies}
initialOrgNumber={initialOrgNumber}
initialEntityType={initialEntityType}
initialLegalName={initialLegalName}
preverifiedOrgNumber={preverifiedOrgNumber}
/>
)
}
+73
View File
@@ -0,0 +1,73 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { getActiveCompanyId } from '@/lib/company/context'
import { checkAgentRateLimit, agentRateLimitResponseBody } from '@/lib/rate-limits/agent'
import { composeAgentProfile } from '@/lib/agent/composer'
const BodySchema = z.object({
// Optional override; if absent we use the user's active_company_id.
company_id: z.string().uuid().optional(),
// dry_run=true returns the composed profile without writing to agent_profiles.
dry_run: z.boolean().optional(),
})
// POST /api/agent/composer
//
// Runs the specialized accountant composer pipeline for a company:
// 1. Gathers TIC snapshot, optional SIE summary, optional banking summary.
// 2. Calls Opus 4.7 to select horizontal/vertical/modifier atoms.
// 3. Calls Sonnet 4.6 to write the Swedish profile_summary.
// 4. Persists to agent_profiles (skipped on dry_run).
// 5. Fires fire-and-forget cache pre-warm.
//
// Auth: must be a member of the target company.
//
// Plan ref: dev_docs/specialized-agent-plan.md §6.
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const rate = await checkAgentRateLimit(supabase, user.id)
if (!rate.ok) {
return NextResponse.json(agentRateLimitResponseBody(rate), {
status: 429,
headers: rate.retryAfterSec ? { 'Retry-After': String(rate.retryAfterSec) } : undefined,
})
}
let body: z.infer<typeof BodySchema>
try {
body = BodySchema.parse(await request.json().catch(() => ({})))
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Invalid body' },
{ status: 400 },
)
}
const companyId = body.company_id ?? (await getActiveCompanyId(supabase, user.id))
if (!companyId) {
return NextResponse.json({ error: 'No active company' }, { status: 400 })
}
// Defense in depth alongside RLS — confirm membership before composing.
const { data: membership } = await supabase
.from('company_members')
.select('role')
.eq('company_id', companyId)
.eq('user_id', user.id)
.maybeSingle()
if (!membership) {
return NextResponse.json({ error: 'Not a member of this company' }, { status: 403 })
}
try {
const composed = await composeAgentProfile(supabase, companyId, { dryRun: body.dry_run })
return NextResponse.json({ data: composed })
} catch (err) {
const message = err instanceof Error ? err.message : 'Composer failed'
return NextResponse.json({ error: message }, { status: 500 })
}
}
+115
View File
@@ -0,0 +1,115 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { z } from 'zod'
// GET /api/agent/conversations/[id]
//
// Returns one conversation + its messages in chronological order. The chat
// page hydrates with this on mount; the agent loop then continues via
// /api/agent/invoke with the conversation_id.
//
// PATCH /api/agent/conversations/[id]
//
// Updates pin/archive state or title. The chat list relies on these.
const PatchSchema = z.object({
pinned: z.boolean().nullable().optional(),
archived: z.boolean().nullable().optional(),
title: z.string().min(1).max(200).nullable().optional(),
})
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { id } = await params
const { data: conv, error: convErr } = await supabase
.from('agent_conversations')
.select(
'id, company_id, user_id, intent_id, context_ref, title, pinned, archived, last_message_at, created_at',
)
.eq('id', id)
.maybeSingle()
if (convErr) return NextResponse.json({ error: convErr.message }, { status: 500 })
if (!conv) return NextResponse.json({ error: 'Conversation not found' }, { status: 404 })
// Defense in depth alongside RLS — verify caller is a member of the
// conversation's company AND owns the conversation row. Conversations are
// user-scoped within a company; one team member should not see another's.
if (conv.user_id !== user.id) {
return NextResponse.json({ error: 'Conversation not found' }, { status: 404 })
}
const { data: membership } = await supabase
.from('company_members')
.select('role')
.eq('company_id', conv.company_id)
.eq('user_id', user.id)
.maybeSingle()
if (!membership) {
return NextResponse.json({ error: 'Conversation not found' }, { status: 404 })
}
const { data: messages, error: msgErr } = await supabase
.from('agent_messages')
.select('id, role, content, tool_use_id, hidden, created_at')
.eq('conversation_id', id)
.order('created_at', { ascending: true })
if (msgErr) return NextResponse.json({ error: msgErr.message }, { status: 500 })
return NextResponse.json({ data: { conversation: conv, messages: messages ?? [] } })
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { id } = await params
let body: z.infer<typeof PatchSchema>
try {
body = PatchSchema.parse(await request.json())
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Invalid body' },
{ status: 400 },
)
}
const update: Record<string, unknown> = {}
if (body.pinned != null) update.pinned = body.pinned
if (body.archived != null) update.archived = body.archived
if (body.title != null) update.title = body.title
if (Object.keys(update).length === 0) {
return NextResponse.json({ error: 'Nothing to update' }, { status: 400 })
}
// Defense in depth — verify ownership before update so a 404 is returned
// (instead of relying solely on RLS, which would silently 0-row).
const { data: existing } = await supabase
.from('agent_conversations')
.select('user_id, company_id')
.eq('id', id)
.maybeSingle()
if (!existing || existing.user_id !== user.id) {
return NextResponse.json({ error: 'Conversation not found' }, { status: 404 })
}
const { data, error } = await supabase
.from('agent_conversations')
.update(update)
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', existing.company_id)
.select('id, intent_id, context_ref, title, pinned, archived, last_message_at, created_at')
.single()
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ data })
}
+60
View File
@@ -0,0 +1,60 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { getActiveCompanyId } from '@/lib/company/context'
// GET /api/agent/conversations
//
// Query params:
// archived: 'true' | 'false' (default 'false')
// pinned: 'true' filters to pinned only
// intent: 'general.help' (or any intent id) — filter
// q: case-insensitive substring match on title/context_ref
// limit: 1..200, default 50
//
// Returns: { data: [{ id, intent_id, context_ref, title, pinned, archived,
// last_message_at, created_at }] }
//
// Ordered: pinned first (within archived bucket), then last_message_at desc.
// Used by the /chat sidebar and "resume conversation" UI in the sheet.
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const companyId = await getActiveCompanyId(supabase, user.id)
if (!companyId) return NextResponse.json({ error: 'No active company' }, { status: 400 })
const url = new URL(request.url)
const archived = url.searchParams.get('archived') === 'true'
const pinnedOnly = url.searchParams.get('pinned') === 'true'
const intent = url.searchParams.get('intent') ?? null
const q = url.searchParams.get('q')?.trim() ?? ''
const limit = Math.min(Math.max(Number(url.searchParams.get('limit')) || 50, 1), 200)
let query = supabase
.from('agent_conversations')
.select(
'id, intent_id, context_ref, title, pinned, archived, last_message_at, last_message_preview, created_at',
)
.eq('company_id', companyId)
.eq('archived', archived)
if (pinnedOnly) query = query.eq('pinned', true)
if (intent) query = query.eq('intent_id', intent)
if (q.length > 0) {
// Pattern is sanitized via Postgres' percent-handling; ilike accepts the
// % wildcard and we only inject the user's substring between them.
const safe = q.replace(/[%_]/g, (m) => `\\${m}`)
query = query.or(`title.ilike.%${safe}%,context_ref.ilike.%${safe}%`)
}
// Sort: pinned first, then most recent message.
query = query
.order('pinned', { ascending: false })
.order('last_message_at', { ascending: false, nullsFirst: false })
.limit(limit)
const { data, error } = await query
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ data: data ?? [] })
}
+292
View File
@@ -0,0 +1,292 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { ensureInitialized } from '@/lib/init'
import { getActiveCompanyId } from '@/lib/company/context'
import { getIntent } from '@/lib/agent/intents/registry'
import { checkAgentRateLimit, agentRateLimitResponseBody } from '@/lib/rate-limits/agent'
import { runChatTurn, friendlyModelError } from '@/lib/agent/chat/run-turn'
// Make sure extensions are loaded — the chat loop dispatches against the
// agent tool registry which is populated by the mcp-server extension at load.
ensureInitialized()
// Hard cap on the per-turn user input. Generous for a chat composer (about
// 5k words / 20 pages) but bounds Bedrock token cost if the rate limiter is
// ever fail-open and a client floods large payloads.
const MAX_USER_MESSAGE_LEN = 20_000
const BodySchema = z.object({
intent_id: z.string().min(1).max(200),
// Existing conversation to resume; if omitted, the route creates one. The
// chat sheet's React state holds the conversation id as `string | null`
// and serializes `null` on the first turn, so accept null alongside
// undefined and treat both as "no existing conversation".
conversation_id: z.string().uuid().nullable().optional(),
// Optional company override; defaults to active_company_id.
company_id: z.string().uuid().nullable().optional(),
// The user's message (or, on the first turn, this is empty and we send the
// intent's prompt template instead). Capped to bound LLM cost.
user_message: z.string().max(MAX_USER_MESSAGE_LEN).nullable().optional(),
// Intent-specific capture args (e.g. { transaction_id: '...' } for
// transaction.categorization). Used only on the first turn to build the
// prompt template. Each value is bounded so capture inputs can't be a
// megabyte each; the dispatcher rejects oversize values upfront.
intent_args: z
.record(z.string().max(120), z.unknown())
.nullable()
.optional()
.refine(
(v) => {
if (!v) return true
try {
return JSON.stringify(v).length <= MAX_USER_MESSAGE_LEN
} catch {
return false
}
},
{ message: 'intent_args too large' },
),
// Optional context_ref for the conversation row, e.g. 'transaction:<id>'.
context_ref: z.string().max(200).nullable().optional(),
// When true (and user_message is provided), persist the turn but flag it
// hidden so it doesn't render as a user bubble on resume. Used by the chat's
// rejection-correction flow (ApprovalCard → AgentChat) to feed the agent a
// synthetic correction without showing it as something the user typed.
user_message_hidden: z.boolean().nullable().optional(),
})
// POST /api/agent/invoke
//
// Streams NDJSON events from the chat loop. Each line is a JSON object whose
// `kind` identifies the event type — see lib/agent/chat/run-turn.ts StreamEvent.
//
// Auth: the user must be a member of the resolved company.
//
// Plan ref: dev_docs/specialized-agent-plan.md §9 (chat loop).
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
// Generous per-user rate limit — bounds runaway Bedrock spend (loop-firing
// sessions). Fails open on infra error.
const rate = await checkAgentRateLimit(supabase, user.id)
if (!rate.ok) {
return NextResponse.json(agentRateLimitResponseBody(rate), {
status: 429,
headers: rate.retryAfterSec ? { 'Retry-After': String(rate.retryAfterSec) } : undefined,
})
}
let body: z.infer<typeof BodySchema>
try {
body = BodySchema.parse(await request.json())
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Invalid body' },
{ status: 400 },
)
}
const intent = getIntent(body.intent_id)
if (!intent) {
return NextResponse.json({ error: `Unknown intent: ${body.intent_id}` }, { status: 400 })
}
const companyId = body.company_id ?? (await getActiveCompanyId(supabase, user.id))
if (!companyId) return NextResponse.json({ error: 'No active company' }, { status: 400 })
const { data: membership } = await supabase
.from('company_members')
.select('role')
.eq('company_id', companyId)
.eq('user_id', user.id)
.maybeSingle()
if (!membership) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
// onboarding.intake completion signal — once the user has actually
// engaged (typed a real reply, not the auto-fired greeting prompt that
// mounts the chat), stamp intake_completed_at on the profile so re-entry
// logic and opportunistic follow-up logic in other intents can tell the
// intake happened. Idempotent: the IS NULL guard ensures we never
// overwrite the first engagement timestamp. Best-effort — failure here
// doesn't break the chat; the next user turn retries.
if (
body.intent_id === 'onboarding.intake' &&
typeof body.user_message === 'string' &&
body.user_message.trim().length > 0 &&
body.user_message_hidden !== true
) {
try {
await supabase
.from('agent_profiles')
.update({ intake_completed_at: new Date().toISOString() })
.eq('company_id', companyId)
.is('intake_completed_at', null)
} catch {
// ignored — see comment above
}
}
// Load lightweight company + user signals for the system prompt.
const [{ data: company }, { data: profile }] = await Promise.all([
supabase.from('companies').select('name').eq('id', companyId).single(),
supabase.from('profiles').select('full_name').eq('id', user.id).single(),
])
const companyName = company?.name ?? ''
const firstName = profile?.full_name?.split(' ')[0] ?? null
// Resolve / create the conversation row.
let conversationId = body.conversation_id ?? null
if (!conversationId) {
const { data: newConv, error: convErr } = await supabase
.from('agent_conversations')
.insert({
company_id: companyId,
user_id: user.id,
intent_id: body.intent_id,
context_ref: body.context_ref ?? null,
title: intent.sheetTitle,
})
.select('id')
.single()
if (convErr || !newConv) {
return NextResponse.json(
{ error: convErr?.message ?? 'Failed to create conversation' },
{ status: 500 },
)
}
conversationId = newConv.id as string
}
// Compute the user message to send to Anthropic. On the first turn (no
// user_message provided), we run the intent's capture + promptTemplate
// pipeline so the prompt is anchored on the page context the user
// clicked from.
let effectiveUserMessage = body.user_message ?? ''
// When the caller didn't supply a user_message, we synthesize one from the
// intent's promptTemplate. Mark that synthetic turn hidden so the UI
// doesn't render the template scaffolding as a user bubble on resume. The
// client can also explicitly request a hidden turn (rejection correction)
// even when it DID supply a user_message.
let userMessageHidden = body.user_message_hidden === true
if (!effectiveUserMessage) {
try {
const captured = await intent.capture(body.intent_args ?? {}, {
supabase,
userId: user.id,
companyId,
})
const profileSummary = await loadProfileSummary(supabase, companyId)
const memory = await loadRankedMemory(supabase, companyId, 30)
effectiveUserMessage = intent.promptTemplate({
captured,
profileSummary,
activeMemory: memory,
})
userMessageHidden = true
} catch (err) {
return NextResponse.json(
{
error:
err instanceof Error
? `Capture failed: ${err.message}`
: 'Capture failed',
},
{ status: 500 },
)
}
}
// Stream — NDJSON events from the chat loop.
const encoder = new TextEncoder()
// Conversation id is set above; capture into a non-null local for the
// streaming closure's first emission.
const convId: string = conversationId
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
const emit = (event: unknown): boolean => {
try {
controller.enqueue(encoder.encode(JSON.stringify(event) + '\n'))
return true
} catch {
return false
}
}
// Surface the conversation id so the client can resume with it.
emit({ kind: 'conversation', conversation_id: convId })
try {
await runChatTurn({
supabase,
userId: user.id,
companyId,
companyName,
firstName,
intent,
conversationId: convId,
userMessage: effectiveUserMessage,
userMessageHidden,
persist: true,
emit: (event) => emit(event),
})
} catch (err) {
// run-turn already emitted a friendly error before re-throwing; emit a
// normalized one here too so this outer catch never overwrites it with a
// raw AWS SDK string.
emit({
kind: 'error',
message: friendlyModelError(err),
})
} finally {
try {
controller.close()
} catch {
// Already closed
}
}
},
})
return new Response(stream, {
headers: {
'Content-Type': 'application/x-ndjson; charset=utf-8',
'Cache-Control': 'no-store',
'X-Accel-Buffering': 'no',
},
})
}
async function loadProfileSummary(
supabase: Awaited<ReturnType<typeof createClient>>,
companyId: string,
): Promise<string | null> {
const { data } = await supabase
.from('agent_profiles')
.select('profile_summary')
.eq('company_id', companyId)
.maybeSingle()
return (data?.profile_summary as string | null) ?? null
}
async function loadRankedMemory(
supabase: Awaited<ReturnType<typeof createClient>>,
companyId: string,
cap: number,
): Promise<{ content: string; kind: string }[]> {
const { data } = await supabase
.from('agent_memory')
.select('content, kind, relevance_score, last_accessed_at')
.eq('company_id', companyId)
.eq('is_active', true)
.order('relevance_score', { ascending: false })
.order('last_accessed_at', { ascending: false, nullsFirst: false })
.limit(cap)
return (data ?? []).map((r: { content: string; kind: string }) => ({
content: r.content,
kind: r.kind,
}))
}
+90
View File
@@ -0,0 +1,90 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { requireWritePermission } from '@/lib/auth/require-write'
// PATCH /api/agent/memory/[id]
//
// Mutate a single memory entry. Powers the list/edit/pin/dismiss affordances
// on /settings/agent-memory (plan §11).
//
// content — edit the durable text (kind never changes; that would
// muddle the audit lineage). Append-only is preserved by
// superseded_by chains when an upstream caller wants it; the
// transparency UI is allowed to overwrite in place because
// the row's `updated_at` already documents the edit.
// is_pinned — boost into the top-N prompt block regardless of score.
// is_active — false = dismiss (removes from ranking pool); true = restore.
//
// RLS scopes to user_company_ids(); we ALSO re-verify membership for the
// row's company_id as defense in depth before mutating.
const PatchSchema = z
.object({
content: z.string().min(2).max(2000).optional(),
is_pinned: z.boolean().optional(),
is_active: z.boolean().optional(),
})
.refine(
(v) => v.content !== undefined || v.is_pinned !== undefined || v.is_active !== undefined,
{ message: 'Nothing to update' },
)
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const { id } = await params
let body: z.infer<typeof PatchSchema>
try {
body = PatchSchema.parse(await request.json())
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Invalid body' },
{ status: 400 },
)
}
const update: Record<string, unknown> = {}
if (body.content !== undefined) update.content = body.content
if (body.is_pinned !== undefined) update.is_pinned = body.is_pinned
if (body.is_active !== undefined) update.is_active = body.is_active
// Look up the row's company_id and re-check membership before mutating.
const { data: existing } = await supabase
.from('agent_memory')
.select('company_id')
.eq('id', id)
.maybeSingle()
if (!existing) return NextResponse.json({ error: 'Memory not found' }, { status: 404 })
const { data: membership } = await supabase
.from('company_members')
.select('role')
.eq('company_id', existing.company_id)
.eq('user_id', user.id)
.maybeSingle()
if (!membership) return NextResponse.json({ error: 'Memory not found' }, { status: 404 })
const { data, error } = await supabase
.from('agent_memory')
.update(update)
.eq('id', id)
.eq('company_id', existing.company_id)
.select(
'id, kind, content, source, source_ref, relevance_score, is_pinned, is_active, last_accessed_at, created_at, updated_at',
)
.maybeSingle()
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
if (!data) return NextResponse.json({ error: 'Memory not found' }, { status: 404 })
return NextResponse.json({ data })
}
@@ -0,0 +1,295 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createMockRequest,
createMockRouteParams,
parseJsonResponse,
createQueuedMockSupabase,
} from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
const getActiveCompanyIdMock = vi.fn()
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: (...args: unknown[]) => getActiveCompanyIdMock(...args),
}))
const requireWritePermissionMock = vi.fn()
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: (...args: unknown[]) => requireWritePermissionMock(...args),
}))
import { GET, POST } from '../route'
import { PATCH } from '../[id]/route'
const mockUser = { id: 'user-1', email: 'test@test.se' }
beforeEach(() => {
vi.clearAllMocks()
reset()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
getActiveCompanyIdMock.mockResolvedValue('company-1')
requireWritePermissionMock.mockResolvedValue({ ok: true })
})
describe('GET /api/agent/memory', () => {
it('returns 401 when not authenticated', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
const response = await GET(createMockRequest('/api/agent/memory'))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(401)
expect(body.error).toBe('Unauthorized')
})
it('returns 400 when no active company', async () => {
getActiveCompanyIdMock.mockResolvedValue(null)
const response = await GET(createMockRequest('/api/agent/memory'))
const { status } = await parseJsonResponse(response)
expect(status).toBe(400)
})
it('returns rows for the active company', async () => {
const rows = [
{
id: 'mem-1',
kind: 'fact',
content: 'Räkenskapsår jandec',
source: 'composer',
source_ref: null,
relevance_score: 0.5,
is_pinned: false,
is_active: true,
last_accessed_at: null,
created_at: '2026-05-10T09:00:00Z',
updated_at: '2026-05-10T09:00:00Z',
},
]
enqueue({ data: rows })
const response = await GET(createMockRequest('/api/agent/memory'))
const { status, body } = await parseJsonResponse<{ data: typeof rows }>(response)
expect(status).toBe(200)
expect(body.data).toHaveLength(1)
expect(body.data[0].id).toBe('mem-1')
})
it('does not require write permission for read', async () => {
enqueue({ data: [] })
await GET(createMockRequest('/api/agent/memory'))
expect(requireWritePermissionMock).not.toHaveBeenCalled()
})
})
describe('POST /api/agent/memory', () => {
it('returns 401 when not authenticated', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
const response = await POST(
createMockRequest('/api/agent/memory', {
method: 'POST',
body: { content: 'hello world' },
}),
)
expect(response.status).toBe(401)
})
it('blocks viewers via requireWritePermission', async () => {
const { NextResponse } = await import('next/server')
requireWritePermissionMock.mockResolvedValue({
ok: false,
response: NextResponse.json({ error: 'forbidden' }, { status: 403 }),
})
const response = await POST(
createMockRequest('/api/agent/memory', {
method: 'POST',
body: { content: 'hello world' },
}),
)
expect(response.status).toBe(403)
})
it('rejects too-short content', async () => {
const response = await POST(
createMockRequest('/api/agent/memory', {
method: 'POST',
body: { content: 'x' },
}),
)
expect(response.status).toBe(400)
})
it('inserts and returns the row on happy path', async () => {
const inserted = {
id: 'mem-2',
kind: 'fact',
content: 'En sak att komma ihåg',
source: 'user_taught',
source_ref: null,
relevance_score: 1,
is_pinned: false,
is_active: true,
last_accessed_at: null,
created_at: '2026-05-18T10:00:00Z',
updated_at: '2026-05-18T10:00:00Z',
}
// Defense-in-depth: body company_id membership re-check happens after
// requireWritePermission. POST flow now is: (1) company_members lookup,
// (2) insert.
enqueue({ data: { role: 'member' } })
enqueue({ data: inserted })
const response = await POST(
createMockRequest('/api/agent/memory', {
method: 'POST',
body: { content: 'En sak att komma ihåg' },
}),
)
const { status, body } = await parseJsonResponse<{ data: typeof inserted }>(response)
expect(status).toBe(200)
expect(body.data.id).toBe('mem-2')
})
it('rejects when user is a viewer in the target company', async () => {
enqueue({ data: { role: 'viewer' } })
const response = await POST(
createMockRequest('/api/agent/memory', {
method: 'POST',
body: { content: 'En sak att komma ihåg' },
}),
)
expect(response.status).toBe(403)
})
})
describe('PATCH /api/agent/memory/[id]', () => {
it('returns 401 when not authenticated', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
const response = await PATCH(
createMockRequest('/api/agent/memory/mem-1', {
method: 'PATCH',
body: { is_pinned: true },
}),
createMockRouteParams({ id: 'mem-1' }),
)
expect(response.status).toBe(401)
})
it('blocks viewers via requireWritePermission', async () => {
const { NextResponse } = await import('next/server')
requireWritePermissionMock.mockResolvedValue({
ok: false,
response: NextResponse.json({ error: 'forbidden' }, { status: 403 }),
})
const response = await PATCH(
createMockRequest('/api/agent/memory/mem-1', {
method: 'PATCH',
body: { is_pinned: true },
}),
createMockRouteParams({ id: 'mem-1' }),
)
expect(response.status).toBe(403)
})
it('rejects empty patch body', async () => {
const response = await PATCH(
createMockRequest('/api/agent/memory/mem-1', { method: 'PATCH', body: {} }),
createMockRouteParams({ id: 'mem-1' }),
)
expect(response.status).toBe(400)
})
it('returns 404 when row not found / not visible via RLS', async () => {
// Row lookup returns null → defense-in-depth 404 before update.
enqueue({ data: null })
const response = await PATCH(
createMockRequest('/api/agent/memory/mem-x', {
method: 'PATCH',
body: { is_pinned: true },
}),
createMockRouteParams({ id: 'mem-x' }),
)
expect(response.status).toBe(404)
})
it('returns 404 when user has no membership in row company', async () => {
enqueue({ data: { company_id: 'company-2' } })
enqueue({ data: null }) // company_members lookup
const response = await PATCH(
createMockRequest('/api/agent/memory/mem-1', {
method: 'PATCH',
body: { is_pinned: true },
}),
createMockRouteParams({ id: 'mem-1' }),
)
expect(response.status).toBe(404)
})
it('pins a row', async () => {
const updated = {
id: 'mem-1',
kind: 'fact',
content: 'X',
source: 'composer',
source_ref: null,
relevance_score: 0.5,
is_pinned: true,
is_active: true,
last_accessed_at: null,
created_at: '2026-05-10T09:00:00Z',
updated_at: '2026-05-18T10:00:00Z',
}
// PATCH flow: (1) row lookup, (2) membership lookup, (3) update.
enqueue({ data: { company_id: 'company-1' } })
enqueue({ data: { role: 'member' } })
enqueue({ data: updated })
const response = await PATCH(
createMockRequest('/api/agent/memory/mem-1', {
method: 'PATCH',
body: { is_pinned: true },
}),
createMockRouteParams({ id: 'mem-1' }),
)
const { status, body } = await parseJsonResponse<{ data: typeof updated }>(response)
expect(status).toBe(200)
expect(body.data.is_pinned).toBe(true)
})
it('dismisses a row by setting is_active=false', async () => {
const updated = {
id: 'mem-1',
kind: 'fact',
content: 'X',
source: 'composer',
source_ref: null,
relevance_score: 0.5,
is_pinned: false,
is_active: false,
last_accessed_at: null,
created_at: '2026-05-10T09:00:00Z',
updated_at: '2026-05-18T10:00:00Z',
}
enqueue({ data: { company_id: 'company-1' } })
enqueue({ data: { role: 'member' } })
enqueue({ data: updated })
const response = await PATCH(
createMockRequest('/api/agent/memory/mem-1', {
method: 'PATCH',
body: { is_active: false },
}),
createMockRouteParams({ id: 'mem-1' }),
)
const { status, body } = await parseJsonResponse<{ data: typeof updated }>(response)
expect(status).toBe(200)
expect(body.data.is_active).toBe(false)
})
it('rejects content shorter than 2 chars', async () => {
const response = await PATCH(
createMockRequest('/api/agent/memory/mem-1', {
method: 'PATCH',
body: { content: 'x' },
}),
createMockRouteParams({ id: 'mem-1' }),
)
expect(response.status).toBe(400)
})
})
+136
View File
@@ -0,0 +1,136 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { getActiveCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
// GET /api/agent/memory
//
// Lists memory entries for the active company, ordered by pin > relevance >
// recency. Powers /settings/agent-memory (transparency UI per plan §11).
//
// Query params:
// include_dismissed: 'true' includes is_active=false rows (audit view).
// kind: filter by kind.
// limit: 1..200, default 200 (matches the storage cap).
//
// POST /api/agent/memory
//
// Inserts a memory entry for the company. Used by Phase B's free-text seed
// memory field ("Lägg till om ditt företag (valfritt)") to capture
// foundational facts as `kind=fact`, `source=user_taught` with an elevated
// relevance score so they land in the top-30 prompt block.
//
// Also usable from /settings/agent-memory ("Lägg till minne" affordance) and
// post-Phase 4 explicit "Kom ihåg det här" surfaces.
const KIND = ['fact', 'preference', 'pattern', 'correction'] as const
const SOURCE = ['composer', 'user_taught', 'agent_learned', 'derived'] as const
const BodySchema = z.object({
company_id: z.string().uuid().optional(),
content: z.string().min(2).max(2000),
kind: z.enum(KIND).default('fact'),
source: z.enum(SOURCE).default('user_taught'),
source_ref: z.string().max(200).optional(),
// Default 1.0 keeps user-taught entries above composer-derived (0.5) by
// default; conversation-time captures can land anywhere in [0, 1].
relevance_score: z.number().min(0).max(1).default(1.0),
})
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const companyId = await getActiveCompanyId(supabase, user.id)
if (!companyId) return NextResponse.json({ error: 'No active company' }, { status: 400 })
const url = new URL(request.url)
const includeDismissed = url.searchParams.get('include_dismissed') === 'true'
const kindParam = url.searchParams.get('kind')
const kind = KIND.includes(kindParam as (typeof KIND)[number])
? (kindParam as (typeof KIND)[number])
: null
const limit = Math.min(Math.max(Number(url.searchParams.get('limit')) || 200, 1), 200)
let query = supabase
.from('agent_memory')
.select(
'id, kind, content, source, source_ref, relevance_score, is_pinned, is_active, last_accessed_at, created_at, updated_at',
)
.eq('company_id', companyId)
if (!includeDismissed) query = query.eq('is_active', true)
if (kind) query = query.eq('kind', kind)
query = query
.order('is_active', { ascending: false })
.order('is_pinned', { ascending: false })
.order('relevance_score', { ascending: false })
.order('last_accessed_at', { ascending: false, nullsFirst: false })
.order('created_at', { ascending: false })
.limit(limit)
const { data, error } = await query
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ data: data ?? [] })
}
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
let body: z.infer<typeof BodySchema>
try {
body = BodySchema.parse(await request.json())
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Invalid body' },
{ status: 400 },
)
}
const companyId = body.company_id ?? (await getActiveCompanyId(supabase, user.id))
if (!companyId) return NextResponse.json({ error: 'No active company' }, { status: 400 })
// requireWritePermission above checks the *active* company's role; if the
// caller passes a different company_id in the body, re-check membership +
// non-viewer role for THAT company specifically.
const { data: bodyMembership } = await supabase
.from('company_members')
.select('role')
.eq('company_id', companyId)
.eq('user_id', user.id)
.maybeSingle()
if (!bodyMembership || bodyMembership.role === 'viewer') {
return NextResponse.json(
{ error: 'Du har endast läsbehörighet i detta företag.' },
{ status: 403 },
)
}
const { data, error } = await supabase
.from('agent_memory')
.insert({
company_id: companyId,
kind: body.kind,
content: body.content,
source: body.source,
source_ref: body.source_ref ?? null,
relevance_score: body.relevance_score,
is_active: true,
created_by_user_id: user.id,
})
.select(
'id, kind, content, source, source_ref, relevance_score, is_pinned, is_active, last_accessed_at, created_at, updated_at',
)
.single()
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ data })
}
+298
View File
@@ -0,0 +1,298 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { getActiveCompanyId } from '@/lib/company/context'
import { checkAgentRateLimit, agentRateLimitResponseBody } from '@/lib/rate-limits/agent'
import { gatherComposerInputs, inputsToSourceSignals } from '@/lib/agent/composer/inputs'
import { selectAtoms } from '@/lib/agent/composer/atom-selection'
import { writeNarrative } from '@/lib/agent/composer/narrative'
import { fallbackAtomSelection, fallbackNarrative } from '@/lib/agent/composer/fallback'
import { filterRedundantQuestions } from '@/lib/agent/composer/atom-selection'
import { preWarmAtomCache } from '@/lib/agent/composer/prewarm'
import { OPUS_MODEL } from '@/lib/agent/composer/client'
import { ensureTicSnapshot } from '@/lib/agent/composer/tic-fetch'
import type { AtomSelection } from '@/lib/agent/composer/schemas'
const BodySchema = z.object({
company_id: z.string().uuid().optional(),
})
// 10s — the user is on a wait-screen with visible progress, so we can afford
// the longer budget. The prior 5s clipped legitimate fetches (TIC fans out to
// ~13 Lens calls upstream) into the fallback bucket while still burning the
// in-flight upstream calls against quota. See actions.ts:182-189 for the
// May 2026 incident context.
const TIC_BUDGET_MS = 10_000
// Bedrock cold-starts can take 2-3s before the first token, plus the actual
// Opus selection call typically lands at 10-14s. 15s was too tight and put
// real Opus calls into the fallback bucket on the first turn of the day.
const SELECT_BUDGET_MS = 25_000
const NARRATIVE_BUDGET_MS = 8_000
// Per-step event shape streamed as NDJSON. Each line is one JSON object.
type Step = 'tic' | 'select' | 'narrative' | 'finalize' | 'prewarm'
type Status = 'in_progress' | 'success' | 'fallback' | 'skipped' | 'error'
type StreamEvent =
| { step: Step; status: Status }
| { step: 'select'; status: 'success' | 'fallback'; selection: AtomSelection }
| { step: 'narrative'; status: 'success' | 'fallback'; narrative: string }
| { step: 'finalize'; status: 'success'; profile: ProfilePayload }
| { step: 'error'; status: 'error'; message: string }
interface ProfilePayload {
company_id: string
horizontal_atoms: string[]
vertical_atoms: string[]
modifier_atoms: string[]
is_multi_vertical: boolean
profile_summary: string
verification_questions: string[]
uncertainty_notes: string[]
composer_model: string
composed_at: string
}
// POST /api/agent/onboarding/stream
//
// Streams real-timed progress for the agent build sequence (plan §7 Phase A).
// Each step runs on its actual latency — no artificial delays. On timeout or
// failure, the step emits `fallback` and the pipeline continues with a
// deterministic default so the user always reaches Phase B.
//
// Response: application/x-ndjson — one JSON event per line.
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
// Generous per-user rate limit — bounds reload-spam of the onboarding build
// (each run fires 2 LLM calls). Fails open on infra error.
const rate = await checkAgentRateLimit(supabase, user.id)
if (!rate.ok) {
return NextResponse.json(agentRateLimitResponseBody(rate), {
status: 429,
headers: rate.retryAfterSec ? { 'Retry-After': String(rate.retryAfterSec) } : undefined,
})
}
let body: z.infer<typeof BodySchema>
try {
body = BodySchema.parse(await request.json().catch(() => ({})))
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Invalid body' },
{ status: 400 },
)
}
const companyId = body.company_id ?? (await getActiveCompanyId(supabase, user.id))
if (!companyId) {
return NextResponse.json({ error: 'No active company' }, { status: 400 })
}
const { data: membership } = await supabase
.from('company_members')
.select('role')
.eq('company_id', companyId)
.eq('user_id', user.id)
.maybeSingle()
if (!membership) {
return NextResponse.json({ error: 'Not a member of this company' }, { status: 403 })
}
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
const encoder = new TextEncoder()
const send = (event: StreamEvent) => {
try {
controller.enqueue(encoder.encode(JSON.stringify(event) + '\n'))
} catch {
// Stream was cancelled (user navigated away). Subsequent enqueues
// would throw — we just stop emitting.
}
}
try {
// Step 1 — TIC: read companies.tic_snapshot; if missing or stale,
// live-fetch from the TIC extension (cookies forwarded from the
// incoming request) and persist. Falls through gracefully when TIC is
// disabled, the company has no org_number, or the request times out.
send({ step: 'tic', status: 'in_progress' })
const cookieHeader = request.headers.get('cookie') ?? ''
// SSRF guard: derive origin from NEXT_PUBLIC_APP_URL (a required env
// var per CLAUDE.md) instead of request.headers.host. On self-hosted
// Docker the Host header can be attacker-controlled and would
// otherwise let an attacker redirect the cookie-bearing TIC fetch to
// a host they control.
const appUrl = process.env.NEXT_PUBLIC_APP_URL?.replace(/\/+$/, '') ?? ''
const fallbackHost = request.headers.get('host') ?? 'localhost:3000'
const fallbackProto =
request.headers.get('x-forwarded-proto') ??
(fallbackHost.startsWith('localhost') ? 'http' : 'https')
const origin = appUrl || `${fallbackProto}://${fallbackHost}`
const ticResult = await withTimeout(
// upgradeV1: agent build is the consumer of the v2-only sections;
// bounded to companies actively creating an agent, so the TIC
// budget stays safe even when upgrading pre-v2 snapshots.
// timeoutMs: lift the internal fetch signal to match the outer
// budget — otherwise the 5s default fires first and we get the
// pre-fix behavior even with the longer outer budget.
ensureTicSnapshot({
supabase,
companyId,
cookieHeader,
origin,
upgradeV1: true,
timeoutMs: TIC_BUDGET_MS,
}),
TIC_BUDGET_MS,
).catch(() => ({ snapshot: null, source: 'fallback' as const }))
send({
step: 'tic',
status: ticResult.snapshot ? 'success' : 'fallback',
})
// Gather inputs once — used by select + narrative + persistence.
const inputs = await gatherComposerInputs(supabase, companyId)
// Step 2 — Opus atom selection with timeout + deterministic fallback.
send({ step: 'select', status: 'in_progress' })
let selection: AtomSelection
try {
selection = await withTimeout(selectAtoms(inputs), SELECT_BUDGET_MS)
send({ step: 'select', status: 'success', selection })
} catch {
selection = fallbackAtomSelection(inputs)
send({ step: 'select', status: 'fallback', selection })
}
// Filter verification questions deterministically regardless of which
// path produced the selection. fallbackAtomSelection generates a
// generic template that doesn't know about KÄNDA FAKTA; the Opus
// path is also re-filtered in case the model strayed. Cheap belt-
// and-braces — same function used for both.
selection.verification_questions = filterRedundantQuestions(
selection.verification_questions,
inputs,
selection.modifier_atoms,
)
// Step 3 — Sonnet narrative with timeout + plain fallback.
send({ step: 'narrative', status: 'in_progress' })
let narrative: string
try {
narrative = await withTimeout(writeNarrative(inputs, selection), NARRATIVE_BUDGET_MS)
send({ step: 'narrative', status: 'success', narrative })
} catch {
narrative = fallbackNarrative(inputs)
send({ step: 'narrative', status: 'fallback', narrative })
}
// Step 4 — Persist the profile so Phase B has a row to edit.
const composedAt = new Date().toISOString()
const sourceSignals = inputsToSourceSignals(inputs)
const { error: upsertErr } = await supabase
.from('agent_profiles')
.upsert(
{
company_id: companyId,
horizontal_atoms: selection.horizontal_atoms,
vertical_atoms: selection.vertical_atoms,
modifier_atoms: selection.modifier_atoms,
profile_summary: narrative,
source_signals: sourceSignals,
// Persist so the Phase C intake agent can read them server-
// side when the chat opens. Plan §7 Phase C.
verification_questions: selection.verification_questions,
composed_at: composedAt,
composer_model: OPUS_MODEL,
composer_version: 1,
},
{ onConflict: 'company_id' },
)
if (upsertErr) {
send({ step: 'error', status: 'error', message: upsertErr.message })
return
}
send({
step: 'finalize',
status: 'success',
profile: {
company_id: companyId,
horizontal_atoms: selection.horizontal_atoms,
vertical_atoms: selection.vertical_atoms,
modifier_atoms: selection.modifier_atoms,
is_multi_vertical: selection.is_multi_vertical,
profile_summary: narrative,
verification_questions: selection.verification_questions,
uncertainty_notes: selection.uncertainty_notes,
composer_model: OPUS_MODEL,
composed_at: composedAt,
},
})
// Step 5 — fire-and-forget cache pre-warm. The client renders the
// review card already; pre-warm just buys a faster first chat turn.
send({ step: 'prewarm', status: 'in_progress' })
const allIds = [
...selection.horizontal_atoms,
...selection.vertical_atoms,
...selection.modifier_atoms,
]
if (allIds.length > 0) {
const { data: rows } = await supabase
.from('agent_atom_registry')
.select('id, body')
.in('id', allIds)
const bodies = (rows ?? [])
.map((r: { body: string | null }) => r.body ?? '')
.filter((b: string) => b.length > 0)
void preWarmAtomCache({ atomBodies: bodies })
}
send({ step: 'prewarm', status: 'success' })
} catch (err) {
send({
step: 'error',
status: 'error',
message: err instanceof Error ? err.message : 'Composer pipeline failed',
})
} finally {
try {
controller.close()
} catch {
// Already closed.
}
}
},
})
return new Response(stream, {
headers: {
'Content-Type': 'application/x-ndjson; charset=utf-8',
'Cache-Control': 'no-store',
'X-Accel-Buffering': 'no',
},
})
}
// Run a promise against a wall-clock budget. The underlying work continues to
// completion on the server when the budget elapses — we just stop waiting for
// it. For Anthropic calls that's fine: a slow Opus turn finishing later still
// warms its own cache.
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`Timeout after ${ms}ms`)), ms)
promise.then(
(v) => {
clearTimeout(timer)
resolve(v)
},
(e) => {
clearTimeout(timer)
reject(e)
},
)
})
}
+137
View File
@@ -0,0 +1,137 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { getActiveCompanyId } from '@/lib/company/context'
// GET /api/agent/profile?company_id=...
// PATCH same path
//
// GET returns the composed profile + the source verification_questions stored
// alongside it (read from agent_profiles row).
//
// PATCH updates field_overrides (timestamped, merged with existing) and
// optionally rewrites the atom arrays from the review UI. Does not touch
// verified_at — that flows through /verify.
const AtomArrays = z.object({
horizontal_atoms: z.array(z.string()).optional(),
vertical_atoms: z.array(z.string()).optional(),
modifier_atoms: z.array(z.string()).optional(),
})
const PatchBody = z.object({
company_id: z.string().uuid().optional(),
field_overrides: z.record(z.string(), z.unknown()).optional(),
atoms: AtomArrays.optional(),
profile_summary: z.string().min(1).max(2000).optional(),
// Agent personalization — name shown on the FAB and chat headers, and
// avatar key into the static AVATAR_OPTIONS registry. Both nullable so
// the user can clear them back to defaults.
display_name: z.string().min(1).max(60).nullable().optional(),
avatar_id: z.string().max(60).nullable().optional(),
})
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const url = new URL(request.url)
const companyId =
url.searchParams.get('company_id') ?? (await getActiveCompanyId(supabase, user.id))
if (!companyId) return NextResponse.json({ error: 'No active company' }, { status: 400 })
// Defense in depth alongside RLS — confirm membership before reading.
const { data: membership } = await supabase
.from('company_members')
.select('role')
.eq('company_id', companyId)
.eq('user_id', user.id)
.maybeSingle()
if (!membership) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const { data, error } = await supabase
.from('agent_profiles')
.select(
'company_id, horizontal_atoms, vertical_atoms, modifier_atoms, profile_summary, source_signals, field_overrides, composed_at, composer_model, composer_version, verified_at, verified_by_user_id, display_name, avatar_id',
)
.eq('company_id', companyId)
.maybeSingle()
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
if (!data) return NextResponse.json({ data: null })
return NextResponse.json({ data })
}
export async function PATCH(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
let body: z.infer<typeof PatchBody>
try {
body = PatchBody.parse(await request.json())
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Invalid body' },
{ status: 400 },
)
}
const companyId = body.company_id ?? (await getActiveCompanyId(supabase, user.id))
if (!companyId) return NextResponse.json({ error: 'No active company' }, { status: 400 })
// RLS guards reads/updates by company_id; defense in depth — confirm membership.
const { data: membership } = await supabase
.from('company_members')
.select('role')
.eq('company_id', companyId)
.eq('user_id', user.id)
.maybeSingle()
if (!membership) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
// Load current overrides to merge timestamp-stamped entries. Avoids round-trip
// when caller sends only an atom-array change.
const { data: current } = await supabase
.from('agent_profiles')
.select('field_overrides')
.eq('company_id', companyId)
.single()
if (!current) {
return NextResponse.json({ error: 'agent_profile not found for this company' }, { status: 404 })
}
const update: Record<string, unknown> = {}
if (body.field_overrides && Object.keys(body.field_overrides).length > 0) {
const merged: Record<string, { value: unknown; overridden_at: string }> = {
...((current.field_overrides as Record<string, { value: unknown; overridden_at: string }>) ?? {}),
}
const now = new Date().toISOString()
for (const [k, v] of Object.entries(body.field_overrides)) {
merged[k] = { value: v, overridden_at: now }
}
update.field_overrides = merged
}
if (body.atoms?.horizontal_atoms) update.horizontal_atoms = body.atoms.horizontal_atoms
if (body.atoms?.vertical_atoms) update.vertical_atoms = body.atoms.vertical_atoms
if (body.atoms?.modifier_atoms) update.modifier_atoms = body.atoms.modifier_atoms
if (body.profile_summary) update.profile_summary = body.profile_summary
if (body.display_name !== undefined) update.display_name = body.display_name
if (body.avatar_id !== undefined) update.avatar_id = body.avatar_id
if (Object.keys(update).length === 0) {
return NextResponse.json({ error: 'Nothing to update' }, { status: 400 })
}
const { data, error } = await supabase
.from('agent_profiles')
.update(update)
.eq('company_id', companyId)
.select(
'company_id, horizontal_atoms, vertical_atoms, modifier_atoms, profile_summary, field_overrides',
)
.single()
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ data })
}
+61
View File
@@ -0,0 +1,61 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { getActiveCompanyId } from '@/lib/company/context'
// POST /api/agent/profile/verify
//
// Stamps verified_at + verified_by_user_id on agent_profiles when the user
// clicks "Det här ser rätt ut — kör" in Phase B. Idempotent: re-verifying
// updates the timestamp; this is desirable for "Bygg om" rebuild flows.
const BodySchema = z.object({
company_id: z.string().uuid().optional(),
})
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
let body: z.infer<typeof BodySchema>
try {
body = BodySchema.parse(await request.json().catch(() => ({})))
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Invalid body' },
{ status: 400 },
)
}
const companyId = body.company_id ?? (await getActiveCompanyId(supabase, user.id))
if (!companyId) return NextResponse.json({ error: 'No active company' }, { status: 400 })
// Defense in depth alongside RLS — confirm membership for the target
// company; a non-viewer role is required to stamp verified_at.
const { data: membership } = await supabase
.from('company_members')
.select('role')
.eq('company_id', companyId)
.eq('user_id', user.id)
.maybeSingle()
if (!membership || membership.role === 'viewer') {
return NextResponse.json(
{ error: 'Du har endast läsbehörighet i detta företag.' },
{ status: 403 },
)
}
const { data, error } = await supabase
.from('agent_profiles')
.update({
verified_at: new Date().toISOString(),
verified_by_user_id: user.id,
})
.eq('company_id', companyId)
.select('company_id, verified_at, verified_by_user_id')
.single()
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ data })
}
+92
View File
@@ -0,0 +1,92 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { getActiveCompanyId } from '@/lib/company/context'
// GET /api/agent/skills
//
// Read-only transparency surface for the in-app bookkeeping assistant's domain
// knowledge ("atoms"). Powers /settings/agent-skills — the companion to
// /settings/agent-memory. Memory is what the assistant *learned* about this
// company (user-editable); skills are the Swedish-accounting expertise it
// *ships* with — authored in .claude/skills/**/SKILL.md, seeded into
// agent_atom_registry, read-only for users, curated via mcp_exposed.
//
// Two shapes off one route:
// (no params) → metadata for every active + exposed atom, each flagged
// active-for-this-company. Bodies omitted so the list payload
// stays small.
// ?slug=<id> → the full SKILL.md body for one atom, loaded on expand.
//
// "Active for this company": horizontal atoms are regulatory and shared by
// every Swedish company, so always active. Vertical/modifier atoms are active
// only when the composer selected them into this company's agent_profile —
// others are shown dormant so the user sees both the full library and what's
// tuned for them. The profile arrays store full ids ("vertical/konsult-it"),
// matched directly against agent_atom_registry.id (see lib/agent/chat/system-prompt.ts).
interface AtomMeta {
id: string
tier: 'horizontal' | 'vertical' | 'modifier'
title: string
description: string
active: boolean
}
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const companyId = await getActiveCompanyId(supabase, user.id)
if (!companyId) return NextResponse.json({ error: 'No active company' }, { status: 400 })
const url = new URL(request.url)
const slug = url.searchParams.get('slug')
// Detail: one atom's body, fetched lazily when the user expands a card.
if (slug) {
const { data, error } = await supabase
.from('agent_atom_registry')
.select('id, title, body, is_active, mcp_exposed')
.eq('id', slug)
.maybeSingle()
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
if (!data || !data.is_active || !data.mcp_exposed) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
return NextResponse.json({ data: { id: data.id, title: data.title, body: data.body ?? '' } })
}
// List: metadata for every visible atom + which are active for this company.
const { data: atoms, error } = await supabase
.from('agent_atom_registry')
.select('id, tier, title, description')
.eq('is_active', true)
.eq('mcp_exposed', true)
.is('parent_atom_id', null) // show top-level skills only; reference children are internal
.order('tier', { ascending: true })
.order('title', { ascending: true })
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
const { data: profile } = await supabase
.from('agent_profiles')
.select('vertical_atoms, modifier_atoms')
.eq('company_id', companyId)
.maybeSingle()
const verticalActive = new Set((profile?.vertical_atoms as string[] | null) ?? [])
const modifierActive = new Set((profile?.modifier_atoms as string[] | null) ?? [])
const result: AtomMeta[] = (atoms ?? []).map((a) => {
const tier = a.tier as AtomMeta['tier']
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 }
})
return NextResponse.json({ data: result })
}
@@ -0,0 +1,65 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
// GET /api/documents/:id/extraction-status
//
// Light-weight polling endpoint for the AI document-extraction pipeline.
// Returns the minimal fields needed to drive an "extracting…" UI without
// touching storage (no signed URL creation per poll).
//
// Derived status:
// running — extracted_at IS NULL (pipeline hasn't stamped yet)
// succeeded — extracted_at IS NOT NULL AND extracted_data IS NOT NULL
// unsupported — extraction_model = 'skipped:*' (HEIC, ZIP, …)
// failed — extracted_at IS NOT NULL AND extracted_data IS NULL AND
// extraction_model = 'failed:*'
// disabled — the document-extraction extension isn't enabled (column
// stays untouched indefinitely). Client times out and shows
// a quiet fallback. We don't distinguish this from running
// server-side — the client decides based on elapsed time.
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const companyId = await requireCompanyId(supabase, user.id)
const { id } = await params
const { data, error } = await supabase
.from('document_attachments')
.select('id, extracted_at, extracted_data, extraction_model')
.eq('id', id)
.eq('company_id', companyId)
.maybeSingle()
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
if (!data) return NextResponse.json({ error: 'Not found' }, { status: 404 })
const extractedAt = data.extracted_at as string | null
const extractedData = data.extracted_data as Record<string, unknown> | null
const model = data.extraction_model as string | null
let status: 'running' | 'succeeded' | 'failed' | 'unsupported'
if (!extractedAt) {
status = 'running'
} else if (extractedData) {
status = 'succeeded'
} else if (model?.startsWith('skipped:')) {
status = 'unsupported'
} else {
status = 'failed'
}
return NextResponse.json({
data: {
id: data.id,
status,
extracted_at: extractedAt,
extraction_model: model,
},
})
}
@@ -0,0 +1,234 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createMockRequest,
createMockRouteParams,
parseJsonResponse,
createQueuedMockSupabase,
} from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
const requireCompanyIdMock = vi.fn()
vi.mock('@/lib/company/context', () => ({
requireCompanyId: (...args: unknown[]) => requireCompanyIdMock(...args),
}))
const requireWritePermissionMock = vi.fn()
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: (...args: unknown[]) => requireWritePermissionMock(...args),
}))
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
const mappingMock = vi.fn()
vi.mock('@/lib/bookkeeping/category-mapping', () => ({
buildMappingResultFromCategory: (...args: unknown[]) => mappingMock(...args),
}))
import { PATCH } from '../route'
const mockUser = { id: 'user-1' }
beforeEach(() => {
vi.clearAllMocks()
reset()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
requireWritePermissionMock.mockResolvedValue({ ok: true })
requireCompanyIdMock.mockResolvedValue('company-1')
mappingMock.mockReturnValue({
debit_account: '5410',
credit_account: '1930',
vat_lines: [],
})
})
describe('PATCH /api/pending-operations/[id]', () => {
it('returns 401 when not authenticated', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
const res = await PATCH(
createMockRequest('/api/pending-operations/op-1', {
method: 'PATCH',
body: { category: 'expense_software' },
}),
createMockRouteParams({ id: 'op-1' }),
)
expect(res.status).toBe(401)
})
it('blocks viewers', async () => {
const { NextResponse } = await import('next/server')
requireWritePermissionMock.mockResolvedValue({
ok: false,
response: NextResponse.json({ error: 'forbidden' }, { status: 403 }),
})
const res = await PATCH(
createMockRequest('/api/pending-operations/op-1', {
method: 'PATCH',
body: { category: 'expense_software' },
}),
createMockRouteParams({ id: 'op-1' }),
)
expect(res.status).toBe(403)
})
it('rejects empty body', async () => {
const res = await PATCH(
createMockRequest('/api/pending-operations/op-1', { method: 'PATCH', body: {} }),
createMockRouteParams({ id: 'op-1' }),
)
expect(res.status).toBe(400)
})
it('returns 404 when op not visible', async () => {
enqueue({ data: null })
const res = await PATCH(
createMockRequest('/api/pending-operations/op-x', {
method: 'PATCH',
body: { category: 'expense_software' },
}),
createMockRouteParams({ id: 'op-x' }),
)
expect(res.status).toBe(404)
})
it('returns 409 when op is no longer pending', async () => {
enqueue({
data: {
id: 'op-1',
company_id: 'company-1',
operation_type: 'categorize_transaction',
status: 'committed',
params: { transaction_id: 'tx-1', category: 'expense_other' },
preview_data: {},
title: '',
},
})
const res = await PATCH(
createMockRequest('/api/pending-operations/op-1', {
method: 'PATCH',
body: { category: 'expense_software' },
}),
createMockRouteParams({ id: 'op-1' }),
)
expect(res.status).toBe(409)
})
it('returns 400 when operation_type is not editable', async () => {
enqueue({
data: {
id: 'op-1',
company_id: 'company-1',
operation_type: 'create_invoice',
status: 'pending',
params: {},
preview_data: {},
title: '',
},
})
const res = await PATCH(
createMockRequest('/api/pending-operations/op-1', {
method: 'PATCH',
body: { category: 'expense_software' },
}),
createMockRouteParams({ id: 'op-1' }),
)
expect(res.status).toBe(400)
})
it('re-derives accounts and returns updated preview on category change', async () => {
// Sequence:
// 1. pending_operations lookup
// 2. transactions lookup
// 3. company_settings lookup
// 4. pending_operations update → returns updated row
enqueue({
data: {
id: 'op-1',
company_id: 'company-1',
operation_type: 'categorize_transaction',
status: 'pending',
params: {
transaction_id: 'tx-1',
category: 'expense_other',
vat_treatment: null,
},
preview_data: { debit_account: '6990', credit_account: '1930', amount: 500 },
title: 'Kategorisera: X',
},
})
enqueue({
data: {
id: 'tx-1',
company_id: 'company-1',
amount: -500,
currency: 'SEK',
date: '2026-05-10',
},
})
enqueue({ data: { entity_type: 'aktiebolag' } })
enqueue({
data: {
id: 'op-1',
params: { transaction_id: 'tx-1', category: 'expense_software', vat_treatment: null },
preview_data: {
debit_account: '5410',
credit_account: '1930',
amount: 500,
currency: 'SEK',
vat_lines: [],
category: 'expense_software',
},
title: 'Kategorisera: X',
status: 'pending',
},
})
const res = await PATCH(
createMockRequest('/api/pending-operations/op-1', {
method: 'PATCH',
body: { category: 'expense_software' },
}),
createMockRouteParams({ id: 'op-1' }),
)
const { status, body } = await parseJsonResponse<{
data: { preview_data: { category: string; debit_account: string } }
}>(res)
expect(status).toBe(200)
expect(body.data.preview_data.category).toBe('expense_software')
expect(body.data.preview_data.debit_account).toBe('5410')
expect(mappingMock).toHaveBeenCalledTimes(1)
})
it('returns 400 when mapping yields no accounts', async () => {
enqueue({
data: {
id: 'op-1',
company_id: 'company-1',
operation_type: 'categorize_transaction',
status: 'pending',
params: { transaction_id: 'tx-1', category: 'expense_other' },
preview_data: {},
title: '',
},
})
enqueue({ data: { id: 'tx-1', amount: -100, currency: 'SEK' } })
enqueue({ data: { entity_type: 'enskild_firma' } })
mappingMock.mockReturnValueOnce({
debit_account: null,
credit_account: null,
vat_lines: [],
})
const res = await PATCH(
createMockRequest('/api/pending-operations/op-1', {
method: 'PATCH',
body: { category: 'private' },
}),
createMockRouteParams({ id: 'op-1' }),
)
expect(res.status).toBe(400)
})
})
@@ -8,6 +8,7 @@ import {
makeCompanySettings,
} from '@/tests/helpers'
import { eventBus } from '@/lib/events/bus'
import { AccountsNotInChartError } from '@/lib/bookkeeping/errors'
const { supabase: mockSupabase, enqueue, enqueueMany, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
@@ -141,6 +142,38 @@ describe('POST /api/pending-operations/:id/commit', () => {
expect(mockCreateJournalEntry).toHaveBeenCalledTimes(1)
})
it('returns the structured ACCOUNTS_NOT_IN_CHART envelope when the chart lacks accounts', async () => {
// The booking is valid but posts to reverse-charge accounts (2614/2645)
// not active in the chart. The engine throws AccountsNotInChartError; the
// dispatcher must release the op back to 'pending' (retryable, see the
// 6th enqueued response) and the route must return the structured error
// with code + account_numbers so the chat can offer activation — NOT a
// raw error string.
const tx = makeTransaction({ id: 'tx-1', amount: -500, journal_entry_id: null })
const settings = makeCompanySettings()
mockCreateJournalEntry.mockRejectedValueOnce(new AccountsNotInChartError(['2645', '2614']))
enqueueMany([
{ data: pendingOp }, // route fetch op
{ data: { id: 'op-1' } }, // CAS claim (pending -> committing)
{ data: tx }, // fetch transaction
{ data: settings }, // fetch company settings
{ data: [{ id: 'fp-1' }] }, // fiscal period exists
{ data: null, error: null }, // dispatcher releases op back to 'pending'
])
const request = createMockRequest('/api/pending-operations/op-1/commit', { method: 'POST' })
const response = await POST(request, routeParams)
const { status, body } = await parseJsonResponse<{
error: { code: string; account_numbers: string[] }
}>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('ACCOUNTS_NOT_IN_CHART')
// Numeric sort puts 2614 before 2645 regardless of input order.
expect(body.error.account_numbers).toEqual(['2614', '2645'])
})
it('returns 409 when transaction already categorized', async () => {
const tx = makeTransaction({ id: 'tx-1', journal_entry_id: 'existing-je' })
@@ -4,7 +4,7 @@ import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { commitPendingOperation } from '@/lib/pending-operations/commit'
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
import { bookkeepingErrorResponse, AccountsNotInChartError, ACCOUNTS_NOT_IN_CHART } from '@/lib/bookkeeping/errors'
import type { PendingOperation } from '@/types'
ensureInitialized()
@@ -49,6 +49,15 @@ export async function POST(
if (result.status === 'committed') {
return NextResponse.json({ data: result.data })
}
// Recoverable accounts-not-in-chart: return the structured envelope (code +
// account_numbers) so the client can offer activation and retry the still-
// pending op, instead of leaking the raw error string into the chat.
if (result.code === ACCOUNTS_NOT_IN_CHART && result.account_numbers?.length) {
const structured = bookkeepingErrorResponse(
new AccountsNotInChartError(result.account_numbers)
)
if (structured) return structured
}
return NextResponse.json(
{ error: result.error },
{ status: result.http_status ?? 500 }
@@ -1,12 +1,24 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
const RejectBodySchema = z.object({
rejection_category: z
.enum(['wrong_category', 'wrong_amount', 'duplicate', 'wrong_period', 'other'])
.optional(),
rejection_reason: z.string().max(2000).optional(),
})
/**
* POST /api/pending-operations/:id/reject
*
* Reject a pending operation. Marks it as rejected without executing.
* Reject a pending operation. Optionally accepts a JSON body with
* `rejection_category` (fixed enum) and `rejection_reason` (free text). Both
* are stored on the row so agents can fetch them via gnubok_get_recent_rejections
* and learn from "no". The body is optional bodyless POSTs from older
* clients still mark the op rejected with NULL category/reason.
*/
export async function POST(
request: Request,
@@ -25,6 +37,30 @@ export async function POST(
const companyId = await requireCompanyId(supabase, user.id)
// Body is optional — accept empty/missing body without rejecting the request.
// Old clients posted no body; the UI dialog will now post a body, but we
// keep accepting both shapes to avoid coupling the API to the UI version.
let rejectionCategory: string | undefined
let rejectionReason: string | undefined
const contentLength = request.headers.get('content-length')
if (contentLength && contentLength !== '0') {
try {
const raw = await request.json()
const parsed = RejectBodySchema.safeParse(raw)
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues.map((i) => i.message).join('; ') },
{ status: 400 },
)
}
rejectionCategory = parsed.data.rejection_category
rejectionReason = parsed.data.rejection_reason?.trim() || undefined
} catch {
// Body present but unparseable — fail closed.
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}
}
const { data: op, error: fetchError } = await supabase
.from('pending_operations')
.select('id, status')
@@ -48,6 +84,8 @@ export async function POST(
.update({
status: 'rejected',
resolved_at: new Date().toISOString(),
...(rejectionCategory ? { rejection_category: rejectionCategory } : {}),
...(rejectionReason ? { rejection_reason: rejectionReason } : {}),
})
.eq('id', id)
+177
View File
@@ -0,0 +1,177 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping'
import type { EntityType, Transaction, TransactionCategory, VatTreatment } from '@/types'
// PATCH /api/pending-operations/[id]
//
// Edit-before-approve. Today only supports staged categorize_transaction
// operations — the user can pick a different category (and/or VAT treatment)
// before clicking Godkänn. We re-derive the booking via the same mapping
// engine the commit path uses so the preview the user approves equals the
// preview that gets posted.
//
// Other operation types return 400. As specialized editors land (e.g. edit
// invoice line items before send) they extend this dispatcher.
ensureInitialized()
const CATEGORIES = [
'income_services', 'income_products', 'income_other',
'expense_equipment', 'expense_software', 'expense_travel', 'expense_office',
'expense_marketing', 'expense_professional_services', 'expense_education',
'expense_representation', 'expense_consumables', 'expense_vehicle',
'expense_telecom', 'expense_bank_fees', 'expense_card_fees',
'expense_currency_exchange', 'expense_other', 'private', 'uncategorized',
] as const satisfies readonly TransactionCategory[]
const VAT_TREATMENTS = [
'standard_25', 'reduced_12', 'reduced_6',
'reverse_charge', 'export', 'exempt',
] as const satisfies readonly VatTreatment[]
const PatchSchema = z
.object({
category: z.enum(CATEGORIES).optional(),
vat_treatment: z.enum(VAT_TREATMENTS).nullable().optional(),
})
.refine(
(v) => v.category !== undefined || v.vat_treatment !== undefined,
{ message: 'Nothing to update' },
)
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const { id } = await params
let body: z.infer<typeof PatchSchema>
try {
body = PatchSchema.parse(await request.json())
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Invalid body' },
{ status: 400 },
)
}
const { data: op } = await supabase
.from('pending_operations')
.select('id, company_id, operation_type, status, params, preview_data, title')
.eq('id', id)
.eq('company_id', companyId)
.maybeSingle()
if (!op) return NextResponse.json({ error: 'Pending operation not found' }, { status: 404 })
if (op.status !== 'pending') {
return NextResponse.json(
{ error: `Operation already ${op.status} — cannot edit.` },
{ status: 409 },
)
}
if (op.operation_type !== 'categorize_transaction') {
return NextResponse.json(
{ error: `Editing ${op.operation_type} is not supported.` },
{ status: 400 },
)
}
const oldParams = (op.params as Record<string, unknown>) ?? {}
const newCategory =
body.category ?? (oldParams.category as TransactionCategory | undefined)
const newVatTreatment =
body.vat_treatment !== undefined
? (body.vat_treatment ?? undefined)
: (oldParams.vat_treatment as VatTreatment | undefined)
if (!newCategory) {
return NextResponse.json({ error: 'category is required' }, { status: 400 })
}
const txId = oldParams.transaction_id as string | undefined
if (!txId) {
return NextResponse.json(
{ error: 'Operation has no transaction_id; cannot re-derive.' },
{ status: 500 },
)
}
// Re-derive the preview using the same mapping engine the commit path uses.
const { data: tx } = await supabase
.from('transactions')
.select('*')
.eq('id', txId)
.eq('company_id', companyId)
.maybeSingle()
if (!tx) {
return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
}
const { data: settings } = await supabase
.from('company_settings')
.select('entity_type')
.eq('company_id', companyId)
.maybeSingle()
const entityType = ((settings?.entity_type as EntityType) || 'enskild_firma')
const isBusiness = newCategory !== 'private'
const mapping = buildMappingResultFromCategory(
newCategory,
tx as Transaction,
isBusiness,
entityType,
newVatTreatment,
)
if (!mapping.debit_account || !mapping.credit_account) {
return NextResponse.json(
{ error: `Inget kontomappning för kategorin "${newCategory}" (${entityType}).` },
{ status: 400 },
)
}
const oldPreview = (op.preview_data as Record<string, unknown>) ?? {}
const newPreview = {
...oldPreview,
debit_account: mapping.debit_account,
credit_account: mapping.credit_account,
amount: Math.abs((tx as Transaction).amount),
currency: (tx as Transaction).currency,
vat_lines: (mapping.vat_lines ?? []).map((v) => ({
account: v.account_number,
amount: v.debit_amount || v.credit_amount,
})),
category: newCategory,
}
const newParams = {
...oldParams,
category: newCategory,
vat_treatment: newVatTreatment ?? null,
}
const { data: updated, error } = await supabase
.from('pending_operations')
.update({ params: newParams, preview_data: newPreview })
.eq('id', id)
.eq('company_id', companyId)
.select('id, params, preview_data, title, status')
.single()
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ data: updated })
}
@@ -197,6 +197,72 @@ describe('POST /api/transactions/[id]/categorize', () => {
)
})
it('flags an inbox underlag matched to the transaction as booked', async () => {
// A document was attached to this transaction in the inbox
// (matched_transaction_id) but not booked from there. Booking the
// transaction here — no inbox_item_id in the body — must still stamp the
// matched inbox item with the new journal entry and link its document.
const tx = makeTransaction({
id: 'tx-1',
amount: -500,
merchant_name: 'GitHub',
journal_entry_id: null,
document_id: null, // ensure document_attachments is touched ONLY by the inbox propagation
})
enqueue({ data: tx, error: null }) // fetch transaction
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) // settings
enqueue({ data: [{ id: 'period-1' }], error: null }) // fiscal period check
mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' })
enqueue({ data: [{ id: 'tx-1' }], error: null }) // tx update (CAS matched)
// Inbox propagation: one matched item with a document
enqueue({ data: [{ id: 'inbox-1', document_id: 'doc-1' }], error: null })
enqueue({ data: null, error: null }) // document_attachments update
enqueue({ data: null, error: null }) // invoice_inbox_items update
const request = createMockRequest('/api/transactions/tx-1/categorize', {
method: 'POST',
body: { is_business: true, category: 'expense_software' },
})
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse<{ success: boolean; journal_entry_id: string }>(response)
expect(status).toBe(200)
expect(body.success).toBe(true)
expect(body.journal_entry_id).toBe('je-1')
// The propagation looked up matched inbox items and linked the document.
expect(mockSupabase.from).toHaveBeenCalledWith('invoice_inbox_items')
expect(mockSupabase.from).toHaveBeenCalledWith('document_attachments')
})
it('does not touch the inbox when no underlag is matched to the transaction', async () => {
const tx = makeTransaction({
id: 'tx-1',
amount: -500,
merchant_name: 'GitHub',
journal_entry_id: null,
document_id: null,
})
enqueue({ data: tx, error: null })
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
enqueue({ data: [{ id: 'period-1' }], error: null })
mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' })
enqueue({ data: [{ id: 'tx-1' }], error: null }) // tx update
enqueue({ data: [], error: null }) // inbox propagation: no matched items
const request = createMockRequest('/api/transactions/tx-1/categorize', {
method: 'POST',
body: { is_business: true, category: 'expense_software' },
})
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
// No matched underlag → no document/inbox writes from the propagation.
expect(mockSupabase.from).not.toHaveBeenCalledWith('document_attachments')
})
it('returns success with error when journal entry creation fails (non-blocking)', async () => {
const tx = makeTransaction({
id: 'tx-1',
+57 -1
View File
@@ -638,8 +638,24 @@ export const POST = withRouteContext(
.eq('id', inboxItem.document_id)
.eq('company_id', companyId)
}
// Reflect the booking back onto the inbox row so it stops appearing as
// unmatched. Categorizing here puts the underlag on a verifikation,
// which is the inbox's "booked" state. Without this the inbox keeps
// offering "Matcha mot transaktion" for an underlag that's already on a
// posted entry — while the transactions view (which reads the
// doc↔verifikat link) already shows it as attached. Mirrors the
// backfill that /attach-document does for the manual paperclip path.
await supabase
.from('invoice_inbox_items')
.update({
matched_transaction_id: id,
created_journal_entry_id: journalEntryId,
})
.eq('id', body.inbox_item_id)
.eq('company_id', companyId)
} catch (inboxErr) {
txLog.warn('failed to link inbox document (non-critical)', inboxErr as Error)
txLog.warn('failed to sync inbox item after booking (non-critical)', inboxErr as Error)
}
}
@@ -687,6 +703,46 @@ export const POST = withRouteContext(
return errorResponseFromCode('TX_CATEGORIZE_RACE', txLog, { requestId })
}
// Flag any inbox underlag already matched to this transaction as booked.
// The block above only fires when the caller passes an explicit
// inbox_item_id (booking straight from the inbox flow). Booking the same
// transaction from anywhere else — the /transactions list, quick review —
// would otherwise leave an attached underlag stuck as "Kopplad" in the
// inbox forever. Here we resolve it by the link itself (matched_transaction
// _id) so the inbox reflects the booking regardless of entry point. Mirrors
// the propagation in lib/pending-operations/commit.ts. Runs post-CAS so we
// never stamp the inbox with a journal entry that lost the race.
if (journalEntryId) {
try {
const { data: matchedInboxItems } = await supabase
.from('invoice_inbox_items')
.select('id, document_id')
.eq('company_id', companyId)
.eq('matched_transaction_id', id)
.is('created_journal_entry_id', null)
for (const inbox of (matchedInboxItems ?? []) as Array<{
id: string
document_id: string | null
}>) {
if (inbox.document_id) {
await supabase
.from('document_attachments')
.update({ journal_entry_id: journalEntryId })
.eq('id', inbox.document_id)
.eq('company_id', companyId)
}
await supabase
.from('invoice_inbox_items')
.update({ created_journal_entry_id: journalEntryId })
.eq('id', inbox.id)
.eq('company_id', companyId)
}
} catch (inboxErr) {
txLog.warn('failed to flag matched inbox items after booking (non-critical)', inboxErr as Error)
}
}
await eventBus.emit({
type: 'transaction.categorized',
payload: {
@@ -0,0 +1,94 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { parseJsonResponse, createQueuedMockSupabase, makeTransaction } from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
const mockGetBestInvoiceMatch = vi.fn()
vi.mock('@/lib/invoices/invoice-matching', () => ({
getBestInvoiceMatch: (...args: unknown[]) => mockGetBestInvoiceMatch(...args),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
import { POST } from '../route'
describe('POST /api/transactions/batch-match-invoices', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
beforeEach(() => {
vi.clearAllMocks()
reset()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
})
it('returns 401 when not authenticated', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
const response = await POST()
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(401)
expect(body).toEqual({ error: 'Unauthorized' })
})
it('calls the invoice matcher with companyId (not user.id) and records the match', async () => {
const tx = makeTransaction({ id: 'tx-1', amount: 12500 })
enqueue({ data: [tx], error: null }) // fetch uncategorized income transactions
enqueue({ data: null, error: null }) // update transaction with potential_invoice_id
mockGetBestInvoiceMatch.mockResolvedValue({ invoice: { id: 'inv-1' }, confidence: 0.9 })
const response = await POST()
const { status, body } = await parseJsonResponse<{ processed: number; matched: number }>(response)
expect(status).toBe(200)
expect(body).toEqual({ processed: 1, matched: 1 })
// Regression guard: the matcher's 2nd arg is companyId. The bug passed user.id
// here, which never equals any invoices.company_id, so it silently matched zero.
expect(mockGetBestInvoiceMatch).toHaveBeenCalledWith(
expect.anything(),
'company-1',
expect.objectContaining({ id: 'tx-1' }),
0.5
)
expect(mockGetBestInvoiceMatch).not.toHaveBeenCalledWith(
expect.anything(),
'user-1',
expect.anything(),
expect.anything()
)
})
it('returns matched: 0 when no invoice meets the confidence threshold', async () => {
const tx = makeTransaction({ id: 'tx-1', amount: 12500 })
enqueue({ data: [tx], error: null })
mockGetBestInvoiceMatch.mockResolvedValue(null)
const response = await POST()
const { status, body } = await parseJsonResponse<{ processed: number; matched: number }>(response)
expect(status).toBe(200)
expect(body).toEqual({ processed: 1, matched: 0 })
})
it('returns 500 when the transaction fetch fails', async () => {
enqueue({ data: null, error: { message: 'boom' } })
const response = await POST()
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(500)
expect(body.error).toBe('Failed to fetch transactions')
})
})
@@ -46,7 +46,7 @@ export async function POST() {
try {
const bestMatch = await getBestInvoiceMatch(
supabase,
user.id,
companyId,
tx as Transaction,
0.50
)
+1 -1
View File
@@ -23,7 +23,7 @@ export async function GET(request: Request) {
let query = supabase
.from('transactions')
.select('id, date, description, amount, currency, reference, journal_entry_id, reconciliation_method')
.select('id, date, description, amount, currency, amount_sek, exchange_rate, reference, journal_entry_id, reconciliation_method')
.eq('company_id', companyId)
// unmatched and reconciled are mutually exclusive — unmatched wins if both set
+19
View File
@@ -250,6 +250,25 @@ h1, h2, h3 {
50% { opacity: 0.6; }
}
/* Three-dot "typing" indicator used by the agent chat pre-token bubble.
Each dot rides the same wave but with staggered animation-delay so the
bounce reads left-to-right. */
@keyframes typingDot {
0%, 60%, 100% { transform: translateY(0); opacity: 0.35; }
30% { transform: translateY(-3px); opacity: 1; }
}
.animate-typing-dot {
animation: typingDot 1.1s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.animate-typing-dot {
animation: gentlePulse 1.6s ease-in-out infinite;
transform: none;
}
}
/* Staggered entrance animation */
.stagger-enter > * {
animation: slideUp var(--duration-slow) var(--ease-out) both;
+61
View File
@@ -0,0 +1,61 @@
'use client'
import { MessageCircle } from 'lucide-react'
import { cn } from '@/lib/utils'
import { getAvatarUrl } from './avatars'
interface Props {
avatarId: string | null | undefined
size?: 'xs' | 'sm' | 'md' | 'lg'
className?: string
alt?: string
}
// Renders the agent's avatar — either the chosen dicebear SVG from the
// AVATAR_OPTIONS registry, or a fallback MessageCircle glyph on a dark circle
// when no avatar is set yet (free tier / older profiles).
//
// `next/image` is intentionally NOT used: avatars are tiny remote SVGs from
// the dicebear CDN, and adding the domain to next.config just to render a
// 28px image is overkill. Browser caches the SVG forever via the seed-keyed
// URL.
export default function AgentAvatar({ avatarId, size = 'sm', className, alt }: Props) {
const url = getAvatarUrl(avatarId)
const dim = SIZES[size]
const altText = alt ?? 'Avatar'
if (!url) {
return (
<span
className={cn(
'inline-flex items-center justify-center rounded-full bg-foreground text-background shrink-0',
dim.box,
className,
)}
aria-label={altText}
>
<MessageCircle className={dim.icon} />
</span>
)
}
// eslint-disable-next-line @next/next/no-img-element
return (
<img
src={url}
alt={altText}
className={cn(
'rounded-full shrink-0 bg-secondary object-cover',
dim.box,
className,
)}
/>
)
}
const SIZES = {
xs: { box: 'h-5 w-5', icon: 'h-2.5 w-2.5' },
sm: { box: 'h-8 w-8', icon: 'h-3.5 w-3.5' },
md: { box: 'h-10 w-10', icon: 'h-4 w-4' },
lg: { box: 'h-14 w-14', icon: 'h-5 w-5' },
}
+944
View File
@@ -0,0 +1,944 @@
'use client'
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import Link from 'next/link'
import {
Send,
Square,
RotateCw,
BookmarkCheck,
BookmarkX,
Check,
Brain,
} from 'lucide-react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import ApprovalCard from './ApprovalCard'
// Reusable chat surface — used both inside the right-hand AgentSheet and on
// the full-page /chat route. Owns:
// * Message state (rendered list)
// * NDJSON stream consumer for /api/agent/invoke
// * Markdown rendering + tool-call badges + approval cards
// * Input form
//
// What it does NOT own:
// * Sheet chrome (title bar, close button) — wrapper's job
// * Page layout / sidebar — wrapper's job
//
// Two modes:
// * Fresh start (initialMessages empty, initialConversationId null):
// mount fires the first POST /api/agent/invoke with intent_args, which
// creates a new conversation_id and streams the intent's templated first
// turn back.
// * Resume (initialMessages + initialConversationId supplied): hydrate from
// DB rows, skip the first-turn template, just await user input.
export interface ChatMessage {
role: 'user' | 'assistant'
text: string
// Extended-thinking reasoning, streamed token-by-token via reasoning_delta.
// Shown in a collapsible "Tänkte…" block. Stream-time only — not hydrated.
reasoning?: string
// Tool-use chips. `completed` flips true when the matching `tool_result`
// event arrives so the UI can swap the pulsing dot for a static check
// instead of yanking the chip out from under the user. Hydrated messages
// are always completed (they would not have been persisted otherwise).
toolCalls?: { tool_use_id: string; name: string; completed?: boolean }[]
staged?: StagedOperation[]
memoryEvents?: MemoryEvent[]
}
// Emitted by run-turn.ts after a successful remember_fact / forget_fact call
// so the chat surface can render a quiet "Sparat som minne: …" chip below the
// assistant message. Stream-time only — not hydrated on /chat resume.
interface MemoryEvent {
tool_use_id: string
action: 'remembered' | 'forgotten'
memory_id: string
memory_kind?: 'fact' | 'preference' | 'pattern' | 'correction'
content?: string
}
interface StagedOperation {
tool_use_id: string
operation_id?: string
risk_level: 'low' | 'medium' | 'high'
message: string
// The originating tool name (e.g. 'gnubok_categorize_transaction'). Lets
// ApprovalCard pick the right structured-preview renderer.
tool_name?: string
// The structured operation preview from the staged envelope. Shape varies
// by tool; ApprovalCard's renderers do the type-narrowing.
preview?: unknown
// Period state at the operation's effective date. Surfaced as a small
// badge — open|locked|closed.
period_status?: {
period_id?: string | null
status: 'open' | 'locked' | 'closed'
lock_date?: string | null
}
}
export interface AgentChatProps {
intentId: string
intentArgs?: Record<string, unknown>
contextRef?: string
initialMessages?: ChatMessage[]
initialConversationId?: string | null
onConversationIdChange?: (id: string) => void
// Fires after the first turn_complete in a fresh-start session — used by
// bootstrap starters (ChatNewStarter, ChatIntakeStarter) to defer the URL
// swap until streaming is done. Swapping on the early `conversation`
// event unmounts the component mid-stream and the assistant reply is
// never persisted before /chat/[id] hydrates.
onFirstTurnComplete?: (id: string) => void
// Optional vertical padding override — defaults to py-6 inside the
// scroller. The full-page chat uses py-8 for breathing room.
scrollerClassName?: string
// Pre-baked first user message. When set, the mount effect fires the first
// turn with this verbatim (skipping the intent's promptTemplate path) AND
// renders it as a user-side message in the timeline. Used by /chat empty
// state suggestion chips.
seedUserMessage?: string
}
export default function AgentChat({
intentId,
intentArgs,
contextRef,
initialMessages,
initialConversationId,
onConversationIdChange,
onFirstTurnComplete,
scrollerClassName,
seedUserMessage,
}: AgentChatProps) {
const [conversationId, setConversationId] = useState<string | null>(initialConversationId ?? null)
// Track whether the first-turn callback has fired so the bootstrap
// starters get exactly one notification even if a turn fires before
// the conversation_id event (defensive — order shouldn't matter).
const firstTurnFiredRef = useRef(false)
const conversationIdRef = useRef<string | null>(initialConversationId ?? null)
const [messages, setMessages] = useState<ChatMessage[]>(initialMessages ?? [])
const [input, setInput] = useState('')
const [streaming, setStreaming] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const scrollerRef = useRef<HTMLDivElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
// Active turn's controller, kept in a ref (not state) so the stop button
// can read it without re-renders churning the AbortController identity.
const activeControllerRef = useRef<AbortController | null>(null)
// Set when a tool call runs; consumed by the NEXT text_delta to insert a
// single paragraph break so post-tool narration starts on its own line.
// A ref (not state) because it must be read/cleared synchronously inside
// the streaming loop without triggering re-renders — and because the
// break must fire exactly once per resume, not on every delta.
const breakBeforeNextTextRef = useRef(false)
// Fresh-start vs. resume — only kick off the first turn when we have neither
// a hydrated conversation nor pre-existing messages. React 19 Strict Mode
// runs effects twice in dev; the first call's cleanup aborts its fetch, the
// second completes. The invoke endpoint is idempotent on first-turn when
// no conversation_id is supplied (it creates a fresh row each time, so a
// transient duplicate just orphans the first conversation — harmless).
useEffect(() => {
// Only bootstrap a first turn on a genuine fresh start — i.e. NO
// conversation id. A present id means the conversation already exists
// (or is mid-creation elsewhere), so we must not fire an invoke.
//
// Why id-alone, not id+messages: the intake flow fires an invoke with
// no conversation_id, then swaps the URL to /chat/[id] the moment the
// `conversation` event lands — which can beat the greeting being
// persisted. /chat/[id] then hydrates with 0 messages. If we keyed the
// guard on messages.length we'd auto-fire a SECOND invoke against the
// same conversation and render two greetings. Keying on id presence
// alone closes that race.
const hasResumeState = !!initialConversationId
if (hasResumeState) return
// Seed-message path: render the user's pre-baked starter in the timeline
// and send it as the first turn's user_message (skips intent.capture +
// promptTemplate). Empty seed runs the normal capture-driven flow.
if (seedUserMessage && seedUserMessage.trim().length > 0) {
setMessages([{ role: 'user', text: seedUserMessage.trim() }])
void startTurn({
conversationId: initialConversationId ?? null,
userMessage: seedUserMessage.trim(),
})
} else {
void startTurn({
conversationId: initialConversationId ?? null,
userMessage: '',
})
}
return () => {
activeControllerRef.current?.abort()
activeControllerRef.current = null
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Autoscroll on new content — but only if the user was already pinned to the
// bottom. Scrolling up to re-read a long answer should NOT yank the user
// back on every streaming token. Threshold accounts for sub-pixel rounding.
const wasAtBottomRef = useRef(true)
useEffect(() => {
const el = scrollerRef.current
if (!el) return
const onScroll = () => {
const distance = el.scrollHeight - (el.scrollTop + el.clientHeight)
wasAtBottomRef.current = distance < 64
}
el.addEventListener('scroll', onScroll, { passive: true })
return () => el.removeEventListener('scroll', onScroll)
}, [])
useEffect(() => {
const el = scrollerRef.current
if (!el) return
if (wasAtBottomRef.current) {
el.scrollTop = el.scrollHeight
}
}, [messages])
async function startTurn(body: {
conversationId: string | null
userMessage: string
// When true, the user_message is persisted for agent context but flagged
// hidden so it never renders as a user bubble (e.g. a rejection correction
// fed back into the chat). The caller also skips adding a visible bubble.
hidden?: boolean
}): Promise<void> {
// Abort any in-flight turn before starting a new one — guards against
// racing two turns when handleSend is triggered twice fast.
activeControllerRef.current?.abort()
const controller = new AbortController()
activeControllerRef.current = controller
const signal = controller.signal
// Reset the post-tool paragraph-break ref at the start of every turn so a
// prior turn that ended on tool_use can't leak a leading "\n\n" into the
// next turn's first text delta.
breakBeforeNextTextRef.current = false
setStreaming(true)
setErrorMessage(null)
let response: Response
try {
response = await fetch('/api/agent/invoke', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
intent_id: intentId,
intent_args: intentArgs,
context_ref: contextRef,
conversation_id: body.conversationId,
user_message: body.userMessage,
user_message_hidden: body.hidden ?? false,
}),
signal,
})
} catch (err) {
if (signal.aborted) return
setErrorMessage(err instanceof Error ? err.message : 'Kunde inte nå assistenten.')
setStreaming(false)
activeControllerRef.current = null
return
}
if (!response.ok || !response.body) {
// Surface the server's friendly Swedish message (rate-limit sentence,
// "ingen aktiv firma", etc.) rather than a raw "HTTP 429".
let msg = 'Kunde inte nå assistenten. Försök igen om en stund.'
try {
const errBody = await response.json()
if (errBody && typeof errBody.error === 'string' && errBody.error.trim()) {
msg = errBody.error
}
} catch {
// non-JSON / empty body — keep the generic message
}
setErrorMessage(msg)
setStreaming(false)
activeControllerRef.current = null
return
}
// Assistant bubble is appended LAZILY — only when the first event that
// produces user-visible content arrives. Eagerly appending here would
// leave an empty bubble dangling if the stream errors or yields zero
// events (e.g. proxy hiccup) before any content.
let assistantBubbleAppended = false
const ensureAssistantBubble = () => {
if (assistantBubbleAppended) return
assistantBubbleAppended = true
setMessages((prev) => [...prev, { role: 'assistant', text: '' }])
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
let nl: number
while ((nl = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, nl).trim()
buffer = buffer.slice(nl + 1)
if (!line) continue
// Guard JSON.parse per line — a malformed line (proxy split,
// partial buffer flush) must NOT abort the entire stream. Skip and
// continue; the next well-formed line will be handled normally.
let parsed: unknown
try {
parsed = JSON.parse(line)
} catch {
continue
}
// First user-visible event lazily mounts the bubble. `conversation`
// is a metadata event with no visible payload so it does not.
const ev = parsed as { kind?: string } | null
if (
ev &&
typeof ev.kind === 'string' &&
ev.kind !== 'conversation' &&
ev.kind !== 'turn_complete'
) {
ensureAssistantBubble()
}
handleEvent(parsed)
}
}
} catch (err) {
if (!signal.aborted) {
setErrorMessage(err instanceof Error ? err.message : 'Streamen avbröts.')
}
} finally {
try {
reader.releaseLock()
} catch {
// already released
}
// Guard against an aborted prior turn clobbering the new turn's
// streaming flag — only the active controller may reset the state.
if (activeControllerRef.current === controller) {
setStreaming(false)
activeControllerRef.current = null
}
}
}
function handleStop() {
activeControllerRef.current?.abort()
activeControllerRef.current = null
setStreaming(false)
}
function handleRegenerate() {
// Re-run the last user message and let the agent produce a fresh
// response. UI truncates back to the last user message; DB rows are
// append-only, so the previous assistant turn stays in agent_messages
// (audit trail intact). The new turn is appended on top.
let lastUserIdx = -1
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === 'user') {
lastUserIdx = i
break
}
}
if (lastUserIdx === -1) return
const userMsg = messages[lastUserIdx]
setMessages(messages.slice(0, lastUserIdx + 1))
void startTurn({ conversationId, userMessage: userMsg.text })
}
// Fired after the user rejects a proposal with a reason. The rejection is
// already recorded server-side; here we feed the correction back as a HIDDEN
// user turn so the agent re-proposes inline — no synthetic user bubble (we
// don't add a user row, and the turn is persisted hidden).
function handleCorrection(correctionMessage: string) {
void startTurn({ conversationId, userMessage: correctionMessage, hidden: true })
}
function handleEvent(event: unknown) {
if (typeof event !== 'object' || event === null) return
const ev = event as { kind: string } & Record<string, unknown>
switch (ev.kind) {
case 'conversation': {
const id = ev.conversation_id as string
setConversationId(id)
conversationIdRef.current = id
onConversationIdChange?.(id)
break
}
case 'reasoning_delta':
// Extended-thinking tokens. Accumulate onto the active assistant
// message; the ReasoningBlock renders them live, then collapses.
setMessages((prev) =>
updateLastAssistant(prev, (m) => ({
...m,
reasoning: (m.reasoning ?? '') + (ev.delta as string),
})),
)
break
case 'text_delta':
// Insert a paragraph break ONCE when text resumes after a tool
// call, so post-tool narration starts on its own line instead of
// gluing onto the previous sentence ("kategoriseras.Inget historik").
// breakBeforeNextTextRef is set by tool_use/tool_result and consumed
// here on the first delta. Critically, the break is applied to the
// delta exactly once — NOT re-evaluated per delta, which previously
// split mid-word ("minnes\n\nno\n\nterna") because streaming deltas
// arrive in sub-word chunks.
setMessages((prev) =>
updateLastAssistant(prev, (m) => {
let delta = ev.delta as string
if (breakBeforeNextTextRef.current) {
breakBeforeNextTextRef.current = false
// Only add the break if the buffer has content and doesn't
// already end with whitespace, and the delta isn't itself
// starting with a newline.
if (m.text.length > 0 && !/\s$/.test(m.text) && !/^\s/.test(delta)) {
delta = '\n\n' + delta
}
}
return { ...m, text: m.text + delta }
}),
)
break
case 'tool_use':
// Next text_delta should open a fresh paragraph.
breakBeforeNextTextRef.current = true
setMessages((prev) =>
updateLastAssistant(prev, (m) => ({
...m,
toolCalls: [
...(m.toolCalls ?? []),
{ tool_use_id: ev.tool_use_id as string, name: ev.name as string },
],
})),
)
break
case 'tool_result':
// Mark the matching chip as completed instead of removing it. Tools
// run in 100500 ms so yanking the chip the moment it finishes makes
// the indicator feel like a flicker rather than a record of what
// happened. Leaving the chip in place (with a static check dot,
// no pulse) gives the user a stable trace of which calls ran.
setMessages((prev) =>
updateLastAssistant(prev, (m) => ({
...m,
toolCalls: m.toolCalls?.map((tc) =>
tc.tool_use_id === (ev.tool_use_id as string) ? { ...tc, completed: true } : tc,
),
})),
)
break
case 'memory_captured': {
const evt: MemoryEvent = {
tool_use_id: ev.tool_use_id as string,
action: (ev.action as 'remembered' | 'forgotten') ?? 'remembered',
memory_id: ev.memory_id as string,
memory_kind: ev.memory_kind as MemoryEvent['memory_kind'],
content: ev.content as string | undefined,
}
setMessages((prev) =>
updateLastAssistant(prev, (m) => ({
...m,
memoryEvents: [...(m.memoryEvents ?? []), evt],
// Drop the matching tool_use chip — the richer memory chip
// replaces it and they convey the same event.
toolCalls: m.toolCalls?.filter((tc) => tc.tool_use_id !== evt.tool_use_id),
})),
)
break
}
case 'staged_operation': {
const stagedRaw = ev.staged as {
operation_id?: string
risk_level: 'low' | 'medium' | 'high'
message: string
preview?: unknown
period_status?: {
period_id?: string | null
status: 'open' | 'locked' | 'closed'
lock_date?: string | null
}
}
setMessages((prev) =>
updateLastAssistant(prev, (m) => ({
...m,
staged: [
...(m.staged ?? []),
{
tool_use_id: ev.tool_use_id as string,
tool_name: (ev.tool_name as string | undefined) ?? undefined,
operation_id: stagedRaw.operation_id,
risk_level: stagedRaw.risk_level,
message: stagedRaw.message,
preview: stagedRaw.preview,
period_status: stagedRaw.period_status,
},
],
})),
)
break
}
case 'error':
setErrorMessage(ev.message as string)
break
case 'turn_complete': {
if (!firstTurnFiredRef.current && conversationIdRef.current) {
firstTurnFiredRef.current = true
onFirstTurnComplete?.(conversationIdRef.current)
}
break
}
}
}
async function handleSend() {
const text = input.trim()
if (!text || streaming) return
setInput('')
setMessages((prev) => [...prev, { role: 'user', text }])
await startTurn({ conversationId, userMessage: text })
}
// Auto-resize the textarea as the user types. Capped at 8rem (~128px) so
// the input bar never devours the message list. Shrinks back when the
// user clears or backspaces.
useLayoutEffect(() => {
const el = textareaRef.current
if (!el) return
el.style.height = 'auto'
const max = 128
el.style.height = `${Math.min(el.scrollHeight, max)}px`
}, [input])
// Index of the last assistant bubble — used to gate the Regenerate
// affordance so it only appears on the latest response.
let lastAssistantIdx = -1
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === 'assistant') {
lastAssistantIdx = i
break
}
}
return (
<div className="relative flex flex-col h-full min-h-0">
<div
ref={scrollerRef}
className={cn(
'flex-1 overflow-y-auto px-5 py-6 space-y-6',
scrollerClassName,
)}
>
{messages.length === 0 && streaming && <SkeletonBubble />}
{messages.map((m, i) => (
<div key={i} className="animate-slide-up">
<MessageBubble
message={m}
streamingTail={streaming && i === messages.length - 1}
showRegenerate={
!streaming &&
i === lastAssistantIdx &&
m.role === 'assistant' &&
m.text.length > 0
}
onRegenerate={handleRegenerate}
onCorrection={handleCorrection}
/>
</div>
))}
{errorMessage && (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{errorMessage}
</div>
)}
</div>
<form
// padding-bottom = base 1rem + safe-area-inset-bottom on phones so
// the iOS home indicator / Android gesture bar doesn't overlap the
// input.
className="border-t border-border px-5 pt-4 pb-[calc(env(safe-area-inset-bottom,0px)+1rem)]"
onSubmit={(e) => {
e.preventDefault()
void handleSend()
}}
>
<div className="flex items-end gap-2">
<textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Skriv din fråga…"
rows={1}
className="flex-1 resize-none rounded-lg border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring max-h-32 overflow-y-auto"
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
void handleSend()
}
}}
/>
{streaming ? (
// Stop button while the agent is producing tokens — biggest
// pain killer. Aborts the in-flight fetch + reader.
<Button
type="button"
size="icon"
variant="outline"
onClick={handleStop}
aria-label="Avbryt"
title="Avbryt strömning"
>
<Square className="h-3.5 w-3.5 fill-current" />
</Button>
) : (
<Button
type="submit"
size="icon"
disabled={input.trim().length === 0}
aria-label="Skicka"
>
<Send className="h-4 w-4" />
</Button>
)}
</div>
<p className="mt-2 text-[11px] text-muted-foreground">
Enter att skicka · Shift+Enter för ny rad
</p>
</form>
</div>
)
}
function MessageBubble({
message,
streamingTail,
showRegenerate,
onRegenerate,
onCorrection,
}: {
message: ChatMessage
streamingTail: boolean
showRegenerate?: boolean
onRegenerate?: () => void
onCorrection?: (message: string) => void
}) {
const isUser = message.role === 'user'
// An assistant turn that contains only tool calls (no text, no streaming
// tail) is the LLM's "I want to call tool X" handshake. Rendering the
// empty border-card around nothing looks like a broken bubble; show the
// chips standalone in that case.
// While the model is still in its extended-thinking phase (reasoning streamed
// but no answer text yet), the ReasoningBlock is the activity indicator, so
// suppress the empty cursor bubble underneath it.
const isThinking = !isUser && streamingTail && !message.text && !!message.reasoning
const hideEmptyBubble = (!isUser && !message.text && !streamingTail) || isThinking
return (
<div className={cn('flex flex-col gap-2', isUser ? 'items-end' : 'items-start')}>
{!isUser && message.reasoning && (
<ReasoningBlock reasoning={message.reasoning} active={isThinking} />
)}
{!hideEmptyBubble && (
<div
className={cn(
'max-w-[85%] rounded-lg px-4 py-3 text-sm leading-6',
isUser
? 'bg-secondary text-foreground whitespace-pre-wrap'
: 'border border-border bg-card',
)}
>
{isUser ? (
message.text || (streamingTail ? <Cursor /> : '')
) : message.text ? (
<div className="prose prose-sm max-w-none text-foreground [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 prose-headings:font-display prose-headings:font-normal prose-headings:tracking-tight prose-h2:text-base prose-h2:mt-3 prose-h2:mb-2 prose-h3:text-sm prose-h3:mt-3 prose-h3:mb-1 prose-p:my-2 prose-p:leading-6 prose-strong:font-semibold prose-strong:text-foreground prose-ul:my-2 prose-li:my-0.5 prose-blockquote:border-l-2 prose-blockquote:border-foreground/30 prose-blockquote:not-italic prose-blockquote:text-muted-foreground prose-blockquote:pl-3 prose-blockquote:my-2 prose-code:bg-secondary prose-code:rounded prose-code:px-1 prose-code:py-0.5 prose-code:text-xs prose-code:before:content-none prose-code:after:content-none prose-a:text-foreground prose-a:underline prose-a:underline-offset-2 prose-pre:bg-secondary prose-pre:text-foreground prose-pre:border prose-pre:border-border prose-pre:rounded-lg prose-pre:my-2 prose-pre:p-3 prose-pre:text-xs prose-pre:leading-relaxed prose-pre:overflow-x-auto [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:text-foreground [&_pre_code]:text-xs prose-table:my-2 prose-table:text-xs prose-table:border-collapse [&_table]:w-full [&_th]:border-b [&_th]:border-border [&_th]:py-1.5 [&_th]:px-2 [&_th]:text-left [&_th]:font-medium [&_th]:text-muted-foreground [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-[10px] [&_td]:border-b [&_td]:border-border [&_td]:py-1.5 [&_td]:px-2 [&_td]:align-top [&_tbody_tr:last-child_td]:border-b-0">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{message.text}</ReactMarkdown>
</div>
) : streamingTail ? (
<Cursor />
) : null}
</div>
)}
{message.toolCalls && message.toolCalls.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{message.toolCalls.map((tc) => (
<span
key={tc.tool_use_id}
className={cn(
'inline-flex items-center gap-1.5 text-[11px] rounded-full border border-border px-2 py-0.5',
tc.completed
? 'text-muted-foreground/70 bg-card'
: 'text-muted-foreground bg-secondary/40',
)}
>
{tc.completed ? (
<Check className="h-2.5 w-2.5 text-muted-foreground/60" strokeWidth={3} />
) : (
<span className="relative inline-flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-foreground/40 opacity-75" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-foreground/60" />
</span>
)}
{prettyToolName(tc.name)}
</span>
))}
</div>
)}
{message.memoryEvents && message.memoryEvents.length > 0 && (
<div className="flex flex-col gap-1.5 max-w-[85%]">
{message.memoryEvents.map((m) => (
<MemoryChip key={m.tool_use_id} event={m} />
))}
</div>
)}
{message.staged && message.staged.length > 0 && (
<div className="w-full max-w-[85%] space-y-2">
{message.staged.map((s) =>
s.operation_id ? (
<ApprovalCard
key={s.tool_use_id}
operationId={s.operation_id}
riskLevel={s.risk_level}
message={s.message}
toolName={s.tool_name}
preview={s.preview}
periodStatus={s.period_status}
onRequestCorrection={onCorrection}
/>
) : (
<div
key={s.tool_use_id}
className="rounded-lg border border-border bg-card px-4 py-3 text-sm text-muted-foreground"
>
Förslag stageat men ingen operation-id mottagen. Granska i gnubok under <em>Förslag</em>.
</div>
),
)}
</div>
)}
{showRegenerate && onRegenerate && (
<button
type="button"
onClick={onRegenerate}
className="inline-flex items-center gap-1.5 text-[11px] text-muted-foreground hover:text-foreground transition-colors"
title="Generera om svaret"
>
<RotateCw className="h-3 w-3" />
Generera om
</button>
)}
</div>
)
}
// Pre-token "typing" indicator. Three staggered pulsing dots — reads as
// "Anna is typing" much faster than the single blinking caret it replaced.
// Stays only until the first text_delta lands, then the message body takes
// over.
function Cursor() {
return (
<span className="inline-flex items-center gap-1 align-middle" aria-label="Skriver" role="status">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-foreground/50 animate-typing-dot" style={{ animationDelay: '0ms' }} />
<span className="inline-block h-1.5 w-1.5 rounded-full bg-foreground/50 animate-typing-dot" style={{ animationDelay: '150ms' }} />
<span className="inline-block h-1.5 w-1.5 rounded-full bg-foreground/50 animate-typing-dot" style={{ animationDelay: '300ms' }} />
</span>
)
}
// Collapsible extended-thinking trace. While the model is still reasoning
// (active), it auto-expands and streams — doubling as the "working" indicator
// in place of the typing cursor. Once the answer starts it collapses to a
// quiet toggle so the reply stays the focus and the surface stays calm.
function ReasoningBlock({ reasoning, active }: { reasoning: string; active: boolean }) {
const [open, setOpen] = useState(false)
const show = open || active
return (
<div className="w-full max-w-[85%]">
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="inline-flex items-center gap-1.5 text-[11px] text-muted-foreground hover:text-foreground transition-colors"
aria-expanded={show}
>
{active ? (
<span className="relative inline-flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-foreground/40 opacity-75" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-foreground/60" />
</span>
) : (
<Brain className="h-3 w-3" />
)}
{active ? 'Tänker…' : show ? 'Dölj resonemang' : 'Visa resonemang'}
</button>
{show && (
<div className="mt-1.5 rounded-lg border border-border bg-muted/30 px-3 py-2 text-xs leading-5 text-muted-foreground whitespace-pre-wrap">
{reasoning}
</div>
)}
</div>
)
}
const MEMORY_KIND_LABEL: Record<'fact' | 'preference' | 'pattern' | 'correction', string> = {
fact: 'Fakta',
preference: 'Preferens',
pattern: 'Mönster',
correction: 'Korrigering',
}
function MemoryChip({ event }: { event: MemoryEvent }) {
const Icon = event.action === 'remembered' ? BookmarkCheck : BookmarkX
const verb = event.action === 'remembered' ? 'Sparat som minne' : 'Glömt minne'
const kindLabel = event.memory_kind ? MEMORY_KIND_LABEL[event.memory_kind] : null
const snippet = event.content
? event.content.length > 140
? `${event.content.slice(0, 140).trim()}`
: event.content
: null
return (
<Link
href="/settings/assistant"
className="group inline-flex items-start gap-2 rounded-lg border border-border bg-card px-3 py-2 text-xs text-muted-foreground transition-colors hover:border-foreground/30 hover:text-foreground"
title="Visa i Assistentens minne"
>
<Icon className="mt-0.5 h-3.5 w-3.5 shrink-0 text-foreground/70" />
<span className="flex-1 min-w-0">
<span className="font-medium text-foreground">{verb}</span>
{kindLabel && <span className="ml-1 text-muted-foreground">· {kindLabel}</span>}
{snippet && (
<span className="block text-muted-foreground mt-0.5 leading-snug break-words">
{snippet}
</span>
)}
</span>
</Link>
)
}
// Rendered for the brief moment between sending the first request and the
// first text_delta. Three pulsing lines that fade out as soon as a real
// bubble takes their place.
function SkeletonBubble() {
return (
<div className="flex flex-col gap-2 items-start animate-fade-in">
<div className="max-w-[85%] rounded-lg border border-border bg-card px-4 py-3 space-y-2 w-72">
<div className="h-3 rounded bg-muted-foreground/15 animate-pulse w-full" />
<div className="h-3 rounded bg-muted-foreground/15 animate-pulse w-[85%]" />
<div className="h-3 rounded bg-muted-foreground/15 animate-pulse w-[60%]" />
</div>
</div>
)
}
function updateLastAssistant(
prev: ChatMessage[],
update: (m: ChatMessage) => ChatMessage,
): ChatMessage[] {
if (prev.length === 0) return prev
const last = prev[prev.length - 1]
if (last.role !== 'assistant') return prev
return [...prev.slice(0, -1), update(last)]
}
// Swedish present-progressive labels for the most common MCP tools, so the
// inline badge reads as "what the agent is doing right now" rather than
// dumping the raw tool slug. Anything not in the map falls back to a
// humanized stem ("gnubok_foo_bar" → "kör foo bar…").
const TOOL_BADGE_LABELS: Record<string, string> = {
// Discovery
gnubok_search_tools: 'Letar efter verktyg…',
gnubok_list_skills: 'Letar bland kunskap…',
gnubok_load_skill: 'Slår upp regelverk…',
// Reading / context
gnubok_get_document_content: 'Läser underlaget…',
gnubok_get_counterparty_templates: 'Letar i mottagar­mallar…',
gnubok_get_supplier_ledger: 'Hämtar leverantörshistorik…',
gnubok_get_ar_ledger: 'Hämtar kundreskontra…',
gnubok_get_trial_balance: 'Hämtar saldobalans…',
gnubok_get_balance_sheet: 'Hämtar balansräkning…',
gnubok_get_income_statement: 'Hämtar resultatrapport…',
gnubok_get_general_ledger: 'Slår i huvudboken…',
gnubok_get_kpi_report: 'Beräknar nyckeltal…',
gnubok_get_vat_report: 'Hämtar momsrapport…',
gnubok_vat_close_check: 'Kontrollerar momsperiod…',
gnubok_query_journal: 'Söker i bokföringen…',
gnubok_year_end_readiness: 'Kontrollerar bokslutsläge…',
gnubok_list_customers: 'Söker bland kunder…',
gnubok_list_invoices: 'Listar fakturor…',
// Writes (staged)
gnubok_categorize_transaction: 'Förbereder bokning…',
gnubok_match_transaction_to_invoice: 'Matchar mot faktura…',
gnubok_create_customer: 'Skapar kund…',
gnubok_create_invoice: 'Förbereder faktura…',
gnubok_create_voucher: 'Förbereder verifikation…',
gnubok_create_transactions: 'Förbereder transaktioner…',
gnubok_approve_supplier_invoice: 'Stagear attestering…',
gnubok_credit_supplier_invoice: 'Förbereder kreditfaktura…',
gnubok_propose_accruals: 'Räknar fram periodiseringar…',
gnubok_propose_annual_depreciation: 'Beräknar avskrivningar…',
gnubok_propose_dispositioner: 'Förbereder dispositioner…',
gnubok_preview_arsredovisning: 'Förhandsgranskar årsredovisning…',
gnubok_preview_ef_declaration: 'Förbereder NE-bilaga…',
gnubok_post_annual_depreciation: 'Bokar avskrivningar…',
// Memory
gnubok_remember_fact: 'Sparar i minnet…',
gnubok_forget_fact: 'Tar bort från minnet…',
}
function prettyToolName(name: string): string {
if (TOOL_BADGE_LABELS[name]) return TOOL_BADGE_LABELS[name]
return `kör ${name.replace(/^gnubok_/, '').replace(/_/g, ' ')}`
}
// Helper used by /chat/[id] server component to normalize agent_messages
// rows into the ChatMessage shape this component expects. Exported here so
// both the sheet (for future "resume" support) and the page can use it.
export function normalizeStoredMessages(
rows: { role: string; content: unknown; hidden?: boolean | null }[],
): ChatMessage[] {
const out: ChatMessage[] = []
for (const r of rows) {
if (r.role === 'tool') continue // tool_result blocks aren't shown in the timeline
if (r.hidden === true) continue // synthetic first-turn templates + hidden correction turns
const content = r.content
if (typeof content === 'string') {
out.push({ role: r.role === 'assistant' ? 'assistant' : 'user', text: content })
continue
}
if (!Array.isArray(content)) continue
let text = ''
const toolCalls: { tool_use_id: string; name: string; completed?: boolean }[] = []
for (const block of content as { type: string; text?: string; id?: string; name?: string }[]) {
if (block.type === 'text' && block.text) text += block.text
else if (block.type === 'tool_use' && block.id && block.name) {
// Hydrated rows are historical — the tool already finished by
// definition (otherwise the assistant content wouldn't have been
// persisted). Mark every chip as completed so the rendered state
// matches the live tool_result-handled state.
toolCalls.push({ tool_use_id: block.id, name: block.name, completed: true })
}
}
out.push({
role: r.role === 'assistant' ? 'assistant' : 'user',
text,
...(toolCalls.length > 0 ? { toolCalls } : {}),
})
}
return out
}
+108
View File
@@ -0,0 +1,108 @@
'use client'
import { useEffect, useState } from 'react'
import { X, Expand } from 'lucide-react'
import Link from 'next/link'
import AgentChat from './AgentChat'
import AgentAvatar from './AgentAvatar'
import { useAgentSheet } from './AgentSheetProvider'
// Undimmed non-modal side sheet — sits above the page on a hairline border +
// shadow, but the page underneath stays fully interactive. Plan §3b.
//
// The sheet is a thin wrapper around AgentChat: it owns the title bar, close
// button, and "expand to /chat/[id]" affordance. All message rendering and
// streaming live in AgentChat so the full-page chat view can reuse them.
interface Props {
intentId: string
intentArgs?: Record<string, unknown>
contextRef?: string
seedUserMessage?: string
onClose: () => void
}
export default function AgentSheet({
intentId,
intentArgs,
contextRef,
seedUserMessage,
onClose,
}: Props) {
const [conversationId, setConversationId] = useState<string | null>(null)
const { identity } = useAgentSheet()
const agentName = identity.displayName?.trim() || null
const sheetTitle = intentToTitle(intentId, agentName)
// Esc closes the sheet.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose])
return (
<div
role="dialog"
aria-label={sheetTitle}
// z-[60] sits above the mobile bottom nav (z-50) so on phones the sheet
// covers the full screen including where the nav would otherwise show.
className="fixed inset-y-0 right-0 z-[60] flex w-full max-w-[480px] flex-col border-l border-border bg-background shadow-lg"
style={{
// iOS notch / Android cutout — the sheet top edge needs to clear the
// status bar. Bottom is handled inside the form below.
paddingTop: 'env(safe-area-inset-top, 0px)',
}}
>
<header className="flex items-center gap-3 border-b border-border px-5 py-4">
<AgentAvatar avatarId={identity.avatarId} size="sm" alt={agentName ?? 'Assistent'} />
<h2 className="font-display text-lg tracking-tight truncate">{sheetTitle}</h2>
<div className="ml-auto flex items-center gap-1">
{conversationId && (
<Link
href={`/chat/${conversationId}`}
onClick={onClose}
className="h-9 w-9 inline-flex items-center justify-center rounded-md text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
aria-label="Öppna i fullskärm"
title="Öppna i fullskärm"
>
<Expand className="h-4 w-4" />
</Link>
)}
<button
onClick={onClose}
className="h-9 w-9 inline-flex items-center justify-center rounded-md text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
aria-label="Stäng"
>
<X className="h-4 w-4" />
</button>
</div>
</header>
<AgentChat
intentId={intentId}
intentArgs={intentArgs}
contextRef={contextRef}
seedUserMessage={seedUserMessage}
onConversationIdChange={(id) => setConversationId(id)}
/>
</div>
)
}
function intentToTitle(intentId: string, agentName: string | null): string {
switch (intentId) {
case 'general.help':
return agentName ? `Fråga ${agentName}` : 'Fråga din assistent'
case 'transaction.categorization':
return 'Hjälp med transaktion'
case 'invoice.draft':
return 'Hjälp med faktura'
case 'supplier_invoice.review':
return 'Granska leverantörsfaktura'
default:
return agentName ? `Fråga ${agentName}` : 'Fråga din assistent'
}
}
+103
View File
@@ -0,0 +1,103 @@
'use client'
import { createContext, useCallback, useContext, useMemo, useState } from 'react'
import AgentSheet from './AgentSheet'
export interface AgentIdentity {
displayName: string | null
avatarId: string | null
// True only after the user has completed Phase B verification in
// /onboarding/agent. Consumers (AgentTrigger, page-level Sparkle
// buttons) should hide themselves when this is false so the FAB
// doesn't pop up before the agent build flow has run.
isVerified: boolean
}
// Provider exposes a single imperative function: openAgentSheet({...}). Any
// client component (top-nav button, transaction row "Fråga om" button, etc.)
// calls it to bring the sheet up with a specific intent + capture args.
//
// The sheet itself manages its own message list, streaming state, and
// dismissal. The provider just owns "what is open" and re-opens or replaces
// the panel when called again.
export interface OpenAgentSheetArgs {
intentId: string
// Intent-specific args passed to the server's intent.capture() — e.g.
// { transaction_id: '...' } for transaction.categorization.
intentArgs?: Record<string, unknown>
// Optional ref persisted on agent_conversations.context_ref so the UI can
// surface a back-pointer ("om transaktion 12 mar / 1 240 kr") later.
contextRef?: string
// Pre-populated first user message. When set, the chat skips the intent's
// promptTemplate and sends this verbatim instead. Used by /chat empty-state
// suggestion chips to give the user a one-click starting prompt.
seedUserMessage?: string
}
interface AgentSheetContextValue {
openAgentSheet: (args: OpenAgentSheetArgs) => void
closeAgentSheet: () => void
isOpen: boolean
// Agent name + avatar — set once from the server-loaded agent_profile
// and exposed through context so the trigger / chat headers can render
// them without their own fetches. Null when the user hasn't verified a
// profile yet (free tier or pre-onboarding).
identity: AgentIdentity
}
const AgentSheetContext = createContext<AgentSheetContextValue | null>(null)
interface AgentSheetProviderProps {
children: React.ReactNode
identity?: AgentIdentity
}
export function AgentSheetProvider({ children, identity }: AgentSheetProviderProps) {
const [activeArgs, setActiveArgs] = useState<OpenAgentSheetArgs | null>(null)
const openAgentSheet = useCallback((args: OpenAgentSheetArgs) => {
setActiveArgs(args)
}, [])
const closeAgentSheet = useCallback(() => {
setActiveArgs(null)
}, [])
const resolvedIdentity: AgentIdentity =
identity ?? { displayName: null, avatarId: null, isVerified: false }
const value = useMemo<AgentSheetContextValue>(
() => ({
openAgentSheet,
closeAgentSheet,
isOpen: activeArgs !== null,
identity: resolvedIdentity,
}),
[openAgentSheet, closeAgentSheet, activeArgs, resolvedIdentity],
)
return (
<AgentSheetContext.Provider value={value}>
{children}
{activeArgs && (
<AgentSheet
key={`${activeArgs.intentId}:${activeArgs.contextRef ?? ''}:${activeArgs.seedUserMessage ?? ''}`}
intentId={activeArgs.intentId}
intentArgs={activeArgs.intentArgs}
contextRef={activeArgs.contextRef}
seedUserMessage={activeArgs.seedUserMessage}
onClose={closeAgentSheet}
/>
)}
</AgentSheetContext.Provider>
)
}
export function useAgentSheet(): AgentSheetContextValue {
const ctx = useContext(AgentSheetContext)
if (!ctx) {
throw new Error('useAgentSheet must be used inside <AgentSheetProvider>')
}
return ctx
}
+62
View File
@@ -0,0 +1,62 @@
'use client'
import { MessageCircle } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { useAgentSheet } from './AgentSheetProvider'
interface Props {
intentId: string
// eslint-disable-next-line @typescript-eslint/no-explicit-any
intentArgs?: Record<string, any>
contextRef?: string
// Override the auto-derived "Fråga [namn]" label when the page wants
// something more contextual (e.g. "Förklara denna siffra"). Most surfaces
// should leave this unset.
label?: string
size?: 'sm' | 'default' | 'lg'
variant?: 'outline' | 'default' | 'ghost' | 'secondary'
className?: string
}
// Single source of truth for the in-page "Fråga [namn]" affordance. Every
// page-header button across the dashboard (invoice form, supplier invoice,
// bookkeeping, year-end, VAT report, KPI, …) routes through this component
// so they share the same icon size, label format, spacing, and resolved
// agent name. The transaction-row icon button stays separate — it's a
// different UX (icon-only, ghost) embedded inside a row's action group.
export default function AgentSparkleButton({
intentId,
intentArgs,
contextRef,
label,
size = 'sm',
variant = 'outline',
className,
}: Props) {
const { openAgentSheet, identity } = useAgentSheet()
// Same gate as AgentTrigger — hide all "Fråga …" affordances until the
// user has finished /onboarding/agent.
if (!identity.isVerified) return null
const name = identity.displayName?.trim() || 'min assistent'
const resolvedLabel = label ?? `Fråga ${name}`
return (
<Button
type="button"
variant={variant}
size={size}
className={cn('shrink-0', className)}
onClick={() =>
openAgentSheet({
intentId,
intentArgs,
contextRef,
})
}
>
<MessageCircle className="mr-2 h-4 w-4" />
{resolvedLabel}
</Button>
)
}
+79
View File
@@ -0,0 +1,79 @@
'use client'
import { useAgentSheet } from './AgentSheetProvider'
import { usePathname } from 'next/navigation'
import AgentAvatar from './AgentAvatar'
import { routeToIntent } from '@/lib/agent/intents/route-mapping'
// Floating trigger sits above the page bottom-right, opens the AgentSheet when
// clicked. Hidden when the sheet is already open so the icon doesn't double up.
//
// Route-aware: routeToIntent(pathname) picks the right intent + intentArgs so
// clicking the FAB on /invoices/abc-123 opens invoice.draft with that invoice
// id (rather than the page-agnostic general.help with just the URL). The
// label suffix renders "Fråga Anna om denna faktura" so the user can tell at
// a glance that the agent is going to know which entity they're on.
//
// Reads the agent's display_name + avatar_id from the AgentSheet context so
// the button reads "Fråga Anna" (with Anna's face) rather than the generic
// "Fråga min assistent".
//
// Page-specific triggers (e.g. "Granska med assistent" on a supplier invoice)
// still call useAgentSheet() directly from their own buttons because they
// know exactly which entity to pass. (Per-transaction help is reached from
// Dokumentinkorgen, not a transactions-page row button.)
export default function AgentTrigger() {
const { openAgentSheet, isOpen, identity } = useAgentSheet()
const pathname = usePathname()
if (isOpen) return null
// The /chat surface IS the chat — a floating "Fråga …" pill on top of it
// is redundant and overlaps the input. Suppress while the user is here.
if (pathname?.startsWith('/chat')) return null
// The verifikation editor is a dense regulatory surface (debits/credits,
// BAS codes, period locks) — a floating "Fråga … om denna verifikation"
// pill on top of it adds noise without earning its place. Suppress on
// /bookkeeping/[id] specifically; /bookkeeping (list), /bookkeeping/new,
// and /bookkeeping/year-end still get the FAB.
{
const segs = pathname?.split('/').filter(Boolean) ?? []
if (segs[0] === 'bookkeeping' && segs[1] && segs[1] !== 'year-end' && segs[1] !== 'new') {
return null
}
}
// Pre-onboarding: no agent_profile.verified_at yet. The FAB would lead
// into a generic chat with no specialization. Better to hide it until
// the user has finished /onboarding/agent.
if (!identity.isVerified) return null
const name = identity.displayName?.trim() || 'min assistent'
const dispatch = routeToIntent(pathname)
const labelText = dispatch.labelSuffix
? `Fråga ${name} ${dispatch.labelSuffix}`
: `Fråga ${name}`
return (
<button
onClick={() =>
openAgentSheet({
intentId: dispatch.intentId,
intentArgs: dispatch.intentArgs,
contextRef: dispatch.contextRef,
})
}
// Mobile: sit above the bottom nav (h-16 = 64px) AND the iOS home
// indicator (env(safe-area-inset-bottom)). Desktop: standard 20px lift,
// no mobile nav to worry about.
className="fixed right-4 z-30 flex h-12 max-w-[calc(100vw-2rem)] items-center gap-2 rounded-full bg-foreground pl-2 pr-4 text-background shadow-lg hover:bg-foreground/90 transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)] md:bottom-4"
aria-label={labelText}
>
<AgentAvatar
avatarId={identity.avatarId}
size="sm"
className="ring-2 ring-background/20 shrink-0"
alt={name}
/>
<span className="text-sm font-medium truncate">{labelText}</span>
</button>
)
}
+738
View File
@@ -0,0 +1,738 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { Check, X, Loader2, AlertTriangle, Lock, ShieldCheck, ArrowRight } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import type { PendingOperationRejectionCategory } from '@/types'
import { cn } from '@/lib/utils'
import { formatCurrency } from '@/lib/utils'
// Inline approval card for an agent-staged pending_operation.
//
// Risk tiers (plan §9, §12):
// low — single-click "Godkänn". Trust UI for auto-approve lives
// post-V0 (data model supports it via agent_profiles.trust_per_tool).
// medium — single-click "Godkänn".
// high — requires the user to type "godkänn" verbatim. Never auto-
// approvable, by design (legal compliance).
//
// Reject is always one-click.
//
// The card posts to the existing /api/pending-operations/<id>/{commit,reject}
// endpoints — same surface the gnubok "Förslag" page uses, so there is
// exactly one approval source of record.
//
// Structured preview: when the staged envelope carries a preview object, we
// render a scannable summary block under the prose. Each common tool has its
// own renderer; unknown tools fall through to a flat key/value list so a new
// tool can ship without an ApprovalCard change.
interface PeriodStatus {
period_id?: string | null
status: 'open' | 'locked' | 'closed'
lock_date?: string | null
}
interface Props {
operationId: string
riskLevel: 'low' | 'medium' | 'high'
message: string
toolName?: string
preview?: unknown
periodStatus?: PeriodStatus
// Fired after a reject that carries a reason — the chat feeds this synthetic
// correction back as a hidden user turn so the agent re-proposes inline.
onRequestCorrection?: (correctionMessage: string) => void
}
type State = 'pending' | 'committing' | 'committed' | 'rejecting' | 'rejected' | 'error'
// Mirrors the granskning (/pending) reject dialog so chat rejections capture
// the same structured feedback. Stored on the op + surfaced to the agent via
// gnubok_get_recent_rejections.
const REJECTION_CATEGORY_LABELS: Record<PendingOperationRejectionCategory, string> = {
wrong_category: 'Fel kategori / konto',
wrong_amount: 'Fel belopp',
duplicate: 'Dubblett',
wrong_period: 'Fel period',
other: 'Annat',
}
// Subset of fields the commit response may return that the success state
// uses to deep-link to the freshly-created artifact. Different
// operation_types return different shapes — only the ones we actually
// surface as links are declared.
interface CommitResultData {
journal_entry_id?: string | null
invoice_id?: string | null
customer_id?: string | null
supplier_invoice_id?: string | null
}
export default function ApprovalCard({
operationId,
riskLevel,
message,
toolName,
preview,
periodStatus,
onRequestCorrection,
}: Props) {
const [state, setState] = useState<State>('pending')
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [confirmText, setConfirmText] = useState('')
// Reject-with-reason form (mirrors the granskning dialog). Clicking "Avslå"
// opens it; both fields are optional. When a reason is given, the rejection
// is fed back so the agent re-proposes.
const [showRejectForm, setShowRejectForm] = useState(false)
const [rejectCategory, setRejectCategory] = useState<PendingOperationRejectionCategory | ''>('')
const [rejectReason, setRejectReason] = useState('')
// Surfaced in the "Godkänt" success state so the user can jump directly
// to the newly-created artifact (verifikation / faktura / kund) instead
// of hunting through /bookkeeping.
const [commitResult, setCommitResult] = useState<CommitResultData | null>(null)
// Set when commit fails because the booking posts to BAS accounts not yet
// active in the chart. Drives the inline "activate and approve" affordance
// (the op stays pending server-side, so retrying after activation works).
const [accountsToActivate, setAccountsToActivate] = useState<string[] | null>(null)
const requiresTextConfirm = riskLevel === 'high'
const canCommit =
!requiresTextConfirm || confirmText.trim().toLowerCase() === 'godkänn'
async function handleCommit() {
setState('committing')
setErrorMessage(null)
setAccountsToActivate(null)
try {
const res = await fetch(`/api/pending-operations/${operationId}/commit`, {
method: 'POST',
})
const body = (await res.json().catch(() => ({}))) as {
data?: CommitResultData
error?: string | { code?: string; message?: string; account_numbers?: string[] }
}
if (!res.ok) {
// Recoverable: the booking posts to BAS accounts not active in the
// chart. Offer to activate them and retry — the op stays pending.
const structured = typeof body.error === 'object' && body.error !== null ? body.error : null
if (structured?.code === 'ACCOUNTS_NOT_IN_CHART' && structured.account_numbers?.length) {
setAccountsToActivate(structured.account_numbers)
setState('pending')
return
}
throw new Error(errorText(body.error) || `HTTP ${res.status}`)
}
// Best-effort deep-link to the created artifact in the success state.
if (body?.data) setCommitResult(body.data)
setState('committed')
} catch (err) {
setState('error')
setErrorMessage(err instanceof Error ? err.message : 'Kunde inte godkänna.')
}
}
// Activate the missing BAS accounts (one POST) then retry the commit. The
// pending_operation was left 'pending' server-side precisely so this retry
// commits the same booking without re-staging it.
async function handleActivateAndCommit() {
if (!accountsToActivate || accountsToActivate.length === 0) return
setState('committing')
setErrorMessage(null)
try {
const res = await fetch('/api/bookkeeping/accounts/activate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ account_numbers: accountsToActivate }),
})
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string }
throw new Error(body.error || 'Kunde inte aktivera kontona.')
}
setAccountsToActivate(null)
await handleCommit()
} catch (err) {
setState('error')
setErrorMessage(err instanceof Error ? err.message : 'Kunde inte aktivera kontona.')
}
}
async function handleReject() {
setState('rejecting')
setErrorMessage(null)
const categoryLabel = rejectCategory ? REJECTION_CATEGORY_LABELS[rejectCategory] : null
const reason = rejectReason.trim()
// Both fields optional — a bare "Avvisa" still rejects (parity with the
// granskning dialog and older bodyless clients).
const body =
rejectCategory || reason
? {
...(rejectCategory ? { rejection_category: rejectCategory } : {}),
...(reason ? { rejection_reason: reason } : {}),
}
: undefined
try {
const res = await fetch(`/api/pending-operations/${operationId}/reject`, {
method: 'POST',
...(body
? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }
: {}),
})
if (!res.ok) {
const text = await res.text()
throw new Error(text || `HTTP ${res.status}`)
}
setShowRejectForm(false)
setState('rejected')
// Feed the correction back so the agent re-proposes — only when the user
// actually said what was wrong. A bare reject just stops here.
const parts = [categoryLabel, reason].filter(Boolean) as string[]
if (parts.length > 0) {
onRequestCorrection?.(
`Jag avvisade förslaget. Det som var fel: ${parts.join(' — ')}. Föreslå en korrigerad bokning.`,
)
}
} catch (err) {
setState('error')
setErrorMessage(err instanceof Error ? err.message : 'Kunde inte avslå.')
}
}
if (state === 'committed') {
// Build a deep-link to the newly-created artifact when the commit
// response told us what it was. Falls back to nothing if no relevant
// id was returned (e.g. period close / unlock / mark-as-sent).
let deepLink: { href: string; label: string } | null = null
if (commitResult?.journal_entry_id) {
deepLink = {
href: `/bookkeeping/${commitResult.journal_entry_id}`,
label: 'Öppna verifikation',
}
} else if (commitResult?.invoice_id) {
deepLink = {
href: `/invoices/${commitResult.invoice_id}`,
label: 'Öppna faktura',
}
} else if (commitResult?.supplier_invoice_id) {
deepLink = {
href: `/supplier-invoices/${commitResult.supplier_invoice_id}`,
label: 'Öppna leverantörsfaktura',
}
} else if (commitResult?.customer_id) {
deepLink = {
href: `/customers/${commitResult.customer_id}`,
label: 'Öppna kund',
}
}
// The server's `message` field (e.g. "Operation staged for review …
// Open the gnubok web app to approve or reject it.") was written for
// MCP clients without an inline approval surface. Inside the in-app
// chat it's redundant noise — the agent already narrated the why
// above the card. We keep it accessible via aria-description for
// screen readers but don't render it.
return (
<div
className="rounded-lg border border-success/40 bg-success/10 px-4 py-3 text-sm"
aria-description={message}
>
<p className="flex items-center gap-2 font-medium">
<Check className="h-4 w-4" /> Godkänt
</p>
{deepLink && (
<Link
href={deepLink.href}
className="mt-2 inline-flex items-center gap-1 text-xs font-medium text-foreground hover:underline"
>
{deepLink.label}
<ArrowRight className="h-3 w-3" />
</Link>
)}
</div>
)
}
if (state === 'rejected') {
return (
<div
className="rounded-lg border border-border bg-card px-4 py-3 text-sm text-muted-foreground"
aria-description={message}
>
<p className="flex items-center gap-2">
<X className="h-4 w-4" /> Avslaget
{rejectCategory && (
<span className="text-xs text-muted-foreground/80">· {REJECTION_CATEGORY_LABELS[rejectCategory]}</span>
)}
</p>
</div>
)
}
const isBusy = state === 'committing' || state === 'rejecting'
return (
<div
className={cn(
// Subtle accent border-top tells the eye what to do BEFORE reading
// the risk label. high = destructive red, medium = warning yellow,
// low = neutral foreground. animate-scale-in gives the card a soft
// entrance when it first lands inline in the conversation.
'rounded-lg border bg-card px-4 py-3 space-y-3 border-t-2 animate-scale-in',
riskLevel === 'high'
? 'border-destructive/50 border-t-destructive'
: riskLevel === 'medium'
? 'border-border border-t-warning'
: 'border-border border-t-foreground/30',
)}
>
<div className="flex items-center justify-between gap-2 flex-wrap">
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Förslag · risk {translateRisk(riskLevel)}
</p>
{periodStatus && <PeriodBadge status={periodStatus} />}
</div>
<PreviewBlock toolName={toolName} preview={preview} />
{requiresTextConfirm && (
<div className="space-y-1">
<p className="flex items-center gap-2 text-xs text-destructive">
<AlertTriangle className="h-3.5 w-3.5" />
Hög risk skriv <strong className="font-semibold">godkänn</strong> för att bekräfta.
</p>
<input
type="text"
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
disabled={isBusy}
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
autoComplete="off"
aria-label="Bekräfta med ordet godkänn"
/>
</div>
)}
{errorMessage && <p className="text-xs text-destructive">{errorMessage}</p>}
{showRejectForm ? (
<div className="space-y-2 rounded-md border border-border bg-muted/30 px-3 py-2">
<p className="text-xs font-medium">Vad är fel?</p>
<Select
value={rejectCategory}
onValueChange={(v) => setRejectCategory(v as PendingOperationRejectionCategory)}
>
<SelectTrigger className="h-8 text-xs" aria-label="Anledning">
<SelectValue placeholder="Anledning (valfritt)" />
</SelectTrigger>
{/* The agent sheet panel is z-[60]; SelectContent defaults to z-50
and portals to <body>, so without this it opens BEHIND the
sheet. z-[70] sits above the sheet, below toasts (z-[100]). */}
<SelectContent className="z-[70]">
{(Object.keys(REJECTION_CATEGORY_LABELS) as PendingOperationRejectionCategory[]).map((cat) => (
<SelectItem key={cat} value={cat}>{REJECTION_CATEGORY_LABELS[cat]}</SelectItem>
))}
</SelectContent>
</Select>
<Textarea
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
placeholder="T.ex. ska vara IT-tjänster, inte telefoni…"
rows={2}
maxLength={2000}
disabled={isBusy}
className="text-xs"
aria-label="Notering"
/>
<p className="text-[11px] text-muted-foreground">
Med en anledning eller notering föreslår assistenten en korrigerad bokning direkt.
</p>
<div className="flex gap-2">
<Button
variant="destructive"
size="sm"
onClick={handleReject}
disabled={isBusy}
className="flex-1"
>
{state === 'rejecting' ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Avvisa'}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowRejectForm(false)}
disabled={isBusy}
className="flex-1"
>
Avbryt
</Button>
</div>
</div>
) : accountsToActivate ? (
<div className="space-y-2 rounded-md border border-border bg-muted/30 px-3 py-2">
<p className="text-xs leading-5">
Bokningen använder konton som inte är aktiva i din kontoplan:{' '}
<strong className="tabular-nums">{accountsToActivate.join(', ')}</strong>. Aktivera dem för att godkänna bokningen.
</p>
<div className="flex gap-2">
<Button
size="sm"
onClick={handleActivateAndCommit}
disabled={isBusy}
className="flex-1"
>
{state === 'committing' ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Aktivera och godkänn'}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setAccountsToActivate(null)}
disabled={isBusy}
className="flex-1"
>
Avbryt
</Button>
</div>
</div>
) : (
<div className="flex gap-2">
<Button
size="sm"
onClick={handleCommit}
disabled={isBusy || !canCommit}
className="flex-1"
>
{state === 'committing' ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
'Godkänn'
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowRejectForm(true)}
disabled={isBusy}
className="flex-1"
>
Avslå
</Button>
</div>
)}
</div>
)
}
// ─── Structured preview block ──────────────────────────────────────────────
//
// Dispatches on tool_name. Adding a new tool: write a specialized renderer
// here. Falling back to the generic flat list is fine for low-volume tools.
interface PreviewBlockProps {
toolName?: string
preview?: unknown
}
function PreviewBlock({ toolName, preview }: PreviewBlockProps) {
if (!preview || typeof preview !== 'object') return null
const p = preview as Record<string, unknown>
if (toolName === 'gnubok_categorize_transaction') {
return <CategorizeTransactionPreview preview={p} />
}
if (toolName === 'gnubok_create_invoice') {
return <CreateInvoicePreview preview={p} />
}
if (toolName === 'gnubok_create_voucher' || toolName === 'gnubok_correct_entry') {
return <VoucherPreview preview={p} />
}
return <GenericPreview preview={p} />
}
// 20 categories from types/index.ts TransactionCategory. Kept inline so the
// component has no cross-module enum import; sync if the type changes.
const CATEGORY_OPTIONS: { value: string; label: string }[] = [
{ value: 'income_services', label: 'Intäkt — tjänster' },
{ value: 'income_products', label: 'Intäkt — produkter' },
{ value: 'income_other', label: 'Intäkt — övrigt' },
{ value: 'expense_software', label: 'Kostnad — mjukvara' },
{ value: 'expense_equipment', label: 'Kostnad — utrustning' },
{ value: 'expense_office', label: 'Kostnad — kontor' },
{ value: 'expense_travel', label: 'Kostnad — resor' },
{ value: 'expense_marketing', label: 'Kostnad — marknadsföring' },
{ value: 'expense_professional_services', label: 'Kostnad — konsult/tjänster' },
{ value: 'expense_education', label: 'Kostnad — utbildning' },
{ value: 'expense_representation', label: 'Kostnad — representation' },
{ value: 'expense_consumables', label: 'Kostnad — förbrukning' },
{ value: 'expense_vehicle', label: 'Kostnad — fordon' },
{ value: 'expense_telecom', label: 'Kostnad — telefon/internet' },
{ value: 'expense_bank_fees', label: 'Kostnad — bankavgifter' },
{ value: 'expense_card_fees', label: 'Kostnad — kortavgifter' },
{ value: 'expense_currency_exchange', label: 'Kostnad — valutaväxling' },
{ value: 'expense_other', label: 'Kostnad — övrigt' },
{ value: 'private', label: 'Privat uttag' },
]
function CategorizeTransactionPreview({
preview,
}: {
preview: Record<string, unknown>
}) {
const debit = preview.debit_account as string | undefined
const credit = preview.credit_account as string | undefined
const amount = preview.amount as number | undefined
const currency = (preview.currency as string | undefined) ?? 'SEK'
const category = preview.category as string | undefined
// Server emits { account_number, debit_amount, credit_amount, description }
// per VAT line (extensions/general/mcp-server/server.ts:390-395). One side
// is non-zero, the other 0 — render the active side with D/K prefix.
const vatLines = (preview.vat_lines as
| {
account_number?: string
debit_amount?: number
credit_amount?: number
description?: string
}[]
| undefined) ?? []
return (
<div className="rounded-md border border-border bg-muted/30 px-3 py-2 text-xs space-y-1.5">
<div className="flex items-baseline gap-3">
<span className="w-20 shrink-0 text-muted-foreground text-[10px] uppercase tracking-wider">
Kategori
</span>
<span className="flex-1 min-w-0 leading-5 text-foreground">
{prettyCategory(category)}
</span>
</div>
{debit && credit && amount != null && (
<Row
label="Bokning"
value={
<span className="tabular-nums">
<span className="text-muted-foreground">D </span>
<strong className="font-medium">{debit}</strong>
<span className="text-muted-foreground"> / K </span>
<strong className="font-medium">{credit}</strong>
<span className="ml-2">{formatCurrency(amount, currency)}</span>
</span>
}
/>
)}
{vatLines.length > 0 && (
<div className="pt-1 mt-1 border-t border-border">
{vatLines.map((v, i) => {
const debit = typeof v.debit_amount === 'number' ? v.debit_amount : 0
const credit = typeof v.credit_amount === 'number' ? v.credit_amount : 0
const side: 'D' | 'K' | null = debit > 0 ? 'D' : credit > 0 ? 'K' : null
const amount = side === 'D' ? debit : side === 'K' ? credit : 0
return (
<Row
key={i}
label={i === 0 ? 'Moms' : ''}
value={
<span className="tabular-nums">
{side && <span className="text-muted-foreground">{side} </span>}
<span className="text-muted-foreground">{v.account_number ?? ''} </span>
{formatCurrency(amount, currency)}
</span>
}
/>
)
})}
</div>
)}
</div>
)
}
// Pull a human message out of an API error body that may be either a bare
// string ({ error: "…" }) or the structured envelope ({ error: { message } }).
function errorText(error: string | { message?: string } | undefined): string | null {
if (typeof error === 'string') return error
if (error && typeof error === 'object' && typeof error.message === 'string') return error.message
return null
}
function prettyCategory(value: string | undefined): string {
if (!value) return '(saknas)'
return CATEGORY_OPTIONS.find((o) => o.value === value)?.label ?? value
}
function CreateInvoicePreview({ preview }: { preview: Record<string, unknown> }) {
const customer = preview.customer_name as string | undefined
const subtotal = preview.subtotal as number | undefined
const vatAmount = preview.vat_amount as number | undefined
const total = preview.total as number | undefined
const currency = (preview.currency as string | undefined) ?? 'SEK'
const items =
(preview.items as { description?: string; line_total?: number }[] | undefined) ?? []
return (
<div className="rounded-md border border-border bg-muted/30 px-3 py-2 text-xs space-y-1.5">
{customer && (
<Row label="Kund" value={<span className="text-foreground">{customer}</span>} />
)}
{items.length > 0 && (
<div className="space-y-0.5 max-h-32 overflow-y-auto">
{items.slice(0, 5).map((it, i) => (
<Row
key={i}
label={i === 0 ? 'Rader' : ''}
value={
<span className="tabular-nums truncate">
<span className="text-muted-foreground">
{it.description ?? '(rad)'}
</span>
{it.line_total != null && (
<span className="ml-2">{formatCurrency(it.line_total, currency)}</span>
)}
</span>
}
/>
))}
{items.length > 5 && (
<p className="pl-[88px] text-muted-foreground/70">
+ {items.length - 5} ytterligare rader
</p>
)}
</div>
)}
<div className="pt-1 mt-1 border-t border-border space-y-0.5">
{subtotal != null && (
<Row
label="Netto"
value={
<span className="tabular-nums">{formatCurrency(subtotal, currency)}</span>
}
/>
)}
{vatAmount != null && (
<Row
label="Moms"
value={
<span className="tabular-nums">{formatCurrency(vatAmount, currency)}</span>
}
/>
)}
{total != null && (
<Row
label="Totalt"
value={
<span className="tabular-nums font-medium text-foreground">
{formatCurrency(total, currency)}
</span>
}
/>
)}
</div>
</div>
)
}
function VoucherPreview({ preview }: { preview: Record<string, unknown> }) {
const lines = (preview.lines as { account?: string; debit?: number; credit?: number; description?: string }[] | undefined) ?? []
const date = preview.date as string | undefined
const description = preview.description as string | undefined
if (lines.length === 0) return <GenericPreview preview={preview} />
return (
<div className="rounded-md border border-border bg-muted/30 px-3 py-2 text-xs space-y-1.5">
{date && <Row label="Datum" value={<span className="tabular-nums">{date}</span>} />}
{description && (
<Row label="Notering" value={<span className="text-foreground">{description}</span>} />
)}
<div className="pt-1 mt-1 border-t border-border space-y-0.5">
{lines.map((l, i) => (
<Row
key={i}
label={i === 0 ? 'Rader' : ''}
value={
<span className="tabular-nums">
<strong className="font-medium">{l.account ?? '?'}</strong>
<span className="text-muted-foreground"> · </span>
{l.debit != null && l.debit !== 0 && <span>D {formatCurrency(l.debit)}</span>}
{l.credit != null && l.credit !== 0 && <span>K {formatCurrency(l.credit)}</span>}
{l.description && (
<span className="text-muted-foreground/70 ml-2 truncate">{l.description}</span>
)}
</span>
}
/>
))}
</div>
</div>
)
}
// Fallback: render the top-level key/value pairs from any preview object.
// Strips internal-looking keys, formats numbers tabular, truncates long
// strings. Caps at 8 rows to keep the card compact.
function GenericPreview({ preview }: { preview: Record<string, unknown> }) {
const rows: { key: string; value: string }[] = []
for (const [k, v] of Object.entries(preview)) {
if (rows.length >= 8) break
if (k.startsWith('_') || k === 'period_status') continue
if (v == null) continue
if (typeof v === 'object') continue
rows.push({ key: prettyKey(k), value: String(v) })
}
if (rows.length === 0) return null
return (
<div className="rounded-md border border-border bg-muted/30 px-3 py-2 text-xs space-y-1">
{rows.map((r) => (
<Row key={r.key} label={r.key} value={<span className="tabular-nums">{r.value}</span>} />
))}
</div>
)
}
function Row({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex gap-3 items-baseline">
<span className="w-20 shrink-0 text-muted-foreground text-[10px] uppercase tracking-wider">
{label}
</span>
<span className="flex-1 min-w-0 leading-5">{value}</span>
</div>
)
}
function prettyKey(k: string): string {
// 'customer_name' → 'Customer name' → keep Swedish-leaning by capitalising
// first letter only; lots of preview keys are already short.
const spaced = k.replace(/_/g, ' ')
return spaced.charAt(0).toUpperCase() + spaced.slice(1)
}
function PeriodBadge({ status }: { status: PeriodStatus }) {
if (status.status === 'open') {
return (
<span className="inline-flex items-center gap-1 text-[10px] uppercase tracking-wider text-success">
<ShieldCheck className="h-3 w-3" /> Period öppen
</span>
)
}
if (status.status === 'locked') {
return (
<span className="inline-flex items-center gap-1 text-[10px] uppercase tracking-wider text-warning">
<Lock className="h-3 w-3" /> Period låst
{status.lock_date ? <span className="tabular-nums">· {status.lock_date}</span> : null}
</span>
)
}
return (
<span className="inline-flex items-center gap-1 text-[10px] uppercase tracking-wider text-destructive">
<Lock className="h-3 w-3" /> Period stängd
</span>
)
}
function translateRisk(risk: 'low' | 'medium' | 'high'): string {
if (risk === 'low') return 'låg'
if (risk === 'medium') return 'medel'
return 'hög'
}
+66
View File
@@ -0,0 +1,66 @@
'use client'
import { useMemo } from 'react'
import Link from 'next/link'
import { ArrowLeft } from 'lucide-react'
import AgentChat, { normalizeStoredMessages } from './AgentChat'
import AgentAvatar from './AgentAvatar'
import { useAgentSheet } from './AgentSheetProvider'
interface Props {
conversationId: string
intentId: string
contextRef: string | null
title: string
rawMessages: { role: string; content: unknown; hidden?: boolean }[]
}
// Full-page conversation view. Wraps AgentChat with a header that shows the
// title (or intent label). AgentChat handles the streaming + input + render.
export default function ChatConversationView({
conversationId,
intentId,
contextRef,
title,
rawMessages,
}: Props) {
const initialMessages = useMemo(() => normalizeStoredMessages(rawMessages), [rawMessages])
const { identity } = useAgentSheet()
return (
<>
<header className="flex items-center gap-3 border-b border-border px-5 py-4 shrink-0">
{/* Mobile-only back-to-list arrow. On desktop the sidebar is always
visible so a back button would be redundant. */}
<Link
href="/chat"
className="md:hidden inline-flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors -ml-1"
aria-label="Tillbaka till konversationer"
>
<ArrowLeft className="h-4 w-4" />
</Link>
<AgentAvatar
avatarId={identity.avatarId}
size="sm"
alt={identity.displayName ?? 'Assistent'}
/>
<div className="min-w-0">
<h1 className="font-display text-lg tracking-tight truncate">{title}</h1>
{contextRef && (
<p className="text-xs text-muted-foreground truncate">{contextRef}</p>
)}
</div>
</header>
<div className="flex-1 min-h-0">
<AgentChat
intentId={intentId}
contextRef={contextRef ?? undefined}
initialConversationId={conversationId}
initialMessages={initialMessages}
scrollerClassName="px-6 py-8"
/>
</div>
</>
)
}
+66
View File
@@ -0,0 +1,66 @@
'use client'
import { ArrowUpRight } from 'lucide-react'
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { useAgentSheet } from './AgentSheetProvider'
import AgentAvatar from './AgentAvatar'
// Tiny client component for /chat empty state. Reads the agent identity from
// the provider so it can show the user's chosen avatar + name above the
// "starta en konversation" CTA.
//
// The three suggestion chips below the headline give users a one-click way
// in. They navigate to /chat/new?intent=…&prompt=… which mounts AgentChat
// inline and swaps to /chat/[id] once the conversation is created — so the
// flow stays full-screen instead of opening a slide-in sheet.
const SUGGESTIONS: { label: string; prompt: string }[] = [
{
label: 'Vad är min största utgiftspost den här månaden?',
prompt: 'Vad är min största utgiftspost den här månaden? Visa de fem största kategorierna.',
},
{
label: 'Hur ser min momsrapport ut för senaste perioden?',
prompt: 'Hur ser min momsrapport ut för den senaste perioden? Vad blir moms att betala eller få tillbaka, och ser något ovanligt ut?',
},
{
label: 'När är min nästa skatte- eller momsdeadline?',
prompt: 'När är min nästa skatte- eller momsdeadline, och vad behöver jag göra inför den?',
},
]
export default function ChatEmptyState() {
const { identity } = useAgentSheet()
const name = identity.displayName?.trim() || 'din assistent'
// Hidden on mobile — the sidebar IS the page when no conversation is open.
// On desktop, fills the right pane with a centered prompt.
return (
<div className="hidden md:flex flex-1 flex-col items-center justify-center px-6 py-12 text-center">
<AgentAvatar avatarId={identity.avatarId} size="lg" alt={name} className="mb-5" />
<h1 className="font-display text-2xl tracking-tight mb-2">Fråga {name}</h1>
<p className="text-muted-foreground max-w-md mb-6">
Välj en konversation till vänster, eller starta en ny om något har dykt upp.
</p>
<div className="flex flex-col gap-2 w-full max-w-md mb-6">
{SUGGESTIONS.map((s) => (
<Link
key={s.label}
href={`/chat/new?intent=general.help&prompt=${encodeURIComponent(s.prompt)}`}
className="group flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3 text-left text-sm transition-colors hover:border-foreground/30 hover:bg-secondary/30"
>
<span className="flex-1 text-muted-foreground group-hover:text-foreground transition-colors">
{s.label}
</span>
<ArrowUpRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground/60 group-hover:text-foreground transition-colors" />
</Link>
))}
</div>
<Button size="lg" variant="outline" asChild>
<Link href="/chat/new?intent=general.help">Eller skriv din egen fråga</Link>
</Button>
</div>
)
}
+57
View File
@@ -0,0 +1,57 @@
'use client'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
import AgentChat from './AgentChat'
import AgentAvatar from './AgentAvatar'
import { useAgentSheet } from './AgentSheetProvider'
// Phase C entry surface. Lands here from ReviewCard's "kör" after Phase B
// verify succeeds. Renders AgentChat in fresh-start mode — no
// initialConversationId, no initialMessages — so the auto-fire effect
// kicks the first invoke. As soon as /api/agent/invoke emits the
// conversation event, the URL swaps to /chat/[id] so reload / share /
// browser-back all work like any other conversation.
//
// Plan refs: §7 Phase C.
export default function ChatIntakeStarter() {
const router = useRouter()
const { identity } = useAgentSheet()
const agentName = identity.displayName?.trim() || 'Din assistent'
// Lock the swap to the first id we see — defensive guard against the
// AgentChat callback firing twice during React 19 Strict Mode reruns.
const [swapped, setSwapped] = useState(false)
return (
<>
<header className="flex items-center gap-3 border-b border-border px-5 py-4 shrink-0">
<AgentAvatar avatarId={identity.avatarId} size="sm" alt={agentName} />
<div className="min-w-0">
<h1 className="font-display text-lg tracking-tight truncate">{agentName} är redo</h1>
<p className="text-xs text-muted-foreground">
Några frågor för att lära känna din verksamhet svara i din egen takt, du kan avsluta när du vill.
</p>
</div>
</header>
<div className="flex-1 min-h-0">
<AgentChat
intentId="onboarding.intake"
initialMessages={[]}
initialConversationId={null}
onFirstTurnComplete={(id) => {
// Wait for the greeting to finish streaming AND persist before
// swapping the URL. Swapping on the early `conversation` event
// unmounts AgentChat mid-stream, so the greeting is never saved
// and /chat/[id] hydrates empty — the bug where the chat lands
// blank and only shows the intro on a later visit.
if (swapped) return
setSwapped(true)
router.replace(`/chat/${id}`)
}}
scrollerClassName="px-6 py-8"
/>
</div>
</>
)
}
+54
View File
@@ -0,0 +1,54 @@
'use client'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
import AgentChat from './AgentChat'
import AgentAvatar from './AgentAvatar'
import { useAgentSheet } from './AgentSheetProvider'
// Inline starter used by suggestion chips and ⌘K. Mirrors ChatIntakeStarter
// but accepts any intent + seed so we don't fork the intake-specific
// onboarding path. When AgentChat emits the new conversation_id, the URL
// is swapped to /chat/[id] so reload / share / browser-back all work.
export default function ChatNewStarter({
intentId,
seedUserMessage,
}: {
intentId: string
seedUserMessage?: string
}) {
const router = useRouter()
const { identity } = useAgentSheet()
const agentName = identity.displayName?.trim() || 'Din assistent'
const [swapped, setSwapped] = useState(false)
return (
<>
<header className="flex items-center gap-3 border-b border-border px-5 py-4 shrink-0">
<AgentAvatar avatarId={identity.avatarId} size="sm" alt={agentName} />
<div className="min-w-0">
<h1 className="font-display text-lg tracking-tight truncate">{agentName}</h1>
<p className="text-xs text-muted-foreground truncate">Ny konversation</p>
</div>
</header>
<div className="flex-1 min-h-0">
<AgentChat
intentId={intentId}
seedUserMessage={seedUserMessage}
initialMessages={[]}
initialConversationId={null}
onFirstTurnComplete={(id) => {
// Wait for the first turn to finish before swapping the URL —
// otherwise the unmount aborts the in-flight stream and
// /chat/[id] hydrates with only the user message.
if (swapped) return
setSwapped(true)
router.replace(`/chat/${id}`)
}}
scrollerClassName="px-6 py-8"
/>
</div>
</>
)
}
+347
View File
@@ -0,0 +1,347 @@
'use client'
import { useEffect, useMemo, useState, useTransition } from 'react'
import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
import { Pin, PinOff, Archive, Search, X, PanelLeftOpen, PanelLeftClose } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useAgentSheet } from './AgentSheetProvider'
import AgentAvatar from './AgentAvatar'
interface ConversationRow {
id: string
intent_id: string
context_ref: string | null
title: string | null
pinned: boolean
archived: boolean
last_message_at: string | null
last_message_preview: string | null
created_at: string
}
interface Props {
initialConversations: ConversationRow[]
}
// Time buckets for date grouping. Computed once per render against now().
// Mirrors the Idag / Igår / Denna vecka / Äldre pattern users know from
// Mail and iMessage.
type DateBucket = 'pinned' | 'today' | 'yesterday' | 'thisWeek' | 'older'
const BUCKET_LABELS: Record<DateBucket, string> = {
pinned: 'Fästade',
today: 'Idag',
yesterday: 'Igår',
thisWeek: 'Denna vecka',
older: 'Äldre',
}
function bucketFor(c: ConversationRow): DateBucket {
if (c.pinned) return 'pinned'
const when = c.last_message_at ?? c.created_at
if (!when) return 'older'
const t = new Date(when)
const now = new Date()
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const yesterdayStart = new Date(todayStart.getTime() - 24 * 60 * 60 * 1000)
const weekStart = new Date(todayStart.getTime() - 6 * 24 * 60 * 60 * 1000)
if (t >= todayStart) return 'today'
if (t >= yesterdayStart) return 'yesterday'
if (t >= weekStart) return 'thisWeek'
return 'older'
}
// Compact relative-time label shown to the right of each row. Locale-tuned
// to feel native in Swedish without going full date-fns.
function relativeTime(iso: string | null | undefined): string {
if (!iso) return ''
const t = new Date(iso).getTime()
const now = Date.now()
const diffMin = Math.round((now - t) / 60000)
if (diffMin < 1) return 'nu'
if (diffMin < 60) return `${diffMin} min`
const diffHr = Math.round(diffMin / 60)
if (diffHr < 24) return `${diffHr} h`
const diffDay = Math.round(diffHr / 24)
if (diffDay < 7) return `${diffDay} d`
return new Date(iso).toLocaleDateString('sv-SE', { month: 'short', day: 'numeric' })
}
export default function ChatSidebar({ initialConversations }: Props) {
const router = useRouter()
const pathname = usePathname()
const { openAgentSheet, identity } = useAgentSheet()
const agentName = identity.displayName?.trim() || null
const [conversations, setConversations] = useState<ConversationRow[]>(initialConversations)
const [query, setQuery] = useState('')
const [, startTransition] = useTransition()
// Collapsed by default; persisted across reloads so power users keep
// their preference. Hidden behind a thin rail when collapsed so the
// conversation pane runs nearly edge-to-edge.
const [collapsed, setCollapsed] = useState(true)
useEffect(() => {
const stored = localStorage.getItem('gnubok:chat-sidebar-collapsed')
if (stored === 'false') setCollapsed(false)
}, [])
const toggleCollapsed = () => {
setCollapsed(c => {
const next = !c
try { localStorage.setItem('gnubok:chat-sidebar-collapsed', next ? 'true' : 'false') } catch {}
return next
})
}
const activeId = pathname?.startsWith('/chat/') ? pathname.split('/')[2] : null
const isConversationOpen = !!activeId
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
if (!q) return conversations
return conversations.filter((c) => {
return (
(c.title ?? '').toLowerCase().includes(q) ||
(c.last_message_preview ?? '').toLowerCase().includes(q) ||
(c.context_ref ?? '').toLowerCase().includes(q) ||
c.intent_id.toLowerCase().includes(q)
)
})
}, [conversations, query])
// Group filtered into ordered buckets, preserving the sort order already
// applied server-side (pinned first, then last_message_at desc).
const grouped = useMemo(() => {
const buckets: Record<DateBucket, ConversationRow[]> = {
pinned: [],
today: [],
yesterday: [],
thisWeek: [],
older: [],
}
for (const c of filtered) buckets[bucketFor(c)].push(c)
const order: DateBucket[] = ['pinned', 'today', 'yesterday', 'thisWeek', 'older']
return order
.map((b) => ({ bucket: b, rows: buckets[b] }))
.filter((g) => g.rows.length > 0)
}, [filtered])
async function togglePin(id: string, current: boolean) {
setConversations((prev) =>
prev.map((c) => (c.id === id ? { ...c, pinned: !current } : c)),
)
await fetch(`/api/agent/conversations/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pinned: !current }),
})
}
async function archive(id: string) {
setConversations((prev) => prev.filter((c) => c.id !== id))
await fetch(`/api/agent/conversations/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ archived: true }),
})
if (activeId === id) startTransition(() => router.push('/chat'))
}
// Collapsed rail (desktop only). Mobile keeps the existing behavior where
// the sidebar IS the page when no conversation is open, so the rail is
// hidden below md. On desktop the rail keeps a thin column with toggle
// + new-chat buttons so the conversation pane runs near-edge-to-edge.
const railAside = collapsed ? (
<aside
className="hidden md:flex md:w-12 flex-col items-center border-r border-border bg-card/40 shrink-0 py-3 gap-2"
aria-label="Konversationer (hopfälld)"
>
<button
type="button"
onClick={toggleCollapsed}
aria-label="Visa konversationer"
title="Visa konversationer"
className="inline-flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors"
>
<PanelLeftOpen className="h-4 w-4" />
</button>
<div className="h-px w-6 bg-border" />
<button
type="button"
onClick={() => openAgentSheet({ intentId: 'general.help' })}
aria-label="Ny konversation"
title="Ny konversation"
className="inline-flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors text-lg"
>
+
</button>
</aside>
) : null
return (
<>
{railAside}
<aside
className={cn(
'flex-col border-r border-border bg-card/40 shrink-0',
// Mobile: sidebar IS the page when no conversation; hidden otherwise.
isConversationOpen ? 'hidden' : 'flex w-full',
// Desktop: hidden if collapsed (rail takes its place); else 320px.
collapsed ? 'md:hidden' : 'md:flex md:w-80',
)}
>
<div className="border-b border-border px-5 py-4 space-y-3">
<div className="flex items-center gap-2">
<AgentAvatar avatarId={identity.avatarId} size="sm" alt={agentName ?? 'Assistent'} />
<div className="flex-1 min-w-0">
<h2 className="font-display text-base tracking-tight truncate">
{agentName ?? 'Din assistent'}
</h2>
<p className="text-[11px] text-muted-foreground">Konversationer</p>
</div>
<button
onClick={toggleCollapsed}
aria-label="Dölj konversationer"
title="Dölj konversationer"
className="hidden md:inline-flex h-8 w-8 items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors"
>
<PanelLeftClose className="h-4 w-4" />
</button>
<button
onClick={() => openAgentSheet({ intentId: 'general.help' })}
className="text-xs uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors"
>
+ Ny
</button>
</div>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Sök…"
className="w-full rounded-md border border-border bg-background pl-8 pr-7 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
{query.length > 0 && (
<button
type="button"
onClick={() => setQuery('')}
aria-label="Rensa sökning"
className="absolute right-1 top-1/2 -translate-y-1/2 inline-flex h-8 w-8 items-center justify-center rounded text-muted-foreground hover:bg-secondary hover:text-foreground"
>
<X className="h-3 w-3" />
</button>
)}
</div>
</div>
<div className="flex-1 overflow-y-auto">
{grouped.length === 0 ? (
<div className="p-6 text-sm text-muted-foreground">
{conversations.length === 0
? 'Inga konversationer ännu. Klicka på + Ny för att börja.'
: 'Inga träffar.'}
</div>
) : (
grouped.map(({ bucket, rows }) => (
<section key={bucket} className="py-2">
<p className="px-4 pb-1 text-[10px] uppercase tracking-wider text-muted-foreground">
{BUCKET_LABELS[bucket]}
</p>
<ul>
{rows.map((c) => (
<li key={c.id}>
<Link
href={`/chat/${c.id}`}
className={cn(
'group flex items-start gap-2 px-4 py-2 hover:bg-secondary/60 transition-colors border-l-2',
activeId === c.id
? 'bg-secondary/50 border-foreground'
: 'border-transparent',
)}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<p className="text-sm font-medium truncate flex-1 min-w-0">
{c.title ?? intentLabel(c.intent_id)}
</p>
<p className="text-[11px] text-muted-foreground tabular-nums shrink-0">
{relativeTime(c.last_message_at ?? c.created_at)}
</p>
</div>
<p className="text-xs text-muted-foreground line-clamp-1 mt-0.5">
{c.last_message_preview ?? intentLabel(c.intent_id)}
</p>
</div>
{/* Always-visible action icons. Touch-friendly, no
hover-only invisibility on mobile. */}
<div className="flex flex-col gap-1 shrink-0 -mr-1">
<button
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
void togglePin(c.id, c.pinned)
}}
title={c.pinned ? 'Avfäst' : 'Fäst'}
aria-label={c.pinned ? 'Avfäst konversation' : 'Fäst konversation'}
className={cn(
'inline-flex h-8 w-8 items-center justify-center rounded transition-colors',
c.pinned
? 'text-foreground'
: 'text-muted-foreground/50 hover:text-foreground hover:bg-secondary',
)}
>
{c.pinned ? (
<Pin className="h-3 w-3" fill="currentColor" />
) : (
<PinOff className="h-3 w-3" />
)}
</button>
<button
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
void archive(c.id)
}}
title="Arkivera"
aria-label="Arkivera konversation"
className="inline-flex h-8 w-8 items-center justify-center rounded text-muted-foreground/50 hover:text-foreground hover:bg-secondary transition-colors"
>
<Archive className="h-3 w-3" />
</button>
</div>
</Link>
</li>
))}
</ul>
</section>
))
)}
</div>
</aside>
</>
)
}
function intentLabel(intentId: string): string {
switch (intentId) {
case 'general.help':
return 'Fråga din assistent'
case 'transaction.categorization':
return 'Hjälp med transaktion'
case 'invoice.draft':
return 'Hjälp med faktura'
case 'supplier_invoice.review':
return 'Granska leverantörsfaktura'
case 'vat.review':
return 'Granska moms­deklaration'
case 'bokslut.step':
return 'Hjälp med bokslut'
case 'verifikation.draft':
return 'Hjälp med verifikation'
case 'kpi.explain':
return 'Förklara nyckeltal'
default:
return intentId
}
}
+42
View File
@@ -0,0 +1,42 @@
// Avatar registry for the specialized accountant agent.
//
// We use the dicebear "notionists" style — clean line illustrations that
// match the editorial monochrome brand without the cartoony feel of most
// avatar libraries. 8 hand-picked seeds give distinct faces without being
// overwhelming. The user picks one during Phase B review; the choice is
// persisted as agent_profiles.avatar_id.
//
// URLs are served by dicebear's free CDN. They're public SVGs derived from
// the seed only — no user data leaves gnubok. If we ever need fully offline
// generation, swap to @dicebear/core npm package and render server-side.
export interface AvatarOption {
id: string
label: string
url: string
}
// Build URL from seed. Public dicebear CDN. ?radius=50 rounds the bounding
// box; ?backgroundColor=transparent keeps the editorial paper-white feel.
function dicebearNotionists(seed: string): string {
return `https://api.dicebear.com/9.x/notionists/svg?seed=${encodeURIComponent(seed)}&radius=50&backgroundColor=f5f3ed`
}
// Eight neutral seeds — names chosen to produce visibly different faces.
// Labels are just for the picker tooltip; the user names the agent
// themselves in the adjacent text field.
export const AVATAR_OPTIONS: readonly AvatarOption[] = [
{ id: 'notionists-1', label: 'Linn', url: dicebearNotionists('linn-revisor-1') },
{ id: 'notionists-2', label: 'Erik', url: dicebearNotionists('erik-revisor-2') },
{ id: 'notionists-3', label: 'Maja', url: dicebearNotionists('maja-revisor-3') },
{ id: 'notionists-4', label: 'Anders', url: dicebearNotionists('anders-revisor-4') },
{ id: 'notionists-5', label: 'Karin', url: dicebearNotionists('karin-revisor-5') },
{ id: 'notionists-6', label: 'Johan', url: dicebearNotionists('johan-revisor-6') },
{ id: 'notionists-7', label: 'Eva', url: dicebearNotionists('eva-revisor-7') },
{ id: 'notionists-8', label: 'Per', url: dicebearNotionists('per-revisor-8') },
]
export function getAvatarUrl(avatarId: string | null | undefined): string | null {
if (!avatarId) return null
return AVATAR_OPTIONS.find((a) => a.id === avatarId)?.url ?? null
}
@@ -20,6 +20,9 @@ import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
interface Props {
onApply: (lines: FormLine[], description: string) => void
entityType?: EntityType
/** Prefill the "total amount" field when the caller already knows it (e.g.
* booking from an underlag with a known total). The user can still edit it. */
defaultAmount?: number
}
const SCOPE_ICONS = {
@@ -28,7 +31,7 @@ const SCOPE_ICONS = {
company: Building2,
} as const
export default function BookingTemplatePicker({ onApply, entityType }: Props) {
export default function BookingTemplatePicker({ onApply, entityType, defaultAmount }: Props) {
const { toast } = useToast()
const [open, setOpen] = useState(false)
const [templates, setTemplates] = useState<BookingTemplateLibrary[]>([])
@@ -38,6 +41,15 @@ export default function BookingTemplatePicker({ onApply, entityType }: Props) {
const [amount, setAmount] = useState('')
const [selectedId, setSelectedId] = useState<string | null>(null)
// Prefill the amount from the caller's known total each time the picker
// opens. Only when provided — callers without a known amount (e.g. the
// journal-entry form) keep the blank-then-type behaviour.
useEffect(() => {
if (open && defaultAmount != null && defaultAmount > 0) {
setAmount(String(Math.round(defaultAmount * 100) / 100))
}
}, [open, defaultAmount])
const fetchTemplates = useCallback(async (signal?: AbortSignal) => {
setIsLoading(true)
try {
+279
View File
@@ -0,0 +1,279 @@
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import {
Receipt,
ArrowLeftRight,
Users,
Wallet,
Building2,
BookOpen,
BarChart3,
Upload,
Package,
ClipboardCheck,
HandCoins,
Wand2,
Inbox,
TrendingUp,
Settings,
HelpCircle,
ArrowRight,
type LucideIcon,
} from 'lucide-react'
import { cn } from '@/lib/utils'
type Entry = {
id: string
label: string
hint?: string
icon: LucideIcon
href: string
keywords?: string
}
const ACTION_ENTRIES: Entry[] = [
{ id: 'new-invoice', label: 'Ny faktura', hint: 'Skapa & skicka faktura', icon: Receipt, href: '/invoices/new', keywords: 'fakturera ny invoice send create' },
{ id: 'book-transaction', label: 'Boka transaktion', hint: 'Gå till transaktionsinkorgen', icon: ArrowLeftRight, href: '/transactions', keywords: 'transaktion bokför kategorisera categorize' },
{ id: 'new-customer', label: 'Lägg till kund', icon: Users, href: '/customers', keywords: 'kund customer ny lägg till' },
{ id: 'new-supplier-invoice', label: 'Skapa leverantörsfaktura', icon: Wallet, href: '/supplier-invoices/new', keywords: 'leverantörsfaktura supplier invoice ny' },
{ id: 'reports', label: 'Visa resultaträkning', hint: 'Rapporter', icon: BarChart3, href: '/reports', keywords: 'rapport resultat balans report' },
]
const PAGE_ENTRIES: Entry[] = [
{ id: 'kunder', label: 'Kunder', icon: Users, href: '/customers' },
{ id: 'leverantörer', label: 'Leverantörer', icon: Building2, href: '/suppliers' },
{ id: 'leverantörsfakturor', label: 'Leverantörsfakturor', icon: Wallet, href: '/supplier-invoices' },
{ id: 'bokföring', label: 'Bokföring', icon: BookOpen, href: '/bookkeeping', keywords: 'verifikat journal ledger' },
{ id: 'anläggningstillgångar', label: 'Anläggningstillgångar', icon: Package, href: '/assets', keywords: 'tillgångar assets' },
{ id: 'rapporter', label: 'Rapporter', icon: BarChart3, href: '/reports' },
{ id: 'importera', label: 'Importera', icon: Upload, href: '/import' },
{ id: 'granskning', label: 'Granskning', icon: ClipboardCheck, href: '/pending', keywords: 'pending review' },
{ id: 'löner', label: 'Löner', icon: HandCoins, href: '/salary' },
{ id: 'anställda', label: 'Anställda', icon: Users, href: '/salary/employees' },
{ id: 'dokumentinkorg', label: 'Dokumentinkorg', icon: Inbox, href: '/e/general/invoice-inbox' },
{ id: 'nyckeltal', label: 'Nyckeltal', icon: TrendingUp, href: '/kpi' },
{ id: 'inställningar', label: 'Inställningar', icon: Settings, href: '/settings' },
{ id: 'hjälp', label: 'Hjälp', icon: HelpCircle, href: '/help' },
]
function matches(entry: Entry, q: string): boolean {
const hay = `${entry.label} ${entry.hint ?? ''} ${entry.keywords ?? ''}`.toLowerCase()
return q.split(/\s+/).filter(Boolean).every(t => hay.includes(t))
}
export default function CommandPalette() {
const router = useRouter()
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [activeIndex, setActiveIndex] = useState(0)
const inputRef = useRef<HTMLInputElement>(null)
function handleOpenChange(next: boolean) {
setOpen(next)
if (!next) {
setQuery('')
setActiveIndex(0)
}
}
// Global ⌘K / Ctrl+K
useEffect(() => {
function onKey(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault()
setOpen(prev => !prev)
}
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [])
useEffect(() => {
if (!open) return
const raf = requestAnimationFrame(() => inputRef.current?.focus())
return () => cancelAnimationFrame(raf)
}, [open])
const q = query.trim().toLowerCase()
const filteredActions = useMemo(
() => (q ? ACTION_ENTRIES.filter(e => matches(e, q)) : ACTION_ENTRIES),
[q],
)
const filteredPages = useMemo(
() => (q ? PAGE_ENTRIES.filter(e => matches(e, q)) : PAGE_ENTRIES.slice(0, 6)),
[q],
)
const annaFallback: Entry | null = q && filteredActions.length === 0 && filteredPages.length === 0
? {
id: 'anna-fallback',
label: `Fråga Anna: "${query.trim()}"`,
icon: Wand2,
href: `/chat?prompt=${encodeURIComponent(query.trim())}`,
}
: q
? {
id: 'anna-followup',
label: `Fråga Anna istället: "${query.trim()}"`,
icon: Wand2,
href: `/chat?prompt=${encodeURIComponent(query.trim())}`,
}
: null
const flatEntries: Entry[] = [
...(annaFallback && filteredActions.length === 0 && filteredPages.length === 0 ? [annaFallback] : []),
...filteredActions,
...filteredPages,
...(annaFallback && (filteredActions.length > 0 || filteredPages.length > 0) ? [annaFallback] : []),
]
function commit(entry: Entry) {
setOpen(false)
router.push(entry.href)
}
function onInputKey(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === 'ArrowDown') {
e.preventDefault()
setActiveIndex(i => Math.min(flatEntries.length - 1, i + 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setActiveIndex(i => Math.max(0, i - 1))
} else if (e.key === 'Enter') {
e.preventDefault()
const target = flatEntries[activeIndex]
if (target) commit(target)
}
}
return (
<DialogPrimitive.Root open={open} onOpenChange={handleOpenChange}>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay
className="fixed inset-0 z-50 bg-black/40 backdrop-blur-[2px] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
/>
<DialogPrimitive.Content
aria-label="Snabbkommandon"
className="fixed left-[50%] top-[20%] z-50 w-[calc(100vw-2rem)] max-w-xl translate-x-[-50%] rounded-xl border border-border bg-card data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
>
<DialogPrimitive.Title className="sr-only">Snabbkommandon</DialogPrimitive.Title>
<DialogPrimitive.Description className="sr-only">
Sök efter sidor och åtgärder, eller fråga Anna.
</DialogPrimitive.Description>
<div className="px-4 py-3 border-b border-border">
<input
ref={inputRef}
value={query}
onChange={e => { setQuery(e.target.value); setActiveIndex(0) }}
onKeyDown={onInputKey}
placeholder="Sök eller skriv vad du vill göra…"
aria-label="Sök eller skriv vad du vill göra"
className="w-full bg-transparent text-base placeholder:text-muted-foreground outline-none"
/>
</div>
<div className="max-h-[60vh] overflow-y-auto py-1.5" role="listbox">
{filteredActions.length > 0 && (
<Section title="Åtgärder">
{filteredActions.map((entry) => {
const idx = flatEntries.indexOf(entry)
return (
<Row
key={entry.id}
entry={entry}
active={idx === activeIndex}
onSelect={() => commit(entry)}
onHover={() => setActiveIndex(idx)}
/>
)
})}
</Section>
)}
{filteredPages.length > 0 && (
<Section title="Sidor">
{filteredPages.map((entry) => {
const idx = flatEntries.indexOf(entry)
return (
<Row
key={entry.id}
entry={entry}
active={idx === activeIndex}
onSelect={() => commit(entry)}
onHover={() => setActiveIndex(idx)}
/>
)
})}
</Section>
)}
{annaFallback && (
<Section title="Anna">
<Row
entry={annaFallback}
active={flatEntries.indexOf(annaFallback) === activeIndex}
onSelect={() => commit(annaFallback)}
onHover={() => setActiveIndex(flatEntries.indexOf(annaFallback))}
/>
</Section>
)}
{flatEntries.length === 0 && (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
Inget hittades. Tryck Enter eller börja om.
</div>
)}
</div>
<div className="px-4 py-2 border-t border-border flex items-center justify-between text-[11px] text-muted-foreground">
<span> navigera · Enter välj · Esc stäng</span>
<span className="font-mono">K</span>
</div>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
)
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="mb-1.5 last:mb-0">
<p className="px-4 pt-2 pb-1 text-[10px] font-medium uppercase tracking-[0.08em] text-muted-foreground">{title}</p>
<div>{children}</div>
</div>
)
}
function Row({
entry,
active,
onSelect,
onHover,
}: {
entry: Entry
active: boolean
onSelect: () => void
onHover: () => void
}) {
const Icon = entry.icon
return (
<button
type="button"
role="option"
aria-selected={active}
onMouseEnter={onHover}
onFocus={onHover}
onClick={onSelect}
className={cn(
'w-full text-left flex items-center gap-3 px-4 py-2 text-sm transition-colors',
active ? 'bg-secondary text-foreground' : 'text-foreground hover:bg-secondary/60',
)}
>
<Icon className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<span className="flex-1 truncate">{entry.label}</span>
{entry.hint && <span className="text-xs text-muted-foreground">{entry.hint}</span>}
{active && <ArrowRight className="h-3.5 w-3.5 text-muted-foreground" />}
</button>
)
}
+38
View File
@@ -0,0 +1,38 @@
import Link from 'next/link'
import { MessageCircle, ArrowRight } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
interface Props {
companyName: string
}
// Renders above the dashboard when the active company has no verified
// agent_profile yet. Mounted from the dashboard server component which
// already does the existence check, so this component itself doesn't fetch.
//
// Single CTA, no dismiss — building the agent is part of onboarding and
// once verified it disappears on its own.
export default function AgentSetupBanner({ companyName }: Props) {
return (
<Link
href="/onboarding/agent"
className="group block rounded-lg border border-border bg-card hover:bg-secondary/60 transition-colors duration-150 mb-8"
>
<div className="flex items-center gap-4 p-5">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-foreground text-background shrink-0">
<MessageCircle className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="font-display text-lg tracking-tight">Bygg din bokföringsassistent</p>
<Badge variant="secondary" className="uppercase tracking-wider">Beta</Badge>
</div>
<p className="text-sm text-muted-foreground mt-1">
Skräddarsy en hjälp som kan ditt företag laddas under en halv minut för {companyName}.
</p>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors shrink-0" />
</div>
</Link>
)
}
+131 -3
View File
@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { cn, formatCurrency } from '@/lib/utils'
import { UpcomingDeadlinesWidget } from '@/components/deadlines/UpcomingDeadlinesWidget'
import { TaxTodoWidget } from '@/components/deadlines/TaxTodoWidget'
@@ -17,8 +18,11 @@ import {
CheckCircle2,
FileWarning,
Clock,
ArrowRight,
MessageCircle,
} from 'lucide-react'
import type { Deadline, ReceiptQueueSummary, OnboardingProgress } from '@/types'
import { getBranding } from '@/lib/branding/service'
const setupFreshStartKey = (companyId: string) => `erp_setup_fresh_start:${companyId}`
@@ -42,9 +46,16 @@ interface DashboardContentProps {
staleUncategorizedCount: number
}
onboardingProgress?: OnboardingProgress
/**
* False until the company has a verified agent_profile. When false the hero
* slot shows a build-assistant prompt instead of the next-best-action card,
* so existing/migrated users are nudged to build the assistant without a
* full-screen onboarding takeover.
*/
agentBuilt?: boolean
}
export default function DashboardContent({ companyId, summary, onboardingProgress }: DashboardContentProps) {
export default function DashboardContent({ companyId, summary, onboardingProgress, agentBuilt = true }: DashboardContentProps) {
const [showAllAlerts, setShowAllAlerts] = useState(false)
const t = useTranslations('dashboard')
@@ -226,8 +237,125 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
: 0
const todoCount = summary.uncategorizedCount + summary.overdueInvoicesCount + pendingReceiptsCount + passedDeadlinesCount
const slim = getBranding().navDensity === 'slim'
// Pick the single most-urgent next action so the launchpad surfaces one
// unambiguous CTA. Order matches the friction we actually want to remove
// first: stale → overdue → uncategorized → unpaid → all clear.
const nextBestAction = (() => {
if (summary.staleUncategorizedCount > 0) {
return {
href: '/transactions',
title: 'Gamla transaktioner väntar',
body: `${summary.staleUncategorizedCount} transaktion${summary.staleUncategorizedCount === 1 ? '' : 'er'} äldre än 14 dagar saknar bokföring.`,
cta: 'Bokför nu',
tone: 'destructive' as const,
icon: Clock,
}
}
if (summary.overdueInvoicesCount > 0) {
return {
href: '/invoices?status=unpaid',
title: 'Förfallna fakturor',
body: `${summary.overdueInvoicesCount} st · ${formatCurrency(summary.unpaidInvoicesTotal)}`,
cta: 'Gå till fakturor',
tone: 'destructive' as const,
icon: Receipt,
}
}
if (summary.uncategorizedCount > 0) {
return {
href: '/transactions',
title: 'Transaktioner att bokföra',
body: `${summary.uncategorizedCount} obokförd${summary.uncategorizedCount === 1 ? '' : 'a'} transaktion${summary.uncategorizedCount === 1 ? '' : 'er'}.`,
cta: 'Bokför nu',
tone: 'primary' as const,
icon: ArrowLeftRight,
}
}
if (summary.unpaidInvoicesCount > 0) {
return {
href: '/invoices?status=unpaid',
title: 'Obetalda fakturor',
body: `${summary.unpaidInvoicesCount} st · ${formatCurrency(summary.unpaidInvoicesTotal)}`,
cta: 'Visa fakturor',
tone: 'primary' as const,
icon: Receipt,
}
}
return {
href: '/invoices/new',
title: 'Allt är ikapp',
body: 'Inga obokförda transaktioner och inga obetalda fakturor. Skicka nästa faktura?',
cta: 'Skapa faktura',
tone: 'neutral' as const,
icon: CheckCircle2,
}
})()
return (
<div className="stagger-enter space-y-8">
{!agentBuilt ? (
/* Build-assistant hero shown until the company has a verified
agent_profile. Takes the hero slot so existing/migrated users get a
clear prompt instead of a full-screen onboarding takeover. */
<section>
<Link href="/onboarding/agent" className="block group">
<Card className="transition-colors hover:border-primary/50">
<CardContent className="p-6 flex items-center gap-5">
<div className="flex-shrink-0 h-10 w-10 rounded-lg flex items-center justify-center bg-foreground text-background">
<MessageCircle className="h-5 w-5" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="font-display text-xl leading-tight">Bygg din bokföringsassistent</p>
<Badge variant="secondary" className="uppercase tracking-wider">Beta</Badge>
</div>
<p className="text-sm text-muted-foreground mt-1">
Några frågor om din verksamhet kalibrerar en assistent som föreslår bokföring åt dig.
</p>
</div>
<div className="hidden sm:flex items-center gap-1.5 text-sm font-medium text-foreground group-hover:translate-x-0.5 transition-transform">
<span>Kom igång</span>
<ArrowRight className="h-4 w-4" />
</div>
</CardContent>
</Card>
</Link>
</section>
) : slim ? (
/* Next best action — single hero card */
<section>
<Link href={nextBestAction.href} className="block group">
<Card className={cn(
'transition-colors',
nextBestAction.tone === 'destructive' && 'border-destructive/30 hover:bg-destructive/[0.03]',
nextBestAction.tone === 'primary' && 'hover:border-primary/50',
nextBestAction.tone === 'neutral' && 'hover:border-primary/30',
)}>
<CardContent className="p-6 flex items-center gap-5">
<div className={cn(
'flex-shrink-0 h-10 w-10 rounded-lg flex items-center justify-center',
nextBestAction.tone === 'destructive' && 'bg-destructive/10 text-destructive',
nextBestAction.tone === 'primary' && 'bg-secondary text-foreground',
nextBestAction.tone === 'neutral' && 'bg-secondary text-foreground',
)}>
<nextBestAction.icon className="h-5 w-5" />
</div>
<div className="flex-1 min-w-0">
<p className="font-display text-xl leading-tight">{nextBestAction.title}</p>
<p className="text-sm text-muted-foreground mt-1">{nextBestAction.body}</p>
</div>
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground group-hover:translate-x-0.5 transition-transform">
<span>{nextBestAction.cta}</span>
<ArrowRight className="h-4 w-4" />
</div>
</CardContent>
</Card>
</Link>
</section>
) : null}
{/* Key metrics — 4 compact cards */}
<section>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
@@ -345,8 +473,8 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
</div>
</section>
{/* Alerts */}
{alertItems.length > 0 && (
{/* Att hantera — hidden in slim mode; the hero card already surfaces the top action */}
{!slim && alertItems.length > 0 && (
<section id="alerts-section">
<h2 className="font-display text-lg font-medium mb-4">{t('alerts_title')}</h2>
<div id="alerts-list" className="grid gap-4 md:grid-cols-2">
+453 -296
View File
@@ -9,6 +9,7 @@ import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
LayoutDashboard,
Home,
Receipt,
Users,
ArrowLeftRight,
@@ -28,12 +29,24 @@ import {
ClipboardCheck,
HandCoins,
Package,
ChevronsUpDown,
Sparkles,
} from 'lucide-react'
import { getBranding } from '@/lib/branding/service'
import { ENABLED_EXTENSION_IDS as _ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import { resolveIcon } from '@/lib/extensions/icon-resolver'
import { SupportLink } from '@/components/ui/support-link'
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu'
import CompanySwitcher from '@/components/dashboard/CompanySwitcher'
import AgentAvatar from '@/components/agent/AgentAvatar'
import { useAgentSheet } from '@/components/agent/AgentSheetProvider'
import { useCompany } from '@/contexts/CompanyContext'
import type { EntityType } from '@/types'
@@ -52,10 +65,17 @@ interface DashboardNavProps {
pendingOperationsCount?: number
isSandbox?: boolean
extensionNavItems?: ExtensionNavItem[]
// Signed-in user's full name + email — drives the bottom-left account
// popover trigger so the user can see WHO they're logged in as,
// distinct from the active COMPANY shown by CompanySwitcher up top.
userName?: string | null
userEmail?: string | null
}
type NavLabelKey =
| 'dashboard'
| 'home'
| 'assistant'
| 'kpi'
| 'invoice_inbox'
| 'invoices'
@@ -73,7 +93,17 @@ type NavLabelKey =
| 'help'
| 'settings'
type GroupKey = 'main' | 'försäljning' | 'inköp' | 'redovisning' | 'personal' | 'övrigt'
// New nav layout (May 2026):
// top-of-sidebar — CompanySwitcher (active company / org context).
// top section — flat, no dropdown: Hem (agent), Underlag,
// Transaktioner, Granskning.
// four dropdowns — Försäljning, Inköp, Redovisning, Personal.
// bottom-left popover — signed-in user's name + initial, opens upward
// to Inställningar, Hjälp, Support, Logga ut.
// Help + Settings are NOT in `navItems` anymore; they live in the account
// popover. KPI moved from main to redovisning. Pending stays visible at all
// times — the inline badge carries the count.
type GroupKey = 'top' | 'försäljning' | 'inköp' | 'redovisning' | 'personal'
interface NavItem {
href: string
@@ -88,23 +118,27 @@ interface NavItem {
}
const navItems: NavItem[] = [
{ href: '/', labelKey: 'dashboard', icon: LayoutDashboard, group: 'main' },
{ href: '/kpi', labelKey: 'kpi', icon: TrendingUp, group: 'main' },
{ href: '/e/general/invoice-inbox', labelKey: 'invoice_inbox', icon: Inbox, group: 'main', betaBadge: true },
// Top section — flat list, always visible, no header
{ href: '/', labelKey: 'home', icon: Home, group: 'top' },
{ href: '/chat', labelKey: 'assistant', icon: Sparkles, group: 'top' },
{ href: '/e/general/invoice-inbox', labelKey: 'invoice_inbox', icon: Inbox, group: 'top' },
{ href: '/transactions', labelKey: 'transactions', icon: ArrowLeftRight, group: 'top' },
{ href: '/pending', labelKey: 'review', icon: ClipboardCheck, group: 'top' },
// Försäljning dropdown
{ href: '/invoices', labelKey: 'invoices', icon: Receipt, group: 'försäljning' },
{ href: '/customers', labelKey: 'customers', icon: Users, group: 'försäljning' },
// Inköp dropdown
{ href: '/supplier-invoices', labelKey: 'supplier_invoices', icon: Wallet, group: 'inköp' },
{ href: '/suppliers', labelKey: 'suppliers', icon: Building2, group: 'inköp', hidden: true },
{ href: '/pending', labelKey: 'review', icon: ClipboardCheck, group: 'redovisning' },
{ href: '/transactions', labelKey: 'transactions', icon: ArrowLeftRight, group: 'redovisning' },
{ href: '/suppliers', labelKey: 'suppliers', icon: Building2, group: 'inköp' },
// Redovisning dropdown
{ href: '/kpi', labelKey: 'kpi', icon: TrendingUp, group: 'redovisning' },
{ href: '/bookkeeping', labelKey: 'bookkeeping', icon: BookOpen, group: 'redovisning' },
{ href: '/assets', labelKey: 'assets', icon: Package, group: 'redovisning' },
{ href: '/reports', labelKey: 'reports', icon: BarChart3, group: 'redovisning' },
{ href: '/import', labelKey: 'import', icon: Upload, group: 'redovisning' },
// Personal — "Beta" badge while we validate the end-to-end salary + AGI flow.
{ href: '/salary', labelKey: 'salary', icon: HandCoins, group: 'personal', modes: ['aktiebolag'], betaBadge: true },
{ href: '/salary/employees', labelKey: 'employees', icon: Users, group: 'personal', modes: ['aktiebolag'], betaBadge: true },
{ href: '/help', labelKey: 'help', icon: HelpCircle, group: 'övrigt' },
{ href: '/settings', labelKey: 'settings', icon: Settings, group: 'övrigt' },
]
// Map known extension hrefs to nav translation keys so sidebar labels translate.
@@ -115,20 +149,34 @@ function extensionLabelKey(href: string): string | null {
return null
}
const groupLabelKey: Record<GroupKey, string> = {
main: 'group_main',
const groupLabelKey: Record<Exclude<GroupKey, 'top'>, string> = {
försäljning: 'group_sales',
inköp: 'group_purchases',
redovisning: 'group_accounting',
personal: 'group_personnel',
övrigt: 'group_other',
}
export default function DashboardNav({ companyName: _companyName, entityType, uncategorizedTransactionCount = 0, pendingOperationsCount = 0, isSandbox = false, extensionNavItems = [] }: DashboardNavProps) {
// Best single-character initial we can show in the bottom-left account
// trigger. Prefers the first letter of the user's full name; falls back
// to the email's first character; falls back to "?" so the avatar never
// renders empty.
function accountInitial(name: string | null, email: string | null): string {
const trimmedName = name?.trim()
if (trimmedName && trimmedName.length > 0) return trimmedName[0]!.toUpperCase()
const trimmedEmail = email?.trim()
if (trimmedEmail && trimmedEmail.length > 0) return trimmedEmail[0]!.toUpperCase()
return '?'
}
export default function DashboardNav({ companyName: _companyName, entityType, uncategorizedTransactionCount = 0, pendingOperationsCount = 0, isSandbox = false, extensionNavItems = [], userName = null, userEmail = null }: DashboardNavProps) {
const pathname = usePathname()
const router = useRouter()
const supabase = createClient()
const { company } = useCompany()
// Agent identity drives the "Assistent" nav icon — when the user has
// built their assistant we show its chosen avatar instead of the
// generic Sparkles glyph.
const { identity: agentIdentity } = useAgentSheet()
const tNav = useTranslations('nav')
const tCommon = useTranslations('common')
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
@@ -138,9 +186,21 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
const hasCompany = !!company
const ALWAYS_ENABLED = new Set(['/settings'])
const isItemEnabled = (href: string) => hasCompany || ALWAYS_ENABLED.has(href)
const isOnOvrigtPage = ['/help', '/settings', '/e/'].some(p => pathname.startsWith(p))
const [manualOvrigtExpanded, setManualOvrigtExpanded] = useState(false)
const isOvrigtExpanded = isOnOvrigtPage || manualOvrigtExpanded
// Per-group collapse state. Default: ALL groups expanded — the user
// can see every child link without hunting. Clicking the chevron
// collapses the group; opening it again restores the children.
// Active route still forces a group expanded even when the user has
// manually collapsed it (so deep-linking into /salary doesn't leave
// Personal hidden).
type ExpandableGroup = Exclude<GroupKey, 'top'>
const [manualCollapsed, setManualCollapsed] = useState<Record<ExpandableGroup, boolean>>({
försäljning: false,
inköp: false,
redovisning: false,
personal: false,
})
const toggleGroup = (g: ExpandableGroup) =>
setManualCollapsed((prev) => ({ ...prev, [g]: !prev[g] }))
const openMobileMenu = () => {
if (closeTimerRef.current) {
@@ -177,29 +237,74 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
const hiddenNavHrefs = new Set(getBranding().hiddenNavHrefs)
// Render a nav item's leading glyph. The "Assistent" entry (/chat) shows
// the agent's chosen avatar once built; everything else (and the
// pre-onboarding /chat) uses its lucide icon. The passed className carries
// size + margin + active color; tailwind-merge lets the explicit h/w win
// over AgentAvatar's default box size.
const renderNavIcon = (
item: { href: string; icon: typeof LayoutDashboard },
className: string,
) => {
if (item.href === '/chat' && agentIdentity.avatarId) {
return (
<AgentAvatar
avatarId={agentIdentity.avatarId}
size="xs"
alt={agentIdentity.displayName ?? 'Assistent'}
className={className}
/>
)
}
const Icon = item.icon
return <Icon className={className} />
}
const filteredItems = navItems.filter(item => {
if (item.hidden) return false
if (hiddenNavHrefs.has(item.href)) return false
if (item.modes && !item.modes.includes(entityType)) return false
if (item.href === '/pending' && pendingOperationsCount === 0) return false
// Hide the Assistent (/chat) tab until the agent is built — mirrors the
// floating AgentTrigger and avoids a nav entry that only bounces to the
// home checklist (chat/layout redirects unverified users to /).
if (item.href === '/chat' && !agentIdentity.isVerified) return false
// Granskning stays in the top nav at all times now — the badge
// surfaces the count when there are pending ops, but the link is
// always present so users can navigate there manually.
return true
})
const mainItems = filteredItems.filter(i => i.group === 'main')
const övrigtItems = filteredItems.filter(i => i.group === 'övrigt')
const topItems = filteredItems.filter((i) => i.group === 'top')
const sidebarGroups: { key: GroupKey; items: NavItem[]; spacing: string }[] = [
{ key: 'försäljning', items: filteredItems.filter(i => i.group === 'försäljning'), spacing: 'mb-4' },
{ key: 'inköp', items: filteredItems.filter(i => i.group === 'inköp'), spacing: 'mb-4' },
{ key: 'redovisning', items: filteredItems.filter(i => i.group === 'redovisning'), spacing: 'mb-4' },
{ key: 'personal', items: filteredItems.filter(i => i.group === 'personal'), spacing: 'mb-6' },
// The TIC workspace (/e/general/tic, labelled "Företagsprofil") surfaces
// the same Bolagsuppgifter now shown under Inställningar → Företagsprofil.
// Drop it from the nav so the company profile lives in exactly one place.
const visibleExtensionNavItems = extensionNavItems.filter(
(i) => i.href !== '/e/general/tic',
)
const sidebarGroups: { key: ExpandableGroup; items: NavItem[] }[] = [
{ key: 'försäljning', items: filteredItems.filter((i) => i.group === 'försäljning') },
{ key: 'inköp', items: filteredItems.filter((i) => i.group === 'inköp') },
{ key: 'redovisning', items: filteredItems.filter((i) => i.group === 'redovisning') },
{ key: 'personal', items: filteredItems.filter((i) => i.group === 'personal') },
]
const mobileNavItems: { href: string; labelKey: NavLabelKey; icon: typeof LayoutDashboard }[] = [
{ href: '/', labelKey: 'dashboard', icon: LayoutDashboard },
{ href: '/invoices', labelKey: 'invoices', icon: Receipt },
// A group is expanded when the user hasn't manually collapsed it OR
// an active route lives inside it (the active route always wins so a
// deep-link to /salary keeps Personal open even if previously collapsed).
const isGroupExpanded = (g: ExpandableGroup, items: NavItem[]) =>
!manualCollapsed[g] || items.some((it) => isActive(it.href))
const allMobileNavItems: { href: string; labelKey: NavLabelKey; icon: typeof LayoutDashboard }[] = [
{ href: '/', labelKey: 'home', icon: Home },
{ href: '/chat', labelKey: 'assistant', icon: Sparkles },
{ href: '/transactions', labelKey: 'transactions', icon: ArrowLeftRight },
]
// Same gate as the sidebar: no Assistent tab until the agent is built.
const mobileNavItems = allMobileNavItems.filter(
(item) => item.href !== '/chat' || agentIdentity.isVerified,
)
const renderBadge = (item: NavItem | { comingSoon?: boolean; devBadge?: boolean; betaBadge?: boolean }, position: 'sidebar' | 'mobile') => {
const baseClass =
@@ -218,236 +323,268 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
<aside className="hidden md:fixed md:inset-y-0 md:flex md:w-64 md:flex-col">
<div className="flex min-h-0 flex-1 flex-col border-r border-border bg-background">
<div className="flex flex-1 flex-col overflow-y-auto pt-7 pb-4">
{/* Company switcher */}
{/* Company switcher pinned to the top the active company is
the strongest piece of context for everything below it. */}
<div className="px-5 mb-8">
<CompanySwitcher />
</div>
{/* Navigation with group headers */}
<nav className="px-3" aria-label={tNav('main_navigation')}>
{/* Main group */}
<div className="mb-6">
<p className="px-3 mb-1.5 text-[10px] font-semibold text-muted-foreground uppercase tracking-[0.08em]">
{tNav('group_main')}
</p>
<div className="space-y-px">
{mainItems.map((item) => {
const Icon = item.icon
const active = isActive(item.href)
const enabled = isItemEnabled(item.href)
const content = (
<>
<Icon className={cn(
"mr-2.5 h-[15px] w-[15px] flex-shrink-0",
active ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
)} />
<span className="flex-1">{tNav(item.labelKey)}</span>
{renderBadge(item, 'sidebar')}
</>
)
const baseClass = cn(
'group flex items-center px-3 py-[7px] text-[13px] rounded-lg',
enabled
? cn(
'transition-colors duration-150',
active
? 'bg-secondary text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/60'
)
: 'text-muted-foreground/40 cursor-not-allowed'
)
return enabled ? (
<Link key={item.href} href={item.href} className={baseClass}>
{content}
</Link>
) : (
<div
key={item.href}
className={baseClass}
aria-disabled="true"
title={tNav('needs_company_tooltip')}
{/* Top section: flat, no header. Hem, Underlag, Transaktioner, Granskning. */}
<div className="mb-4 space-y-px">
{topItems.map((item) => {
const active = isActive(item.href)
const enabled = isItemEnabled(item.href)
const badge =
item.href === '/transactions' && uncategorizedTransactionCount > 0
? uncategorizedTransactionCount
: item.href === '/pending' && pendingOperationsCount > 0
? pendingOperationsCount
: null
const decorBadge = renderBadge(item, 'sidebar')
const content = (
<>
{renderNavIcon(
item,
cn(
'mr-2.5 h-[15px] w-[15px] flex-shrink-0',
active ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground',
),
)}
<span className="flex-1">{tNav(item.labelKey)}</span>
{decorBadge ? decorBadge : badge !== null && (
<span className="ml-auto min-w-[18px] h-[18px] flex items-center justify-center rounded-full bg-primary/15 text-primary text-[10px] font-semibold px-1">
{badge > 99 ? '99+' : badge}
</span>
)}
</>
)
const baseClass = cn(
'group flex items-center px-3 py-[7px] text-[13px] rounded-lg',
enabled
? cn(
'transition-colors duration-150',
active
? 'bg-secondary text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/60',
)
: 'text-muted-foreground/40 cursor-not-allowed',
)
return enabled ? (
<Link key={item.href} href={item.href} className={baseClass}>
{content}
</Link>
) : (
<div
key={item.href}
className={baseClass}
aria-disabled="true"
title={tNav('needs_company_tooltip')}
>
{content}
</div>
)
})}
</div>
{/* Collapsible groups: Försäljning, Inköp, Redovisning, Personal */}
{sidebarGroups
.filter(({ items }) => items.length > 0)
.map(({ key, items }) => {
const expanded = isGroupExpanded(key, items)
return (
<div key={key} className="mb-1">
<button
onClick={() => toggleGroup(key)}
className="w-full flex items-center justify-between px-3 py-1.5 text-[10px] font-semibold text-muted-foreground uppercase tracking-[0.08em] hover:text-foreground transition-colors rounded-lg"
>
{content}
</div>
)
})}
</div>
</div>
{/* AR / AP / Personal / Accounting groups */}
{sidebarGroups.filter(({ items }) => items.length > 0).map(({ key, items, spacing }) => (
<div key={key} className={spacing}>
<p className="px-3 mb-1.5 text-[10px] font-semibold text-muted-foreground uppercase tracking-[0.08em]">
{tNav(groupLabelKey[key])}
</p>
<div className="space-y-px">
{items.map((item) => {
const Icon = item.icon
const active = isActive(item.href)
const enabled = isItemEnabled(item.href) && !item.comingSoon
const badge = item.href === '/transactions' && uncategorizedTransactionCount > 0
? uncategorizedTransactionCount
: item.href === '/pending' && pendingOperationsCount > 0
? pendingOperationsCount
: null
const decorBadge = renderBadge(item, 'sidebar')
const content = (
<>
<Icon className={cn(
"mr-2.5 h-[15px] w-[15px] flex-shrink-0",
active ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
)} />
<span className="flex-1">{tNav(item.labelKey)}</span>
{decorBadge ? decorBadge : badge !== null && (
<span className="ml-auto min-w-[18px] h-[18px] flex items-center justify-center rounded-full bg-primary/15 text-primary text-[10px] font-semibold px-1">
{badge > 99 ? '99+' : badge}
</span>
<span>{tNav(groupLabelKey[key])}</span>
<ChevronDown
className={cn(
'h-3 w-3 transition-transform duration-200',
expanded && 'rotate-180',
)}
</>
)
const baseClass = cn(
'group flex items-center px-3 py-[7px] text-[13px] rounded-lg',
enabled
? cn(
'transition-colors duration-150',
active
? 'bg-secondary text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/60'
/>
</button>
{expanded && (
<div className="space-y-px animate-fade-in mb-2">
{items.map((item) => {
const Icon = item.icon
const active = isActive(item.href)
const enabled = isItemEnabled(item.href) && !item.comingSoon
const decorBadge = renderBadge(item, 'sidebar')
const content = (
<>
<Icon
className={cn(
'mr-2.5 h-[15px] w-[15px] flex-shrink-0',
active
? 'text-primary'
: 'text-muted-foreground group-hover:text-foreground',
)}
/>
<span className="flex-1">{tNav(item.labelKey)}</span>
{decorBadge}
</>
)
: 'text-muted-foreground/40 cursor-not-allowed'
)
return enabled ? (
<Link key={item.href} href={item.href} className={baseClass}>
{content}
</Link>
) : (
<div
key={item.href}
className={baseClass}
aria-disabled="true"
title={item.comingSoon ? tNav('badge_coming_soon') : tNav('needs_company_tooltip')}
>
{content}
</div>
)
})}
</div>
</div>
))}
{/* Övrigt group - collapsible */}
<div className="mb-4">
<button
onClick={() => setManualOvrigtExpanded(!isOvrigtExpanded)}
className="w-full flex items-center justify-between px-3 mb-1.5 text-[10px] font-semibold text-muted-foreground uppercase tracking-[0.08em] hover:text-muted-foreground transition-colors"
>
<span>{tNav('group_other')}</span>
<ChevronDown className={cn(
"h-3 w-3 transition-transform duration-200",
isOvrigtExpanded && "rotate-180"
)} />
</button>
{isOvrigtExpanded && (
<div className="space-y-px animate-fade-in">
{extensionNavItems.map((item) => {
const Icon = resolveIcon(item.icon)
const active = isActive(item.href)
const enabled = hasCompany
const labelTranslationKey = extensionLabelKey(item.href)
const label = labelTranslationKey ? tNav(labelTranslationKey) : item.label
const content = (
<>
<Icon className={cn(
"mr-2.5 h-[15px] w-[15px] flex-shrink-0",
active ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
)} />
{label}
</>
)
const baseClass = cn(
'group flex items-center px-3 py-[7px] text-[13px] rounded-lg',
enabled
? cn(
'transition-colors duration-150',
active
? 'bg-secondary text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/60'
const baseClass = cn(
'group flex items-center px-3 py-[7px] text-[13px] rounded-lg',
enabled
? cn(
'transition-colors duration-150',
active
? 'bg-secondary text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/60',
)
: 'text-muted-foreground/40 cursor-not-allowed',
)
: 'text-muted-foreground/40 cursor-not-allowed'
)
return enabled ? (
<Link key={item.href} href={item.href} className={baseClass}>
{content}
</Link>
) : (
<div
key={item.href}
className={baseClass}
aria-disabled="true"
title={tNav('needs_company_tooltip')}
>
{content}
</div>
)
})}
{övrigtItems.map((item) => {
const Icon = item.icon
const active = isActive(item.href)
const enabled = isItemEnabled(item.href)
const content = (
<>
<Icon className={cn(
"mr-2.5 h-[15px] w-[15px] flex-shrink-0",
active ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
)} />
{tNav(item.labelKey)}
</>
)
const baseClass = cn(
'group flex items-center px-3 py-[7px] text-[13px] rounded-lg',
enabled
? cn(
'transition-colors duration-150',
active
? 'bg-secondary text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/60'
return enabled ? (
<Link key={item.href} href={item.href} className={baseClass}>
{content}
</Link>
) : (
<div
key={item.href}
className={baseClass}
aria-disabled="true"
title={
item.comingSoon
? tNav('badge_coming_soon')
: tNav('needs_company_tooltip')
}
>
{content}
</div>
)
: 'text-muted-foreground/40 cursor-not-allowed'
)
return enabled ? (
<Link key={item.href} href={item.href} className={baseClass}>
{content}
</Link>
) : (
<div
key={item.href}
className={baseClass}
aria-disabled="true"
title={tNav('needs_company_tooltip')}
>
{content}
})}
{/* Extension nav items land in Redovisning since the
current extensions (TIC workspace, etc.) are
accounting-adjacent. Future categorised extensions
can opt into a different group via their manifest. */}
{key === 'redovisning' &&
visibleExtensionNavItems.map((item) => {
const Icon = resolveIcon(item.icon)
const active = isActive(item.href)
const enabled = hasCompany
const labelTranslationKey = extensionLabelKey(item.href)
const label = labelTranslationKey
? tNav(labelTranslationKey)
: item.label
const content = (
<>
<Icon
className={cn(
'mr-2.5 h-[15px] w-[15px] flex-shrink-0',
active
? 'text-primary'
: 'text-muted-foreground group-hover:text-foreground',
)}
/>
{label}
</>
)
const baseClass = cn(
'group flex items-center px-3 py-[7px] text-[13px] rounded-lg',
enabled
? cn(
'transition-colors duration-150',
active
? 'bg-secondary text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/60',
)
: 'text-muted-foreground/40 cursor-not-allowed',
)
return enabled ? (
<Link key={item.href} href={item.href} className={baseClass}>
{content}
</Link>
) : (
<div
key={item.href}
className={baseClass}
aria-disabled="true"
title={tNav('needs_company_tooltip')}
>
{content}
</div>
)
})}
</div>
)
})}
</div>
)}
</div>
)}
</div>
)
})}
</nav>
</div>
{/* Support + Logout */}
<div className="flex-shrink-0 px-3 py-3 border-t border-border space-y-1">
<div className="px-3 py-1.5">
<SupportLink variant="muted" />
</div>
<Button
variant="ghost"
className="w-full justify-start text-muted-foreground hover:text-foreground text-[13px] h-9 px-3"
onClick={handleLogout}
>
<LogOut className="mr-2.5 h-[15px] w-[15px]" />
{isSandbox ? tNav('logout_sandbox') : tCommon('logout')}
</Button>
{/* Account popover (bottom-left). Triggered by the signed-in
user's name + initial. Holds Inställningar, Hjälp, Support,
Logga ut. CompanySwitcher lives at the top of the sidebar,
not in here different concept ("which company am I working
with" vs "who am I logged in as"). */}
<div className="flex-shrink-0 px-3 py-3 border-t border-border">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="group flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-left text-[13px] text-muted-foreground hover:bg-secondary/60 hover:text-foreground transition-colors duration-150"
>
<span className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full bg-secondary text-[11px] font-semibold uppercase text-foreground">
{accountInitial(userName, userEmail)}
</span>
<span className="flex-1 truncate font-medium text-foreground">
{userName?.trim() || userEmail || tNav('mitt_konto')}
</span>
<ChevronsUpDown className="h-3.5 w-3.5 opacity-50 group-hover:opacity-100" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start" className="w-60">
{(userName || userEmail) && (
<>
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col gap-0.5">
{userName && (
<span className="text-sm font-medium text-foreground truncate">
{userName}
</span>
)}
{userEmail && (
<span className="text-xs text-muted-foreground truncate">
{userEmail}
</span>
)}
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem asChild>
<Link href="/settings" className="cursor-pointer">
<Settings className="mr-2 h-4 w-4" />
{tNav('settings')}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/help" className="cursor-pointer">
<HelpCircle className="mr-2 h-4 w-4" />
{tNav('help')}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<SupportLink variant="muted" className="cursor-pointer" />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault()
void handleLogout()
}}
className="cursor-pointer text-muted-foreground focus:text-foreground"
>
<LogOut className="mr-2 h-4 w-4" />
{isSandbox ? tNav('logout_sandbox') : tCommon('logout')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</aside>
@@ -456,7 +593,6 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
<nav className="md:hidden fixed bottom-0 left-0 right-0 z-50 bg-card/98 backdrop-blur-sm border-t border-border/40" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} aria-label={tNav('mobile_navigation')}>
<div className="flex items-center justify-around h-16 px-2">
{mobileNavItems.map((item) => {
const Icon = item.icon
const active = isActive(item.href)
const enabled = isItemEnabled(item.href)
const badge = item.href === '/transactions' && uncategorizedTransactionCount > 0
@@ -466,10 +602,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
const content = (
<>
<div className="relative">
<Icon className={cn(
"h-5 w-5 mb-1",
active && "text-primary"
)} />
{renderNavIcon(item, cn('h-5 w-5 mb-1', active && 'text-primary'))}
{badge !== null && (
<span className="absolute -top-1.5 -right-2.5 min-w-[16px] h-[16px] flex items-center justify-center rounded-full bg-primary text-primary-foreground text-[9px] font-semibold px-0.5">
{badge > 99 ? '99+' : badge}
@@ -561,17 +694,27 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
{/* Navigation */}
<div className="px-2">
{/* Main items */}
{/* Top items (Hem, Underlag, Transaktioner, Granskning) */}
<div className="space-y-0.5">
{mainItems.map((item) => {
const Icon = item.icon
{topItems.map((item) => {
const active = isActive(item.href)
const enabled = isItemEnabled(item.href)
const badge =
item.href === '/transactions' && uncategorizedTransactionCount > 0
? uncategorizedTransactionCount
: item.href === '/pending' && pendingOperationsCount > 0
? pendingOperationsCount
: null
const decorBadge = renderBadge(item, 'mobile')
const content = (
<>
<Icon className={cn("h-[18px] w-[18px] flex-shrink-0", active ? "text-primary" : "text-muted-foreground")} />
{renderNavIcon(item, cn('h-[18px] w-[18px] flex-shrink-0', active ? 'text-primary' : 'text-muted-foreground'))}
<span className="text-sm flex-1">{tNav(item.labelKey)}</span>
{renderBadge(item, 'mobile')}
{decorBadge ? decorBadge : badge !== null && (
<span className="min-w-[20px] h-[20px] flex items-center justify-center rounded-full bg-primary/15 text-primary text-[10px] font-semibold px-1.5">
{badge > 99 ? '99+' : badge}
</span>
)}
</>
)
const baseClass = cn(
@@ -661,53 +804,67 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
</div>
))}
{/* Övrigt divider */}
{/* Tillägg (extensions) — only when there's at least one */}
{visibleExtensionNavItems.length > 0 && (
<>
<div className="flex items-center gap-3 my-1.5 px-3">
<span className="text-[10px] font-semibold text-muted-foreground/60 uppercase tracking-[0.08em]">{tNav('group_extensions')}</span>
<div className="flex-1 h-px bg-border/30" />
</div>
<div className="space-y-0.5">
{visibleExtensionNavItems.map((item) => {
const Icon = resolveIcon(item.icon)
const active = isActive(item.href)
const enabled = hasCompany
const labelTranslationKey = extensionLabelKey(item.href)
const label = labelTranslationKey ? tNav(labelTranslationKey) : item.label
const content = (
<>
<Icon className={cn("h-[18px] w-[18px] flex-shrink-0", active ? "text-primary" : "text-muted-foreground")} />
<span className="text-sm">{label}</span>
</>
)
const baseClass = cn(
'flex items-center gap-3 px-3 min-h-[44px] rounded-lg',
enabled
? cn(
'transition-colors',
active
? 'bg-primary/10 text-primary font-medium'
: 'text-foreground active:bg-muted/60'
)
: 'text-muted-foreground/40'
)
return enabled ? (
<Link
key={item.href}
href={item.href}
onClick={closeMobileMenu}
className={baseClass}
>
{content}
</Link>
) : (
<div key={item.href} className={baseClass} aria-disabled="true">
{content}
</div>
)
})}
</div>
</>
)}
{/* Mitt konto divider */}
<div className="flex items-center gap-3 my-1.5 px-3">
<span className="text-[10px] font-semibold text-muted-foreground/60 uppercase tracking-[0.08em]">{tNav('group_other')}</span>
<span className="text-[10px] font-semibold text-muted-foreground/60 uppercase tracking-[0.08em]">{tNav('mitt_konto')}</span>
<div className="flex-1 h-px bg-border/30" />
</div>
{/* Other items */}
<div className="space-y-0.5">
{extensionNavItems.map((item) => {
const Icon = resolveIcon(item.icon)
const active = isActive(item.href)
const enabled = hasCompany
const labelTranslationKey = extensionLabelKey(item.href)
const label = labelTranslationKey ? tNav(labelTranslationKey) : item.label
const content = (
<>
<Icon className={cn("h-[18px] w-[18px] flex-shrink-0", active ? "text-primary" : "text-muted-foreground")} />
<span className="text-sm">{label}</span>
</>
)
const baseClass = cn(
'flex items-center gap-3 px-3 min-h-[44px] rounded-lg',
enabled
? cn(
'transition-colors',
active
? 'bg-primary/10 text-primary font-medium'
: 'text-foreground active:bg-muted/60'
)
: 'text-muted-foreground/40'
)
return enabled ? (
<Link
key={item.href}
href={item.href}
onClick={closeMobileMenu}
className={baseClass}
>
{content}
</Link>
) : (
<div key={item.href} className={baseClass} aria-disabled="true">
{content}
</div>
)
})}
{övrigtItems.map((item) => {
{([
{ href: '/settings', labelKey: 'settings' as NavLabelKey, icon: Settings },
{ href: '/help', labelKey: 'help' as NavLabelKey, icon: HelpCircle },
]).map((item) => {
const Icon = item.icon
const active = isActive(item.href)
const enabled = isItemEnabled(item.href)
+8 -4
View File
@@ -5,8 +5,8 @@ import type { ReactNode } from 'react'
/**
* Picks the dashboard chrome container based on route. Extension workspaces
* (/e/*) want the full viewport for file viewers and dashboards; everything
* else gets the centered max-w-5xl card.
* (/e/*) and the /chat app shell want the full viewport for their own
* multi-pane layouts; everything else gets the centered max-w-5xl card.
*
* Lives in a client component because the parent (dashboard) layout is
* shared across all dashboard routes. Server-side pathname checks done in
@@ -22,9 +22,13 @@ export function MainContainer({
children: ReactNode
}) {
const pathname = usePathname()
const isExtensionWorkspace = pathname.startsWith('/e/')
// Full-bleed routes own their own padding + multi-pane layout. They
// shouldn't sit inside max-w-5xl or any horizontal padding — that's what
// causes a visible gap between the dashboard sidebar and the chat-sidebar
// pane on wide viewports.
const isFullBleed = pathname.startsWith('/e/') || pathname.startsWith('/chat')
return isExtensionWorkspace ? (
return isFullBleed ? (
<div key={companyId ?? ''} className="h-full">{children}</div>
) : (
<div
+87 -6
View File
@@ -50,6 +50,42 @@ function logError(message: string, extra?: Record<string, unknown>) {
}).catch(() => {})
}
// Parse TIC v2's `startMonthDay` ("MM-DD" — e.g. "07-01") into a month
// number 112. Returns null on missing / malformed input so the caller
// can fall through to the manual picker default.
function parseStartMonthDay(value: string | null | undefined): number | null {
if (!value) return null
const match = /^(\d{1,2})-\d{1,2}$/.exec(value)
if (!match) return null
const month = Number(match[1])
if (!Number.isInteger(month) || month < 1 || month > 12) return null
return month
}
// Derive the Step-3 first-year defaults from TIC's `registrationDate`.
// A company is treated as "first year" when registered less than 12 months
// ago — fits BFL's 6-18 month opening-period window comfortably. Returns
// both the toggle state and a seeded `first_year_start` (always the 1st of
// the registration month, the format Step 3's date inputs expect).
function deriveFirstYearDefaults(registrationDate: number | null | undefined): {
isFirstFiscalYear: boolean
firstYearStart: string | undefined
} {
if (!registrationDate || !Number.isFinite(registrationDate)) {
return { isFirstFiscalYear: false, firstYearStart: undefined }
}
const regDate = new Date(registrationDate)
if (Number.isNaN(regDate.getTime())) {
return { isFirstFiscalYear: false, firstYearStart: undefined }
}
const monthsAgo =
(Date.now() - regDate.getTime()) / (1000 * 60 * 60 * 24 * 30.44)
if (monthsAgo >= 12) return { isFirstFiscalYear: false, firstYearStart: undefined }
const year = regDate.getUTCFullYear()
const month = String(regDate.getUTCMonth() + 1).padStart(2, '0')
return { isFirstFiscalYear: true, firstYearStart: `${year}-${month}-01` }
}
interface WelcomeOnboardingProps {
firstName?: string | null
teamId: string
@@ -57,6 +93,15 @@ interface WelcomeOnboardingProps {
hasExistingCompanies?: boolean
/** Pre-fill Step 2 org_number when the picker routed here via ?org_number=. */
initialOrgNumber?: string
/** Mapped from a TIC `legalEntityType`. Pre-selects Step 1's radio. */
initialEntityType?: EntityType
/** Legal name from CompanyRoles. Pre-fills Step 2's company_name field. */
initialLegalName?: string
/** Set when the orgnr came from BankID CompanyRoles. Step 2 treats the
* field as pre-verified skips the client-side Lens `/lookup` since
* CompanyRoles already confirms the company exists. Cleared the moment
* the user edits the orgnr. */
preverifiedOrgNumber?: string
}
export default function WelcomeOnboarding({
@@ -65,6 +110,9 @@ export default function WelcomeOnboarding({
skipWelcome,
hasExistingCompanies,
initialOrgNumber,
initialEntityType,
initialLegalName,
preverifiedOrgNumber,
}: WelcomeOnboardingProps) {
const router = useRouter()
const { toast } = useToast()
@@ -74,9 +122,17 @@ export default function WelcomeOnboarding({
const [started, setStarted] = useState(skipWelcome ?? false)
const [isSaving, setIsSaving] = useState(false)
const [currentStep, setCurrentStep] = useState(1)
const [settings, setSettings] = useState<Partial<CompanySettings>>(
initialOrgNumber ? { org_number: initialOrgNumber } : {},
)
// Seed settings with what BankID CompanyRoles already told us. address /
// F-skatt / VAT come later — user types address in Step 2 and confirms
// F-skatt/VAT in Step 4. We deliberately don't pre-fetch from Lens to
// preserve the 3000/mo budget for the manual-orgnr path.
const [settings, setSettings] = useState<Partial<CompanySettings>>(() => {
const seed: Partial<CompanySettings> = {}
if (initialOrgNumber) seed.org_number = initialOrgNumber
if (initialEntityType) seed.entity_type = initialEntityType
if (initialLegalName) seed.company_name = initialLegalName
return seed
})
const ticEnabled = ENABLED_EXTENSION_IDS.has('tic')
const [ticLookup, setTicLookup] = useState<CompanyLookupResult | null>(null)
@@ -144,6 +200,7 @@ export default function WelcomeOnboarding({
endDate: periodResult.endStr,
name: periodResult.periodName,
},
ticLookup,
})
if (result.error || !result.companyId) {
@@ -299,24 +356,48 @@ export default function WelcomeOnboarding({
entityType={settings.entity_type as EntityType}
ticEnabled={ticEnabled}
onTicLookup={setTicLookup}
preverifiedOrgNumber={preverifiedOrgNumber}
onNext={(data) => handleNext(data)}
onBack={handleBack}
isSaving={isSaving}
/>
)}
{currentStep === 3 && (
{currentStep === 3 && (() => {
// Derive both first-year defaults from TIC's registrationDate
// (null-safe — returns { false, undefined } for established
// companies and for missing registrationDate).
const firstYearDefaults = deriveFirstYearDefaults(
ticLookup?.registrationDate,
)
return (
<Step3TaxRegistration
initialData={{
f_skatt: settings.f_skatt ?? (ticLookup ? ticLookup.registration.fTax : undefined),
fiscal_year_start_month: settings.fiscal_year_start_month ?? undefined,
// Prefer the user's previously-saved value, then fall back
// to TIC's v2 `fiscalYear.startMonthDay` (e.g. "07-01" →
// start month 7). Parsing the MM-DD lets us skip the
// manual end-month picker for the ~95% of companies that
// have a registered fiscal year in Bolagsverket.
fiscal_year_start_month:
settings.fiscal_year_start_month
?? parseStartMonthDay(ticLookup?.fiscalYear?.startMonthDay)
?? undefined,
// Companies registered <12 months ago land in their first
// fiscal year — pre-check the toggle and seed the start
// date so the user only confirms the end date.
is_first_fiscal_year:
settings.is_first_fiscal_year ?? firstYearDefaults.isFirstFiscalYear,
first_year_start:
settings.first_year_start ?? firstYearDefaults.firstYearStart,
}}
entityType={settings.entity_type as EntityType}
onNext={(data) => handleNext(data)}
onBack={handleBack}
isSaving={isSaving}
/>
)}
)
})()}
{currentStep === 4 && (
<Step4VatAccounting
@@ -18,13 +18,16 @@ import { useToast } from '@/components/ui/use-toast'
import { Loader2, Plus, Trash2, AlertTriangle, Search, Check } from 'lucide-react'
import { cn, formatCurrency } from '@/lib/utils'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import BookingTemplatePicker from '@/components/bookkeeping/BookingTemplatePicker'
import { ActivateAccountsDialog } from '@/components/bookkeeping/ActivateAccountsDialog'
import { useCompany } from '@/contexts/CompanyContext'
import {
useSubmitWithAccountActivation,
throwOnStructuredError,
} from '@/lib/hooks/use-submit-with-account-activation'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
import type { BASAccount, FiscalPeriod, InvoiceExtractionResult } from '@/types'
interface InboxItem {
@@ -40,6 +43,19 @@ interface PickerTransaction {
description: string
amount: number
currency: string | null
amount_sek?: number | null
exchange_rate?: number | null
}
// SEK magnitude of a (usually-SEK) bank transaction. Foreign rows are
// normalised via their stored amount_sek/exchange_rate so ranking against the
// underlag's SEK value is apples-to-apples.
function txSekAmount(tx: PickerTransaction): number {
const cur = (tx.currency ?? 'SEK').toUpperCase()
if (cur === 'SEK') return Math.abs(tx.amount)
return Math.abs(
resolveSekAmount(tx.amount, tx.amount_sek ?? null, tx.currency, tx.exchange_rate ?? null),
)
}
interface FormLine {
@@ -116,21 +132,31 @@ function buildPrefillLines(
return lines
}
function rankByAmount(
// Rank candidates by closeness to the underlag's SEK value. `targetSek` is the
// document total already converted to SEK (the bank charge for a 216 USD
// receipt is ~2 109 kr, not 216) — ranking against the raw foreign total used
// to bury the real match far down the list. Null target → leave order intact.
function rankBySekCloseness(
rows: PickerTransaction[],
target: number | null
targetSek: number | null
): PickerTransaction[] {
if (target == null) return rows
const abs = Math.abs(target)
return [...rows].sort((a, b) => {
const da = Math.abs(Math.abs(a.amount) - abs)
const db = Math.abs(Math.abs(b.amount) - abs)
return da - db
})
if (targetSek == null) return rows
const abs = Math.abs(targetSek)
return [...rows].sort((a, b) => Math.abs(txSekAmount(a) - abs) - Math.abs(txSekAmount(b) - abs))
}
export default function BookDirectlyDialog({ open, onOpenChange, item, onSuccess }: Props) {
const { toast } = useToast()
const { company } = useCompany()
// Underlag total + currency. Booking happens in SEK, so a foreign total needs
// an FX rate to rank/compare against the (SEK) bank transactions.
const targetAmount = item.extracted_data?.totals?.total ?? null
const targetCurrency = (item.extracted_data?.invoice?.currency ?? 'SEK').toUpperCase()
// SEK per unit of the underlag currency (e.g. ~9.8 for USD). null = SEK,
// pending, or unsupported.
const [fxRate, setFxRate] = useState<number | null>(null)
const [periods, setPeriods] = useState<FiscalPeriod[]>([])
const [accounts, setAccounts] = useState<BASAccount[]>([])
const [entryDate, setEntryDate] = useState<string>(
@@ -168,13 +194,50 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, onSuccess
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, item.id])
// Fetch the underlag's SEK rate for a foreign-currency document so candidate
// transactions can be ranked against the SEK-equivalent total (and not the
// raw foreign number). SEK / unsupported currencies skip the fetch.
useEffect(() => {
if (!open) return
setFxRate(null)
if (targetCurrency === 'SEK' || !['EUR', 'USD', 'GBP', 'NOK', 'DKK'].includes(targetCurrency)) {
return
}
let cancelled = false
const invoiceDate = item.extracted_data?.invoice?.invoiceDate
const dateParam = invoiceDate ? `&date=${invoiceDate}` : ''
fetch(`/api/currency/rate?currency=${targetCurrency}${dateParam}`)
.then((r) => (r.ok ? r.json() : null))
.then((body) => {
if (cancelled) return
const rate = body?.data?.rate
if (typeof rate === 'number' && rate > 0) setFxRate(rate)
})
.catch(() => { /* leave null — ranking falls back to face amounts */ })
return () => { cancelled = true }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, targetCurrency, item.id])
// SEK-equivalent of the underlag total — the anchor for ranking candidates.
const targetSek = useMemo(() => {
if (targetAmount == null) return null
if (targetCurrency === 'SEK') return targetAmount
if (fxRate != null) return Math.round(targetAmount * fxRate * 100) / 100
return null
}, [targetAmount, targetCurrency, fxRate])
// When the user picks a transaction (or the toggle changes), re-derive
// the prefilled amounts so foreign-currency invoices follow the SEK
// figure on the actual bank movement.
// figure on the actual bank movement. Normalised to SEK — a foreign bank
// row is booked at its SEK value, never its face amount.
const selectedTransactionAmount = useMemo(() => {
if (!selectedTransactionId) return null
const tx = transactions.find((t) => t.id === selectedTransactionId)
return tx?.amount ?? null
if (!tx) return null
const cur = (tx.currency ?? 'SEK').toUpperCase()
return cur === 'SEK'
? tx.amount
: resolveSekAmount(tx.amount, tx.amount_sek ?? null, tx.currency, tx.exchange_rate ?? null)
}, [selectedTransactionId, transactions])
useEffect(() => {
@@ -237,7 +300,6 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, onSuccess
if (!open) return
let cancelled = false
setIsLoadingTransactions(true)
const targetAmount = item.extracted_data?.totals?.total ?? null
;(async () => {
try {
const res = await fetch('/api/transactions?unmatched=true')
@@ -250,8 +312,11 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, onSuccess
description: t.description,
amount: t.amount,
currency: t.currency || 'SEK',
amount_sek: t.amount_sek ?? null,
exchange_rate: t.exchange_rate ?? null,
}))
setTransactions(rankByAmount(rows, targetAmount))
// Ranking happens in a memo (it depends on the async FX rate).
setTransactions(rows)
} catch (err) {
console.error('[book-direct] fetch transactions failed:', err)
} finally {
@@ -259,13 +324,30 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, onSuccess
}
})()
return () => { cancelled = true }
}, [open, item.extracted_data?.totals?.total])
}, [open])
// FX-aware ranking by closeness to the underlag's SEK value.
const rankedTransactions = useMemo(
() => rankBySekCloseness(transactions, targetSek),
[transactions, targetSek],
)
const filteredTransactions = useMemo(() => {
const term = txSearch.trim().toLowerCase()
if (!term) return transactions
return transactions.filter((t) => (t.description || '').toLowerCase().includes(term))
}, [transactions, txSearch])
if (!term) return rankedTransactions
return rankedTransactions.filter((t) => (t.description || '').toLowerCase().includes(term))
}, [rankedTransactions, txSearch])
// Pin the already-selected/matched transaction to the top so it's always
// visible — otherwise a correct match that ranks past the rendered cap looks
// unselected and the user re-picks it. The pinned row carries a "Matchad"
// badge when it's the one matched in the inbox.
const displayedTransactions = useMemo(() => {
if (!selectedTransactionId) return filteredTransactions
const sel = filteredTransactions.find((t) => t.id === selectedTransactionId)
if (!sel) return filteredTransactions
return [sel, ...filteredTransactions.filter((t) => t.id !== selectedTransactionId)]
}, [filteredTransactions, selectedTransactionId])
const totals = useMemo(() => {
const debit = lines.reduce((sum, l) => sum + (parseFloat(l.debit_amount) || 0), 0)
@@ -292,6 +374,27 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, onSuccess
setLines((prev) => prev.length <= 2 ? prev : prev.filter((_, i) => i !== idx))
}, [])
// Replace the line set with a booking template's computed rows. The picker
// hands back JournalEntryForm-shaped lines; we keep only the three fields
// book-direct posts. A meaningful supplier description is preserved — the
// template name only fills an empty field.
const handleTemplateApply = useCallback(
(
templateLines: Array<{ account_number: string; debit_amount: string; credit_amount: string }>,
templateDescription: string,
) => {
setLines(
templateLines.map((l) => ({
account_number: l.account_number,
debit_amount: l.debit_amount,
credit_amount: l.credit_amount,
})),
)
setDescription((prev) => (prev.trim() ? prev : templateDescription))
},
[],
)
const disabledReason = useMemo(() => {
if (isSubmitting) return null
if (!entryDate) return 'Välj datum'
@@ -366,9 +469,6 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, onSuccess
}
}, [canSubmit, runSubmit, toast, onSuccess, onOpenChange])
const targetAmount = item.extracted_data?.totals?.total ?? null
const targetCurrency = item.extracted_data?.invoice?.currency ?? 'SEK'
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
@@ -465,8 +565,11 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, onSuccess
</p>
) : (
<ul className="divide-y">
{filteredTransactions.slice(0, 30).map((tx) => {
{displayedTransactions.slice(0, 30).map((tx) => {
const isSelected = selectedTransactionId === tx.id
const isInboxMatch = item.matched_transaction_id === tx.id
const cur = (tx.currency || 'SEK').toUpperCase()
const sek = txSekAmount(tx)
return (
<li key={tx.id}>
<button
@@ -488,17 +591,31 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, onSuccess
) : null}
</span>
<div className="min-w-0 flex-1">
<p className="truncate">{tx.description}</p>
<div className="flex items-center gap-1.5 min-w-0">
<p className="truncate">{tx.description}</p>
{isInboxMatch && (
<Badge variant="secondary" className="shrink-0 text-[10px] px-1.5 py-0">
Matchad
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground tabular-nums">{tx.date}</p>
</div>
<span
className={cn(
'tabular-nums text-sm shrink-0',
tx.amount < 0 ? 'text-destructive' : 'text-foreground'
<div className="text-right shrink-0">
<span
className={cn(
'tabular-nums text-sm block',
tx.amount < 0 ? 'text-destructive' : 'text-foreground'
)}
>
{formatCurrency(tx.amount, tx.currency || 'SEK')}
</span>
{cur !== 'SEK' && (
<span className="text-[11px] text-muted-foreground tabular-nums">
{formatCurrency(sek, 'SEK')}
</span>
)}
>
{formatCurrency(tx.amount, tx.currency || 'SEK')}
</span>
</div>
</button>
</li>
)
@@ -627,16 +744,27 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, onSuccess
</table>
</div>
<div className="flex items-center justify-between gap-3">
<Button
type="button"
variant="ghost"
size="sm"
onClick={addLine}
disabled={isSubmitting}
>
<Plus className="h-3.5 w-3.5 mr-1.5" />
Lägg till rad
</Button>
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={addLine}
disabled={isSubmitting}
>
<Plus className="h-3.5 w-3.5 mr-1.5" />
Lägg till rad
</Button>
<BookingTemplatePicker
onApply={handleTemplateApply}
entityType={company?.entity_type}
defaultAmount={
selectedTransactionAmount != null
? Math.abs(selectedTransactionAmount)
: targetSek ?? undefined
}
/>
</div>
{totals.balanced ? (
<Badge variant="success" className="text-[11px]">
Balanserad
@@ -30,6 +30,8 @@ import { cn, formatCurrency } from '@/lib/utils'
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
import type { InvoiceExtractionResult } from '@/types'
import BookDirectlyDialog from '@/components/extensions/general/BookDirectlyDialog'
import TransactionMatchPicker from '@/components/inbox/TransactionMatchPicker'
import { useAgentSheet } from '@/components/agent/AgentSheetProvider'
type AccountingMethod = 'accrual' | 'cash'
@@ -94,6 +96,24 @@ function pickSupplierName(item: InboxItem): string | null {
return item.extracted_data?.supplier?.name ?? null
}
// Lifecycle stage of an inbox item. Single source of truth shared by the list
// filter, the count pills, and the row icons so they never drift apart.
//
// Precedence mirrors the FieldsRail: a booked item (supplier invoice OR a
// direct journal entry) is done and drops out of the active inbox. A
// matched-but-unbooked item is "linked" — it STAYS in the inbox as its own
// category because the bank payment still needs booking (a document attached
// to a transaction is not the same as a booked one). An extraction failure is
// "error"; everything else needs a first action.
type InboxStatus = 'needs_action' | 'linked' | 'booked' | 'error'
function deriveInboxStatus(item: InboxItem): InboxStatus {
if (item.created_supplier_invoice_id || item.created_journal_entry_id) return 'booked'
if (item.matched_transaction_id) return 'linked'
if (item.status === 'error') return 'error'
return 'needs_action'
}
// ── Skeleton ─────────────────────────────────────────────────
// Mirrors the live layout (top bar + 3-pane card) so the transition from
// the route-level loading.tsx to data-loaded content has no visible reflow.
@@ -150,12 +170,16 @@ function WorkspaceSkeleton() {
export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
const { toast } = useToast()
const fileInputRef = useRef<HTMLInputElement | null>(null)
const { openAgentSheet, identity } = useAgentSheet()
const [items, setItems] = useState<InboxItem[]>([])
const [isLoading, setIsLoading] = useState(true)
const [selectedId, setSelectedId] = useState<string | null>(null)
// List filter + search (client-side over the already-fetched items list).
const [filter, setFilter] = useState<'all' | 'needs_action' | 'done' | 'error'>('all')
// Defaults to 'todo' — the active inbox (everything not yet booked) — so
// booked underlag drop out of the default view while attached-but-unbooked
// ones stay visible.
const [filter, setFilter] = useState<'todo' | 'linked' | 'booked' | 'error' | 'all'>('todo')
const [searchTerm, setSearchTerm] = useState('')
// Bulk selection. Items linked to a supplier invoice are skipped at delete
// time (server returns 409); we still allow them to be selected so the
@@ -179,6 +203,9 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
const [isRotating, setIsRotating] = useState(false)
const [isDragging, setIsDragging] = useState(false)
const [bookDirectOpen, setBookDirectOpen] = useState(false)
// Match-to-bank-transaction picker (opens when user clicks "Matcha mot
// transaktion" on an unmatched inbox item).
const [matchPickerOpen, setMatchPickerOpen] = useState(false)
// Cash method users see "Bokför direkt" as the primary CTA; accrual users
// see "Skapa leverantörsfaktura". Defaults to 'accrual' until we've read
// the company settings so we don't flicker the CTA order on first paint.
@@ -188,7 +215,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
const fetchItems = useCallback(async () => {
try {
const res = await fetch('/api/extensions/ext/invoice-inbox/items?limit=50')
const res = await fetch('/api/extensions/ext/invoice-inbox/items?limit=500')
const json = await res.json()
if (res.ok) setItems(json.data?.items ?? [])
} catch (err) {
@@ -263,19 +290,44 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
// ── List filter + search (client-side over the fetched list) ─
// Per-status counts for the filter pills. Computed once over the full list.
const statusCounts = useMemo(() => {
const counts = { todo: 0, linked: 0, booked: 0, error: 0, all: items.length }
for (const item of items) {
const status = deriveInboxStatus(item)
if (status !== 'booked') counts.todo += 1
if (status === 'linked') counts.linked += 1
if (status === 'booked') counts.booked += 1
if (status === 'error') counts.error += 1
}
return counts
}, [items])
// Pills, in order. The error pill only appears when there's something errored
// (or it's the active filter) — keeps the happy-path inbox uncluttered.
const pills = useMemo(() => {
const list: { key: typeof filter; label: string; count: number }[] = [
{ key: 'todo', label: 'Att göra', count: statusCounts.todo },
{ key: 'linked', label: 'Kopplade', count: statusCounts.linked },
{ key: 'booked', label: 'Bokförda', count: statusCounts.booked },
]
if (statusCounts.error > 0 || filter === 'error') {
list.push({ key: 'error', label: 'Fel', count: statusCounts.error })
}
list.push({ key: 'all', label: 'Alla', count: statusCounts.all })
return list
}, [statusCounts, filter])
const filteredItems = useMemo(() => {
const term = searchTerm.trim().toLowerCase()
return items.filter((item) => {
// Status filter
const isErr = item.status === 'error'
const isDone =
!!item.created_supplier_invoice_id ||
!!item.matched_transaction_id ||
!!item.created_journal_entry_id
const needsAction = !isErr && !isDone
if (filter === 'error' && !isErr) return false
if (filter === 'done' && !isDone) return false
if (filter === 'needs_action' && !needsAction) return false
// Status filter. "todo" is the active inbox — everything except booked.
const status = deriveInboxStatus(item)
if (filter === 'todo' && status === 'booked') return false
if (filter === 'linked' && status !== 'linked') return false
if (filter === 'booked' && status !== 'booked') return false
if (filter === 'error' && status !== 'error') return false
// 'all' → no status narrowing
// Search filter — supplier name, email subject/from, placeholder filename
if (term === '') return true
@@ -672,14 +724,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
/>
</div>
<div className="flex flex-wrap gap-1">
{(
[
{ key: 'all', label: 'Alla' },
{ key: 'needs_action', label: 'Behöver åtgärd' },
{ key: 'done', label: 'Bearbetade' },
{ key: 'error', label: 'Fel' },
] as const
).map((pill) => (
{pills.map((pill) => (
<button
key={pill.key}
type="button"
@@ -692,6 +737,16 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
)}
>
{pill.label}
{pill.count > 0 && (
<span
className={cn(
'ml-1 tabular-nums',
filter === pill.key ? 'opacity-80' : 'opacity-50'
)}
>
{pill.count}
</span>
)}
</button>
))}
</div>
@@ -761,7 +816,9 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
)
) : filteredItems.length === 0 ? (
<div className="p-6 text-center text-xs text-muted-foreground">
Inga poster matchar filtret.
{filter === 'todo'
? 'Inget att åtgärda — allt är bearbetat.'
: 'Inga poster matchar filtret.'}
</div>
) : (
<ul>
@@ -824,6 +881,35 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
accountingMethod={accountingMethod}
onDelete={() => handleDelete(selected.id)}
onBookDirect={() => setBookDirectOpen(true)}
onMatchTransaction={() => setMatchPickerOpen(true)}
onUnmatchTransaction={async () => {
const targetId = selected.id
const res = await fetch(
`/api/extensions/ext/invoice-inbox/items/${targetId}/unmatch-transaction`,
{ method: 'POST' },
)
if (!res.ok) {
const json = await res.json().catch(() => ({}))
toast({
title: 'Kunde inte avbryta matchningen',
description: json.error ?? `HTTP ${res.status}`,
variant: 'destructive',
})
return
}
await Promise.all([fetchItems(), handleSelect(targetId)])
}}
onAskAssistant={
identity.isVerified
? (transactionId) => {
openAgentSheet({
intentId: 'transaction.categorization',
intentArgs: { transaction_id: transactionId },
contextRef: `transaction:${transactionId}`,
})
}
: undefined
}
isDeleting={isDeleting}
onRetryRequested={async () => {
await Promise.all([fetchItems(), handleSelect(selected.id)])
@@ -864,6 +950,17 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
}}
/>
)}
{selected && (
<TransactionMatchPicker
open={matchPickerOpen}
onClose={() => setMatchPickerOpen(false)}
inboxItemId={selected.id}
extractedData={selected.extracted_data}
onMatched={async () => {
await Promise.all([fetchItems(), handleSelect(selected.id)])
}}
/>
)}
</div>
)
}
@@ -890,10 +987,11 @@ function InboxRow({
}) {
const amount = pickAmount(item)
const supplierName = pickSupplierName(item)
const isErrored = item.status === 'error'
const isProcessed = !!item.created_supplier_invoice_id
const isLinkedToTransaction = !isProcessed && !!item.matched_transaction_id
const isPlaceholder = !!item.isPlaceholder
const status = deriveInboxStatus(item)
const isErrored = status === 'error'
const isBooked = status === 'booked'
const isLinkedToTransaction = status === 'linked'
return (
<li
@@ -943,13 +1041,13 @@ function InboxRow({
: (supplierName ?? item.email_subject ?? 'Okänt dokument')}
</span>
{isErrored && (
<AlertTriangle className="h-3 w-3 text-destructive shrink-0" />
<AlertTriangle className="h-3 w-3 text-destructive shrink-0" aria-label="Fel vid bearbetning" />
)}
{isLinkedToTransaction && (
<Link2 className="h-3 w-3 text-emerald-600 shrink-0" aria-label="Kopplad till transaktion" />
)}
{isProcessed && (
<Check className="h-3 w-3 text-emerald-600 shrink-0" />
{isBooked && (
<Check className="h-3 w-3 text-emerald-600 shrink-0" aria-label="Bokförd" />
)}
</div>
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
@@ -977,7 +1075,7 @@ function InboxRow({
// ── Document preview pane ────────────────────────────────────
// (placed below the row so editors can fold the row cleanly)
function DocumentPreview({
export function DocumentPreview({
docUrl,
docMime,
isProcessing = false,
@@ -1262,6 +1360,9 @@ function FieldsRail({
accountingMethod,
onDelete,
onBookDirect,
onMatchTransaction,
onUnmatchTransaction,
onAskAssistant,
isDeleting,
onFieldsUpdated,
onRetryRequested,
@@ -1270,6 +1371,9 @@ function FieldsRail({
accountingMethod: AccountingMethod
onDelete: () => void
onBookDirect: () => void
onMatchTransaction: () => void
onUnmatchTransaction: () => Promise<void>
onAskAssistant?: (transactionId: string) => void
isDeleting: boolean
onFieldsUpdated: (data: InvoiceExtractionResult) => void
onRetryRequested: () => Promise<void>
@@ -1278,9 +1382,11 @@ function FieldsRail({
const data = item.extracted_data
const isProcessed = !!item.created_supplier_invoice_id
const isBookedDirectly = !isProcessed && !!item.created_journal_entry_id
const isLinkedToTransaction =
!isProcessed && !isBookedDirectly && !!item.matched_transaction_id
const isResolved = isProcessed || isBookedDirectly || isLinkedToTransaction
// "Resolved" now means a journal entry exists — matched_transaction_id alone
// is not resolved, it's the prerequisite for booking against that tx.
const isLinkedToTransaction = !isProcessed && !isBookedDirectly && !!item.matched_transaction_id
const isResolved = isProcessed || isBookedDirectly
const [isUnmatchingTx, setIsUnmatchingTx] = useState(false)
const [isRetrying, setIsRetrying] = useState(false)
// Surface a quiet hint when extraction caught a supplier name but no existing
@@ -1431,43 +1537,79 @@ function FieldsRail({
</Button>
</Link>
) : isLinkedToTransaction && item.matched_transaction_id ? (
<Link href={`/transactions?highlight=${item.matched_transaction_id}`} className="block">
<Button variant="default" size="sm" className="w-full">
<ArrowRight className="h-3.5 w-3.5 mr-1.5" />
Bokför transaktionen
</Button>
</Link>
) : accountingMethod === 'cash' ? (
<>
<Button
variant="default"
size="sm"
className="w-full"
onClick={onBookDirect}
>
Bokför direkt
</Button>
<Link href={`/supplier-invoices/new?inbox_item_id=${item.id}`} className="block">
<Button variant="outline" size="sm" className="w-full">
Skapa leverantörsfaktura
{/* Matched-to-tx state: show the bridge to booking. The user
picks one of two actions book themselves with the
deterministic dialog, or hand off to the assistant. */}
<div className="rounded-md border border-success/30 bg-success/5 px-3 py-2 text-xs">
<div className="flex items-center gap-1.5 text-success font-medium mb-1">
<Link2 className="h-3 w-3" />
Matchad mot transaktion
</div>
<Link
href={`/transactions?highlight=${item.matched_transaction_id}`}
className="text-muted-foreground hover:text-foreground hover:underline"
>
Öppna transaktionen
</Link>
</div>
{onAskAssistant && (
<Button
variant="default"
size="sm"
className="w-full"
onClick={() => onAskAssistant(item.matched_transaction_id!)}
>
Fråga assistenten
</Button>
</Link>
</>
) : (
<>
<Link href={`/supplier-invoices/new?inbox_item_id=${item.id}`} className="block">
<Button variant="default" size="sm" className="w-full">
Skapa leverantörsfaktura
</Button>
</Link>
)}
<Button
variant="outline"
size="sm"
className="w-full"
onClick={onBookDirect}
>
Bokför direkt
Bokför manuellt
</Button>
<button
type="button"
onClick={async () => {
setIsUnmatchingTx(true)
try {
await onUnmatchTransaction()
} finally {
setIsUnmatchingTx(false)
}
}}
disabled={isUnmatchingTx}
className="w-full text-xs text-muted-foreground hover:text-foreground hover:underline pt-1"
>
{isUnmatchingTx ? 'Avbryter…' : 'Avbryt matchning'}
</button>
</>
) : (
<>
{/* Unmatched state: the canonical next step is to find the bank
transaction this underlag belongs to. "Skapa leverantörs-
faktura" stays as an escape hatch for users who want
supplier-invoice tracking (accrual flow). The old "Bokför
direkt" escape hatch was removed its label was unclear
and the deterministic-book-without-bank-tx use case is
covered by "Matcha mot transaktion" "Bokför manuellt"
(matched state). */}
<Button
variant="default"
size="sm"
className="w-full"
onClick={onMatchTransaction}
>
Matcha mot transaktion
</Button>
<Link href={`/supplier-invoices/new?inbox_item_id=${item.id}`} className="block">
<Button variant="outline" size="sm" className="w-full">
Skapa leverantörsfaktura
</Button>
</Link>
</>
)}
<Button
@@ -1519,7 +1661,7 @@ function FieldsRail({
// ── Extracted fields list ────────────────────────────────────
function emptyExtraction(): InvoiceExtractionResult {
export function emptyExtraction(): InvoiceExtractionResult {
return {
supplier: { name: null, orgNumber: null, vatNumber: null, address: null, bankgiro: null, plusgiro: null },
invoice: { invoiceNumber: null, invoiceDate: null, dueDate: null, paymentReference: null, currency: 'SEK' },
@@ -1595,7 +1737,7 @@ function buildPatchBody(key: FieldKey, raw: string, currency: string) {
return { [group]: { [name]: trimmed === '' ? null : trimmed } }
}
function EditableFieldsList({
export function EditableFieldsList({
itemId,
data,
disabled,
@@ -23,7 +23,13 @@ import {
Mail,
Phone,
Settings,
AlertTriangle,
CalendarRange,
ShieldCheck,
Users,
Receipt,
} from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import Link from 'next/link'
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
import type { TICCompanyProfile } from '@/extensions/general/tic/lib/tic-types'
@@ -38,6 +44,31 @@ function formatPercent(value: number | null): string {
return `${value.toFixed(1)} %`
}
function formatSek(value: number | null): string {
if (value === null || value === undefined) return '—'
return `${value.toLocaleString('sv-SE')} kr`
}
function formatIsoDate(iso: string | null): string {
if (!iso) return '—'
return iso.slice(0, 10)
}
function statusColorToVariant(
color: 'red' | 'yellow' | 'green' | 'neutral' | null
): 'destructive' | 'warning' | 'success' | 'secondary' {
switch (color) {
case 'red':
return 'destructive'
case 'yellow':
return 'warning'
case 'green':
return 'success'
default:
return 'secondary'
}
}
function toMs(epoch: number): number {
// TIC returns epoch seconds; Date() expects milliseconds
return epoch < 1e12 ? epoch * 1000 : epoch
@@ -388,6 +419,214 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
</Card>
</div>
{/* Status entries — most recent first; usually 1-3 rows */}
{profile.statuses.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<ShieldCheck className="h-4 w-4" />
{t('status_section')}
</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2">
{profile.statuses.slice(0, 6).map((status, i) => (
<li key={i} className="flex items-center justify-between gap-3 text-sm">
<div className="flex items-center gap-2">
<Badge variant={statusColorToVariant(status.color)}>
{status.description ?? status.code ?? '—'}
</Badge>
{status.isCeased && (
<span className="text-xs text-muted-foreground">
{t('deregistered')}
</span>
)}
</div>
<span className="text-xs text-muted-foreground tabular-nums">
{formatIsoDate(status.statusDate)}
</span>
</li>
))}
</ul>
</CardContent>
</Card>
)}
{/* Fiscal year + signatory side-by-side */}
{(profile.fiscalYear || profile.signatory.length > 0) && (
<div className="grid gap-6 md:grid-cols-2">
{profile.fiscalYear && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<CalendarRange className="h-4 w-4" />
{t('fiscal_year_section')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<p className="font-mono tabular-nums">
{t('fiscal_year_current', {
start: profile.fiscalYear.startMonthDay ?? '—',
end: profile.fiscalYear.endMonthDay ?? '—',
})}
</p>
{profile.fiscalYearHistory.length > 1 && (
<p className="text-xs text-muted-foreground">
{t('fiscal_year_changed', { n: profile.fiscalYearHistory.length - 1 })}
</p>
)}
</CardContent>
</Card>
)}
{profile.signatory.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('signatory_section')}</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
{profile.signatory.map((s, i) => (
<p key={i} className="text-muted-foreground whitespace-pre-line">
{s.description}
</p>
))}
</CardContent>
</Card>
)}
</div>
)}
{/* Board summary + representatives */}
{(profile.board || profile.representatives.length > 0) && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Users className="h-4 w-4" />
{t('board_section')}
</CardTitle>
{profile.board && (
<CardDescription className="flex flex-wrap items-center gap-x-3 gap-y-1">
{profile.board.numberOfBoardMembers !== null && (
<span>
{t('board_summary_members', { n: profile.board.numberOfBoardMembers })}
</span>
)}
{profile.board.numberOfDeputyBoardMembers !== null && profile.board.numberOfDeputyBoardMembers > 0 && (
<span>
{t('board_summary_deputies', { n: profile.board.numberOfDeputyBoardMembers })}
</span>
)}
{profile.board.hasVacancy && (
<Badge variant="warning" className="gap-1">
<AlertTriangle className="h-3 w-3" />
{t('board_vacancy')}
</Badge>
)}
{profile.board.missingCEODate && (
<span className="text-warning">
{t('board_missing_ceo', { date: formatIsoDate(profile.board.missingCEODate) })}
</span>
)}
{profile.board.missingAuditor && (
<span className="text-warning">
{t('board_missing_auditor', { date: formatIsoDate(profile.board.missingAuditor) })}
</span>
)}
</CardDescription>
)}
</CardHeader>
<CardContent>
{profile.representatives.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('col_name')}</TableHead>
<TableHead>{t('col_position')}</TableHead>
<TableHead>{t('col_since')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{profile.representatives.slice(0, 12).map((p, i) => (
<TableRow key={i}>
<TableCell className="text-sm">{p.name ?? '—'}</TableCell>
<TableCell className="text-sm">
{p.positionDescription ?? p.positionType ?? '—'}
</TableCell>
<TableCell className="text-xs tabular-nums text-muted-foreground">
{formatIsoDate(p.positionStart)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<p className="text-sm text-muted-foreground">{t('board_no_representatives')}</p>
)}
</CardContent>
</Card>
)}
{/* Payroll history — payroll2 array, newest first */}
{profile.payrolls.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Receipt className="h-4 w-4" />
{t('payroll_section')}
</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('col_payroll_period')}</TableHead>
<TableHead className="text-right">{t('col_payroll_employees')}</TableHead>
<TableHead className="text-right">{t('col_payroll_tax')}</TableHead>
<TableHead className="text-right">{t('col_payroll_personnel_costs')}</TableHead>
<TableHead className="text-right">{t('col_payroll_deviation')}</TableHead>
<TableHead className="text-right">{t('col_payroll_late_fees')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{profile.payrolls.slice(0, 10).map((p, i) => (
<TableRow key={i}>
<TableCell className="font-mono tabular-nums text-xs">
{p.periodStart && p.periodEnd
? `${formatIsoDate(p.periodStart)} ${formatIsoDate(p.periodEnd)}`
: '—'}
</TableCell>
<TableCell className="text-right text-sm tabular-nums">
{p.numberOfEmployees !== null ? p.numberOfEmployees.toFixed(0) : '—'}
</TableCell>
<TableCell className="text-right text-sm tabular-nums">
{formatSek(p.sumPayrollTax)}
</TableCell>
<TableCell className="text-right text-sm tabular-nums">
{formatSek(p.calculatedPersonnelCosts)}
</TableCell>
<TableCell
className={`text-right text-sm tabular-nums ${
p.deviation !== null && Math.abs(p.deviation) > 0.1
? 'text-warning'
: 'text-muted-foreground'
}`}
>
{p.deviation !== null ? `${(p.deviation * 100).toFixed(1)} %` : '—'}
</TableCell>
<TableCell
className={`text-right text-sm tabular-nums ${
(p.numberOfLateFeesForPeriod ?? 0) > 0 ? 'text-destructive' : 'text-muted-foreground'
}`}
>
{p.numberOfLateFeesForPeriod ?? 0}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{/* Financial reports table */}
{profile.financialReports.length > 0 && (
<Card>
+464
View File
@@ -0,0 +1,464 @@
'use client'
import { useState, useEffect, useMemo } from 'react'
import { createClient } from '@/lib/supabase/client'
import { useCompany } from '@/contexts/CompanyContext'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { useToast } from '@/components/ui/use-toast'
import { cn, formatCurrency, formatDate } from '@/lib/utils'
import { Loader2, Search } from 'lucide-react'
import { Input } from '@/components/ui/input'
import {
amountVarianceForMatch,
calculateMatchConfidence,
calculateMerchantSimilarity,
} from '@/lib/documents/core-receipt-matcher'
import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
import type { InvoiceExtractionResult } from '@/types'
// TransactionMatchPicker
//
// Opens from the InvoiceInboxWorkspace FieldsRail when the user clicks
// "Matcha mot transaktion" on an inbox item whose matched_transaction_id is
// null. Lists *uncategorised* transactions only — matching points an underlag
// at the bank payment you're about to book, so already-booked rows are out of
// scope (and surfacing them would invite a double-booking via "Bokför
// manuellt"). Two modes:
// • Suggested (no search): every uncategorised company transaction, scored
// via lib/documents/core-receipt-matcher and sorted best-first. No date
// window — over-fetching is cheap for a manual picker and a hard window
// silently dropped late payments out of the candidate set.
// • Search (≥2 chars): the same uncategorised set across ALL dates, narrowed
// server-side by description/merchant. This is the fix for "not all
// transactions come up to search for" — the old client-only filter could
// only see the windowed rows already fetched.
// Amounts are compared currency-aware (see scoring memo): same-currency raw,
// otherwise normalised to SEK via the underlag's FX rate. User picks one →
// POST /items/:id/match-transaction → onMatched callback.
interface RawTransaction {
id: string
date: string
description: string | null
merchant_name: string | null
amount: number
currency: string | null
amount_sek: number | null
exchange_rate: number | null
}
interface CandidateTransaction {
id: string
date: string
description: string | null
merchant_name: string | null
amount: number
currency: string
/** SEK-equivalent for non-SEK transactions, for the "≈ … kr" hint. */
amountSek: number | null
confidence: number
reasons: string[]
}
interface Props {
open: boolean
onClose: () => void
inboxItemId: string
extractedData: InvoiceExtractionResult | null
onMatched: (transactionId: string) => void
}
// Currencies the /api/currency/rate endpoint (Riksbanken) can resolve. Used
// to normalise a foreign-currency underlag total into SEK so it can be
// compared against SEK bank charges.
const SUPPORTED_FX = ['EUR', 'USD', 'GBP', 'NOK', 'DKK']
// Date tolerance used only for *ranking* candidates (not for filtering — there
// is no longer a date window). Far wider than the receipt matcher's tight ±3d
// default so a payment landing weeks or months after the invoice still earns
// partial date credit and the true match floats to the top, instead of every
// candidate collapsing to "Svag match".
const MATCH_DATE_TOLERANCE_DAYS = 120
// Minimum characters before a keystroke flips the picker into server-side
// search mode. Below this we stay on the scored suggestions and just narrow
// them client-side for instant feedback.
const SEARCH_MIN_CHARS = 2
export default function TransactionMatchPicker({
open,
onClose,
inboxItemId,
extractedData,
onMatched,
}: Props) {
const supabase = useMemo(() => createClient(), [])
const { company } = useCompany()
const { toast } = useToast()
// Starts true so the first paint after opening shows skeletons, not a flash
// of the empty state before the fetch effect runs.
const [loading, setLoading] = useState(true)
const [rawRows, setRawRows] = useState<RawTransaction[]>([])
const [search, setSearch] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
const [matchingId, setMatchingId] = useState<string | null>(null)
// SEK per unit of the underlag currency (e.g. ~11.5 for EUR). null = SEK
// underlag, fetch pending, or unsupported/failed — amount matching then
// falls back to same-currency-only.
const [fxRate, setFxRate] = useState<number | null>(null)
// ── Underlag (receipt) facts pulled from extracted_data ──────
const rawInvoiceDate = extractedData?.invoice?.invoiceDate ?? null
const hasInvoiceDate = useMemo(() => {
if (!rawInvoiceDate) return false
return !Number.isNaN(new Date(rawInvoiceDate).getTime())
}, [rawInvoiceDate])
// Default to today if no date was extracted so scoring still has an anchor.
const invoiceDate = useMemo(() => {
if (!rawInvoiceDate) return new Date()
const parsed = new Date(rawInvoiceDate)
return Number.isNaN(parsed.getTime()) ? new Date() : parsed
}, [rawInvoiceDate])
const total = extractedData?.totals?.total ?? null
const receiptCurrency = (extractedData?.invoice?.currency ?? 'SEK').toUpperCase()
const supplier = extractedData?.supplier?.name ?? null
// SEK value of the underlag total. For a SEK underlag that's the total
// itself; for a foreign one it needs the fetched FX rate.
const receiptSek = useMemo(() => {
if (total == null) return null
if (receiptCurrency === 'SEK') return total
if (fxRate != null) return Math.round(total * fxRate * 100) / 100
return null
}, [total, receiptCurrency, fxRate])
// True when the underlag is in a foreign currency we can't price right now —
// amount matching is unavailable, so we say so instead of mis-ranking.
const amountMatchUnavailable =
total != null && receiptCurrency !== 'SEK' && receiptSek == null
// ── Reset transient state each time the dialog opens ─────────
useEffect(() => {
if (!open) return
setSearch('')
setDebouncedSearch('')
setRawRows([])
setLoading(true)
}, [open])
// ── Debounce the search term feeding the server query ────────
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(search), 300)
return () => clearTimeout(t)
}, [search])
// ── Fetch the underlag's FX rate (foreign currency only) ─────
useEffect(() => {
if (!open) return
setFxRate(null)
if (receiptCurrency === 'SEK' || !SUPPORTED_FX.includes(receiptCurrency)) return
let cancelled = false
const dateParam = hasInvoiceDate && rawInvoiceDate ? `&date=${rawInvoiceDate}` : ''
fetch(`/api/currency/rate?currency=${receiptCurrency}${dateParam}`)
.then((r) => (r.ok ? r.json() : null))
.then((body) => {
if (cancelled) return
const rate = body?.data?.rate
if (typeof rate === 'number' && rate > 0) setFxRate(rate)
})
.catch(() => {
/* leave null — cross-currency amount signal simply drops out */
})
return () => {
cancelled = true
}
}, [open, receiptCurrency, hasInvoiceDate, rawInvoiceDate])
// ── Fetch candidate rows (suggested vs search mode) ──────────
useEffect(() => {
if (!open) return
if (!company) return // provider still hydrating
let cancelled = false
setLoading(true)
;(async () => {
// Strip PostgREST filter-DSL structural chars before interpolating into
// `.or()`. Commas separate OR-conditions and parentheses group nested
// filters, so leaving them in would let a search term inject a synthetic
// clause (e.g. "Acme,company_id.neq.…"). `%` and `\` are LIKE wildcards/
// escapes we also drop. `.` is safe — it stays inside the ilike value.
const safe = debouncedSearch.trim().replace(/[%,()\\]/g, ' ').trim()
const searchMode = safe.length >= SEARCH_MIN_CHARS
// Defense-in-depth: RLS only narrows to "any company the user belongs
// to". Multi-tenant users (consultants) would otherwise see other
// companies' transactions. Filter to the active company explicitly.
// Uncategorised only (journal_entry_id IS NULL) in both modes — see the
// component header. The search just adds a server-side name filter so
// matches outside the suggested set still surface.
let query = supabase
.from('transactions')
.select(
'id, date, description, merchant_name, amount, currency, amount_sek, exchange_rate',
)
.eq('company_id', company.id)
.is('journal_entry_id', null)
if (searchMode) {
query = query
.or(`description.ilike.%${safe}%,merchant_name.ilike.%${safe}%`)
.order('date', { ascending: false })
.limit(100)
} else {
// Recent first; scoring floats the best match up regardless of date.
query = query.order('date', { ascending: false }).limit(300)
}
const { data, error } = await query
if (cancelled) return
if (error) {
toast({
title: 'Kunde inte hämta transaktioner',
description: error.message,
variant: 'destructive',
})
setRawRows([])
setLoading(false)
return
}
setRawRows((data ?? []) as RawTransaction[])
setLoading(false)
})()
return () => {
cancelled = true
}
}, [open, supabase, company, debouncedSearch, toast])
// ── Score + sort (currency-aware) ────────────────────────────
const candidates = useMemo<CandidateTransaction[]>(() => {
const scored = rawRows.map((tx) => {
const txCurrency = (tx.currency ?? 'SEK').toUpperCase()
const txSek =
txCurrency === 'SEK'
? tx.amount
: resolveSekAmount(tx.amount, tx.amount_sek, tx.currency, tx.exchange_rate)
// Currency-aware variance — null when uncomparable, which makes the
// matcher drop the amount signal instead of matching 750 EUR to 750 SEK.
const amountVariance = amountVarianceForMatch(
total,
receiptCurrency,
receiptSek,
tx.amount,
txCurrency,
txSek,
)
const dateVariance = Math.abs(
(new Date(tx.date).getTime() - invoiceDate.getTime()) / (1000 * 60 * 60 * 24),
)
const merchant = tx.merchant_name || tx.description || ''
const similarity = supplier ? calculateMerchantSimilarity(supplier, merchant) : 0
const { confidence, matchReasons } = calculateMatchConfidence(
dateVariance,
amountVariance,
similarity,
MATCH_DATE_TOLERANCE_DAYS,
)
return {
id: tx.id,
date: tx.date,
description: tx.description ?? null,
merchant_name: tx.merchant_name ?? null,
amount: tx.amount,
currency: txCurrency,
amountSek: txCurrency === 'SEK' ? null : Math.round(txSek * 100) / 100,
confidence,
reasons: matchReasons,
}
})
scored.sort((a, b) => b.confidence - a.confidence)
return scored
}, [rawRows, invoiceDate, total, receiptCurrency, receiptSek, supplier])
// Instant client-side narrowing while the debounced server query catches up.
const filtered = useMemo(() => {
const q = search.trim().toLowerCase()
if (!q) return candidates
return candidates.filter((c) => {
const hay = `${c.description ?? ''} ${c.merchant_name ?? ''}`.toLowerCase()
return hay.includes(q)
})
}, [candidates, search])
// Any typed text counts as "searching" for labelling/empty-state purposes
// (1 char narrows the suggested set client-side; ≥2 also hits the server).
const hasSearchText = search.trim().length > 0
async function handlePick(transactionId: string) {
setMatchingId(transactionId)
try {
const res = await fetch(
`/api/extensions/ext/invoice-inbox/items/${inboxItemId}/match-transaction`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ transaction_id: transactionId }),
},
)
const json = (await res.json().catch(() => ({}))) as { error?: string }
if (!res.ok) {
toast({
title: 'Kunde inte matcha',
description: json.error ?? `HTTP ${res.status}`,
variant: 'destructive',
})
return
}
onMatched(transactionId)
onClose()
} finally {
setMatchingId(null)
}
}
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Matcha mot transaktion</DialogTitle>
<DialogDescription>
Välj banktransaktionen som hör till underlaget.
</DialogDescription>
</DialogHeader>
{/* Underlag reference what we're matching against, so a currency or
amount mismatch with a candidate is obvious at a glance. */}
{(total != null || supplier) && (
<div className="rounded-md border bg-muted/30 px-3 py-2 text-xs flex items-center gap-x-3 gap-y-1 flex-wrap">
<span className="text-muted-foreground shrink-0">Underlag</span>
{supplier && <span className="font-medium truncate">{supplier}</span>}
{total != null && (
<span className="tabular-nums font-medium shrink-0">
{formatCurrency(total, receiptCurrency)}
{receiptSek != null && receiptCurrency !== 'SEK' && (
<span className="text-muted-foreground font-normal">
{' '}
{formatCurrency(receiptSek, 'SEK')}
</span>
)}
</span>
)}
{hasInvoiceDate && rawInvoiceDate && (
<span className="text-muted-foreground tabular-nums shrink-0">
{formatDate(rawInvoiceDate)}
</span>
)}
</div>
)}
{amountMatchUnavailable && (
<p className="text-[11px] text-muted-foreground -mt-1">
Växelkurs saknas för {receiptCurrency} kandidaterna rankas datum
och leverantör, inte belopp.
</p>
)}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Sök i alla transaktioner…"
className="pl-9"
/>
</div>
<div className="flex items-center justify-between px-1 text-[11px] text-muted-foreground">
<span>{hasSearchText ? 'Sökresultat' : 'Föreslagna matchningar'}</span>
{!loading && <span className="tabular-nums">{filtered.length} st</span>}
</div>
<div className="max-h-[55vh] overflow-y-auto -mx-6 px-6 divide-y">
{loading ? (
<div className="space-y-3 py-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
) : filtered.length === 0 ? (
<p className="py-6 text-sm text-muted-foreground text-center">
{hasSearchText
? `Inga okategoriserade transaktioner matchar "${search.trim()}".`
: 'Inga okategoriserade transaktioner att matcha mot.'}
</p>
) : (
filtered.map((c) => {
const tier =
c.confidence >= 0.8 ? 'success' : c.confidence >= 0.5 ? 'warning' : 'outline'
const tierLabel =
c.confidence >= 0.8
? 'Stark match'
: c.confidence >= 0.5
? 'Möjlig match'
: 'Svag match'
const isMatching = matchingId === c.id
return (
<button
key={c.id}
type="button"
onClick={() => void handlePick(c.id)}
disabled={!!matchingId}
className={cn(
'w-full text-left flex items-center gap-3 py-3 hover:bg-muted/50 transition-colors px-2 -mx-2 rounded',
matchingId && !isMatching && 'opacity-50',
)}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 min-w-0">
<span className="text-sm font-medium truncate">
{c.merchant_name ?? c.description ?? 'Okänd transaktion'}
</span>
<Badge variant={tier} className="shrink-0 text-[10px]">
{tierLabel}
</Badge>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground tabular-nums">
<span>{formatDate(c.date)}</span>
{c.reasons.length > 0 && (
<>
<span>·</span>
<span className="truncate">{c.reasons.join(' · ')}</span>
</>
)}
</div>
</div>
<div className="text-right shrink-0">
<p className="text-sm font-medium tabular-nums">
{formatCurrency(c.amount, c.currency)}
</p>
{c.amountSek != null && (
<p className="text-[11px] text-muted-foreground tabular-nums">
{formatCurrency(c.amountSek, 'SEK')}
</p>
)}
</div>
{isMatching && <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
</button>
)
})
)}
</div>
</DialogContent>
</Dialog>
)
}
+13 -166
View File
@@ -1,15 +1,15 @@
'use client'
import { useState, useTransition } from 'react'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import Link from 'next/link'
import { Building2, ArrowRight, Loader2, Plus, Check, AlertTriangle } from 'lucide-react'
import { Building2, ArrowRight, Loader2, Plus, AlertTriangle } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useToast } from '@/components/ui/use-toast'
import { switchCompany, createCompanyFromTicRole } from '@/lib/company/actions'
import { switchCompany } from '@/lib/company/actions'
import { mapEntityType } from '@/lib/company-lookup/entity-type-map'
import type { CompanyLookupResult, EnrichmentCompanyRole } from '@/lib/company-lookup/types'
import type { EnrichmentCompanyRole } from '@/lib/company-lookup/types'
import { getBranding } from '@/lib/branding/service'
const branding = getBranding()
@@ -39,7 +39,6 @@ interface BankIdCompanyPickerProps {
type SetupState =
| { kind: 'idle' }
| { kind: 'opening'; companyId: string }
| { kind: 'creating'; orgNumber: string; step: 'lookup' | 'provision' }
// Swedish legal entity names (Aktiebolag, Enskild firma, etc.) are statutory
// terms — kept in Swedish in both locales.
@@ -79,12 +78,11 @@ export default function BankIdCompanyPicker({
const { toast } = useToast()
const t = useTranslations('select_company')
const [setup, setSetup] = useState<SetupState>({ kind: 'idle' })
const [isPending, startTransition] = useTransition()
const hour = new Date().getHours()
const greeting = hour < 5 ? t('greeting_night') : hour < 10 ? t('greeting_morning') : hour < 14 ? t('greeting_hello') : hour < 18 ? t('greeting_afternoon') : t('greeting_evening')
const busy = setup.kind !== 'idle' || isPending
const busy = setup.kind !== 'idle'
async function handleOpenMember(companyId: string) {
if (busy) return
@@ -98,167 +96,16 @@ export default function BankIdCompanyPicker({
window.location.assign('/')
}
async function handleCreateFromTic(role: EnrichmentCompanyRole) {
// BankID picker no longer one-click-provisions. Every pick routes to the
// onboarding wizard with the orgnr (and entity_type via the server-side
// CompanyRoles match in /onboarding) pre-filled. F-skatt / VAT / address
// are confirmed by the user in Steps 2-4 instead of being auto-fetched
// from TIC — costs us ~1 Lens call per signup but avoids any TIC budget
// spend (companyRoles is on the Identity API, which is a separate quota).
function handleCreateFromTic(role: EnrichmentCompanyRole) {
if (busy) return
const orgNumber = role.companyRegistrationNumber.replace(/[\s-]/g, '')
const mapped = mapEntityType(role.legalEntityType)
if (!mapped) {
// Entity type isn't supported end-to-end — route to manual wizard with
// the org number pre-filled.
router.push(`/onboarding?org_number=${encodeURIComponent(orgNumber)}`)
return
}
setSetup({ kind: 'creating', orgNumber, step: 'lookup' })
let lookup: CompanyLookupResult | null = null
try {
const res = await fetch(
`/api/extensions/ext/tic/lookup?org_number=${encodeURIComponent(orgNumber)}`,
{ method: 'GET' },
)
if (res.ok) {
const json = await res.json()
lookup = (json?.data as CompanyLookupResult | undefined) ?? null
}
} catch {
// Network/parse failure — fall through to the lookup-missing branch below.
}
// Without a lookup we don't know the company's VAT/F-skatt status.
// Defaulting those to false for a momsregistrerat bolag would silently
// create a company that issues invoices without moms — ML 17 kap violation.
// Route to the manual wizard with the known fields pre-filled instead.
if (!lookup) {
toast({
title: t('toast_lookup_failed_title'),
description: t('toast_lookup_failed_description'),
})
setSetup({ kind: 'idle' })
router.push(`/onboarding?org_number=${encodeURIComponent(orgNumber)}`)
return
}
// Block provisioning for companies that are avregistrerade/likviderade.
// Under BFL 2 kap, bokföringsskyldighet ends when a company is struck off.
if (lookup.isCeased) {
toast({
title: t('toast_company_ceased_title'),
description: t('toast_company_ceased_description'),
variant: 'destructive',
})
setSetup({ kind: 'idle' })
return
}
setSetup({ kind: 'creating', orgNumber, step: 'provision' })
startTransition(async () => {
const result = await createCompanyFromTicRole({
teamId,
orgNumber,
legalName: role.legalName,
legalEntityType: role.legalEntityType,
lookup,
})
if (result.error === 'lookup_missing') {
// Extremely unlikely (we just verified lookup above) but if it happens,
// the same fallback applies.
setSetup({ kind: 'idle' })
router.push(`/onboarding?org_number=${encodeURIComponent(orgNumber)}`)
return
}
if (result.error === 'org_number_exists') {
toast({
title: t('toast_company_exists_title'),
description: t('toast_company_exists_description'),
variant: 'destructive',
})
setSetup({ kind: 'idle' })
return
}
if (result.error === 'company_ceased') {
// Belt-and-suspenders: we already check lookup.isCeased client-side
// above, but the server-side guard catches any race where TIC's
// cached result differs between the two calls.
toast({
title: t('toast_company_ceased_title'),
description: t('toast_company_ceased_description'),
variant: 'destructive',
})
setSetup({ kind: 'idle' })
return
}
if (result.error === 'org_number_invalid') {
toast({
title: t('toast_org_invalid_title'),
description: t('toast_org_invalid_description'),
variant: 'destructive',
})
setSetup({ kind: 'idle' })
router.push(`/onboarding?org_number=${encodeURIComponent(orgNumber)}`)
return
}
if (result.error || !result.companyId) {
toast({
title: t('toast_create_failed_title'),
description: result.error ?? t('toast_create_failed_description'),
variant: 'destructive',
})
setSetup({ kind: 'idle' })
return
}
toast({ title: t('toast_welcome_title'), description: t('toast_company_ready') })
window.location.assign('/')
})
}
// Progress card while creating
if (setup.kind === 'creating') {
const lookupDone = setup.step === 'provision'
return (
<div className="stagger-enter">
<header className="mb-10">
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">
{greeting}{firstName ? `, ${firstName}` : ''}
</h1>
<p className="text-muted-foreground text-sm mt-1.5">{t('setting_up')}</p>
</header>
<div className="max-w-lg rounded-xl border bg-card p-6" style={{ boxShadow: 'var(--shadow-md)' }}>
<div className="flex items-start gap-3">
<Building2 className="h-5 w-5 text-muted-foreground mt-0.5" />
<div className="flex-1">
<p className="font-medium text-sm">{t('org_nr_prefix', { orgNumber: setup.orgNumber })}</p>
<ul className="mt-3 space-y-2 text-sm">
<li className="flex items-center gap-2">
{lookupDone
? <Check className="h-4 w-4 text-sage" />
: <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
<span className={cn(!lookupDone && 'text-muted-foreground')}>
{t('progress_lookup')}
</span>
</li>
<li className="flex items-center gap-2">
{lookupDone
? <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
: <span className="h-4 w-4 inline-block rounded-full border border-border" />}
<span className={cn(!lookupDone && 'text-muted-foreground/50')}>
{t('progress_provision')}
</span>
</li>
</ul>
</div>
</div>
</div>
</div>
)
router.push(`/onboarding?org_number=${encodeURIComponent(orgNumber)}`)
}
return (
+123 -22
View File
@@ -9,8 +9,10 @@ import {
FileText,
Landmark,
ArrowRightLeft,
MessageCircle,
ShieldCheck,
} from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import { getBranding } from '@/lib/branding/service'
@@ -21,16 +23,23 @@ interface NewUserChecklistProps {
onFreshStart: () => void
className?: string
/**
* Whether the active user already has a Skatteverket OAuth connection.
* When true the Skatteverket step renders as completed instead of as a CTA.
* Per-step completion flags. The render flips each step from CTA to a
* compact "done" card when its corresponding flag is true so the user
* sees their progress without having to remember what they finished.
*/
hasBookkeepingImported?: boolean
hasBankConnected?: boolean
hasSkatteverketConnected?: boolean
hasAgentBuilt?: boolean
}
export default function NewUserChecklist({
onFreshStart,
className,
hasBookkeepingImported,
hasBankConnected,
hasSkatteverketConnected,
hasAgentBuilt,
}: NewUserChecklistProps) {
const t = useTranslations('new_user_checklist')
const hasMigration = ENABLED_EXTENSION_IDS.has('arcim-migration')
@@ -40,9 +49,11 @@ export default function NewUserChecklist({
return (
<div className={cn('min-h-[75vh] flex flex-col items-center justify-center px-4 sm:px-0 stagger-enter', className)}>
<div className="w-full max-w-2xl">
{/* Header */}
{/* Header centered welcome. Data-import steps lead; building the
assistant is the last step so a user coming from another system
brings their books in first. */}
<div className="text-center mb-8 md:mb-12">
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">
<h1 className="font-display text-2xl md:text-3xl tracking-tight">
{t('welcome', { appName: branding.appName.toLowerCase() })}
</h1>
<p className="text-muted-foreground text-sm md:text-base leading-relaxed max-w-md mx-auto mt-3">
@@ -53,19 +64,30 @@ export default function NewUserChecklist({
{/* Step 1: Migrate bookkeeping */}
<div className="mb-6 md:mb-8">
<div className="flex items-center gap-3 mb-4">
<span className="h-7 w-7 rounded-full bg-foreground text-background flex items-center justify-center text-xs font-semibold flex-shrink-0 tabular-nums">
1
<span className={cn(
'h-7 w-7 rounded-full flex items-center justify-center text-xs font-semibold flex-shrink-0 tabular-nums',
hasBookkeepingImported
? 'bg-secondary text-foreground'
: 'bg-foreground text-background',
)}>
{hasBookkeepingImported ? <CheckCircle2 className="h-4 w-4" /> : '1'}
</span>
<h2 className="font-display text-base font-medium tracking-tight">
<h2 className="font-display text-base tracking-tight">
{t('step1_title')}
</h2>
</div>
{hasBookkeepingImported ? (
<div className="ml-0 sm:ml-10 p-4 sm:p-5 rounded-lg border border-border bg-secondary/40">
<p className="text-sm text-foreground font-medium">{t('step1_done_title')}</p>
<p className="text-xs sm:text-sm text-muted-foreground mt-1">{t('step1_done_description')}</p>
</div>
) : (
<div className="space-y-3 ml-0 sm:ml-10">
{hasMigration && (
<Link
href="/import?mode=migration"
className="group block p-4 sm:p-5 rounded-xl border border-primary/20 bg-primary/[0.02] hover:bg-primary/[0.05] hover:border-primary/40 transition-all duration-150 active:scale-[0.99]"
className="group block p-4 sm:p-5 rounded-lg border border-border bg-secondary/40 hover:bg-secondary/60 hover:border-primary/40 transition-colors duration-150"
>
<div className="flex items-start gap-3 sm:gap-4">
<div className="p-2 sm:p-2.5 rounded-lg bg-primary/[0.08] group-hover:bg-primary/[0.12] transition-colors flex-shrink-0">
@@ -85,9 +107,14 @@ export default function NewUserChecklist({
{ name: 'Bokio', logo: '/logos/bokio.png' },
{ name: 'Björn Lundén', logo: '/logos/bjornlunden.png' },
{ name: 'Briox', logo: '/logos/Briox_logo.png' },
{ name: 'SIE4-fil', logo: null },
] as const).map(provider => (
<div key={provider.name} className="flex items-center gap-1 sm:gap-1.5 rounded border border-border/60 bg-muted/30 px-1.5 sm:px-2 py-0.5 sm:py-1">
<img src={provider.logo} alt={provider.name} className="h-3.5 w-3.5 sm:h-4 sm:w-4 shrink-0 rounded-sm object-contain" />
<div key={provider.name} className="flex items-center gap-1 sm:gap-1.5 rounded border border-border bg-muted/30 px-1.5 sm:px-2 py-0.5 sm:py-1">
{provider.logo ? (
<img src={provider.logo} alt={provider.name} className="h-3.5 w-3.5 sm:h-4 sm:w-4 shrink-0 rounded-sm object-contain" />
) : (
<FileText className="h-3.5 w-3.5 sm:h-4 sm:w-4 shrink-0 text-muted-foreground" />
)}
<span className="text-[10px] sm:text-[11px] font-medium text-muted-foreground">{provider.name}</span>
</div>
))}
@@ -100,7 +127,7 @@ export default function NewUserChecklist({
<Link
href="/import?mode=sie"
className="group block p-4 sm:p-5 rounded-xl border border-border/60 hover:border-primary/40 hover:bg-primary/[0.02] transition-all duration-150 active:scale-[0.99]"
className="group block p-4 sm:p-5 rounded-lg border border-border hover:border-primary/40 hover:bg-primary/[0.02] transition-colors duration-150"
>
<div className="flex items-start gap-3 sm:gap-4">
<div className="p-2 sm:p-2.5 rounded-lg bg-muted/60 group-hover:bg-primary/[0.08] transition-colors flex-shrink-0">
@@ -118,23 +145,35 @@ export default function NewUserChecklist({
</div>
</Link>
</div>
)}
</div>
{/* Step 2: Connect bank */}
<div className="mb-6 md:mb-8">
<div className="flex items-center gap-3 mb-4">
<span className="h-7 w-7 rounded-full bg-foreground text-background flex items-center justify-center text-xs font-semibold flex-shrink-0 tabular-nums">
2
<span className={cn(
'h-7 w-7 rounded-full flex items-center justify-center text-xs font-semibold flex-shrink-0 tabular-nums',
hasBankConnected
? 'bg-secondary text-foreground'
: 'bg-foreground text-background',
)}>
{hasBankConnected ? <CheckCircle2 className="h-4 w-4" /> : '2'}
</span>
<h2 className="font-display text-base font-medium tracking-tight">
<h2 className="font-display text-base tracking-tight">
{t('step2_title')}
</h2>
</div>
<div className="ml-0 sm:ml-10">
{hasBankConnected ? (
<div className="p-4 sm:p-5 rounded-lg border border-border bg-secondary/40">
<p className="text-sm text-foreground font-medium">Bank kopplad</p>
<p className="text-xs sm:text-sm text-muted-foreground mt-1">Transaktioner synkas automatiskt.</p>
</div>
) : (
<Link
href={hasBanking ? '/import?mode=psd2' : '/import?mode=bank'}
className="group block p-4 sm:p-5 rounded-xl border border-border/60 hover:border-primary/40 hover:bg-primary/[0.02] transition-all duration-150 active:scale-[0.99]"
className="group block p-4 sm:p-5 rounded-lg border border-border hover:border-primary/40 hover:bg-primary/[0.02] transition-colors duration-150"
>
<div className="flex items-start gap-3 sm:gap-4">
<div className="p-2 sm:p-2.5 rounded-lg bg-muted/60 group-hover:bg-primary/[0.08] transition-colors flex-shrink-0">
@@ -153,6 +192,7 @@ export default function NewUserChecklist({
<ArrowRight className="h-4 w-4 text-muted-foreground/40 group-hover:text-primary/60 mt-1 flex-shrink-0 transition-colors" />
</div>
</Link>
)}
</div>
</div>
@@ -168,14 +208,14 @@ export default function NewUserChecklist({
<span className={cn(
'h-7 w-7 rounded-full flex items-center justify-center text-xs font-semibold flex-shrink-0 tabular-nums',
hasSkatteverketConnected
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300'
? 'bg-secondary text-foreground'
: 'bg-foreground text-background',
)}>
{hasSkatteverketConnected
? <CheckCircle2 className="h-4 w-4" />
: '3'}
</span>
<h2 className="font-display text-base font-medium tracking-tight">
<h2 className="font-display text-base tracking-tight">
{t('step3_title')}
</h2>
<span className="text-xs text-muted-foreground">{t('optional_suffix')}</span>
@@ -183,13 +223,13 @@ export default function NewUserChecklist({
<div className="ml-0 sm:ml-10">
{hasSkatteverketConnected ? (
<div className="block p-4 sm:p-5 rounded-xl border border-emerald-500/30 bg-emerald-500/[0.04]">
<div className="block p-4 sm:p-5 rounded-lg border border-border bg-secondary/40">
<div className="flex items-start gap-3 sm:gap-4">
<div className="p-2 sm:p-2.5 rounded-lg bg-emerald-500/[0.10] flex-shrink-0">
<FileCheck className="h-4 w-4 sm:h-5 sm:w-5 text-emerald-700 dark:text-emerald-400" />
<div className="p-2 sm:p-2.5 rounded-lg bg-background flex-shrink-0">
<FileCheck className="h-4 w-4 sm:h-5 sm:w-5 text-foreground" />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm sm:text-base text-emerald-900 dark:text-emerald-200">
<p className="font-medium text-sm sm:text-base text-foreground">
{t('skatteverket_connected_title')}
</p>
<p className="text-xs sm:text-sm text-muted-foreground mt-1 sm:mt-1.5 leading-relaxed">
@@ -205,7 +245,7 @@ export default function NewUserChecklist({
// skatteverket.se; <Link> would route via Next's client
// router which doesn't follow cross-origin redirects.
href="/api/extensions/ext/skatteverket/authorize?return_to=/"
className="group block p-4 sm:p-5 rounded-xl border border-border/60 hover:border-primary/40 hover:bg-primary/[0.02] transition-all duration-150 active:scale-[0.99]"
className="group block p-4 sm:p-5 rounded-lg border border-border hover:border-primary/40 hover:bg-primary/[0.02] transition-colors duration-150"
>
<div className="flex items-start gap-3 sm:gap-4">
<div className="p-2 sm:p-2.5 rounded-lg bg-muted/60 group-hover:bg-primary/[0.08] transition-colors flex-shrink-0">
@@ -227,6 +267,67 @@ export default function NewUserChecklist({
</div>
)}
{/* Build the assistant always the last step, so a user migrating
from another system brings their books in first. */}
<div className="mb-8 md:mb-12">
<div className="flex items-center gap-3 mb-4">
<span className={cn(
'h-7 w-7 rounded-full flex items-center justify-center text-xs font-semibold flex-shrink-0 tabular-nums',
hasAgentBuilt
? 'bg-secondary text-foreground'
: 'bg-foreground text-background',
)}>
{hasAgentBuilt ? <CheckCircle2 className="h-4 w-4" /> : (hasSkatteverket ? '4' : '3')}
</span>
<h2 className="font-display text-base tracking-tight">
Skapa din assistent
</h2>
</div>
<div className="ml-0 sm:ml-10">
{hasAgentBuilt ? (
<div className="p-4 sm:p-5 rounded-lg border border-border bg-secondary/40">
<div className="flex items-start gap-3 sm:gap-4">
<div className="p-2 sm:p-2.5 rounded-lg bg-background flex-shrink-0">
<MessageCircle className="h-4 w-4 sm:h-5 sm:w-5 text-foreground" />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm sm:text-base text-foreground">
Assistenten är klar
</p>
<p className="text-xs sm:text-sm text-muted-foreground mt-1 sm:mt-1.5 leading-relaxed">
Du kan börja chatta direkt. Justera tonalitet och kunskap i Inställningar &gt; Assistentens minne.
</p>
</div>
</div>
</div>
) : (
<Link
href="/onboarding/agent"
className="group block p-4 sm:p-5 rounded-lg border border-border hover:border-primary/40 hover:bg-primary/[0.02] transition-colors duration-150"
>
<div className="flex items-start gap-3 sm:gap-4">
<div className="p-2 sm:p-2.5 rounded-lg bg-muted/60 group-hover:bg-primary/[0.08] transition-colors flex-shrink-0">
<MessageCircle className="h-4 w-4 sm:h-5 sm:w-5 text-muted-foreground group-hover:text-primary transition-colors" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="font-medium group-hover:text-primary transition-colors text-sm sm:text-base">
Bygg din bokföringsassistent
</p>
<Badge variant="secondary" className="uppercase tracking-wider">Beta</Badge>
</div>
<p className="text-xs sm:text-sm text-muted-foreground mt-1 sm:mt-1.5 leading-relaxed">
Några frågor om din verksamhet kalibrerar tonalitet, signatur och vad assistenten kan. Ju mer du delar, desto bättre förstår den dig.
</p>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground/40 group-hover:text-primary/60 mt-1 flex-shrink-0 transition-colors" />
</div>
</Link>
)}
</div>
</div>
{/* Escape hatch */}
<div className="space-y-5">
<div className="flex items-center gap-4">
@@ -41,6 +41,12 @@ interface Step2Props {
onBack: () => void
isSaving: boolean
orgNumberLocked?: boolean
// Orgnr we already trust without a Lens call — typically because it came
// from BankID CompanyRoles which confirms the user has a director role at
// this company. When set and the form's orgnr matches, Step 2 skips the
// debounced `/lookup` to avoid burning a Lens call on something we know
// exists. The guard clears as soon as the user edits the field.
preverifiedOrgNumber?: string | null
}
export default function Step2CompanyDetails({
@@ -52,6 +58,7 @@ export default function Step2CompanyDetails({
onBack,
isSaving,
orgNumberLocked,
preverifiedOrgNumber,
}: Step2Props) {
const t = useTranslations('onboarding')
const {
@@ -78,6 +85,14 @@ export default function Step2CompanyDetails({
const [orgNumberExists, setOrgNumberExists] = useState(false)
const abortRef = useRef<AbortController | null>(null)
const dupAbortRef = useRef<AbortController | null>(null)
// Tracks an orgnr that's been pre-verified (BankID CompanyRoles match) so
// the client-side Lens lookup is skipped for that exact value. Cleared
// (set to null) the moment the user edits the org number — a different
// orgnr is no longer covered by the BankID confirmation and needs a real
// lookup.
const prefetchedForOrgRef = useRef<string | null>(
preverifiedOrgNumber ? normalizeOrgNumber(preverifiedOrgNumber) : null,
)
const orgNumber = watch('org_number')
@@ -117,6 +132,17 @@ export default function Step2CompanyDetails({
return
}
// Server already fetched this orgnr (BankID deep-link). Don't burn a
// second TIC call to re-confirm what we already have in `initialLookup`.
// Once the user edits the field, normalizeOrgNumber(orgNumber) will
// diverge from the prefetched value and the lookup re-arms.
const normalized = normalizeOrgNumber(orgNumber)
if (prefetchedForOrgRef.current && normalized === prefetchedForOrgRef.current) {
return
}
// Any subsequent edit invalidates the prefetched-match guard for good.
prefetchedForOrgRef.current = null
setLookupError(null)
setLookupDone(null)
+34
View File
@@ -0,0 +1,34 @@
'use client'
import { useRouter } from 'next/navigation'
import NewUserChecklist from './NewUserChecklist'
interface Props {
companyId: string
hasBookkeepingImported: boolean
hasBankConnected: boolean
hasSkatteverketConnected: boolean
}
// Thin client wrapper around NewUserChecklist, shown only to a genuinely empty
// company (no data, no assistant). The data-import steps lead and building the
// assistant is the last step; the "I'm starting fresh" escape hatch forwards
// straight to /onboarding/agent for users with no books to bring in.
export default function WelcomeGate({
companyId: _companyId,
hasBookkeepingImported,
hasBankConnected,
hasSkatteverketConnected,
}: Props) {
const router = useRouter()
return (
<NewUserChecklist
hasBookkeepingImported={hasBookkeepingImported}
hasBankConnected={hasBankConnected}
hasSkatteverketConnected={hasSkatteverketConnected}
hasAgentBuilt={false}
onFreshStart={() => router.push('/onboarding/agent')}
/>
)
}
@@ -0,0 +1,291 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { Check, Loader2, Circle, AlertTriangle } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { cn } from '@/lib/utils'
import ReviewCard from './ReviewCard'
type StepId = 'tic' | 'select' | 'narrative' | 'finalize'
type StepStatus = 'pending' | 'in_progress' | 'success' | 'fallback' | 'error'
interface StepConfig {
id: StepId
label: string
fallbackLabel?: string
}
const STEPS: StepConfig[] = [
{ id: 'tic', label: 'Hämtar uppgifter', fallbackLabel: 'Inga företagsuppgifter, fortsätter ändå' },
{ id: 'select', label: 'Identifierar din verksamhet', fallbackLabel: 'Använder standardval' },
{ id: 'narrative', label: 'Sammanfattar', fallbackLabel: 'Standardsammanfattning' },
{ id: 'finalize', label: 'Klar' },
]
interface InitialFields {
entity_type_label: string
sni_codes: { code: string; name: string }[]
purpose: string | null
city: string | null
fiscal_period: string | null
vat_period: string | null
f_skatt: string | null
employees: string | null
}
interface ProfilePayload {
company_id: string
horizontal_atoms: string[]
vertical_atoms: string[]
modifier_atoms: string[]
is_multi_vertical: boolean
profile_summary: string
verification_questions: string[]
uncertainty_notes: string[]
composer_model: string
composed_at: string
}
interface Props {
companyId: string
companyName: string
firstName: string | null
initialFields: InitialFields
atomTitles: Record<string, string>
alreadyVerified: boolean
existingSummary: string | null
}
export default function AgentOnboarding({
companyId,
companyName,
firstName,
initialFields,
atomTitles,
alreadyVerified,
existingSummary,
}: Props) {
const router = useRouter()
const [phase, setPhase] = useState<'building' | 'review' | 'done'>(
alreadyVerified ? 'review' : 'building',
)
const [statuses, setStatuses] = useState<Record<StepId, StepStatus>>({
tic: 'pending',
select: 'pending',
narrative: 'pending',
finalize: 'pending',
})
// Hydrate from props during the initial render so we don't flash an empty
// ReviewCard for already-verified profiles.
const [profile, setProfile] = useState<ProfilePayload | null>(() => {
if (alreadyVerified && existingSummary) {
return {
company_id: companyId,
horizontal_atoms: [],
vertical_atoms: [],
modifier_atoms: [],
is_multi_vertical: false,
profile_summary: existingSummary,
verification_questions: [],
uncertainty_notes: [],
composer_model: '',
composed_at: '',
}
}
return null
})
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const setStatus = useCallback((id: StepId, status: StepStatus) => {
setStatuses((prev) => ({ ...prev, [id]: status }))
}, [])
// No started-ref guard: React 19 Strict Mode runs this effect twice in dev.
// The first invocation's cleanup aborts its fetch; the second invocation
// makes the request that actually completes. The stream endpoint is
// idempotent (it upserts agent_profiles on company_id), so a transient
// duplicate call during Strict Mode rerun is safe.
useEffect(() => {
if (alreadyVerified) return
const controller = new AbortController()
void runStream(companyId, controller.signal, {
setStatus,
onProfile: (p) => setProfile(p),
onError: (msg) => setErrorMessage(msg),
onComplete: () => setPhase('review'),
})
return () => {
controller.abort()
}
}, [companyId, alreadyVerified, setStatus])
if (phase === 'review' || phase === 'done') {
return (
<ReviewCard
companyId={companyId}
companyName={companyName}
initialFields={initialFields}
atomTitles={atomTitles}
profile={profile}
onVerified={() => router.push('/chat/intake')}
/>
)
}
return (
<div className="w-full">
<header className="mb-10 text-center">
<p className="text-sm uppercase tracking-wider text-muted-foreground">
{firstName ? `${firstName}, ` : ''}ett ögonblick
</p>
<h1 className="font-display text-3xl md:text-4xl tracking-tight mt-2">
Vi bygger din bokföringsassistent
</h1>
<p className="text-muted-foreground mt-3 text-balance">
Skräddarsyr för {companyName}. Tar oftast under en halv minut.
</p>
</header>
<Card className="border-border">
<CardContent className="p-6 md:p-8">
<ol className="space-y-4">
{STEPS.map((step) => (
<StepRow key={step.id} step={step} status={statuses[step.id]} />
))}
</ol>
{errorMessage && (
<div className="mt-6 flex items-start gap-3 rounded-lg border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm">
<AlertTriangle className="h-4 w-4 mt-0.5 text-destructive shrink-0" />
<div>
<p className="font-medium">Något gick fel</p>
<p className="text-muted-foreground mt-1">{errorMessage}</p>
<Button
variant="outline"
size="sm"
className="mt-3"
onClick={() => window.location.reload()}
>
Försök igen
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</div>
)
}
function StepRow({ step, status }: { step: StepConfig; status: StepStatus }) {
return (
<li className="flex items-start gap-3">
<span className="mt-0.5 shrink-0">
{status === 'success' && <Check className="h-4 w-4 text-success" />}
{status === 'fallback' && <Check className="h-4 w-4 text-warning" />}
{status === 'in_progress' && <Loader2 className="h-4 w-4 animate-spin text-foreground" />}
{status === 'pending' && <Circle className="h-4 w-4 text-muted-foreground/50" />}
{status === 'error' && <AlertTriangle className="h-4 w-4 text-destructive" />}
</span>
<span
className={cn(
'text-sm leading-6',
status === 'pending' && 'text-muted-foreground',
status === 'fallback' && 'text-muted-foreground',
)}
>
{status === 'fallback' && step.fallbackLabel ? step.fallbackLabel : step.label}
</span>
</li>
)
}
async function runStream(
companyId: string,
signal: AbortSignal,
cbs: {
setStatus: (id: StepId, status: StepStatus) => void
onProfile: (p: ProfilePayload) => void
onError: (msg: string) => void
onComplete: () => void
},
): Promise<void> {
let response: Response
try {
response = await fetch('/api/agent/onboarding/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ company_id: companyId }),
signal,
})
} catch (err) {
if (signal.aborted) return
cbs.onError(err instanceof Error ? err.message : 'Kunde inte starta byggsekvensen.')
return
}
if (!response.ok || !response.body) {
cbs.onError(`HTTP ${response.status}: kunde inte starta byggsekvensen.`)
return
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
let newlineIdx: number
while ((newlineIdx = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, newlineIdx).trim()
buffer = buffer.slice(newlineIdx + 1)
if (!line) continue
try {
const event = JSON.parse(line) as
| { step: StepId; status: StepStatus }
| { step: 'finalize'; status: 'success'; profile: ProfilePayload }
| { step: 'error'; status: 'error'; message: string }
| { step: 'prewarm'; status: StepStatus }
if (event.step === 'error') {
cbs.onError(event.message || 'Okänt fel under byggsekvensen.')
continue
}
if (event.step === 'prewarm') {
// Pre-warm runs after finalize — surface nothing in the UI; the
// user has already moved to Phase B by then.
continue
}
cbs.setStatus(event.step, event.status as StepStatus)
if (event.step === 'finalize' && 'profile' in event) {
cbs.onProfile(event.profile)
cbs.onComplete()
}
} catch {
// Malformed JSON line — keep reading.
}
}
}
} catch (err) {
if (!signal.aborted) {
cbs.onError(err instanceof Error ? err.message : 'Streamen avbröts.')
}
} finally {
try {
reader.releaseLock()
} catch {
// Already released
}
}
}
+643
View File
@@ -0,0 +1,643 @@
'use client'
import { useState } from 'react'
import { Pencil, X, Loader2, ArrowLeft, ArrowRight } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
import { AVATAR_OPTIONS } from '@/components/agent/avatars'
import AgentAvatar from '@/components/agent/AgentAvatar'
interface InitialFields {
entity_type_label: string
sni_codes: { code: string; name: string }[]
purpose: string | null
city: string | null
fiscal_period: string | null
vat_period: string | null
f_skatt: string | null
employees: string | null
}
interface ProfilePayload {
company_id: string
horizontal_atoms: string[]
vertical_atoms: string[]
modifier_atoms: string[]
is_multi_vertical: boolean
profile_summary: string
// Still carried from the composer + stored on the profile row, but no
// longer surfaced as a form step here — the Phase C chat intake owns the
// questions now (reads them server-side). Kept on the type so the payload
// shape stays aligned with the stream event.
verification_questions: string[]
uncertainty_notes: string[]
composer_model: string
composed_at: string
}
interface Props {
companyId: string
companyName: string
initialFields: InitialFields
// Pre-fetched atom titles from agent_atom_registry — used to render chips
// with the authored title instead of a naive slug-derived label. Missing
// ids fall through to deriveSlugTitle which is intentionally minimal.
atomTitles: Record<string, string>
profile: ProfilePayload | null
onVerified: () => void
}
// Maps an atom id to a chip label. Prefers the registry title when known.
function atomLabel(id: string, atomTitles: Record<string, string>): string {
if (atomTitles[id]) return atomTitles[id]
const slug = id.split('/').slice(-1)[0] ?? id
return slug
.split('-')
.map((w) => {
const upper = w.toUpperCase()
if (upper === 'VAT' || upper === 'SRU' || upper === 'SIE' || upper === 'IT') return upper
return w.length > 0 ? w[0].toUpperCase() + w.slice(1) : w
})
.join(' ')
}
export default function ReviewCard({
companyId,
companyName,
initialFields,
atomTitles,
profile,
onVerified,
}: Props) {
// Field-edit state. The pencil affordances let the user override anything
// the composer inferred. Each override is sent to PATCH /api/agent/profile,
// which stamps an overridden_at timestamp.
const [fields, setFields] = useState<InitialFields>(initialFields)
const [editing, setEditing] = useState<keyof InitialFields | null>(null)
const [summary, setSummary] = useState<string>(profile?.profile_summary ?? '')
const [editingSummary, setEditingSummary] = useState(false)
const [horizontal, setHorizontal] = useState<string[]>(profile?.horizontal_atoms ?? [])
const [vertical, setVertical] = useState<string[]>(profile?.vertical_atoms ?? [])
const [modifier, setModifier] = useState<string[]>(profile?.modifier_atoms ?? [])
// Agent identity — name shown on the FAB, avatar shown alongside.
const [displayName, setDisplayName] = useState('')
const [avatarId, setAvatarId] = useState<string>(AVATAR_OPTIONS[0].id)
const [seedMemory, setSeedMemory] = useState('')
const [verifying, setVerifying] = useState(false)
const [verifyError, setVerifyError] = useState<string | null>(null)
// Two steps now:
// 1 — meet your assistant (name + avatar)
// 2 — agree on the facts (profile + specialties + form fields + optional
// seed note), then "kör" which hands off to the Phase C chat intake.
// The verification-question interview that used to live here as a form
// stepper is gone — the chat conducts the real interview instead.
type Step = 1 | 2
const [step, setStep] = useState<Step>(1)
const totalPositions = 2
const currentPosition = step - 1
const agentName = displayName.trim() || 'din assistent'
async function handleVerify() {
setVerifying(true)
setVerifyError(null)
try {
// Persist edits before verifying. Skipped if nothing changed.
const changedFields: Record<string, unknown> = {}
for (const key of Object.keys(initialFields) as (keyof InitialFields)[]) {
if (fields[key] !== initialFields[key]) {
changedFields[key] = fields[key]
}
}
const atomsChanged =
!arrEq(horizontal, profile?.horizontal_atoms ?? []) ||
!arrEq(vertical, profile?.vertical_atoms ?? []) ||
!arrEq(modifier, profile?.modifier_atoms ?? [])
const summaryChanged = summary !== (profile?.profile_summary ?? '')
const trimmedName = displayName.trim()
// Identity is always persisted on first verify so the FAB picks it up
// immediately. If the user typed nothing, we leave display_name null
// (UI falls back to "min revisor").
const identityChanged = trimmedName.length > 0 || avatarId !== AVATAR_OPTIONS[0].id
if (Object.keys(changedFields).length > 0 || atomsChanged || summaryChanged || identityChanged) {
const patchBody: Record<string, unknown> = { company_id: companyId }
if (Object.keys(changedFields).length > 0) patchBody.field_overrides = changedFields
if (atomsChanged) {
patchBody.atoms = {
horizontal_atoms: horizontal,
vertical_atoms: vertical,
modifier_atoms: modifier,
}
}
if (summaryChanged) patchBody.profile_summary = summary
if (identityChanged) {
patchBody.display_name = trimmedName.length > 0 ? trimmedName : null
patchBody.avatar_id = avatarId
}
const res = await fetch('/api/agent/profile', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patchBody),
})
if (!res.ok) {
const text = await res.text()
throw new Error(text || `HTTP ${res.status}`)
}
}
if (seedMemory.trim().length > 1) {
await fetch('/api/agent/memory', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
company_id: companyId,
content: seedMemory.trim(),
kind: 'fact',
source: 'user_taught',
source_ref: 'onboarding_seed',
relevance_score: 1.0,
}),
})
}
const verifyRes = await fetch('/api/agent/profile/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ company_id: companyId }),
})
if (!verifyRes.ok) {
const text = await verifyRes.text()
throw new Error(text || `HTTP ${verifyRes.status}`)
}
onVerified()
} catch (err) {
setVerifyError(err instanceof Error ? err.message : 'Kunde inte verifiera.')
} finally {
setVerifying(false)
}
}
const stepTitle = step === 1 ? 'Träffa din assistent' : 'Stäm av detaljerna'
const stepSubtitle =
step === 1
? 'Ge din assistent ett namn och välj en avatar.'
: 'Bekräfta att uppgifterna stämmer, eller ändra det som blivit fel. Sen lär din assistent känna dig i en kort intervju.'
return (
<div className="w-full">
<header className="mb-6">
<p className="text-xs uppercase tracking-wider text-muted-foreground mb-1">
{companyName}
</p>
<h1 className="font-display text-3xl md:text-4xl tracking-tight">{stepTitle}</h1>
<p className="text-muted-foreground mt-2">{stepSubtitle}</p>
</header>
{/* Progress one segment per step. Back navigation lives on the
"Tillbaka" button below. */}
<div className="flex items-center gap-1.5 mb-6" aria-hidden="true">
{Array.from({ length: totalPositions }, (_, i) => i).map((pos) => (
<div
key={pos}
className={cn(
'h-1.5 flex-1 rounded-full transition-colors',
pos <= currentPosition ? 'bg-foreground' : 'bg-border',
)}
/>
))}
</div>
<Card className="border-border">
<CardContent className="p-6 md:p-8 space-y-6">
{step === 1 && (
<section className="space-y-6">
<div className="flex flex-col items-center text-center gap-3 py-4">
<AgentAvatar avatarId={avatarId} size="lg" className="h-20 w-20" />
<div>
<p className="font-display text-xl tracking-tight">
{displayName.trim() || 'Din assistent'}
</p>
<p className="text-xs text-muted-foreground mt-1">
Visas som <span className="font-medium">Fråga {displayName.trim() || 'min assistent'}</span> i appen.
</p>
</div>
</div>
<div>
<label htmlFor="agent-display-name" className="block text-sm font-medium mb-2">
Vad ska den heta?
</label>
<Input
id="agent-display-name"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="t.ex. Anna, Lars, Karin. Eller hoppa över."
maxLength={60}
autoFocus
/>
</div>
<div>
<p className="block text-sm font-medium mb-2">Välj en avatar</p>
<div className="grid grid-cols-4 gap-3 sm:grid-cols-8">
{AVATAR_OPTIONS.map((opt) => (
<button
key={opt.id}
type="button"
onClick={() => setAvatarId(opt.id)}
aria-label={`Välj avatar ${opt.label}`}
className={cn(
'aspect-square rounded-full overflow-hidden transition-all',
avatarId === opt.id
? 'ring-2 ring-foreground ring-offset-2 ring-offset-background'
: 'opacity-70 hover:opacity-100 hover:ring-1 hover:ring-border',
)}
>
<AgentAvatar avatarId={opt.id} size="md" className="h-full w-full" />
</button>
))}
</div>
</div>
</section>
)}
{step === 2 && (
<>
{/* Value first the prose summary the composer wrote, so the
user sees the assistant understood them before being asked to
check dry registry facts. */}
<section>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
här har jag förstått dig
</h2>
{!editingSummary && summary && (
<button
onClick={() => setEditingSummary(true)}
className="text-muted-foreground hover:text-foreground transition-colors"
aria-label="Redigera profil"
>
<Pencil className="h-4 w-4" />
</button>
)}
</div>
{editingSummary ? (
<textarea
value={summary}
onChange={(e) => setSummary(e.target.value)}
onBlur={() => setEditingSummary(false)}
autoFocus
rows={5}
className="w-full rounded-lg border border-border bg-background px-4 py-3 text-sm leading-6 resize-none focus:outline-none focus:ring-2 focus:ring-ring"
/>
) : (
<p className="text-sm leading-6 italic text-muted-foreground">
{summary || 'Ingen sammanfattning ännu.'}
</p>
)}
</section>
{/* What the assistant can actually do the differentiated
output of the build. Plain-language heading, not the internal
"atoms/specialiteter" framing. */}
{(horizontal.length > 0 || vertical.length > 0 || modifier.length > 0) && (
<section>
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground mb-1">
Vad jag kan hjälpa dig med
</h2>
<p className="text-xs text-muted-foreground mb-3">
Kunskapsområden jag läst in för din verksamhet. Ta bort det som inte passar, slipper du förslag som inte är relevanta.
</p>
<ChipGroup
primaryLabel="Bransch"
secondaryLabel="Övrigt"
primary={vertical.map((id) => ({ id, label: atomLabel(id, atomTitles) }))}
secondary={[
...modifier.map((id) => ({ id, label: atomLabel(id, atomTitles), group: 'modifier' as const })),
...horizontal.map((id) => ({ id, label: atomLabel(id, atomTitles), group: 'horizontal' as const })),
]}
onRemove={(id, group) => {
if (group === 'horizontal') setHorizontal((arr) => arr.filter((x) => x !== id))
else if (group === 'vertical') setVertical((arr) => arr.filter((x) => x !== id))
else setModifier((arr) => arr.filter((x) => x !== id))
}}
/>
</section>
)}
{/* Inferred facts to confirm */}
<section>
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground mb-4">
Uppgifter
</h2>
<dl className="divide-y divide-border">
<FieldRow
label="Form"
value={fields.entity_type_label}
editing={editing === 'entity_type_label'}
onEdit={() => setEditing('entity_type_label')}
onChange={(v) => setFields((f) => ({ ...f, entity_type_label: v }))}
onCommit={() => setEditing(null)}
/>
<SniRow sniCodes={fields.sni_codes} />
<FieldRow
label="Säte"
value={fields.city ?? ''}
placeholder="—"
editing={editing === 'city'}
onEdit={() => setEditing('city')}
onChange={(v) => setFields((f) => ({ ...f, city: v }))}
onCommit={() => setEditing(null)}
/>
<FieldRow
label="Räkenskapsår"
value={fields.fiscal_period ?? ''}
placeholder="januaridecember"
editing={editing === 'fiscal_period'}
onEdit={() => setEditing('fiscal_period')}
onChange={(v) => setFields((f) => ({ ...f, fiscal_period: v }))}
onCommit={() => setEditing(null)}
/>
<FieldRow
label="Moms"
value={fields.vat_period ?? ''}
placeholder="Kvartal / månad / år"
editing={editing === 'vat_period'}
onEdit={() => setEditing('vat_period')}
onChange={(v) => setFields((f) => ({ ...f, vat_period: v }))}
onCommit={() => setEditing(null)}
/>
<FieldRow
label="F-skatt"
value={fields.f_skatt ?? ''}
placeholder="Aktivt / saknas"
editing={editing === 'f_skatt'}
onEdit={() => setEditing('f_skatt')}
onChange={(v) => setFields((f) => ({ ...f, f_skatt: v }))}
onCommit={() => setEditing(null)}
/>
<FieldRow
label="Anställda"
value={fields.employees ?? ''}
placeholder="0"
editing={editing === 'employees'}
onEdit={() => setEditing('employees')}
onChange={(v) => setFields((f) => ({ ...f, employees: v }))}
onCommit={() => setEditing(null)}
/>
</dl>
</section>
{/* Verksamhetsbeskrivning from Bolagsverket verbatim, since
authoritative legal text. Hidden when TIC didn't return one. */}
{fields.purpose && (
<section>
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground mb-3">
Verksamhet
</h2>
<p className="text-sm leading-6 text-muted-foreground italic">
{fields.purpose}
</p>
</section>
)}
{/* Optional seed note the fast path for users who'd rather jot
one thing than chat. The Phase C intake will draw the rest
out conversationally. */}
<section>
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground mb-1">
Bra att veta
</h2>
<p className="text-xs text-muted-foreground mb-3">
Valfritt du kan också berätta i chatten strax. T.ex. återkommande kunder, en hyresfaktura som kommer den 25:e, eller att kunderna mest finns i Tyskland.
</p>
<textarea
id="seed-memory"
value={seedMemory}
onChange={(e) => setSeedMemory(e.target.value)}
rows={3}
placeholder="Skriv något, eller lämna tomt"
className="w-full rounded-lg border border-border bg-background px-4 py-3 text-sm leading-6 resize-none focus:outline-none focus:ring-2 focus:ring-ring"
/>
</section>
</>
)}
{verifyError && (
<p className="text-sm text-destructive">{verifyError}</p>
)}
{/* Step nav. Step 1 forward to review. Step 2 back to meet, or
run the verify pipeline and hand off to the chat intake. */}
<div className="flex items-center justify-between gap-3 pt-2">
<Button
variant="ghost"
onClick={() => setStep(1)}
disabled={step === 1}
>
<ArrowLeft className="h-4 w-4 mr-2" />
Tillbaka
</Button>
{step === 1 && (
<Button onClick={() => setStep(2)}>
Nästa
<ArrowRight className="h-4 w-4 ml-2" />
</Button>
)}
{step === 2 && (
<Button size="lg" onClick={handleVerify} disabled={verifying}>
{verifying ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Sparar
</>
) : (
<>
Möt {agentName}
<ArrowRight className="h-4 w-4 ml-2" />
</>
)}
</Button>
)}
</div>
</CardContent>
</Card>
</div>
)
}
// Renders one or more SNI codes alongside their human-readable industry
// labels. Read-only for the POC — TIC is authoritative for SNI and we don't
// surface a UI to add codes that Bolagsverket doesn't have.
//
// TIC occasionally returns the same SNI code twice (e.g. as both primary and
// secondary on the company record). Dedupe by code so the same line doesn't
// render twice in a row.
function SniRow({ sniCodes }: { sniCodes: { code: string; name: string }[] }) {
const seen = new Set<string>()
const uniqueCodes = sniCodes.filter((s) => {
if (seen.has(s.code)) return false
seen.add(s.code)
return true
})
return (
<div className="flex items-start gap-4 py-3">
<dt className="w-32 text-sm text-muted-foreground shrink-0">SNI</dt>
<dd className="flex-1 min-w-0">
{uniqueCodes.length === 0 ? (
<span className="text-sm italic text-muted-foreground/60">Saknas</span>
) : (
<ul className="space-y-1">
{uniqueCodes.map((s) => (
<li key={s.code} className="flex gap-3 text-sm">
<span className="tabular-nums text-muted-foreground shrink-0">{s.code}</span>
<span className="min-w-0">{s.name}</span>
</li>
))}
</ul>
)}
</dd>
</div>
)
}
function FieldRow({
label,
value,
placeholder,
editing,
onEdit,
onChange,
onCommit,
}: {
label: string
value: string
placeholder?: string
editing: boolean
onEdit: () => void
onChange: (v: string) => void
onCommit: () => void
}) {
return (
<div className="flex items-center gap-4 py-3">
<dt className="w-32 text-sm text-muted-foreground shrink-0">{label}</dt>
<dd className="flex-1 min-w-0">
{editing ? (
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
onBlur={onCommit}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
onCommit()
}
}}
autoFocus
className="h-9"
/>
) : (
<span
className={cn(
'text-sm',
!value && 'text-muted-foreground/60 italic',
)}
>
{value || placeholder || '—'}
</span>
)}
</dd>
{!editing && (
<button
onClick={onEdit}
className="text-muted-foreground hover:text-foreground transition-colors shrink-0"
aria-label={`Redigera ${label}`}
>
<Pencil className="h-4 w-4" />
</button>
)}
</div>
)
}
function ChipGroup({
primaryLabel,
secondaryLabel,
primary,
secondary,
onRemove,
}: {
primaryLabel: string
secondaryLabel: string
primary: { id: string; label: string }[]
secondary: { id: string; label: string; group: 'horizontal' | 'modifier' }[]
onRemove: (id: string, group: 'horizontal' | 'vertical' | 'modifier') => void
}) {
return (
<div className="space-y-3">
{primary.length > 0 && (
<div>
<p className="text-xs text-muted-foreground mb-2">{primaryLabel}</p>
<div className="flex flex-wrap gap-2">
{primary.map((c) => (
<Chip key={c.id} label={c.label} onRemove={() => onRemove(c.id, 'vertical')} />
))}
</div>
</div>
)}
{secondary.length > 0 && (
<div>
<p className="text-xs text-muted-foreground mb-2">{secondaryLabel}</p>
<div className="flex flex-wrap gap-2">
{secondary.map((c) => (
<Chip
key={c.id}
label={c.label}
onRemove={() => onRemove(c.id, c.group)}
muted
/>
))}
</div>
</div>
)}
</div>
)
}
function Chip({
label,
onRemove,
muted,
}: {
label: string
onRemove: () => void
muted?: boolean
}) {
return (
<Badge
variant={muted ? 'outline' : 'secondary'}
className="pl-3 pr-1.5 py-1 text-xs gap-1 inline-flex items-center"
>
{label}
<button
onClick={onRemove}
className="ml-1 rounded-full hover:bg-foreground/10 p-0.5 transition-colors"
aria-label={`Ta bort ${label}`}
>
<X className="h-3 w-3" />
</button>
</Badge>
)
}
function arrEq(a: string[], b: string[]): boolean {
if (a.length !== b.length) return false
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false
return true
}
+2 -2
View File
@@ -633,11 +633,11 @@ export function AGIPanel(props: AGIPanelProps) {
</p>
<p className="mt-1 text-xs text-muted-foreground">
Din anslutning utfärdades innan AGI-stödet aktiverades. Koppla
bort och anslut igen via Inställningar Skatteverket för att
bort och anslut igen via Inställningar Skatt för att
kunna skicka AGI direkt.
</p>
<a
href="/settings/skatteverket"
href="/settings/tax"
className="mt-2 inline-flex items-center gap-1 text-sm font-medium hover:underline"
>
Öppna inställningar <ExternalLink className="h-3.5 w-3.5" />
+403
View File
@@ -0,0 +1,403 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Brain, Loader2, Pin, PinOff, Pencil, Plus, RotateCcw, Trash2, X } from 'lucide-react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { EmptyState } from '@/components/ui/empty-state'
import { Skeleton } from '@/components/ui/skeleton'
import { useToast } from '@/components/ui/use-toast'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { formatDateLong } from '@/lib/utils'
type Kind = 'fact' | 'preference' | 'pattern' | 'correction'
type Source = 'composer' | 'user_taught' | 'agent_learned' | 'derived'
interface AgentMemoryRow {
id: string
kind: Kind
content: string
source: Source
source_ref: string | null
relevance_score: number
is_pinned: boolean
is_active: boolean
last_accessed_at: string | null
created_at: string
updated_at: string
}
const KIND_LABEL: Record<Kind, string> = {
fact: 'Fakta',
preference: 'Preferens',
pattern: 'Mönster',
correction: 'Korrigering',
}
const SOURCE_LABEL: Record<Source, string> = {
composer: 'Inläst vid uppstart',
user_taught: 'Du lärde mig',
agent_learned: 'Jag noterade',
derived: 'Härlett',
}
const KIND_FILTER: { value: 'all' | Kind; label: string }[] = [
{ value: 'all', label: 'Alla' },
{ value: 'fact', label: 'Fakta' },
{ value: 'preference', label: 'Preferenser' },
{ value: 'pattern', label: 'Mönster' },
{ value: 'correction', label: 'Korrigeringar' },
]
export function AgentMemoryPanel() {
const { toast } = useToast()
const { canWrite } = useCanWrite()
const [rows, setRows] = useState<AgentMemoryRow[] | null>(null)
const [includeDismissed, setIncludeDismissed] = useState(false)
const [kindFilter, setKindFilter] = useState<'all' | Kind>('all')
const [busyId, setBusyId] = useState<string | null>(null)
const [editingId, setEditingId] = useState<string | null>(null)
const [editDraft, setEditDraft] = useState('')
const [showAdd, setShowAdd] = useState(false)
const [newContent, setNewContent] = useState('')
const [newKind, setNewKind] = useState<Kind>('fact')
const [adding, setAdding] = useState(false)
const load = useCallback(async () => {
const params = new URLSearchParams()
if (includeDismissed) params.set('include_dismissed', 'true')
if (kindFilter !== 'all') params.set('kind', kindFilter)
const res = await fetch(`/api/agent/memory?${params.toString()}`)
const json = await res.json()
if (!res.ok) {
toast({ title: 'Kunde inte hämta minne', description: json.error, variant: 'destructive' })
setRows([])
return
}
setRows(json.data as AgentMemoryRow[])
}, [includeDismissed, kindFilter, toast])
useEffect(() => { void load() }, [load])
const counts = useMemo(() => {
const active = rows?.filter((r) => r.is_active).length ?? 0
const pinned = rows?.filter((r) => r.is_active && r.is_pinned).length ?? 0
const dismissed = rows?.filter((r) => !r.is_active).length ?? 0
return { active, pinned, dismissed }
}, [rows])
async function patch(id: string, body: Partial<Pick<AgentMemoryRow, 'content' | 'is_pinned' | 'is_active'>>) {
setBusyId(id)
try {
const res = await fetch(`/api/agent/memory/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const json = await res.json()
if (!res.ok) {
toast({ title: 'Kunde inte uppdatera', description: json.error, variant: 'destructive' })
return
}
setRows((prev) => prev?.map((r) => (r.id === id ? (json.data as AgentMemoryRow) : r)) ?? null)
} finally {
setBusyId(null)
}
}
async function addMemory() {
if (newContent.trim().length < 2) return
setAdding(true)
try {
const res = await fetch('/api/agent/memory', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: newContent.trim(), kind: newKind }),
})
const json = await res.json()
if (!res.ok) {
toast({ title: 'Kunde inte spara minne', description: json.error, variant: 'destructive' })
return
}
setRows((prev) => [json.data as AgentMemoryRow, ...(prev ?? [])])
setNewContent('')
setNewKind('fact')
setShowAdd(false)
toast({ title: 'Minne sparat' })
} finally {
setAdding(false)
}
}
function startEdit(row: AgentMemoryRow) {
setEditingId(row.id)
setEditDraft(row.content)
}
async function saveEdit(row: AgentMemoryRow) {
const next = editDraft.trim()
if (next.length < 2 || next === row.content) {
setEditingId(null)
return
}
await patch(row.id, { content: next })
setEditingId(null)
}
return (
<Card>
<CardHeader className="flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between">
<div>
<CardTitle className="text-base">Vad min assistent kommer ihåg</CardTitle>
<CardDescription>
Bokföringsassistenten använder dessa anteckningar för att ge dig rätt råd. Fäst det som
alltid ska vara med, redigera fel, eller dölj det som inte längre stämmer.
</CardDescription>
</div>
{canWrite && (
<Button
variant="outline"
size="sm"
onClick={() => setShowAdd((v) => !v)}
disabled={adding}
>
<Plus className="mr-2 h-4 w-4" />
Lägg till minne
</Button>
)}
</CardHeader>
<CardContent className="space-y-6">
{showAdd && canWrite && (
<div className="rounded-lg border border-border p-4 space-y-3">
<Textarea
value={newContent}
onChange={(e) => setNewContent(e.target.value)}
placeholder="T.ex. Vi använder Stripe för B2C-betalningar; utbetalningar landar på 1930 var måndag."
rows={3}
maxLength={2000}
/>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">Typ</span>
<Select value={newKind} onValueChange={(v) => setNewKind(v as Kind)}>
<SelectTrigger className="h-8 w-[160px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(KIND_LABEL) as Kind[]).map((k) => (
<SelectItem key={k} value={k}>{KIND_LABEL[k]}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={() => { setShowAdd(false); setNewContent('') }}>
Avbryt
</Button>
<Button size="sm" onClick={addMemory} disabled={adding || newContent.trim().length < 2}>
{adding ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Spara
</Button>
</div>
</div>
</div>
)}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap gap-1">
{KIND_FILTER.map((f) => {
const active = kindFilter === f.value
return (
<button
key={f.value}
onClick={() => setKindFilter(f.value)}
className={`rounded-md px-3 py-1.5 text-xs transition-colors ${
active
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:bg-secondary/60 hover:text-foreground'
}`}
>
{f.label}
</button>
)
})}
</div>
<label className="inline-flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={includeDismissed}
onChange={(e) => setIncludeDismissed(e.target.checked)}
className="h-3.5 w-3.5"
/>
Visa dolda
</label>
</div>
{rows && (
<div className="text-xs text-muted-foreground tabular-nums">
{counts.active} aktiva · {counts.pinned} fästa
{includeDismissed && counts.dismissed > 0 ? ` · ${counts.dismissed} dolda` : ''}
<span className="ml-1">(upp till 30 ingår i samtal per tur)</span>
</div>
)}
{rows === null && (
<div className="space-y-3">
{[0, 1, 2].map((i) => (
<Skeleton key={i} className="h-20 w-full rounded-lg" />
))}
</div>
)}
{rows && rows.length === 0 && (
<EmptyState
icon={Brain}
title="Inga minnen ännu"
description="När du lär assistenten saker — eller när den noterar saker själv med ditt godkännande — dyker de upp här."
/>
)}
{rows && rows.length > 0 && (
<ul className="space-y-3">
{rows.map((row) => {
const isEditing = editingId === row.id
const isBusy = busyId === row.id
const dimmed = !row.is_active
return (
<li
key={row.id}
className={`rounded-lg border border-border p-4 transition-colors ${
dimmed ? 'bg-muted/30 opacity-70' : 'bg-card'
}`}
>
<div className="flex items-start gap-3">
{canWrite && row.is_active ? (
<button
onClick={() => patch(row.id, { is_pinned: !row.is_pinned })}
disabled={isBusy}
className={`mt-0.5 shrink-0 rounded-md p-1.5 transition-colors ${
row.is_pinned
? 'text-foreground bg-secondary'
: 'text-muted-foreground hover:bg-secondary/60 hover:text-foreground'
}`}
aria-label={row.is_pinned ? 'Lossa' : 'Fäst'}
title={row.is_pinned ? 'Lossa' : 'Fäst — säkerställer att minnet alltid skickas med'}
>
{row.is_pinned ? <Pin className="h-4 w-4 fill-current" /> : <PinOff className="h-4 w-4" />}
</button>
) : (
<div className="mt-0.5 shrink-0 p-1.5">
{row.is_pinned && <Pin className="h-4 w-4 fill-current text-foreground" />}
</div>
)}
<div className="flex-1 min-w-0 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={kindBadge(row.kind)}>{KIND_LABEL[row.kind]}</Badge>
<Badge variant="outline">{SOURCE_LABEL[row.source]}</Badge>
{dimmed && <Badge variant="secondary">Dold</Badge>}
</div>
{isEditing ? (
<Textarea
value={editDraft}
onChange={(e) => setEditDraft(e.target.value)}
rows={3}
maxLength={2000}
autoFocus
/>
) : (
<p className="text-sm text-foreground whitespace-pre-wrap break-words">{row.content}</p>
)}
<div className="flex flex-wrap items-center justify-between gap-2 pt-1">
<p className="text-[11px] text-muted-foreground tabular-nums">
Skapad {formatDateLong(row.created_at)}
{row.updated_at !== row.created_at && ` · uppdaterad ${formatDateLong(row.updated_at)}`}
</p>
{canWrite && (
<div className="flex items-center gap-1">
{isEditing ? (
<>
<Button
variant="ghost"
size="sm"
onClick={() => setEditingId(null)}
disabled={isBusy}
>
<X className="h-4 w-4" />
<span className="sr-only">Avbryt</span>
</Button>
<Button
size="sm"
onClick={() => saveEdit(row)}
disabled={isBusy || editDraft.trim().length < 2}
>
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Spara'}
</Button>
</>
) : row.is_active ? (
<>
<Button
variant="ghost"
size="sm"
onClick={() => startEdit(row)}
disabled={isBusy}
>
<Pencil className="mr-1 h-3.5 w-3.5" />
Redigera
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => patch(row.id, { is_active: false })}
disabled={isBusy}
>
<Trash2 className="mr-1 h-3.5 w-3.5" />
Dölj
</Button>
</>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => patch(row.id, { is_active: true })}
disabled={isBusy}
>
<RotateCcw className="mr-1 h-3.5 w-3.5" />
Återställ
</Button>
)}
</div>
)}
</div>
</div>
</div>
</li>
)
})}
</ul>
)}
</CardContent>
</Card>
)
}
function kindBadge(kind: Kind): 'default' | 'secondary' | 'outline' | 'success' | 'warning' {
switch (kind) {
case 'fact':
return 'default'
case 'preference':
return 'secondary'
case 'pattern':
return 'outline'
case 'correction':
return 'warning'
}
}
+218
View File
@@ -0,0 +1,218 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { ChevronDown, GraduationCap, Loader2 } from 'lucide-react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { EmptyState } from '@/components/ui/empty-state'
import { Skeleton } from '@/components/ui/skeleton'
import { useToast } from '@/components/ui/use-toast'
type Tier = 'horizontal' | 'vertical' | 'modifier'
interface AtomMeta {
id: string
tier: Tier
title: string
description: string
active: boolean
}
const TIER_ORDER: Tier[] = ['horizontal', 'vertical', 'modifier']
const TIER_SECTION: Record<Tier, { title: string; blurb: string }> = {
horizontal: {
title: 'Kärnkompetens',
blurb: 'Svenska bokförings- och skatteregler som assistenten alltid har med sig.',
},
vertical: {
title: 'Anpassat för din bransch',
blurb: 'Branschkunskap som valts utifrån vad ditt företag gör. Vilande områden finns men används inte för dig.',
},
modifier: {
title: 'Din bolagssituation',
blurb: 'Särskilda regler för hur just ditt bolag är uppbyggt.',
},
}
// Mirror of the chat surface's markdown styling (components/agent/AgentChat.tsx),
// trimmed for a wider settings column.
const PROSE =
'prose prose-sm max-w-none text-foreground [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 ' +
'prose-headings:font-display prose-headings:font-normal prose-headings:tracking-tight ' +
'prose-h1:text-lg prose-h2:text-base prose-h3:text-sm prose-p:my-2 prose-p:leading-6 ' +
'prose-strong:font-semibold prose-strong:text-foreground prose-ul:my-2 prose-li:my-0.5 ' +
'prose-a:text-foreground prose-a:underline prose-a:underline-offset-2 ' +
'prose-code:bg-secondary prose-code:rounded prose-code:px-1 prose-code:py-0.5 prose-code:text-xs ' +
'prose-code:before:content-none prose-code:after:content-none ' +
'prose-table:my-2 prose-table:text-xs [&_table]:w-full ' +
'[&_th]:border-b [&_th]:border-border [&_th]:py-1.5 [&_th]:px-2 [&_th]:text-left [&_th]:font-medium ' +
'[&_th]:text-muted-foreground [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-[10px] ' +
'[&_td]:border-b [&_td]:border-border/60 [&_td]:py-1.5 [&_td]:px-2 [&_td]:align-top'
export function AgentSkillsPanel() {
const { toast } = useToast()
const [atoms, setAtoms] = useState<AtomMeta[] | null>(null)
const [expandedId, setExpandedId] = useState<string | null>(null)
const [bodies, setBodies] = useState<Record<string, string>>({})
const [loadingBody, setLoadingBody] = useState<string | null>(null)
const load = useCallback(async () => {
const res = await fetch('/api/agent/skills')
const json = await res.json()
if (!res.ok) {
toast({ title: 'Kunde inte hämta kunskap', description: json.error, variant: 'destructive' })
setAtoms([])
return
}
setAtoms(json.data as AtomMeta[])
}, [toast])
useEffect(() => { void load() }, [load])
const grouped = useMemo(() => {
const map: Record<Tier, AtomMeta[]> = { horizontal: [], vertical: [], modifier: [] }
for (const a of atoms ?? []) map[a.tier]?.push(a)
return map
}, [atoms])
const counts = useMemo(() => {
const total = atoms?.length ?? 0
const active = atoms?.filter((a) => a.active).length ?? 0
return { total, active }
}, [atoms])
async function toggle(atom: AtomMeta) {
if (expandedId === atom.id) {
setExpandedId(null)
return
}
setExpandedId(atom.id)
if (bodies[atom.id] !== undefined) return
setLoadingBody(atom.id)
try {
const res = await fetch(`/api/agent/skills?slug=${encodeURIComponent(atom.id)}`)
const json = await res.json()
if (!res.ok) {
toast({ title: 'Kunde inte läsa kunskapen', description: json.error, variant: 'destructive' })
return
}
setBodies((prev) => ({ ...prev, [atom.id]: (json.data?.body as string) ?? '' }))
} finally {
setLoadingBody(null)
}
}
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Vad min assistent kan</CardTitle>
<CardDescription>
Utöver vad den minns om ditt företag bygger assistenten en uppsättning kunskapsområden
om svensk bokföring och skatt. Kärnkompetensen gäller alla; bransch- och bolagsanpassningen
väljs utifrån ditt företag. Klicka för att läsa hela kunskapen.
</CardDescription>
</CardHeader>
<CardContent className="space-y-8">
{atoms && (
<div className="text-xs text-muted-foreground tabular-nums">
{counts.total} kunskapsområden · {counts.active} aktiva för ditt företag
</div>
)}
{atoms === null && (
<div className="space-y-3">
{[0, 1, 2, 3].map((i) => (
<Skeleton key={i} className="h-16 w-full rounded-lg" />
))}
</div>
)}
{atoms && atoms.length === 0 && (
<EmptyState
icon={GraduationCap}
title="Inga kunskapsområden ännu"
description="När din assistent har komponerats dyker dess kunskapsområden upp här."
/>
)}
{atoms && atoms.length > 0 &&
TIER_ORDER.filter((tier) => grouped[tier].length > 0).map((tier) => (
<section key={tier} className="space-y-3">
<div className="space-y-1">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{TIER_SECTION[tier].title}
</h2>
<p className="text-xs text-muted-foreground">{TIER_SECTION[tier].blurb}</p>
</div>
<ul className="space-y-2">
{grouped[tier].map((atom) => {
const isOpen = expandedId === atom.id
const isLoading = loadingBody === atom.id
const body = bodies[atom.id]
const dormant = !atom.active
return (
<li
key={atom.id}
className={`rounded-lg border border-border transition-colors ${
dormant ? 'bg-muted/30' : 'bg-card'
}`}
>
<button
onClick={() => toggle(atom)}
aria-expanded={isOpen}
className="flex w-full items-start gap-3 p-4 text-left"
>
<ChevronDown
className={`mt-0.5 h-4 w-4 shrink-0 text-muted-foreground transition-transform ${
isOpen ? '' : '-rotate-90'
}`}
/>
<div className="flex-1 min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium text-foreground">{atom.title}</span>
{tier !== 'horizontal' && (
<Badge variant={atom.active ? 'success' : 'secondary'}>
{atom.active ? 'Aktiv' : 'Vilande'}
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">{atom.description}</p>
</div>
</button>
{isOpen && (
<div className="border-t border-border px-4 py-4 pl-11">
{isLoading && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Läser in
</div>
)}
{!isLoading && body !== undefined && body.length > 0 && (
<div className={PROSE}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{body}</ReactMarkdown>
</div>
)}
{!isLoading && body !== undefined && body.length === 0 && (
<p className="text-xs text-muted-foreground">
Innehållet kunde inte läsas in.
</p>
)}
</div>
)}
</li>
)
})}
</ul>
</section>
))}
</CardContent>
</Card>
)
}
@@ -0,0 +1,44 @@
'use client'
import { useEffect, useState } from 'react'
import { createClient } from '@/lib/supabase/client'
import { useCompany } from '@/contexts/CompanyContext'
import { CompanyProfileView } from '@/components/settings/CompanyProfileView'
import { Skeleton } from '@/components/ui/skeleton'
type Snapshot = Parameters<typeof CompanyProfileView>[0]['snapshot']
// Företagsprofil — the cached TIC company snapshot (Bolagsuppgifter), rendered
// as a read-only section on the Företag tab. Fetched client-side (low-traffic
// settings) so it sits alongside the client-rendered company form. RLS scopes
// the read to the user's own company.
export function CompanyProfileSection() {
const { company } = useCompany()
const [snapshot, setSnapshot] = useState<Snapshot>(null)
const [fetchedAt, setFetchedAt] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
if (!company?.id) return
const supabase = createClient()
let cancelled = false
supabase
.from('companies')
.select('tic_snapshot, tic_snapshot_fetched_at')
.eq('id', company.id)
.maybeSingle()
.then(({ data }) => {
if (cancelled) return
setSnapshot((data?.tic_snapshot as Snapshot) ?? null)
setFetchedAt((data?.tic_snapshot_fetched_at as string | null) ?? null)
setLoading(false)
})
return () => {
cancelled = true
}
}, [company?.id])
if (loading) return <Skeleton className="h-48 w-full rounded-lg" />
return <CompanyProfileView snapshot={snapshot} fetchedAt={fetchedAt} />
}
+308
View File
@@ -0,0 +1,308 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { formatDate, formatDateLong } from '@/lib/utils'
// Read-only "Bolagsuppgifter" view of the cached TIC company profile
// (companies.tic_snapshot). Lives in core — reads the snapshot as plain
// JSON rather than importing the TIC extension's types, so the
// core-build CI boundary (no core → @/extensions/) stays intact.
//
// The snapshot is written by the TIC /profile endpoint; shape mirrors
// TICCompanyProfile. We type only the fields we render and treat
// everything as optional/defensive since older snapshots predate some
// sections.
interface SnapshotShape {
companyName?: string | null
orgNumber?: string | null
legalEntityType?: string | null
address?: { street?: string | null; postalCode?: string | null; city?: string | null } | null
registration?: { fTax?: boolean; vat?: boolean; payroll?: boolean } | null
sniCodes?: { code: string; name: string }[] | null
bankAccounts?: { type: string; accountNumber: string; bic?: string | null }[] | null
purpose?: string | null
employeeRange?: string | null
financials?: {
periodStart?: number
periodEnd?: number
netSalesK?: number | null
operatingProfitK?: number | null
} | null
statuses?: {
code?: string | null
description?: string | null
color?: 'red' | 'yellow' | 'green' | 'neutral' | string | null
statusDate?: string | null
isCeased?: boolean | null
}[] | null
fiscalYear?: { startMonthDay?: string | null; endMonthDay?: string | null } | null
signatory?: { description: string }[] | null
board?: {
numberOfBoardMembers?: number | null
numberOfDeputyBoardMembers?: number | null
} | null
representatives?: {
name?: string | null
positionType?: string | null
positionStart?: string | null
}[] | null
}
// Clean Bolagsverket signatory text: the source carries ">" list markers
// and collapses several rules onto one line. Strip the markers, normalise
// whitespace, and split run-on "Firman tecknas …" clauses onto their own
// lines so each rule reads as a sentence.
function cleanSignatory(raw: string): string[] {
const normalised = raw
.replace(/>/g, ' ')
.replace(/\s+/g, ' ')
.trim()
// Each firmateckningsregel starts with "Firman tecknas". Split on the
// boundary before subsequent occurrences so they stack vertically.
return normalised
.split(/(?=Firman tecknas)/g)
.map((s) => s.trim())
.filter((s) => s.length > 0)
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section>
<h3 className="text-sm font-medium uppercase tracking-wider text-muted-foreground mb-2">
{title}
</h3>
{children}
</section>
)
}
export function CompanyProfileView({
snapshot,
fetchedAt,
}: {
snapshot: SnapshotShape | null
fetchedAt: string | null
}) {
if (!snapshot) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Bolagsuppgifter</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Inga företagsuppgifter hämtade ännu. Uppgifterna hämtas automatiskt
från Bolagsverket via organisationsnumret.
</p>
</CardContent>
</Card>
)
}
const entityLabel =
snapshot.legalEntityType === 'AB'
? 'Aktiebolag'
: snapshot.legalEntityType === 'EF'
? 'Enskild firma'
: snapshot.legalEntityType ?? null
const reg = snapshot.registration
const regBadges = [
reg?.fTax ? 'F-skatt' : null,
reg?.vat ? 'Moms' : null,
reg?.payroll ? 'Arbetsgivare' : null,
].filter(Boolean) as string[]
const fyLabel =
snapshot.fiscalYear?.startMonthDay && snapshot.fiscalYear?.endMonthDay
? `${snapshot.fiscalYear.startMonthDay} ${snapshot.fiscalYear.endMonthDay}`
: null
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Bolagsuppgifter</CardTitle>
{fetchedAt && (
<p className="text-xs text-muted-foreground">
Uppdaterad {formatDateLong(fetchedAt)}
</p>
)}
</CardHeader>
<CardContent className="space-y-8">
{/* Identity */}
<div>
<p className="font-display text-xl tracking-tight">
{snapshot.companyName ?? 'Okänt företag'}
</p>
<p className="text-sm text-muted-foreground tabular-nums">
{[snapshot.orgNumber, entityLabel].filter(Boolean).join(' · ')}
</p>
{snapshot.address && (
<p className="text-sm text-muted-foreground mt-2">
{[
snapshot.address.street,
[snapshot.address.postalCode, snapshot.address.city].filter(Boolean).join(' '),
]
.filter(Boolean)
.join(', ')}
</p>
)}
</div>
{regBadges.length > 0 && (
<Section title="Registrerat för">
<div className="flex flex-wrap gap-2">
{regBadges.map((b) => (
<Badge key={b} variant="secondary" className="font-normal">{b}</Badge>
))}
</div>
</Section>
)}
{Array.isArray(snapshot.sniCodes) && snapshot.sniCodes.length > 0 && (
<Section title="SNI-koder">
<ul className="space-y-1">
{snapshot.sniCodes.map((s) => (
<li key={s.code} className="text-sm tabular-nums">
<span className="text-foreground">{s.code}</span>{' '}
<span className="text-muted-foreground">{s.name}</span>
</li>
))}
</ul>
</Section>
)}
{Array.isArray(snapshot.bankAccounts) && snapshot.bankAccounts.length > 0 && (
<Section title="Bankuppgifter">
<ul className="space-y-1">
{snapshot.bankAccounts.map((b, i) => (
<li key={`${b.type}-${b.accountNumber}-${i}`} className="text-sm tabular-nums">
<span className="text-muted-foreground">{b.type}:</span>{' '}
<span className="text-foreground">{b.accountNumber}</span>
</li>
))}
</ul>
</Section>
)}
{snapshot.purpose && (
<Section title="Verksamhet">
<p className="text-sm leading-6 text-muted-foreground">{snapshot.purpose}</p>
</Section>
)}
<Section title="Anställda">
<p className="text-sm text-muted-foreground">
{snapshot.employeeRange ?? 'Inga anställda'}
</p>
</Section>
<Section title="Senaste bokslut">
{snapshot.financials ? (
<dl className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm">
<dt className="text-muted-foreground">Nettoomsättning</dt>
<dd className="text-right tabular-nums">
{snapshot.financials.netSalesK != null
? `${snapshot.financials.netSalesK.toLocaleString('sv-SE')} tkr`
: '—'}
</dd>
<dt className="text-muted-foreground">Rörelseresultat</dt>
<dd className="text-right tabular-nums">
{snapshot.financials.operatingProfitK != null
? `${snapshot.financials.operatingProfitK.toLocaleString('sv-SE')} tkr`
: '—'}
</dd>
</dl>
) : (
<p className="text-sm text-muted-foreground">Inga finansiella uppgifter tillgängliga.</p>
)}
</Section>
{(() => {
// Only show dated status entries — Bolagsverket emits informational
// flags like "Har aldrig varit verksam" with no date that read as
// noise next to the real ones. Plain text, no colour: per the
// design system, semantic colour is data-only and never chrome.
const datedStatuses = (snapshot.statuses ?? []).filter((s) => s.statusDate)
if (datedStatuses.length === 0) return null
return (
<Section title="Status">
<dl className="space-y-1">
{datedStatuses.map((s, i) => (
<div key={`${s.code}-${i}`} className="flex items-center justify-between gap-3 text-sm">
<dt className={s.isCeased ? 'text-destructive' : 'text-foreground'}>
{s.description ?? s.code ?? '—'}
</dt>
<dd className="text-xs text-muted-foreground tabular-nums">
{formatDate(s.statusDate!)}
</dd>
</div>
))}
</dl>
</Section>
)
})()}
{fyLabel && (
<Section title="Räkenskapsår">
<p className="text-sm tabular-nums text-muted-foreground">Nuvarande: {fyLabel}</p>
</Section>
)}
{(() => {
// Flatten every signatory row, clean ">" markers, split run-on
// clauses, and dedupe — the source repeats "Firman tecknas av
// styrelsen" across rows.
const rules = Array.from(
new Set(
(snapshot.signatory ?? []).flatMap((s) => cleanSignatory(s.description)),
),
)
if (rules.length === 0) return null
return (
<Section title="Firmateckning">
<ul className="space-y-1.5">
{rules.map((rule, i) => (
<li key={i} className="text-sm leading-6 text-muted-foreground">
{rule}
</li>
))}
</ul>
</Section>
)
})()}
{Array.isArray(snapshot.representatives) && snapshot.representatives.length > 0 && (
<Section title="Företrädare">
{snapshot.board && (
<p className="text-xs text-muted-foreground mb-2">
{[
snapshot.board.numberOfBoardMembers != null
? `${snapshot.board.numberOfBoardMembers} styrelseledamot/-ledamöter`
: null,
snapshot.board.numberOfDeputyBoardMembers != null
? `${snapshot.board.numberOfDeputyBoardMembers} suppleant(er)`
: null,
]
.filter(Boolean)
.join(' · ')}
</p>
)}
<ul className="space-y-1">
{snapshot.representatives.map((r, i) => (
<li key={`${r.name}-${i}`} className="flex items-center justify-between gap-3 text-sm">
<span className="text-foreground">{r.name ?? '—'}</span>
<span className="text-xs text-muted-foreground text-right">
{[r.positionType, r.positionStart ? formatDate(r.positionStart) : null]
.filter(Boolean)
.join(' · ')}
</span>
</li>
))}
</ul>
</Section>
)}
</CardContent>
</Card>
)
}
+5 -2
View File
@@ -6,6 +6,7 @@ import { useTranslations } from 'next-intl'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { useRouter } from 'next/navigation'
import { useCompany } from '@/contexts/CompanyContext'
import { useAgentSheet } from '@/components/agent/AgentSheetProvider'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
interface NavItem {
@@ -18,23 +19,25 @@ export function SettingsNav({ isSandbox }: { isSandbox?: boolean }) {
const pathname = usePathname()
const router = useRouter()
const { company } = useCompany()
const { identity } = useAgentSheet()
const t = useTranslations('settings_nav')
const hasCompany = !!company
const hasBankingExtension = ENABLED_EXTENSION_IDS.has('enable-banking')
const hasMcpExtension = ENABLED_EXTENSION_IDS.has('mcp-server')
const hasSkatteverketExtension = ENABLED_EXTENSION_IDS.has('skatteverket')
const items: NavItem[] = [
// Företagsprofil (TIC-snapshot) lives under Företag; Skatteverket under Skatt;
// assistentens minne + kunskap under Assistenten; säkerhetsbackup under Importera/Exportera.
{ href: '/settings/company', label: t('company'), show: hasCompany },
{ href: '/settings/invoicing', label: t('invoicing'), show: hasCompany },
{ href: '/settings/bookkeeping', label: t('bookkeeping'), show: hasCompany },
{ href: '/settings/tax', label: t('tax'), show: hasCompany },
{ href: '/settings/team', label: t('team'), show: false },
{ href: '/settings/banking', label: t('banking'), show: hasCompany && !isSandbox && hasBankingExtension },
{ href: '/settings/skatteverket', label: t('skatteverket'), show: hasCompany && !isSandbox && hasSkatteverketExtension },
{ href: '/settings/salary', label: t('salary'), show: hasCompany && company?.entity_type === 'aktiebolag' },
{ href: '/settings/templates', label: t('templates'), show: hasCompany },
{ href: '/settings/assistant', label: t('assistant'), show: hasCompany && identity.isVerified },
{ href: '/settings/account', label: t('account'), show: true },
{ href: '/settings/api', label: t('api'), show: hasCompany && hasMcpExtension },
].filter(item => item.show)
@@ -60,7 +60,7 @@ export function SkatteverketConnectPanel() {
}, [])
function startConnect() {
const returnTo = encodeURIComponent('/settings/skatteverket')
const returnTo = encodeURIComponent('/settings/tax')
window.location.href = `/api/extensions/ext/skatteverket/authorize?return_to=${returnTo}`
}
@@ -1,9 +1,12 @@
'use client'
import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { useDocumentExtraction } from '@/lib/hooks/use-document-extraction'
import ExtractionStatus from '@/components/ui/extraction-status'
import { motion } from 'framer-motion'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
DataListRow,
@@ -21,6 +24,12 @@ import {
Loader2,
Trash2,
} from 'lucide-react'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
// True when the AI tier is active — gates user-facing strings that promise
// AI behavior. On the free build (document-extraction disabled) we keep the
// upload functional but drop the "AI:n läser dokumentet" promise.
const HAS_AI_EXTRACTION = ENABLED_EXTENSION_IDS.has('document-extraction')
import { TransactionAttachmentIndicator } from './TransactionAttachmentIndicator'
import type { TransactionWithInvoice, CategorizeHandler } from './transaction-types'
@@ -61,6 +70,33 @@ export default function TransactionInboxCard({
const isProcessing = processingId === transaction.id
const isDisabled = processingId !== null && processingId !== transaction.id
const isIncome = transaction.amount > 0
// Optimistic override — flips the indicator to "attached" as soon as the
// upload POST succeeds, without waiting for the parent to refetch. The
// next parent refresh will sync; in the meantime the user sees the
// correct visual state immediately. Same hook handles agent-chat uploads
// via the gnubok:transaction-document-linked window event (AgentChat
// dispatches it after /api/agent/upload returns).
const [optimisticDocumentId, setOptimisticDocumentId] = useState<string | null>(null)
useEffect(() => {
function onLinked(e: Event) {
const detail = (e as CustomEvent<{ transaction_id?: string; document_id?: string }>).detail
if (!detail || detail.transaction_id !== transaction.id || !detail.document_id) return
setOptimisticDocumentId(detail.document_id)
}
window.addEventListener('gnubok:transaction-document-linked', onLinked)
return () => window.removeEventListener('gnubok:transaction-document-linked', onLinked)
}, [transaction.id])
const attachedDocumentId =
optimisticDocumentId ?? (transaction as { document_id?: string | null }).document_id ?? null
// Only poll extraction status for documents the user attached during THIS
// session. Pre-existing attached docs from prior sessions wouldn't change
// status during this view, and polling them would be wasted requests.
// Gated on HAS_AI_EXTRACTION so the free tier doesn't poll an endpoint
// whose pipeline never runs.
const extraction = useDocumentExtraction(
HAS_AI_EXTRACTION ? optimisticDocumentId : null,
)
const hasInvoiceMatch = !!transaction.potential_invoice && !transaction.invoice_id
const hasSupplierInvoiceMatch =
!!transaction.potential_supplier_invoice && !transaction.supplier_invoice_id
@@ -221,6 +257,13 @@ export default function TransactionInboxCard({
<Link2 className="h-4 w-4" />
</Button>
)}
{/* The Paperclip indicator next to the description
(TransactionAttachmentIndicator) is the single click
target for opening the underlag. We deliberately don't
duplicate that with a second icon in the trailing slot.
Per-transaction agent help has moved to Dokumentinkorgen:
match the underlag to the transaction and ask from there,
where the receipt/invoice is in view. */}
{isDeletable && onDelete && (
<Button
variant="ghost"
@@ -243,7 +286,7 @@ export default function TransactionInboxCard({
>
<div className="flex items-center gap-1.5 min-w-0">
<DataListPrimary className="text-base">{transaction.description}</DataListPrimary>
<TransactionAttachmentIndicator documentId={transaction.document_id} />
<TransactionAttachmentIndicator documentId={attachedDocumentId} />
</div>
<DataListMeta className="mt-1">
<span className="tabular-nums">{formatDate(transaction.date)}</span>
@@ -257,6 +300,18 @@ export default function TransactionInboxCard({
</>
)}
</DataListMeta>
{/* Extraction status visible only while AI is reading a freshly
attached document, or briefly if reading failed. */}
{HAS_AI_EXTRACTION &&
!isBatchMode &&
(extraction.status === 'running' || extraction.status === 'failed') && (
<div className="mt-2 pt-2 border-t border-border/40">
<ExtractionStatus
status={extraction.status}
elapsedMs={extraction.elapsedMs}
/>
</div>
)}
</DataListRow>
</motion.div>
)
+41
View File
@@ -0,0 +1,41 @@
'use client'
import { cn } from '@/lib/utils'
interface Props {
// When true, render the dot. The parent owns the "did this value come from
// AI extraction?" question — usually by comparing the current input value
// against the originally-prefilled value, and clearing the flag once the
// user edits the field.
active: boolean
// Visible label next to the dot. Defaults to none (just the dot, with
// tooltip text on hover). Set to "AI-fyllt" or similar when the field
// has room.
label?: string
className?: string
// Tooltip / aria-label for the dot.
title?: string
}
// Tiny indicator that a form field's value was pre-filled by the AI
// extraction pipeline. Sits to the right of the field label or inside the
// input's right padding. Fades when the user edits the field — see how
// supplier-invoices/new wires it.
//
// Design: a small filled dot, success color when the extraction succeeded.
// Optional uppercase micro-label for forms where space allows.
export default function AiFilledIndicator({ active, label, className, title }: Props) {
if (!active) return null
return (
<span
title={title ?? 'Värdet är ifyllt av AI baserat på dokumentet'}
className={cn(
'inline-flex items-center gap-1 text-[10px] uppercase tracking-wider text-muted-foreground',
className,
)}
>
<span aria-hidden className="inline-block h-1.5 w-1.5 rounded-full bg-success" />
{label ?? <span className="sr-only">AI-fyllt</span>}
</span>
)
}
+79
View File
@@ -0,0 +1,79 @@
'use client'
import { Loader2, Check, AlertCircle, FileWarning } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { ExtractionStatus } from '@/lib/hooks/use-document-extraction'
interface Props {
status: ExtractionStatus
elapsedMs?: number
className?: string
}
// Inline status indicator for an AI extraction in flight. Sits next to a
// freshly-attached document in upload flows. Five visual states map to the
// useDocumentExtraction hook output. "disabled" renders nothing — the free
// tier has no AI extraction and shouldn't see scary UI.
//
// Copy is intentionally short and Swedish. The status changes inline; the
// layout doesn't shift between states (icon + single line).
export default function ExtractionStatus({ status, elapsedMs = 0, className }: Props) {
if (status === 'idle' || status === 'disabled') return null
const slow = elapsedMs > 8_000
if (status === 'running') {
return (
<span
className={cn(
'inline-flex items-center gap-1.5 text-xs text-muted-foreground',
className,
)}
>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{slow ? 'Tar lite längre än vanligt…' : 'Läser dokumentet…'}
</span>
)
}
if (status === 'succeeded') {
return (
<span
className={cn(
'inline-flex items-center gap-1.5 text-xs text-success',
className,
)}
>
<Check className="h-3.5 w-3.5" />
Inläst av AI
</span>
)
}
if (status === 'unsupported') {
return (
<span
className={cn(
'inline-flex items-center gap-1.5 text-xs text-muted-foreground',
className,
)}
>
<FileWarning className="h-3.5 w-3.5" />
Filtypen kan inte läsas automatiskt
</span>
)
}
// failed
return (
<span
className={cn(
'inline-flex items-center gap-1.5 text-xs text-warning',
className,
)}
>
<AlertCircle className="h-3.5 w-3.5" />
Kunde inte läsa automatiskt fyll i manuellt
</span>
)
}
+1 -1
View File
@@ -1 +1 @@
{"$schema":"./extensions.schema.json","extensions":["enable-banking","email","arcim-migration","tic","mcp-server","cloud-backup","skatteverket","invoice-inbox"]}
{"$schema":"./extensions.schema.json","extensions":["enable-banking","email","arcim-migration","tic","mcp-server","cloud-backup","skatteverket","invoice-inbox","document-extraction"]}
+2 -1
View File
@@ -29,7 +29,8 @@
"tic",
"mcp-server",
"skatteverket",
"cloud-backup"
"cloud-backup",
"document-extraction"
]
},
"description": "Extension IDs to enable. Each ID must match a manifest.json in the extensions/ directory."
@@ -0,0 +1,196 @@
import type { Extension } from '@/lib/extensions/types'
import type { SupabaseClient } from '@supabase/supabase-js'
import { extractInvoiceFields } from '@/extensions/general/invoice-inbox/lib/extract-invoice-fields'
import { createLogger } from '@/lib/logger'
import { createServiceClient } from '@/lib/supabase/server'
import type { DocumentAttachment } from '@/types'
const log = createLogger('document-extraction')
// Mime types we know Claude can read directly via Bedrock. Anything else
// (HEIC, ZIP, TXT, …) is skipped — extracted_at still gets stamped so the
// row is marked as "attempted, not eligible".
const SUPPORTED_MIME_TYPES = new Set([
'application/pdf',
'image/jpeg',
'image/png',
'image/webp',
'image/gif',
])
// AI-extraction extension — paid AI tier only.
//
// Subscribes to the existing document.uploaded event bus topic and runs
// Sonnet 4.6 (via Bedrock, reusing invoice-inbox's extractInvoiceFields) on
// every uploaded receipt or invoice. Writes the result to
// document_attachments.extracted_data so the agent intent capture can use
// it without re-asking the user.
//
// Idempotency: skips when extracted_at is already set on the row. Also
// dedupes against invoice_inbox_items.extracted_data — when the inbox
// extension already extracted the same file, we copy its result instead
// of paying for a second Sonnet call.
//
// Free tier: disable this extension in extensions.config.json. Uploads
// still work; the agent intent will see null extracted_data and either
// ask the user or call gnubok_get_document_content at chat-time.
//
// See dev_docs/specialized-agent-plan.md (§ paid/free tier note) — to be
// authored.
export const documentExtractionExtension: Extension = {
id: 'document-extraction',
name: 'AI document extraction',
version: '1.0.0',
eventHandlers: [
{
eventType: 'document.uploaded',
handler: async (payload) => {
const { document, companyId } = payload as {
document: DocumentAttachment
userId: string
companyId: string
}
await extractAndPersist(document, companyId)
},
},
],
}
async function extractAndPersist(
document: DocumentAttachment,
companyId: string,
): Promise<void> {
// Service-role client: the handler runs out-of-band of the request that
// emitted the event, so we don't have user cookies. RLS doesn't fit —
// events have no user context.
const supabase: SupabaseClient = createServiceClient()
// Idempotency guard: never re-extract a row that already has extracted_at.
// Note: the column may be null OR the row may not yet have the new
// schema (legacy supabase types). Fail closed on missing schema.
const { data: existing, error: existingErr } = await supabase
.from('document_attachments')
.select('id, mime_type, storage_path, extracted_at')
.eq('id', document.id)
.single()
if (existingErr || !existing) {
log.warn('document not found, skipping extraction', {
doc: document.id,
err: existingErr?.message,
})
return
}
if (existing.extracted_at) {
return
}
// Dedup against inbox: if invoice-inbox already extracted this exact file
// (same document_id), copy its result to avoid a second AI call. If the
// inbox row marked the upload as skip_extraction=true, the inbox row's
// extracted_data is an empty skeleton — we must stamp the doc with a
// 'skipped:*' model so the client-side useDocumentExtraction hook reports
// 'unsupported' rather than 'succeeded' (otherwise the UI would claim AI
// finished reading a doc it never opened).
const { data: inboxRow } = await supabase
.from('invoice_inbox_items')
.select('extracted_data, extraction_skipped')
.eq('document_id', document.id)
.maybeSingle()
if (inboxRow?.extraction_skipped) {
await supabase
.from('document_attachments')
.update({
extracted_at: new Date().toISOString(),
extraction_model: 'skipped:invoice_inbox_gate',
})
.eq('id', document.id)
return
}
let extractedData: Record<string, unknown> | null = null
let model: string = 'copied-from-invoice-inbox'
if (inboxRow?.extracted_data) {
extractedData = inboxRow.extracted_data as Record<string, unknown>
} else {
const mimeType = existing.mime_type as string | null
if (!mimeType || !SUPPORTED_MIME_TYPES.has(mimeType)) {
// Stamp the attempt so we don't keep retrying unsupported types.
await supabase
.from('document_attachments')
.update({ extracted_at: new Date().toISOString(), extraction_model: 'skipped:unsupported_mime' })
.eq('id', document.id)
return
}
// Download the file from Supabase Storage. The bucket is private — the
// service-role client can read any path.
const storagePath = existing.storage_path as string | null
if (!storagePath) {
log.warn('document has no storage_path, skipping', { doc: document.id })
return
}
const { data: blob, error: dlErr } = await supabase.storage
.from('documents')
.download(storagePath)
if (dlErr || !blob) {
log.warn('storage download failed', { doc: document.id, err: dlErr?.message })
return
}
const buffer = Buffer.from(await blob.arrayBuffer())
try {
const { data, rawText } = await extractInvoiceFields({
buffer,
mimeType,
fileName: (document.file_name as string) || 'document',
})
// extractInvoiceFields returns an "empty" result on failure rather
// than throwing — distinguish by checking rawText. When rawText is
// null, the call was skipped (creds missing, unsupported type) or
// the JSON parse failed.
if (!rawText) {
await supabase
.from('document_attachments')
.update({
extracted_at: new Date().toISOString(),
extraction_model: 'failed:no_raw_text',
})
.eq('id', document.id)
return
}
extractedData = data as unknown as Record<string, unknown>
model = process.env.BEDROCK_MODEL_ID || 'eu.anthropic.claude-sonnet-4-6'
} catch (err) {
log.warn('extraction threw', {
doc: document.id,
err: err instanceof Error ? err.message : String(err),
})
await supabase
.from('document_attachments')
.update({
extracted_at: new Date().toISOString(),
extraction_model: 'failed:exception',
})
.eq('id', document.id)
return
}
}
const { error: updateErr } = await supabase
.from('document_attachments')
.update({
extracted_data: extractedData,
extracted_at: new Date().toISOString(),
extraction_model: model,
})
.eq('id', document.id)
if (updateErr) {
log.warn('persist failed', { doc: document.id, err: updateErr.message, companyId })
return
}
log.info('extraction persisted', { doc: document.id, model, companyId })
}
@@ -0,0 +1,19 @@
{
"id": "document-extraction",
"sector": "general",
"exportName": "documentExtractionExtension",
"entryPoint": "@/extensions/general/document-extraction",
"requiredEnvVars": [
"AWS_REGION"
],
"definition": {
"name": "AI-extrahering av underlag",
"category": "accounting",
"icon": "MessageCircle",
"dataPattern": "both",
"hasOwnData": false,
"readsCoreTables": ["document_attachments", "invoice_inbox_items"],
"description": "Läser kvitton och fakturor med AI och fyller i leverantör, belopp, moms och datum automatiskt",
"longDescription": "Lyssnar på document.uploaded-händelser och kör Sonnet 4.6 via AWS Bedrock på varje uppladdat kvitto eller faktura (PDF eller bild). De extraherade fälten skrivs till document_attachments.extracted_data så att den specialiserade bokföringsassistenten kan föreslå rätt BAS-konto utan att fråga användaren om sådant som redan står på underlaget. Hoppar över dokument som redan extraherats av andra extensions (t.ex. invoice-inbox) för att undvika dubbla AI-anrop."
}
}
@@ -0,0 +1,138 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { invoiceInboxExtension } from '@/extensions/general/invoice-inbox'
import {
createQueuedMockSupabase,
createMockRequest,
parseJsonResponse,
} from '@/tests/helpers'
import type { ExtensionContext } from '@/lib/extensions/types'
function findRoute(method: string, path: string) {
return invoiceInboxExtension.apiRoutes!.find(
(r) => r.method === method && r.path === path,
)!
}
const matchRoute = findRoute('POST', '/items/:id/match-transaction')
const unmatchRoute = findRoute('POST', '/items/:id/unmatch-transaction')
function buildCtx(supabase: unknown): ExtensionContext {
return {
userId: 'user-1',
companyId: 'company-1',
extensionId: 'invoice-inbox',
supabase: supabase as ExtensionContext['supabase'],
emit: vi.fn(),
settings: { get: vi.fn(), set: vi.fn() },
storage: { from: vi.fn() } as unknown as ExtensionContext['storage'],
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as unknown as ExtensionContext['log'],
services: {},
} as ExtensionContext
}
function makeReq(path: string, body?: unknown) {
return createMockRequest(path, {
method: 'POST',
searchParams: { _id: 'item-1' },
body,
})
}
describe('POST /items/:id/match-transaction', () => {
let mock: ReturnType<typeof createQueuedMockSupabase>
beforeEach(() => {
mock = createQueuedMockSupabase()
})
it('returns 401 without ctx', async () => {
const res = await matchRoute.handler(makeReq('/items/item-1/match-transaction'))
expect(res.status).toBe(401)
})
it('returns 400 when transaction_id is missing', async () => {
const ctx = buildCtx(mock.supabase)
const res = await matchRoute.handler(
makeReq('/items/item-1/match-transaction', {}),
ctx,
)
expect(res.status).toBe(400)
})
it('returns 404 when transaction is outside this company', async () => {
// tx lookup returns null → 404 before any further calls
mock.enqueue({ data: null })
const ctx = buildCtx(mock.supabase)
const res = await matchRoute.handler(
makeReq('/items/item-1/match-transaction', { transaction_id: 'tx-1' }),
ctx,
)
expect(res.status).toBe(404)
})
it('sets matched_transaction_id and mirrors document_id onto the transaction', async () => {
// Sequence: tx lookup (no doc yet) → inbox doc lookup → inbox update → tx update
mock.enqueue({ data: { id: 'tx-1', document_id: null } })
mock.enqueue({ data: { id: 'item-1', document_id: 'doc-1' } })
mock.enqueue({ data: { id: 'item-1', matched_transaction_id: 'tx-1' } })
mock.enqueue({ data: null }) // tx update result (we don't read)
const ctx = buildCtx(mock.supabase)
const res = await matchRoute.handler(
makeReq('/items/item-1/match-transaction', { transaction_id: 'tx-1' }),
ctx,
)
const { body } = await parseJsonResponse<{ data: { matched_transaction_id: string } }>(res)
expect(res.status).toBe(200)
expect(body.data.matched_transaction_id).toBe('tx-1')
})
it('does not overwrite an existing tx.document_id', async () => {
// tx already has a doc — match still succeeds but the tx update for
// document_id is skipped (no enqueue beyond the inbox update).
mock.enqueue({ data: { id: 'tx-1', document_id: 'existing-doc' } })
mock.enqueue({ data: { id: 'item-1', document_id: 'doc-1' } })
mock.enqueue({ data: { id: 'item-1', matched_transaction_id: 'tx-1' } })
const ctx = buildCtx(mock.supabase)
const res = await matchRoute.handler(
makeReq('/items/item-1/match-transaction', { transaction_id: 'tx-1' }),
ctx,
)
const { body } = await parseJsonResponse<{ data: { matched_transaction_id: string } }>(res)
expect(res.status).toBe(200)
expect(body.data.matched_transaction_id).toBe('tx-1')
})
})
describe('POST /items/:id/unmatch-transaction', () => {
let mock: ReturnType<typeof createQueuedMockSupabase>
beforeEach(() => {
mock = createQueuedMockSupabase()
})
it('clears matched_transaction_id and mirrors the unmatch onto the transaction', async () => {
// Sequence: existing-state lookup → inbox update → tx update
mock.enqueue({
data: {
id: 'item-1',
document_id: 'doc-1',
matched_transaction_id: 'tx-1',
},
})
mock.enqueue({ data: { id: 'item-1', matched_transaction_id: null } })
mock.enqueue({ data: null }) // tx update result
const ctx = buildCtx(mock.supabase)
const res = await unmatchRoute.handler(
makeReq('/items/item-1/unmatch-transaction'),
ctx,
)
const { body } = await parseJsonResponse<{
data: { matched_transaction_id: string | null }
}>(res)
expect(res.status).toBe(200)
expect(body.data.matched_transaction_id).toBeNull()
})
it('returns 401 without ctx', async () => {
const res = await unmatchRoute.handler(makeReq('/items/item-1/unmatch-transaction'))
expect(res.status).toBe(401)
})
})
+180 -3
View File
@@ -148,7 +148,12 @@ async function uploadAndExtract(
file: { name: string; buffer: ArrayBuffer; type: string },
source: 'upload' | 'email',
emailMeta?: EmailMeta,
opts: { skipExtraction?: boolean } = {}
// Pre-match the new inbox item to a bank transaction. Set when the caller
// already knows which transaction this receipt belongs to (e.g. the
// VerifyAndBookOverlay opened from a transaction row's paperclip or from
// a transaction-anchored chat). Skipped silently if missing.
matchedTransactionId?: string | null,
opts: { skipExtraction?: boolean } = {},
) {
const correlationId = crypto.randomUUID()
@@ -254,6 +259,7 @@ async function uploadAndExtract(
? { messageId: emailMeta.messageId, filename: file.name }
: null,
correlation_id: correlationId,
matched_transaction_id: matchedTransactionId ?? null,
})
.select('*')
.single()
@@ -291,6 +297,7 @@ async function uploadAndExtract(
status: inbox.status,
extracted_data: extracted,
matched_supplier_id: inbox.matched_supplier_id,
matched_transaction_id: inbox.matched_transaction_id,
extraction_skipped: skipExtraction,
skip_reason: skipReason,
page_count: pageCount,
@@ -347,6 +354,11 @@ export const invoiceInboxExtension: Extension = {
const formData = await request.formData()
const file = formData.get('file') as File | null
const matchedTransactionIdRaw = formData.get('matched_transaction_id')
const matchedTransactionId =
typeof matchedTransactionIdRaw === 'string' && matchedTransactionIdRaw.length > 0
? matchedTransactionIdRaw
: null
// Opt-out of the built-in Claude/Bedrock OCR. Agents with their own
// extraction pipeline upload the document, get the inbox row, then
// PUT /items/:id/extracted-data with their parsed fields.
@@ -365,6 +377,29 @@ export const invoiceInboxExtension: Extension = {
)
}
// Validate matched_transaction_id belongs to this company before we
// spend the AI extraction budget. RLS would also catch a mismatch on
// the insert, but failing fast gives a clearer error and lets the
// caller distinguish "your context_ref pointed at a tx you don't own"
// from a generic upload failure.
if (matchedTransactionId) {
const { data: tx, error: txErr } = await ctx.supabase
.from('transactions')
.select('id')
.eq('id', matchedTransactionId)
.eq('company_id', ctx.companyId)
.maybeSingle()
if (txErr) {
return NextResponse.json({ error: txErr.message }, { status: 500 })
}
if (!tx) {
return NextResponse.json(
{ error: 'matched_transaction_id refers to a transaction outside this company.' },
{ status: 400 },
)
}
}
try {
const buffer = await file.arrayBuffer()
const result = await uploadAndExtract(
@@ -374,7 +409,8 @@ export const invoiceInboxExtension: Extension = {
{ name: file.name, buffer, type: file.type },
'upload',
undefined,
{ skipExtraction }
matchedTransactionId,
{ skipExtraction },
)
return NextResponse.json({ data: result })
} catch (error) {
@@ -396,7 +432,11 @@ export const invoiceInboxExtension: Extension = {
const url = new URL(request.url)
const status = url.searchParams.get('status')
const limit = Math.min(Math.max(1, Number(url.searchParams.get('limit')) || 20), 50)
// Cap raised from 50 → 500: the inbox is a workqueue and booked items
// now drop out of the default view client-side, so a low cap silently
// hid active underlag behind older booked ones. 500 covers realistic
// single-company volumes; pagination is the next step beyond that.
const limit = Math.min(Math.max(1, Number(url.searchParams.get('limit')) || 50), 500)
let query = ctx.supabase
.from('invoice_inbox_items')
@@ -847,6 +887,143 @@ export const invoiceInboxExtension: Extension = {
},
},
// ── Match a bank transaction to an inbox item ──────────
// Sets invoice_inbox_items.matched_transaction_id. Used by the
// TransactionMatchPicker dialog after the user picks a candidate from
// the confidence-scored list. The transaction.categorization agent
// intent already reads this column in its capture() so the agent will
// see the inbox metadata as underlag on its next invocation.
{
method: 'POST',
path: '/items/:id/match-transaction',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const url = new URL(request.url)
const id = url.searchParams.get('_id')
if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 })
let body: { transaction_id?: string }
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
}
if (!body.transaction_id || typeof body.transaction_id !== 'string') {
return NextResponse.json({ error: 'transaction_id required' }, { status: 400 })
}
// Confirm transaction belongs to this company before linking. RLS
// would also catch it on the update, but failing fast keeps the
// error specific. Also fetch the existing document_id so we can
// decide whether to backfill it from the inbox doc below.
const { data: tx } = await ctx.supabase
.from('transactions')
.select('id, document_id')
.eq('id', body.transaction_id)
.eq('company_id', ctx.companyId)
.maybeSingle()
if (!tx) {
return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
}
// Fetch the inbox item's document_id so we can mirror it to
// transactions.document_id below — the TransactionInboxCard reads
// that column to decide whether to show the paperclip/file-check
// indicators on the /transactions list. Without this, a row that
// has a matched inbox item still appears doc-less in the UI.
const { data: inboxItem } = await ctx.supabase
.from('invoice_inbox_items')
.select('id, document_id')
.eq('id', id)
.eq('company_id', ctx.companyId)
.maybeSingle()
const { data: updated, error: updateError } = await ctx.supabase
.from('invoice_inbox_items')
.update({ matched_transaction_id: body.transaction_id })
.eq('id', id)
.eq('company_id', ctx.companyId)
.select('id, matched_transaction_id')
.single()
if (updateError) {
return NextResponse.json({ error: updateError.message }, { status: 500 })
}
// Mirror the inbox document onto the transaction so the list view
// reflects "underlag bifogat" immediately. Only when the tx has
// no other doc already (we never overwrite an existing link).
if (inboxItem?.document_id && !tx.document_id) {
const { error: txUpdateError } = await ctx.supabase
.from('transactions')
.update({ document_id: inboxItem.document_id })
.eq('id', body.transaction_id)
.eq('company_id', ctx.companyId)
.is('document_id', null)
if (txUpdateError) {
// Non-fatal: the match itself succeeded; the UI indicator just
// won't flip until next page refresh. Log but don't roll back.
console.error('[invoice-inbox/match-transaction] tx.document_id backfill failed:', txUpdateError)
}
}
return NextResponse.json({ data: updated })
},
},
// ── Clear matched_transaction_id (user mistake / re-match) ────
{
method: 'POST',
path: '/items/:id/unmatch-transaction',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const url = new URL(request.url)
const id = url.searchParams.get('_id')
if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 })
// Capture the current match before clearing so we can mirror the
// unmatch onto transactions.document_id below.
const { data: existing } = await ctx.supabase
.from('invoice_inbox_items')
.select('id, document_id, matched_transaction_id')
.eq('id', id)
.eq('company_id', ctx.companyId)
.maybeSingle()
const { data: updated, error: updateError } = await ctx.supabase
.from('invoice_inbox_items')
.update({ matched_transaction_id: null })
.eq('id', id)
.eq('company_id', ctx.companyId)
.select('id, matched_transaction_id')
.single()
if (updateError) {
return NextResponse.json({ error: updateError.message }, { status: 500 })
}
// Clear the mirrored tx.document_id only when it currently points
// at the same doc this inbox item brought in. Guards against
// clobbering a doc that came from another source (paperclip
// upload, SIE import, etc.).
if (existing?.matched_transaction_id && existing.document_id) {
const { error: txUpdateError } = await ctx.supabase
.from('transactions')
.update({ document_id: null })
.eq('id', existing.matched_transaction_id)
.eq('company_id', ctx.companyId)
.eq('document_id', existing.document_id)
if (txUpdateError) {
console.error('[invoice-inbox/unmatch-transaction] tx.document_id clear failed:', txUpdateError)
}
}
return NextResponse.json({ data: updated })
},
},
// ── Retry extraction on a stored document ──────────────
{
method: 'POST',

Some files were not shown because too many files have changed in this diff Show More