dataflow/ui/src/pages/Import.jsx
Paul Trowbridge 356e61d043 Add a mobile layout and stop shipping Perspective to every page
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
2026-08-02 14:09:06 -04:00

347 lines
12 KiB
JavaScript

import { useState, useEffect, useRef } from 'react'
import { api } from '../api'
function KeyList({ keys, label, color }) {
if (!keys || keys.length === 0) return null
return (
<div className="mb-2">
<div className={`text-xs font-medium mb-1 ${color}`}>{label} ({keys.length})</div>
<div className="max-h-32 overflow-y-auto bg-raised rounded p-2 font-mono text-xs text-muted space-y-0.5">
{keys.map((k, i) => (
<div key={i}>
{typeof k === 'object' && k !== null
? Object.entries(k).map(([field, val]) => `${field}: ${val}`).join(' · ')
: k}
</div>
))}
</div>
</div>
)
}
function LogRow({ entry, selected, onToggle }) {
const [expanded, setExpanded] = useState(false)
const info = entry.info || {}
const insertedKeys = info.inserted_keys || []
const excludedKeys = info.excluded_keys || []
const hasKeys = insertedKeys.length > 0 || excludedKeys.length > 0
return (
<>
<tr className={`border-b border-line-soft ${selected ? 'bg-danger-soft' : ''}`}>
<td className="py-1.5 pr-2">
<input type="checkbox" checked={selected} onChange={onToggle} className="cursor-pointer" />
</td>
<td className="py-1.5 text-xs text-muted font-mono">{entry.id}</td>
<td className="py-1.5 text-muted">{new Date(entry.imported_at).toLocaleString()}</td>
<td className="py-1.5 text-ink">{entry.records_imported}</td>
<td className="py-1.5 text-muted">{entry.records_duplicate}</td>
<td className="py-1.5">
{hasKeys && (
<button
onClick={() => setExpanded(e => !e)}
className="text-xs text-accent hover:text-accent"
>
{expanded ? '▲ hide' : '▼ keys'}
</button>
)}
</td>
</tr>
{expanded && (
<tr className={selected ? 'bg-danger-soft' : 'bg-raised'}>
<td colSpan={6} className="px-4 py-3">
<KeyList keys={insertedKeys} label="Inserted" color="text-ok" />
<KeyList keys={excludedKeys} label="Excluded" color="text-muted" />
</td>
</tr>
)}
</>
)
}
export default function Import({ source }) {
const [stats, setStats] = useState(null)
const [log, setLog] = useState([])
const [result, setResult] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [dragOver, setDragOver] = useState(false)
const [selected, setSelected] = useState(new Set())
const [simplefin, setSimplefin] = useState(null)
const [days, setDays] = useState('10')
const fileRef = useRef()
useEffect(() => {
if (!source) return
api.getStats(source).then(setStats).catch(() => {})
api.getImportLog(source).then(setLog).catch(() => {})
api.getSource(source).then(s => setSimplefin(s.config?.simplefin || null)).catch(() => setSimplefin(null))
setSelected(new Set())
}, [source])
async function handleImport(file) {
if (!file || !source) return
setLoading(true)
setError('')
setResult(null)
try {
const res = await api.importCSV(source, file)
setResult(res)
api.getStats(source).then(setStats)
api.getImportLog(source).then(setLog)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
async function handleSync() {
if (!source) return
setLoading(true)
setError('')
setResult(null)
try {
const res = await api.syncSimpleFin(source, { days })
setResult(res)
api.getStats(source).then(setStats)
api.getImportLog(source).then(setLog)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
async function handleTransform() {
if (!source) return
setLoading(true)
try {
const res = await api.transform(source)
setResult(res)
api.getStats(source).then(setStats)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
function toggleSelect(id) {
setSelected(prev => {
const next = new Set(prev)
next.has(id) ? next.delete(id) : next.add(id)
return next
})
}
async function handleDeleteSelected() {
if (selected.size === 0) return
const plural = selected.size === 1 ? 'import' : 'imports'
if (!confirm(`Delete ${selected.size} ${plural}? This will permanently remove all records from those batches.`)) return
setLoading(true)
try {
await Promise.all([...selected].map(id => api.deleteImport(source, id)))
const [newLog, newStats] = await Promise.all([api.getImportLog(source), api.getStats(source)])
setLog(newLog)
setStats(newStats)
setSelected(new Set())
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
async function handleReprocess() {
if (!confirm('Reprocess all records? This will clear and reapply all transformation rules.')) return
setLoading(true)
setResult(null)
try {
const res = await api.reprocess(source)
setResult(res)
api.getStats(source).then(setStats)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
if (!source) return <div className="p-4 sm:p-6 text-sm text-muted">Select a source first.</div>
return (
<div className="p-4 sm:p-6 max-w-2xl">
<h1 className="text-xl font-semibold text-ink mb-6">Import {source}</h1>
{/* Stats */}
{stats && (
<div className="flex gap-4 mb-6">
{[
{ label: 'Total records', value: stats.total_records },
{ label: 'Transformed', value: stats.transformed_records },
{ label: 'Pending', value: stats.pending_records },
].map(({ label, value }) => (
<div key={label} className="bg-surface border border-line rounded px-4 py-3 flex-1 text-center">
<div className="text-2xl font-semibold text-ink">{value}</div>
<div className="text-xs text-muted mt-0.5">{label}</div>
</div>
))}
</div>
)}
{/* SimpleFIN sync — only for sources with a bridge account in their config */}
{simplefin?.account_id && (
<div className="bg-surface border border-line rounded p-4 mb-4 flex items-center gap-3">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-ink-soft">SimpleFIN</div>
<div className="text-xs text-muted font-mono truncate">{simplefin.account_id}</div>
</div>
<select
value={days}
onChange={e => setDays(e.target.value)}
className="text-sm border border-line rounded px-2 py-1.5 bg-surface text-ink-soft"
>
<option value="10">Last 10 days</option>
<option value="30">Last 30 days</option>
<option value="45">Last 45 days</option>
<option value="89">Backfill (bridge maximum)</option>
</select>
<button onClick={handleSync} disabled={loading}
className="text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700 disabled:opacity-50">
{loading ? 'Syncing…' : 'Sync now'}
</button>
</div>
)}
{/* Drop zone */}
<div
className={`border-2 border-dashed rounded-lg p-8 text-center mb-4 cursor-pointer transition-colors ${
dragOver ? 'border-accent bg-accent-soft' : 'border-line hover:border-line'
}`}
onDragOver={e => { e.preventDefault(); setDragOver(true) }}
onDragLeave={() => setDragOver(false)}
onDrop={e => { e.preventDefault(); setDragOver(false); handleImport(e.dataTransfer.files[0]) }}
onClick={() => fileRef.current?.click()}
>
<input
ref={fileRef}
type="file"
accept=".csv"
className="hidden"
onChange={e => handleImport(e.target.files[0])}
/>
{loading
? <p className="text-sm text-muted">Importing</p>
: <p className="text-sm text-muted">Drop a CSV file here, or click to browse</p>
}
</div>
{error && <p className="text-sm text-danger mb-3">{error}</p>}
{result && (
<div className={`border rounded p-4 mb-4 text-sm ${result.success === false ? 'bg-danger-soft border-danger-line' : 'bg-surface border-line'}`}>
{result.success === false ? (
<>
<p className="text-danger font-medium mb-2">{result.error}</p>
{result.duplicate_rows && (
<div>
<p className="text-xs text-danger mb-1">Offending rows:</p>
<div className="max-h-48 overflow-y-auto bg-surface rounded border border-danger-line p-2 font-mono text-xs text-danger space-y-0.5">
{result.duplicate_rows.map((row, i) => (
<div key={i}>
{Object.entries(row).map(([f, v]) => `${f}: ${v}`).join(' · ')}
</div>
))}
</div>
</div>
)}
</>
) : result.imported !== undefined ? (
<>
{result.errors?.length > 0 && (
<div className="mb-2 text-xs text-warn">
{result.errors.map((e, i) => <div key={i}>Bridge: {e}</div>)}
</div>
)}
{result.fetched !== undefined && (
<>
<span className="text-muted">{result.fetched} fetched</span>
<span className="text-muted mx-2">·</span>
</>
)}
<span className="text-ok font-medium">{result.imported} imported</span>
<span className="text-muted mx-2">·</span>
<span className="text-muted">{result.duplicates} duplicates skipped</span>
{result.transform && (
<>
<span className="text-muted mx-2">·</span>
<span className="text-muted">{result.transform.transformed} transformed</span>
</>
)}
</>
) : (
<span className="text-ok font-medium">{result.transformed} records transformed</span>
)}
</div>
)}
{/* Action buttons */}
<div className="flex gap-2 mb-6">
{stats && Number(stats.pending_records) > 0 && (
<button onClick={handleTransform} disabled={loading}
className="text-sm bg-green-600 text-white px-3 py-1.5 rounded hover:bg-green-700 disabled:opacity-50">
Transform {stats.pending_records} pending records
</button>
)}
{stats && Number(stats.total_records) > 0 && (
<button onClick={handleReprocess} disabled={loading}
className="text-sm bg-orange-500 text-white px-3 py-1.5 rounded hover:bg-orange-600 disabled:opacity-50">
Reprocess all records
</button>
)}
</div>
{/* Import log */}
{log.length > 0 && (
<div>
<div className="flex items-center justify-between mb-2">
<h2 className="text-sm font-semibold text-ink-soft">Import history</h2>
{selected.size > 0 && (
<button
onClick={handleDeleteSelected}
disabled={loading}
className="text-xs bg-red-500 text-white px-2.5 py-1 rounded hover:bg-red-600 disabled:opacity-50"
>
Delete {selected.size} selected
</button>
)}
</div>
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-muted border-b border-line-soft">
<th className="pb-1 w-6"></th>
<th className="pb-1 font-medium w-12">ID</th>
<th className="pb-1 font-medium">Date</th>
<th className="pb-1 font-medium">Imported</th>
<th className="pb-1 font-medium">Duplicates</th>
<th className="pb-1 w-16"></th>
</tr>
</thead>
<tbody>
{log.map(entry => (
<LogRow
key={entry.id}
entry={entry}
selected={selected.has(entry.id)}
onToggle={() => toggleSelect(entry.id)}
/>
))}
</tbody>
</table>
</div>
)}
</div>
)
}