* fix(enable-banking): keep bank account mappings across reconnects and surface dead sessions A PSD2 reconnect silently moved the user's ledger mapping. Account identity came from the provider's account uid, which does not survive a re-authorization at every ASPSP, and a fresh connect to an already-connected bank mints a new bank_connections row regardless. Both paths looked like "an account we have never seen", so the allocator handed out the next free 19xx slot and a 1930/1940/1941 mapping came back as 1942-1946 on every consent renewal, roughly quarterly per connection. Match on the IBAN instead. resolvePsd2LedgerAccount() finds the existing cash_accounts row by normalized IBAN before allocating, and upsertFromPsd2 promotes that row in place rather than inserting a second one, so it keeps its id and its linked transactions and is re-pointed at the connection that just authorized. The previous holder's connection status is deliberately ignored: one IBAN is one physical account, and the old row often still reads 'active' because the bank killed the session without telling us. The allocator also stopped treating a 19xx number as free just because no cash_accounts row holds it. A chart imported from SIE carries the company's real bank accounts by name with no PSD2 row behind them, which is how a SEK company account got proposed as an unrelated brokerage account. Overflow now skips chart-occupied numbers, falling back only when nothing unnamed is left. Dead connections kept rendering as "Aktiv": status only ever changed when a transaction fetch failed, so a session killed bank-side stayed healthy-looking with a stale last_synced_at while the user read old balances as current. Add probeSessionHealth() and run it in the daily cron over every connection that run did not prove alive, including the ones the loop skips silently (capability gate, all accounts deselected) and the ones parked in pending_selection that the cron never looked at. It acts only on a definite dead answer; anything ambiguous leaves the row alone, since a wrong flip costs a full BankID re-authorization. The all-accounts-deselected branch is reclassified 'synced' to 'skipped' for the same reason: it never contacts the bank, so it must not count as proof of life. The settings row warns when an active connection has not synced in three days or has never synced. Which company a connection belongs to was invisible. Everything was already scoped to ctx.companyId, so there was no cross-tenant leak, but a bank authorized while the wrong company was active looked identical to the right one. Name the company on the connect surface and in the account picker, and say where the connection went when the callback lands under a different active company. Warn (bypassably) before authorizing a bank where the same user already holds live connections in other companies: several ASPSPs allow one active AIS session per login, so the new authorization can kill the others. The history start date already defaulted to the fiscal-year start; the card above it recommended a mid-year date and contradicted the selected option. It now states the fact and offers the shortcut without presenting it as advice. Not addressed: sharing one PSD2 session across companies. company_id is the tenancy anchor on bank_connections and cash_accounts hangs off (company_id, bank_connection_id), so that needs the session to become its own entity. See DECISIONS.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(supplier-invoices): show the posted line description in the voucher preview The "Verifikation som bokförs" preview built its expense debit lines with description set to the raw account number, so the BESKRIVNING column showed "5615" or "6990" where the posted verifikat actually says "Leverantörsfaktura 123, ACME AB". A hardcoded 11-entry ACCOUNT_LABELS map masked this for 2440/2641/26xx, which is why the column read as a mix of friendly labels and bare account numbers, neither of which was the posted text. The preview now renders exactly the line_description the engine writes: the shared invoice-level text on expense lines and 2440, "Ingående moms {rate}% {desc}" on 2641, and the reverse-charge pair taken straight from generateReverseChargeLines instead of being re-derived locally. buildSupplierDescription moves into its own dependency-free module so the client-side preview can call it without pulling the journal engine (and its Supabase server client) into the browser bundle. The account name stays reachable on the AccountNumber hover card. Picked option A from the issue, keeping the fixed invoice-level description rather than propagating each item's own text: the customer-invoice side already writes invoice-level descriptions, so per-item text would create an inconsistency between the two invoice sides rather than remove one, and it would need an aggregation-collision policy in the journal engine. Rationale recorded in DECISIONS.md. Refs #1258 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(bookkeeping): restore the copy icon on verifikat rows The row-language rewrite in #1123 reused the copy icon's slot for the new expand toggle, removing the zero-click copy affordance from the bookkeeping list without mentioning it. The leftover orphaned copy_voucher_tooltip key in both message files is what identifies it as collateral rather than a product decision. Restore a copy icon in the row's right-edge action cell, reusing that key for aria-label and title. stopPropagation keeps the click off the row's expand toggle. The icon is hover-revealed on md+ and always visible below it: #1123 collapsed the desktop table and the mobile card into one responsive table, so hover-only would leave touch users with nothing. Copy is no longer gated on posted. The copy_from handler and the GET journal-entries route never looked at status, so copying a draft already worked end-to-end and only the detail-page button hid it; the two list surfaces were already ungated. Both list affordances now respect canWrite, which previously dropped read-only users into a dialog they could not submit. The repo does not render components in tests, which is why #1123 removed this silently. Pin the source shape instead, the same way the copy-invoice query is pinned. Closes #1266 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(transactions): revalidate stale invoice match pointers before offering a match potential_invoice_id / potential_supplier_invoice_id are written once, at bank import, and never revisited. When one of several identical recurring invoices was settled by a different transaction, every other transaction kept pointing at the now fully paid invoice. The match dialog then measured the bank amount against a 0 kr remaining balance and reported a "Beloppen skiljer sig ... fakturan blir delbetald" partial payment, and the worklist offered the same dead suggestion as a one-click confirm row. Worse, the manual escape hatch was hidden exactly when it was needed: TransactionInboxCard only shows "Matcha mot leverantörsfaktura" when no suggestion exists, so a stale pointer left the user with no way at all to reach the correct invoice. Fixed by revalidating at read time rather than by clearing sibling pointers on settle. Invoices are settled through many paths (both match routes, mark-paid, MCP, bank reconciliation, SIE import), so write-time cleanup leaks the moment one is missed, while the candidate lookup covers every route into the list. The shared accept-lists in lib/invoices/matchable-statuses.ts mirror the CAS guards the match routes already enforce. - listSuggestedMatches and the transactions page candidate fetch filter on status + remaining_amount, so a settled candidate yields no suggestion and the manual picker reappears on its own. - InvoiceMatchDialog blocks a settled target with a distinct message and a disabled confirm. Not advisory: both routes reject it outright with MATCH_INVOICE_ALREADY_PAID / MATCH_SI_ALREADY_PAID, so no override could succeed. - The supplier detail card now shows remaining_amount like the customer branch, instead of total. On a partially paid invoice it used to print "1 250 kr" directly beside "Differens: 1 250 kr". - match-supplier-invoice clears potential_supplier_invoice_id on the transaction it just matched, mirroring the customer route. No bookkeeping was ever at risk: both routes already refused a settled target before creating a voucher. The damage was confined to a misleading dialog and a dead end. createQueuedMockSupabase gains passive call recording (calls / findCall / findCalls) because the proxy swallowed filter and update arguments, which made the new assertions inexpressible. Refs #1259, #1260 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(webhooks): dispatch on emit instead of waiting for the next cron tick (#1256) * feat(webhooks): dispatch on emit instead of waiting for the next cron tick The webhook dispatcher ran only on a per-minute cron, so the floor on delivery latency was up to 60 seconds plus the request. An external consumer that wanted to react as a transaction landed had only one alternative: polling /api/events, which the 100 rpm per-key limit makes expensive and which still cannot beat the tick interval. Schedules one dispatch cycle as soon as deliveries are enqueued. The cron is unchanged and remains the retry and sweep path; this only moves the first attempt forward. Wired into the event-bus fanout plus the two routes that enqueue a delivery directly: the :test verb, whose entire purpose is telling someone whether their receiver works, and the manual delivery retry. Three properties are load-bearing and covered by tests. The kick is never awaited, because eventBus.emit is awaited at ~99 call sites including journal_entry.committed and each delivery can burn a 10 s receiver timeout. It coalesces per function instance, so a bulk booking that emits once per row does not schedule one claim round trip per row. It claims 5 rows rather than the cron's 50, because it runs on the tail of a user-facing request. Double delivery is not a risk: claim_due_webhook_deliveries already claims FOR UPDATE SKIP LOCKED and flips rows to in_flight in the same statement, so a kick racing the cron sees disjoint rows. Does not close #1201, which asks for a realtime stream for API consumers. This is the cheap half. Refs #1201 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(webhooks): stop claiming the kick makes double delivery impossible Adversarial review of the previous commit caught an overstatement in its own comments. SKIP LOCKED keeps a kick and the cron from claiming the same row at the same moment, but claim_due_webhook_deliveries autocommits before any POST is issued, so from then on ownership is only status='in_flight' and a later cycle's recoverStuckInFlight sweep can re-arm a row still queued behind an earlier cycle's serial loop. Delivery is at-least-once, which is what the public docs already tell receivers ("the same delivery id may arrive more than once ... idempotency is on you"). The comments contradicted that. No behaviour change. The kick does not create this window: the cron claims 50 rows serially against the same 20 s stuck threshold, which is wider than what a batch of 5 can open. Refs #1201 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(bokslut): add bokslut-flow depreciation (78xx) back to the bolagsskatt base (#1253) * fix(bokslut): add bokslut-flow depreciation (78xx) back to the bolagsskatt base sumPostedYearEndDispositions reconstructs resultat fore skatt for the tax calculation, because generateIncomeStatement excludes every source_type='year_end' entry. It summed class 88 and 7533 but not 78xx, so planenlig avskrivning posted by the bokslut flow (lib/bokslut/assets/depreciation-engine.ts) was dropped from the income statement and never added back. The bolagsskatt base and the periodiseringsfond 25 % cap were therefore computed on an overstated result: tax too high by roughly 20.6 % of the depreciation. Also exclude the period's final bokslutsverifikation from the fetch. It carries source_type='year_end' as well and reverses every P&L account, 78xx/88xx/7533 included (verified against production closing entries), so once the year is closed it would cancel the add-back this function exists to produce. That hazard already applied to 88xx and 7533; the fix closes it for all three rather than widening it. Scope is deliberately the tax base only. Making the standalone resultatrakning show bokslut entries is a separate, larger change: the same exclusion is duplicated in the kpi_report_aggregates RPC, it moves displayed profit for every company that ran the bokslut flow, and it means removing the add-back at four call sites. Refs #1051 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(bokslut): scope the closing-entry lookup to the company and fail loudly Review (CodeRabbit + the compliance swarm, ASVS V8.2.1) flagged the new fiscal_periods read in sumPostedYearEndDispositions on two counts, both fair. It filtered only on the period id while every sibling query in the same function carries the tenant scope. Primary key or not, service-role paths have no RLS to fall back on and the repo's rule is to filter company_id explicitly, so it now does. It also discarded the query error. That mattered more than it looks: a failed read fell through to closingEntryId = null, which silently re-admits the closing verifikat's 78xx/88xx reversals and understates the tax base, i.e. exactly the failure this lookup was added to prevent. It now throws, and the surrounding catch turns it into the existing 'Failed to read posted dispositions' error. A wrong bolagsskatt is worse than a loud failure. Two regression tests: the lookup carries both eq filters, and a lookup failure propagates instead of degrading to a wrong number. Refs #1051 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(storage): drop the client-side DELETE policy on the documents bucket (#1254) * fix(storage): drop the client-side DELETE policy on the documents bucket 20240101000024 documents this bucket as WORM: "No UPDATE or DELETE policies". That described the repo, not production. Production carries a users_delete_own_documents policy that exists in no migration file: FOR DELETE TO authenticated USING (bucket_id = 'documents' AND (storage.foldername(name))[2] = auth.uid()::text) Under it, the uploading user can delete the storage bytes of any document they uploaded under the legacy documents/{userId}/... layout, using nothing but their normal browser token. That includes documents linked to a posted verifikat, which are rakenskapsinformation under the BFL 7 kap 2 § seven-year retention duty. deleteDocument()'s linked-check and the block_document_deletion() trigger both guard the document_attachments ROW, not the object: the row survives, still pointing at a file that is gone. Reproduced against a local replay of the full migration stream: with the policy present the uploader's own DELETE removes the object; with it dropped the same statement matches zero rows. Company-scoped keys were never exposed (their second path segment is the company id, not auth.uid()), so this only ever reached the legacy layout, which is where most documents still live. Safe because every in-app remove() on this bucket already runs on the service role, covered by service_role_all_documents. Deliberately narrow: users_read_own_documents and users_upload_own_documents stay. The Phase B backfill from 20260726092000 has not run, so dropping the legacy SELECT policy now would make existing documents unreadable. That is Phase C. The pg-real test asserts no DELETE and no UPDATE policy over the bucket under ANY name: the hole arrived under a name this repo never used, so pinning a name would not have caught it. Refs #1208 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(storage): make the WORM ratchet see FOR ALL and WITH CHECK policies Review caught two blind spots in the ratchet, both fair. It matched only polcmd 'd' and 'w', but polcmd '*' (FOR ALL) grants DELETE and UPDATE just as effectively, and FOR ALL is the shape the one legitimate policy on this table already uses, so a hostile one would look unremarkable in the catalogue. It also read only polqual, so an UPDATE policy carrying its bucket restriction in WITH CHECK was invisible. Both assertions now run through one helper that covers d/w/*, concatenates USING and WITH CHECK, and filters by grantee so service_role_all_documents (how the application does its authorized deletes) is excluded while every client-reachable role is not. A policy granted to PUBLIC has an empty polroles, which is the most permissive case there is, so it is treated as client-reachable rather than as "no roles". Matching on the substring rather than the exact `bucket_id = 'documents'` shape pg_get_expr emits today: a policy written as bucket_id::text or with the comparison reversed would slip past a stricter match, and for a WORM ratchet a false alarm is cheap while a silent hole is not. Adds a probe case that creates a FOR ALL policy and asserts the helper sees it, so the main assertion cannot pass vacuously. That case earned its keep immediately: it caught that node-postgres hands back a raw string for a name[] column, so the role filter needed rolname::text to work at all. Verified against a local replay of the full migration stream: red with the original prod FOR DELETE policy present, red with a FOR ALL probe, green without either. Full pg-real suite 933 passed. Refs #1208 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(storage): catch a destructive policy that names no bucket at all Adversarial review of the previous commit found the ratchet still failed open, and reproduced it: a policy with no bucket_id predicate covers EVERY bucket, documents included, so gating on the bucket name discarded exactly the widest hole. The concrete shape is Supabase's own stock "Enable delete for users based on user_id" template, USING (auth.uid() = owner), which is the single most likely form of a future dashboard edit. A destructive policy is now in scope unless it provably cannot reach this bucket, i.e. only a bucket_id predicate naming some other bucket exempts it. The behavioural assertions had the matching blind spot: fixtures were seeded without an owner, so an owner-based policy matched NULL and the DELETE reported 0 rows for the wrong reason. Objects now carry an owner the way storage-api stamps them in production, so those tests fail loudly instead of passing by accident. Two probes pin both directions: a bucketless policy must be reported (and is shown to really permit the delete), and a policy scoped to another bucket must not be, so the ratchet cannot start crying wolf on receipts or sie-files and get switched off. Verified against a local replay of the full migration stream: red with the stock bucketless template installed, green without it. Full pg-real suite 935 passed. Refs #1208 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(kontoplan): make a deactivated account reachable again (#1262) is_active=false read as "does not exist" on every read path but as "exists" on the (company_id, account_number) unique constraint, so a deactivated account vanished from the kontoplan with no way back and re-creating it answered "Kontonummer X finns redan i din kontoplan." The write side was already correct: POST /accounts/activate has a toReactivate branch and PUT /accounts/[number] accepts is_active:true. Both were simply unreachable, so this opens routes to them rather than relaxing the read filters, which are load-bearing for AccountsNotInChartError. - Kontoplan gets a "Visa inaktiva" filter; inactive rows carry an "Inaktiv" chip and the existing per-row switch reactivates them in one click. - Deactivating an account that has posted lines now warns first, using the usage count already loaded for the Verifikat column. - POST /accounts distinguishes the two collisions and returns the new ACCOUNT_EXISTS_INACTIVE code; AddAccountDialog offers "Aktivera kontot istallet" rather than a dead-end 409. The stored account is left exactly as it was; values typed into the failed create form are not applied. - bas-lookup consults the company's own chart before the static BAS reference, so a deactivated custom account reads as known and "Aktivera och bokfor" is no longer disabled for it. New in_chart / is_active fields let callers tell "will be added" from "will be revived". - BAS-katalog stops showing "Aktiverat" for an account the company holds but has deactivated; it falls through to a relabelled Aktivera button, and the per-class counts follow. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(supplier-invoices): flag foreign 0 % lines with reverse charge switched off (#1255) * fix(supplier-invoices): flag foreign 0 % lines with reverse charge switched off A foreign supplier charging no Swedish VAT is normally omvand skattskyldighet. With the reverse-charge switch off, createSupplierInvoiceRegistrationEntry emits neither the 26x4 output leg nor the 44xx/45xx basis lines, so ruta 20-24, 30-32 and 48 all stay empty and the momsdeklaration takes a shape Skatteverket rejects. For a fully deductible purchase the net moms att betala is unchanged, which is exactly why this goes unnoticed. The form already auto-ticks reverse charge for eu_business but not for non_eu_business, so that path slips through silently. Adds a pure helper plus a non-blocking banner cloned from the existing rc_account_warning block. Deliberately silent for swedish_business, where 0 % is a genuine exemption that belongs in no ruta at all, and phrased as a question rather than an assertion: a non-EU goods purchase cleared at customs is legitimately 0 % without reverse charge, and pushing that user into ticking the switch would manufacture a new wrong verifikat. Does not add the exempt/import/other picker the issue proposes: supplier_invoices.vat_treatment is metadata that no booking or ruta mapping reads, and the codebase cannot book import VAT at all, so an import option would imply ruta 50/60 were handled when they are not. Refs #1042 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(supplier-invoices): name the local-VAT case in the foreign 0 % hint Review flagged that the most common foreign document a Swedish small company sees is an invoice carrying the supplier's OWN local VAT, booked at 0 % Swedish VAT with reverse charge correctly off. The banner fires there, and the previous copy only offered "momsfri av annat skal, till exempel en varuimport" as the way out, which does not describe that invoice at all: it is not VAT-free, it carries foreign VAT. Names both legitimate cases explicitly and says 0 % is correct in them, so the hint cannot read as an instruction to tick reverse charge on a purchase where that would produce a wrong verifikat. Title also narrowed to "utan svensk moms" for the same reason. Refs #1042 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(sandbox): call the sandbox assistant Assistenten, not Anna (#1244) A named persona earns its name once someone has been through onboarding and chosen it: it is their assistant and they named it. Nobody in the sandbox chose anything, so a first name reads as a character the product invented and implies a relationship the visitor never opted into. Both halves move together, which is the point. profile_summary is the agent's own self-description inside the system prompt, so leaving it as "Du är Anna" would have the header say one thing while the assistant introduces itself as another in its first sentence. Nothing else in the stack checks that pairing, so a test now does. Scope: this changes the seed, so new sandbox companies get the new name. The 483 sandbox profiles already seeded keep 'Anna' (the seeder returns early once a profile exists, and its caller only runs while verified_at is null). Backfilling those is a production write on demo data and is being raised separately rather than smuggled into a code change. Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * feat(reports): show the last posted voucher per series in report headers Adds a "Senaste bokforda verifikat: A 214, B 37" line to the balans- and resultatrapport, so a printed or exported report answers which vouchers are actually in it rather than only which dates it spans (#1267). Reads MAX(voucher_number) over posted entries, never voucher_sequences.last_number. The sequence counter is an allocation high-water mark that drifts from the books in both directions: next_voucher_number burns a number when the follow-up insert fails, delete_last_voucher decrements by one instead of resetting to the new MAX, and pre-RPC SIE imports left it behind. Since the point of the line is avstamning, an allocated number would send a reconciler chasing a gap that does not exist, so the label says plainly that the number is the posted one. Scoped to the report own date range, so a Q1 report printed in November says something true about Q1. The balansrapport keeps the fiscal-year start as its lower bound because it accumulates. Skipped on a dimension-filtered resultatrapport: that report already discloses it is partial, and an unfiltered voucher range beside a filtered result invites the wrong conclusion. Populated in both engines, so the JSON, PDF and XLSX routes all inherit it without signature changes. Best-effort: a header nicety never breaks a report. The pure formatter lives in its own module so the client view does not pull the Supabase query path into the browser bundle. No new i18n keys; both report views and the PDF template are hard-coded Swedish per the "stays Swedish" report surfaces in .claude/rules/i18n.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(customers): stop rendering personnummer ciphertext, make unreadable rows editable, add a reveal path (#1263) customers.personal_number holds AES-256-GCM ciphertext (20260726110000). Three defects compounded into one broken surface for private customers. The list queried Supabase from the browser with select('*') and rendered the raw value, 76-82 chars of hex, into the nowrap identifier cell. It now reads GET /api/customers, which already masks every row, so the ciphertext never leaves the server. Searching by personnummer works again: the client filter had been matching against ciphertext and could never hit. A row whose value cannot be decrypted renders as the placeholder '********-????'. None of the three mask checks recognised it, each having its own '-1234'-only copy, so such a customer could not be edited in ANY field: name and address edits 400'd on a personnummer the user had no way to correct. All three now share one pattern from the new crypto-free lib/customers/mask-personal-number.ts, which the client form can import. Typing a fresh personnummer overwrites the unreadable value, which is the only repair possible: the rejected writes failed whole INSERTs, so there is nothing to backfill. The value was write-only by construction. GET /api/customers/{id}/personal-number is the deliberate drill-in, mirroring the employee convention, gated on the write role because .compliance/ropa.yaml listed no_full_value_read_endpoint as a safeguard for this column; that entry is rewritten rather than left stale, and reveals log actor and customer id but never the value. Also: arcim-migration wrote the identity number as plaintext, which aborts any import containing a Privatperson with 23514 since the constraint flip; and the customer embeds on /api/invoices shipped ciphertext to the browser on every invoice read. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: enhance ruta 05 handling for dynamic revenue accounts - Introduced `fetchDynamicRuta05Accounts` to fetch company-specific revenue accounts marked with a VAT rate, addressing issue #1261. - Updated VAT declaration logic to include these dynamic accounts in ruta 05 calculations, ensuring accurate reporting for user-added accounts. - Modified `ACCOUNT_RUTA` to include account 3000 for completeness in ruta 05. - Enhanced tests to validate the inclusion of user-added revenue accounts in ruta 05 and ensure correct VAT calculations. - Seeded default VAT rates for BAS revenue accounts to ensure proper classification in the VAT declaration. * fix: enhance data handling and masking in customer and invoice APIs * fix(vat): resolve the 3000 gruppkonto's rate for the ruta 05 base split 3000 "Forsaljning inom Sverige" is mapped to ruta05 by ACCOUNT_RUTA, so a balance on it is filed in the right box already. What was missing is the rate split: unlike 3001/3002/3003 the account number carries no sats, and fetchDynamicRuta05Accounts skipped it because it is in ACCOUNT_TO_BOX. A company posting to the gruppkonto therefore got a ruta 05 total that breakdown.invoices.base25/12/6 did not add up to. Surface those rates separately as staticRateByAccount: rate-only on purpose, because the static map already sums the account and adding it to the dynamic account list would double the filed figure. A test pins that single-count property. Also add 3000 to the MCP server's RUTA_05_ACCOUNTS, which is the display list behind report.rutor.ruta05: without it a 3000 balance appeared in the filed projection but not in the report the agent reads back. The comment claiming SALES_OUTPUT_VAT_SHORTFALL reads base25/12/6 was wrong and is corrected. That check derives its expected base from the output-VAT rutor (ruta10/0.25 + ruta11/0.12 + ruta12/0.06); nothing reads the per-rate bases, which are reporting metadata. So the incomplete split never affected a filed return or a warning, only the breakdown. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com>
1600 lines
70 KiB
TypeScript
1600 lines
70 KiB
TypeScript
'use client'
|
|
|
|
import { Fragment, useState, useEffect, useCallback } from 'react'
|
|
import Link from 'next/link'
|
|
import { useRouter } from 'next/navigation'
|
|
import { useTranslations } from 'next-intl'
|
|
import {
|
|
DataList,
|
|
DataListEmpty,
|
|
DataListLoading,
|
|
} from '@/components/ui/data-list'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Label } from '@/components/ui/label'
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
|
import { Switch } from '@/components/ui/switch'
|
|
import { Checkbox } from '@/components/ui/checkbox'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
DialogFooter,
|
|
DialogClose,
|
|
} from '@/components/ui/dialog'
|
|
import {
|
|
STORAGE_KEY_PREFIX as FISCAL_YEAR_STORAGE_KEY_PREFIX,
|
|
ALL_YEARS_VALUE as FISCAL_YEAR_ALL_VALUE,
|
|
} from '@/components/common/FiscalYearSelector'
|
|
import { FyPicker } from '@/components/common/FyPicker'
|
|
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
|
import {
|
|
TH_CLASS,
|
|
TD_CLASS,
|
|
VTH_CLASS,
|
|
VTD_CLASS,
|
|
QUIET_LINK_CLASS,
|
|
RowFoldout,
|
|
} from '@/components/ui/dry-table'
|
|
import { ChevronRight, ChevronLeft, ChevronsLeft, ChevronsRight, Copy, Paperclip, CircleSlash, Loader2, BookOpen, X, Lock, Search, SlidersHorizontal, RotateCcw } from 'lucide-react'
|
|
import { cn, formatDate, formatCurrency } from '@/lib/utils'
|
|
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
|
|
import { resolveCurrentPeriodId } from '@/lib/bookkeeping/suggest-fiscal-period'
|
|
import { Input } from '@/components/ui/input'
|
|
import { AccountNumber } from '@/components/ui/account-number'
|
|
import { getAccountDescription } from '@/lib/bookkeeping/account-descriptions'
|
|
import JournalEntryAttachments from '@/components/bookkeeping/JournalEntryAttachments'
|
|
import NoDocRequiredToggle from '@/components/bookkeeping/NoDocRequiredToggle'
|
|
import CorrectionEntryDialog from '@/components/bookkeeping/CorrectionEntryDialog'
|
|
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
|
|
import JournalEntryStatusBadge from '@/components/bookkeeping/JournalEntryStatusBadge'
|
|
import AttachmentPreviewSheet from '@/components/bookkeeping/AttachmentPreviewSheet'
|
|
import { useToast } from '@/components/ui/use-toast'
|
|
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
|
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
|
import { useCompanyOptional } from '@/contexts/CompanyContext'
|
|
import type { FiscalPeriod, JournalEntry, JournalEntryLine } from '@/types'
|
|
|
|
const NEEDS_ATTACHMENT = new Set([
|
|
'manual',
|
|
'bank_transaction',
|
|
'supplier_invoice_registered',
|
|
'supplier_invoice_paid',
|
|
'supplier_invoice_cash_payment',
|
|
'import',
|
|
])
|
|
|
|
type SortBy = 'date_desc' | 'date_asc' | 'voucher_asc' | 'voucher_desc'
|
|
|
|
// Per-company persistence of the sort dropdown. Mirrors the localStorage
|
|
// convention used by FiscalYearSelector ('Accounted:fiscal-year:<companyId>').
|
|
const SORT_STORAGE_KEY_PREFIX = 'Accounted:journal-sort:'
|
|
const SORT_VALUES = new Set<SortBy>(['date_desc', 'date_asc', 'voucher_asc', 'voucher_desc'])
|
|
|
|
// Compact row density (support feedback: "kompakt visning av verifikat").
|
|
// Persisted per company, mirroring the sort key convention.
|
|
|
|
// Page-size selector. Persisted per company, mirroring the sort key convention.
|
|
// 'all' fetches everything in the current scope (capped server-side at MAX_LIMIT);
|
|
// the numeric options paginate normally.
|
|
type PageSizeChoice = '20' | '50' | '100' | 'all'
|
|
const PAGE_SIZE_STORAGE_KEY_PREFIX = 'Accounted:journal-page-size:'
|
|
const PAGE_SIZE_OPTIONS = [20, 50, 100] as const
|
|
const PAGE_SIZE_VALUES = new Set<PageSizeChoice>(['20', '50', '100', 'all'])
|
|
// Sentinel limit sent for "Alla". The route clamps this to its own MAX_LIMIT.
|
|
const ALL_PAGE_SIZE = 100000
|
|
|
|
export default function JournalEntryList() {
|
|
const router = useRouter()
|
|
const { toast } = useToast()
|
|
const { canWrite } = useCanWrite()
|
|
const company = useCompanyOptional()?.company ?? null
|
|
const t = useTranslations('journal_list')
|
|
const [entries, setEntries] = useState<JournalEntry[]>([])
|
|
const [committingId, setCommittingId] = useState<string | null>(null)
|
|
// Confirm-before-posting (convention 10): the draft the user is about to
|
|
// commit, plus the predicted voucher label ("A-218") for the dialog copy.
|
|
const [commitTarget, setCommitTarget] = useState<JournalEntry | null>(null)
|
|
const [commitVoucherPreview, setCommitVoucherPreview] = useState<string | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [expandedId, setExpandedId] = useState<string | null>(null)
|
|
const [count, setCount] = useState(0)
|
|
const [page, setPage] = useState(0)
|
|
const [attachmentCounts, setAttachmentCounts] = useState<Record<string, number>>({})
|
|
// Entries with inline rättelser (journal_entry_rattelse_log rows): drives
|
|
// the "Rättad" marker so a rättelse is discoverable from the list
|
|
// (BFL 5 kap 5 §), not only on the detail page.
|
|
const [rattelseFlags, setRattelseFlags] = useState<Set<string>>(new Set())
|
|
const [noDocRequired, setNoDocRequired] = useState<Map<string, string | null>>(new Map())
|
|
const [showMissingOnly, setShowMissingOnly] = useState(false)
|
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
|
const [batchReason, setBatchReason] = useState('')
|
|
const [batchSubmitting, setBatchSubmitting] = useState(false)
|
|
const [bulkOpen, setBulkOpen] = useState(false)
|
|
const [bulkCount, setBulkCount] = useState<number | null>(null)
|
|
const [bulkReason, setBulkReason] = useState('')
|
|
const [bulkSubmitting, setBulkSubmitting] = useState(false)
|
|
const [correctionEntry, setCorrectionEntry] = useState<JournalEntry | null>(null)
|
|
const [reverseEntryTarget, setReverseEntryTarget] = useState<JournalEntry | null>(null)
|
|
const [isReversing, setIsReversing] = useState(false)
|
|
const [previewEntryId, setPreviewEntryId] = useState<string | null>(null)
|
|
const [sortBy, setSortBy] = useState<SortBy>('date_desc')
|
|
const [sortHydrated, setSortHydrated] = useState(false)
|
|
const [periodId, setPeriodId] = useState<string | null>(null)
|
|
const [periodHydrated, setPeriodHydrated] = useState(false)
|
|
const [filterOpen, setFilterOpen] = useState(false)
|
|
const [dateFrom, setDateFrom] = useState('')
|
|
const [dateTo, setDateTo] = useState('')
|
|
const [dateFromInput, setDateFromInput] = useState('')
|
|
const [dateToInput, setDateToInput] = useState('')
|
|
const [seriesFilter, setSeriesFilter] = useState<string>('all')
|
|
const [searchInput, setSearchInput] = useState('')
|
|
const [search, setSearch] = useState('')
|
|
// Verifikat (committed) vs Utkast (drafts) view. Drafts are excluded from the
|
|
// committed list server-side and surfaced here behind a count badge.
|
|
const [listMode, setListMode] = useState<'committed' | 'drafts'>('committed')
|
|
// Collapse correction groups to the live correction (hide storno + reversed
|
|
// original). Toggled off via the filter dialog to reveal the full chain.
|
|
const [collapseCorrections, setCollapseCorrections] = useState(true)
|
|
const [draftCount, setDraftCount] = useState(0)
|
|
const [pageSizeChoice, setPageSizeChoice] = useState<PageSizeChoice>('20')
|
|
const [pageSizeHydrated, setPageSizeHydrated] = useState(false)
|
|
const showingAll = pageSizeChoice === 'all'
|
|
const pageSize = showingAll ? ALL_PAGE_SIZE : Number(pageSizeChoice)
|
|
|
|
const normalizeDate = (v: string): string | null => {
|
|
const trimmed = v.trim()
|
|
if (!trimmed) return null
|
|
// YYYY
|
|
if (/^\d{4}$/.test(trimmed)) {
|
|
const y = parseInt(trimmed, 10)
|
|
if (y < 1900 || y > 2100) return null
|
|
return `${trimmed}-01-01`
|
|
}
|
|
// YYYY-MM
|
|
if (/^\d{4}-\d{2}$/.test(trimmed)) {
|
|
const [y, m] = trimmed.split('-').map(Number)
|
|
if (y < 1900 || y > 2100 || m < 1 || m > 12) return null
|
|
return `${trimmed}-01`
|
|
}
|
|
// YYYY-MM-DD
|
|
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
|
|
const d = new Date(trimmed + 'T00:00:00')
|
|
if (isNaN(d.getTime())) return null
|
|
// Verify the date didn't roll over (e.g. 2024-02-31 → March)
|
|
const [y, m, day] = trimmed.split('-').map(Number)
|
|
if (d.getFullYear() !== y || d.getMonth() + 1 !== m || d.getDate() !== day) return null
|
|
return trimmed
|
|
}
|
|
return null
|
|
}
|
|
|
|
const applyDateFilter = () => {
|
|
const fromVal = dateFromInput.trim()
|
|
const toVal = dateToInput.trim()
|
|
const nextFrom = fromVal === '' ? '' : normalizeDate(fromVal) ?? dateFrom
|
|
const nextTo = toVal === '' ? '' : normalizeDate(toVal) ?? dateTo
|
|
setDateFromInput(nextFrom)
|
|
setDateToInput(nextTo)
|
|
if (nextFrom !== dateFrom || nextTo !== dateTo) {
|
|
setDateFrom(nextFrom)
|
|
setDateTo(nextTo)
|
|
setPage(0)
|
|
}
|
|
}
|
|
|
|
const fetchAttachmentCounts = useCallback(async (entryIds: string[]) => {
|
|
if (entryIds.length === 0) {
|
|
setAttachmentCounts({})
|
|
return
|
|
}
|
|
// The counts route caps each request at 50 IDs, so a large page ("Alla", or
|
|
// 100/page) must be split into chunks and merged. Without this the whole
|
|
// request 400s and every document-requiring row falsely shows the
|
|
// missing-underlag warning until it's expanded.
|
|
const COUNTS_BATCH_SIZE = 50
|
|
const batches: string[][] = []
|
|
for (let i = 0; i < entryIds.length; i += COUNTS_BATCH_SIZE) {
|
|
batches.push(entryIds.slice(i, i + COUNTS_BATCH_SIZE))
|
|
}
|
|
try {
|
|
const results = await Promise.all(
|
|
batches.map(async (batch) => {
|
|
const res = await fetch(
|
|
`/api/documents/counts?journal_entry_ids=${batch.join(',')}`
|
|
)
|
|
if (!res.ok) return {} as Record<string, number>
|
|
const { data } = await res.json()
|
|
return (data || {}) as Record<string, number>
|
|
})
|
|
)
|
|
setAttachmentCounts(Object.assign({}, ...results))
|
|
} catch {
|
|
// Non-critical: silently ignore
|
|
}
|
|
}, [])
|
|
|
|
const fetchRattelseFlags = useCallback(async (entryIds: string[]) => {
|
|
if (entryIds.length === 0) {
|
|
setRattelseFlags(new Set())
|
|
return
|
|
}
|
|
try {
|
|
const res = await fetch(
|
|
`/api/bookkeeping/journal-entries/rattelse-flags?ids=${entryIds.join(',')}`
|
|
)
|
|
if (!res.ok) return
|
|
const { data } = await res.json()
|
|
setRattelseFlags(new Set((data || []) as string[]))
|
|
} catch {
|
|
// Non-critical: silently ignore
|
|
}
|
|
}, [])
|
|
|
|
const fetchNoDocRequired = useCallback(async () => {
|
|
try {
|
|
const res = await fetch('/api/bookkeeping/no-doc-required')
|
|
if (!res.ok) return
|
|
const { data } = await res.json()
|
|
const map = new Map<string, string | null>()
|
|
for (const row of (data || []) as { journal_entry_id: string; reason: string | null }[]) {
|
|
map.set(row.journal_entry_id, row.reason)
|
|
}
|
|
setNoDocRequired(map)
|
|
} catch {
|
|
// Non-critical: silently ignore
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
fetchNoDocRequired()
|
|
}, [fetchNoDocRequired])
|
|
|
|
// Restore the persisted sort order (per company). Read in an effect rather
|
|
// than the useState initializer to avoid an SSR/client hydration mismatch.
|
|
// sortHydrated gates the first fetch so the list is fetched once, already in
|
|
// the saved order, no flash of the default sort.
|
|
useEffect(() => {
|
|
if (typeof window !== 'undefined') {
|
|
const stored = window.localStorage.getItem(SORT_STORAGE_KEY_PREFIX + (company?.id ?? 'default'))
|
|
if (stored && SORT_VALUES.has(stored as SortBy)) setSortBy(stored as SortBy)
|
|
}
|
|
setSortHydrated(true)
|
|
}, [company?.id])
|
|
|
|
|
|
// Restore the persisted page-size choice (per company). Same hydration pattern
|
|
// as the sort order, read in an effect to avoid an SSR mismatch, and gate the
|
|
// first fetch so the list is fetched once at the saved size.
|
|
useEffect(() => {
|
|
if (typeof window !== 'undefined') {
|
|
const stored = window.localStorage.getItem(PAGE_SIZE_STORAGE_KEY_PREFIX + (company?.id ?? 'default'))
|
|
if (stored && PAGE_SIZE_VALUES.has(stored as PageSizeChoice)) setPageSizeChoice(stored as PageSizeChoice)
|
|
}
|
|
setPageSizeHydrated(true)
|
|
}, [company?.id])
|
|
|
|
// Fetch fiscal periods AND resolve the initial fiscal-year scope in one pass.
|
|
// The list is period-oriented (BFL): verifikationsnummer run as an unbroken
|
|
// series *per räkenskapsår*, so the same number (e.g. A42) recurs once per
|
|
// year. Showing every year at once makes those look like duplicates and makes
|
|
// a bare "A42" reference ambiguous, so we default to the räkenskapsår the
|
|
// user is currently in rather than "all years". An explicit "Alla
|
|
// räkenskapsår" choice (persisted as ALL_YEARS_VALUE) is still honoured.
|
|
// Resolving the scope here, not in the dialog's FiscalYearSelector, which
|
|
// only mounts when opened, keeps the first fetch correct. periodHydrated
|
|
// gates that first fetch so the list loads already scoped to the resolved year.
|
|
useEffect(() => {
|
|
if (!company?.id) {
|
|
setPeriodId(null)
|
|
setPeriodHydrated(true)
|
|
return
|
|
}
|
|
|
|
const stored =
|
|
typeof window !== 'undefined'
|
|
? window.localStorage.getItem(FISCAL_YEAR_STORAGE_KEY_PREFIX + company.id)
|
|
: null
|
|
// Optimistic hydration: a saved scope unblocks the first entries fetch
|
|
// immediately instead of serializing it behind the fiscal-periods
|
|
// round-trip (the common returning-user case). The fetch below still
|
|
// validates a saved period id and re-scopes to the current räkenskapsår
|
|
// if it went stale (e.g. the period was deleted), the entries effect
|
|
// then refires with the corrected scope.
|
|
if (stored) {
|
|
// FISCAL_YEAR_ALL_VALUE = user explicitly chose "all years", respect it.
|
|
setPeriodId(stored === FISCAL_YEAR_ALL_VALUE ? null : stored)
|
|
setPeriodHydrated(true)
|
|
}
|
|
|
|
let cancelled = false
|
|
;(async () => {
|
|
let fetched: FiscalPeriod[] = []
|
|
try {
|
|
const res = await fetch('/api/bookkeeping/fiscal-periods')
|
|
if (res.ok) {
|
|
const { data } = await res.json()
|
|
fetched = (data || []) as FiscalPeriod[]
|
|
}
|
|
} catch {
|
|
// Non-critical: fall through with an empty list (scope stays "all years").
|
|
}
|
|
if (cancelled) return
|
|
|
|
if (stored === FISCAL_YEAR_ALL_VALUE) return
|
|
if (stored && fetched.some((p) => p.id === stored)) return
|
|
|
|
// No (valid) saved scope → default to the current räkenskapsår.
|
|
const today = new Date().toISOString().split('T')[0]
|
|
setPeriodId(resolveCurrentPeriodId(fetched, today))
|
|
setPeriodHydrated(true)
|
|
})()
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [company?.id])
|
|
|
|
// Debounce the free-text search before it reaches the API. Require ≥2 chars:
|
|
// a single character matches almost every verifikationstext and isn't a useful
|
|
// filter, so 0-1 chars are treated as "no search" instead of firing a query on
|
|
// every keystroke (ASVS V2.4).
|
|
useEffect(() => {
|
|
const handle = setTimeout(() => {
|
|
const trimmed = searchInput.trim()
|
|
setSearch(trimmed.length >= 2 ? trimmed : '')
|
|
setPage(0)
|
|
}, 300)
|
|
return () => clearTimeout(handle)
|
|
}, [searchInput])
|
|
|
|
async function fetchEntries() {
|
|
setLoading(true)
|
|
setSelectedIds(new Set()) // selection is page-scoped, reset on reload
|
|
const params = new URLSearchParams({
|
|
limit: String(pageSize),
|
|
offset: String(page * pageSize),
|
|
sort_by: sortBy,
|
|
})
|
|
if (listMode === 'drafts') {
|
|
// Drafts get their own view spanning all years: they're work-in-progress
|
|
// and shouldn't be hidden by the selected fiscal-year scope.
|
|
params.set('status', 'draft')
|
|
} else {
|
|
params.set('exclude_draft', 'true')
|
|
if (collapseCorrections) params.set('collapse_corrections', 'true')
|
|
if (periodId) params.set('period_id', periodId)
|
|
if (dateFrom) params.set('date_from', dateFrom)
|
|
if (dateTo) params.set('date_to', dateTo)
|
|
if (seriesFilter !== 'all') params.set('series', seriesFilter)
|
|
}
|
|
if (search) params.set('search', search)
|
|
|
|
const res = await fetch(`/api/bookkeeping/journal-entries?${params}`)
|
|
if (!res.ok) {
|
|
setLoading(false)
|
|
return
|
|
}
|
|
const { data, count: total } = await res.json()
|
|
const loadedEntries = data || []
|
|
setEntries(loadedEntries)
|
|
setCount(total || 0)
|
|
|
|
// The pristine empty card vs. the (toggle-bearing) "drafts exist" state hinges
|
|
// on draftCount. When the committed list comes back empty, resolve the draft
|
|
// count BEFORE clearing loading so the toggle doesn't flash out for a frame on
|
|
// a stale count of 0. Every other case refreshes the badge in the background.
|
|
if (loadedEntries.length === 0 && listMode === 'committed') {
|
|
await fetchDraftCount()
|
|
} else {
|
|
fetchDraftCount()
|
|
}
|
|
setLoading(false)
|
|
|
|
// Fetch attachment counts + rättelse markers for the loaded entries
|
|
const ids = loadedEntries.map((e: JournalEntry) => e.id)
|
|
fetchAttachmentCounts(ids)
|
|
fetchRattelseFlags(ids)
|
|
}
|
|
|
|
// Cheap count-only query for the "Utkast" badge, all years, so the badge
|
|
// surfaces drafts regardless of the selected fiscal-year scope.
|
|
async function fetchDraftCount() {
|
|
try {
|
|
const res = await fetch('/api/bookkeeping/journal-entries?status=draft&limit=1')
|
|
if (!res.ok) return
|
|
const { count: total } = await res.json()
|
|
setDraftCount(total || 0)
|
|
} catch {
|
|
// Non-fatal: the badge keeps its last value.
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!sortHydrated || !periodHydrated || !pageSizeHydrated) return
|
|
fetchEntries()
|
|
}, [periodId, page, pageSize, sortBy, dateFrom, dateTo, seriesFilter, search, listMode, collapseCorrections, sortHydrated, periodHydrated, pageSizeHydrated])
|
|
|
|
const handleAttachmentCountChange = useCallback((entryId: string, count: number) => {
|
|
setAttachmentCounts((prev) => ({ ...prev, [entryId]: count }))
|
|
}, [])
|
|
|
|
const toggleExpand = (id: string) => {
|
|
setExpandedId(expandedId === id ? null : id)
|
|
}
|
|
|
|
function switchMode(mode: 'committed' | 'drafts') {
|
|
if (mode === listMode) return
|
|
setListMode(mode)
|
|
setPage(0)
|
|
setSelectedIds(new Set())
|
|
if (mode === 'drafts') setShowMissingOnly(false)
|
|
}
|
|
|
|
// Open the confirm dialog and fetch the predicted voucher number. The
|
|
// prediction is indicative (numbers are assigned atomically at commit,
|
|
// and a draft in another period/series may land elsewhere); the success
|
|
// toast always shows the real one.
|
|
const openCommitConfirm = (entry: JournalEntry) => {
|
|
setCommitTarget(entry)
|
|
setCommitVoucherPreview(null)
|
|
fetch('/api/bookkeeping/voucher-sequences/next')
|
|
.then((r) => r.json())
|
|
.then(({ data }) => {
|
|
if (data?.next != null) setCommitVoucherPreview(`${data.series}${data.next}`)
|
|
})
|
|
.catch(() => {})
|
|
}
|
|
|
|
const handleCommit = async (entryId: string) => {
|
|
setCommittingId(entryId)
|
|
try {
|
|
const res = await fetch(`/api/bookkeeping/journal-entries/${entryId}/commit`, { method: 'POST' })
|
|
const result = await res.json()
|
|
if (res.ok) {
|
|
const posted = result.data
|
|
toast({
|
|
title: t('toast_posted_title'),
|
|
description: t('toast_posted_description', { voucher: formatVoucher(posted ?? {}) }),
|
|
})
|
|
await fetchEntries()
|
|
} else {
|
|
toast({ title: t('toast_post_failed'), description: getErrorMessage(result, { context: 'journal_entry' }), variant: 'destructive' })
|
|
}
|
|
} catch {
|
|
toast({ title: t('toast_post_failed_generic'), variant: 'destructive' })
|
|
} finally {
|
|
setCommittingId(null)
|
|
}
|
|
}
|
|
|
|
// Pure reversal (storno) of a posted verifikat: books a stornoverifikation
|
|
// with no replacement, per BFL 5 kap 5§. Routes through the engine's
|
|
// reverseEntry (storno + reverses_id link; original → 'reversed', never
|
|
// deleted). "Rätta" stays the path for booking a replacement entry instead.
|
|
const handleReverse = async () => {
|
|
const target = reverseEntryTarget
|
|
if (!target) return
|
|
setIsReversing(true)
|
|
try {
|
|
const res = await fetch(`/api/bookkeeping/journal-entries/${target.id}/reverse`, { method: 'POST' })
|
|
const result = await res.json()
|
|
if (res.ok) {
|
|
const storno = result.data
|
|
toast({
|
|
title: t('toast_reverse_done_title'),
|
|
description: t('toast_reverse_done_description', { voucher: formatVoucher(storno ?? {}) }),
|
|
})
|
|
setReverseEntryTarget(null)
|
|
await fetchEntries()
|
|
} else {
|
|
toast({ title: t('toast_reverse_failed'), description: getErrorMessage(result, { context: 'journal_entry' }), variant: 'destructive' })
|
|
}
|
|
} catch {
|
|
toast({ title: t('toast_reverse_failed'), variant: 'destructive' })
|
|
} finally {
|
|
setIsReversing(false)
|
|
}
|
|
}
|
|
|
|
// A posted, document-requiring entry with no attachment yet and not already
|
|
// exempt, i.e. the rows that show the warning triangle. Only these can be
|
|
// batch-marked "Inget underlag krävs".
|
|
const isEligibleForExempt = useCallback(
|
|
(entry: JournalEntry) =>
|
|
entry.status === 'posted' &&
|
|
NEEDS_ATTACHMENT.has(entry.source_type) &&
|
|
!attachmentCounts[entry.id] &&
|
|
!noDocRequired.has(entry.id),
|
|
[attachmentCounts, noDocRequired],
|
|
)
|
|
|
|
const toggleSelect = (id: string) => {
|
|
setSelectedIds((prev) => {
|
|
const next = new Set(prev)
|
|
if (next.has(id)) next.delete(id)
|
|
else next.add(id)
|
|
return next
|
|
})
|
|
}
|
|
|
|
const handleBatchExempt = async () => {
|
|
const ids = Array.from(selectedIds)
|
|
if (ids.length === 0) return
|
|
setBatchSubmitting(true)
|
|
const reason = batchReason.trim() || null
|
|
try {
|
|
const res = await fetch('/api/bookkeeping/no-doc-required/batch', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ journal_entry_ids: ids, reason }),
|
|
})
|
|
const body = await res.json().catch(() => ({}))
|
|
if (!res.ok) {
|
|
// Resolve via the parsed body plus the status: the route answers
|
|
// thrown errors with the canonical envelope `{ error: { code,
|
|
// message } }`, and rendering that object as the toast description
|
|
// would crash React ("Objects are not valid as a React child").
|
|
toast({
|
|
title: t('no_doc_required_save_failed'),
|
|
description: getErrorMessage(body, { statusCode: res.status }),
|
|
variant: 'destructive',
|
|
})
|
|
return
|
|
}
|
|
// Reflect the new exemptions locally: triangle → muted "no doc" indicator.
|
|
setNoDocRequired((prev) => {
|
|
const next = new Map(prev)
|
|
for (const id of ids) next.set(id, reason)
|
|
return next
|
|
})
|
|
setSelectedIds(new Set())
|
|
setBatchReason('')
|
|
toast({
|
|
title: t('batch_no_doc_done_title'),
|
|
description: t('batch_no_doc_done_description', { count: body.data?.exempted ?? ids.length }),
|
|
})
|
|
} catch {
|
|
toast({ title: t('no_doc_required_save_failed'), variant: 'destructive' })
|
|
} finally {
|
|
setBatchSubmitting(false)
|
|
}
|
|
}
|
|
|
|
// Filter-scoped bulk mark: mark EVERY missing-doc verifikat matching the active
|
|
// filters (period/series/date/search), across all pages: the scalable remedy
|
|
// for a post-import flood. A dry_run first surfaces the exact count to confirm.
|
|
const filterPayload = () => ({
|
|
period_id: periodId,
|
|
series: seriesFilter !== 'all' ? seriesFilter : null,
|
|
date_from: dateFrom || null,
|
|
date_to: dateTo || null,
|
|
search: search || null,
|
|
})
|
|
|
|
const openBulk = async () => {
|
|
setBulkOpen(true)
|
|
setBulkCount(null)
|
|
setBulkReason('')
|
|
try {
|
|
const res = await fetch('/api/bookkeeping/no-doc-required/bulk-missing', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ ...filterPayload(), dry_run: true }),
|
|
})
|
|
const body = await res.json().catch(() => ({}))
|
|
setBulkCount(res.ok ? (body.data?.count ?? 0) : 0)
|
|
} catch {
|
|
setBulkCount(0)
|
|
}
|
|
}
|
|
|
|
const handleBulkConfirm = async () => {
|
|
setBulkSubmitting(true)
|
|
try {
|
|
const res = await fetch('/api/bookkeeping/no-doc-required/bulk-missing', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ ...filterPayload(), reason: bulkReason.trim() || null }),
|
|
})
|
|
const body = await res.json().catch(() => ({}))
|
|
if (!res.ok) {
|
|
toast({
|
|
title: t('no_doc_required_save_failed'),
|
|
description: getErrorMessage(body, { statusCode: res.status }),
|
|
variant: 'destructive',
|
|
})
|
|
return
|
|
}
|
|
setBulkOpen(false)
|
|
setBulkCount(null)
|
|
setBulkReason('')
|
|
setSelectedIds(new Set())
|
|
toast({
|
|
title: t('batch_no_doc_done_title'),
|
|
description: t('batch_no_doc_done_description', { count: body.data?.exempted ?? 0 }),
|
|
})
|
|
await fetchNoDocRequired()
|
|
await fetchEntries()
|
|
} catch {
|
|
toast({ title: t('no_doc_required_save_failed'), variant: 'destructive' })
|
|
} finally {
|
|
setBulkSubmitting(false)
|
|
}
|
|
}
|
|
|
|
const filteredEntries = showMissingOnly
|
|
? entries.filter(
|
|
(e) =>
|
|
NEEDS_ATTACHMENT.has(e.source_type) &&
|
|
!attachmentCounts[e.id] &&
|
|
e.status === 'posted' &&
|
|
!noDocRequired.has(e.id)
|
|
)
|
|
: entries
|
|
|
|
// Count of active dialog filters, shown as a badge on the Filtrera button so
|
|
// the user can tell the list is scoped without opening the dialog. Sort order
|
|
// is a view preference (always set), not a filter, so it is excluded.
|
|
// Filters that live inside the Filtrera dialog. The fiscal-year scope is its
|
|
// own control now, so it no longer counts toward the dialog badge (it would
|
|
// otherwise always read "1" for the default year).
|
|
const dialogFilterCount =
|
|
(seriesFilter !== 'all' ? 1 : 0) +
|
|
(dateFrom || dateTo ? 1 : 0) +
|
|
(showMissingOnly ? 1 : 0)
|
|
// Year scope included: drives empty-state messaging + keeping the bar mounted.
|
|
const activeFilterCount = (periodId ? 1 : 0) + dialogFilterCount
|
|
|
|
// When any filter or search is active we keep the filter bar mounted even
|
|
// with zero results, so the user can edit or clear their query. The pristine
|
|
// "no entries yet" state below only applies to an untouched, empty ledger.
|
|
const hasActiveFilters = Boolean(search) || activeFilterCount > 0
|
|
|
|
// Apply a fiscal-year selection. The FiscalYearSelector (now an inline control
|
|
// in the toolbar, not buried in the filter dialog) persists the choice to
|
|
// localStorage itself; here we only mirror it into local state and reset
|
|
// pagination.
|
|
const handlePeriodChange = (next: string | null) => {
|
|
setPeriodId(next)
|
|
setPage(0)
|
|
}
|
|
|
|
// Change how many verifikat are shown per page. Resets to the first page and
|
|
// persists the choice per company (same convention as the sort order).
|
|
const handlePageSizeChange = (next: PageSizeChoice) => {
|
|
setPageSizeChoice(next)
|
|
setPage(0)
|
|
if (typeof window !== 'undefined') {
|
|
window.localStorage.setItem(PAGE_SIZE_STORAGE_KEY_PREFIX + (company?.id ?? 'default'), next)
|
|
}
|
|
}
|
|
|
|
const clearAllFilters = () => {
|
|
setPeriodId(null)
|
|
// Mirror the selector's "Alla räkenskapsår" write so the cleared scope
|
|
// survives a remount/reload instead of being restored from a stale value.
|
|
if (company?.id && typeof window !== 'undefined') {
|
|
window.localStorage.setItem(FISCAL_YEAR_STORAGE_KEY_PREFIX + company.id, FISCAL_YEAR_ALL_VALUE)
|
|
}
|
|
setSeriesFilter('all')
|
|
setShowMissingOnly(false)
|
|
setDateFrom('')
|
|
setDateTo('')
|
|
setDateFromInput('')
|
|
setDateToInput('')
|
|
setPage(0)
|
|
}
|
|
|
|
// Rows on this page the user can batch-mark "Inget underlag krävs".
|
|
const eligibleEntries = canWrite ? filteredEntries.filter(isEligibleForExempt) : []
|
|
const allEligibleSelected =
|
|
eligibleEntries.length > 0 && eligibleEntries.every((e) => selectedIds.has(e.id))
|
|
const toggleSelectAll = () => {
|
|
setSelectedIds((prev) => {
|
|
const next = new Set(prev)
|
|
if (allEligibleSelected) {
|
|
for (const e of eligibleEntries) next.delete(e.id)
|
|
} else {
|
|
for (const e of eligibleEntries) next.add(e.id)
|
|
}
|
|
return next
|
|
})
|
|
}
|
|
|
|
// Pristine, untouched ledger: nothing posted, no drafts, no filters, and we're
|
|
// on the committed view. ONLY this genuinely-empty case may short-circuit the
|
|
// whole component: every other empty state (a draft exists, or we're in the
|
|
// drafts view) must fall through to the main render below so the
|
|
// Verifikat/Utkast toggle stays reachable.
|
|
if (!loading && entries.length === 0 && !hasActiveFilters && listMode === 'committed' && draftCount === 0) {
|
|
return (
|
|
<DataList className="stagger-enter">
|
|
<DataListEmpty
|
|
icon={<BookOpen className="h-6 w-6" />}
|
|
title={t('empty_title')}
|
|
description={t('empty_description')}
|
|
/>
|
|
</DataList>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Control bar: view toggle + search + filters + active fiscal-year scope
|
|
on one aligned row (wraps on narrow screens) rather than four stacked rows. */}
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{/* Verifikat vs Utkast. Drafts live in their own view with a count badge so
|
|
they don't sink to the last page of the committed list. */}
|
|
<div className="inline-flex shrink-0 gap-0.5 rounded-lg bg-muted/70 p-[3px]" role="tablist">
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={listMode === 'committed'}
|
|
onClick={() => switchMode('committed')}
|
|
className={cn(
|
|
'rounded-md px-3.5 py-[5px] text-[12.5px] transition-colors duration-150',
|
|
listMode === 'committed'
|
|
? 'border border-border bg-card font-medium text-foreground'
|
|
: 'text-muted-foreground hover:text-foreground',
|
|
)}
|
|
>
|
|
{t('mode_vouchers')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={listMode === 'drafts'}
|
|
onClick={() => switchMode('drafts')}
|
|
className={cn(
|
|
'inline-flex items-center gap-1.5 rounded-md px-3.5 py-[5px] text-[12.5px] transition-colors duration-150',
|
|
listMode === 'drafts'
|
|
? 'border border-border bg-card font-medium text-foreground'
|
|
: 'text-muted-foreground hover:text-foreground',
|
|
)}
|
|
>
|
|
{t('mode_drafts')}
|
|
{draftCount > 0 && (
|
|
<span className="rounded-full bg-secondary px-1.5 text-[10px] font-medium tabular-nums">
|
|
{draftCount}
|
|
</span>
|
|
)}
|
|
</button>
|
|
</div>
|
|
<div className="relative flex-1 sm:flex-none sm:w-[280px]">
|
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
|
|
<Input
|
|
type="text"
|
|
inputMode="search"
|
|
placeholder={t('search_placeholder')}
|
|
aria-label={t('search_placeholder')}
|
|
value={searchInput}
|
|
onChange={(e) => setSearchInput(e.target.value)}
|
|
className="h-8 pl-8 pr-7 text-xs"
|
|
/>
|
|
{searchInput && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setSearchInput('')}
|
|
className="absolute right-1.5 top-1/2 -translate-y-1/2 p-0.5 rounded-sm hover:bg-muted text-muted-foreground"
|
|
title={t('clear_search')}
|
|
aria-label={t('clear_search')}
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<Dialog open={filterOpen} onOpenChange={setFilterOpen}>
|
|
<DialogTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-8 gap-2 text-xs shrink-0"
|
|
aria-label={
|
|
dialogFilterCount > 0
|
|
? t('filter_with_count', { count: dialogFilterCount })
|
|
: t('filter')
|
|
}
|
|
>
|
|
<SlidersHorizontal className="h-3.5 w-3.5" />
|
|
{t('filter')}
|
|
{dialogFilterCount > 0 && (
|
|
<Badge
|
|
variant="secondary"
|
|
className="h-4 min-w-4 justify-center px-1 text-[10px] tabular-nums"
|
|
>
|
|
{dialogFilterCount}
|
|
</Badge>
|
|
)}
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>{t('filter_dialog_title')}</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4">
|
|
{/* Sortering */}
|
|
<div className="space-y-2">
|
|
<Label className="text-sm font-medium">{t('filter_section_sort')}</Label>
|
|
<Select
|
|
value={sortBy}
|
|
onValueChange={(v) => {
|
|
const next = v as SortBy
|
|
setSortBy(next)
|
|
setPage(0)
|
|
if (typeof window !== 'undefined') {
|
|
window.localStorage.setItem(SORT_STORAGE_KEY_PREFIX + (company?.id ?? 'default'), next)
|
|
}
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-9 w-full text-sm">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="date_desc">{t('sort_date_desc')}</SelectItem>
|
|
<SelectItem value="date_asc">{t('sort_date_asc')}</SelectItem>
|
|
<SelectItem value="voucher_asc">{t('sort_voucher_asc')}</SelectItem>
|
|
<SelectItem value="voucher_desc">{t('sort_voucher_desc')}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Verifikationsserie */}
|
|
<div className="space-y-2">
|
|
<Label className="text-sm font-medium">{t('filter_section_series')}</Label>
|
|
<Select value={seriesFilter} onValueChange={(v) => { setSeriesFilter(v); setPage(0) }}>
|
|
<SelectTrigger className="h-9 w-full text-sm font-mono" aria-label={t('filter_section_series')}>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">Alla serier</SelectItem>
|
|
{'ABCDEFG'.split('').map((letter) => (
|
|
<SelectItem key={letter} value={letter} className="font-mono">
|
|
Serie {letter}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Datumintervall */}
|
|
<div className="space-y-2">
|
|
<Label className="text-sm font-medium">{t('filter_section_date')}</Label>
|
|
<div className="flex items-center gap-2">
|
|
<Input
|
|
type="text"
|
|
placeholder={t('date_from_placeholder')}
|
|
value={dateFromInput}
|
|
onChange={(e) => setDateFromInput(e.target.value)}
|
|
onBlur={applyDateFilter}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault()
|
|
applyDateFilter()
|
|
}
|
|
}}
|
|
className="h-9 flex-1 text-sm"
|
|
/>
|
|
<span className="text-sm text-muted-foreground">-</span>
|
|
<Input
|
|
type="text"
|
|
placeholder={t('date_to_placeholder')}
|
|
value={dateToInput}
|
|
onChange={(e) => setDateToInput(e.target.value)}
|
|
onBlur={applyDateFilter}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault()
|
|
applyDateFilter()
|
|
}
|
|
}}
|
|
className="h-9 flex-1 text-sm"
|
|
/>
|
|
{(dateFrom || dateTo) && (
|
|
<button
|
|
type="button"
|
|
onClick={() => { setDateFrom(''); setDateTo(''); setDateFromInput(''); setDateToInput(''); setPage(0) }}
|
|
className="p-1 rounded-sm hover:bg-muted text-muted-foreground shrink-0"
|
|
title={t('clear_date_filter')}
|
|
aria-label={t('clear_date_filter')}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Visa saknade underlag */}
|
|
<div className="flex items-center gap-2">
|
|
<Switch
|
|
id="missing-attachments"
|
|
checked={showMissingOnly}
|
|
onCheckedChange={setShowMissingOnly}
|
|
/>
|
|
<Label htmlFor="missing-attachments" className="text-sm cursor-pointer">
|
|
{t('show_missing')}
|
|
</Label>
|
|
{showMissingOnly && (
|
|
<Badge variant="secondary" className="text-xs tabular-nums">
|
|
{filteredEntries.length}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
|
|
{/* Reveal the storno + reversed-original rows the default view folds
|
|
into the surviving correction (3 rows → 1). */}
|
|
<div className="flex items-center gap-2">
|
|
<Switch
|
|
id="show-correction-chain"
|
|
checked={!collapseCorrections}
|
|
onCheckedChange={(on) => { setCollapseCorrections(!on); setPage(0) }}
|
|
/>
|
|
<Label htmlFor="show-correction-chain" className="text-sm cursor-pointer">
|
|
{t('show_correction_chain')}
|
|
</Label>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter className="sm:justify-between">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={clearAllFilters}
|
|
disabled={activeFilterCount === 0}
|
|
>
|
|
{t('filter_clear_all')}
|
|
</Button>
|
|
<DialogClose asChild>
|
|
<Button variant="outline" size="sm">{t('filter_done')}</Button>
|
|
</DialogClose>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
{/* The page context picker (convention 8): fiscal-year scope as a
|
|
chip-dropdown far right in the toolbar, one click to change,
|
|
always visible per BFL. Persists to localStorage (same key as
|
|
before); first-load scope resolution still happens
|
|
authoritatively in the period effect. */}
|
|
{periodHydrated && (
|
|
<div className="sm:ml-auto">
|
|
<FyPicker value={periodId} onChange={handlePeriodChange} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{loading ? (
|
|
<DataList className="stagger-enter">
|
|
<DataListLoading />
|
|
</DataList>
|
|
) : filteredEntries.length === 0 ? (
|
|
// Empty placeholder, scoped to the situation: an empty drafts view, a
|
|
// filtered committed view with no matches, or a committed view with no
|
|
// posted entries yet (but drafts exist, hence we got here, not the
|
|
// pristine early return above).
|
|
<DataList className="stagger-enter">
|
|
<DataListEmpty
|
|
icon={
|
|
listMode === 'drafts' || !hasActiveFilters ? (
|
|
<BookOpen className="h-6 w-6" />
|
|
) : (
|
|
<Search className="h-6 w-6" />
|
|
)
|
|
}
|
|
title={
|
|
listMode === 'drafts'
|
|
? t('empty_drafts_title')
|
|
: hasActiveFilters
|
|
? t('no_results_title')
|
|
: t('empty_title')
|
|
}
|
|
description={
|
|
listMode === 'drafts'
|
|
? t('empty_drafts_description')
|
|
: hasActiveFilters
|
|
? t('no_results_description')
|
|
: t('empty_description')
|
|
}
|
|
/>
|
|
</DataList>
|
|
) : (
|
|
<div>
|
|
{/* Bulkbar (concept): hidden until at least one verifikat is
|
|
selected via the hover checkboxes, then it pops in with the
|
|
count and the batch actions. Select-all and the filter-scoped
|
|
bulk mark live inside it as quiet actions. */}
|
|
{selectedIds.size > 0 && (
|
|
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 border-b border-border px-1 py-2.5 text-[12.5px] animate-fade-in">
|
|
<span className="whitespace-nowrap">
|
|
<strong className="font-semibold tabular-nums">{selectedIds.size}</strong>{' '}
|
|
{t('bulkbar_selected', { count: selectedIds.size })}
|
|
</span>
|
|
<Input
|
|
value={batchReason}
|
|
onChange={(e) => setBatchReason(e.target.value)}
|
|
placeholder={t('no_doc_required_reason_placeholder')}
|
|
list="batch-no-doc-suggestions"
|
|
maxLength={200}
|
|
className="h-8 w-56 text-xs"
|
|
disabled={batchSubmitting}
|
|
/>
|
|
<datalist id="batch-no-doc-suggestions">
|
|
<option value={t('no_doc_required_suggestion_bank_fee')} />
|
|
<option value={t('no_doc_required_suggestion_interest')} />
|
|
<option value={t('no_doc_required_suggestion_internal_transfer')} />
|
|
<option value={t('no_doc_required_suggestion_tax_payment')} />
|
|
<option value={t('no_doc_required_suggestion_salary')} />
|
|
</datalist>
|
|
<Button size="sm" onClick={handleBatchExempt} disabled={batchSubmitting}>
|
|
{batchSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
{t('batch_mark_no_doc')}
|
|
</Button>
|
|
{!allEligibleSelected && (
|
|
<button type="button" className={QUIET_LINK_CLASS} onClick={toggleSelectAll}>
|
|
{t('batch_select_all', { count: eligibleEntries.length })}
|
|
</button>
|
|
)}
|
|
{/* Filter-scoped: mark every missing-doc verifikat matching the
|
|
active filters across all pages: scales to a post-import flood. */}
|
|
<button type="button" className={QUIET_LINK_CLASS} onClick={openBulk}>
|
|
{t('batch_mark_all_missing')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={QUIET_LINK_CLASS}
|
|
onClick={() => setSelectedIds(new Set())}
|
|
disabled={batchSubmitting}
|
|
>
|
|
{t('batch_clear_selection')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full border-collapse text-[13px]">
|
|
<thead>
|
|
<tr>
|
|
<th className={cn(TH_CLASS, 'w-[26px] !pl-1')} aria-hidden="true"></th>
|
|
<th className={TH_CLASS}>{t('th_voucher')}</th>
|
|
<th className={cn(TH_CLASS, 'hidden sm:table-cell')}>{t('th_date')}</th>
|
|
<th className={cn(TH_CLASS, 'w-full')}>{t('th_description')}</th>
|
|
<th className={cn(TH_CLASS, 'text-right')}>{t('th_amount')}</th>
|
|
<th className={TH_CLASS} aria-hidden="true"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="stagger-enter">
|
|
{filteredEntries.map((entry) => {
|
|
const isExpanded = expandedId === entry.id
|
|
const lines = (entry.lines || []) as JournalEntryLine[]
|
|
// Voucher total = sum of the debit side (= credit side when balanced).
|
|
const voucherTotal = lines.reduce((sum, l) => sum + (Number(l.debit_amount) || 0), 0)
|
|
const selectable = canWrite && isEligibleForExempt(entry)
|
|
|
|
return (
|
|
<Fragment key={entry.id}>
|
|
<tr
|
|
className={cn(
|
|
'group cursor-pointer transition-colors duration-150',
|
|
isExpanded ? 'bg-secondary/25' : 'hover:bg-secondary/35',
|
|
selectedIds.has(entry.id) && 'bg-secondary/40',
|
|
)}
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-expanded={isExpanded}
|
|
onClick={() => toggleExpand(entry.id)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault()
|
|
toggleExpand(entry.id)
|
|
}
|
|
}}
|
|
>
|
|
{/* Hover-revealed selection checkbox (concept .cb) */}
|
|
<td
|
|
className={cn(TD_CLASS, 'w-[26px] !pl-1 py-[9px]')}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{selectable && (
|
|
<Checkbox
|
|
checked={selectedIds.has(entry.id)}
|
|
onCheckedChange={() => toggleSelect(entry.id)}
|
|
aria-label={t('batch_select_row')}
|
|
className={cn(
|
|
'transition-opacity duration-150',
|
|
selectedIds.has(entry.id)
|
|
? 'opacity-100'
|
|
: 'opacity-0 group-hover:opacity-100 focus-visible:opacity-100',
|
|
)}
|
|
/>
|
|
)}
|
|
</td>
|
|
<td className={cn(TD_CLASS, 'whitespace-nowrap')}>
|
|
<Link
|
|
href={`/bookkeeping/${entry.id}`}
|
|
className="font-mono text-[13px] tabular-nums hover:underline"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{formatVoucher(entry)}
|
|
</Link>
|
|
</td>
|
|
<td className={cn(TD_CLASS, 'hidden sm:table-cell whitespace-nowrap tabular-nums text-muted-foreground')}>
|
|
{formatDate(entry.entry_date)}
|
|
</td>
|
|
<td className={cn(TD_CLASS, 'max-w-0 w-full')}>
|
|
<span className="flex min-w-0 items-center gap-2">
|
|
<span className="truncate">{entry.description}</span>
|
|
{entry.out_of_period && (
|
|
<Badge
|
|
variant="outline"
|
|
className="text-xs font-normal shrink-0"
|
|
title={t('out_of_period_tooltip')}
|
|
>
|
|
{t('out_of_period_label')}
|
|
</Badge>
|
|
)}
|
|
{(entry.status === 'reversed' || entry.status === 'draft' || entry.source_type === 'storno' || entry.source_type === 'correction') && (
|
|
<JournalEntryStatusBadge entry={entry} showStatus={entry.status === 'reversed' || entry.status === 'draft'} />
|
|
)}
|
|
{rattelseFlags.has(entry.id) && (
|
|
<Badge
|
|
variant="outline"
|
|
className="text-xs font-normal shrink-0"
|
|
title={t('rattelse_badge_tooltip')}
|
|
>
|
|
{t('rattelse_badge')}
|
|
</Badge>
|
|
)}
|
|
</span>
|
|
</td>
|
|
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right tabular-nums rr-mask')}>
|
|
{formatCurrency(voucherTotal, 'SEK', { minimumFractionDigits: 2 })}
|
|
</td>
|
|
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right py-[9px]')}>
|
|
<span className="inline-flex items-center justify-end gap-2">
|
|
{attachmentCounts[entry.id] ? (
|
|
<button
|
|
type="button"
|
|
aria-label={t('view_attachments')}
|
|
title={t('attachment_count_tooltip', { count: attachmentCounts[entry.id] })}
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
setPreviewEntryId(entry.id)
|
|
}}
|
|
className="inline-flex items-center gap-0.5 rounded text-muted-foreground transition-colors duration-150 hover:text-foreground"
|
|
>
|
|
<Paperclip className="h-3.5 w-3.5" />
|
|
<span className="text-xs tabular-nums">{attachmentCounts[entry.id]}</span>
|
|
</button>
|
|
) : (
|
|
NEEDS_ATTACHMENT.has(entry.source_type) && entry.status === 'posted' && (
|
|
noDocRequired.has(entry.id) ? (
|
|
<span title={t('no_doc_required_indicator_tooltip')}>
|
|
<CircleSlash className="h-3.5 w-3.5 text-muted-foreground" />
|
|
</span>
|
|
) : (
|
|
<Badge variant="warning" title={t('missing_attachment_tooltip')}>
|
|
{t('missing_attachment_chip')}
|
|
</Badge>
|
|
)
|
|
)
|
|
)}
|
|
{entry.status === 'draft' && (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="h-7 px-3.5 text-xs"
|
|
disabled={!canWrite || committingId === entry.id}
|
|
title={!canWrite ? t('read_only_tooltip') : undefined}
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
openCommitConfirm(entry)
|
|
}}
|
|
>
|
|
{committingId === entry.id && <Loader2 className="mr-1.5 h-3 w-3 animate-spin" />}
|
|
{t('post')}
|
|
</Button>
|
|
)}
|
|
{canWrite && (
|
|
<button
|
|
type="button"
|
|
aria-label={t('copy_voucher_tooltip')}
|
|
title={t('copy_voucher_tooltip')}
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
router.push(`/bookkeeping?copy_from=${entry.id}`)
|
|
}}
|
|
className={cn(
|
|
// p-2 grows the tap target to 30px without
|
|
// changing row height (the row is ~40px from
|
|
// the description cell).
|
|
'inline-flex items-center rounded p-2 text-muted-foreground transition-opacity duration-150 hover:text-foreground',
|
|
// Quiet at rest on desktop, but the table has no
|
|
// mobile card to fall back on, so touch keeps the
|
|
// icon visible.
|
|
'opacity-100 md:opacity-0 md:group-hover:opacity-100 md:focus-visible:opacity-100',
|
|
)}
|
|
>
|
|
<Copy className="h-3.5 w-3.5" />
|
|
</button>
|
|
)}
|
|
<ChevronRight
|
|
className={cn(
|
|
'h-3.5 w-3.5 text-muted-foreground transition-all duration-200',
|
|
isExpanded
|
|
? 'rotate-90 opacity-100'
|
|
: 'opacity-0 group-hover:opacity-100',
|
|
)}
|
|
/>
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
{isExpanded && (
|
|
<tr>
|
|
<td colSpan={6} className="border-b border-border p-0">
|
|
<RowFoldout>
|
|
<div className="px-1 pb-6 pt-1 sm:pl-9 sm:pr-4">
|
|
{lines.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground py-2">{t('no_lines')}</p>
|
|
) : (
|
|
<table className="w-full border-collapse text-[12.5px]" aria-label={formatVoucher(entry)}>
|
|
<thead>
|
|
<tr>
|
|
<th className={cn(VTH_CLASS, 'w-[180px]')}>{t('account_column')}</th>
|
|
<th className={VTH_CLASS}>{t('description_column')}</th>
|
|
<th className={cn(VTH_CLASS, 'text-right')}>{t('debit')}</th>
|
|
<th className={cn(VTH_CLASS, 'text-right')}>{t('credit')}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{lines
|
|
.slice()
|
|
.sort((a, b) => a.sort_order - b.sort_order)
|
|
.map((line) => {
|
|
const accountName = getAccountDescription(line.account_number)?.name
|
|
const desc = line.line_description
|
|
const showDesc = desc
|
|
&& desc.toLowerCase() !== accountName?.toLowerCase()
|
|
&& desc.toLowerCase() !== entry.description?.toLowerCase()
|
|
const debit = Number(line.debit_amount) || 0
|
|
const credit = Number(line.credit_amount) || 0
|
|
const fx = line.currency && line.currency !== 'SEK' && line.amount_in_currency != null
|
|
? `${Number(line.amount_in_currency).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} ${line.currency}`
|
|
: null
|
|
return (
|
|
<tr key={line.id}>
|
|
<td className={cn(VTD_CLASS, 'whitespace-nowrap')}>
|
|
<AccountNumber number={line.account_number} showName />
|
|
</td>
|
|
<td className={cn(VTD_CLASS, 'text-muted-foreground')}>
|
|
{showDesc ? desc : ''}
|
|
</td>
|
|
<td className={cn(VTD_CLASS, 'text-right tabular-nums whitespace-nowrap')}>
|
|
{debit > 0 ? debit.toLocaleString('sv-SE', { minimumFractionDigits: 2 }) : ''}
|
|
{debit > 0 && fx && (
|
|
<span className="block text-xs text-muted-foreground tabular-nums">{fx}</span>
|
|
)}
|
|
</td>
|
|
<td className={cn(VTD_CLASS, 'text-right tabular-nums whitespace-nowrap')}>
|
|
{credit > 0 ? credit.toLocaleString('sv-SE', { minimumFractionDigits: 2 }) : ''}
|
|
{credit > 0 && fx && (
|
|
<span className="block text-xs text-muted-foreground tabular-nums">{fx}</span>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
<tr>
|
|
<td colSpan={2} className="py-[7px] pr-4 font-medium">{t('sum_label')}</td>
|
|
<td className="py-[7px] pr-4 text-right tabular-nums font-medium">
|
|
{lines.reduce((sum, l) => sum + (Number(l.debit_amount) || 0), 0).toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
|
|
</td>
|
|
<td className="py-[7px] pr-4 text-right tabular-nums font-medium">
|
|
{lines.reduce((sum, l) => sum + (Number(l.credit_amount) || 0), 0).toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
|
|
{entry.notes && (
|
|
<p className="mt-3 text-xs text-muted-foreground italic">
|
|
{entry.notes}
|
|
</p>
|
|
)}
|
|
|
|
<JournalEntryAttachments
|
|
journalEntryId={entry.id}
|
|
onCountChange={(c) => handleAttachmentCountChange(entry.id, c)}
|
|
/>
|
|
|
|
{entry.status === 'posted' && NEEDS_ATTACHMENT.has(entry.source_type) && (
|
|
<NoDocRequiredToggle
|
|
entryId={entry.id}
|
|
initialExempt={noDocRequired.has(entry.id)}
|
|
initialReason={noDocRequired.get(entry.id) ?? null}
|
|
canWrite={canWrite}
|
|
onChange={(exempted, reason) => {
|
|
setNoDocRequired((prev) => {
|
|
const next = new Map(prev)
|
|
if (exempted) next.set(entry.id, reason ?? null)
|
|
else next.delete(entry.id)
|
|
return next
|
|
})
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Quiet link actions (concept vact); posting keeps
|
|
its pill because it changes legal state. */}
|
|
<div className="mt-4 flex flex-wrap items-center gap-x-5 gap-y-2">
|
|
{entry.status === 'draft' && (
|
|
<Button
|
|
size="sm"
|
|
onClick={() => openCommitConfirm(entry)}
|
|
disabled={!canWrite || committingId === entry.id}
|
|
title={!canWrite ? t('read_only_tooltip') : undefined}
|
|
>
|
|
{!canWrite ? <Lock className="mr-2 h-4 w-4" /> : committingId === entry.id && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
{t('post')}
|
|
</Button>
|
|
)}
|
|
<Link href={`/bookkeeping/${entry.id}`} className={QUIET_LINK_CLASS}>
|
|
{t('show_details')}
|
|
</Link>
|
|
{entry.status === 'posted' && entry.source_type !== 'storno' && entry.source_type !== 'correction' && (
|
|
<button type="button" className={QUIET_LINK_CLASS} onClick={() => setCorrectionEntry(entry)}>
|
|
{t('create_correction')}
|
|
</button>
|
|
)}
|
|
{canWrite && entry.status === 'posted' && entry.source_type !== 'storno' && entry.source_type !== 'correction' && (
|
|
<button type="button" className={QUIET_LINK_CLASS} onClick={() => setReverseEntryTarget(entry)}>
|
|
{t('reverse_action')}
|
|
</button>
|
|
)}
|
|
{canWrite && (
|
|
<button type="button" className={QUIET_LINK_CLASS} onClick={() => router.push(`/bookkeeping?copy_from=${entry.id}`)}>
|
|
{t('copy')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</RowFoldout>
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</Fragment>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Filter-scoped bulk "Inget underlag krävs" confirmation */}
|
|
<Dialog
|
|
open={bulkOpen}
|
|
onOpenChange={(o) => {
|
|
if (bulkSubmitting) return
|
|
setBulkOpen(o)
|
|
if (!o) {
|
|
setBulkCount(null)
|
|
setBulkReason('')
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>{t('bulk_mark_title')}</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-3 text-sm">
|
|
{bulkCount === null ? (
|
|
<div className="flex items-center gap-2 text-muted-foreground">
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
{t('bulk_mark_counting')}
|
|
</div>
|
|
) : bulkCount === 0 ? (
|
|
<p className="text-muted-foreground">{t('bulk_mark_none')}</p>
|
|
) : (
|
|
<>
|
|
<p>{t('bulk_mark_body', { count: bulkCount })}</p>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs text-muted-foreground">{t('no_doc_required_reason_add')}</Label>
|
|
<Input
|
|
value={bulkReason}
|
|
onChange={(e) => setBulkReason(e.target.value)}
|
|
placeholder={t('no_doc_required_reason_placeholder')}
|
|
list="bulk-no-doc-suggestions"
|
|
maxLength={200}
|
|
className="h-8 text-xs"
|
|
disabled={bulkSubmitting}
|
|
/>
|
|
<datalist id="bulk-no-doc-suggestions">
|
|
<option value={t('no_doc_required_suggestion_bank_fee')} />
|
|
<option value={t('no_doc_required_suggestion_interest')} />
|
|
<option value={t('no_doc_required_suggestion_internal_transfer')} />
|
|
<option value={t('no_doc_required_suggestion_tax_payment')} />
|
|
<option value={t('no_doc_required_suggestion_salary')} />
|
|
</datalist>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" size="sm" onClick={() => setBulkOpen(false)} disabled={bulkSubmitting}>
|
|
{t('bulk_cancel')}
|
|
</Button>
|
|
<Button size="sm" onClick={handleBulkConfirm} disabled={bulkSubmitting || !bulkCount}>
|
|
{bulkSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
{t('bulk_mark_confirm', { count: bulkCount ?? 0 })}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Correction dialog */}
|
|
{correctionEntry && (
|
|
<CorrectionEntryDialog
|
|
entry={correctionEntry}
|
|
open={!!correctionEntry}
|
|
onOpenChange={(open) => { if (!open) setCorrectionEntry(null) }}
|
|
onCorrected={() => { setCorrectionEntry(null); fetchEntries() }}
|
|
/>
|
|
)}
|
|
|
|
{/* Confirm-before-posting for drafts (convention 10): describes the
|
|
outcome ("Bokförs som A-218 ...") before the commit runs. */}
|
|
{commitTarget && (
|
|
<ConfirmDialog
|
|
open={!!commitTarget}
|
|
onOpenChange={(open) => {
|
|
if (!open && committingId === null) setCommitTarget(null)
|
|
}}
|
|
title={t('confirm_post_title')}
|
|
description={
|
|
commitVoucherPreview
|
|
? t('confirm_post_description', {
|
|
voucher: commitVoucherPreview,
|
|
description: commitTarget.description || '',
|
|
amount: formatCurrency(
|
|
((commitTarget.lines || []) as JournalEntryLine[]).reduce(
|
|
(sum, l) => sum + (Number(l.debit_amount) || 0),
|
|
0,
|
|
),
|
|
),
|
|
})
|
|
: t('confirm_post_description_generic', {
|
|
description: commitTarget.description || '',
|
|
})
|
|
}
|
|
confirmLabel={t('post')}
|
|
onConfirm={async () => {
|
|
await handleCommit(commitTarget.id)
|
|
setCommitTarget(null)
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Reverse (storno) confirmation dialog */}
|
|
{reverseEntryTarget && (
|
|
<ConfirmationDialog
|
|
open={!!reverseEntryTarget}
|
|
onOpenChange={(open) => { if (!open && !isReversing) setReverseEntryTarget(null) }}
|
|
onConfirm={handleReverse}
|
|
isSubmitting={isReversing}
|
|
title={t('reverse_confirm_title')}
|
|
warningText={t('reverse_warning')}
|
|
confirmLabel={t('reverse_confirm_label')}
|
|
>
|
|
<div className="flex items-start gap-3 rounded-lg border bg-muted/50 p-4">
|
|
<RotateCcw className="h-5 w-5 text-muted-foreground mt-0.5 shrink-0" />
|
|
<div className="text-sm">
|
|
<p className="font-medium mb-1">{t('reverse_dialog_heading', { voucher: formatVoucher(reverseEntryTarget) })}</p>
|
|
<p className="text-muted-foreground">{t('reverse_dialog_body')}</p>
|
|
</div>
|
|
</div>
|
|
</ConfirmationDialog>
|
|
)}
|
|
|
|
{/* Attachment preview sheet */}
|
|
<AttachmentPreviewSheet
|
|
entryId={previewEntryId}
|
|
open={previewEntryId !== null}
|
|
onOpenChange={(open) => { if (!open) setPreviewEntryId(null) }}
|
|
/>
|
|
|
|
{/* Pagination + page-size selector. Shown when the result set spans more
|
|
than one page at the default size, OR when a non-default page size
|
|
('all' included) is active, so a user who narrowed the list below the
|
|
default can always switch the size back. Hidden for an empty result. */}
|
|
{count > 0 && (count > PAGE_SIZE_OPTIONS[0] || pageSizeChoice !== '20') && (
|
|
<div className="flex flex-col gap-3 pt-2 sm:flex-row sm:items-center sm:justify-between">
|
|
{/* Page size + result range */}
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<Label htmlFor="journal-page-size" className="text-xs font-normal shrink-0">
|
|
{t('page_size_label')}
|
|
</Label>
|
|
<Select value={pageSizeChoice} onValueChange={(v) => handlePageSizeChange(v as PageSizeChoice)}>
|
|
<SelectTrigger id="journal-page-size" className="h-8 w-[88px] text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{PAGE_SIZE_OPTIONS.map((n) => (
|
|
<SelectItem key={n} value={String(n)} className="text-xs tabular-nums">
|
|
{n}
|
|
</SelectItem>
|
|
))}
|
|
<SelectItem value="all" className="text-xs">{t('page_size_all')}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<span className="tabular-nums whitespace-nowrap">
|
|
{showingAll
|
|
? t('showing_all', { total: count })
|
|
: t('showing_range', {
|
|
from: count === 0 ? 0 : page * pageSize + 1,
|
|
to: Math.min((page + 1) * pageSize, count),
|
|
total: count,
|
|
})}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Page navigation: hidden when showing all or when everything fits on one page */}
|
|
{!showingAll && count > pageSize && (
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
className="h-8 w-8"
|
|
disabled={page === 0}
|
|
onClick={() => setPage(0)}
|
|
aria-label={t('first_page')}
|
|
title={t('first_page')}
|
|
>
|
|
<ChevronsLeft className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
className="h-8 w-8"
|
|
disabled={page === 0}
|
|
onClick={() => setPage(page - 1)}
|
|
aria-label={t('previous')}
|
|
title={t('previous')}
|
|
>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
</Button>
|
|
<span className="px-2 text-xs text-muted-foreground tabular-nums self-center whitespace-nowrap">
|
|
{t('page_of', { page: page + 1, total: Math.ceil(count / pageSize) })}
|
|
</span>
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
className="h-8 w-8"
|
|
disabled={(page + 1) * pageSize >= count}
|
|
onClick={() => setPage(page + 1)}
|
|
aria-label={t('next')}
|
|
title={t('next')}
|
|
>
|
|
<ChevronRight className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
className="h-8 w-8"
|
|
disabled={(page + 1) * pageSize >= count}
|
|
onClick={() => setPage(Math.ceil(count / pageSize) - 1)}
|
|
aria-label={t('last_page')}
|
|
title={t('last_page')}
|
|
>
|
|
<ChevronsRight className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|