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
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user