Below md: the sidebar is replaced by a fixed bottom bar carrying the same destinations plus the theme toggle. NAV moved to navItems.jsx so the two can't drift apart. Perspective is now lazy-loaded. It was ~90% of the bundle and only Pivot uses it, so the initial download drops from 4.9 MB gzipped to 173 kB and the rest arrives only when a pivot is opened. Desktop benefits as much as phones do. Bridge's balance table becomes stacked cards under sm: with the same subtotals, source tabs scroll rather than wrap, and page gutters tighten on small screens. Rules, Mappings, and Records are deliberately untouched — they are wide data tables and belong on a desktop. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G2HFeU5neCKagTnmA6o9Tu
130 lines
4.8 KiB
JavaScript
130 lines
4.8 KiB
JavaScript
import { useState, useEffect } from 'react'
|
|
import { Link } from 'react-router-dom'
|
|
import { api } from '../api'
|
|
|
|
// Importing is the frequent job; configuring a source is the rare one. This is
|
|
// the top-level entry point for the frequent one — every source in one place,
|
|
// with a sync button for anything on a bank feed.
|
|
export default function ImportHub({ sources }) {
|
|
const [stats, setStats] = useState({}) // name -> stats
|
|
const [lastImport, setLastImport] = useState({}) // name -> ISO timestamp
|
|
const [busy, setBusy] = useState('')
|
|
const [results, setResults] = useState({}) // name -> message
|
|
const [errors, setErrors] = useState({}) // name -> message
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
Promise.all(sources.map(s =>
|
|
api.getStats(s.name).then(st => [s.name, st]).catch(() => [s.name, null])
|
|
)).then(pairs => {
|
|
if (!cancelled) setStats(Object.fromEntries(pairs))
|
|
})
|
|
api.getAllImportLog().then(log => {
|
|
if (cancelled) return
|
|
const latest = {}
|
|
for (const entry of log) {
|
|
if (!latest[entry.source_name] || entry.imported_at > latest[entry.source_name]) {
|
|
latest[entry.source_name] = entry.imported_at
|
|
}
|
|
}
|
|
setLastImport(latest)
|
|
}).catch(() => {})
|
|
return () => { cancelled = true }
|
|
}, [sources])
|
|
|
|
async function sync(name) {
|
|
setBusy(name)
|
|
setErrors(e => ({ ...e, [name]: '' }))
|
|
setResults(r => ({ ...r, [name]: '' }))
|
|
try {
|
|
const res = await api.syncSimpleFin(name, { days: 10 })
|
|
setResults(r => ({
|
|
...r,
|
|
[name]: `${res.imported} imported, ${res.duplicates} already had` +
|
|
(res.errors?.length ? ` — ${res.errors.join('; ')}` : ''),
|
|
}))
|
|
api.getStats(name).then(st => setStats(s => ({ ...s, [name]: st }))).catch(() => {})
|
|
api.getAllImportLog().then(log => {
|
|
const entry = log.find(l => l.source_name === name)
|
|
if (entry) setLastImport(l => ({ ...l, [name]: entry.imported_at }))
|
|
}).catch(() => {})
|
|
} catch (err) {
|
|
setErrors(e => ({ ...e, [name]: err.message }))
|
|
} finally {
|
|
setBusy('')
|
|
}
|
|
}
|
|
|
|
const feeds = sources.filter(s => s.config?.simplefin?.account_id)
|
|
const manual = sources.filter(s => !s.config?.simplefin?.account_id)
|
|
|
|
function Row({ s, isFeed }) {
|
|
const st = stats[s.name]
|
|
const when = lastImport[s.name]
|
|
return (
|
|
<div className="px-4 py-3 flex items-center gap-3 flex-wrap">
|
|
<div className="flex-1 min-w-40">
|
|
<Link to={`/sources/${encodeURIComponent(s.name)}/import`}
|
|
className="text-sm font-medium text-ink hover:text-accent">
|
|
{s.name}
|
|
</Link>
|
|
<div className="text-xs text-muted">
|
|
{st ? `${st.total_records} records` : '—'}
|
|
{st && Number(st.pending_records) > 0 && ` · ${st.pending_records} untransformed`}
|
|
{when && ` · last import ${new Date(when).toLocaleDateString()}`}
|
|
</div>
|
|
</div>
|
|
|
|
{results[s.name] && <span className="text-xs text-ok">{results[s.name]}</span>}
|
|
{errors[s.name] && <span className="text-xs text-danger">{errors[s.name]}</span>}
|
|
|
|
{isFeed ? (
|
|
<button
|
|
onClick={() => sync(s.name)}
|
|
disabled={busy === s.name}
|
|
className="text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700 disabled:opacity-50"
|
|
>
|
|
{busy === s.name ? 'Syncing…' : 'Sync'}
|
|
</button>
|
|
) : (
|
|
<Link to={`/sources/${encodeURIComponent(s.name)}/import`}
|
|
className="text-sm border border-line rounded px-3 py-1.5 text-ink-soft hover:bg-raised hover:border-line">
|
|
Upload CSV
|
|
</Link>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="p-4 sm:p-6 max-w-4xl space-y-6">
|
|
<h1 className="text-xl font-semibold text-ink">Import</h1>
|
|
|
|
{feeds.length > 0 && (
|
|
<div>
|
|
<h2 className="text-sm font-semibold text-ink-soft mb-2">Bank feeds</h2>
|
|
<div className="bg-surface border border-line rounded divide-y divide-line-soft">
|
|
{feeds.map(s => <Row key={s.name} s={s} isFeed />)}
|
|
</div>
|
|
<p className="text-xs text-muted mt-1">Syncs pull the last 10 days; use a source’s Import tab to backfill further.</p>
|
|
</div>
|
|
)}
|
|
|
|
{manual.length > 0 && (
|
|
<div>
|
|
<h2 className="text-sm font-semibold text-ink-soft mb-2">CSV sources</h2>
|
|
<div className="bg-surface border border-line rounded divide-y divide-line-soft">
|
|
{manual.map(s => <Row key={s.name} s={s} isFeed={false} />)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{sources.length === 0 && <p className="text-sm text-muted">No sources yet.</p>}
|
|
|
|
<Link to="/log" className="inline-block text-xs text-accent hover:text-accent">
|
|
Full import history →
|
|
</Link>
|
|
</div>
|
|
)
|
|
}
|