Linking migrated underlag to verifikat by filename ran one request per file, strictly in sequence, and each request awaited a vision-model extraction through document.uploaded even though the file lands on an already-posted verifikat. A few hundred files took ten-plus minutes in the foreground. - The attach route passes extractionOwner: 'none' to uploadDocument: the booking is already known, so the model pass bought nothing. Same opt-out the provider underlag sweep took in #1783. - The wizard runs the attach step through mapWithConcurrency with a pool of 4 instead of one-after-another; the per-file counter still ticks and the outcome list keeps plan order. The per-file re-plan (planPermitsAttach) still costs two DB round trips per file; it is bounded now that the pool overlaps them, and left as is. Closes #2188 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>
690 lines
26 KiB
TypeScript
690 lines
26 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, 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'
|
|
|
|
/**
|
|
* 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 [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)
|
|
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)}
|
|
/>
|
|
|
|
<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>
|
|
)
|
|
}
|