- SQL: search_mapping_outputs(search) — distinct (col, val, count) groups
get_mappings_by_output_field(col, val) — individual mappings
remap_output_field(col, from, to) — bulk UPDATE via jsonb_set
- API: GET /mappings/outputs?search=, GET /mappings/outputs/:col/:val,
POST /mappings/remap-field
- UI: Remap page — search output values, click to select, edit the
replacement value, see all affected mappings, apply globally
- Nav: Remap added between Mappings and Records
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
160 lines
6.1 KiB
JavaScript
160 lines
6.1 KiB
JavaScript
import { useState, useEffect } from 'react'
|
|
import { BrowserRouter, Routes, Route, NavLink, Navigate } from 'react-router-dom'
|
|
import { api, setCredentials, clearCredentials } from './api'
|
|
import Login from './pages/Login'
|
|
import Sources from './pages/Sources'
|
|
import Import from './pages/Import'
|
|
import Rules from './pages/Rules'
|
|
import Mappings from './pages/Mappings'
|
|
import Records from './pages/Records'
|
|
import Log from './pages/Log'
|
|
import Pivot from './pages/Pivot'
|
|
import Remap from './pages/Remap'
|
|
|
|
const NAV = [
|
|
{ to: '/sources', label: 'Sources' },
|
|
{ to: '/import', label: 'Import' },
|
|
{ to: '/rules', label: 'Rules' },
|
|
{ to: '/mappings', label: 'Mappings' },
|
|
{ to: '/remap', label: 'Remap' },
|
|
{ to: '/records', label: 'Records' },
|
|
{ to: '/pivot', label: 'Pivot' },
|
|
{ to: '/log', label: 'Log' },
|
|
]
|
|
|
|
export default function App() {
|
|
const [authed, setAuthed] = useState(false)
|
|
const [loginUser, setLoginUser] = useState('')
|
|
const [sources, setSources] = useState([])
|
|
const [source, setSource] = useState(() => localStorage.getItem('selectedSource') || '')
|
|
const [sidebarOpen, setSidebarOpen] = useState(false)
|
|
|
|
async function handleLogin(user, pass) {
|
|
setCredentials(user, pass)
|
|
await api.getSources().then(s => {
|
|
sessionStorage.setItem('df_user', user)
|
|
sessionStorage.setItem('df_pass', pass)
|
|
setSources(s)
|
|
if (!source && s.length > 0) setSource(s[0].name)
|
|
setAuthed(true)
|
|
setLoginUser(user)
|
|
})
|
|
}
|
|
|
|
function handleLogout() {
|
|
clearCredentials()
|
|
sessionStorage.removeItem('df_user')
|
|
sessionStorage.removeItem('df_pass')
|
|
setAuthed(false)
|
|
setLoginUser('')
|
|
setSources([])
|
|
}
|
|
|
|
// On mount, restore session if credentials are saved
|
|
useEffect(() => {
|
|
const user = sessionStorage.getItem('df_user')
|
|
const pass = sessionStorage.getItem('df_pass')
|
|
if (user && pass) handleLogin(user, pass).catch(() => handleLogout())
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (source) localStorage.setItem('selectedSource', source)
|
|
}, [source])
|
|
|
|
if (!authed) return <Login onLogin={handleLogin} />
|
|
|
|
const sidebar = (
|
|
<div className="flex flex-col h-full">
|
|
{/* Header */}
|
|
<div className="px-4 py-3 border-b border-gray-200">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-semibold text-gray-800 tracking-wide uppercase">Dataflow</span>
|
|
<button onClick={() => setSidebarOpen(false)} className="md:hidden text-gray-400 hover:text-gray-600 leading-none" title="Close">✕</button>
|
|
</div>
|
|
<div className="flex items-center justify-between mt-1">
|
|
<span className="text-xs text-gray-400">{loginUser}</span>
|
|
<button onClick={handleLogout} className="text-xs text-gray-400 hover:text-red-500" title="Sign out">Sign out</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Source selector */}
|
|
<div className="px-3 py-3 border-b border-gray-200">
|
|
<div className="flex items-center justify-between mb-1">
|
|
<label className="text-xs text-gray-500">Source</label>
|
|
<NavLink to="/sources?new=1" className="text-xs text-blue-400 hover:text-blue-600 leading-none" title="New source" onClick={() => setSidebarOpen(false)}>+</NavLink>
|
|
</div>
|
|
<select
|
|
className="w-full text-sm border border-gray-200 rounded px-2 py-1 bg-white focus:outline-none focus:border-blue-400"
|
|
value={source}
|
|
onChange={e => setSource(e.target.value)}
|
|
>
|
|
{sources.length === 0 && <option value="">—</option>}
|
|
{sources.map(s => <option key={s.name} value={s.name}>{s.name}</option>)}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Nav */}
|
|
<nav className="flex-1 py-2">
|
|
{NAV.map(({ to, label }) => (
|
|
<NavLink
|
|
key={to}
|
|
to={to}
|
|
onClick={() => setSidebarOpen(false)}
|
|
className={({ isActive }) =>
|
|
`block px-4 py-2 text-sm ${isActive
|
|
? 'bg-blue-50 text-blue-700 font-medium'
|
|
: 'text-gray-600 hover:bg-gray-50'}`
|
|
}
|
|
>
|
|
{label}
|
|
</NavLink>
|
|
))}
|
|
</nav>
|
|
</div>
|
|
)
|
|
|
|
return (
|
|
<BrowserRouter>
|
|
<div className="flex h-screen bg-gray-50">
|
|
|
|
{/* Mobile overlay */}
|
|
{sidebarOpen && (
|
|
<div className="fixed inset-0 z-20 bg-black/30 md:hidden" onClick={() => setSidebarOpen(false)} />
|
|
)}
|
|
|
|
{/* Sidebar — fixed on mobile, static on desktop */}
|
|
<div className={`
|
|
fixed inset-y-0 left-0 z-30 w-44 bg-white border-r border-gray-200 transform transition-transform duration-200
|
|
md:static md:translate-x-0 md:z-auto md:transition-none
|
|
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
|
|
`}>
|
|
{sidebar}
|
|
</div>
|
|
|
|
{/* Main */}
|
|
<div className="flex-1 overflow-auto flex flex-col min-w-0">
|
|
{/* Mobile top bar */}
|
|
<div className="md:hidden flex items-center px-3 py-2 bg-white border-b border-gray-200">
|
|
<button onClick={() => setSidebarOpen(true)} className="text-gray-500 hover:text-gray-700 mr-3 text-lg leading-none">☰</button>
|
|
<span className="text-sm font-semibold text-gray-800 tracking-wide uppercase">Dataflow</span>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-auto">
|
|
<Routes>
|
|
<Route path="/" element={<Navigate to="/sources" replace />} />
|
|
<Route path="/sources" element={<Sources source={source} sources={sources} setSources={setSources} setSource={setSource} />} />
|
|
<Route path="/import" element={<Import source={source} />} />
|
|
<Route path="/rules" element={<Rules source={source} />} />
|
|
<Route path="/mappings" element={<Mappings source={source} />} />
|
|
<Route path="/remap" element={<Remap />} />
|
|
<Route path="/records" element={<Records source={source} />} />
|
|
<Route path="/pivot" element={<Pivot source={source} />} />
|
|
<Route path="/log" element={<Log />} />
|
|
</Routes>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</BrowserRouter>
|
|
)
|
|
}
|