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
215 lines
8.2 KiB
JavaScript
215 lines
8.2 KiB
JavaScript
import { useState, useRef } from 'react'
|
|
import { api } from '../api'
|
|
|
|
export default function Remap() {
|
|
const [search, setSearch] = useState('')
|
|
const [results, setResults] = useState(null)
|
|
const [searching, setSearching] = useState(false)
|
|
|
|
const [selected, setSelected] = useState(null) // { col, val }
|
|
const [matches, setMatches] = useState(null) // individual mappings
|
|
const [loadingMatches, setLoadingMatches] = useState(false)
|
|
|
|
const [toVal, setToVal] = useState('')
|
|
const [applying, setApplying] = useState(false)
|
|
const [msg, setMsg] = useState(null) // { text, ok }
|
|
|
|
const searchRef = useRef()
|
|
|
|
async function handleSearch(e) {
|
|
e.preventDefault()
|
|
const q = search.trim()
|
|
if (!q) return
|
|
setSearching(true)
|
|
setResults(null)
|
|
setSelected(null)
|
|
setMatches(null)
|
|
setMsg(null)
|
|
try {
|
|
const rows = await api.searchMappingOutputs(q)
|
|
setResults(rows)
|
|
} catch (err) {
|
|
setMsg({ text: err.message, ok: false })
|
|
} finally {
|
|
setSearching(false)
|
|
}
|
|
}
|
|
|
|
async function handleSelect(row) {
|
|
setSelected(row)
|
|
setToVal(row.val)
|
|
setMatches(null)
|
|
setMsg(null)
|
|
setLoadingMatches(true)
|
|
try {
|
|
const rows = await api.getMappingsByOutputField(row.col, row.val)
|
|
setMatches(rows)
|
|
} catch (err) {
|
|
setMsg({ text: err.message, ok: false })
|
|
} finally {
|
|
setLoadingMatches(false)
|
|
}
|
|
}
|
|
|
|
async function handleApply() {
|
|
if (!selected || !toVal.trim() || toVal === selected.val) return
|
|
setApplying(true)
|
|
setMsg(null)
|
|
try {
|
|
const { updated } = await api.remapOutputField(selected.col, selected.val, toVal.trim())
|
|
setMsg({ text: `Updated ${updated} mapping${updated !== 1 ? 's' : ''}.`, ok: true })
|
|
// Refresh match list to show new values
|
|
const rows = await api.getMappingsByOutputField(selected.col, toVal.trim())
|
|
setMatches(rows)
|
|
setSelected({ ...selected, val: toVal.trim() })
|
|
// Re-run search to refresh counts
|
|
const refreshed = await api.searchMappingOutputs(search.trim())
|
|
setResults(refreshed)
|
|
} catch (err) {
|
|
setMsg({ text: err.message, ok: false })
|
|
} finally {
|
|
setApplying(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="p-4 sm:p-6 max-w-4xl">
|
|
<h1 className="text-base font-semibold text-ink mb-4">Remap Output Values</h1>
|
|
|
|
{/* Search */}
|
|
<form onSubmit={handleSearch} className="flex items-center gap-2 mb-5">
|
|
<input
|
|
ref={searchRef}
|
|
value={search}
|
|
onChange={e => setSearch(e.target.value)}
|
|
placeholder="Search output values…"
|
|
className="text-sm border border-line rounded px-3 py-1.5 w-72 focus:outline-none focus:border-accent"
|
|
/>
|
|
<button type="submit" disabled={searching}
|
|
className="text-sm bg-blue-600 text-white rounded px-3 py-1.5 hover:bg-blue-700 disabled:opacity-50">
|
|
{searching ? 'Searching…' : 'Search'}
|
|
</button>
|
|
</form>
|
|
|
|
{/* Search results */}
|
|
{results !== null && (
|
|
<div className="mb-6">
|
|
{results.length === 0 ? (
|
|
<p className="text-sm text-muted">No matching output values found.</p>
|
|
) : (
|
|
<>
|
|
<div className="text-xs text-muted uppercase tracking-wide mb-1">
|
|
{results.length} result{results.length !== 1 ? 's' : ''} — click one to remap
|
|
</div>
|
|
<table className="w-full text-sm border border-line rounded overflow-hidden">
|
|
<thead>
|
|
<tr className="bg-raised text-left text-xs text-muted uppercase tracking-wide">
|
|
<th className="px-3 py-2">Field</th>
|
|
<th className="px-3 py-2">Value</th>
|
|
<th className="px-3 py-2 text-right">Mappings</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{results.map((r, i) => {
|
|
const isActive = selected?.col === r.col && selected?.val === r.val
|
|
return (
|
|
<tr key={i}
|
|
onClick={() => handleSelect(r)}
|
|
className={`border-t border-line-soft cursor-pointer transition-colors
|
|
${isActive ? 'bg-accent-soft' : 'hover:bg-raised'}`}>
|
|
<td className="px-3 py-2 font-mono text-muted">{r.col}</td>
|
|
<td className="px-3 py-2 font-mono text-ink">{r.val}</td>
|
|
<td className="px-3 py-2 text-right text-muted">{r.mapping_count}</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Remap panel */}
|
|
{selected && (
|
|
<div className="border border-line rounded p-4 mb-6 bg-surface">
|
|
<div className="text-xs text-muted uppercase tracking-wide mb-3">
|
|
Remap <span className="font-mono text-ink-soft">{selected.col}</span>
|
|
</div>
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<div className="flex-1">
|
|
<div className="text-xs text-muted mb-1">From</div>
|
|
<div className="text-sm font-mono bg-raised border border-line rounded px-3 py-1.5 text-ink-soft">
|
|
{selected.val}
|
|
</div>
|
|
</div>
|
|
<div className="text-muted mt-4">→</div>
|
|
<div className="flex-1">
|
|
<div className="text-xs text-muted mb-1">To</div>
|
|
<input
|
|
value={toVal}
|
|
onChange={e => setToVal(e.target.value)}
|
|
onKeyDown={e => e.key === 'Enter' && handleApply()}
|
|
className="w-full text-sm font-mono border border-line rounded px-3 py-1.5 focus:outline-none focus:border-accent"
|
|
/>
|
|
</div>
|
|
<div className="mt-4">
|
|
<button
|
|
onClick={handleApply}
|
|
disabled={applying || !toVal.trim() || toVal.trim() === selected.val}
|
|
className="text-sm bg-blue-600 text-white rounded px-3 py-1.5 hover:bg-blue-700 disabled:opacity-40 whitespace-nowrap">
|
|
{applying ? 'Applying…' : `Apply to all ${matches?.length ?? '…'}`}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{msg && (
|
|
<div className={`text-sm mb-3 ${msg.ok ? 'text-ok' : 'text-danger'}`}>
|
|
{msg.text}
|
|
</div>
|
|
)}
|
|
|
|
{/* Affected mappings */}
|
|
{loadingMatches ? (
|
|
<p className="text-xs text-muted">Loading…</p>
|
|
) : matches && matches.length > 0 && (
|
|
<div>
|
|
<div className="text-xs text-muted uppercase tracking-wide mb-1">
|
|
Affected mappings
|
|
</div>
|
|
<table className="w-full text-xs border border-line-soft rounded overflow-hidden">
|
|
<thead>
|
|
<tr className="bg-raised text-left text-muted">
|
|
<th className="px-2 py-1">Source</th>
|
|
<th className="px-2 py-1">Rule</th>
|
|
<th className="px-2 py-1">Input</th>
|
|
<th className="px-2 py-1">Output</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{matches.map(m => (
|
|
<tr key={m.id} className="border-t border-line-soft">
|
|
<td className="px-2 py-1 font-mono text-muted">{m.source_name}</td>
|
|
<td className="px-2 py-1 font-mono text-muted">{m.rule_name}</td>
|
|
<td className="px-2 py-1 font-mono text-ink-soft">
|
|
{typeof m.input_value === 'string' ? m.input_value : JSON.stringify(m.input_value)}
|
|
</td>
|
|
<td className="px-2 py-1 font-mono text-ink-soft">
|
|
{Object.entries(m.output).map(([k, v]) => (
|
|
<span key={k} className={k === selected.col ? 'text-accent font-semibold' : ''}>
|
|
{k}: {v}{' '}
|
|
</span>
|
|
))}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|