From 4dfd790de59151705eb89887c8dd3a8c733379c7 Mon Sep 17 00:00:00 2001
From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com>
Date: Mon, 8 Jun 2026 20:41:30 +0200
Subject: [PATCH] feat(bookkeeping): Ny verifikat modal, ledger-style list, SIE
no-underlag exemptions (#698)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(bookkeeping): Ny verifikat modal, ledger-style list, SIE no-underlag exemptions
Verifikat UX
- "Ny verifikat" opens in a modal (NewJournalEntryDialog) instead of an inline tab;
the review step renders inline in the dialog rather than stacking a second dialog.
- JournalEntryForm: konteringsrader are the focus, with a compact pre-filled metadata
bar (datum/serie/text/valuta/period) on top; verifikationstext auto-fills from the
first row's account.
- JournalEntryList: belopp shown on collapsed rows; expanded view is an aligned
Konto/Benämning/Debet/Kredit table.
SIE imports no longer flood "Att hantera: saknade underlag"
- Import gains an opt-in (off by default) toggle to mark imported verifikat as "Inget
underlag krävs"; a "Rekommenderas vid migrering" badge nudges it for historical years.
- Multi-select batch-mark in the list for selective cleanup.
- Filter-scoped bulk mark (POST /api/bookkeeping/no-doc-required/bulk-missing): marks
every missing-doc verifikat matching the active filters across all pages, with a
dry_run count to confirm scope — the scalable remedy for a post-import flood.
- Shared helper markEntriesNoDocRequired + per-entry batch route.
Tests: no-doc helper, batch route, bulk-missing route.
Co-Authored-By: Claude Opus 4.8 (1M context)
* fix(bookkeeping): address PR #698 review findings
- JournalEntryForm: restore the explicit "no underlag" acknowledgement in the
modal's inline review. When no document is attached, the confirm button reads
"Bokför utan underlag" (BFL 5 kap 6-7 §§), equivalent to the blocking dialog the
non-bare flow shows — the bare path no longer posts behind only a passive banner.
- batch no-doc route: guard the ownership query with source_type IN
NEEDS_DOC_SOURCE_TYPES so a crafted request can't exempt non-document-requiring
entries (defense in depth on top of company + posted scoping).
- bulk-missing route: resolve doc/exemption status by querying only the candidate
ids (chunked) instead of loading the company's full document_attachments and
journal_entry_no_doc_required tables into memory — data minimisation + bounded
memory for large migrations (the most-repeated reviewer finding).
Triaged as non-issues (left as-is): partial-import exemption (gated on
result.success == zero errors), reason write-back (sidecar row is FK-linked and
carries the reason), and "bulk-exempting manual entries" (consistent with the
existing per-entry NoDocRequiredToggle). No DB migration — reuses the existing
journal_entry_no_doc_required table.
Co-Authored-By: Claude Opus 4.8 (1M context)
* fix(bookkeeping): centralize bulk-missing date/series validation in Zod
Move the ISO-date and verifikationsserie format checks into the Zod schema so
malformed input is rejected with a clean 400 instead of being silently nulled
(or, for a shaped-but-invalid date, throwing a 500 via fetchAllRows). The date
refinement rejects values like 9999-99-99 / 2026-02-30 that a bare
/^\d{4}-\d{2}-\d{2}$/ regex lets through. Addresses the PR #698 reviewer nit on
split schema-vs-runtime validation. +2 route tests.
Co-Authored-By: Claude Opus 4.8 (1M context)
---------
Co-authored-by: Claude Opus 4.8 (1M context)
---
app/(dashboard)/bookkeeping/page.tsx | 101 ++---
.../batch/__tests__/route.test.ts | 84 ++++
.../no-doc-required/batch/route.ts | 63 +++
.../bulk-missing/__tests__/route.test.ts | 95 +++++
.../no-doc-required/bulk-missing/route.ts | 143 +++++++
app/api/import/sie/execute/route.ts | 1 +
components/bookkeeping/JournalEntryForm.tsx | 339 ++++++++++-----
components/bookkeeping/JournalEntryList.tsx | 401 +++++++++++++++---
.../bookkeeping/NewJournalEntryDialog.tsx | 86 ++++
components/import/ImportReviewStep.tsx | 39 ++
.../__tests__/no-doc-required.test.ts | 60 +++
lib/bookkeeping/no-doc-required.ts | 50 +++
lib/import/sie-import.ts | 45 ++
lib/import/types.ts | 4 +
messages/en.json | 22 +-
messages/sv.json | 22 +-
16 files changed, 1327 insertions(+), 228 deletions(-)
create mode 100644 app/api/bookkeeping/no-doc-required/batch/__tests__/route.test.ts
create mode 100644 app/api/bookkeeping/no-doc-required/batch/route.ts
create mode 100644 app/api/bookkeeping/no-doc-required/bulk-missing/__tests__/route.test.ts
create mode 100644 app/api/bookkeeping/no-doc-required/bulk-missing/route.ts
create mode 100644 components/bookkeeping/NewJournalEntryDialog.tsx
create mode 100644 lib/bookkeeping/__tests__/no-doc-required.test.ts
create mode 100644 lib/bookkeeping/no-doc-required.ts
diff --git a/app/(dashboard)/bookkeeping/page.tsx b/app/(dashboard)/bookkeeping/page.tsx
index 5f69cd47..da0b5d3f 100644
--- a/app/(dashboard)/bookkeeping/page.tsx
+++ b/app/(dashboard)/bookkeeping/page.tsx
@@ -7,28 +7,21 @@ import { useTranslations } from 'next-intl'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { Button } from '@/components/ui/button'
import JournalEntryList from '@/components/bookkeeping/JournalEntryList'
-import JournalEntryForm, { type FormLine } from '@/components/bookkeeping/JournalEntryForm'
+import { type FormLine } from '@/components/bookkeeping/JournalEntryForm'
+import NewJournalEntryDialog, { type CopyPrefill } from '@/components/bookkeeping/NewJournalEntryDialog'
import ChartOfAccountsManager from '@/components/bookkeeping/ChartOfAccountsManager'
import { useToast } from '@/components/ui/use-toast'
-import { Lock, Loader2, Copy } from 'lucide-react'
+import { Lock, Plus } from 'lucide-react'
import { PageHeader } from '@/components/ui/page-header'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import type { JournalEntry, JournalEntryLine } from '@/types'
-interface CopyPrefill {
- sourceId: string
- sourceVoucherLabel: string
- lines: FormLine[]
- description: string
- notes: string
-}
-
interface NextVoucher {
next: number
series: string
}
-type TabValue = 'journal' | 'new-entry' | 'accounts'
+type TabValue = 'journal' | 'accounts'
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
@@ -43,6 +36,7 @@ export default function BookkeepingPage() {
const [refreshKey, setRefreshKey] = useState(0)
const [activeTab, setActiveTab] = useState('journal')
+ const [showNewEntry, setShowNewEntry] = useState(false)
const [copyPrefill, setCopyPrefill] = useState(null)
const [isLoadingCopy, setIsLoadingCopy] = useState(false)
const [nextVoucher, setNextVoucher] = useState(null)
@@ -56,7 +50,7 @@ export default function BookkeepingPage() {
useEffect(() => {
if (!copyFromId) return
- setActiveTab('new-entry')
+ setShowNewEntry(true)
setCopyPrefill(null)
setIsLoadingCopy(true)
@@ -136,12 +130,27 @@ export default function BookkeepingPage() {
title={t('title')}
action={
+
{
+ setCopyPrefill(null)
+ setShowNewEntry(true)
+ }}
+ >
+
+ {t('tab_new_entry')}
+ {nextVoucher && (
+
+ ({nextVoucher.series}{nextVoucher.next})
+
+ )}
+
-
-
- {t('year_end')}
-
-
+
+
+ {t('year_end')}
+
+
}
/>
@@ -149,14 +158,6 @@ export default function BookkeepingPage() {
setActiveTab(v as TabValue)}>
{t('tab_journal')}
-
- {t('tab_new_entry')}
- {nextVoucher && (
-
- ({nextVoucher.series}{nextVoucher.next})
-
- )}
-
{t('tab_accounts')}
@@ -164,45 +165,25 @@ export default function BookkeepingPage() {
-
- {isLoadingCopy ? (
-
-
- {t('loading_source_voucher')}
-
- ) : (
- <>
- {copyPrefill && (
-
-
-
-
- {t('copy_banner_title', { label: copyPrefill.sourceVoucherLabel || t('copy_banner_unknown_label') })}
-
-
- {t('copy_banner_body')}
-
-
-
- )}
- {
- setRefreshKey((k) => k + 1)
- setCopyPrefill(null)
- }}
- initialLines={copyPrefill?.lines}
- initialDescription={copyPrefill?.description}
- initialNotes={copyPrefill?.notes}
- />
- >
- )}
-
-
+
+ {
+ setShowNewEntry(o)
+ if (!o) setCopyPrefill(null)
+ }}
+ onCreated={() => {
+ setRefreshKey((k) => k + 1)
+ setShowNewEntry(false)
+ setCopyPrefill(null)
+ }}
+ copyPrefill={copyPrefill}
+ isLoading={isLoadingCopy}
+ />
)
}
diff --git a/app/api/bookkeeping/no-doc-required/batch/__tests__/route.test.ts b/app/api/bookkeeping/no-doc-required/batch/__tests__/route.test.ts
new file mode 100644
index 00000000..01e83d2f
--- /dev/null
+++ b/app/api/bookkeeping/no-doc-required/batch/__tests__/route.test.ts
@@ -0,0 +1,84 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { parseJsonResponse, createQueuedMockSupabase } from '@/tests/helpers'
+import { NextResponse } from 'next/server'
+
+const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
+
+vi.mock('@/lib/auth/require-auth', () => ({ requireAuth: vi.fn() }))
+vi.mock('@/lib/company/context', () => ({ getActiveCompanyId: vi.fn() }))
+vi.mock('@/lib/auth/require-write', () => ({ requireWritePermission: vi.fn() }))
+
+import { POST } from '../route'
+import { requireAuth } from '@/lib/auth/require-auth'
+import { getActiveCompanyId } from '@/lib/company/context'
+import { requireWritePermission } from '@/lib/auth/require-write'
+
+const mockUser = { id: 'user-1', email: 't@t.se' }
+const UUID_A = '11111111-1111-4111-8111-111111111111'
+const UUID_B = '22222222-2222-4222-8222-222222222222'
+
+function makeReq(body: unknown) {
+ return new Request('http://localhost/api/bookkeeping/no-doc-required/batch', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(body),
+ })
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ reset()
+ ;(requireAuth as ReturnType).mockResolvedValue({ user: mockUser, supabase: mockSupabase })
+ ;(getActiveCompanyId as ReturnType).mockResolvedValue('company-1')
+ ;(requireWritePermission as ReturnType).mockResolvedValue({ ok: true })
+})
+
+describe('POST /api/bookkeeping/no-doc-required/batch', () => {
+ it('returns 401 when not authenticated', async () => {
+ ;(requireAuth as ReturnType).mockResolvedValue({
+ error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
+ })
+ const res = await POST(makeReq({ journal_entry_ids: [UUID_A] }))
+ const { status } = await parseJsonResponse(res)
+ expect(status).toBe(401)
+ })
+
+ it('returns 403 for read-only members', async () => {
+ ;(requireWritePermission as ReturnType).mockResolvedValue({
+ ok: false,
+ response: NextResponse.json({ error: 'forbidden' }, { status: 403 }),
+ })
+ const res = await POST(makeReq({ journal_entry_ids: [UUID_A] }))
+ const { status } = await parseJsonResponse(res)
+ expect(status).toBe(403)
+ })
+
+ it('returns 400 for an empty id list', async () => {
+ const res = await POST(makeReq({ journal_entry_ids: [] }))
+ const { status } = await parseJsonResponse(res)
+ expect(status).toBe(400)
+ })
+
+ it('returns 400 for non-uuid ids', async () => {
+ const res = await POST(makeReq({ journal_entry_ids: ['not-a-uuid'] }))
+ const { status } = await parseJsonResponse(res)
+ expect(status).toBe(400)
+ })
+
+ it('exempts only owned, posted entries (defense in depth)', async () => {
+ enqueue({ data: [{ id: UUID_A }], error: null }) // ownership query: only A owned
+ enqueue({ error: null }) // helper upsert
+ const res = await POST(makeReq({ journal_entry_ids: [UUID_A, UUID_B], reason: 'Importerad' }))
+ const { status, body } = await parseJsonResponse<{ data: { exempted: number } }>(res)
+ expect(status).toBe(200)
+ expect(body.data.exempted).toBe(1)
+ })
+
+ it('returns exempted:0 without writing when no ids are owned', async () => {
+ enqueue({ data: [], error: null }) // ownership query → none owned
+ const res = await POST(makeReq({ journal_entry_ids: [UUID_A] }))
+ const { status, body } = await parseJsonResponse<{ data: { exempted: number } }>(res)
+ expect(status).toBe(200)
+ expect(body.data.exempted).toBe(0)
+ })
+})
diff --git a/app/api/bookkeeping/no-doc-required/batch/route.ts b/app/api/bookkeeping/no-doc-required/batch/route.ts
new file mode 100644
index 00000000..b73da674
--- /dev/null
+++ b/app/api/bookkeeping/no-doc-required/batch/route.ts
@@ -0,0 +1,63 @@
+import { NextResponse } from 'next/server'
+import { z } from 'zod'
+import { withRouteContext } from '@/lib/api/with-route-context'
+import { validateBody } from '@/lib/api/validate'
+import { markEntriesNoDocRequired } from '@/lib/bookkeeping/no-doc-required'
+import { NEEDS_DOC_SOURCE_TYPES } from '@/lib/worklist/categories'
+
+const BatchNoDocSchema = z.object({
+ journal_entry_ids: z.array(z.string().uuid()).min(1).max(500),
+ reason: z.string().trim().max(200).nullable().optional(),
+})
+
+/**
+ * Batch-mark posted verifikationer as "Inget underlag krävs". Lets the user
+ * clear many entries (e.g. historical SIE imports) out of "Att hantera: saknade
+ * underlag" in one action instead of toggling each one.
+ *
+ * The exemption is shared bookkeeping metadata (company-scoped, like mapping
+ * rules) — the audit_log trigger records the actor.
+ */
+export const POST = withRouteContext(
+ 'journal_entry.batch_no_document_required',
+ async (request, { supabase, companyId, user }) => {
+ const validation = await validateBody(request, BatchNoDocSchema)
+ if (!validation.success) return validation.response
+
+ const { journal_entry_ids, reason } = validation.data
+
+ // Defense in depth: only exempt posted entries that belong to this company.
+ // Validate ownership in chunks so the PostgREST `in()` URL stays bounded.
+ const ownedIds: string[] = []
+ for (let i = 0; i < journal_entry_ids.length; i += 200) {
+ const chunk = journal_entry_ids.slice(i, i + 200)
+ const { data, error } = await supabase
+ .from('journal_entries')
+ .select('id')
+ .eq('company_id', companyId)
+ .eq('status', 'posted')
+ .in('source_type', [...NEEDS_DOC_SOURCE_TYPES])
+ .in('id', chunk)
+
+ if (error) {
+ return NextResponse.json({ error: error.message }, { status: 400 })
+ }
+ ownedIds.push(...(data ?? []).map((r) => r.id))
+ }
+
+ if (ownedIds.length === 0) {
+ return NextResponse.json({ data: { exempted: 0 } })
+ }
+
+ const exempted = await markEntriesNoDocRequired(
+ supabase,
+ companyId,
+ user.id,
+ ownedIds,
+ reason ?? null,
+ )
+
+ return NextResponse.json({ data: { exempted } })
+ },
+ { requireWrite: true },
+)
diff --git a/app/api/bookkeeping/no-doc-required/bulk-missing/__tests__/route.test.ts b/app/api/bookkeeping/no-doc-required/bulk-missing/__tests__/route.test.ts
new file mode 100644
index 00000000..c4d08a08
--- /dev/null
+++ b/app/api/bookkeeping/no-doc-required/bulk-missing/__tests__/route.test.ts
@@ -0,0 +1,95 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { parseJsonResponse, createQueuedMockSupabase } from '@/tests/helpers'
+import { NextResponse } from 'next/server'
+
+const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
+
+vi.mock('@/lib/auth/require-auth', () => ({ requireAuth: vi.fn() }))
+vi.mock('@/lib/company/context', () => ({ getActiveCompanyId: vi.fn() }))
+vi.mock('@/lib/auth/require-write', () => ({ requireWritePermission: vi.fn() }))
+
+import { POST } from '../route'
+import { requireAuth } from '@/lib/auth/require-auth'
+import { getActiveCompanyId } from '@/lib/company/context'
+import { requireWritePermission } from '@/lib/auth/require-write'
+
+const mockUser = { id: 'user-1', email: 't@t.se' }
+
+function makeReq(body: unknown) {
+ return new Request('http://localhost/api/bookkeeping/no-doc-required/bulk-missing', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(body),
+ })
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ reset()
+ ;(requireAuth as ReturnType).mockResolvedValue({ user: mockUser, supabase: mockSupabase })
+ ;(getActiveCompanyId as ReturnType).mockResolvedValue('company-1')
+ ;(requireWritePermission as ReturnType).mockResolvedValue({ ok: true })
+})
+
+describe('POST /api/bookkeeping/no-doc-required/bulk-missing', () => {
+ it('returns 401 when not authenticated', async () => {
+ ;(requireAuth as ReturnType).mockResolvedValue({
+ error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
+ })
+ const res = await POST(makeReq({}))
+ expect((await parseJsonResponse(res)).status).toBe(401)
+ })
+
+ it('returns 403 for read-only members', async () => {
+ ;(requireWritePermission as ReturnType).mockResolvedValue({
+ ok: false,
+ response: NextResponse.json({ error: 'forbidden' }, { status: 403 }),
+ })
+ const res = await POST(makeReq({}))
+ expect((await parseJsonResponse(res)).status).toBe(403)
+ })
+
+ it('returns 400 for a non-uuid period_id', async () => {
+ const res = await POST(makeReq({ period_id: 'not-a-uuid' }))
+ expect((await parseJsonResponse(res)).status).toBe(400)
+ })
+
+ it('returns 400 for a shaped-but-invalid date', async () => {
+ const res = await POST(makeReq({ date_from: '9999-99-99' }))
+ expect((await parseJsonResponse(res)).status).toBe(400)
+ })
+
+ it('returns 400 for an invalid series filter', async () => {
+ const res = await POST(makeReq({ series: 'all' }))
+ expect((await parseJsonResponse(res)).status).toBe(400)
+ })
+
+ it('dry_run counts only entries that are missing AND not exempt', async () => {
+ enqueue({ data: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], error: null }) // candidates
+ enqueue({ data: [{ journal_entry_id: 'a' }], error: null }) // a has a document
+ enqueue({ data: [{ journal_entry_id: 'b' }], error: null }) // b already exempt
+ const res = await POST(makeReq({ dry_run: true }))
+ const { status, body } = await parseJsonResponse<{ data: { count: number } }>(res)
+ expect(status).toBe(200)
+ expect(body.data.count).toBe(1) // only c
+ })
+
+ it('marks the missing entries and returns the count', async () => {
+ enqueue({ data: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], error: null }) // candidates
+ enqueue({ data: [], error: null }) // no documents
+ enqueue({ data: [{ journal_entry_id: 'a' }], error: null }) // a already exempt
+ enqueue({ error: null }) // helper upsert
+ const res = await POST(makeReq({ period_id: null, reason: 'Importerad' }))
+ const { status, body } = await parseJsonResponse<{ data: { exempted: number } }>(res)
+ expect(status).toBe(200)
+ expect(body.data.exempted).toBe(2) // b and c
+ })
+
+ it('short-circuits to 0 when no candidates match the filters', async () => {
+ enqueue({ data: [], error: null }) // no candidates
+ const res = await POST(makeReq({ dry_run: true }))
+ const { status, body } = await parseJsonResponse<{ data: { count: number } }>(res)
+ expect(status).toBe(200)
+ expect(body.data.count).toBe(0)
+ })
+})
diff --git a/app/api/bookkeeping/no-doc-required/bulk-missing/route.ts b/app/api/bookkeeping/no-doc-required/bulk-missing/route.ts
new file mode 100644
index 00000000..ddebedb0
--- /dev/null
+++ b/app/api/bookkeeping/no-doc-required/bulk-missing/route.ts
@@ -0,0 +1,143 @@
+import { NextResponse } from 'next/server'
+import { z } from 'zod'
+import { withRouteContext } from '@/lib/api/with-route-context'
+import { validateBody } from '@/lib/api/validate'
+import { fetchAllRows } from '@/lib/supabase/fetch-all'
+import { markEntriesNoDocRequired } from '@/lib/bookkeeping/no-doc-required'
+import { NEEDS_DOC_SOURCE_TYPES } from '@/lib/worklist/categories'
+import { escapeLikePattern } from '@/lib/invoices/duplicate-payment-guard'
+
+// A real calendar date in YYYY-MM-DD form. Rejects shaped-but-invalid values
+// (e.g. 9999-99-99 or 2026-02-30) that a bare /^\d{4}-\d{2}-\d{2}$/ regex would
+// let through and that would otherwise reach the query layer.
+const isoDate = z.string().refine(
+ (v) => {
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(v)) return false
+ const [y, m, d] = v.split('-').map(Number)
+ const date = new Date(Date.UTC(y, m - 1, d))
+ return (
+ date.getUTCFullYear() === y &&
+ date.getUTCMonth() + 1 === m &&
+ date.getUTCDate() === d
+ )
+ },
+ { message: 'Ogiltigt datum (förväntat YYYY-MM-DD)' },
+)
+
+const BulkMissingSchema = z.object({
+ period_id: z.string().uuid().nullable().optional(),
+ // Single uppercase verifikationsserie (A–Z); the list sends null for "all".
+ series: z.string().regex(/^[A-Z]$/).nullable().optional(),
+ date_from: isoDate.nullable().optional(),
+ date_to: isoDate.nullable().optional(),
+ search: z.string().max(200).nullable().optional(),
+ reason: z.string().trim().max(200).nullable().optional(),
+ // When true, only count the matching verifikat (no writes) so the UI can
+ // confirm the scope before the user commits.
+ dry_run: z.boolean().optional(),
+})
+
+/**
+ * Mark every posted, document-requiring verifikat that currently lacks an
+ * underlag AND matches the active list filters (period / series / date / search)
+ * as "Inget underlag krävs" — across all pages, in one action. This is the
+ * scalable remedy for the "thousands of saknade underlag after a migration"
+ * problem; the per-entry batch route handles selective marking.
+ *
+ * The missing-doc predicate mirrors countVerifikatMissingDocument: posted +
+ * NEEDS_DOC source type, no current-version document_attachment, not already
+ * exempt.
+ */
+export const POST = withRouteContext(
+ 'journal_entry.bulk_missing_no_document_required',
+ async (request, { supabase, companyId, user }) => {
+ const validation = await validateBody(request, BulkMissingSchema)
+ if (!validation.success) return validation.response
+
+ // All formats are enforced by the schema above, so these are already valid
+ // (or null). No re-validation needed before they reach the query layer.
+ const { period_id, reason, dry_run } = validation.data
+ const series = validation.data.series ?? null
+ const dateFrom = validation.data.date_from ?? null
+ const dateTo = validation.data.date_to ?? null
+ const search = validation.data.search?.trim() || null
+
+ // Candidate entries: posted, document-requiring, matching the active filters.
+ const candidates = await fetchAllRows<{ id: string }>(({ from, to }) => {
+ let q = supabase
+ .from('journal_entries')
+ .select('id')
+ .eq('company_id', companyId)
+ .eq('status', 'posted')
+ .in('source_type', [...NEEDS_DOC_SOURCE_TYPES])
+ if (period_id) q = q.eq('fiscal_period_id', period_id)
+ if (series) q = q.eq('voucher_series', series)
+ if (dateFrom) q = q.gte('entry_date', dateFrom)
+ if (dateTo) q = q.lte('entry_date', dateTo)
+ if (search) q = q.ilike('description', `%${escapeLikePattern(search)}%`)
+ return q.order('id').range(from, to)
+ })
+
+ if (candidates.length === 0) {
+ return NextResponse.json({ data: dry_run ? { count: 0 } : { exempted: 0 } })
+ }
+
+ // Resolve which candidates already have a document or an exemption by
+ // querying ONLY for the candidate ids (chunked), rather than loading the
+ // company's full document_attachments + journal_entry_no_doc_required tables
+ // into memory. Data minimisation + bounded memory for large migrations.
+ const candidateIds = candidates.map((e) => e.id)
+ const withDoc = new Set()
+ const exempt = new Set()
+ const LOOKUP_CHUNK = 300
+ for (let i = 0; i < candidateIds.length; i += LOOKUP_CHUNK) {
+ const chunk = candidateIds.slice(i, i + LOOKUP_CHUNK)
+ const [docRes, exemptRes] = await Promise.all([
+ supabase
+ .from('document_attachments')
+ .select('journal_entry_id')
+ .eq('company_id', companyId)
+ .eq('is_current_version', true)
+ .in('journal_entry_id', chunk),
+ supabase
+ .from('journal_entry_no_doc_required')
+ .select('journal_entry_id')
+ .eq('company_id', companyId)
+ .in('journal_entry_id', chunk),
+ ])
+ if (docRes.error) {
+ return NextResponse.json({ error: docRes.error.message }, { status: 400 })
+ }
+ if (exemptRes.error) {
+ return NextResponse.json({ error: exemptRes.error.message }, { status: 400 })
+ }
+ for (const r of (docRes.data ?? []) as { journal_entry_id: string }[]) {
+ withDoc.add(r.journal_entry_id)
+ }
+ for (const r of (exemptRes.data ?? []) as { journal_entry_id: string }[]) {
+ exempt.add(r.journal_entry_id)
+ }
+ }
+
+ const missingIds = candidateIds.filter((id) => !withDoc.has(id) && !exempt.has(id))
+
+ if (dry_run) {
+ return NextResponse.json({ data: { count: missingIds.length } })
+ }
+
+ if (missingIds.length === 0) {
+ return NextResponse.json({ data: { exempted: 0 } })
+ }
+
+ const exempted = await markEntriesNoDocRequired(
+ supabase,
+ companyId,
+ user.id,
+ missingIds,
+ reason ?? null,
+ )
+
+ return NextResponse.json({ data: { exempted } })
+ },
+ { requireWrite: true },
+)
diff --git a/app/api/import/sie/execute/route.ts b/app/api/import/sie/execute/route.ts
index ea1af70a..9520bcd7 100644
--- a/app/api/import/sie/execute/route.ts
+++ b/app/api/import/sie/execute/route.ts
@@ -109,6 +109,7 @@ export const POST = withRouteContext(
importTransactions: options.importTransactions,
voucherSeries: options.voucherSeries || companyDefaultSeries,
updateAccountNames: options.updateAccountNames ?? true,
+ markImportedNoDocRequired: options.markImportedNoDocRequired ?? false,
},
)
diff --git a/components/bookkeeping/JournalEntryForm.tsx b/components/bookkeeping/JournalEntryForm.tsx
index 88765d77..b53b91fd 100644
--- a/components/bookkeeping/JournalEntryForm.tsx
+++ b/components/bookkeeping/JournalEntryForm.tsx
@@ -63,6 +63,9 @@ interface Props {
sourceId?: string
submitUrl?: string
embedded?: boolean
+ /** Render without the Card chrome (e.g. inside a dialog) but keep the full
+ * non-embedded field set (series, notes, documents, voucher hint). */
+ bare?: boolean
}
const BLANK_LINE: FormLine = { account_number: '', debit_amount: '', credit_amount: '', line_description: '' }
@@ -78,6 +81,7 @@ export default function JournalEntryForm({
sourceId,
submitUrl,
embedded,
+ bare,
}: Props) {
const { canWrite } = useCanWrite()
const { toast } = useToast()
@@ -89,6 +93,7 @@ export default function JournalEntryForm({
const [entryDate, setEntryDate] = useState(initialDate ?? new Date().toISOString().split('T')[0])
const [description, setDescription] = useState(initialDescription ?? '')
const [notes, setNotes] = useState(initialNotes ?? '')
+ const [showNotes, setShowNotes] = useState(false)
const [lines, setLines] = useState(
initialLines ?? [{ ...BLANK_LINE }, { ...BLANK_LINE }]
)
@@ -341,6 +346,11 @@ export default function JournalEntryForm({
const account = accounts.find((a) => a.account_number === value)
if (account) {
updated[index].line_description = account.account_name
+ // Fortnox-style: seed the verifikationstext from the first row's account
+ // when the user hasn't typed one yet. Non-destructive — never overwrites.
+ if (index === 0 && !description.trim()) {
+ setDescription(account.account_name)
+ }
}
}
@@ -493,7 +503,7 @@ export default function JournalEntryForm({
const handleReview = () => {
if (!selectedPeriod || !description || !isBalanced || periodMismatch) return
const hasDocuments = uploadedFiles.some((f) => f.status === 'uploaded')
- if (!embedded && !hasDocuments) {
+ if (!embedded && !bare && !hasDocuments) {
setShowNoDocWarning(true)
return
}
@@ -668,125 +678,190 @@ export default function JournalEntryForm({
}
}
- const formContent = (
+ // Inline review for the modal (bare): swap the form body to a read-only
+ // summary instead of stacking a second dialog over the form dialog. The
+ // no-underlag caveat folds in here so there's a single confirm step.
+ const reviewPanel = (
-
-
- {t('fiscal_year')}
-
-
-
-
-
- {periods.map((p) => (
-
- {p.name}
-
- ))}
-
-
-
- {!(embedded && initialDate) && (
-
- {t('date')}
- setEntryDate(e.target.value)}
- />
-
- )}
-
- {t('description')}
- setDescription(e.target.value)}
- placeholder={t('description_placeholder')}
- />
-
-
- {t('internal_note')} {t('internal_note_optional')}
-
- {!embedded && (
-
- {t('series')}
- {
- const v = e.target.value.toUpperCase().replace(/[^A-Z]/g, '').slice(-1)
- setVoucherSeries(v)
- }}
- onFocus={(e) => {
- const target = e.target
- setTimeout(() => target.select(), 0)
- }}
- onBlur={() => {
- if (!voucherSeries) setVoucherSeries('A')
- }}
- className="mt-1 text-center font-mono"
- maxLength={1}
- />
-
- )}
+
+ setShowReview(false)}
+ className="text-sm text-muted-foreground hover:text-foreground transition-colors"
+ >
+ ← {t('review_back')}
+
+
+ {nextVoucherNumber != null
+ ? t('review_title_with_voucher', { voucher: formatVoucher({ voucher_series: voucherSeries, voucher_number: nextVoucherNumber }) })
+ : t('review_title')}
+
- {/* Period mismatch warning */}
- {periodMismatch === 'no_period' && (
+ {(monthChanged || selectedPeriodLocked) && (
-
-
{t('no_period_warning', { date: entryDate })}
-
{t('no_period_help')}
+
+ {monthChanged && (
+
+ {t('review_month_changed', { prev: monthLabel(lastPostedMonth as string), current: monthLabel(entryMonth) })}
+
+ )}
+ {selectedPeriodLocked &&
{t('review_period_locked')}
}
-
setShowCreatePeriod(true)}
- className="shrink-0"
- >
-
- {t('create_period')}
-
)}
- {/* Currency section */}
-
-
-
{t('currency')}
-
{
- setEntryCurrency(v as Currency)
- if (v === 'SEK') {
- setExchangeRate('')
- setForeignAmount('')
- }
- }}>
-
-
-
-
- {CURRENCIES.map((c) => (
- {c.label}
- ))}
-
-
+ {uploadedFiles.filter((f) => f.status === 'uploaded').length === 0 && (
+
+ )}
+
+
p.id === selectedPeriod)?.name || ''}
+ entryDate={entryDate}
+ description={description}
+ notes={notes || undefined}
+ voucherSeries={voucherSeries}
+ lines={lines}
+ totalDebit={totalDebit}
+ totalCredit={totalCredit}
+ attachmentCount={uploadedFiles.filter((f) => f.status === 'uploaded').length}
+ showBalanceBadge
+ hideDate={false}
+ />
+
+
+ setShowReview(false)} disabled={isSubmitting}>
+ {t('review_back')}
+
+
+ {isSubmitting && }
+ {/* No underlag attached → explicit acknowledgement, equivalent to the
+ blocking "Bokför utan underlag" dialog in the non-bare flow (BFL
+ 5 kap 6-7 §§). With a document it's the normal create label. */}
+ {uploadedFiles.some((f) => f.status === 'uploaded')
+ ? t('review_confirm')
+ : t('no_doc_confirm')}
+
+
+
+ )
+
+ const formContent = (
+
+ {bare && showReview ? reviewPanel : (
+ <>
+ {/* Verifikat metadata — compact bar on top (Fortnox-style). Date, series
+ and period are pre-filled; the period derives from the date. The
+ konteringsrader below are the focus. */}
+
+
+
+
+ {embedded ? (
+
+ {t('fiscal_year')}
+
+
+
+
+
+ {periods.map((p) => (
+ {p.name}
+ ))}
+
+
+
+ ) : (
+ selectedPeriodObj && (
+
+ {t('fiscal_year')}:{' '}
+ {selectedPeriodObj.name}
+ {nextVoucherNumber != null && (
+
+ {voucherSeries}{nextVoucherNumber}
+
+ )}
+
+ )
+ )}
+
+ {t('currency')}
+ {
+ setEntryCurrency(v as Currency)
+ if (v === 'SEK') {
+ setExchangeRate('')
+ setForeignAmount('')
+ }
+ }}>
+
+
+
+
+ {CURRENCIES.map((c) => (
+ {c.label}
+ ))}
+
+
+
+ {!embedded && !showNotes && !notes && (
+
setShowNotes(true)}
+ className="text-muted-foreground hover:text-foreground transition-colors"
+ >
+ + {t('internal_note')}
+
+ )}
+
+
{isForeign && (
- <>
+
{t('exchange_rate_label', { currency: entryCurrency })}
@@ -825,7 +900,43 @@ export default function JournalEntryForm({
{computedForeignAmount.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} {entryCurrency} × {rate.toLocaleString('sv-SE', { minimumFractionDigits: 4 })} = {computedSekAmount.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} SEK
)}
- >
+
+ )}
+
+ {!embedded && (showNotes || notes) && (
+
+
+ {t('internal_note')}{' '}
+ {t('internal_note_optional')}
+
+
+ )}
+
+ {periodMismatch === 'no_period' && (
+
+
+
+
{t('no_period_warning', { date: entryDate })}
+
{t('no_period_help')}
+
+
setShowCreatePeriod(true)}
+ className="shrink-0"
+ >
+
+ {t('create_period')}
+
+
)}
@@ -1127,6 +1238,8 @@ export default function JournalEntryForm({
)}
+ >
+ )}
{
setShowNoDocWarning(false)
@@ -1250,7 +1363,7 @@ export default function JournalEntryForm({
)
- if (embedded) {
+ if (embedded || bare) {
return formContent
}
diff --git a/components/bookkeeping/JournalEntryList.tsx b/components/bookkeeping/JournalEntryList.tsx
index 38126aa3..437d5ede 100644
--- a/components/bookkeeping/JournalEntryList.tsx
+++ b/components/bookkeeping/JournalEntryList.tsx
@@ -10,6 +10,8 @@ import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
+import { Checkbox } from '@/components/ui/checkbox'
+import { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell } from '@/components/ui/table'
import {
Dialog,
DialogContent,
@@ -25,7 +27,7 @@ import {
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 { formatDate, formatCurrency } from '@/lib/utils'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import { Input } from '@/components/ui/input'
import { AccountNumber } from '@/components/ui/account-number'
@@ -72,6 +74,13 @@ export default function JournalEntryList() {
const [attachmentCounts, setAttachmentCounts] = useState
>({})
const [noDocRequired, setNoDocRequired] = useState>(new Map())
const [showMissingOnly, setShowMissingOnly] = useState(false)
+ const [selectedIds, setSelectedIds] = useState>(new Set())
+ const [batchReason, setBatchReason] = useState('')
+ const [batchSubmitting, setBatchSubmitting] = useState(false)
+ const [bulkOpen, setBulkOpen] = useState(false)
+ const [bulkCount, setBulkCount] = useState(null)
+ const [bulkReason, setBulkReason] = useState('')
+ const [bulkSubmitting, setBulkSubmitting] = useState(false)
const [correctionEntry, setCorrectionEntry] = useState(null)
const [previewEntryId, setPreviewEntryId] = useState(null)
const [sortBy, setSortBy] = useState('date_desc')
@@ -230,6 +239,7 @@ export default function JournalEntryList() {
async function fetchEntries() {
setLoading(true)
+ setSelectedIds(new Set()) // selection is page-scoped — reset on reload
const params = new URLSearchParams({
limit: String(pageSize),
offset: String(page * pageSize),
@@ -292,6 +302,120 @@ export default function JournalEntryList() {
}
}
+ // A posted, document-requiring entry with no attachment yet and not already
+ // exempt — i.e. the rows that show the warning triangle. Only these can be
+ // batch-marked "Inget underlag krävs".
+ const isEligibleForExempt = useCallback(
+ (entry: JournalEntry) =>
+ entry.status === 'posted' &&
+ NEEDS_ATTACHMENT.has(entry.source_type) &&
+ !attachmentCounts[entry.id] &&
+ !noDocRequired.has(entry.id),
+ [attachmentCounts, noDocRequired],
+ )
+
+ const toggleSelect = (id: string) => {
+ setSelectedIds((prev) => {
+ const next = new Set(prev)
+ if (next.has(id)) next.delete(id)
+ else next.add(id)
+ return next
+ })
+ }
+
+ const handleBatchExempt = async () => {
+ const ids = Array.from(selectedIds)
+ if (ids.length === 0) return
+ setBatchSubmitting(true)
+ const reason = batchReason.trim() || null
+ try {
+ const res = await fetch('/api/bookkeeping/no-doc-required/batch', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ journal_entry_ids: ids, reason }),
+ })
+ const body = await res.json().catch(() => ({}))
+ if (!res.ok) {
+ toast({ title: t('no_doc_required_save_failed'), description: body.error, variant: 'destructive' })
+ return
+ }
+ // Reflect the new exemptions locally: triangle → muted "no doc" indicator.
+ setNoDocRequired((prev) => {
+ const next = new Map(prev)
+ for (const id of ids) next.set(id, reason)
+ return next
+ })
+ setSelectedIds(new Set())
+ setBatchReason('')
+ toast({
+ title: t('batch_no_doc_done_title'),
+ description: t('batch_no_doc_done_description', { count: body.data?.exempted ?? ids.length }),
+ })
+ } catch {
+ toast({ title: t('no_doc_required_save_failed'), variant: 'destructive' })
+ } finally {
+ setBatchSubmitting(false)
+ }
+ }
+
+ // Filter-scoped bulk mark: mark EVERY missing-doc verifikat matching the active
+ // filters (period/series/date/search), across all pages — the scalable remedy
+ // for a post-import flood. A dry_run first surfaces the exact count to confirm.
+ const filterPayload = () => ({
+ period_id: periodId,
+ series: seriesFilter !== 'all' ? seriesFilter : null,
+ date_from: dateFrom || null,
+ date_to: dateTo || null,
+ search: search || null,
+ })
+
+ const openBulk = async () => {
+ setBulkOpen(true)
+ setBulkCount(null)
+ setBulkReason('')
+ try {
+ const res = await fetch('/api/bookkeeping/no-doc-required/bulk-missing', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ ...filterPayload(), dry_run: true }),
+ })
+ const body = await res.json().catch(() => ({}))
+ setBulkCount(res.ok ? (body.data?.count ?? 0) : 0)
+ } catch {
+ setBulkCount(0)
+ }
+ }
+
+ const handleBulkConfirm = async () => {
+ setBulkSubmitting(true)
+ try {
+ const res = await fetch('/api/bookkeeping/no-doc-required/bulk-missing', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ ...filterPayload(), reason: bulkReason.trim() || null }),
+ })
+ const body = await res.json().catch(() => ({}))
+ if (!res.ok) {
+ toast({ title: t('no_doc_required_save_failed'), description: body.error, variant: 'destructive' })
+ return
+ }
+ setBulkOpen(false)
+ setBulkCount(null)
+ setBulkReason('')
+ setSelectedIds(new Set())
+ toast({
+ title: t('batch_no_doc_done_title'),
+ description: t('batch_no_doc_done_description', { count: body.data?.exempted ?? 0 }),
+ })
+ await fetchNoDocRequired()
+ await fetchEntries()
+ } catch {
+ toast({ title: t('no_doc_required_save_failed'), variant: 'destructive' })
+ } finally {
+ setBulkSubmitting(false)
+ }
+ }
+
const filteredEntries = showMissingOnly
? entries.filter(
(e) =>
@@ -348,6 +472,22 @@ export default function JournalEntryList() {
setPage(0)
}
+ // Rows on this page the user can batch-mark "Inget underlag krävs".
+ const eligibleEntries = canWrite ? filteredEntries.filter(isEligibleForExempt) : []
+ const allEligibleSelected =
+ eligibleEntries.length > 0 && eligibleEntries.every((e) => selectedIds.has(e.id))
+ const toggleSelectAll = () => {
+ setSelectedIds((prev) => {
+ const next = new Set(prev)
+ if (allEligibleSelected) {
+ for (const e of eligibleEntries) next.delete(e.id)
+ } else {
+ for (const e of eligibleEntries) next.add(e.id)
+ }
+ return next
+ })
+ }
+
if (!loading && entries.length === 0 && !hasActiveFilters) {
return (
@@ -578,6 +718,66 @@ export default function JournalEntryList() {
)}
+ {/* Batch-mark "Inget underlag krävs": select-all + contextual action bar */}
+ {(eligibleEntries.length > 0 || selectedIds.size > 0) && (
+
+
+
+
+ {selectedIds.size > 0
+ ? t('batch_selected_count', { count: selectedIds.size })
+ : t('batch_select_all', { count: eligibleEntries.length })}
+
+
+ {selectedIds.size > 0 ? (
+
+
setBatchReason(e.target.value)}
+ placeholder={t('no_doc_required_reason_placeholder')}
+ list="batch-no-doc-suggestions"
+ maxLength={200}
+ className="h-8 text-xs sm:w-56"
+ disabled={batchSubmitting}
+ />
+
+
+
+
+
+
+
+
+
+ {batchSubmitting && }
+ {t('batch_mark_no_doc')}
+
+ setSelectedIds(new Set())}
+ disabled={batchSubmitting}
+ >
+ {t('batch_clear_selection')}
+
+
+
+ ) : (
+ // Filter-scoped: mark every missing-doc verifikat matching the active
+ // filters across all pages — scales to a post-import flood.
+
+
+ {t('batch_mark_all_missing')}
+
+ )}
+
+ )}
+
{loading ? (
@@ -602,13 +802,26 @@ export default function JournalEntryList() {
{filteredEntries.map((entry) => {
const isExpanded = expandedId === entry.id
const lines = (entry.lines || []) as JournalEntryLine[]
+ // Voucher total = sum of the debit side (= credit side when balanced).
+ const voucherTotal = lines.reduce((sum, l) => sum + (Number(l.debit_amount) || 0), 0)
+ const selectable = canWrite && isEligibleForExempt(entry)
return (
+
+ {selectable && (
+
e.stopPropagation()}>
+ toggleSelect(entry.id)}
+ aria-label={t('batch_select_row')}
+ />
+
+ )}
toggleExpand(entry.id)}
aria-expanded={isExpanded}
- className="w-full p-4 text-left hover:bg-muted/50 transition-colors min-h-[44px]"
+ className="flex-1 min-w-0 p-4 text-left hover:bg-muted/50 transition-colors min-h-[44px]"
>
{/* Desktop: single row */}
@@ -640,6 +853,9 @@ export default function JournalEntryList() {
)}
{entry.description}
+
+ {formatCurrency(voucherTotal)}
+
- {entry.description}
+
+
{entry.description}
+
+ {formatCurrency(voucherTotal)}
+
+
+
{isExpanded && (
{lines.length === 0 ? (
{t('no_lines')}
) : (
- <>
-
- {lines
- .sort((a, b) => a.sort_order - b.sort_order)
- .map((line) => {
- const accountName = getAccountDescription(line.account_number)?.name
- const desc = line.line_description
- const showDesc = desc
- && desc.toLowerCase() !== accountName?.toLowerCase()
- && desc.toLowerCase() !== entry.description?.toLowerCase()
- return (
-
-
- {showDesc && (
-
{desc}
- )}
-
-
- {Number(line.debit_amount) > 0 ? t('debit') : t('credit')}
-
-
-
- {Number(line.debit_amount) > 0
- ? Number(line.debit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })
- : Number(line.credit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
-
- {line.currency && line.currency !== 'SEK' && line.amount_in_currency != null && (
-
- {Number(line.amount_in_currency).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} {line.currency}
-
- )}
-
-
-
- )
- })}
-
-
- {t('sum_debit')}
- {lines.reduce((sum, l) => sum + (Number(l.debit_amount) || 0), 0).toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
-
-
- {t('sum_credit')}
- {lines.reduce((sum, l) => sum + (Number(l.credit_amount) || 0), 0).toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
-
-
-
- >
+
+
+
+
+ {t('account_column')}
+ {t('description_column')}
+ {t('debit')}
+ {t('credit')}
+
+
+
+ {lines
+ .slice()
+ .sort((a, b) => a.sort_order - b.sort_order)
+ .map((line) => {
+ const accountName = getAccountDescription(line.account_number)?.name
+ const desc = line.line_description
+ const showDesc = desc
+ && desc.toLowerCase() !== accountName?.toLowerCase()
+ && desc.toLowerCase() !== entry.description?.toLowerCase()
+ const debit = Number(line.debit_amount) || 0
+ const credit = Number(line.credit_amount) || 0
+ const fx = line.currency && line.currency !== 'SEK' && line.amount_in_currency != null
+ ? `${Number(line.amount_in_currency).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} ${line.currency}`
+ : null
+ return (
+
+
+
+
+
+ {showDesc ? desc : ''}
+
+
+ {debit > 0 ? debit.toLocaleString('sv-SE', { minimumFractionDigits: 2 }) : ''}
+ {debit > 0 && fx && (
+ {fx}
+ )}
+
+
+ {credit > 0 ? credit.toLocaleString('sv-SE', { minimumFractionDigits: 2 }) : ''}
+ {credit > 0 && fx && (
+ {fx}
+ )}
+
+
+ )
+ })}
+
+
+
+ {t('sum_label')}
+
+ {lines.reduce((sum, l) => sum + (Number(l.debit_amount) || 0), 0).toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
+
+
+ {lines.reduce((sum, l) => sum + (Number(l.credit_amount) || 0), 0).toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
+
+
+
+
+
)}
{entry.notes && (
@@ -949,6 +1183,67 @@ export default function JournalEntryList() {
)}
+ {/* Filter-scoped bulk "Inget underlag krävs" confirmation */}
+ {
+ if (bulkSubmitting) return
+ setBulkOpen(o)
+ if (!o) {
+ setBulkCount(null)
+ setBulkReason('')
+ }
+ }}
+ >
+
+
+ {t('bulk_mark_title')}
+
+
+ {bulkCount === null ? (
+
+
+ {t('bulk_mark_counting')}
+
+ ) : bulkCount === 0 ? (
+
{t('bulk_mark_none')}
+ ) : (
+ <>
+
{t('bulk_mark_body', { count: bulkCount })}
+
+ {t('no_doc_required_reason_add')}
+ setBulkReason(e.target.value)}
+ placeholder={t('no_doc_required_reason_placeholder')}
+ list="bulk-no-doc-suggestions"
+ maxLength={200}
+ className="h-8 text-xs"
+ disabled={bulkSubmitting}
+ />
+
+
+
+
+
+
+
+
+ >
+ )}
+
+
+ setBulkOpen(false)} disabled={bulkSubmitting}>
+ {t('bulk_cancel')}
+
+
+ {bulkSubmitting && }
+ {t('bulk_mark_confirm', { count: bulkCount ?? 0 })}
+
+
+
+
+
{/* Correction dialog */}
{correctionEntry && (
void
+ /** Fired after a verifikat is created/saved as draft. */
+ onCreated: () => void
+ /** When set, the form is pre-filled from a copied verifikat. */
+ copyPrefill?: CopyPrefill | null
+ /** True while the copy source is being fetched. */
+ isLoading?: boolean
+}
+
+/**
+ * "Ny verifikat" as a modal — the dialog you type a manual voucher into,
+ * instead of an inline tab. Wraps the standalone JournalEntryForm; the form's
+ * own review/confirm dialogs stack on top of this one.
+ */
+export default function NewJournalEntryDialog({
+ open,
+ onOpenChange,
+ onCreated,
+ copyPrefill,
+ isLoading,
+}: Props) {
+ const t = useTranslations('bookkeeping')
+
+ return (
+
+
+
+ {t('new_entry_dialog_title')}
+
+
+ {isLoading ? (
+
+
+ {t('loading_source_voucher')}
+
+ ) : (
+ <>
+ {copyPrefill && (
+
+
+
+
+ {t('copy_banner_title', {
+ label: copyPrefill.sourceVoucherLabel || t('copy_banner_unknown_label'),
+ })}
+
+
{t('copy_banner_body')}
+
+
+ )}
+
+ >
+ )}
+
+
+ )
+}
diff --git a/components/import/ImportReviewStep.tsx b/components/import/ImportReviewStep.tsx
index 135bd8e9..bf1eb970 100644
--- a/components/import/ImportReviewStep.tsx
+++ b/components/import/ImportReviewStep.tsx
@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
+import { Badge } from '@/components/ui/badge'
import { Label } from '@/components/ui/label'
import {
Select,
@@ -45,6 +46,7 @@ export interface ImportExecuteOptions {
importTransactions: boolean
updateAccountNames: boolean
voucherSeries: string
+ markImportedNoDocRequired: boolean
}
export default function ImportReviewStep({
@@ -62,6 +64,7 @@ export default function ImportReviewStep({
importTransactions: true,
updateAccountNames: true,
voucherSeries: 'B',
+ markImportedNoDocRequired: false,
})
const [defaultSeries, setDefaultSeries] = useState(null)
const [existingSeries, setExistingSeries] = useState>(new Set())
@@ -146,6 +149,15 @@ export default function ImportReviewStep({
const mappedCount = mappings.filter((m) => m.targetAccount).length
const hasOpeningBalances = preview.openingBalanceTotal > 0
const hasTransactions = preview.voucherCount > 0
+ // An import whose fiscal year already ended in a prior calendar year is a
+ // historical/migration import — the underlag live in the old system, so the
+ // exemption is especially apt. Nudges (does not force) the toggle.
+ const isHistoricalImport = (() => {
+ if (!preview.fiscalYearEnd) return false
+ const end = new Date(preview.fiscalYearEnd)
+ const startOfThisYear = new Date(new Date().getFullYear(), 0, 1)
+ return !isNaN(end.getTime()) && end < startOfThisYear
+ })()
// Identity-mapped accounts whose #KONTO name differs from the BAS default —
// mirrors the filter in syncMappedAccounts, so the count matches what the
// import would actually rename/create with a custom name.
@@ -353,6 +365,33 @@ export default function ImportReviewStep({
)}
+
+ {/* No-underlag exemption — keeps a multi-year migration from flooding
+ "Att hantera: saknade underlag" with thousands of items. */}
+
+
+
+ Markera som "Inget underlag krävs"
+ {isHistoricalImport && (
+
+ Rekommenderas vid migrering
+
+ )}
+
+
+ Märker alla importerade verifikationer som att de inte behöver något
+ separat underlag — underlagen finns kvar i ditt tidigare system. Annars
+ hamnar de under "Att hantera: saknade underlag". Kan ändras per
+ verifikation efteråt.
+
+
+
updateOption('markImportedNoDocRequired', checked)}
+ disabled={!options.importTransactions || !hasTransactions}
+ />
+
diff --git a/lib/bookkeeping/__tests__/no-doc-required.test.ts b/lib/bookkeeping/__tests__/no-doc-required.test.ts
new file mode 100644
index 00000000..0fe3c21e
--- /dev/null
+++ b/lib/bookkeeping/__tests__/no-doc-required.test.ts
@@ -0,0 +1,60 @@
+import { describe, it, expect, vi } from 'vitest'
+import { markEntriesNoDocRequired } from '@/lib/bookkeeping/no-doc-required'
+import type { SupabaseClient } from '@supabase/supabase-js'
+
+function makeMock() {
+ const upsert = vi.fn().mockResolvedValue({ error: null })
+ const from = vi.fn().mockReturnValue({ upsert })
+ return { supabase: { from } as unknown as SupabaseClient, upsert, from }
+}
+
+describe('markEntriesNoDocRequired', () => {
+ it('writes nothing and returns 0 for an empty list', async () => {
+ const { supabase, from } = makeMock()
+ const n = await markEntriesNoDocRequired(supabase, 'c1', 'u1', [], null)
+ expect(n).toBe(0)
+ expect(from).not.toHaveBeenCalled()
+ })
+
+ it('inserts one chunk with the right rows, reason and conflict options', async () => {
+ const { supabase, upsert, from } = makeMock()
+ const n = await markEntriesNoDocRequired(supabase, 'c1', 'u1', ['a', 'b'], 'Importerad')
+ expect(n).toBe(2)
+ expect(from).toHaveBeenCalledWith('journal_entry_no_doc_required')
+ expect(upsert).toHaveBeenCalledTimes(1)
+ const [rows, opts] = upsert.mock.calls[0]
+ expect(rows).toEqual([
+ { journal_entry_id: 'a', company_id: 'c1', user_id: 'u1', reason: 'Importerad' },
+ { journal_entry_id: 'b', company_id: 'c1', user_id: 'u1', reason: 'Importerad' },
+ ])
+ expect(opts).toEqual({ onConflict: 'journal_entry_id', ignoreDuplicates: true })
+ })
+
+ it('de-dupes ids and defaults reason to null', async () => {
+ const { supabase, upsert } = makeMock()
+ const n = await markEntriesNoDocRequired(supabase, 'c1', 'u1', ['a', 'a', 'b'], null)
+ expect(n).toBe(2)
+ const [rows] = upsert.mock.calls[0]
+ expect(rows.map((r: { journal_entry_id: string }) => r.journal_entry_id)).toEqual(['a', 'b'])
+ expect(rows[0].reason).toBeNull()
+ })
+
+ it('chunks large id lists into batches of 500', async () => {
+ const { supabase, upsert } = makeMock()
+ const ids = Array.from({ length: 1200 }, (_, i) => `id-${i}`)
+ const n = await markEntriesNoDocRequired(supabase, 'c1', 'u1', ids, null)
+ expect(n).toBe(1200)
+ expect(upsert).toHaveBeenCalledTimes(3) // 500 + 500 + 200
+ expect(upsert.mock.calls[0][0]).toHaveLength(500)
+ expect(upsert.mock.calls[1][0]).toHaveLength(500)
+ expect(upsert.mock.calls[2][0]).toHaveLength(200)
+ })
+
+ it('throws when an upsert errors', async () => {
+ const upsert = vi.fn().mockResolvedValue({ error: { message: 'boom' } })
+ const supabase = { from: vi.fn().mockReturnValue({ upsert }) } as unknown as SupabaseClient
+ await expect(
+ markEntriesNoDocRequired(supabase, 'c1', 'u1', ['a'], null),
+ ).rejects.toThrow('boom')
+ })
+})
diff --git a/lib/bookkeeping/no-doc-required.ts b/lib/bookkeeping/no-doc-required.ts
new file mode 100644
index 00000000..13922ff1
--- /dev/null
+++ b/lib/bookkeeping/no-doc-required.ts
@@ -0,0 +1,50 @@
+import type { SupabaseClient } from '@supabase/supabase-js'
+
+const CHUNK_SIZE = 500
+
+/**
+ * Bulk-mark posted journal entries as "Inget underlag krävs" (no supporting
+ * document required) by inserting rows into journal_entry_no_doc_required.
+ *
+ * The flag lives in a sidecar table so the verifikation itself stays immutable
+ * per BFL — same write the single-entry route performs, just batched. Inserts
+ * are chunked (Postgres/PostgREST payload safety) and idempotent: rows that
+ * already exist are left untouched (`ignoreDuplicates`).
+ *
+ * The caller is responsible for passing only entry IDs that belong to
+ * `companyId` and are eligible (posted, document-requiring source type); RLS on
+ * the table is the security backstop. Used by the SIE-import opt-in auto-exempt
+ * flow and the batch-mark endpoint.
+ *
+ * @returns the number of entry IDs processed (deduped), not the number of new rows.
+ */
+export async function markEntriesNoDocRequired(
+ supabase: SupabaseClient,
+ companyId: string,
+ userId: string,
+ entryIds: string[],
+ reason: string | null,
+): Promise {
+ if (entryIds.length === 0) return 0
+
+ // De-dupe so a chunk can never carry the same id twice (ON CONFLICT target).
+ const uniqueIds = Array.from(new Set(entryIds))
+
+ for (let i = 0; i < uniqueIds.length; i += CHUNK_SIZE) {
+ const chunk = uniqueIds.slice(i, i + CHUNK_SIZE)
+ const rows = chunk.map((journal_entry_id) => ({
+ journal_entry_id,
+ company_id: companyId,
+ user_id: userId,
+ reason: reason ?? null,
+ }))
+
+ const { error } = await supabase
+ .from('journal_entry_no_doc_required')
+ .upsert(rows, { onConflict: 'journal_entry_id', ignoreDuplicates: true })
+
+ if (error) throw new Error(error.message)
+ }
+
+ return uniqueIds.length
+}
diff --git a/lib/import/sie-import.ts b/lib/import/sie-import.ts
index fab8bd9f..a925d81a 100644
--- a/lib/import/sie-import.ts
+++ b/lib/import/sie-import.ts
@@ -34,6 +34,7 @@ import { getBASReference } from '@/lib/bookkeeping/bas-reference'
import { classifyAccount } from '@/lib/bookkeeping/account-classifier'
import { computeSRUCode } from '@/lib/bookkeeping/bas-data/sru-mapping'
import { populateTemplatesFromSieVouchers } from '@/lib/bookkeeping/counterparty-templates'
+import { markEntriesNoDocRequired } from '@/lib/bookkeeping/no-doc-required'
import { parseDateParts } from '@/lib/bookkeeping/validate-period-duration'
/**
@@ -871,6 +872,10 @@ export async function importVouchers(
): Promise<{
created: number
ids: string[]
+ // Subset of `ids` whose entries were inserted with source_type='import' (i.e.
+ // excludes #VER vouchers re-tagged as opening_balance). Used to scope the
+ // opt-in "Inget underlag krävs" auto-exemption to genuinely migrated vouchers.
+ importTypedIds: string[]
errors: string[]
skippedEmpty: number
skippedSingleLine: number
@@ -898,6 +903,7 @@ export async function importVouchers(
const results = {
created: 0,
ids: [] as string[],
+ importTypedIds: [] as string[],
errors: [] as string[],
skippedEmpty: 0,
skippedSingleLine: 0,
@@ -1287,6 +1293,11 @@ export async function importVouchers(
})
results.ids.push(entryId)
+ // #VER vouchers re-tagged as opening_balance never need an underlag and
+ // aren't in NEEDS_DOC_SOURCE_TYPES, so keep them out of the exempt set.
+ if (voucher.sourceType === 'import') {
+ results.importTypedIds.push(entryId)
+ }
results.created++
}
@@ -1864,6 +1875,10 @@ export async function executeSIEImport(
voucherSeries?: string
onExistingPeriod?: 'block' | 'replace'
updateAccountNames?: boolean
+ // Opt-in: mark every imported (source_type='import') verifikat as "Inget
+ // underlag krävs" so a multi-year migration doesn't flood "Att hantera:
+ // saknade underlag" with thousands of items. OFF by default.
+ markImportedNoDocRequired?: boolean
}
): Promise {
const result: ImportResult = {
@@ -1878,6 +1893,11 @@ export async function executeSIEImport(
replacedPriorImport: null,
}
+ // Collected source_type='import' entry ids (vouchers + migration adjustment),
+ // used only when options.markImportedNoDocRequired is set. Kept separate from
+ // result.journalEntryIds because that also holds opening_balance entries.
+ const importTypedEntryIds: string[] = []
+
const onExistingPeriod = options.onExistingPeriod ?? 'block'
const updateAccountNames = options.updateAccountNames ?? true
@@ -2285,6 +2305,7 @@ export async function executeSIEImport(
result.journalEntriesCreated += voucherResults.created
result.journalEntryIds.push(...voucherResults.ids)
+ importTypedEntryIds.push(...voucherResults.importTypedIds)
result.errors.push(...voucherResults.errors)
voucherNumberMapping = voucherResults.voucherNumberMapping
voucherSeriesUsed = voucherResults.seriesUsed
@@ -2346,6 +2367,8 @@ export async function executeSIEImport(
if (adjustment.entryId) {
result.journalEntriesCreated++
result.journalEntryIds.push(adjustment.entryId)
+ // The omföringsverifikation is source_type='import' too.
+ importTypedEntryIds.push(adjustment.entryId)
result.warnings.push(
`Migreringsjustering skapad: ${adjustment.deltaAccounts} konton justerade för att matcha UB/RES från källsystemet`
)
@@ -2501,6 +2524,28 @@ export async function executeSIEImport(
}
}
+ // Opt-in: mark imported verifikat as "Inget underlag krävs" (non-blocking).
+ // Migrated vouchers carry their underlag in the source system, so the user
+ // can choose to keep all of them out of "Att hantera: saknade underlag" in
+ // one go instead of clearing thousands of items by hand. Data is already
+ // committed at this point, so a failure here only loses the convenience.
+ if (result.success && options.markImportedNoDocRequired && importTypedEntryIds.length > 0) {
+ try {
+ await markEntriesNoDocRequired(
+ supabase,
+ companyId,
+ userId,
+ importTypedEntryIds,
+ 'Importerad från tidigare system (SIE)',
+ )
+ } catch (exemptError) {
+ console.error('[sie-import] Failed to mark imported entries no-doc-required (non-fatal):', exemptError)
+ result.warnings.push(
+ 'Kunde inte markera importerade verifikat som "Inget underlag krävs" — du kan markera dem manuellt i bokföringslistan.',
+ )
+ }
+ }
+
// Add warnings for any issues
for (const issue of parsed.issues) {
if (issue.severity === 'warning') {
diff --git a/lib/import/types.ts b/lib/import/types.ts
index 1fd2183d..5638b423 100644
--- a/lib/import/types.ts
+++ b/lib/import/types.ts
@@ -234,6 +234,10 @@ export interface ImportOptions {
// Voucher series to use for imported entries
voucherSeries?: string
+
+ // Opt-in: mark imported verifikat as "Inget underlag krävs" so a migration
+ // doesn't flood "Att hantera: saknade underlag". OFF by default.
+ markImportedNoDocRequired?: boolean
}
/**
diff --git a/messages/en.json b/messages/en.json
index 5464e533..0ada5724 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -2908,7 +2908,24 @@
"no_doc_required_suggestion_interest": "Interest",
"no_doc_required_suggestion_internal_transfer": "Internal transfer",
"no_doc_required_suggestion_tax_payment": "Tax payment",
- "no_doc_required_suggestion_salary": "Salary"
+ "no_doc_required_suggestion_salary": "Salary",
+ "account_column": "Account",
+ "description_column": "Description",
+ "sum_label": "Total",
+ "batch_select_all": "Select all ({count})",
+ "batch_select_row": "Select entry",
+ "batch_selected_count": "{count} selected",
+ "batch_mark_no_doc": "Mark as no document required",
+ "batch_clear_selection": "Clear",
+ "batch_no_doc_done_title": "Marked as no document required",
+ "batch_no_doc_done_description": "{count} entries updated.",
+ "batch_mark_all_missing": "Mark all without documents",
+ "bulk_mark_title": "Mark all without documents",
+ "bulk_mark_counting": "Counting entries...",
+ "bulk_mark_none": "No entries without documents match the filter.",
+ "bulk_mark_body": "{count} entries without a supporting document match the current filter. They will be marked \"No supporting document required\". You can undo this per entry afterwards.",
+ "bulk_mark_confirm": "Mark {count}",
+ "bulk_cancel": "Cancel"
},
"attachment_preview_sheet": {
"title": "Attachments",
@@ -3098,6 +3115,8 @@
"review_title": "Review journal entry",
"review_title_with_voucher": "Review journal entry ({voucher})",
"review_warning": "A journal entry is created and cannot be edited afterwards. Corrections are made via storno.",
+ "review_back": "Back",
+ "review_confirm": "Create entry",
"no_doc_dialog_title": "Document missing",
"no_doc_dialog_warning": "No document attached. Under the Swedish Accounting Act (BFL 5 kap. 6-7 §§) every accounting entry must have a verification as supporting document. You can attach one now or continue without.",
"no_doc_confirm": "Post without document",
@@ -3431,6 +3450,7 @@
"tab_journal": "Journal entries",
"tab_new_entry": "New journal entry",
"tab_accounts": "Chart of accounts",
+ "new_entry_dialog_title": "New journal entry",
"loading_source_voucher": "Loading source voucher...",
"copy_failed_title": "Could not copy journal entry",
"copy_source_missing": "Source voucher not found.",
diff --git a/messages/sv.json b/messages/sv.json
index ddd6125d..78294783 100644
--- a/messages/sv.json
+++ b/messages/sv.json
@@ -2908,7 +2908,24 @@
"no_doc_required_suggestion_interest": "Ränta",
"no_doc_required_suggestion_internal_transfer": "Intern överföring",
"no_doc_required_suggestion_tax_payment": "Skatteinbetalning",
- "no_doc_required_suggestion_salary": "Lön"
+ "no_doc_required_suggestion_salary": "Lön",
+ "account_column": "Konto",
+ "description_column": "Benämning",
+ "sum_label": "Summa",
+ "batch_select_all": "Markera alla ({count})",
+ "batch_select_row": "Markera verifikat",
+ "batch_selected_count": "{count} markerade",
+ "batch_mark_no_doc": "Markera som inget underlag krävs",
+ "batch_clear_selection": "Avmarkera",
+ "batch_no_doc_done_title": "Markerade som inget underlag krävs",
+ "batch_no_doc_done_description": "{count} verifikat uppdaterade.",
+ "batch_mark_all_missing": "Markera alla utan underlag",
+ "bulk_mark_title": "Markera alla utan underlag",
+ "bulk_mark_counting": "Räknar verifikat...",
+ "bulk_mark_none": "Inga verifikat utan underlag matchar filtret.",
+ "bulk_mark_body": "{count} verifikat som saknar underlag matchar nuvarande filter. De markeras som \"Inget underlag krävs\". Du kan ångra per verifikat efteråt.",
+ "bulk_mark_confirm": "Markera {count}",
+ "bulk_cancel": "Avbryt"
},
"attachment_preview_sheet": {
"title": "Bilagor",
@@ -3098,6 +3115,8 @@
"review_title": "Granska verifikation",
"review_title_with_voucher": "Granska verifikation ({voucher})",
"review_warning": "En verifikation skapas och kan inte ändras efteråt. Korrigeringar görs genom storno.",
+ "review_back": "Tillbaka",
+ "review_confirm": "Skapa verifikat",
"no_doc_dialog_title": "Underlag saknas",
"no_doc_dialog_warning": "Inget underlag bifogat. Enligt bokföringslagen (BFL 5 kap. 6-7 §§) ska varje bokföringspost ha en verifikation som underlag. Du kan bifoga underlag nu eller fortsätta utan.",
"no_doc_confirm": "Bokför utan underlag",
@@ -3431,6 +3450,7 @@
"tab_journal": "Verifikationer",
"tab_new_entry": "Ny verifikation",
"tab_accounts": "Kontoplan",
+ "new_entry_dialog_title": "Ny verifikation",
"loading_source_voucher": "Laddar källverifikat...",
"copy_failed_title": "Kunde inte kopiera verifikat",
"copy_source_missing": "Källverifikatet hittades inte.",