From 64991eb3c9e3b88f02bece2206d03133722e2027 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Mon, 8 Jun 2026 15:25:27 +0200 Subject: [PATCH] Add/transaction deletion (#695) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(salary): add remove-employee button to draft salary runs The DELETE /api/salary/runs/{id}/employees/{employeeId} endpoint already existed (draft-only, cascades to the employee's line items) but had no UI trigger, so a mistakenly added employee could only be cleared by deleting the whole draft. Add a trash-icon action column to the "Anställda" table, gated on draft status + write permission to match the endpoint's guard, with a confirm prompt and success/error toast. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(settings): prevent horizontal overflow on mobile The company settings invite form was a non-wrapping fixed-width flex row that overflowed narrow viewports, forcing the full-screen settings modal to scroll on the x-axis. Stack the form vertically on mobile (sm:flex-row at and above the sm breakpoint) and add the missing min-w-0 guard to the modal content pane. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(bookkeeping): move journal entry filters into a filter dialog The ledger toolbar showed every filter inline (fiscal year, sort, series, date range, missing-documents toggle), which felt cluttered. Keep only the search field visible and move the rest into a "Filtrera" dialog with an active-filter count badge. - JournalEntryList now owns the fiscal-year scope, restored from the same localStorage key FiscalYearSelector writes, so the page no longer renders the selector separately. - Filters apply live and the dialog stays open; "Rensa alla filter" clears them. - Export STORAGE_KEY_PREFIX / ALL_YEARS_VALUE from FiscalYearSelector so the list reuses the persisted selection without duplicating the key. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(transactions): implement imported transaction guard for deletion - Added a guard to prevent deletion of transactions that are imported via bank sync or file uploads. - Introduced `isImportedTransaction` utility to determine if a transaction is user-created or imported. - Updated DELETE endpoint to return a 409 status for attempts to delete imported transactions. - Enhanced transaction history and inbox components to reflect the new deletion rules. - Added tests for transaction origin determination and deletion behavior. - Updated UI components to include a confirmation dialog for clearing journal entry forms. - Localized new strings for clearing form functionality in English and Swedish. * feat(transactions): enhance transaction deletion guard and improve fiscal year visibility --------- Co-authored-by: Claude Opus 4.8 (1M context) --- app/(dashboard)/bookkeeping/page.tsx | 5 +- app/(dashboard)/salary/runs/[id]/page.tsx | 118 +++- app/(dashboard)/transactions/page.tsx | 42 +- .../journal-entries/__tests__/route.test.ts | 16 + app/api/bookkeeping/journal-entries/route.ts | 20 +- app/api/salary/runs/[id]/preview/route.ts | 47 +- .../transactions/[id]/__tests__/route.test.ts | 64 +- app/api/transactions/[id]/route.ts | 27 +- components/auth/BankIdAuth.tsx | 377 +++++++----- components/bookkeeping/JournalEntryForm.tsx | 54 +- components/bookkeeping/JournalEntryList.tsx | 558 ++++++++++++++---- components/common/FiscalYearSelector.tsx | 6 +- components/settings/CompanyMembersSection.tsx | 4 +- components/settings/SettingsShell.tsx | 2 +- .../transactions/TransactionHistoryList.tsx | 8 +- .../transactions/TransactionInboxCard.tsx | 15 +- .../__tests__/duplicate-payment-guard.test.ts | 53 ++ lib/transactions/__tests__/origin.test.ts | 52 ++ lib/transactions/origin.ts | 61 ++ messages/en.json | 21 + messages/sv.json | 21 + 21 files changed, 1223 insertions(+), 348 deletions(-) create mode 100644 lib/invoices/__tests__/duplicate-payment-guard.test.ts create mode 100644 lib/transactions/__tests__/origin.test.ts create mode 100644 lib/transactions/origin.ts diff --git a/app/(dashboard)/bookkeeping/page.tsx b/app/(dashboard)/bookkeeping/page.tsx index 384e7958..5f69cd47 100644 --- a/app/(dashboard)/bookkeeping/page.tsx +++ b/app/(dashboard)/bookkeeping/page.tsx @@ -9,7 +9,6 @@ import { Button } from '@/components/ui/button' import JournalEntryList from '@/components/bookkeeping/JournalEntryList' import JournalEntryForm, { type FormLine } from '@/components/bookkeeping/JournalEntryForm' import ChartOfAccountsManager from '@/components/bookkeeping/ChartOfAccountsManager' -import { FiscalYearSelector } from '@/components/common/FiscalYearSelector' import { useToast } from '@/components/ui/use-toast' import { Lock, Loader2, Copy } from 'lucide-react' import { PageHeader } from '@/components/ui/page-header' @@ -44,7 +43,6 @@ export default function BookkeepingPage() { const [refreshKey, setRefreshKey] = useState(0) const [activeTab, setActiveTab] = useState('journal') - const [periodId, setPeriodId] = useState(null) const [copyPrefill, setCopyPrefill] = useState(null) const [isLoadingCopy, setIsLoadingCopy] = useState(false) const [nextVoucher, setNextVoucher] = useState(null) @@ -163,8 +161,7 @@ export default function BookkeepingPage() { - - + diff --git a/app/(dashboard)/salary/runs/[id]/page.tsx b/app/(dashboard)/salary/runs/[id]/page.tsx index d2af7456..04692ff8 100644 --- a/app/(dashboard)/salary/runs/[id]/page.tsx +++ b/app/(dashboard)/salary/runs/[id]/page.tsx @@ -50,8 +50,8 @@ interface EntryPreview { } interface PreviewData { - salaryEntry: EntryPreview - avgifterEntry: EntryPreview + salaryEntry: EntryPreview | null + avgifterEntry: EntryPreview | null vacationEntry: EntryPreview | null } @@ -167,6 +167,29 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id: setActionLoading(null) } + // Remove an employee from a draft run. The DELETE endpoint is draft-only and + // cascades to the employee's line items; the button is only rendered while the + // run is a draft, matching that guard. + async function handleRemoveEmployee(employeeId: string, name: string) { + if (!confirm(`Ta bort ${name} från lönekörningen?`)) return + setActionLoading(`remove-${employeeId}`) + const res = await fetch(`/api/salary/runs/${id}/employees/${employeeId}`, { + method: 'DELETE', + }) + if (res.ok) { + await loadRun() + toast({ title: 'Anställd borttagen' }) + } else { + const result = await res.json() + toast({ + title: 'Kunde inte ta bort anställd', + description: getErrorMessage(result, { context: 'salary', statusCode: res.status }), + variant: 'destructive', + }) + } + setActionLoading(null) + } + // Edit this month's monthly salary for one employee (draft only). The engine // reads this per-run value at calc time, so each month's gross can differ // without changing the employee's standard pay. Saved on blur; the user then @@ -317,6 +340,9 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id: const employees = (run.employees || []) as SalaryRunEmployee[] const addedEmployeeIds = new Set(employees.map(e => e.employee_id)) const notAdded = availableEmployees.filter(e => !addedEmployeeIds.has(e.id)) + // Employees can only be removed while the run is a draft (matches the DELETE + // endpoint's guard); gate the row action column on the same condition. + const canRemoveEmployee = run.status === 'draft' && canWrite // calculation_params is frozen only when the run has been calculated, so it // distinguishes "not yet calculated" from "calculated to 0" (a nollkörning). @@ -464,6 +490,7 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id: Avgifter Semester Lönespec + {canRemoveEmployee && Ta bort} @@ -530,6 +557,28 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id: Visa PDF + {canRemoveEmployee && ( + + + + )} ) })} @@ -582,31 +631,48 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id: Förhandsgranskning — verifikationer - {[preview.salaryEntry, preview.avgifterEntry, preview.vacationEntry, (preview as unknown as Record).pensionEntry].filter(Boolean).map((entry, idx) => ( -
-

{entry!.description}

- - - - - - - - - - - {entry!.lines.map((line, li) => ( - - - - - + {(() => { + const entries = [ + preview.salaryEntry, + preview.avgifterEntry, + preview.vacationEntry, + (preview as unknown as Record).pensionEntry, + ].filter(Boolean) as EntryPreview[] + if (entries.length === 0) { + return ( +

+ Nollkörning — inga verifikat bokförs för den här körningen. + Kontrollera att övriga lönekörningar för perioden täcker + arbetsgivardeklarationen till Skatteverket. +

+ ) + } + return entries.map((entry, idx) => ( +
+

{entry.description}

+
KontoBeskrivningDebetKredit
{line.account_number}{line.line_description}{line.debit_amount ? formatCurrency(line.debit_amount) : ''}{line.credit_amount ? formatCurrency(line.credit_amount) : ''}
+ + + + + + - ))} - -
KontoBeskrivningDebetKredit
-
- ))} + + + {entry.lines.map((line, li) => ( + + {line.account_number} + {line.line_description} + {line.debit_amount ? formatCurrency(line.debit_amount) : ''} + {line.credit_amount ? formatCurrency(line.credit_amount) : ''} + + ))} + + + + )) + })()}
)} diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index 6e0640c1..4a9361c4 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -58,6 +58,7 @@ import { getErrorMessage } from '@/lib/errors/get-error-message' import { formatCurrency, formatDate } from '@/lib/utils' import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, SupplierInvoice, Supplier, VatTreatment, EntityType, LinePatternEntry, BookingTemplateLibrary } from '@/types' import type { SuggestedTemplate } from '@/lib/transactions/category-suggestions' +import { isImportedTransaction } from '@/lib/transactions/origin' type InvoiceWithCustomer = Invoice & { customer?: Customer } type SupplierInvoiceWithSupplier = SupplierInvoice & { supplier?: Supplier } @@ -1472,15 +1473,40 @@ export default function TransactionsPage() { } async function handleBatchDelete() { - const ids = Array.from(selectedIds) + // Only user-created rows can be deleted; imported (bank sync / CSV) rows are + // ignore-only. Split the selection so we never fire a delete the server + // would 409, and tell the user how many were skipped. Mirrors the server + // guard in DELETE /api/transactions/[id]. + const selected = Array.from(selectedIds) + const ids: string[] = [] + let skippedImported = 0 + for (const id of selected) { + const tx = transactions.find((t) => t.id === id) + if (tx && isImportedTransaction(tx)) skippedImported++ + else ids.push(id) + } + + if (ids.length === 0) { + toast({ + title: 'Inget att ta bort', + description: 'De valda transaktionerna är importerade och kan endast ignoreras, inte raderas.', + variant: 'destructive', + }) + return + } + const ok = await confirm({ title: `Ta bort ${ids.length} transaktioner?`, - description: 'Åtgärden kan inte ångras.', + description: + skippedImported > 0 + ? `${skippedImported} importerade transaktioner hoppas över (kan endast ignoreras). Åtgärden kan inte ångras.` + : 'Åtgärden kan inte ångras.', confirmLabel: 'Ta bort', variant: 'destructive', }) if (!ok) return + const deletedIds = new Set() setBatchProgress({ done: 0, total: ids.length }) let successes = 0 const failures: string[] = [] @@ -1489,6 +1515,7 @@ export default function TransactionsPage() { const response = await fetch(`/api/transactions/${ids[i]}`, { method: 'DELETE' }) if (response.ok) { successes++ + deletedIds.add(ids[i]) } else { const tx = transactions.find((t) => t.id === ids[i]) failures.push(tx?.description || ids[i]) @@ -1498,16 +1525,19 @@ export default function TransactionsPage() { } setBatchProgress({ done: i + 1, total: ids.length }) } - if (successes > 0) { - setTransactions((prev) => prev.filter((t) => !selectedIds.has(t.id) || failures.includes(t.description))) + if (deletedIds.size > 0) { + setTransactions((prev) => prev.filter((t) => !deletedIds.has(t.id))) } setBatchProgress(null) - if (failures.length === 0) { + if (failures.length === 0 && skippedImported === 0) { toast({ title: 'Klart', description: `${successes} transaktioner borttagna` }) } else { + const parts = [`${successes} borttagna`] + if (failures.length > 0) parts.push(`${failures.length} misslyckades`) + if (skippedImported > 0) parts.push(`${skippedImported} importerade kunde inte raderas`) toast({ title: 'Delvis klart', - description: `${successes} borttagna, ${failures.length} misslyckades`, + description: parts.join(', '), variant: 'destructive', }) } diff --git a/app/api/bookkeeping/journal-entries/__tests__/route.test.ts b/app/api/bookkeeping/journal-entries/__tests__/route.test.ts index b8636361..ea3a3106 100644 --- a/app/api/bookkeeping/journal-entries/__tests__/route.test.ts +++ b/app/api/bookkeeping/journal-entries/__tests__/route.test.ts @@ -140,6 +140,22 @@ describe('GET /api/bookkeeping/journal-entries', () => { ) }) + it('uses the direct query path (not the RPC) when a search term is set', async () => { + enqueue({ data: [], error: null, count: 0 }) + + const request = createMockRequest('/api/bookkeeping/journal-entries', { + searchParams: { period_id: 'period-1', search: 'luftfyllning' }, + }) + const response = await GET(request) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(200) + // Free-text search needs an ILIKE the include_related RPC can't express, so + // the route must fall through to the direct PostgREST query. + expect(mockSupabase.from).toHaveBeenCalledWith('journal_entries') + expect(mockSupabase.rpc).not.toHaveBeenCalled() + }) + it('returns 500 on database error', async () => { enqueue({ data: null, error: { message: 'DB error' } }) diff --git a/app/api/bookkeeping/journal-entries/route.ts b/app/api/bookkeeping/journal-entries/route.ts index b806575e..9b2066a4 100644 --- a/app/api/bookkeeping/journal-entries/route.ts +++ b/app/api/bookkeeping/journal-entries/route.ts @@ -7,6 +7,7 @@ import { validateBody } from '@/lib/api/validate' import { CreateJournalEntrySchema } from '@/lib/api/schemas' import { requireCompanyId } from '@/lib/company/context' import { requireWritePermission } from '@/lib/auth/require-write' +import { escapeLikePattern } from '@/lib/invoices/duplicate-payment-guard' ensureInitialized() @@ -32,6 +33,14 @@ export async function GET(request: Request) { // other value is passed (defense against trivial injection / typos). const seriesRaw = searchParams.get('series') const seriesFilter = seriesRaw && /^[A-Z]$/.test(seriesRaw) ? seriesRaw : null + // Free-text search over the voucher description (verifikationstext). When set, + // we take the direct-query path below (the include_related RPC can't search), + // which filters strictly by fiscal_period_id. So search is scoped to the + // selected fiscal period / company and — like voucher sort — does NOT surface + // cross-period follow-up entries: every result stays inside the selected + // 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. @@ -55,7 +64,7 @@ export async function GET(request: Request) { // 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) { + if (periodId && includeRelated && !isVoucherSort && !search) { const { data, error } = await supabase.rpc('list_fiscal_period_entries_with_related', { p_company_id: companyId, p_period_id: periodId, @@ -133,6 +142,15 @@ export async function GET(request: Request) { query = query.eq('voucher_series', seriesFilter) } + if (search) { + // Escape LIKE wildcards (\ % _) so they match literally, and cap the needle + // length (≤200 chars) — both handled by the shared escapeLikePattern helper. + // The cap bounds DB work against oversized/pathological inputs (compliance + // A.8.28 / ASVS V1.2.5); escaping prevents silent over-matching on values + // like "50%". Supabase parameterises the value, so this is not about SQLi. + query = query.ilike('description', `%${escapeLikePattern(search)}%`) + } + const { data, error, count } = await query if (error) { diff --git a/app/api/salary/runs/[id]/preview/route.ts b/app/api/salary/runs/[id]/preview/route.ts index f452aa65..0299322d 100644 --- a/app/api/salary/runs/[id]/preview/route.ts +++ b/app/api/salary/runs/[id]/preview/route.ts @@ -88,22 +88,28 @@ export async function GET( }) } - // Build avgifter entry preview + // Build avgifter entry preview — skipped for a nollkörning (0 avgifter), + // mirroring the vacation/pension guards below. The bookkeeping engine never + // posts an all-zero 7510/2731 voucher (see book/route.ts nollkörning path), + // so previewing one would falsely imply a verifikat that is never created. const totalAvgifter = employees.reduce((sum, e) => sum + e.avgifter_amount, 0) - const avgifterLines: CreateJournalEntryLineInput[] = [ - { - account_number: SALARY_ACCOUNTS.AVGIFTER_EXPENSE, - debit_amount: Math.round(totalAvgifter * 100) / 100, - credit_amount: 0, - line_description: `${desc} — Arbetsgivaravgifter`, - }, - { - account_number: SALARY_ACCOUNTS.AVGIFTER_LIABILITY, - debit_amount: 0, - credit_amount: Math.round(totalAvgifter * 100) / 100, - line_description: `${desc} — Arbetsgivaravgifter`, - }, - ] + const roundedAvgifter = Math.round(totalAvgifter * 100) / 100 + const avgifterLines: CreateJournalEntryLineInput[] = roundedAvgifter !== 0 + ? [ + { + account_number: SALARY_ACCOUNTS.AVGIFTER_EXPENSE, + debit_amount: roundedAvgifter, + credit_amount: 0, + line_description: `${desc} — Arbetsgivaravgifter`, + }, + { + account_number: SALARY_ACCOUNTS.AVGIFTER_LIABILITY, + debit_amount: 0, + credit_amount: roundedAvgifter, + line_description: `${desc} — Arbetsgivaravgifter`, + }, + ] + : [] // Build vacation entry preview const totalVacation = employees.reduce((sum, e) => sum + e.vacation_accrual, 0) @@ -170,14 +176,17 @@ export async function GET( return NextResponse.json({ data: { - salaryEntry: { + // Each entry is null when it has no lines — a nollkörning posts nothing, + // so the salary and avgifter entries fall away just like vacation/pension + // already do, and the UI can simply skip the null ones. + salaryEntry: salaryLines.length > 0 ? { description: desc, lines: salaryLines, - }, - avgifterEntry: { + } : null, + avgifterEntry: avgifterLines.length > 0 ? { description: `${desc} — Arbetsgivaravgifter`, lines: avgifterLines, - }, + } : null, vacationEntry: vacationLines.length > 0 ? { description: `${desc} — Semesteravsättning`, lines: vacationLines, diff --git a/app/api/transactions/[id]/__tests__/route.test.ts b/app/api/transactions/[id]/__tests__/route.test.ts index 2534c901..c4925d81 100644 --- a/app/api/transactions/[id]/__tests__/route.test.ts +++ b/app/api/transactions/[id]/__tests__/route.test.ts @@ -79,33 +79,46 @@ describe('DELETE /api/transactions/[id]', () => { expect(body.error.message).toMatch(/Bankavstämning|storna/) }) - it('allows deleting unbooked bank-synced transactions', async () => { + it('blocks deleting an unbooked bank-synced transaction (ignore-only)', async () => { + // A live bank connection (PSD2) makes this an imported row — not the user's + // to delete, only to ignore. const tx = makeTransaction({ bank_connection_id: 'bc-1', journal_entry_id: null, import_source: null }) - enqueue({ data: tx, error: null }) // fetch - enqueue({ data: null, error: null }) // delete + enqueue({ data: tx, error: null }) // fetch (no delete should follow) const request = new Request('http://localhost/api/transactions/tx-1', { method: 'DELETE' }) const response = await DELETE(request, createMockRouteParams({ id: 'tx-1' })) - const { status, body } = await parseJsonResponse(response) + const { status, body } = await parseJsonResponse<{ error: { code: string; message: string } }>(response) - expect(status).toBe(200) - expect(body).toEqual({ success: true }) + expect(status).toBe(409) + expect(body.error.code).toBe('TRANSACTION_DELETE_IMPORTED') + expect(body.error.message).toMatch(/banken|ignorera/i) }) - it('allows deleting unbooked imported transactions', async () => { + it('blocks deleting an unbooked CSV-imported transaction (ignore-only)', async () => { const tx = makeTransaction({ import_source: 'csv_nordea', journal_entry_id: null, bank_connection_id: null }) - enqueue({ data: tx, error: null }) // fetch - enqueue({ data: null, error: null }) // delete + enqueue({ data: tx, error: null }) // fetch (no delete should follow) const request = new Request('http://localhost/api/transactions/tx-1', { method: 'DELETE' }) const response = await DELETE(request, createMockRouteParams({ id: 'tx-1' })) - const { status, body } = await parseJsonResponse(response) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) - expect(status).toBe(200) - expect(body).toEqual({ success: true }) + expect(status).toBe(409) + expect(body.error.code).toBe('TRANSACTION_DELETE_IMPORTED') }) - it('deletes a manually added unbooked transaction', async () => { + it('blocks deleting an unbooked Enable Banking transaction (ignore-only)', async () => { + const tx = makeTransaction({ import_source: 'enable_banking', journal_entry_id: null, bank_connection_id: null }) + enqueue({ data: tx, error: null }) // fetch (no delete should follow) + + const request = new Request('http://localhost/api/transactions/tx-1', { method: 'DELETE' }) + const response = await DELETE(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + + expect(status).toBe(409) + expect(body.error.code).toBe('TRANSACTION_DELETE_IMPORTED') + }) + + it('deletes a manually added unbooked transaction (null source)', async () => { const tx = makeTransaction({ journal_entry_id: null, bank_connection_id: null, import_source: null }) enqueue({ data: tx, error: null }) // fetch enqueue({ data: null, error: null }) // delete @@ -118,6 +131,22 @@ describe('DELETE /api/transactions/[id]', () => { expect(body).toEqual({ success: true }) }) + it.each(['manual', 'mcp'])( + 'deletes an unbooked in-app transaction created via %s', + async (source) => { + const tx = makeTransaction({ journal_entry_id: null, bank_connection_id: null, import_source: source }) + enqueue({ data: tx, error: null }) // fetch + enqueue({ data: null, error: null }) // delete + + const request = new Request('http://localhost/api/transactions/tx-1', { method: 'DELETE' }) + const response = await DELETE(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(200) + expect(body).toEqual({ success: true }) + }, + ) + it('returns 500 with a structured code when deletion fails', async () => { const tx = makeTransaction({ journal_entry_id: null, bank_connection_id: null, import_source: null }) enqueue({ data: tx, error: null }) // fetch @@ -132,9 +161,12 @@ describe('DELETE /api/transactions/[id]', () => { }) it('returns 409 with an audit-trail code when the immutability trigger blocks the delete', async () => { - // An unbooked row with payment_match_log rows: the cascade hits the - // audit_log_immutable trigger (P0001), not a clean FK error. - const tx = makeTransaction({ journal_entry_id: null, bank_connection_id: 'bc-1', import_source: null }) + // A user-created (deletable) row that still carries payment_match_log rows + // — e.g. it was auto-suggested a match. The cascade on delete hits the + // audit_log_immutable trigger (P0001), not a clean FK error. Fixture must be + // user-created (no bank link / import source) so it passes the imported + // guard and actually reaches the delete. + const tx = makeTransaction({ journal_entry_id: null, bank_connection_id: null, import_source: 'manual' }) enqueue({ data: tx, error: null }) // fetch enqueue({ data: null, diff --git a/app/api/transactions/[id]/route.ts b/app/api/transactions/[id]/route.ts index 51164a44..916e9ecb 100644 --- a/app/api/transactions/[id]/route.ts +++ b/app/api/transactions/[id]/route.ts @@ -7,6 +7,7 @@ import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structure import { validateBody } from '@/lib/api/validate' import { UpdateTransactionTitleSchema } from '@/lib/api/schemas' import { guardSandbox } from '@/lib/sandbox/guard' +import { isImportedTransaction } from '@/lib/transactions/origin' import type { Transaction } from '@/types' export async function DELETE( @@ -26,10 +27,11 @@ export async function DELETE( const companyId = await requireCompanyId(supabase, user.id) - // Fetch the transaction with ownership check + // Fetch the transaction with ownership check. `bank_connection_id` + + // `import_source` tell us where the row came from (see the imported guard below). const { data: transaction, error: fetchError } = await supabase .from('transactions') - .select('id, journal_entry_id') + .select('id, journal_entry_id, bank_connection_id, import_source') .eq('id', id) .eq('company_id', companyId) .single() @@ -66,6 +68,27 @@ export async function DELETE( ) } + // Guard: only transactions the user created in the app can be deleted. Rows + // fetched via bank sync or uploaded via a bank-file import are an external + // record of money that moved — deleting one would silently drop a real bank + // line (and a re-sync would just bring it back). The user can *ignore* such a + // row (POST /api/transactions/[id]/ignore) to take it off the to-book list, + // but never delete it. See lib/transactions/origin.ts for the origin rule. + if (isImportedTransaction(transaction)) { + return NextResponse.json( + { + error: { + code: 'TRANSACTION_DELETE_IMPORTED', + message: + 'Transaktionen har hämtats från banken eller importerats via fil och kan inte raderas. Du kan ignorera den så att den döljs från listan över transaktioner att bokföra.', + message_en: + 'This transaction was fetched from your bank or imported from a file and cannot be deleted. You can ignore it to hide it from the list of transactions to book.', + }, + }, + { status: 409 } + ) + } + const { error: deleteError } = await supabase .from('transactions') .delete() diff --git a/components/auth/BankIdAuth.tsx b/components/auth/BankIdAuth.tsx index b4827cc4..6d695981 100644 --- a/components/auth/BankIdAuth.tsx +++ b/components/auth/BankIdAuth.tsx @@ -40,6 +40,78 @@ function isMobile(): boolean { return /iPhone|iPad|iPod|Android/i.test(navigator.userAgent) } +/** + * sessionStorage key holding an in-flight BankID session across the mobile + * return-redirect. On iOS the BankID app returns to the SAME Safari tab by + * reloading it (see launchBankIdApp), which wipes React state — so we stash the + * session here and resume polling on the next mount. + */ +const PENDING_KEY = 'bankid:pending' +/** Ignore a stashed session older than this (BankID orders expire in ~3 min). */ +const PENDING_TTL_MS = 5 * 60 * 1000 + +interface PendingBankId { + session: BankIdSession + mode: string + ts: number +} + +function persistPending(session: BankIdSession, mode: string): void { + try { + sessionStorage.setItem( + PENDING_KEY, + JSON.stringify({ session, mode, ts: Date.now() } satisfies PendingBankId) + ) + } catch { + // sessionStorage unavailable (private mode / quota) — auto-resume just won't + // fire; the user can still switch back to the tab manually as before. + } +} + +function readPending(mode: string): PendingBankId | null { + try { + const raw = sessionStorage.getItem(PENDING_KEY) + if (!raw) return null + const parsed = JSON.parse(raw) as PendingBankId + if (parsed?.mode !== mode) return null + if (typeof parsed.ts !== 'number' || Date.now() - parsed.ts > PENDING_TTL_MS) return null + if (!parsed.session?.sessionId) return null + return parsed + } catch { + return null + } +} + +function clearPending(): void { + try { + sessionStorage.removeItem(PENDING_KEY) + } catch { + // ignore + } +} + +/** + * Launch the BankID app on the same (mobile) device. + * + * Uses the universal link https://app.bankid.com/ — NOT the bankid:/// custom + * scheme. A custom-scheme launch has no association with the originating Safari + * tab, so on iOS the post-auth redirect opens in a NEW tab (git history: commit + * 3bc652cc reverted a redirect for exactly that reason). The universal link is + * tied to the originating tab, so BankID returns the user to it. + * + * redirect: + * • iOS → current URL, so the app navigates this tab back here on success + * (the resume effect then completes the flow). + * • Android → "null": the BankID app returns via the task stack, and a real + * redirect URL would spawn a new tab / Chrome instance instead. + */ +function launchBankIdApp(autoStartToken: string): void { + const ua = typeof navigator !== 'undefined' ? navigator.userAgent : '' + const isIOS = /iPad|iPhone|iPod/.test(ua) + const redirect = isIOS ? encodeURIComponent(window.location.href) : 'null' + window.location.href = `https://app.bankid.com/?autostarttoken=${autoStartToken}&redirect=${redirect}` +} + /** * BankID authentication flow component. * Handles QR code display (desktop) or app deep link (mobile), @@ -72,6 +144,149 @@ export function BankIdAuth({ mode, onComplete }: BankIdAuthProps) { useEffect(() => cleanup, [cleanup]) + // Poll an in-flight BankID session until it completes, fails, or the service + // gives up. Extracted from startSession so the resume effect (mobile return) + // can re-attach to a session that was started before the tab reloaded. + const beginPolling = useCallback((session: BankIdSession) => { + abortRef.current = new AbortController() + pollRef.current = setInterval(async () => { + try { + const pollRes = await fetch(`${API_BASE}/poll`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId: session.sessionId }), + signal: abortRef.current?.signal, + }) + + if (!pollRes.ok) { + const pollErr = await pollRes.json().catch(() => ({})) + if (pollErr.error === 'service_unavailable' || pollRes.status === 502 || pollRes.status === 503) { + pollFailureCount.current++ + if (pollFailureCount.current >= MAX_POLL_FAILURES) { + cleanup() + clearPending() + setStatus('service_unavailable') + setErrorMessage('BankID-tjänsten är inte tillgänglig just nu') + onCompleteRef.current({ error: 'service_unavailable' }) + } + } + return + } + + // Reset failure counter on successful poll + pollFailureCount.current = 0 + + const pollJson = await pollRes.json() + const pollData = pollJson.data + + if (!pollData) { + console.warn('[bankid] poll returned no data:', pollJson) + return + } + + // Update hint message from TIC API + if (pollData.message) { + setHintMessage(pollData.message) + } + + // Handle token refresh (order regeneration ~25s) + if (pollData.qrStartToken && pollData.qrStartSecret) { + setSession((prev) => + prev + ? { ...prev, qrStartToken: pollData.qrStartToken, qrStartSecret: pollData.qrStartSecret } + : prev + ) + } + + if (pollData.status === 'complete') { + cleanup() + clearPending() + setStatus('complete') + + if (mode === 'login') { + // For login, call /complete to exchange for Supabase session + try { + const completeRes = await fetch(`${API_BASE}/complete`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sessionId: session.sessionId, + mode: 'login', + }), + }) + const completeJson = await completeRes.json() + + if (!completeRes.ok) { + const errorCode = completeJson.error === 'service_unavailable' || completeRes.status === 502 || completeRes.status === 503 + ? 'service_unavailable' as const + : completeJson.error + if (errorCode === 'service_unavailable') { + setStatus('service_unavailable') + setErrorMessage('BankID-tjänsten är inte tillgänglig just nu') + } + onCompleteRef.current({ + error: errorCode, + givenName: completeJson.givenName, + surname: completeJson.surname, + }) + return + } + + onCompleteRef.current({ + tokenHash: completeJson.data.tokenHash, + type: completeJson.data.type, + isNewUser: completeJson.data.isNewUser, + }) + } catch { + onCompleteRef.current({ error: 'session_invalid' }) + } + } else if (mode === 'link') { + // For link, call /link to associate BankID with current user + try { + const linkRes = await fetch(`${API_BASE}/link`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId: session.sessionId }), + }) + const linkJson = await linkRes.json() + + if (!linkRes.ok) { + onCompleteRef.current({ error: linkJson.error }) + return + } + + onCompleteRef.current({}) + } catch { + onCompleteRef.current({ error: 'session_invalid' }) + } + } else { + // For signup, return user data + sessionId so parent can collect email + onCompleteRef.current({ + givenName: pollData.user?.givenName, + surname: pollData.user?.surname, + sessionId: session.sessionId, + }) + } + } else if (pollData.status === 'failed' || pollData.status === 'cancelled') { + cleanup() + clearPending() + setStatus('failed') + setErrorMessage(pollData.message || 'BankID-identifieringen misslyckades') + } + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') return + pollFailureCount.current++ + if (pollFailureCount.current >= MAX_POLL_FAILURES) { + cleanup() + clearPending() + setStatus('service_unavailable') + setErrorMessage('BankID-tjänsten är inte tillgänglig just nu') + onCompleteRef.current({ error: 'service_unavailable' }) + } + } + }, 2000) + }, [cleanup, mode]) + const startSession = useCallback(async () => { // Prevent rapid restarts (each start = billable TIC session) const now = Date.now() @@ -79,6 +294,7 @@ export function BankIdAuth({ mode, onComplete }: BankIdAuthProps) { lastStartRef.current = now cleanup() + clearPending() setStatus('scanning') setHintMessage('Starta BankID-appen') setErrorMessage('') @@ -101,157 +317,42 @@ export function BankIdAuth({ mode, onComplete }: BankIdAuthProps) { const newSession: BankIdSession = data setSession(newSession) - // On mobile, open BankID app — redirect=null so it doesn't open a new tab; - // the polling in this tab detects completion when the user switches back. + // On mobile, open the BankID app on this device. if (isMobile()) { - window.location.href = `bankid:///?autostarttoken=${newSession.autoStartToken}&redirect=null` + // Persist BEFORE launching: on iOS the BankID app returns by reloading + // THIS tab (universal link → same tab), which wipes in-memory state. + // The resume effect re-attaches polling on load. See launchBankIdApp. + persistPending(newSession, mode) + launchBankIdApp(newSession.autoStartToken) } - // Start polling - abortRef.current = new AbortController() - pollRef.current = setInterval(async () => { - try { - const pollRes = await fetch(`${API_BASE}/poll`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sessionId: newSession.sessionId }), - signal: abortRef.current?.signal, - }) - - if (!pollRes.ok) { - const pollErr = await pollRes.json().catch(() => ({})) - if (pollErr.error === 'service_unavailable' || pollRes.status === 502 || pollRes.status === 503) { - pollFailureCount.current++ - if (pollFailureCount.current >= MAX_POLL_FAILURES) { - cleanup() - setStatus('service_unavailable') - setErrorMessage('BankID-tjänsten är inte tillgänglig just nu') - onCompleteRef.current({ error: 'service_unavailable' }) - } - } - return - } - - // Reset failure counter on successful poll - pollFailureCount.current = 0 - - const pollJson = await pollRes.json() - const pollData = pollJson.data - - if (!pollData) { - console.warn('[bankid] poll returned no data:', pollJson) - return - } - - // Update hint message from TIC API - if (pollData.message) { - setHintMessage(pollData.message) - } - - // Handle token refresh (order regeneration ~25s) - if (pollData.qrStartToken && pollData.qrStartSecret) { - setSession((prev) => - prev - ? { ...prev, qrStartToken: pollData.qrStartToken, qrStartSecret: pollData.qrStartSecret } - : prev - ) - } - - if (pollData.status === 'complete') { - cleanup() - setStatus('complete') - - if (mode === 'login') { - // For login, call /complete to exchange for Supabase session - try { - const completeRes = await fetch(`${API_BASE}/complete`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - sessionId: newSession.sessionId, - mode: 'login', - }), - }) - const completeJson = await completeRes.json() - - if (!completeRes.ok) { - const errorCode = completeJson.error === 'service_unavailable' || completeRes.status === 502 || completeRes.status === 503 - ? 'service_unavailable' as const - : completeJson.error - if (errorCode === 'service_unavailable') { - setStatus('service_unavailable') - setErrorMessage('BankID-tjänsten är inte tillgänglig just nu') - } - onCompleteRef.current({ - error: errorCode, - givenName: completeJson.givenName, - surname: completeJson.surname, - }) - return - } - - onCompleteRef.current({ - tokenHash: completeJson.data.tokenHash, - type: completeJson.data.type, - isNewUser: completeJson.data.isNewUser, - }) - } catch { - onCompleteRef.current({ error: 'session_invalid' }) - } - } else if (mode === 'link') { - // For link, call /link to associate BankID with current user - try { - const linkRes = await fetch(`${API_BASE}/link`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sessionId: newSession.sessionId }), - }) - const linkJson = await linkRes.json() - - if (!linkRes.ok) { - onCompleteRef.current({ error: linkJson.error }) - return - } - - onCompleteRef.current({}) - } catch { - onCompleteRef.current({ error: 'session_invalid' }) - } - } else { - // For signup, return user data + sessionId so parent can collect email - onCompleteRef.current({ - givenName: pollData.user?.givenName, - surname: pollData.user?.surname, - sessionId: newSession.sessionId, - }) - } - } else if (pollData.status === 'failed' || pollData.status === 'cancelled') { - cleanup() - setStatus('failed') - setErrorMessage(pollData.message || 'BankID-identifieringen misslyckades') - } - } catch (error) { - if (error instanceof Error && error.name === 'AbortError') return - pollFailureCount.current++ - if (pollFailureCount.current >= MAX_POLL_FAILURES) { - cleanup() - setStatus('service_unavailable') - setErrorMessage('BankID-tjänsten är inte tillgänglig just nu') - onCompleteRef.current({ error: 'service_unavailable' }) - } - } - }, 2000) + beginPolling(newSession) } catch (error) { setStatus('failed') setErrorMessage(error instanceof Error ? error.message : 'Ett oväntat fel uppstod') } - }, [cleanup, mode]) + }, [cleanup, mode, beginPolling]) + + // After returning from the BankID app on mobile, iOS reloads this tab. Pick up + // the session we persisted before launching and resume polling so the flow + // completes without the user having to tap "Logga in med BankID" again. + useEffect(() => { + const pending = readPending(mode) + if (!pending) return + setSession(pending.session) + setStatus('scanning') + setHintMessage('Slutför BankID-verifieringen...') + beginPolling(pending.session) + // Run once on mount: we're recovering state that the return-reload destroyed. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) const handleCancel = useCallback(async () => { if (session) { fetch(`${API_BASE}/${session.sessionId}`, { method: 'DELETE' }).catch(() => {}) } cleanup() + clearPending() setStatus('idle') setSession(null) }, [session, cleanup]) diff --git a/components/bookkeeping/JournalEntryForm.tsx b/components/bookkeeping/JournalEntryForm.tsx index c95db446..88765d77 100644 --- a/components/bookkeeping/JournalEntryForm.tsx +++ b/components/bookkeeping/JournalEntryForm.tsx @@ -8,8 +8,9 @@ import { Input } from '@/components/ui/input' import { Textarea } from '@/components/ui/textarea' import { Label } from '@/components/ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog' import { useToast } from '@/components/ui/use-toast' -import { Plus, Trash2, AlertTriangle, Loader2, Lock, CalendarPlus } from 'lucide-react' +import { Plus, Trash2, AlertTriangle, Loader2, Lock, CalendarPlus, Eraser } from 'lucide-react' import { useCanWrite } from '@/lib/hooks/use-can-write' import { ConfirmationDialog } from '@/components/ui/confirmation-dialog' import { JournalEntryReviewContent } from '@/components/bookkeeping/JournalEntryReviewContent' @@ -106,6 +107,7 @@ export default function JournalEntryForm({ const [foreignAmount, setForeignAmount] = useState('') const [periodMismatch, setPeriodMismatch] = useState<'no_period' | 'wrong_period' | null>(null) const [showCreatePeriod, setShowCreatePeriod] = useState(false) + const [showClearConfirm, setShowClearConfirm] = useState(false) // Month (YYYY-MM) of the most recently posted voucher this session. Used to // flag, at the review step, when the user is about to book into a different // month — guards against accidentally posting to the wrong month. @@ -457,6 +459,20 @@ export default function JournalEntryForm({ if (!description) setDescription(templateDescription) } + // Wipe the form back to a blank entry. Mirrors the post-submit reset: it + // clears the data the user typed (lines, description, note, attachments, + // currency) but keeps the contextual defaults (period, date, series) so the + // form is immediately ready for the next entry. + const handleClearAll = () => { + setDescription('') + setNotes('') + setUploadedFiles([]) + setLines([{ ...BLANK_LINE }, { ...BLANK_LINE }]) + setEntryCurrency('SEK') + setExchangeRate('') + setForeignAmount('') + } + const handleOpenCreateAccount = (lineIndex: number, prefill: string) => { setCreatingAccountForLine(lineIndex) setCreateAccountPrefill(prefill) @@ -1067,6 +1083,17 @@ export default function JournalEntryForm({
+ {!embedded && ( + + )} + + + +
) diff --git a/components/bookkeeping/JournalEntryList.tsx b/components/bookkeeping/JournalEntryList.tsx index 600bec2d..38126aa3 100644 --- a/components/bookkeeping/JournalEntryList.tsx +++ b/components/bookkeeping/JournalEntryList.tsx @@ -4,13 +4,27 @@ import { useState, useEffect, useCallback } from 'react' import Link from 'next/link' import { useRouter } from 'next/navigation' import { useTranslations } from 'next-intl' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Card, CardContent } from '@/components/ui/card' 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 { ChevronDown, ChevronRight, Paperclip, AlertTriangle, CircleSlash, Loader2, BookOpen, X, Copy, Lock } from 'lucide-react' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, + DialogFooter, + DialogClose, +} from '@/components/ui/dialog' +import { + FiscalYearSelector, + STORAGE_KEY_PREFIX as FISCAL_YEAR_STORAGE_KEY_PREFIX, + ALL_YEARS_VALUE as FISCAL_YEAR_ALL_VALUE, +} from '@/components/common/FiscalYearSelector' +import { ChevronDown, ChevronRight, Paperclip, AlertTriangle, CircleSlash, Loader2, BookOpen, X, Copy, Lock, Search, SlidersHorizontal } from 'lucide-react' import { formatDate } from '@/lib/utils' import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver' import { Input } from '@/components/ui/input' @@ -24,7 +38,8 @@ import AttachmentPreviewSheet from '@/components/bookkeeping/AttachmentPreviewSh import { useToast } from '@/components/ui/use-toast' import { useCanWrite } from '@/lib/hooks/use-can-write' import { getErrorMessage } from '@/lib/errors/get-error-message' -import type { JournalEntry, JournalEntryLine } from '@/types' +import { useCompanyOptional } from '@/contexts/CompanyContext' +import type { FiscalPeriod, JournalEntry, JournalEntryLine } from '@/types' const NEEDS_ATTACHMENT = new Set([ 'manual', @@ -35,14 +50,18 @@ const NEEDS_ATTACHMENT = new Set([ 'import', ]) -interface Props { - periodId?: string -} +type SortBy = 'date_desc' | 'date_asc' | 'voucher_asc' | 'voucher_desc' -export default function JournalEntryList({ periodId }: Props) { +// Per-company persistence of the sort dropdown. Mirrors the localStorage +// convention used by FiscalYearSelector ('Accounted:fiscal-year:'). +const SORT_STORAGE_KEY_PREFIX = 'Accounted:journal-sort:' +const SORT_VALUES = new Set(['date_desc', 'date_asc', 'voucher_asc', 'voucher_desc']) + +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([]) const [committingId, setCommittingId] = useState(null) @@ -55,12 +74,19 @@ export default function JournalEntryList({ periodId }: Props) { const [showMissingOnly, setShowMissingOnly] = useState(false) const [correctionEntry, setCorrectionEntry] = useState(null) const [previewEntryId, setPreviewEntryId] = useState(null) - const [sortBy, setSortBy] = useState<'date_desc' | 'date_asc' | 'voucher_asc' | 'voucher_desc'>('date_desc') + const [sortBy, setSortBy] = useState('date_desc') + const [sortHydrated, setSortHydrated] = useState(false) + const [periodId, setPeriodId] = useState(null) + const [periodHydrated, setPeriodHydrated] = useState(false) + const [periods, setPeriods] = useState([]) + 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('all') + const [searchInput, setSearchInput] = useState('') + const [search, setSearch] = useState('') const pageSize = 20 const normalizeDate = (v: string): string | null => { @@ -136,6 +162,72 @@ export default function JournalEntryList({ periodId }: Props) { 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 fiscal-year selection (per company), reading the same + // localStorage key FiscalYearSelector writes. The selector lives inside the + // filter dialog and only mounts when opened, so we resolve the saved scope + // here — independent of the dialog — to keep the initial fetch correct. + // periodHydrated gates the first fetch so the list loads already scoped. + useEffect(() => { + if (company?.id && typeof window !== 'undefined') { + const stored = window.localStorage.getItem(FISCAL_YEAR_STORAGE_KEY_PREFIX + company.id) + setPeriodId(stored && stored !== FISCAL_YEAR_ALL_VALUE ? stored : null) + } else { + setPeriodId(null) + } + setPeriodHydrated(true) + }, [company?.id]) + + // Fetch fiscal periods so the active räkenskapsår can be labelled on the + // filter bar without opening the dialog (BFL period-orientation: the user + // should always see which year the ledger is scoped to). Read-only — the + // dialog's FiscalYearSelector still owns selection; this copy resolves the + // name for display. + useEffect(() => { + if (!company?.id) { + setPeriods([]) + return + } + let cancelled = false + ;(async () => { + try { + const res = await fetch('/api/bookkeeping/fiscal-periods') + if (!res.ok) return + const { data } = await res.json() + if (!cancelled) setPeriods((data || []) as FiscalPeriod[]) + } catch { + // Non-critical — the chip falls back to the active-filter count badge. + } + })() + 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) const params = new URLSearchParams({ @@ -147,6 +239,7 @@ export default function JournalEntryList({ periodId }: Props) { 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) { @@ -165,8 +258,9 @@ export default function JournalEntryList({ periodId }: Props) { } useEffect(() => { + if (!sortHydrated || !periodHydrated) return fetchEntries() - }, [periodId, page, sortBy, dateFrom, dateTo, seriesFilter]) + }, [periodId, page, sortBy, dateFrom, dateTo, seriesFilter, search, sortHydrated, periodHydrated]) const handleAttachmentCountChange = useCallback((entryId: string, count: number) => { setAttachmentCounts((prev) => ({ ...prev, [entryId]: count })) @@ -198,18 +292,63 @@ export default function JournalEntryList({ periodId }: Props) { } } - if (loading) { - return ( - - - -

{t('loading')}

-
-
- ) + 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. + const activeFilterCount = + (periodId ? 1 : 0) + + (seriesFilter !== 'all' ? 1 : 0) + + (dateFrom || dateTo ? 1 : 0) + + (showMissingOnly ? 1 : 0) + + // 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 + + // Resolve the active fiscal-year scope for the bar chip. "All years" (periodId + // null) renders immediately; a specific period waits until its name resolves + // from the fetched list (scopeLabel stays null meanwhile, so the chip never + // flashes the wrong scope). Surfacing this keeps the period visible per BFL + // without the user having to open the filter dialog. + const activePeriod = periodId ? periods.find((p) => p.id === periodId) ?? null : null + const scopeLabel = periodId ? activePeriod?.name ?? null : t('scope_all_years') + + // Apply a fiscal-year selection from the dialog. The FiscalYearSelector + // 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) } - if (entries.length === 0) { + 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) + } + + if (!loading && entries.length === 0 && !hasActiveFilters) { return ( @@ -225,124 +364,240 @@ export default function JournalEntryList({ periodId }: Props) { ) } - const filteredEntries = showMissingOnly - ? entries.filter( - (e) => - NEEDS_ATTACHMENT.has(e.source_type) && - !attachmentCounts[e.id] && - e.status === 'posted' && - !noDocRequired.has(e.id) - ) - : entries - return (
- {/* Filters and sorting */} -
-
-
- - - {showMissingOnly && ( - - {filteredEntries.length} - - )} -
-
- - -
+ {/* Search (always visible) + filter dialog for everything else */} +
+
+ setDateFromInput(e.target.value)} - onBlur={() => { - const v = dateFromInput.trim() - if (v === '') return - const normalized = normalizeDate(v) - if (normalized) setDateFromInput(normalized) - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault() - applyDateFilter() - } - }} - className="h-8 flex-1 sm:flex-none sm:w-[145px] text-xs" + 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" /> - setDateToInput(e.target.value)} - onBlur={() => { - const v = dateToInput.trim() - if (v === '') return - const normalized = normalizeDate(v) - if (normalized) setDateToInput(normalized) - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault() - applyDateFilter() - } - }} - className="h-8 flex-1 sm:flex-none sm:w-[145px] text-xs" - /> - - {(dateFrom || dateTo) && ( + {searchInput && ( )}
+ + + + + + + + {t('filter_dialog_title')} + + +
+ {/* Räkenskapsår */} +
+ + +
+ + {/* Sortering */} +
+ + +
+ + {/* Verifikationsserie */} +
+ + +
+ + {/* Datumintervall */} +
+ +
+ setDateFromInput(e.target.value)} + onBlur={applyDateFilter} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + applyDateFilter() + } + }} + className="h-9 flex-1 text-sm" + /> + – + setDateToInput(e.target.value)} + onBlur={applyDateFilter} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + applyDateFilter() + } + }} + className="h-9 flex-1 text-sm" + /> + {(dateFrom || dateTo) && ( + + )} +
+
+ + {/* Visa saknade underlag */} +
+ + + {showMissingOnly && ( + + {filteredEntries.length} + + )} +
+
+ + + + + + + +
+
+ {/* Active fiscal-year scope — visible without opening the filter dialog so + the user always sees which räkenskapsår the ledger is scoped to (BFL + period-correctness). Clicking it opens the dialog to change the scope. */} + {periodHydrated && scopeLabel && ( +
+ {t('scope_label')} + +
+ )} + + {loading ? ( + + + +

{t('loading')}

+
+
+ ) : filteredEntries.length === 0 ? ( + + +
+ +
+

{t('no_results_title')}

+

+ {t('no_results_description')} +

+
+
+ ) : (
{filteredEntries.map((entry) => { const isExpanded = expandedId === entry.id @@ -385,6 +640,33 @@ export default function JournalEntryList({ periodId }: Props) { )} {entry.description} + {attachmentCounts[entry.id] ? ( {attachmentCounts[entry.id] ? (