diff --git a/DECISIONS.md b/DECISIONS.md index 6e033539..558749b5 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -861,6 +861,12 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-10] Validex round 3 falsified the round-2 reading: rule 237 (creditor PstlAdr mandatory) fires on the BGNR-to-BGNR path too, so rule 020's "required unless DbtrAcct is BGNR" only governs the Ctry element, not the address as a whole. Creditor PstlAdr is now emitted on every payment, TwnNm from the payee_city snapshot when known, and the preview warns (payee_city_missing) when the supplier register lacks a city, since 237 + 222 together make a town effectively mandatory at Swedbank and a Ctry-only address is rejected today, not from November. [2026-08-10] Tax table percent rows (>80 000 kr/month) drop ore via Math.floor: whole-krona rule (oretal bortfaller) per SFF 2011:1261 22 kap. 1 § as applied by Skatteverket's tabellavdrag guidance (the statute governs stated amounts; Skatteverket's tables and guidance apply the same truncation to computed skatteavdrag); an API response missing either table section (30B or 30%) is treated as API failure so the bundled fallback serves complete data instead of clamping. Incomplete bracket data (gaps, failed pagination pages, malformed kolumn values) fails loudly rather than withholding 0. [2026-08-10] Staging DB reconcile (metjnjrhvujscngnpzdv): the tracker had skipped everything from 20260721101500 to 2026-08-10 (105 local-only versions) while 25 rows existed only remotely. Renamed 10 remote rows to their repo versions (same name, MCP apply-time version drift: sandbox-cleanup consolidation, shopify, tax-depreciation, JEL index), deleted 7 superseded sandbox-iteration rows with no local file, and left 8 rows from unmerged branches (white-label brands/teams, vacation columns, agent-atom product tier) untouched since their content is deliberately live for the byra rigs. Older seed_agent_atom_bodies files register version-only: each seed is a full idempotent upsert with a version guard, so only the newest seed's content needs to run. +[2026-08-11] Verifikat header sort (belopp): PostgREST computed column total_amount(journal_entries) + the direct-query path (RPC bypass, same strict-period trade-off voucher sort already documents) instead of extending list_fiscal_period_entries_with_related: one 15-line SQL function versus a new RPC signature + regenerating every caller, and "Alla rakenskapsaren" needs the direct path anyway. Header toggle copies the invoices SortableHeader semantics (first click asc, second flips) over per-column natural direction: cross-page consistency beats the marginally better first click. +[2026-08-11] Verifikat header sort is a tri-state cycle (asc -> desc -> default date_desc) after Emil's first click-test: with a persisted sort there was no way back to the resting order from the header. Deliberate divergence from the invoices header (two-state), which starts unsorted and has no default to return to. Multi-column stacked sorting declined: single active sort is the intended model; the second click replacing the first is standard table behavior, not a bug. (Superseded later the same day: shift-click stacked sorting shipped, see the stacked verifikat sorting entry below; the plain-click tri-state stays.) +[2026-08-11] HTML mail invoices (invoice inbox): body-only and .html mails become text/html documents stored in the received form instead of a server-side PDF conversion; BFL wants the original form preserved, PDF rendering would need a headless-chromium dependency (declined), and the browser print dialog covers save-as-PDF. Rendering is fully sandboxed (iframe sandbox in the workspace, CSP sandbox header on /api/documents/:id/inline) because mail HTML is attacker-controlled; a new-tab/open-raw affordance was deliberately NOT added. text/html is accepted only via the email pipeline (EMAIL_ALLOWED_MIME_TYPES), not manual upload. +[2026-08-11] Stacked verifikat sorting: shift-click appends a key (asc -> desc -> remove), plain click stays single-key tri-state, capped at 3 keys. Wire format sort_by=token,token keeps single tokens valid so old clients and previously persisted values need no migration. Voucher tiebreak follows the LAST key's direction to generalize the #972 same-direction rule; RPC path stays reserved for the single-date-key shape it can express. +[2026-08-11] Transactions "Alla" tab source filter: un-gated the shared toolbar picker for both modes and removed TransactionHistoryList internal coarse picker instead of extending it: design convention 8 (one context picker per page); history filters client-side over the paged window, so "Ladda fler" pages unfiltered dates (same behavior as search). [2026-08-11] suggest-booking derives the proposed kontering on demand rather than storing it on the inbox row or computing it in the receipt hunt: a stored proposal goes stale against a corrected amount, a re-matched transaction or a template the company taught itself since, and the nightly hunt is already at its 300 s ceiling for a proposal most rows never open. It composes the existing evaluateMappingRules -> buildTransactionEntryLines chain rather than a second one, so the shown lines cannot drift from the posted lines. It withholds the proposal entirely on a foreign-currency row that matched via the mapping_rules branch: mapping-engine.ts buildResult computes VAT from the transaction's own currency while every other line is SEK (its own NOTE tracks this), which understates ingaende moms by the exchange rate and still balances, so nothing downstream catches it. Guarding the surface was chosen over fixing buildResult in this PR because that changes posted VAT amounts across every caller; the counterparty and static-template paths already convert correctly and are not withheld. [2026-08-11] Agent skills for the API ship as generated artifacts, not authored docs: skills/accounted-api/ is CI-checked output (apiskill:check) of scripts/api-skill/generate.ts, rendered from the same lib/api/v1 registry that serves the API and its OpenAPI spec, so the installable skill cannot drift from the server. Edit scripts/api-skill/overlays/ or the registry, never the output. The per-operation renderer is the portable tool inside skills/openapi-to-skill/ (the generic spec-to-skill generator): our own skill dogfoods it. Skills live in top-level skills/ because that is the directory `npx skills add erp-mafia/accounted` scans; the OpenAPI generator was extended to emit requestBody + path parameters (previously response-only) rather than teaching the skill generator to read Zod directly, so every spec consumer benefits, not just the skill. [2026-08-11] Gmail search fans out with a bounded pool (5 per connection) instead of Promise.all over every message id: Gmail enforces a per-user CONCURRENCY ceiling, not just a daily quota, and answers 429 "Too many concurrent requests for user" well below this app's volume. A real two-connection run returned mails=25 documents=0 purely from 429s. The catch in searchOne turned every refusal into an empty result, which is indistinguishable from an empty mailbox, and the manual hunt loop stops on fetched===0 as its "nothing left to find" signal, so the user was told their receipts do not exist by a search that never ran. searchFailureCount() now separates "could not look" from "nothing there", the run route returns it, and the loop treats a pass with failures as failed rather than finished. +[2026-08-11] Credit-note deduction fields (deduction_total, per-item deduction_amount) stay POSITIVE magnitudes, unlike every other amount on a credit note: both columns carry CHECK (>= 0) in the DB, and negating them made every ROT/RUT credit fail at insert (prod support case 2026-08-11). Verified inert: the reversing verifikat recomputes the ROT/RUT split from quantity/unit_price (generateRotRutLines), the PDF hides the deduction section for credit notes, getAmountToPay skips deductions when credited_invoice_id is set, and ROT payout candidates require status='paid', which invoices_credit_note_not_paid makes impossible for credit notes. Any future reader summing these fields across invoice + credit note must special-case credit notes. diff --git a/app/(dashboard)/import/page.tsx b/app/(dashboard)/import/page.tsx index bff1476a..5a512b95 100644 --- a/app/(dashboard)/import/page.tsx +++ b/app/(dashboard)/import/page.tsx @@ -51,6 +51,7 @@ import type { import type { ImportExecuteOptions } from '@/components/import/ImportReviewStep' import { applyMappingOverride } from '@/lib/import/account-mapper' +import { decodeFileContent } from '@/lib/import/shared/encoding' import type { BankFileParseResult, BankFileFormatId, GenericCSVColumnMapping } from '@/lib/import/bank-file/types' import type { IngestResult } from '@/lib/transactions/ingest' import type { @@ -220,8 +221,11 @@ function BankFileImportWizard() { setFileHash(data.data.file_hash) setFilename(data.data.filename) - // Read raw file content for CSV preview - const text = await file.text() + // Read raw file content for CSV preview. Decode from bytes, not + // file.text(): that is UTF-8-only and turns Windows-1252 åäö into + // U+FFFD (Handelsbanken exports Windows-1252), corrupting both the + // column-mapping preview and the re-parse on confirm. + const text = decodeFileContent(await file.arrayBuffer()) setRawFileContent(text) const txCount = data.data.parse_result.transactions.length diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index 5272921c..06afcf4b 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -37,6 +37,7 @@ import { isLibraryTemplateId } from '@/lib/bookkeeping/template-library' import type { TransactionWithInvoice, ViewMode, + SourceFilter, CategorizeHandler, } from '@/components/transactions/transaction-types' import type { @@ -102,12 +103,6 @@ const TemplatePicker = dynamic(() => import('@/components/transactions/TemplateP type InvoiceWithCustomer = Invoice & { customer?: Customer } type SupplierInvoiceWithSupplier = SupplierInvoice & { supplier?: Supplier } -// Source filter for the merged inbox (concept scene 10 account chooser): -// everything, one cash account ('acct:'), bank rows not yet tied to a -// registered cash account ('bank:other'), all bank rows ('bank': the fallback -// split when no cash accounts are registered), or the skattekonto side. -type SourceFilter = 'all' | 'bank' | 'bank:other' | 'skatteverket' | `acct:${string}` - const SOURCE_FILTER_STORAGE_KEY = 'Accounted:transaction-source-filter:v1' // Journal-entry ids get interpolated into the supplier-invoice .or() filter @@ -525,9 +520,14 @@ export default function TransactionsPage() { return sekBalances.reduce((sum, a) => sum + (a.balance ?? 0), 0) }, [cashAccounts]) + // The picker is shared by both view modes (convention 8), so 'bank:other' + // must surface when EITHER dataset holds unassigned rows: history can hold + // older null-account rows after every pending row got assigned. const hasUnassignedBankRows = useMemo( - () => uncategorizedTransactions.some((tx) => tx.cash_account_id == null), - [uncategorizedTransactions], + () => + uncategorizedTransactions.some((tx) => tx.cash_account_id == null) || + historyTransactions.some((tx) => tx.cash_account_id == null), + [uncategorizedTransactions, historyTransactions], ) const sourceItems = useMemo(() => { @@ -2553,10 +2553,11 @@ export default function TransactionsPage() { className="h-9 pl-10" /> - {/* Account chooser (convention 8): the one context chip, far right. - Per-cash-account rows with balances (concept scene 10); hidden - only when there is nothing beyond "Alla källor" to choose. */} - {mode === 'inbox' && sourceItems.length > 1 && ( + {/* Account chooser (convention 8): the one context chip, far right, + shared by both view modes. Per-cash-account rows with balances + (concept scene 10); hidden only when there is nothing beyond + "Alla källor" to choose. */} + {sourceItems.length > 1 && (
({ createClient: () => Promise.resolve(mockSupabase), })) @@ -156,6 +156,130 @@ describe('GET /api/bookkeeping/journal-entries', () => { expect(mockSupabase.rpc).not.toHaveBeenCalled() }) + it('orders by the total_amount computed column on amount sort, bypassing the RPC', async () => { + enqueue({ data: [], error: null, count: 0 }) + + const request = createMockRequest('/api/bookkeeping/journal-entries', { + searchParams: { period_id: 'period-1', sort_by: 'total_desc' }, + }) + const response = await GET(request) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(200) + // Amount sort orders by a PostgREST computed column (migration + // 20260811100000) that the include_related RPC can't express, so the + // route must fall through to the direct query (strict period view), + // exactly like voucher sort. + expect(mockSupabase.from).toHaveBeenCalledWith('journal_entries') + expect(mockSupabase.rpc).not.toHaveBeenCalled() + expect(findCalls('journal_entries', 'order')[0]).toEqual([ + 'total_amount', + { ascending: false }, + ]) + }) + + it('orders by description on description sort, bypassing the RPC', async () => { + enqueue({ data: [], error: null, count: 0 }) + + const request = createMockRequest('/api/bookkeeping/journal-entries', { + searchParams: { period_id: 'period-1', sort_by: 'description_asc' }, + }) + const response = await GET(request) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(200) + expect(mockSupabase.rpc).not.toHaveBeenCalled() + expect(findCalls('journal_entries', 'order')[0]).toEqual([ + 'description', + { ascending: true }, + ]) + }) + + it('chains stacked sort keys in priority order and bypasses the RPC', async () => { + enqueue({ data: [], error: null, count: 0 }) + + const request = createMockRequest('/api/bookkeeping/journal-entries', { + searchParams: { period_id: 'period-1', sort_by: 'total_desc,description_asc' }, + }) + const response = await GET(request) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(200) + expect(mockSupabase.rpc).not.toHaveBeenCalled() + // Priority order preserved, then the voucher tiebreak in the LAST key's + // direction (ascending here), then the globally unique id tiebreak: + // series+number repeat across fiscal years, so equal keys need a total + // order for stable pagination. + expect(findCalls('journal_entries', 'order')).toEqual([ + ['total_amount', { ascending: false }], + ['description', { ascending: true }], + ['voucher_series', { ascending: true }], + ['voucher_number', { ascending: true }], + ['id', { ascending: true }], + ]) + }) + + it('dedupes repeated sort columns and caps the stack at three keys', async () => { + enqueue({ data: [], error: null, count: 0 }) + + const request = createMockRequest('/api/bookkeeping/journal-entries', { + searchParams: { + include_related: 'false', + sort_by: 'date_desc,date_asc,nonsense,voucher_desc,total_asc,description_asc', + }, + }) + const response = await GET(request) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(200) + // date deduped (first token wins), the unknown token is ignored, and the + // stack caps at three keys (date, voucher, total): description never + // makes it in. Voucher is in the stack, so no series+number tiebreak is + // appended; the id tiebreak always is (duplicate series+number across + // fiscal years on the all-years scope). + expect(findCalls('journal_entries', 'order')).toEqual([ + ['entry_date', { ascending: false }], + ['voucher_series', { ascending: false }], + ['voucher_number', { ascending: false }], + ['total_amount', { ascending: true }], + ['id', { ascending: true }], + ]) + }) + + it('appends the id tiebreak on the all-years scope where voucher identifiers repeat', async () => { + enqueue({ data: [], error: null, count: 0 }) + + // No period_id: the "Alla räkenskapsår" scope, where A-1 exists once per + // fiscal year. Without a globally unique final key, rows with equal + // (entry_date, series, number) can swap between page requests and be + // duplicated or dropped at page boundaries. + const request = createMockRequest('/api/bookkeeping/journal-entries', { + searchParams: { include_related: 'false', sort_by: 'date_desc' }, + }) + const response = await GET(request) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(200) + const orderCalls = findCalls('journal_entries', 'order') + expect(orderCalls[orderCalls.length - 1]).toEqual(['id', { ascending: false }]) + }) + + it('still serves a single date sort key through the include_related RPC', async () => { + enqueue({ data: [], error: null }) + + const request = createMockRequest('/api/bookkeeping/journal-entries', { + searchParams: { period_id: 'period-1', sort_by: 'date_asc' }, + }) + const response = await GET(request) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(200) + expect(mockSupabase.rpc).toHaveBeenCalledWith( + 'list_fiscal_period_entries_with_related', + expect.objectContaining({ p_sort_date: 'asc' }), + ) + }) + it('accepts a large limit (the "Alla" page size) and a negative offset without erroring', async () => { enqueue({ data: [], error: null, count: 0 }) diff --git a/app/api/bookkeeping/journal-entries/__tests__/total-amount.pg.test.ts b/app/api/bookkeeping/journal-entries/__tests__/total-amount.pg.test.ts new file mode 100644 index 00000000..98efd96d --- /dev/null +++ b/app/api/bookkeeping/journal-entries/__tests__/total-amount.pg.test.ts @@ -0,0 +1,122 @@ +import { randomUUID } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { getPool } from '@/tests/pg/setup' +import { seedCompany } from '@/tests/pg/fixtures' + +// Covers the total_amount(journal_entries) computed column (migration +// 20260811100000): the verifikat list's amount sort orders by it through +// PostgREST, so the function must return the debit-side sum (= credit side on +// every balanced entry) and 0 for an entry with no lines yet. +describe('total_amount computed column', () => { + // Header and balanced lines in ONE transaction: check_balance_on_posted_insert + // is deferred to commit, so a posted header committed alone is rejected. + async function insertEntry(p: { + userId: string + companyId: string + fiscalPeriodId: string + voucherNumber: number + description: string + lines: Array<{ account: string; debit: number; credit: number }> + status?: 'draft' | 'posted' + }): Promise { + const id = randomUUID() + const client = await getPool().connect() + try { + await client.query('BEGIN') + await client.query( + `INSERT INTO public.journal_entries + (id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series, + entry_date, description, source_type, status) + VALUES ($1,$2,$3,$4,$5,'A','2026-06-01',$6,'manual',$7)`, + [ + id, + p.userId, + p.companyId, + p.fiscalPeriodId, + p.voucherNumber, + p.description, + p.status ?? 'posted', + ], + ) + for (const line of p.lines) { + await client.query( + `INSERT INTO public.journal_entry_lines + (journal_entry_id, account_number, debit_amount, credit_amount) + VALUES ($1, $2, $3, $4)`, + [id, line.account, line.debit, line.credit], + ) + } + await client.query('COMMIT') + } catch (err) { + await client.query('ROLLBACK').catch(() => {}) + throw err + } finally { + client.release() + } + return id + } + + it('returns the debit-side sum, including öre amounts and multi-line entries', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + + const single = await insertEntry({ + userId, companyId, fiscalPeriodId, voucherNumber: 1, description: 'Single line pair', + lines: [ + { account: '1930', debit: 250.5, credit: 0 }, + { account: '3001', debit: 0, credit: 250.5 }, + ], + }) + // Multi-line: total = sum of the debit side (100 + 200), not the line count. + const multi = await insertEntry({ + userId, companyId, fiscalPeriodId, voucherNumber: 2, description: 'Multi line', + lines: [ + { account: '1930', debit: 100, credit: 0 }, + { account: '5410', debit: 200, credit: 0 }, + { account: '2440', debit: 0, credit: 300 }, + ], + }) + + const { rows } = await getPool().query<{ id: string; total: string }>( + `SELECT je.id, public.total_amount(je.*)::text AS total + FROM public.journal_entries je + WHERE je.id = ANY($1::uuid[])`, + [[single, multi]], + ) + const byId = new Map(rows.map((r) => [r.id, Number(r.total)])) + expect(byId.get(single)).toBe(250.5) + expect(byId.get(multi)).toBe(300) + }) + + it('returns 0 for an entry without lines and orders a query correctly', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + + const mid = await insertEntry({ + userId, companyId, fiscalPeriodId, voucherNumber: 1, description: 'Mid', + lines: [ + { account: '1930', debit: 200, credit: 0 }, + { account: '3001', debit: 0, credit: 200 }, + ], + }) + const big = await insertEntry({ + userId, companyId, fiscalPeriodId, voucherNumber: 2, description: 'Big', + lines: [ + { account: '1930', debit: 900, credit: 0 }, + { account: '3001', debit: 0, credit: 900 }, + ], + }) + // A draft with no lines yet must sort as 0, not error or drop out. + const empty = await insertEntry({ + userId, companyId, fiscalPeriodId, voucherNumber: 0, description: 'Empty draft', + status: 'draft', lines: [], + }) + + const { rows } = await getPool().query<{ id: string }>( + `SELECT je.id + FROM public.journal_entries je + WHERE je.company_id = $1 + ORDER BY public.total_amount(je.*) DESC, je.voucher_number ASC`, + [companyId], + ) + expect(rows.map((r) => r.id)).toEqual([big, mid, empty]) + }) +}) diff --git a/app/api/bookkeeping/journal-entries/route.ts b/app/api/bookkeeping/journal-entries/route.ts index 5855cfcf..aa96296e 100644 --- a/app/api/bookkeeping/journal-entries/route.ts +++ b/app/api/bookkeeping/journal-entries/route.ts @@ -47,30 +47,47 @@ export const GET = withRouteContext('bookkeeping.journal_entries.list', async (r // year's series (the BFL-compliant per-year view). It narrows the period, it // never widens it. const search = searchParams.get('search')?.trim() || null - // 'date_desc' (default) | 'date_asc' | 'voucher_asc' | 'voucher_desc' - // sort_by overrides sort_date when present. sort_date is kept for backwards - // compatibility with older clients. - const sortBy = searchParams.get('sort_by') - const isVoucherSort = sortBy === 'voucher_asc' || sortBy === 'voucher_desc' + // sort_by is a comma-separated priority list of `${column}_${direction}` + // tokens over date | voucher | total | description (single tokens, the old + // format, stay valid). Unknown tokens are ignored, repeated columns are + // deduped, and the list is capped at 3 keys to bound DB work. Amount keys + // order by the total_amount computed column (sum of debit lines, migration + // 20260811100000): PostgREST evaluates it per row, which the RPC path + // below cannot express. sort_by overrides sort_date when present; + // sort_date is kept for backwards compatibility with older clients. + const SORT_TOKEN_RE = /^(date|voucher|total|description)_(asc|desc)$/ + const MAX_SORT_KEYS = 3 + const sortKeys: { column: 'date' | 'voucher' | 'total' | 'description'; ascending: boolean }[] = [] + for (const token of (searchParams.get('sort_by') ?? '').split(',')) { + const m = SORT_TOKEN_RE.exec(token.trim()) + if (!m) continue + const column = m[1] as (typeof sortKeys)[number]['column'] + if (sortKeys.some((k) => k.column === column)) continue + sortKeys.push({ column, ascending: m[2] === 'asc' }) + if (sortKeys.length === MAX_SORT_KEYS) break + } + // A single date key (or no keys at all) is the only shape the RPC path can + // serve, via p_sort_date. Everything else falls through to the direct query. + const soloDateKey = sortKeys.length === 1 && sortKeys[0].column === 'date' ? sortKeys[0] : null // Default on: when a fiscal period is selected, include follow-up entries // booked in later periods whose source aggregate (invoice, supplier invoice) // is dated inside the selected period. Pass include_related=false to // restore strict fiscal_period_id filtering. const includeRelated = searchParams.get('include_related') !== 'false' - const dateAscending = sortDate === 'asc' || sortBy === 'date_asc' - const sortDateParam = sortBy === 'date_asc' || sortDate === 'asc' ? 'asc' : 'desc' + const dateAscending = sortDate === 'asc' + const sortDateParam = soloDateKey ? (soloDateKey.ascending ? 'asc' : 'desc') : sortDate === 'asc' ? 'asc' : 'desc' - // Voucher-sort path: include_related RPC doesn't support voucher ordering, - // so fall through to the direct query below. This means voucher sort is - // *strict by fiscal_period_id*: cross-period follow-up entries that the - // RPC normally surfaces under date sort are excluded under voucher sort. + // Non-date sorts (and stacked sorts): the include_related RPC only orders + // by date, so fall through to the direct query below. This means these + // sorts are *strict by fiscal_period_id*: cross-period follow-up entries + // that the RPC normally surfaces under date sort are excluded. // That's intentional: voucher numbers are series-scoped within a fiscal // year (BFL 5 kap 6-7 §§), so showing series A1, A2 … alongside entries // belonging to a different year's series would be misleading. The trade-off // is that the visible row count may differ between sort modes for the same // period; the strict count is the BFL-compliant view of that year. - if (periodId && includeRelated && !isVoucherSort && !search) { + if (periodId && includeRelated && (sortKeys.length === 0 || soloDateKey) && !search) { const { data, error } = await supabase.rpc('list_fiscal_period_entries_with_related', { p_company_id: companyId, p_period_id: periodId, @@ -108,22 +125,53 @@ export const GET = withRouteContext('bookkeeping.journal_entries.list', async (r .select('*, lines:journal_entry_lines(*)', { count: 'exact' }) .eq('company_id', companyId) - if (isVoucherSort) { - const voucherAscending = sortBy === 'voucher_asc' - query = query - .order('voucher_series', { ascending: voucherAscending }) - .order('voucher_number', { ascending: voucherAscending }) - } else if (sortDate === 'asc' || sortDate === 'desc' || sortBy === 'date_asc' || sortBy === 'date_desc') { - // Tiebreak same-date vouchers in the SAME direction as the date sort, and - // by series before number so the order matches the RPC path (#972). + if (sortKeys.length > 0) { + // Apply the priority list in order. total_amount is a computed column (a + // function on the row type), which PostgREST accepts in order=. + for (const key of sortKeys) { + switch (key.column) { + case 'voucher': + query = query + .order('voucher_series', { ascending: key.ascending }) + .order('voucher_number', { ascending: key.ascending }) + break + case 'date': + query = query.order('entry_date', { ascending: key.ascending }) + break + case 'total': + query = query.order('total_amount', { ascending: key.ascending }) + break + case 'description': + query = query.order('description', { ascending: key.ascending }) + break + } + } + // Stable pagination needs a total order: unless voucher is already a key, + // tiebreak by series+number in the LAST key's direction, so a plain date + // sort keeps same-date vouchers running the same way as the RPC (#972). + if (!sortKeys.some((k) => k.column === 'voucher')) { + const tiebreakAscending = sortKeys[sortKeys.length - 1].ascending + query = query + .order('voucher_series', { ascending: tiebreakAscending }) + .order('voucher_number', { ascending: tiebreakAscending }) + } + // Final id tiebreak: series+number repeat across fiscal years, so on an + // all-years scope equal sort keys could reshuffle between page requests + // and duplicate or drop rows at page boundaries. + query = query.order('id', { ascending: sortKeys[sortKeys.length - 1].ascending }) + } else if (sortDate === 'asc' || sortDate === 'desc') { + // Legacy sort_date param (older clients). Tiebreak same-date vouchers in + // the SAME direction as the date sort (#972). query = query .order('entry_date', { ascending: dateAscending }) .order('voucher_series', { ascending: dateAscending }) .order('voucher_number', { ascending: dateAscending }) + .order('id', { ascending: dateAscending }) } else { query = query .order('voucher_series', { ascending: true }) .order('voucher_number', { ascending: true }) + .order('id', { ascending: true }) } query = query.range(offset, offset + limit - 1) diff --git a/app/api/documents/[id]/inline/__tests__/route.test.ts b/app/api/documents/[id]/inline/__tests__/route.test.ts index 23834b99..45d10961 100644 --- a/app/api/documents/[id]/inline/__tests__/route.test.ts +++ b/app/api/documents/[id]/inline/__tests__/route.test.ts @@ -110,5 +110,31 @@ describe('GET /api/documents/[id]/inline', () => { expect(disposition).toContain('filename="kvitto f_rvaring.pdf"') expect(res.headers.get('Content-Type')).toBe('application/pdf') expect(res.headers.get('Cache-Control')).toBe('private, no-store') + // The mail-body CSP is HTML-only: it must not restrict PDF rendering. + expect(res.headers.get('Content-Security-Policy')).toBeNull() + }) + + it('serves HTML documents with a sandboxing CSP that blocks outbound requests', async () => { + enqueue({ + data: makeDoc({ file_name: 'faktura.html', mime_type: 'text/html' }), + error: null, + }) + downloadMock.mockResolvedValue({ + data: new Blob(['']), + error: null, + }) + + const res = await GET(makeReq(), createMockRouteParams({ id: 'doc-1' })) + + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toBe('text/html') + // sandbox alone neutralizes scripts but still loads remote resources: a + // tracking pixel in a mail body would notify the sender on preview. The + // source policy confines the document to inline styles and embedded + // data:/blob: images. + expect(res.headers.get('Content-Security-Policy')).toBe( + "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data: blob:", + ) + expect(res.headers.get('X-Content-Type-Options')).toBe('nosniff') }) }) diff --git a/app/api/documents/[id]/inline/route.ts b/app/api/documents/[id]/inline/route.ts index f2b1c422..c5484ae3 100644 --- a/app/api/documents/[id]/inline/route.ts +++ b/app/api/documents/[id]/inline/route.ts @@ -76,10 +76,12 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( ) } + const contentType = resolveContentType(doc.file_name, doc.mime_type) + return new NextResponse(blob, { status: 200, headers: { - 'Content-Type': resolveContentType(doc.file_name, doc.mime_type), + 'Content-Type': contentType, // RFC 5987 dual form: NFD filenames from macOS/iOS uploads contain // combining marks (> 0xFF), which undici Headers reject as non- // ByteString values; splicing the raw name here 500ed the route. @@ -90,6 +92,20 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( // content. Without nosniff a tampered file_name extension could // serve a stored document under an attacker-chosen MIME type. 'X-Content-Type-Options': 'nosniff', + // text/html documents are attacker-controlled mail bodies from the + // invoice inbox. Served inline on the app origin they would execute + // scripts with our origin's authority: CSP sandbox (no tokens) makes + // the rendered document opaque-origin and script-free wherever it is + // opened, iframe or direct tab. The source policy blocks outbound + // requests on top of that: sandbox alone still loads remote images, + // so a tracking pixel would notify the sender when the preview is + // opened. Inline styles and embedded data:/blob: images keep working. + ...(contentType === 'text/html' + ? { + 'Content-Security-Policy': + "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data: blob:", + } + : {}), }, }) }, diff --git a/app/api/invoices/__tests__/route.test.ts b/app/api/invoices/__tests__/route.test.ts index b424a83e..b69b9af6 100644 --- a/app/api/invoices/__tests__/route.test.ts +++ b/app/api/invoices/__tests__/route.test.ts @@ -8,7 +8,7 @@ import { } from '@/tests/helpers' import { eventBus } from '@/lib/events' -const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() +const { supabase: mockSupabase, enqueue, reset, findCall } = createQueuedMockSupabase() vi.mock('@/lib/supabase/server', () => ({ createClient: () => Promise.resolve(mockSupabase), })) @@ -550,6 +550,81 @@ describe('POST /api/invoices (create credit note)', () => { expect(mockSupabase.from).toHaveBeenCalledTimes(6) }) + // Regression: crediting a ROT/RUT invoice used to negate deduction_total and + // deduction_amount like the other amounts, which the DB refuses (both columns + // carry CHECK >= 0), so no deduction-carrying invoice could be credited. The + // stored deduction fields are positive magnitudes on credit notes too. + it('keeps deduction fields positive when crediting a ROT invoice', async () => { + const original = makeInvoice({ + id: VALID_UUID, + status: 'sent', + subtotal: 60000, + vat_amount: 15000, + total: 75000, + deduction_total: 22500, + items: [ + { + id: 'item-1', + invoice_id: VALID_UUID, + sort_order: 0, + description: 'Snickeri', + quantity: 30, + unit: 'tim', + unit_price: 2000, + line_total: 60000, + vat_rate: 25, + vat_amount: 15000, + deduction_type: 'rot', + deduction_amount: 22500, + created_at: '2026-08-01T00:00:00Z', + }, + ], + }) + const creditNote = makeInvoice({ + id: 'cn-rot', + credited_invoice_id: VALID_UUID, + status: 'draft', + }) + + // Fetch original invoice + enqueue({ data: original, error: null }) + // No existing credit-note draft + enqueue({ data: null, error: null }) + // Insert credit note + enqueue({ data: creditNote, error: null }) + // Insert credit note items + enqueue({ data: null, error: null }) + // Mark creation complete + enqueue({ data: null, error: null }) + // Fetch complete credit note + enqueue({ data: { ...creditNote, items: [] }, error: null }) + + const request = createMockRequest('/api/invoices', { + method: 'POST', + body: { credited_invoice_id: VALID_UUID }, + }) + const response = await POST(request) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(200) + const [invoiceInsert] = findCall('invoices', 'insert') ?? [] + expect(invoiceInsert).toMatchObject({ + total: -75000, + subtotal: -60000, + vat_amount: -15000, + deduction_total: 22500, + }) + const [itemsInsert] = findCall('invoice_items', 'insert') ?? [] + expect(itemsInsert).toMatchObject([ + { + line_total: -60000, + vat_amount: -15000, + deduction_type: 'rot', + deduction_amount: 22500, + }, + ]) + }) + it('returns an existing credit-note draft instead of creating a duplicate', async () => { const original = makeInvoice({ id: VALID_UUID, status: 'sent' }) const existing = makeInvoice({ diff --git a/app/api/invoices/route.ts b/app/api/invoices/route.ts index 85068cee..9c271ebd 100644 --- a/app/api/invoices/route.ts +++ b/app/api/invoices/route.ts @@ -381,8 +381,11 @@ async function createCreditNote( reverse_charge_text: originalInvoice.reverse_charge_text, your_reference: originalInvoice.your_reference, our_reference: originalInvoice.our_reference, + // Positive magnitude, unlike the negated amounts above: the DB has + // CHECK (deduction_total >= 0), and every reader either recomputes the + // ROT/RUT amount from the items or skips credit notes entirely. deduction_total: originalInvoice.deduction_total - ? -Math.abs(originalInvoice.deduction_total) + ? Math.abs(originalInvoice.deduction_total) : 0, deduction_personnummer_encrypted: originalInvoice.deduction_personnummer_encrypted ?? null, deduction_personnummer_last4: originalInvoice.deduction_personnummer_last4 ?? null, diff --git a/components/bookkeeping/JournalEntryList.tsx b/components/bookkeeping/JournalEntryList.tsx index d7ef51ae..814a5bea 100644 --- a/components/bookkeeping/JournalEntryList.tsx +++ b/components/bookkeeping/JournalEntryList.tsx @@ -1,6 +1,6 @@ 'use client' -import { Fragment, useState, useEffect, useCallback } from 'react' +import { Fragment, useState, useEffect, useCallback, useRef } from 'react' import Link from 'next/link' import { useRouter } from 'next/navigation' import { useTranslations } from 'next-intl' @@ -39,7 +39,7 @@ import { 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 { ArrowDown, ArrowUp, ArrowUpDown, 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' @@ -67,12 +67,111 @@ const NEEDS_ATTACHMENT = new Set([ 'import', ]) -type SortBy = 'date_desc' | 'date_asc' | 'voucher_asc' | 'voucher_desc' +// Column-header sorting (support feedback: "filtrera/sortera alla rubriker"). +// The sort order is a priority-ordered STACK of keys (max 3): the second key +// breaks ties in the first, and so on. Plain click sets a single key; +// shift-click stacks. +type SortColumn = 'voucher' | 'date' | 'description' | 'total' +type SortDirection = 'asc' | 'desc' +interface SortKey { + column: SortColumn + direction: SortDirection +} -// Per-company persistence of the sort dropdown. Mirrors the localStorage +// Per-company persistence of the sort order. Mirrors the localStorage // convention used by FiscalYearSelector ('Accounted:fiscal-year:'). +// The stored value is the serialized stack (see serializeSortStack). const SORT_STORAGE_KEY_PREFIX = 'Accounted:journal-sort:' -const SORT_VALUES = new Set(['date_desc', 'date_asc', 'voucher_asc', 'voucher_desc']) +const MAX_SORT_KEYS = 3 +const SORT_TOKEN_RE = /^(voucher|date|description|total)_(asc|desc)$/ + +// The list's resting order, and the exit of the header toggle cycle. +const DEFAULT_SORT_STACK: SortKey[] = [{ column: 'date', direction: 'desc' }] + +// 'total_desc,description_asc' <-> [{total desc}, {description asc}]. The +// single-token form is the pre-stack format, so persisted values from before +// stacking (and the dialog's single-choice select) parse with the same code. +// Any invalid token rejects the whole value: a corrupt stored string falls +// back to the default rather than silently sorting by half a stack. +function parseSortStack(raw: string | null): SortKey[] | null { + if (!raw) return null + const keys: SortKey[] = [] + for (const token of raw.split(',')) { + const m = SORT_TOKEN_RE.exec(token.trim()) + if (!m) return null + const column = m[1] as SortColumn + if (keys.some((k) => k.column === column)) continue + keys.push({ column, direction: m[2] as SortDirection }) + if (keys.length === MAX_SORT_KEYS) break + } + return keys.length > 0 ? keys : null +} + +const serializeSortStack = (stack: SortKey[]): string => + stack.map((k) => `${k.column}_${k.direction}`).join(',') + +const sortStacksEqual = (a: SortKey[], b: SortKey[]): boolean => + serializeSortStack(a) === serializeSortStack(b) + +// Same shape as the invoices list header (app/(dashboard)/invoices/page.tsx): +// click cycles the sort, inactive columns show a dimmed two-way arrow. Two +// deliberate differences: the plain-click cycle has a third step back to +// DEFAULT_SORT_STACK so a sort is always escapable from the header itself +// (the invoices list starts unsorted and has nothing to return to), and +// shift-click stacks the column as a secondary/tertiary key, shown with a +// priority number next to the arrow. +interface SortableHeaderProps { + label: string + sortLabel: string + column: SortColumn + stack: SortKey[] + onSort: (column: SortColumn, additive: boolean) => void + className?: string + align?: 'left' | 'right' +} + +function SortableHeader({ + label, + sortLabel, + column, + stack, + onSort, + className, + align = 'left', +}: SortableHeaderProps) { + const index = stack.findIndex((k) => k.column === column) + const active = index !== -1 + const direction = active ? stack[index].direction : null + const SortIcon = !active ? ArrowUpDown : direction === 'asc' ? ArrowUp : ArrowDown + + return ( + + + + ) +} // Compact row density (support feedback: "kompakt visning av verifikat"). // Persisted per company, mirroring the sort key convention. @@ -105,6 +204,11 @@ export default function JournalEntryList() { // dims them, so the list never collapses to a spinner and springs back to // full height under the pointer. Growing lists were causing real mis-clicks. const [hasLoaded, setHasLoaded] = useState(false) + // A failed list fetch must NEVER render as an empty ledger: the sort order + // is persisted per company, so a sort the backend rejects (e.g. a computed + // column missing on this environment) would otherwise masquerade as "all + // entries gone" on every reload, with no toolbar to escape through. + const [loadFailed, setLoadFailed] = useState(false) const [expandedId, setExpandedId] = useState(null) const [count, setCount] = useState(0) const [page, setPage] = useState(0) @@ -131,7 +235,7 @@ export default function JournalEntryList() { const [reverseEntryTarget, setReverseEntryTarget] = useState(null) const [isReversing, setIsReversing] = useState(false) const [previewEntryId, setPreviewEntryId] = useState(null) - const [sortBy, setSortBy] = useState('date_desc') + const [sortStack, setSortStack] = useState(DEFAULT_SORT_STACK) const [sortHydrated, setSortHydrated] = useState(false) const [periodId, setPeriodId] = useState(null) const [periodHydrated, setPeriodHydrated] = useState(false) @@ -196,7 +300,12 @@ export default function JournalEntryList() { } } - const fetchAttachmentCounts = useCallback(async (entryIds: string[]) => { + // isCurrent: the caller's request-generation guard (see fetchGenRef). The + // metadata writes land after their own awaits, so a stale list request's + // late completion must not overwrite counts/flags for the rows a newer + // request just rendered: wrong counts here flip the missing-underlag + // warning and bulk-exemption eligibility. + const fetchAttachmentCounts = useCallback(async (entryIds: string[], isCurrent: () => boolean = () => true) => { if (entryIds.length === 0) { setAttachmentCounts({}) setAttachmentCountsLoaded(true) @@ -226,15 +335,18 @@ export default function JournalEntryList() { return (data || {}) as Record }) ) + if (!isCurrent()) return setAttachmentCounts(Object.assign({}, ...results)) } catch { // Non-critical: silently ignore } finally { - setAttachmentCountsLoaded(true) + // A stale run must not flip the loaded flag either: the newer run set + // it false on entry and owns setting it true when ITS counts land. + if (isCurrent()) setAttachmentCountsLoaded(true) } }, []) - const fetchRattelseFlags = useCallback(async (entryIds: string[]) => { + const fetchRattelseFlags = useCallback(async (entryIds: string[], isCurrent: () => boolean = () => true) => { if (entryIds.length === 0) { setRattelseFlags(new Set()) return @@ -245,6 +357,7 @@ export default function JournalEntryList() { ) if (!res.ok) return const { data } = await res.json() + if (!isCurrent()) return setRattelseFlags(new Set((data || []) as string[])) } catch { // Non-critical: silently ignore @@ -276,8 +389,10 @@ export default function JournalEntryList() { // 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) + const stored = parseSortStack( + window.localStorage.getItem(SORT_STORAGE_KEY_PREFIX + (company?.id ?? 'default')), + ) + if (stored) setSortStack(stored) } setSortHydrated(true) }, [company?.id]) @@ -367,13 +482,20 @@ export default function JournalEntryList() { return () => clearTimeout(handle) }, [searchInput]) + // Sort/filter changes fire fetchEntries while an earlier request may still + // be in flight; only the newest request may write state, or a slow earlier + // response would overwrite the current sort's rows after they rendered. + const fetchGenRef = useRef(0) + async function fetchEntries() { + const gen = ++fetchGenRef.current + const isCurrent = () => fetchGenRef.current === gen 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, + sort_by: serializeSortStack(sortStack), }) if (listMode === 'drafts') { // Drafts get their own view spanning all years: they're work-in-progress @@ -389,33 +511,52 @@ export default function JournalEntryList() { } if (search) params.set('search', search) - const res = await fetch(`/api/bookkeeping/journal-entries?${params}`) - if (!res.ok) { + try { + const res = await fetch(`/api/bookkeeping/journal-entries?${params}`) + if (!isCurrent()) return + if (!res.ok) { + // Surface the failure: stale rows (if any) stay on screen, the empty + // case renders the error state below, and the toast covers refetches. + setLoadFailed(true) + toast({ title: t('load_failed_title'), variant: 'destructive' }) + setHasLoaded(true) + return + } + setLoadFailed(false) + const { data, count: total } = await res.json() + if (!isCurrent()) return + 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() + } + if (!isCurrent()) return setHasLoaded(true) - 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() + // Fetch attachment counts + rättelse markers for the loaded entries, + // carrying the generation guard so their late completions can't + // overwrite metadata a newer request just rendered. + const ids = loadedEntries.map((e: JournalEntry) => e.id) + fetchAttachmentCounts(ids, isCurrent) + fetchRattelseFlags(ids, isCurrent) + } catch { + // Network-level rejection (offline, aborted response body): same + // surfacing as a non-OK response, or the list stays dimmed forever. + if (!isCurrent()) return + setLoadFailed(true) + setHasLoaded(true) + toast({ title: t('load_failed_title'), variant: 'destructive' }) + } finally { + if (isCurrent()) setLoading(false) } - setHasLoaded(true) - 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 @@ -431,10 +572,14 @@ export default function JournalEntryList() { } } + // The stack serializes to a string for the dependency array: a stable + // primitive, so refetches fire on real sort changes, not array identity. + const sortParam = serializeSortStack(sortStack) + useEffect(() => { if (!sortHydrated || !periodHydrated || !pageSizeHydrated) return fetchEntries() - }, [periodId, page, pageSize, sortBy, dateFrom, dateTo, seriesFilter, search, listMode, collapseCorrections, sortHydrated, periodHydrated, pageSizeHydrated]) + }, [periodId, page, pageSize, sortParam, dateFrom, dateTo, seriesFilter, search, listMode, collapseCorrections, sortHydrated, periodHydrated, pageSizeHydrated]) const handleAttachmentCountChange = useCallback((entryId: string, count: number) => { setAttachmentCounts((prev) => ({ ...prev, [entryId]: count })) @@ -691,6 +836,53 @@ export default function JournalEntryList() { } } + // Apply a sort stack, whether it came from a column header or the dialog + // select. Resets to the first page and persists the choice per company. + const applySort = (next: SortKey[]) => { + setSortStack(next) + setPage(0) + if (typeof window !== 'undefined') { + window.localStorage.setItem( + SORT_STORAGE_KEY_PREFIX + (company?.id ?? 'default'), + serializeSortStack(next), + ) + } + } + + // Plain click: single-key tri-state, replacing any stack: ascending -> + // descending -> back to the default order. On the DATUM column descending + // IS the default, so that column degenerates to a plain asc/desc toggle + // instead of wasting the third click on a no-op. + // Shift-click stacks (max 3 keys): adds the column as the next priority + // (ascending), a second shift-click flips it, a third removes it again. + const handleHeaderSort = (column: SortColumn, additive: boolean) => { + if (additive) { + const index = sortStack.findIndex((k) => k.column === column) + if (index === -1) { + if (sortStack.length >= MAX_SORT_KEYS) return + applySort([...sortStack, { column, direction: 'asc' }]) + } else if (sortStack[index].direction === 'asc') { + applySort( + sortStack.map((k, i) => (i === index ? { column, direction: 'desc' as const } : k)), + ) + } else { + const next = sortStack.filter((_, i) => i !== index) + applySort(next.length > 0 ? next : DEFAULT_SORT_STACK) + } + return + } + const solo = sortStack.length === 1 && sortStack[0].column === column ? sortStack[0] : null + if (!solo) { + applySort([{ column, direction: 'asc' }]) + } else if (solo.direction === 'asc') { + applySort([{ column, direction: 'desc' }]) + } else if (sortStacksEqual(sortStack, DEFAULT_SORT_STACK)) { + applySort([{ column, direction: 'asc' }]) + } else { + applySort(DEFAULT_SORT_STACK) + } + } + const clearAllFilters = () => { setPeriodId(null) // Mirror the selector's "Alla räkenskapsår" write so the cleared scope @@ -728,7 +920,7 @@ export default function JournalEntryList() { // 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) { + if (!loading && entries.length === 0 && !loadFailed && !hasActiveFilters && listMode === 'committed' && draftCount === 0) { return ( + {/* The select models a SINGLE sort key: it shows the primary + key of the stack, and picking an option resets the whole + stack to that one key. Stacking lives on the headers. */} +

{t('sort_stack_hint')}

{/* Verifikationsserie */} @@ -989,6 +1185,22 @@ export default function JournalEntryList() { + ) : loadFailed && filteredEntries.length === 0 ? ( + // Failed load with nothing on screen: an explicit error state with a + // retry, never the "empty ledger" card. The toolbar above stays + // mounted so the sort/filter that caused the failure can be changed. + + } + title={t('load_failed_title')} + description={t('load_failed_description')} + action={ + + } + /> + ) : 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 @@ -1088,10 +1300,38 @@ export default function JournalEntryList() { - {t('th_voucher')} - {t('th_date')} - {t('th_description')} - {t('th_amount')} + + + + diff --git a/components/extensions/general/InvoiceInboxWorkspace.tsx b/components/extensions/general/InvoiceInboxWorkspace.tsx index ca1ef160..157be173 100644 --- a/components/extensions/general/InvoiceInboxWorkspace.tsx +++ b/components/extensions/general/InvoiceInboxWorkspace.tsx @@ -15,6 +15,12 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' import { useToast } from '@/components/ui/use-toast' import { Inbox, @@ -38,6 +44,7 @@ import { ChevronDown, Sparkles, MessageCircle, + Maximize2, } from 'lucide-react' import Link from 'next/link' import { cn, formatCurrency, formatDate } from '@/lib/utils' @@ -600,13 +607,18 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) { const { data } = await res.json() if (docRequestRef.current !== itemId) return const url: string | null = data?.download_url ?? null + // HTML mail underlag renders via the same-origin inline proxy: it + // serves text/html with a CSP sandbox header and guaranteed inline + // disposition. Other types keep the signed storage URL. + const effectiveUrl = + data?.mime_type === 'text/html' ? `/api/documents/${documentId}/inline` : url if (!url) { // The document row exists but no signed URL came back: still a load // failure, not an absent underlag. setDocState('error') return } - setDocUrl(url) + setDocUrl(effectiveUrl) setDocMime(data?.mime_type ?? null) setDocState('ready') } catch { @@ -1669,6 +1681,19 @@ export function DocumentPreview({ className="block max-h-[calc(100vh-9rem)] max-w-full w-auto h-auto object-contain" /> + ) : docMime === 'text/html' ? ( + // HTML mail underlag: arbitrary sender-controlled markup. sandbox + // with no tokens = opaque origin, no scripts, no forms, no popups. + // bg-white because mail HTML assumes a white canvas and would render + // transparent (unreadable in dark mode) otherwise. +
+