feat(bookkeeping): Ny verifikat modal, ledger-style list, SIE no-underlag exemptions (#698)

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-06-08 20:41:30 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 64991eb3c9
commit 4dfd790de5
16 changed files with 1327 additions and 228 deletions
+41 -60
View File
@@ -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<TabValue>('journal')
const [showNewEntry, setShowNewEntry] = useState(false)
const [copyPrefill, setCopyPrefill] = useState<CopyPrefill | null>(null)
const [isLoadingCopy, setIsLoadingCopy] = useState(false)
const [nextVoucher, setNextVoucher] = useState<NextVoucher | null>(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={
<div className="flex gap-2 w-full sm:w-auto">
<Button
className="w-full sm:w-auto"
onClick={() => {
setCopyPrefill(null)
setShowNewEntry(true)
}}
>
<Plus className="mr-2 h-4 w-4" />
{t('tab_new_entry')}
{nextVoucher && (
<span className="ml-1 text-primary-foreground/70 tabular-nums">
({nextVoucher.series}{nextVoucher.next})
</span>
)}
</Button>
<Button variant="outline" asChild className="w-full sm:w-auto">
<Link href="/bookkeeping/year-end">
<Lock className="mr-2 h-4 w-4" />
{t('year_end')}
</Link>
</Button>
<Link href="/bookkeeping/year-end">
<Lock className="mr-2 h-4 w-4" />
{t('year_end')}
</Link>
</Button>
</div>
}
/>
@@ -149,14 +158,6 @@ export default function BookkeepingPage() {
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as TabValue)}>
<TabsList>
<TabsTrigger value="journal">{t('tab_journal')}</TabsTrigger>
<TabsTrigger value="new-entry">
{t('tab_new_entry')}
{nextVoucher && (
<span className="ml-1 text-muted-foreground tabular-nums">
({nextVoucher.series}{nextVoucher.next})
</span>
)}
</TabsTrigger>
<TabsTrigger value="accounts">{t('tab_accounts')}</TabsTrigger>
</TabsList>
@@ -164,45 +165,25 @@ export default function BookkeepingPage() {
<JournalEntryList key={refreshKey} />
</TabsContent>
<TabsContent value="new-entry" forceMount>
{isLoadingCopy ? (
<div className="flex items-center gap-2 py-12 justify-center text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">{t('loading_source_voucher')}</span>
</div>
) : (
<>
{copyPrefill && (
<div className="mb-4 flex items-start gap-3 rounded-lg border border-border bg-muted/30 p-3 text-sm">
<Copy className="h-4 w-4 mt-0.5 shrink-0 text-muted-foreground" />
<div className="flex-1">
<p className="font-medium">
{t('copy_banner_title', { label: copyPrefill.sourceVoucherLabel || t('copy_banner_unknown_label') })}
</p>
<p className="text-muted-foreground mt-0.5">
{t('copy_banner_body')}
</p>
</div>
</div>
)}
<JournalEntryForm
key={copyPrefill?.sourceId ?? 'fresh'}
onCreated={() => {
setRefreshKey((k) => k + 1)
setCopyPrefill(null)
}}
initialLines={copyPrefill?.lines}
initialDescription={copyPrefill?.description}
initialNotes={copyPrefill?.notes}
/>
</>
)}
</TabsContent>
<TabsContent value="accounts" forceMount>
<ChartOfAccountsManager />
</TabsContent>
</Tabs>
<NewJournalEntryDialog
open={showNewEntry}
onOpenChange={(o) => {
setShowNewEntry(o)
if (!o) setCopyPrefill(null)
}}
onCreated={() => {
setRefreshKey((k) => k + 1)
setShowNewEntry(false)
setCopyPrefill(null)
}}
copyPrefill={copyPrefill}
isLoading={isLoadingCopy}
/>
</div>
)
}
@@ -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<typeof vi.fn>).mockResolvedValue({ user: mockUser, supabase: mockSupabase })
;(getActiveCompanyId as ReturnType<typeof vi.fn>).mockResolvedValue('company-1')
;(requireWritePermission as ReturnType<typeof vi.fn>).mockResolvedValue({ ok: true })
})
describe('POST /api/bookkeeping/no-doc-required/batch', () => {
it('returns 401 when not authenticated', async () => {
;(requireAuth as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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)
})
})
@@ -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 },
)
@@ -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<typeof vi.fn>).mockResolvedValue({ user: mockUser, supabase: mockSupabase })
;(getActiveCompanyId as ReturnType<typeof vi.fn>).mockResolvedValue('company-1')
;(requireWritePermission as ReturnType<typeof vi.fn>).mockResolvedValue({ ok: true })
})
describe('POST /api/bookkeeping/no-doc-required/bulk-missing', () => {
it('returns 401 when not authenticated', async () => {
;(requireAuth as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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)
})
})
@@ -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<string>()
const exempt = new Set<string>()
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 },
)
+1
View File
@@ -109,6 +109,7 @@ export const POST = withRouteContext(
importTransactions: options.importTransactions,
voucherSeries: options.voucherSeries || companyDefaultSeries,
updateAccountNames: options.updateAccountNames ?? true,
markImportedNoDocRequired: options.markImportedNoDocRequired ?? false,
},
)
+226 -113
View File
@@ -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<FormLine[]>(
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 = (
<div className="space-y-4">
<div className={`grid gap-4 grid-cols-1 ${
embedded && initialDate
? 'sm:grid-cols-2'
: embedded
? 'sm:grid-cols-3'
: 'sm:grid-cols-[1fr_auto_1fr_3.5rem]'
}`}>
<div>
<Label>{t('fiscal_year')}</Label>
<Select value={selectedPeriod} onValueChange={setSelectedPeriod}>
<SelectTrigger className="mt-1">
<SelectValue placeholder={t('fiscal_year_placeholder')} />
</SelectTrigger>
<SelectContent>
{periods.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{!(embedded && initialDate) && (
<div>
<Label>{t('date')}</Label>
<Input
type="date"
value={entryDate}
onChange={(e) => setEntryDate(e.target.value)}
/>
</div>
)}
<div>
<Label>{t('description')}</Label>
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t('description_placeholder')}
/>
</div>
<div className={embedded ? 'hidden' : 'col-span-full'}>
<Label>{t('internal_note')} <span className="text-muted-foreground font-normal">{t('internal_note_optional')}</span></Label>
<Textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder={t('internal_note_placeholder')}
className="mt-1 resize-none"
rows={2}
maxLength={2000}
/>
</div>
{!embedded && (
<div>
<Label>{t('series')}</Label>
<Input
value={voucherSeries}
onChange={(e) => {
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}
/>
</div>
)}
<div className="flex items-center justify-between gap-2">
<button
type="button"
onClick={() => setShowReview(false)}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
← {t('review_back')}
</button>
<span className="font-display text-lg">
{nextVoucherNumber != null
? t('review_title_with_voucher', { voucher: formatVoucher({ voucher_series: voucherSeries, voucher_number: nextVoucherNumber }) })
: t('review_title')}
</span>
</div>
{/* Period mismatch warning */}
{periodMismatch === 'no_period' && (
{(monthChanged || selectedPeriodLocked) && (
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/10 p-3">
<AlertTriangle className="h-5 w-5 text-warning-foreground mt-0.5 shrink-0" />
<div className="flex-1 text-sm text-warning-foreground">
<p className="font-medium">{t('no_period_warning', { date: entryDate })}</p>
<p className="mt-0.5">{t('no_period_help')}</p>
<div className="flex-1 text-sm text-warning-foreground space-y-0.5">
{monthChanged && (
<p className="font-medium">
{t('review_month_changed', { prev: monthLabel(lastPostedMonth as string), current: monthLabel(entryMonth) })}
</p>
)}
{selectedPeriodLocked && <p>{t('review_period_locked')}</p>}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setShowCreatePeriod(true)}
className="shrink-0"
>
<CalendarPlus className="h-3.5 w-3.5 mr-1.5" />
{t('create_period')}
</Button>
</div>
)}
{/* Currency section */}
<div className="flex flex-wrap items-end gap-3">
<div className="w-24">
<Label className="text-xs text-muted-foreground">{t('currency')}</Label>
<Select value={entryCurrency} onValueChange={(v) => {
setEntryCurrency(v as Currency)
if (v === 'SEK') {
setExchangeRate('')
setForeignAmount('')
}
}}>
<SelectTrigger className="mt-1 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CURRENCIES.map((c) => (
<SelectItem key={c.value} value={c.value}>{c.label}</SelectItem>
))}
</SelectContent>
</Select>
{uploadedFiles.filter((f) => f.status === 'uploaded').length === 0 && (
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/10 p-3 text-sm text-warning-foreground">
<AlertTriangle className="h-5 w-5 mt-0.5 shrink-0" />
<p>{t('no_doc_body')}</p>
</div>
)}
<JournalEntryReviewContent
periodName={periods.find((p) => 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}
/>
<div className="flex justify-end gap-2 pt-2 border-t">
<Button variant="outline" onClick={() => setShowReview(false)} disabled={isSubmitting}>
{t('review_back')}
</Button>
<Button onClick={handleConfirm} disabled={isSubmitting}>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{/* 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')}
</Button>
</div>
</div>
)
const formContent = (
<div className="space-y-4">
{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. */}
<div className="rounded-lg border bg-muted/20 p-3 space-y-3">
<div className="grid gap-3 grid-cols-1 sm:grid-cols-[1fr_2fr_auto]">
{!(embedded && initialDate) && (
<div>
<Label className="text-xs text-muted-foreground">{t('date')}</Label>
<Input
type="date"
value={entryDate}
onChange={(e) => setEntryDate(e.target.value)}
className="mt-1 h-8"
/>
</div>
)}
<div>
<Label className="text-xs text-muted-foreground">{t('description')}</Label>
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t('description_placeholder')}
className="mt-1 h-8"
/>
</div>
{!embedded && (
<div className="w-16">
<Label className="text-xs text-muted-foreground">{t('series')}</Label>
<Input
value={voucherSeries}
onChange={(e) => {
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 h-8 text-center font-mono"
maxLength={1}
/>
</div>
)}
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-xs">
{embedded ? (
<div className="flex items-center gap-2">
<Label className="text-xs text-muted-foreground">{t('fiscal_year')}</Label>
<Select value={selectedPeriod} onValueChange={setSelectedPeriod}>
<SelectTrigger className="h-7 w-auto text-xs">
<SelectValue placeholder={t('fiscal_year_placeholder')} />
</SelectTrigger>
<SelectContent>
{periods.map((p) => (
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : (
selectedPeriodObj && (
<span className="text-muted-foreground">
{t('fiscal_year')}:{' '}
<span className="text-foreground">{selectedPeriodObj.name}</span>
{nextVoucherNumber != null && (
<span className="ml-2 font-mono text-foreground">
{voucherSeries}{nextVoucherNumber}
</span>
)}
</span>
)
)}
<div className="flex items-center gap-2">
<Label className="text-xs text-muted-foreground">{t('currency')}</Label>
<Select value={entryCurrency} onValueChange={(v) => {
setEntryCurrency(v as Currency)
if (v === 'SEK') {
setExchangeRate('')
setForeignAmount('')
}
}}>
<SelectTrigger className="h-7 w-20 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CURRENCIES.map((c) => (
<SelectItem key={c.value} value={c.value}>{c.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{!embedded && !showNotes && !notes && (
<button
type="button"
onClick={() => setShowNotes(true)}
className="text-muted-foreground hover:text-foreground transition-colors"
>
+ {t('internal_note')}
</button>
)}
</div>
{isForeign && (
<>
<div className="flex flex-wrap items-end gap-3">
<div className="w-40">
<Label className="text-xs text-muted-foreground">
{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
</p>
)}
</>
</div>
)}
{!embedded && (showNotes || notes) && (
<div>
<Label className="text-xs text-muted-foreground">
{t('internal_note')}{' '}
<span className="font-normal">{t('internal_note_optional')}</span>
</Label>
<Textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder={t('internal_note_placeholder')}
className="mt-1 resize-none"
rows={2}
maxLength={2000}
/>
</div>
)}
{periodMismatch === 'no_period' && (
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/10 p-3">
<AlertTriangle className="h-5 w-5 text-warning-foreground mt-0.5 shrink-0" />
<div className="flex-1 text-sm text-warning-foreground">
<p className="font-medium">{t('no_period_warning', { date: entryDate })}</p>
<p className="mt-0.5">{t('no_period_help')}</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setShowCreatePeriod(true)}
className="shrink-0"
>
<CalendarPlus className="h-3.5 w-3.5 mr-1.5" />
{t('create_period')}
</Button>
</div>
)}
</div>
@@ -1127,6 +1238,8 @@ export default function JournalEntryForm({
</div>
)}
</div>
</>
)}
<ActivateAccountsDialog
open={activationDialog.open}
@@ -1155,7 +1268,7 @@ export default function JournalEntryForm({
/>
<ConfirmationDialog
open={showReview}
open={showReview && !bare}
onOpenChange={setShowReview}
onConfirm={handleConfirm}
isSubmitting={isSubmitting}
@@ -1199,7 +1312,7 @@ export default function JournalEntryForm({
{/* Warning dialog when no documents attached */}
<ConfirmationDialog
open={showNoDocWarning}
open={showNoDocWarning && !bare}
onOpenChange={setShowNoDocWarning}
onConfirm={() => {
setShowNoDocWarning(false)
@@ -1250,7 +1363,7 @@ export default function JournalEntryForm({
</div>
)
if (embedded) {
if (embedded || bare) {
return formContent
}
+348 -53
View File
@@ -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<Record<string, number>>({})
const [noDocRequired, setNoDocRequired] = useState<Map<string, string | null>>(new Map())
const [showMissingOnly, setShowMissingOnly] = useState(false)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [batchReason, setBatchReason] = useState('')
const [batchSubmitting, setBatchSubmitting] = useState(false)
const [bulkOpen, setBulkOpen] = useState(false)
const [bulkCount, setBulkCount] = useState<number | null>(null)
const [bulkReason, setBulkReason] = useState('')
const [bulkSubmitting, setBulkSubmitting] = useState(false)
const [correctionEntry, setCorrectionEntry] = useState<JournalEntry | null>(null)
const [previewEntryId, setPreviewEntryId] = useState<string | null>(null)
const [sortBy, setSortBy] = useState<SortBy>('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 (
<Card>
@@ -578,6 +718,66 @@ export default function JournalEntryList() {
</div>
)}
{/* Batch-mark "Inget underlag krävs": select-all + contextual action bar */}
{(eligibleEntries.length > 0 || selectedIds.size > 0) && (
<div className="flex flex-col gap-3 rounded-lg border border-border bg-muted/20 p-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2">
<Checkbox
id="select-all-missing"
checked={allEligibleSelected}
onCheckedChange={toggleSelectAll}
disabled={eligibleEntries.length === 0}
/>
<Label htmlFor="select-all-missing" className="text-sm cursor-pointer">
{selectedIds.size > 0
? t('batch_selected_count', { count: selectedIds.size })
: t('batch_select_all', { count: eligibleEntries.length })}
</Label>
</div>
{selectedIds.size > 0 ? (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Input
value={batchReason}
onChange={(e) => setBatchReason(e.target.value)}
placeholder={t('no_doc_required_reason_placeholder')}
list="batch-no-doc-suggestions"
maxLength={200}
className="h-8 text-xs sm:w-56"
disabled={batchSubmitting}
/>
<datalist id="batch-no-doc-suggestions">
<option value={t('no_doc_required_suggestion_bank_fee')} />
<option value={t('no_doc_required_suggestion_interest')} />
<option value={t('no_doc_required_suggestion_internal_transfer')} />
<option value={t('no_doc_required_suggestion_tax_payment')} />
<option value={t('no_doc_required_suggestion_salary')} />
</datalist>
<div className="flex items-center gap-2">
<Button size="sm" onClick={handleBatchExempt} disabled={batchSubmitting}>
{batchSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('batch_mark_no_doc')}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setSelectedIds(new Set())}
disabled={batchSubmitting}
>
{t('batch_clear_selection')}
</Button>
</div>
</div>
) : (
// Filter-scoped: mark every missing-doc verifikat matching the active
// filters across all pages — scales to a post-import flood.
<Button size="sm" variant="outline" onClick={openBulk}>
<CircleSlash className="mr-2 h-4 w-4" />
{t('batch_mark_all_missing')}
</Button>
)}
</div>
)}
{loading ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
@@ -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 (
<Card key={entry.id}>
<div className="flex items-stretch">
{selectable && (
<div className="flex items-center pl-3" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selectedIds.has(entry.id)}
onCheckedChange={() => toggleSelect(entry.id)}
aria-label={t('batch_select_row')}
/>
</div>
)}
<button
onClick={() => 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 */}
<div className="hidden sm:flex items-center gap-3 flex-1">
@@ -640,6 +853,9 @@ export default function JournalEntryList() {
<JournalEntryStatusBadge entry={entry} showStatus={entry.status === 'reversed' || entry.status === 'draft'} />
)}
<span className="flex-1 truncate">{entry.description}</span>
<span className="shrink-0 w-28 text-right tabular-nums text-sm font-medium">
{formatCurrency(voucherTotal)}
</span>
<Button
asChild
variant="ghost"
@@ -816,65 +1032,83 @@ export default function JournalEntryList() {
)}
</span>
</div>
<p className="mt-1 ml-6 text-sm truncate">{entry.description}</p>
<div className="mt-1 ml-6 flex items-center justify-between gap-2">
<p className="text-sm truncate">{entry.description}</p>
<span className="shrink-0 tabular-nums text-sm font-medium">
{formatCurrency(voucherTotal)}
</span>
</div>
</div>
</button>
</div>
{isExpanded && (
<CardContent className="pt-0 pb-4">
{lines.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">{t('no_lines')}</p>
) : (
<>
<div className="space-y-3">
{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 (
<div key={line.id} className="rounded-lg border p-3 space-y-1.5">
<div className="text-sm">
<AccountNumber number={line.account_number} showName />
</div>
{showDesc && (
<p className="text-xs text-muted-foreground">{desc}</p>
)}
<div className="flex justify-between items-center pt-1 border-t text-sm">
<span className="text-muted-foreground">
{Number(line.debit_amount) > 0 ? t('debit') : t('credit')}
</span>
<div className="text-right">
<span className="font-mono tabular-nums font-medium">
{Number(line.debit_amount) > 0
? Number(line.debit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })
: Number(line.credit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
</span>
{line.currency && line.currency !== 'SEK' && line.amount_in_currency != null && (
<span className="block text-xs text-muted-foreground font-mono tabular-nums">
{Number(line.amount_in_currency).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} {line.currency}
</span>
)}
</div>
</div>
</div>
)
})}
<div className="rounded-lg bg-muted/50 p-3 text-sm font-semibold space-y-1">
<div className="flex justify-between">
<span>{t('sum_debit')}</span>
<span className="font-mono tabular-nums">{lines.reduce((sum, l) => sum + (Number(l.debit_amount) || 0), 0).toLocaleString('sv-SE', { minimumFractionDigits: 2 })}</span>
</div>
<div className="flex justify-between">
<span>{t('sum_credit')}</span>
<span className="font-mono tabular-nums">{lines.reduce((sum, l) => sum + (Number(l.credit_amount) || 0), 0).toLocaleString('sv-SE', { minimumFractionDigits: 2 })}</span>
</div>
</div>
</div>
</>
<div className="rounded-lg border overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('account_column')}</TableHead>
<TableHead>{t('description_column')}</TableHead>
<TableHead className="text-right">{t('debit')}</TableHead>
<TableHead className="text-right">{t('credit')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{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 (
<TableRow key={line.id}>
<TableCell className="align-top whitespace-nowrap">
<AccountNumber number={line.account_number} showName />
</TableCell>
<TableCell className="align-top text-muted-foreground">
{showDesc ? desc : ''}
</TableCell>
<TableCell className="align-top text-right tabular-nums">
{debit > 0 ? debit.toLocaleString('sv-SE', { minimumFractionDigits: 2 }) : ''}
{debit > 0 && fx && (
<span className="block text-xs text-muted-foreground tabular-nums">{fx}</span>
)}
</TableCell>
<TableCell className="align-top text-right tabular-nums">
{credit > 0 ? credit.toLocaleString('sv-SE', { minimumFractionDigits: 2 }) : ''}
{credit > 0 && fx && (
<span className="block text-xs text-muted-foreground tabular-nums">{fx}</span>
)}
</TableCell>
</TableRow>
)
})}
</TableBody>
<TableFooter>
<TableRow>
<TableCell colSpan={2} className="font-medium">{t('sum_label')}</TableCell>
<TableCell className="text-right tabular-nums font-medium">
{lines.reduce((sum, l) => sum + (Number(l.debit_amount) || 0), 0).toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
</TableCell>
<TableCell className="text-right tabular-nums font-medium">
{lines.reduce((sum, l) => sum + (Number(l.credit_amount) || 0), 0).toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
)}
{entry.notes && (
@@ -949,6 +1183,67 @@ export default function JournalEntryList() {
</div>
)}
{/* Filter-scoped bulk "Inget underlag krävs" confirmation */}
<Dialog
open={bulkOpen}
onOpenChange={(o) => {
if (bulkSubmitting) return
setBulkOpen(o)
if (!o) {
setBulkCount(null)
setBulkReason('')
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('bulk_mark_title')}</DialogTitle>
</DialogHeader>
<div className="space-y-3 text-sm">
{bulkCount === null ? (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t('bulk_mark_counting')}
</div>
) : bulkCount === 0 ? (
<p className="text-muted-foreground">{t('bulk_mark_none')}</p>
) : (
<>
<p>{t('bulk_mark_body', { count: bulkCount })}</p>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">{t('no_doc_required_reason_add')}</Label>
<Input
value={bulkReason}
onChange={(e) => setBulkReason(e.target.value)}
placeholder={t('no_doc_required_reason_placeholder')}
list="bulk-no-doc-suggestions"
maxLength={200}
className="h-8 text-xs"
disabled={bulkSubmitting}
/>
<datalist id="bulk-no-doc-suggestions">
<option value={t('no_doc_required_suggestion_bank_fee')} />
<option value={t('no_doc_required_suggestion_interest')} />
<option value={t('no_doc_required_suggestion_internal_transfer')} />
<option value={t('no_doc_required_suggestion_tax_payment')} />
<option value={t('no_doc_required_suggestion_salary')} />
</datalist>
</div>
</>
)}
</div>
<DialogFooter>
<Button variant="outline" size="sm" onClick={() => setBulkOpen(false)} disabled={bulkSubmitting}>
{t('bulk_cancel')}
</Button>
<Button size="sm" onClick={handleBulkConfirm} disabled={bulkSubmitting || !bulkCount}>
{bulkSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('bulk_mark_confirm', { count: bulkCount ?? 0 })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Correction dialog */}
{correctionEntry && (
<CorrectionEntryDialog
@@ -0,0 +1,86 @@
'use client'
import { useTranslations } from 'next-intl'
import { Copy, Loader2 } from 'lucide-react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import JournalEntryForm, { type FormLine } from '@/components/bookkeeping/JournalEntryForm'
export interface CopyPrefill {
sourceId: string
sourceVoucherLabel: string
lines: FormLine[]
description: string
notes: string
}
interface Props {
open: boolean
onOpenChange: (open: boolean) => 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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-3xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{t('new_entry_dialog_title')}</DialogTitle>
</DialogHeader>
{isLoading ? (
<div className="flex items-center justify-center gap-2 py-12 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">{t('loading_source_voucher')}</span>
</div>
) : (
<>
{copyPrefill && (
<div className="flex items-start gap-3 rounded-lg border border-border bg-muted/30 p-3 text-sm">
<Copy className="h-4 w-4 mt-0.5 shrink-0 text-muted-foreground" />
<div className="flex-1">
<p className="font-medium">
{t('copy_banner_title', {
label: copyPrefill.sourceVoucherLabel || t('copy_banner_unknown_label'),
})}
</p>
<p className="text-muted-foreground mt-0.5">{t('copy_banner_body')}</p>
</div>
</div>
)}
<JournalEntryForm
key={copyPrefill?.sourceId ?? 'fresh'}
bare
onCreated={onCreated}
initialLines={copyPrefill?.lines}
initialDescription={copyPrefill?.description}
initialNotes={copyPrefill?.notes}
/>
</>
)}
</DialogContent>
</Dialog>
)
}
+39
View File
@@ -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<string | null>(null)
const [existingSeries, setExistingSeries] = useState<Set<string>>(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({
</p>
</div>
)}
{/* No-underlag exemption — keeps a multi-year migration from flooding
"Att hantera: saknade underlag" with thousands of items. */}
<div className="flex items-start justify-between border-t pt-6">
<div className="space-y-0.5 pr-4">
<Label htmlFor="mark-no-doc-required" className="font-medium flex items-center gap-2">
Markera som &quot;Inget underlag krävs&quot;
{isHistoricalImport && (
<Badge variant="secondary" className="text-[10px] font-normal">
Rekommenderas vid migrering
</Badge>
)}
</Label>
<p className="text-sm text-muted-foreground">
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 &quot;Att hantera: saknade underlag&quot;. Kan ändras per
verifikation efteråt.
</p>
</div>
<Switch
id="mark-no-doc-required"
checked={options.markImportedNoDocRequired}
onCheckedChange={(checked) => updateOption('markImportedNoDocRequired', checked)}
disabled={!options.importTransactions || !hasTransactions}
/>
</div>
</CardContent>
</Card>
@@ -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')
})
})
+50
View File
@@ -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<number> {
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
}
+45
View File
@@ -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<ImportResult> {
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') {
+4
View File
@@ -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
}
/**
+21 -1
View File
@@ -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.",
+21 -1
View File
@@ -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.",