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:
co-authored by
Claude Opus 4.8
parent
64991eb3c9
commit
4dfd790de5
@@ -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 },
|
||||
)
|
||||
@@ -109,6 +109,7 @@ export const POST = withRouteContext(
|
||||
importTransactions: options.importTransactions,
|
||||
voucherSeries: options.voucherSeries || companyDefaultSeries,
|
||||
updateAccountNames: options.updateAccountNames ?? true,
|
||||
markImportedNoDocRequired: options.markImportedNoDocRequired ?? false,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 "Inget underlag krävs"
|
||||
{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 "Att hantera: saknade underlag". 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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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') {
|
||||
|
||||
@@ -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
@@ -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
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user