2deea05d42
* refactor(documents): lift the SIE voucher-ref resolver into core
The provider migration sweep resolved a source voucher reference to the
verifikat it became with an in-memory (period, series, number) index built
inside extensions/general/arcim-migration. The underlag filename import needs
the identical resolution, and core must never import from @/extensions, so the
index, its ambiguity handling and the two paged reads move to
lib/documents/voucher-ref-resolver.ts.
Behaviour-preserving for the extension: same index construction, same "drop
both when one key repeats inside a fiscal year" rule, same dateTo-window
resolution. The arcim tests pass unchanged.
Two deliberate additions on top of the lift:
- series comparison is now case-insensitive on both sides. SIE writes series
uppercase in practice but the spec does not require it, and a filename is
whatever the exporting tool produced.
- byNumber and fetchVouchersForNumbers serve the filename flow, which
resolves a handful of refs per request and must not pull every migrated
entry into memory to do it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(import): attach underlag to SIE-migrated verifikat by filename
A SIE file carries the ledger but not the underlag, so a migrating customer
brings the receipts over separately and today has to open every verifikat and
attach them by hand. Systems that export both name each receipt after its
verifikat (A31_<internal-id>.pdf), and the SIE import already preserves that
identity on every entry (source_voucher_series / source_voucher_number), so
the pairing is a lookup, not an interpretation: no AI, no amount matching, no
date windows.
Separate optional import mode (/import?mode=underlag), NOT a step inside the
SIE wizard: the receipts normally arrive later and from a different export, so
a migration must never be blocked on having them ready.
lib/documents/filename-voucher-ref.ts reads the ref out of a filename
lib/documents/underlag-import.ts builds the plan (reads only)
POST /api/import/documents/preview filenames in, match plan out
POST /api/import/documents/attach one file, archived and linked
components/import/UnderlagImportWizard review, adjust, run
Guards, because a document linked to a posted verifikat is
räkenskapsinformation and can never be re-pointed (BFL 7 kap):
- Matching keys on the SOURCE voucher number, never our own. The importer
renumbers per target series, so a file named after our number would land
on the wrong verifikat exactly when the import skipped a voucher.
- Nothing is uploaded until the whole plan has been shown: the preview
sends filenames only, the bytes stay in the browser.
- A ref that hits several migrated years is surfaced as a choice, never
resolved by guessing. So is a filename with a number but no series, which
is resolved but never pre-selected.
- A date-named file (20240131.pdf) is refused outright rather than read as
voucher 20240131.
- A target in a closed or locked period is shown but not selectable:
enforce_period_lock_documents would refuse the write anyway.
- The attach route re-resolves the filename server-side and 409s when it
does not name the target the client sent, so a stale plan cannot scatter
underlag permanently. An explicit manual assignment opts out of that check
and is flagged as such; company ownership of the entry is always verified.
- Idempotent per (verifikat, content): a re-run converges on the same
document row instead of archiving duplicates.
tests/pg/underlag-attach-period-lock.pg.test.ts pins the period-lock contract
the plan surface promises, including that the lock guards the LINK and still
lets an unlinked document be archived.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(import): scope underlag matching to a declared fiscal year
Adversarial review of #1627 refuted the resolver: it looked a ref up
company-wide and treated "exactly one candidate exists" as proof of identity.
Source systems restart voucher numbering every year and a filename carries no
year, so with a partial migration, or with that year's A31 among the vouchers
the importer routinely skips (empty, single-line, unbalanced), a 2023 receipt
was silently attached to a 2025 verifikat. Permanent under BFL 7 kap, and
invisible afterwards. Cardinality is not identity.
Every batch now declares its fiscal year and candidates outside it are dropped
before the index is built, so no downstream branch can see, count or propose
one. The attach route takes the year for its re-resolution from the TARGET
entry, never from the client, so the check cannot be widened by naming a
different year. Scoping cannot make the year inferable; it makes it asserted,
and the confirm dialog reads it back because it is the one input the files
cannot corroborate.
Four further defects from the same review:
- npm test went red: hoisting the column list into a VOUCHER_SELECT constant
hid it from the no-phantom-columns AST scan (ceiling 377 -> 379) and
dropped all eight journal_entries columns out of the guard on the one path
that writes irreversible links. Both selects are inline again, and split:
the provider sweep no longer fetches three display columns it never reads.
- The date guard only caught zero-padded hyphenated dates, so
`2024-1-31 kvitto.pdf`, `2024 01 31 ...`, `2024.1.31` and `24-01-31` all
parsed as voucher 2024 or 24. Widened to unpadded components, two-digit
years and space/slash separators; a bare year-shaped number is refused.
- `Verifikation 31.pdf` parsed as series ION: the alternation matched
`ifikat` and left `ion` for the series group. Reordering alone was not
enough (the engine backtracks into it), so the prefix now requires the
word to end.
- The manual-reference box was an unguarded write path: typing a date got
path-split down to a voucher number, marked the row selected, and posted
with override, which skips both server checks, while the row still showed
"Kan inte tolkas". Directory splitting is gone from the parser, the row
status is updated on resolve, and picking a server-proposed candidate no
longer counts as an override, which had disabled the filename check on
exactly the ambiguous rows it exists to protect.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(import): enforce the declared fiscal year on the server
The second adversarial pass refuted the previous fix. The attach route took
the year for its re-resolution from the TARGET entry, which is tautological:
an entry is by construction inside its own fiscal_period_id, so the filter
could never drop it and the year axis was unfalsifiable. Server-side year
enforcement was zero; the declared year existed only as React state and was
never sent. The regression test that "proved" otherwise passed only because
the mock let one journal_entries row report two different fiscal_period_id
values to two different reads, a state Postgres cannot produce. A test that
could not fail.
The attach request now carries the year the user actually reviewed, echoed
back from the plan, and the route asserts it equals the target's own period
BEFORE any other check and including overrides: an override is a statement
about which verifikat, never about which year. Its test asserts that directly
instead of a mock artifact.
Also from the same pass, a UI race that made the confirm dialog lie: FyPicker
stayed interactive while a preview of up to 2000 filenames was in flight, so
the summary and the confirm text could read back a year the plan was not built
from, and a manually resolved row could join the batch from another year
entirely. The wizard snapshots the plan's year, every downstream read uses the
snapshot, manual re-resolution goes through the server's own echoed
plan.fiscal_period_id, and the picker is frozen while a preview runs.
Parser, from the corpus pass (~360 realistic filenames plus 200k random uuids,
no ReDoS found: 2000 hostile inputs in 26ms):
- Day-first and US dates parsed as voucher numbers: `31.01.2024` became
voucher 31, a number that always exists in the year. The guard now covers
both orders.
- `ver 31.pdf` parsed as series VER and came back auto-selectable, while
every spelled-out `Verifikat 31.pdf` correctly yielded a series-less
reference needing confirmation. Same filename, two trust levels, decided
by an abbreviation. `ver` is no longer a series.
Known residual, stated rather than papered over: a scanner's `A4.pdf` or a
`K10.pdf` blankett in the receipts folder still matches verifikat A4 or K10
when that year has them. No parser can separate those from a genuine
reference; they appear in the review table with the target's date and
description.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(import): make the user actually declare the fiscal year
The third adversarial pass found that the central guarantee of the previous
two commits was fiction. FyPicker auto-selects the newest fiscal period when
nothing is stored, and the wizard passes a page-specific storage key, so that
branch fired on every first use. A user migrating 2023 receipts who never
opened the picker resolved them against the newest year; A31 exists in
essentially every year, so those rows came back `matched`, pre-selected, with
only the confirm dialog between them and permanent links. Every commit message
and code comment claiming "the year the user named" described behaviour the UI
did not have.
FyPicker gains an opt-in `requireExplicitChoice` prop, default off so no other
caller changes, and the wizard uses it. The picker starts empty and the batch
cannot proceed until someone picks. A previously stored explicit choice for
this surface is still restored, which is what makes a multi-batch migration
bearable.
Also: a company with zero fiscal periods hit a disabled picker and a disabled
button with no explanation. There is now a line saying why.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(import): close the restore-branch hole and demote collision-prone refs
Round four of adversarial review, two findings, both fixed.
1. `requireExplicitChoice` gated only the newest-period fallback, not the
localStorage restore branch above it, so the "user declares the year"
guarantee held only for a user's first-ever batch. From the second on, the
year was silently pre-filled from an earlier unrelated batch, and in a
multi-year migration last-used is the worst possible default: the user is
by definition moving to a different year each round. The prop now gates
FyPicker's ENTIRE auto-selection block with one outer condition (restore,
the ALL_YEARS-stored fallback, newest-period, preferLatestEnded), because a
per-branch gate already missed one branch once. It also suppresses the
localStorage write, which fired BEFORE onChange and so recorded picks the
wizard had rejected mid-preview. The wizard drops its storage prefix
entirely: within one sitting reset() carries the year in state, and
nothing survives the session.
2. The filename parser pre-ticked `A4 scan.pdf` and `K10.pdf` while requiring
a click for `31.pdf`, which carries MORE voucher evidence in a
single-series company. Two independent review passes flagged the same
inconsistency. Collision-famous refs (A0-A6 paper sizes, K2-K13/N1-N9/
T1-T2 blanketter, Q1-Q4 quarters) and three-letter series (IMG/DSC/DOC/
SCN are cameras; real SIE series are 1-2 chars) still parse and resolve
but are never auto-selected. Demoted, not refused: verifikat A4 genuinely
exists in every migrated ledger, and its real receipt costs one click.
Residual documented: an existing short series plus a small number in an
ad-hoc name (`B2 hyra.pdf`) is indistinguishable from a real ref by
filename alone.
Also: the attach route's multipart doc now names the required
fiscal_period_id field, and the stale reset() comment describes the actual
persistence model.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(import): honor override only for unresolvable filenames + review round
Resolution pass for the PR #1627 review reports (CodeRabbit, Swedish
accounting review, compliance swarm).
The one substantive finding (CodeRabbit, major): `override: true` skipped the
filename consistency check entirely, so a crafted client could attach a
cleanly-named file to any same-year verifikat. The resolver now runs on every
request; an override is honored only when the filename is unresolvable in the
declared year (no parse, or no candidate) or already resolves to the requested
target. The shipped UI only overrides unresolvable rows, so nothing
user-facing changes. planAcceptsTarget is renamed planPermitsAttach and
carries the semantics in one place, with tests for both directions.
The Swedish review finding (BFNAR 2013:2 systemdokumentation): the
planPermitsAttach JSDoc still described the superseded derive-the-year-from-
the-target design. It now states the actual control: the route asserts the
caller-declared year equals the target's own period before this function runs.
CodeRabbit minors and nitpicks:
- underlag_confirm_body / underlag_run / underlag_locked_warning use ICU
plural forms in both locales; "1 filer arkiveras" was wrong Swedish.
- The attach and preview route tests mock @/lib/supabase/server per the
repo test guideline.
- fetchVouchersForNumbers narrows to the declared fiscal year at the DB;
the in-memory filter in buildUnderlagPlan remains the enforced truth.
- buildVoucherIndex appends into existing arrays instead of copying per
row: the provider sweep indexes every migrated entry in the company and
per-row copies made that O(n^2).
- The pg test reuses its insertDocument helper instead of a duplicated
INSERT; runAttach clears isLoading in a finally.
Declined, with reasons in DECISIONS.md: message-regex classification of
validateDocumentFile failures (established sibling pattern; validator
contract change is out of scope).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(import): attach only to posted or reversed verifikat
Second review cycle on PR #1627: the Swedish accounting review's re-run found
that nothing in the attach route verified the target entry's status. The SIE
import RPC posts every entry inside its own transaction, so a draft carrying a
source ref should be unobservable, but the link this route writes is
irreversible räkenskapsinformation, and an invariant enforced in another file
is not one this surface may lean on. Underlag references a verifikation
(BFL 5 kap 6-7 §), so the target must BE one.
Enforced twice: the route rejects non-posted targets with
UNDERLAG_ENTRY_NOT_POSTED (overrides included), and the resolver reads filter
to posted/reversed so a draft can never even become a candidate. Reversed
stays attachable: a storno'd original remains räkenskapsinformation and its
underlag belongs on it.
Also recorded as confirmed-intentional (review note, no code change): with
override and an unresolvable filename the endpoint links to any same-company,
same-declared-year, posted verifikat, migrated or not, which mirrors the
existing /api/documents/[id]/link capability. The period-lock error-string
regex note restates a disposition already recorded in DECISIONS.md.
The arcim test's Supabase double learns .in(), which the shared resolver read
now uses for the status filter.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
677 lines
25 KiB
TypeScript
677 lines
25 KiB
TypeScript
'use client'
|
|
|
|
import { useCallback, useMemo, useRef, useState } from 'react'
|
|
import { useTranslations } from 'next-intl'
|
|
import { Card, CardContent } from '@/components/ui/card'
|
|
import { Progress } from '@/components/ui/progress'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Input } from '@/components/ui/input'
|
|
import { AttnLine } from '@/components/ui/attn-line'
|
|
import { EmptyState } from '@/components/ui/empty-state'
|
|
import { TD_CLASS, TH_CLASS } from '@/components/ui/dry-table'
|
|
import { useToast } from '@/components/ui/use-toast'
|
|
import {
|
|
DestructiveConfirmDialog,
|
|
useDestructiveConfirm,
|
|
} from '@/components/ui/destructive-confirm-dialog'
|
|
import { FyPicker } from '@/components/common/FyPicker'
|
|
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
|
import { cn, formatDate } from '@/lib/utils'
|
|
import { FileUp, Loader2 } from 'lucide-react'
|
|
import type { FiscalPeriod } from '@/types'
|
|
import type {
|
|
UnderlagPlan,
|
|
UnderlagPlanCandidate,
|
|
UnderlagPlanRow,
|
|
UnderlagPlanStatus,
|
|
} from '@/lib/documents/underlag-import'
|
|
|
|
// UnderlagImportWizard
|
|
//
|
|
// Attaches a folder of receipt files to verifikat that a SIE import already
|
|
// created, by reading the source voucher reference out of each filename
|
|
// (`A31_<id>.pdf`). Deliberately NOT a step inside the SIE wizard: the receipts
|
|
// usually arrive later, from a different export, and a migration must not be
|
|
// blocked on having them ready.
|
|
//
|
|
// The plan is built from filenames alone and shown in full before anything is
|
|
// uploaded. Attaching a document to a posted verifikat makes it
|
|
// räkenskapsinformation, which cannot be re-pointed afterwards (BFL 7 kap), so
|
|
// nothing is ever attached without the user seeing exactly where it lands.
|
|
//
|
|
// The fiscal year is chosen first and every row resolves inside it. A filename
|
|
// carries no year and source systems restart voucher numbering annually, so
|
|
// `A31` names a verifikat only within a year. The year is the one piece of the
|
|
// mapping the files cannot supply, which is why the user supplies it.
|
|
|
|
type Step = 'select' | 'review' | 'result'
|
|
|
|
const ACCEPTED_TYPES = 'application/pdf,image/jpeg,image/png,image/webp'
|
|
|
|
/** Message keys per status, spelled out so next-intl keeps checking them. */
|
|
const STATUS_KEY: Record<UnderlagPlanStatus, string> = {
|
|
matched: 'underlag_status_matched',
|
|
needs_confirmation: 'underlag_status_needs_confirmation',
|
|
ambiguous: 'underlag_status_ambiguous',
|
|
period_locked: 'underlag_status_period_locked',
|
|
no_match: 'underlag_status_no_match',
|
|
unparsed: 'underlag_status_unparsed',
|
|
}
|
|
|
|
type Translate = (key: string, values?: Record<string, string | number>) => string
|
|
|
|
interface ReviewRow extends UnderlagPlanRow {
|
|
/** Position in the batch: two folders can contribute the same filename. */
|
|
id: string
|
|
file: File
|
|
selected: boolean
|
|
targetId: string | null
|
|
/** The user picked this target by hand, so the server skips the name check. */
|
|
manual: boolean
|
|
/** Free-text reference the user typed for a row the filename could not resolve. */
|
|
manualRef: string
|
|
resolving: boolean
|
|
}
|
|
|
|
interface AttachOutcome {
|
|
file_name: string
|
|
ok: boolean
|
|
message?: string
|
|
}
|
|
|
|
/** Statuses whose single resolved target is safe to pre-select. */
|
|
function isPreselected(status: UnderlagPlanStatus): boolean {
|
|
return status === 'matched'
|
|
}
|
|
|
|
function badgeVariant(status: UnderlagPlanStatus): 'secondary' | 'warning' | 'destructive' {
|
|
if (status === 'ambiguous' || status === 'needs_confirmation') return 'warning'
|
|
if (status === 'period_locked') return 'destructive'
|
|
return 'secondary'
|
|
}
|
|
|
|
export default function UnderlagImportWizard() {
|
|
const t = useTranslations('import')
|
|
const { toast } = useToast()
|
|
const { dialogProps, confirm } = useDestructiveConfirm()
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
|
|
const [step, setStep] = useState<Step>('select')
|
|
const [isLoading, setIsLoading] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [fiscalPeriodId, setFiscalPeriodId] = useState<string | null>(null)
|
|
const [fiscalPeriod, setFiscalPeriod] = useState<FiscalPeriod | null>(null)
|
|
/**
|
|
* The year the CURRENT plan was resolved against, snapshotted when the plan
|
|
* was requested. Everything downstream (summary, confirm text, manual
|
|
* re-resolution, the attach requests) reads this, never the live picker: the
|
|
* picker can move while a preview of 2000 filenames is in flight, and a
|
|
* confirm dialog that reads back a different year than the plan was built
|
|
* from is worse than no confirm dialog at all.
|
|
*/
|
|
const [planPeriod, setPlanPeriod] = useState<FiscalPeriod | null>(null)
|
|
const [plan, setPlan] = useState<UnderlagPlan | null>(null)
|
|
const [rows, setRows] = useState<ReviewRow[]>([])
|
|
const [attached, setAttached] = useState(0)
|
|
const [outcomes, setOutcomes] = useState<AttachOutcome[]>([])
|
|
|
|
const steps: Step[] = ['select', 'review', 'result']
|
|
const stepLabels: Record<Step, string> = {
|
|
select: t('underlag_step_select'),
|
|
review: t('underlag_step_review'),
|
|
result: t('underlag_step_result'),
|
|
}
|
|
const currentStepIndex = steps.indexOf(step)
|
|
const progress = ((currentStepIndex + 1) / steps.length) * 100
|
|
|
|
const selectedRows = useMemo(
|
|
() => rows.filter((row) => row.selected && row.targetId),
|
|
[rows],
|
|
)
|
|
|
|
/**
|
|
* Resolve filenames server-side. Only names travel: the bytes stay here.
|
|
* The year is an explicit argument rather than read from state, so a caller
|
|
* cannot accidentally resolve against a year the user has since changed.
|
|
*/
|
|
const fetchPlan = useCallback(
|
|
async (fileNames: string[], periodId: string): Promise<UnderlagPlan | null> => {
|
|
const res = await fetch('/api/import/documents/preview', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ file_names: fileNames, fiscal_period_id: periodId }),
|
|
})
|
|
const data = await res.json()
|
|
if (!res.ok) {
|
|
setError(getErrorMessage(data, { statusCode: res.status }))
|
|
return null
|
|
}
|
|
return data.data as UnderlagPlan
|
|
},
|
|
[],
|
|
)
|
|
|
|
const handleFilesSelected = useCallback(
|
|
async (fileList: FileList | null) => {
|
|
if (!fileList || fileList.length === 0) return
|
|
if (!fiscalPeriodId) return
|
|
const files = Array.from(fileList)
|
|
// Snapshot the year for this batch up front. The picker stays on screen
|
|
// while the request is in flight, so state read afterwards may not be
|
|
// the year the plan was built from.
|
|
const batchPeriodId = fiscalPeriodId
|
|
const batchPeriod = fiscalPeriod
|
|
|
|
setError(null)
|
|
setIsLoading(true)
|
|
try {
|
|
const nextPlan = await fetchPlan(
|
|
files.map((f) => f.name),
|
|
batchPeriodId,
|
|
)
|
|
if (!nextPlan) return
|
|
|
|
setPlan(nextPlan)
|
|
setPlanPeriod(batchPeriod)
|
|
setRows(
|
|
nextPlan.rows.map((row, index) => ({
|
|
...row,
|
|
id: `${index}:${row.file_name}`,
|
|
file: files[index],
|
|
selected: isPreselected(row.status),
|
|
// A resolved-but-locked target stays unselectable: the DB trigger
|
|
// would refuse it, so offering the checkbox would only mislead.
|
|
targetId: row.status === 'period_locked' ? null : row.journal_entry_id,
|
|
manual: false,
|
|
manualRef: '',
|
|
resolving: false,
|
|
})),
|
|
)
|
|
setStep('review')
|
|
} catch (err) {
|
|
setError(getErrorMessage(err))
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
},
|
|
[fetchPlan, fiscalPeriod, fiscalPeriodId],
|
|
)
|
|
|
|
const updateRow = useCallback((id: string, patch: Partial<ReviewRow>) => {
|
|
setRows((prev) => prev.map((row) => (row.id === id ? { ...row, ...patch } : row)))
|
|
}, [])
|
|
|
|
/**
|
|
* Resolve a reference the user typed for a row whose filename said nothing.
|
|
* Goes through the same resolver as the automatic path: the user supplies the
|
|
* verifikat reference, never a free-choice target.
|
|
*/
|
|
const resolveManualRef = useCallback(
|
|
async (row: ReviewRow) => {
|
|
const ref = row.manualRef.trim()
|
|
if (!ref || !plan) return
|
|
|
|
updateRow(row.id, { resolving: true })
|
|
try {
|
|
// Resolve inside the year THIS PLAN was built against, straight from
|
|
// the server's own echo. Reading live state here would let a row join
|
|
// the batch from a different year than every other row in it.
|
|
const refPlan = await fetchPlan([ref], plan.fiscal_period_id)
|
|
const resolved = refPlan?.rows[0]
|
|
const candidates = resolved?.candidates ?? []
|
|
|
|
if (candidates.length === 0) {
|
|
toast({
|
|
title: t('underlag_manual_not_found_title'),
|
|
description: t('underlag_manual_not_found_body', { ref }),
|
|
variant: 'destructive',
|
|
})
|
|
updateRow(row.id, { resolving: false })
|
|
return
|
|
}
|
|
|
|
const single = candidates.length === 1 ? candidates[0] : null
|
|
updateRow(row.id, {
|
|
candidates,
|
|
resolving: false,
|
|
// Hand-resolved: the filename itself still says nothing, so the
|
|
// server cannot re-derive this target and the row carries an override.
|
|
manual: true,
|
|
targetId: single && !single.period_locked ? single.journal_entry_id : null,
|
|
selected: Boolean(single && !single.period_locked),
|
|
// Without this the row keeps rendering "Kan inte tolkas" while
|
|
// sitting checked and queued for an irreversible write.
|
|
status: single
|
|
? single.period_locked
|
|
? 'period_locked'
|
|
: 'needs_confirmation'
|
|
: 'ambiguous',
|
|
})
|
|
} catch (err) {
|
|
updateRow(row.id, { resolving: false })
|
|
toast({ title: t('underlag_manual_not_found_title'), description: getErrorMessage(err) })
|
|
}
|
|
},
|
|
[fetchPlan, plan, t, toast, updateRow],
|
|
)
|
|
|
|
const runAttach = useCallback(async () => {
|
|
if (!plan) return
|
|
// The year is named in the confirm text on purpose: it is the one input
|
|
// the files cannot corroborate, so it is the one worth reading back.
|
|
const ok = await confirm({
|
|
title: t('underlag_confirm_title'),
|
|
description: t('underlag_confirm_body', {
|
|
count: selectedRows.length,
|
|
year: planPeriod?.name ?? '',
|
|
}),
|
|
confirmLabel: t('underlag_confirm_action'),
|
|
variant: 'warning',
|
|
})
|
|
if (!ok) return
|
|
|
|
setIsLoading(true)
|
|
setAttached(0)
|
|
const results: AttachOutcome[] = []
|
|
|
|
try {
|
|
// Sequential on purpose: hundreds of uploads in parallel would swamp the
|
|
// browser and the storage bucket, and a visible one-by-one count is what
|
|
// makes a long migration legible.
|
|
for (const row of selectedRows) {
|
|
const formData = new FormData()
|
|
formData.append('file', row.file)
|
|
formData.append('journal_entry_id', row.targetId as string)
|
|
// The year the plan was built against, echoed from the server. The
|
|
// route refuses any target outside it, overrides included.
|
|
formData.append('fiscal_period_id', plan.fiscal_period_id)
|
|
if (row.manual) formData.append('override', 'true')
|
|
|
|
try {
|
|
const res = await fetch('/api/import/documents/attach', {
|
|
method: 'POST',
|
|
body: formData,
|
|
})
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => null)
|
|
results.push({
|
|
file_name: row.file_name,
|
|
ok: false,
|
|
message: getErrorMessage(data, { statusCode: res.status }),
|
|
})
|
|
} else {
|
|
results.push({ file_name: row.file_name, ok: true })
|
|
}
|
|
} catch (err) {
|
|
results.push({ file_name: row.file_name, ok: false, message: getErrorMessage(err) })
|
|
}
|
|
|
|
setAttached((n) => n + 1)
|
|
}
|
|
} finally {
|
|
// Whatever happens above, the wizard must not stay stuck "loading":
|
|
// that state also freezes the year picker.
|
|
setIsLoading(false)
|
|
}
|
|
|
|
setOutcomes(results)
|
|
setStep('result')
|
|
|
|
const failed = results.filter((r) => !r.ok).length
|
|
toast({
|
|
title: t('underlag_done_title'),
|
|
description: t('underlag_done_body', {
|
|
linked: results.length - failed,
|
|
failed,
|
|
}),
|
|
variant: failed > 0 ? 'destructive' : 'default',
|
|
})
|
|
}, [confirm, plan, planPeriod, selectedRows, t, toast])
|
|
|
|
// The fiscal year survives a reset but never a session: within one sitting
|
|
// a migration is several batches from the same year's export, so in-state
|
|
// carry-over is the convenience. Cross-session persistence is the hazard
|
|
// (last-used is the wrong default for a user moving year by year), which is
|
|
// why the picker neither restores nor writes localStorage on this surface.
|
|
const reset = () => {
|
|
setStep('select')
|
|
setPlan(null)
|
|
setPlanPeriod(null)
|
|
setRows([])
|
|
setOutcomes([])
|
|
setAttached(0)
|
|
setError(null)
|
|
if (fileInputRef.current) fileInputRef.current.value = ''
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<div className="space-y-2">
|
|
<div className="flex justify-between text-sm">
|
|
<span className="sm:hidden text-primary font-medium">
|
|
{t('underlag_step_counter', {
|
|
current: currentStepIndex + 1,
|
|
total: steps.length,
|
|
label: stepLabels[step],
|
|
})}
|
|
</span>
|
|
{steps.map((s, i) => (
|
|
<span
|
|
key={s}
|
|
className={cn(
|
|
'hidden sm:inline',
|
|
i <= currentStepIndex ? 'text-primary font-medium' : 'text-muted-foreground',
|
|
)}
|
|
>
|
|
{stepLabels[s]}
|
|
</span>
|
|
))}
|
|
</div>
|
|
<Progress value={progress} className="h-2" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{error && <AttnLine>{error}</AttnLine>}
|
|
|
|
{step === 'select' && (
|
|
<Card>
|
|
<CardContent className="space-y-6 pt-6">
|
|
<div className="space-y-2 text-sm text-muted-foreground">
|
|
<p>{t('underlag_intro')}</p>
|
|
<p>{t('underlag_intro_formats')}</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<p className="text-sm">{t('underlag_year_label')}</p>
|
|
<FyPicker
|
|
value={fiscalPeriodId}
|
|
onChange={(id, period) => {
|
|
// Ignored while a preview is in flight: that request already
|
|
// captured a year and the plan must not disagree with the
|
|
// control the user is looking at.
|
|
if (isLoading) return
|
|
setFiscalPeriodId(id)
|
|
setFiscalPeriod(period ?? null)
|
|
}}
|
|
includeAllOption={false}
|
|
// The year is the user's assertion, so it must be the user who
|
|
// makes it, EVERY session. Defaulting to the newest year lets
|
|
// a 2023 batch resolve against 2026; restoring last-used is
|
|
// aimed even worse, since a multi-year migration by definition
|
|
// moves to a different year each round. Within one sitting,
|
|
// reset() carries the choice across batches; nothing else does.
|
|
requireExplicitChoice
|
|
className={isLoading ? 'pointer-events-none opacity-60' : undefined}
|
|
/>
|
|
<p className="text-[12.5px] leading-5 text-muted-foreground">
|
|
{t('underlag_year_help')}
|
|
</p>
|
|
</div>
|
|
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
multiple
|
|
accept={ACCEPTED_TYPES}
|
|
className="hidden"
|
|
onChange={(e) => handleFilesSelected(e.target.files)}
|
|
/>
|
|
|
|
<Button
|
|
onClick={() => fileInputRef.current?.click()}
|
|
disabled={isLoading || !fiscalPeriodId}
|
|
>
|
|
{isLoading ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<FileUp className="h-4 w-4" />
|
|
)}
|
|
{t('underlag_pick_files')}
|
|
</Button>
|
|
|
|
{/* Both the picker and the button are disabled until a year is
|
|
chosen, and if the company has no fiscal years at all the
|
|
picker never becomes usable. Say why rather than leave two
|
|
dead controls on screen. */}
|
|
{!fiscalPeriodId && (
|
|
<p className="text-[12.5px] leading-5 text-muted-foreground">
|
|
{t('underlag_year_required')}
|
|
</p>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{step === 'review' && plan && (
|
|
<div className="space-y-4">
|
|
{plan.no_source_refs && <AttnLine>{t('underlag_no_source_refs')}</AttnLine>}
|
|
{!plan.no_source_refs && plan.summary.period_locked > 0 && (
|
|
<AttnLine>
|
|
{t('underlag_locked_warning', { count: plan.summary.period_locked })}
|
|
</AttnLine>
|
|
)}
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
{t('underlag_summary', {
|
|
matched: plan.summary.matched,
|
|
total: plan.summary.total,
|
|
year: planPeriod?.name ?? '',
|
|
})}
|
|
</p>
|
|
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full border-collapse text-[13px]">
|
|
<thead>
|
|
<tr>
|
|
<th className={cn(TH_CLASS, 'w-10')}>
|
|
<span className="sr-only">{t('underlag_col_include')}</span>
|
|
</th>
|
|
<th className={TH_CLASS}>{t('underlag_col_file')}</th>
|
|
<th className={TH_CLASS}>{t('underlag_col_ref')}</th>
|
|
<th className={TH_CLASS}>{t('underlag_col_target')}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="stagger-enter">
|
|
{rows.map((row) => (
|
|
<tr key={row.id} className="hover:bg-secondary/35">
|
|
<td className={TD_CLASS}>
|
|
<input
|
|
type="checkbox"
|
|
className="h-4 w-4 rounded-sm border-border"
|
|
checked={row.selected}
|
|
disabled={!row.targetId}
|
|
aria-label={t('underlag_col_include')}
|
|
onChange={(e) =>
|
|
updateRow(row.id, { selected: e.target.checked })
|
|
}
|
|
/>
|
|
</td>
|
|
<td className={cn(TD_CLASS, 'max-w-[22rem] truncate')} title={row.file_name}>
|
|
{row.file_name}
|
|
</td>
|
|
<td className={cn(TD_CLASS, 'tabular-nums')}>
|
|
{row.parsed_ref
|
|
? `${row.parsed_ref.series ?? ''}${row.parsed_ref.number}`
|
|
: <span className="text-muted-foreground">{t('underlag_ref_none')}</span>}
|
|
</td>
|
|
<td className={TD_CLASS}>
|
|
<TargetCell
|
|
row={row}
|
|
onPick={(candidate) =>
|
|
updateRow(row.id, {
|
|
targetId: candidate.journal_entry_id,
|
|
selected: !candidate.period_locked,
|
|
// Deliberately NOT `manual`: the server proposed
|
|
// this candidate itself, so it can re-derive it.
|
|
// Flagging it would switch the filename check off
|
|
// on exactly the rows it exists to protect.
|
|
})
|
|
}
|
|
onManualRefChange={(value) =>
|
|
updateRow(row.id, { manualRef: value })
|
|
}
|
|
onManualRefSubmit={() => resolveManualRef(row)}
|
|
t={t}
|
|
/>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
<Button onClick={runAttach} disabled={isLoading || selectedRows.length === 0}>
|
|
{isLoading && <Loader2 className="h-4 w-4 animate-spin" />}
|
|
{isLoading
|
|
? t('underlag_running', { done: attached, total: selectedRows.length })
|
|
: t('underlag_run', { count: selectedRows.length })}
|
|
</Button>
|
|
<Button variant="outline" onClick={reset} disabled={isLoading}>
|
|
{t('underlag_back')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{step === 'result' && (
|
|
<Card>
|
|
<CardContent className="space-y-6 pt-6">
|
|
<p className="text-sm">
|
|
{t('underlag_done_body', {
|
|
linked: outcomes.filter((o) => o.ok).length,
|
|
failed: outcomes.filter((o) => !o.ok).length,
|
|
})}
|
|
</p>
|
|
|
|
{outcomes.some((o) => !o.ok) ? (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full border-collapse text-[13px]">
|
|
<thead>
|
|
<tr>
|
|
<th className={TH_CLASS}>{t('underlag_col_file')}</th>
|
|
<th className={TH_CLASS}>{t('underlag_col_error')}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{outcomes
|
|
.filter((o) => !o.ok)
|
|
.map((o, index) => (
|
|
<tr key={`${index}:${o.file_name}`}>
|
|
<td className={TD_CLASS}>{o.file_name}</td>
|
|
<td className={cn(TD_CLASS, 'text-muted-foreground')}>{o.message}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
) : (
|
|
<EmptyState
|
|
title={t('underlag_all_ok_title')}
|
|
description={t('underlag_all_ok_body')}
|
|
/>
|
|
)}
|
|
|
|
<Button onClick={reset}>{t('underlag_new_import')}</Button>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
<DestructiveConfirmDialog {...dialogProps} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function TargetCell({
|
|
row,
|
|
onPick,
|
|
onManualRefChange,
|
|
onManualRefSubmit,
|
|
t,
|
|
}: {
|
|
row: ReviewRow
|
|
onPick: (candidate: UnderlagPlanCandidate) => void
|
|
onManualRefChange: (value: string) => void
|
|
onManualRefSubmit: () => void
|
|
t: Translate
|
|
}) {
|
|
// A single candidate is shown even when it is not selectable (locked period):
|
|
// the user needs to see WHICH verifikat the file wanted before deciding
|
|
// whether to unlock the year.
|
|
if (row.candidates.length === 1) {
|
|
const only = row.candidates[0]
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<span className="tabular-nums">{only.voucher_label}</span>
|
|
<span className="text-muted-foreground">{formatDate(only.entry_date)}</span>
|
|
{row.status !== 'matched' && (
|
|
<Badge variant={badgeVariant(row.status)} className="font-normal">
|
|
{t(STATUS_KEY[row.status])}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (row.candidates.length > 1) {
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<select
|
|
className="h-8 rounded-lg border border-border bg-background px-2 text-[13px]"
|
|
value={row.targetId ?? ''}
|
|
aria-label={t('underlag_col_target')}
|
|
onChange={(e) => {
|
|
const candidate = row.candidates.find((c) => c.journal_entry_id === e.target.value)
|
|
if (candidate) onPick(candidate)
|
|
}}
|
|
>
|
|
<option value="">{t('underlag_pick_candidate')}</option>
|
|
{row.candidates.map((candidate) => (
|
|
<option
|
|
key={candidate.journal_entry_id}
|
|
value={candidate.journal_entry_id}
|
|
disabled={candidate.period_locked}
|
|
>
|
|
{candidate.voucher_label} {formatDate(candidate.entry_date)}
|
|
{candidate.period_locked ? ` (${t('underlag_status_period_locked')})` : ''}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<Badge variant="warning" className="font-normal">
|
|
{t('underlag_status_ambiguous')}
|
|
</Badge>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<Input
|
|
value={row.manualRef}
|
|
placeholder={t('underlag_manual_placeholder')}
|
|
aria-label={t('underlag_manual_placeholder')}
|
|
className="h-8 w-32 text-[13px]"
|
|
onChange={(e) => onManualRefChange(e.target.value)}
|
|
onBlur={onManualRefSubmit}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault()
|
|
onManualRefSubmit()
|
|
}
|
|
}}
|
|
/>
|
|
{row.resolving ? (
|
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
|
) : (
|
|
<Badge variant={badgeVariant(row.status)} className="font-normal">
|
|
{t(STATUS_KEY[row.status])}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|