'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_.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 = { 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 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(null) const [step, setStep] = useState('select') const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(null) const [fiscalPeriodId, setFiscalPeriodId] = useState(null) const [fiscalPeriod, setFiscalPeriod] = useState(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(null) const [plan, setPlan] = useState(null) const [rows, setRows] = useState([]) const [attached, setAttached] = useState(0) const [outcomes, setOutcomes] = useState([]) const steps: Step[] = ['select', 'review', 'result'] const stepLabels: Record = { 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 => { 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) => { 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 (
{t('underlag_step_counter', { current: currentStepIndex + 1, total: steps.length, label: stepLabels[step], })} {steps.map((s, i) => ( {stepLabels[s]} ))}
{error && {error}} {step === 'select' && (

{t('underlag_intro')}

{t('underlag_intro_formats')}

{t('underlag_year_label')}

{ // 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} />

{t('underlag_year_help')}

handleFilesSelected(e.target.files)} /> {/* 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 && (

{t('underlag_year_required')}

)}
)} {step === 'review' && plan && (
{plan.no_source_refs && {t('underlag_no_source_refs')}} {!plan.no_source_refs && plan.summary.period_locked > 0 && ( {t('underlag_locked_warning', { count: plan.summary.period_locked })} )}

{t('underlag_summary', { matched: plan.summary.matched, total: plan.summary.total, year: planPeriod?.name ?? '', })}

{rows.map((row) => ( ))}
{t('underlag_col_include')} {t('underlag_col_file')} {t('underlag_col_ref')} {t('underlag_col_target')}
updateRow(row.id, { selected: e.target.checked }) } /> {row.file_name} {row.parsed_ref ? `${row.parsed_ref.series ?? ''}${row.parsed_ref.number}` : {t('underlag_ref_none')}} 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} />
)} {step === 'result' && (

{t('underlag_done_body', { linked: outcomes.filter((o) => o.ok).length, failed: outcomes.filter((o) => !o.ok).length, })}

{outcomes.some((o) => !o.ok) ? (
{outcomes .filter((o) => !o.ok) .map((o, index) => ( ))}
{t('underlag_col_file')} {t('underlag_col_error')}
{o.file_name} {o.message}
) : ( )}
)}
) } 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 (
{only.voucher_label} {formatDate(only.entry_date)} {row.status !== 'matched' && ( {t(STATUS_KEY[row.status])} )}
) } if (row.candidates.length > 1) { return (
{t('underlag_status_ambiguous')}
) } return (
onManualRefChange(e.target.value)} onBlur={onManualRefSubmit} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault() onManualRefSubmit() } }} /> {row.resolving ? ( ) : ( {t(STATUS_KEY[row.status])} )}
) }