Files
accounted/components/import/UnderlagImportWizard.tsx
T
Jakob Wennberg aeff998b90 feat(import): folder pick for underlag attach (#2189) (#2230)
A migration's underlag arrives as one folder, and Ctrl+A in the file
picker is not obvious to everyone. The underlag wizard gets a second,
outlined "Välj mapp" button next to "Välj filer", backed by a hidden
webkitdirectory input.

A directory pick ignores the accept list and returns every file in the
tree, so the handler keeps only the document types the attach route takes
(PDF, JPEG, PNG, WebP), drops dotfiles, and explains itself when nothing
qualifies. The plan keys on file.name, so nested folders flatten harmlessly.

Closes #2189


Claude-Session: https://claude.ai/code/session_01QPQLwHNEiQfiCNLSMzXMiQ

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 17:17:25 +02:00

734 lines
28 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 { mapWithConcurrency } from '@/lib/concurrency'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { cn, formatDate } from '@/lib/utils'
import { FileUp, FolderUp, 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'
const ACCEPTED_MIME = new Set(ACCEPTED_TYPES.split(','))
/**
* Parallel attach requests during the final step. Same size as the
* transactions page's batch actions: enough that a 300-file migration
* finishes in a few minutes instead of a coffee break, small enough that a
* laptop on hotel wifi does not choke on in-flight uploads.
*/
const ATTACH_CONCURRENCY = 4
/** 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 folderInputRef = 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 | File[] | 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],
)
// Folder pick (#2189): a migration's underlag arrives as one folder, and
// Ctrl+A in the file picker is not obvious to everyone. A directory pick
// ignores `accept` and includes every file in the tree (Thumbs.db,
// .DS_Store, the export tool's own CSV), so keep only the document types
// the attach route takes before planning; the plan keys on file.name, so
// subfolders flatten harmlessly.
const handleFolderSelected = useCallback(
(fileList: FileList | null) => {
if (!fileList || fileList.length === 0) return
const files = Array.from(fileList).filter(
(f) => ACCEPTED_MIME.has(f.type) && !f.name.startsWith('.'),
)
if (files.length === 0) {
setError(t('underlag_folder_no_documents'))
return
}
void handleFilesSelected(files)
},
[handleFilesSelected, t],
)
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)
let results: AttachOutcome[] = []
try {
// A small worker pool, not one-after-another: each attach is a handful
// of serialized round trips (auth, company, plan, storage, insert), so
// a few hundred files took ten-plus minutes in sequence (#2188). The
// pool keeps the storage bucket and the browser bounded, the per-file
// counter still ticks, and mapWithConcurrency preserves plan order.
results = await mapWithConcurrency(selectedRows, ATTACH_CONCURRENCY, async (row) => {
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')
let outcome: AttachOutcome
try {
const res = await fetch('/api/import/documents/attach', {
method: 'POST',
body: formData,
})
if (!res.ok) {
const data = await res.json().catch(() => null)
outcome = {
file_name: row.file_name,
ok: false,
message: getErrorMessage(data, { statusCode: res.status }),
}
} else {
outcome = { file_name: row.file_name, ok: true }
}
} catch (err) {
outcome = { file_name: row.file_name, ok: false, message: getErrorMessage(err) }
}
setAttached((n) => n + 1)
return outcome
})
} 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)}
/>
{/* webkitdirectory is not in React's input typings but is what
every current browser honours for a folder pick; spread so
the attribute reaches the DOM without a type escape hatch. */}
<input
ref={folderInputRef}
type="file"
multiple
className="hidden"
onChange={(e) => handleFolderSelected(e.target.files)}
{...({ webkitdirectory: '' } as Record<string, string>)}
/>
<div className="flex flex-wrap items-center gap-2">
<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>
<Button
variant="outline"
onClick={() => folderInputRef.current?.click()}
disabled={isLoading || !fiscalPeriodId}
>
<FolderUp className="h-4 w-4" />
{t('underlag_pick_folder')}
</Button>
</div>
{/* 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>
)
}