Files
finance/frontend/src/pages/ImportDetailPage.tsx
T
k1nq 39974f3e5d
Gitea Actions CI / detect (push) Successful in 3s
Gitea Actions CI / Compose & nginx validation (push) Failing after 5s
Gitea Actions CI / Frontend (lint / type / test / build) (push) Successful in 50s
Gitea Actions CI / Backend (lint / type / test / build) (push) Successful in 1m34s
Update localization files and add documentation for выписки fixes
- Updated timestamps and AST hashes for English and Russian localization files.
- Modified timestamps and AST hashes for ImportDetailPage and ImportsPage components.
- Updated test file timestamps and AST hash for import-confirm.test.tsx.
- Added new documentation file for выписки fixes with relevant metadata.
2026-09-25 16:55:06 +05:00

461 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, type FormEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { Link, useParams } from 'react-router-dom'
import type { HoldResolutionAction, ImportRow, RowDecision } from '@/api/types'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
import { TextareaField } from '@/components/ui/fields'
import { Modal } from '@/components/ui/Modal'
import { PageHeader } from '@/components/ui/PageHeader'
import { QueryGate } from '@/components/ui/QueryGate'
import {
useConfirmImport,
useImport,
useReparseImport,
useResolveHold,
useRevertImport,
useSaveRowDecision,
} from '@/hooks/useImports'
import { formatDate, formatDateTime } from '@/lib/dates'
import { currentLocale } from '@/lib/locale'
import { formatMoney } from '@/lib/money'
import { IMPORT_STATUS_TONES } from '@/lib/operationFormat'
const DECISIONS: readonly Exclude<RowDecision, 'pending'>[] = [
'create',
'discard',
'duplicate',
'transfer',
'savings_transfer',
]
const STEP_ORDER = ['uploaded', 'queued', 'processing', 'review_required', 'confirmed'] as const
function stepIndexFor(status: string): number {
if (status === 'failed') return STEP_ORDER.length - 1
if (status === 'ready') return STEP_ORDER.indexOf('review_required')
const index = STEP_ORDER.indexOf(status as (typeof STEP_ORDER)[number])
return index === -1 ? 0 : index
}
function HoldResolveModal({
importId,
row,
action,
onClose,
}: {
importId: string
row: ImportRow
action: HoldResolutionAction
onClose: () => void
}) {
const { t, i18n } = useTranslation()
const locale = currentLocale(i18n.language)
const resolve = useResolveHold(importId)
const [reason, setReason] = useState('')
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
resolve.mutate(
{ rowId: row.id, payload: { action, reason: reason.trim() || undefined } },
{ onSuccess: onClose },
)
}
return (
<Modal
open
onClose={onClose}
title={
action === 'confirm_as_posted'
? t('imports.holdPostedTitle')
: t('imports.holdCancelledTitle')
}
>
<form onSubmit={submit} noValidate className="form-grid">
<p className="muted">
{action === 'confirm_as_posted'
? t('imports.holdPostedBody', {
date: formatDate(row.occurredOn, locale),
amount: formatMoney(row.amountKopecks, locale),
})
: t('imports.holdCancelledBody', {
date: formatDate(row.occurredOn, locale),
amount: formatMoney(row.amountKopecks, locale),
})}
</p>
<TextareaField
label={t('imports.holdReason')}
hint={t('imports.holdReasonHint')}
value={reason}
onChange={(event) => setReason(event.target.value)}
/>
<Button type="submit" variant="primary" disabled={resolve.isPending}>
{resolve.isPending ? t('common.saving') : t('common.confirm')}
</Button>
</form>
</Modal>
)
}
function ConfirmModal({
importId,
needsConfirmation,
onClose,
}: {
importId: string
needsConfirmation: boolean
onClose: () => void
}) {
const { t } = useTranslation()
const confirm = useConfirmImport(importId)
const [acknowledged, setAcknowledged] = useState(false)
const disabled = needsConfirmation && !acknowledged
return (
<Modal open onClose={onClose} title={t('imports.confirmTitle')}>
<div className="form-grid">
<p className="muted">{t('imports.confirmBody')}</p>
{needsConfirmation ? (
<>
<label className="checkbox-field">
<input
type="checkbox"
checked={acknowledged}
onChange={(event) => setAcknowledged(event.target.checked)}
/>
{t('imports.confirmWithout')}
</label>
</>
) : null}
<Button
disabled={disabled || confirm.isPending}
onClick={() =>
confirm.mutate(needsConfirmation ? { confirmedWithoutFullReconciliation: true } : {}, {
onSuccess: onClose,
})
}
>
{t('imports.confirm')}
</Button>
</div>
</Modal>
)
}
export function ImportDetailPage() {
const { t, i18n } = useTranslation()
const locale = currentLocale(i18n.language)
const { id = '' } = useParams()
const detail = useImport(id)
const reparse = useReparseImport(id)
const revert = useRevertImport(id)
const saveDecision = useSaveRowDecision(id)
const [confirmOpen, setConfirmOpen] = useState(false)
const [revertOpen, setRevertOpen] = useState(false)
const [holdTarget, setHoldTarget] = useState<{ row: ImportRow; action: HoldResolutionAction } | null>(null)
const status = detail.data?.status
const needsConfirmation =
detail.data?.reconciliation.status === 'mismatched' ||
detail.data?.reconciliation.status === 'not_available'
const goToIssueRow = (rowId: string) => {
const row = document.getElementById(`statement-row-${rowId}`)
row?.scrollIntoView({ block: 'center', behavior: 'instant' })
row?.querySelector<HTMLElement>('select, button')?.focus({ preventScroll: true })
}
return (
<div className="import-detail">
<Link className="import-back" to="/imports">{t('imports.backToList')}</Link>
<PageHeader
title={t('imports.detailTitle')}
actions={
<div className="page-actions-row">
{status && ['review_required', 'ready', 'failed'].includes(status) ? (
<Button variant="subtle" disabled={reparse.isPending} onClick={() => reparse.mutate()}>
{t('imports.reparse')}
</Button>
) : null}
{status === 'confirmed' ? (
<Button variant="danger" disabled={revert.isPending} onClick={() => setRevertOpen(true)}>
{t('imports.revert')}
</Button>
) : null}
{status && ['review_required', 'ready'].includes(status) ? (
<Button onClick={() => setConfirmOpen(true)}>{t('imports.confirm')}</Button>
) : null}
</div>
}
/>
{detail.data ? (
<p className="import-source">
<strong>{detail.data.fileName}</strong>
<span> · {detail.data.accountName ?? t('imports.unassigned')} · {detail.data.bankAdapter}</span>
</p>
) : null}
<div className="import-statuses">
{status ? <Badge tone={IMPORT_STATUS_TONES[status]}>{t(`imports.status.${status}`)}</Badge> : null}
{detail.data && detail.data.unresolvedHoldCount > 0 ? (
<Badge tone="warn">{t('imports.unresolvedHoldsBadge', { count: detail.data.unresolvedHoldCount })}</Badge>
) : null}
</div>
<QueryGate query={detail}>
{(data) => (
<>
<ol className="steps" aria-label={t('imports.progress')}>
{STEP_ORDER.map((step, index) => {
const currentIndex = stepIndexFor(data.status)
const state =
index < currentIndex ? 'done' : index === currentIndex ? 'current' : 'todo'
return (
<li key={step} className={`step step-${state}`} aria-current={state === 'current' ? 'step' : undefined}>
{t(`imports.steps.${step === 'review_required' && data.status === 'ready' ? 'ready' : step}`)}
</li>
)
})}
</ol>
{data.status === 'failed' && data.failureReason ? (
<div className="banner banner-danger" role="alert">
{t('imports.failedReason', { reason: data.failureReason })}
</div>
) : null}
{data.warnings.length > 0 ? (
<details className="banner banner-warn">
<summary>{t('imports.warnings', { count: data.warnings.length })}</summary>
<ul>
{data.warnings.map((issue, index) => (
<li key={`${issue.code}-${index}`}>
{t(`imports.warnings.${issue.code}`, { defaultValue: issue.message ?? issue.code })}
</li>
))}
</ul>
</details>
) : null}
<section aria-label={t('imports.reconciliationTitle')} className="import-reconciliation">
<h2 className="section-title">{t('imports.reconciliationTitle')}</h2>
<Badge tone={data.reconciliation.status === 'matched' ? 'accent' : 'warn'}>
{t(`imports.reconciliation.${data.reconciliation.status}`)}
</Badge>
<dl className="import-balances">
<div>
<dt>{t('imports.openingBalance')}</dt>
<dd className="num">
{data.reconciliation.openingBalanceKopecks !== null
? formatMoney(data.reconciliation.openingBalanceKopecks, locale)
: '—'}
</dd>
</div>
<div>
<dt>{t('imports.closingBalance')}</dt>
<dd className="num">
{data.reconciliation.closingBalanceKopecks !== null
? formatMoney(data.reconciliation.closingBalanceKopecks, locale)
: '—'}
</dd>
</div>
<div>
<dt>{t('imports.computedBalance')}</dt>
<dd className="num">
{data.reconciliation.computedClosingKopecks !== null
? formatMoney(data.reconciliation.computedClosingKopecks, locale)
: '—'}
</dd>
</div>
</dl>
</section>
{data.blockers.length > 0 ? (
<section className="import-issues" aria-label={t('imports.blockers')}>
<h2>{t('imports.blockers')}</h2>
<ul>
{data.blockers.map((issue, index) => {
const row = data.rows.find((item) => item.id === issue.rowId)
return (
<li key={`${issue.code}-${issue.rowId ?? ''}-${index}`}>
<span>
<strong>{t(`imports.blockers.${issue.code}`, { defaultValue: issue.message ?? issue.code })}</strong>
{row ? <small>{formatDate(row.occurredOn, locale)} · {row.description ?? '—'} · {formatMoney(row.amountKopecks, locale)}</small> : null}
</span>
{row ? (
<button type="button" className="import-issue-link" onClick={() => goToIssueRow(row.id)}>
{t('imports.goToIssueRow')}
</button>
) : null}
</li>
)
})}
</ul>
</section>
) : null}
<section aria-label={t('imports.rowsTitle')} className="import-rows">
<h2 className="section-title">{t('imports.rowsTitle')}</h2>
<p className="import-row-count">{t('imports.totals', {
total: data.totals.total,
create: data.totals.create,
discard: data.totals.discard,
duplicate: data.totals.duplicate,
transfer: data.totals.transfer,
savings: data.totals.savingsTransfer,
pending: data.totals.pending,
})}</p>
{data.rows.length === 0 ? (
<p className="muted">{t('imports.noRows')}</p>
) : (
<table className="data-table">
<caption className="visually-hidden">{t('imports.rowsTitle')}</caption>
<colgroup><col className="import-col-date" /><col /><col className="import-col-amount" /><col className="import-col-warnings" /><col className="import-col-decision" /></colgroup>
<thead>
<tr>
<th scope="col">{t('common.date')}</th>
<th scope="col">{t('operations.description')}</th>
<th scope="col" className="num">{t('common.amount')}</th>
<th scope="col">{t('imports.rowWarnings')}</th>
<th scope="col">{t('imports.decision')}</th>
</tr>
</thead>
<tbody>
{data.rows.map((row) => (
<tr key={row.id} id={`statement-row-${row.id}`} className={`statement-row${data.blockers.some((issue) => issue.rowId === row.id) ? ' statement-row-issue' : ''}`}>
<th scope="row" data-label={t('common.date')} className="statement-date">
{formatDate(row.occurredOn, locale)}
</th>
<td data-label={t('operations.description')} className="statement-description">
{row.description ?? '—'}
{row.payeeHint ? <span className="muted"> · {row.payeeHint}</span> : null}
{row.mcc !== null ? (
<span className="muted"> · {t('imports.mcc')} {row.mcc}{row.mccName ? ` · ${row.mccName}` : ''}</span>
) : null}
</td>
<td
data-label={t('common.amount')}
className={`statement-amount num ${row.direction === 'in' ? 'pos' : 'neg'}`}
>
{row.direction === 'in' ? '+' : '−'}
{formatMoney(row.amountKopecks, locale)}
</td>
<td data-label={t('imports.rowWarnings')} className="statement-warnings">
{row.bankStatus === 'hold' ? (
<span className="chip">{t('imports.holdBadge')}</span>
) : row.warnings.length === 0 ? (
'—'
) : (
<ul className="chip-list">
{row.warnings.map((warning) => (
<li key={warning} className="chip">
{t(`imports.rowWarnings.${warning}`, { defaultValue: warning })}
</li>
))}
</ul>
)}
</td>
<td data-label={t('imports.decision')} className="statement-decision">
{row.bankStatus === 'hold' ? (
row.holdResolution ? (
<div className="hold-history">
<Badge
tone={
row.holdResolution.resolution === 'confirmed_as_posted'
? 'accent'
: 'neutral'
}
>
{t(
`imports.holdResolution.${row.holdResolution.resolution}`,
)}
</Badge>
{row.holdResolution.reason ? (
<span className="muted">{row.holdResolution.reason}</span>
) : null}
{row.holdResolution.resolvedAt ? (
<span className="muted">
{formatDateTime(row.holdResolution.resolvedAt, locale)}
</span>
) : null}
</div>
) : (
<div className="inline-actions">
<Button
variant="subtle"
onClick={() =>
setHoldTarget({ row, action: 'confirm_as_posted' })
}
>
{t('imports.holdPosted')}
</Button>
<Button
variant="subtle"
onClick={() =>
setHoldTarget({ row, action: 'discard_as_cancelled' })
}
>
{t('imports.holdCancelled')}
</Button>
</div>
)
) : (
<select
className="input select"
aria-label={`${t('imports.decision')}: ${row.description ?? formatDate(row.occurredOn, locale)}`}
value={row.decision === 'pending' ? '' : row.decision}
onChange={(event) =>
saveDecision.mutate({
rowId: row.id,
payload: {
decision: event.target.value as Exclude<RowDecision, 'pending'>,
},
})
}
>
<option value="">{t('imports.decision.pending')}</option>
{DECISIONS.map((decision) => (
<option key={decision} value={decision}>
{t(`imports.decision.${decision}`)}
</option>
))}
</select>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
{holdTarget ? (
<HoldResolveModal
importId={data.id}
row={holdTarget.row}
action={holdTarget.action}
onClose={() => setHoldTarget(null)}
/>
) : null}
{confirmOpen ? (
<ConfirmModal
importId={data.id}
needsConfirmation={needsConfirmation}
onClose={() => setConfirmOpen(false)}
/>
) : null}
<ConfirmDialog
open={revertOpen}
danger
title={t('imports.revertTitle')}
body={t('imports.revertBody', { fileName: data.fileName })}
confirmLabel={t('imports.revert')}
onClose={() => setRevertOpen(false)}
onConfirm={() => revert.mutate()}
/>
</>
)}
</QueryGate>
<p className="muted">
{detail.data ? t('imports.createdAtLine', { createdAt: formatDateTime(detail.data.createdAt, locale) }) : null}
</p>
</div>
)
}