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
347 lines
12 KiB
JavaScript
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>
|
|
)
|
|
}
|