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
- 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.
461 lines
19 KiB
TypeScript
461 lines
19 KiB
TypeScript
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>
|
||
)
|
||
}
|