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
+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>