dataflow/ui/src/pages/Records.jsx
Paul Trowbridge d24537d3db List override and transformed keys as source fields; show record id
get_source_fields only unioned schema fields, raw data keys, rule
output_field and mapping output keys. Keys that live solely in
records.transformed or records.overrides — a manual override such as
dcard's "Note", or a transformed key whose rule was since deleted — never
appeared on the source page, so there was no way to add them to the view.
Read both columns off the records directly.

Records showed no id column, leaving no handle to identify a row. Split
the hidden-column set: HIDDEN_COLS still keeps id out of the override
editor, GRID_HIDDEN_COLS hides only _overridden, and gridCols() pins id
first in the grid and the filter dropdown.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BDFU3ueEbCYGE37oDgeu2j
2026-07-26 23:10:01 -04:00

658 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useRef } from 'react'
import { api } from '../api'
function AutocompleteInput({ value, onChange, onEnter, suggestions = [], className, placeholder }) {
const [open, setOpen] = useState(false)
const [highlighted, setHighlighted] = useState(0)
const [dropPos, setDropPos] = useState(null)
const inputRef = useRef()
const listRef = useRef()
const filtered = value
? suggestions.filter(s => s.toLowerCase().includes(value.toLowerCase()))
: suggestions
function openList() {
if (inputRef.current) {
const r = inputRef.current.getBoundingClientRect()
setDropPos({ top: r.bottom + 2, left: r.left, minWidth: r.width })
}
setOpen(true)
setHighlighted(0)
}
function select(val) { onChange(val); setOpen(false); inputRef.current?.focus() }
function handleKeyDown(e) {
if (e.altKey && e.key === 'ArrowDown') { e.preventDefault(); openList(); return }
if (open && filtered.length > 0) {
if (e.key === 'Tab') { e.preventDefault(); setHighlighted(h => (h + 1) % filtered.length); return }
if (e.key === 'ArrowDown') { e.preventDefault(); setHighlighted(h => Math.min(h + 1, filtered.length - 1)); return }
if (e.key === 'ArrowUp') { e.preventDefault(); setHighlighted(h => Math.max(h - 1, 0)); return }
if (e.key === 'Enter') { e.preventDefault(); select(filtered[highlighted]); return }
if (e.key === 'Escape') { setOpen(false); return }
}
if (e.key === 'Enter') onEnter?.()
}
useEffect(() => {
if (!open || !listRef.current) return
listRef.current.children[highlighted]?.scrollIntoView({ block: 'nearest' })
}, [highlighted, open])
return (
<div className="relative">
<input ref={inputRef} className={className} value={value} placeholder={placeholder}
onChange={e => { onChange(e.target.value); if (e.target.value) openList() }}
onKeyDown={handleKeyDown}
onBlur={e => { if (!listRef.current?.contains(e.relatedTarget)) setOpen(false) }}
/>
{open && filtered.length > 0 && dropPos && (
<div ref={listRef}
style={{ position: 'fixed', top: dropPos.top, left: dropPos.left, minWidth: dropPos.minWidth, zIndex: 9999 }}
className="bg-white border border-gray-200 rounded shadow-lg max-h-40 overflow-y-auto">
{filtered.map((s, i) => (
<div key={s}
className={`px-2 py-1 text-xs cursor-pointer whitespace-nowrap ${i === highlighted ? 'bg-blue-50 text-blue-700' : 'text-gray-700 hover:bg-gray-50'}`}
onMouseDown={e => { e.preventDefault(); select(s) }}>{s}</div>
))}
</div>
)}
</div>
)
}
const DATE_RE = /^\d{4}-\d{2}-\d{2}(T[\d:.Z+-]+)?$/
// Hidden everywhere a field can be edited as an override — you can't override an id
const HIDDEN_COLS = new Set(['id', '_overridden'])
// The grid and its filters keep id: it's the only handle on a specific row
const GRID_HIDDEN_COLS = new Set(['_overridden'])
// id first, then the rest in view order
function gridCols(keys) {
const visible = keys.filter(c => !GRID_HIDDEN_COLS.has(c))
return visible.includes('id') ? ['id', ...visible.filter(c => c !== 'id')] : visible
}
function formatVal(val) {
if (val === null || val === undefined) return null
const s = String(val)
if (DATE_RE.test(s)) {
const d = new Date(s)
if (!isNaN(d)) {
const y = d.getUTCFullYear()
const m = String(d.getUTCMonth() + 1).padStart(2, '0')
const day = String(d.getUTCDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
}
return s
}
export default function Records({ source }) {
const [rows, setRows] = useState([])
const [cols, setCols] = useState([])
const [exists, setExists] = useState(null)
const [offset, setOffset] = useState(0)
const [loading, setLoading] = useState(false)
const [viewError, setViewError] = useState(null)
const [sort, setSort] = useState({ col: null, dir: 'asc' })
const [filters, setFilters] = useState([]) // DB sort/filter queries
const [rowFilter, setRowFilter] = useState('') // regex filter for selecting rows
const [selected, setSelected] = useState(new Set()) // row IDs selected for bulk override
const [bulkDraft, setBulkDraft] = useState({}) // bulk override values
const LIMIT = 100
// Override cols — loaded from DB once per source, extended by user via +
const [overrideCols, setOverrideCols] = useState([]) // keys seen in overrides across all records
const [extraCols, setExtraCols] = useState([]) // new cols added this session via +
const [globalValues, setGlobalValues] = useState({}) // picklist suggestions
// Override panel
const [panelOpen, setPanelOpen] = useState(false)
const [selectedRow, setSelectedRow] = useState(null)
const [selectedRecord, setSelectedRecord] = useState(null)
const [overrideDraft, setOverrideDraft] = useState({})
const [panelLoading, setPanelLoading] = useState(false)
const [panelSaving, setPanelSaving] = useState(false)
const [panelMsg, setPanelMsg] = useState(null)
const debounceRef = useRef(null)
useEffect(() => {
if (!source) return
setOffset(0)
setSort({ col: null, dir: 'asc' })
setFilters([])
setViewError(null)
setSelectedRecord(null)
setSelectedRow(null)
setPanelOpen(false)
setOverrideCols([])
setExtraCols([])
// Default to newest first on the source's first date field, if it has one
api.getSource(source)
.then(src => (src?.config?.fields || []).find(f => f.type === 'date')?.name || null)
.catch(() => null)
.then(dateCol => {
if (dateCol) setSort({ col: dateCol, dir: 'desc' })
load(0, dateCol, 'desc', [])
})
api.getOverrideKeys(source).then(setOverrideCols).catch(() => {})
api.getGlobalValues().then(setGlobalValues).catch(() => {})
setSelected(new Set())
setBulkDraft({})
setRowFilter('')
}, [source])
// Auto-select all rows matching the regex filter when it changes
useEffect(() => {
if (!rowFilter) return
let re = null
try { re = new RegExp(rowFilter, 'i') } catch { return }
const matches = rows.filter(r => {
for (const col of displayCols) {
const val = r[col]
if (val != null && re.test(String(val))) return true
}
return false
})
setSelected(new Set(matches.map(r => r.id)))
}, [rowFilter, rows])
async function load(off, col, dir, filt) {
setLoading(true)
try {
const active = (filt || []).filter(f => f.col && f.pattern)
const res = await api.getViewData(source, LIMIT, off, col, dir, active)
setExists(res.exists)
setRows(res.rows)
if (res.rows.length > 0) setCols(Object.keys(res.rows[0]))
} catch (err) {
setViewError(err.message)
} finally {
setLoading(false)
}
}
function triggerLoad(off, col, dir, filt) {
clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => load(off, col, dir, filt), 350)
}
function toggleSort(col) {
const next = sort.col === col
? { col, dir: sort.dir === 'asc' ? 'desc' : 'asc' }
: { col, dir: 'asc' }
setSort(next)
setOffset(0)
load(0, next.col, next.dir, filters)
}
function addFilter() {
// id is filterable but a poor default — start on the first data column
const visCols = gridCols(cols).filter(c => c !== 'id')
setFilters(f => [...f, { col: visCols[0] || '', pattern: '' }])
}
function removeFilter(i) {
const next = filters.filter((_, idx) => idx !== i)
setFilters(next)
setSelected(new Set())
setOffset(0)
load(0, sort.col, sort.dir, next)
}
function updateFilter(i, key, val) {
const next = filters.map((f, idx) => idx === i ? { ...f, [key]: val } : f)
setFilters(next)
setSelected(new Set())
setOffset(0)
triggerLoad(0, sort.col, sort.dir, next)
}
function prev() { const o = Math.max(0, offset - LIMIT); setOffset(o); setSelected(new Set()); load(o, sort.col, sort.dir, filters) }
function next() { const o = offset + LIMIT; setOffset(o); setSelected(new Set()); load(o, sort.col, sort.dir, filters) }
async function openPanel(row) {
setPanelOpen(true)
setSelectedRow(row)
setSelectedRecord(null)
setOverrideDraft({})
setPanelMsg(null)
const id = row.id
if (!id) {
setPanelMsg({ text: 'No record ID — regenerate the view in Sources.', ok: false })
return
}
setPanelLoading(true)
try {
const rec = await api.getRecord(id)
setSelectedRecord(rec)
setOverrideDraft(rec.overrides || {})
} catch (err) {
setPanelMsg({ text: err.message, ok: false })
} finally {
setPanelLoading(false)
}
}
function closePanel() {
setPanelOpen(false)
setSelectedRow(null)
setSelectedRecord(null)
setOverrideDraft({})
setPanelMsg(null)
}
async function handleSaveOverrides() {
if (!selectedRecord) return
setPanelSaving(true)
setPanelMsg(null)
try {
const toSave = { ...overrideDraft }
const updated = await api.setRecordOverrides(selectedRecord.id, toSave)
setSelectedRecord(updated)
setOverrideDraft(updated.overrides || {})
// Merge any new cols from extraCols into overrideCols
setOverrideCols(prev => [...new Set([...prev, ...extraCols.filter(c => c.trim())])])
setExtraCols([])
setPanelMsg({ text: 'Saved.', ok: true })
load(offset, sort.col, sort.dir, filters)
} catch (err) {
setPanelMsg({ text: err.message, ok: false })
} finally {
setPanelSaving(false)
}
}
async function handleClearOverrides() {
if (!selectedRecord) return
setPanelSaving(true)
setPanelMsg(null)
try {
const updated = await api.clearRecordOverrides(selectedRecord.id)
setSelectedRecord(updated)
setOverrideDraft({})
setPanelMsg({ text: 'Cleared.', ok: true })
load(offset, sort.col, sort.dir, filters)
} catch (err) {
setPanelMsg({ text: err.message, ok: false })
} finally {
setPanelSaving(false)
}
}
if (!source) return <div className="p-6 text-sm text-gray-400">Select a source first.</div>
const displayCols = gridCols(rows.length > 0 ? Object.keys(rows[0]) : cols)
const visCols = gridCols(cols)
// For bulk bar: only established override keys
const allOverrideCols = [...new Set([...overrideCols, ...extraCols])]
const savedOverrides = selectedRecord?.overrides || {}
const isDirty = Object.values(overrideDraft).some(v => String(v).trim())
|| extraCols.some(c => c.trim())
|| Object.keys(savedOverrides).some(k => !String(overrideDraft[k] ?? '').trim())
return (
<div className="flex h-full min-h-0 overflow-hidden">
<div className="flex-1 overflow-auto p-6 min-w-0">
<div className="flex items-center justify-between mb-4">
<h1 className="text-xl font-semibold text-gray-800">Records {source}</h1>
{exists && rows.length > 0 && (
<span className="text-xs text-gray-400 font-mono">dfv.{source}</span>
)}
</div>
{/* Filter bar */}
{exists !== false && visCols.length > 0 && (
<div className="mb-4 flex flex-wrap gap-2 items-center">
<span className="text-xs text-gray-400 font-medium mr-1">DB query:</span>
{filters.map((f, i) => (
<div key={i} className="flex items-center gap-1 bg-white border border-gray-200 rounded px-2 py-1">
<select
className="text-xs text-gray-600 border-0 focus:outline-none bg-transparent"
value={f.col}
onChange={e => updateFilter(i, 'col', e.target.value)}
>
{visCols.map(c => <option key={c} value={c}>{c}</option>)}
</select>
<span className="text-xs text-gray-300 mx-0.5">~*</span>
<input
className="text-xs font-mono border-0 focus:outline-none w-36 bg-transparent"
placeholder="regex…"
value={f.pattern}
onChange={e => updateFilter(i, 'pattern', e.target.value)}
/>
<button onClick={() => removeFilter(i)} className="text-gray-300 hover:text-gray-500 ml-1 leading-none">×</button>
</div>
))}
<button onClick={addFilter}
className="text-xs text-gray-400 hover:text-gray-600 border border-dashed border-gray-200 rounded px-2 py-1">
+ filter
</button>
{filters.length > 0 && (
<button onClick={() => { setFilters([]); setOffset(0); load(0, sort.col, sort.dir, []) }}
className="text-xs text-gray-400 hover:text-red-500">clear</button>
)}
</div>
)}
{/* Bulk select + override bar */}
{exists && visCols.length > 0 && (
<div className="mb-4 flex flex-wrap gap-2 items-center">
<span className="text-xs text-gray-400 font-medium mr-1">Bulk select:</span>
<input
className={`text-xs font-mono border rounded px-2 py-1.5 w-44 focus:outline-none focus:border-blue-400 ${
rowFilter ? 'border-blue-300' : 'border-gray-200'
}`}
placeholder="regex on loaded rows…"
value={rowFilter}
onChange={e => setRowFilter(e.target.value)}
/>
{rowFilter && (
<span className="text-xs text-gray-400">{selected.size} of {rows.length} rows selected</span>
)}
{selected.size > 0 && (
<div className="flex items-center gap-2 ml-4 p-2 bg-blue-50 border border-blue-200 rounded flex-wrap">
{allOverrideCols.map(col => (
<AutocompleteInput
key={col}
className="border border-blue-300 rounded px-2 py-1 text-xs min-w-24 focus:outline-none focus:border-blue-500 bg-white"
placeholder={col}
value={bulkDraft[col] || ''}
onChange={v => setBulkDraft(d => ({ ...d, [col]: v }))}
suggestions={[...(globalValues[col] || [])].sort()}
/>
))}
<button
onClick={async () => {
const overrides = Object.fromEntries(
Object.entries(bulkDraft).filter(([, v]) => v.trim())
)
if (Object.keys(overrides).length === 0) return
if (selected.size === 0) return
setPanelSaving(true)
setPanelMsg(null)
try {
const res = await api.setBulkRecordOverrides(source, [...selected], overrides)
setSelected(new Set())
setBulkDraft({})
setPanelMsg({ text: `Updated ${res.updated} records.`, ok: true })
load(offset, sort.col, sort.dir, filters)
} catch (err) {
setPanelMsg({ text: err.message, ok: false })
} finally {
setPanelSaving(false)
}
}}
disabled={panelSaving || selected.size === 0 || Object.values(bulkDraft).every(v => !v.trim())}
className="text-xs bg-blue-600 text-white px-3 py-1 rounded hover:bg-blue-700 disabled:opacity-40 whitespace-nowrap"
>
Apply to {selected.size}
</button>
<button
onClick={() => { setSelected(new Set()); setBulkDraft({}); setRowFilter('') }}
className="text-xs text-blue-400 hover:text-blue-600"
>
cancel
</button>
</div>
)}
</div>
)}
{loading && <p className="text-sm text-gray-400">Loading</p>}
{!loading && viewError && <p className="text-sm text-red-500">View error: {viewError} check field types in Sources.</p>}
{!loading && exists === false && (
<p className="text-sm text-gray-400">
No view generated yet. Go to <span className="font-medium text-gray-600">Sources</span>, check fields as <span className="font-medium text-gray-600">In view</span>, then click <span className="font-medium text-gray-600">Generate view</span>.
</p>
)}
{!loading && exists && rows.length === 0 && (
<p className="text-sm text-gray-400">
{filters.some(f => f.col && f.pattern) ? 'No records match the current filters.' : 'View exists but no transformed records yet. Import data and run a transform first.'}
</p>
)}
{!loading && exists && rows.length > 0 && (
<>
<div className="bg-white border border-gray-200 rounded overflow-auto mb-4">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-gray-400 border-b border-gray-100 bg-gray-50">
<th className="px-2 py-2 w-8">
<input
type="checkbox"
className="cursor-pointer"
checked={rows.length > 0 && rows.every(r => selected.has(r.id))}
onChange={e => {
if (e.target.checked) setSelected(new Set(rows.map(r => r.id)))
else setSelected(new Set())
}}
/>
</th>
{displayCols.map(col => {
const active = sort.col === col
return (
<th key={col} onClick={() => toggleSort(col)}
className="px-3 py-2 font-medium whitespace-nowrap cursor-pointer select-none hover:text-gray-600">
{col}
<span className="ml-1 text-gray-300">{active ? (sort.dir === 'asc' ? '▲' : '▼') : '⇅'}</span>
</th>
)
})}
</tr>
</thead>
<tbody>
{rows.map((row, i) => {
const isOverridden = row._overridden
const isRowSelected = selected.has(row.id)
const isPanelSelected = selectedRow?.id != null && selectedRow.id === row.id
return (
<tr key={i} onClick={() => openPanel(row)}
className={`border-t border-gray-50 cursor-pointer transition-colors
${isPanelSelected ? 'bg-blue-50' : isRowSelected ? 'bg-blue-50' : isOverridden ? 'bg-amber-50 hover:bg-amber-100' : 'hover:bg-gray-50'}`}>
<td className="px-2 py-2">
<input
type="checkbox"
className="cursor-pointer"
checked={isRowSelected}
onChange={e => {
e.stopPropagation()
setSelected(s => { const n = new Set(s); n.has(row.id) ? n.delete(row.id) : n.add(row.id); return n })
}}
/>
</td>
{displayCols.map((col, j) => {
const formatted = formatVal(row[col])
return (
<td key={j} className="px-3 py-2 text-xs text-gray-600 whitespace-nowrap max-w-48 truncate">
{formatted === null ? <span className="text-gray-300"></span> : formatted}
</td>
)
})}
</tr>
)
})}
</tbody>
</table>
</div>
<div className="flex items-center gap-3 text-sm text-gray-500">
<button onClick={prev} disabled={offset === 0}
className="px-3 py-1 border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-40"> Prev</button>
<span>{offset + 1}{offset + rows.length}</span>
<button onClick={next} disabled={rows.length < LIMIT}
className="px-3 py-1 border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-40">Next </button>
</div>
</>
)}
</div>
{/* Panel */}
{panelOpen && (
<div className="w-80 border-l border-gray-200 bg-white flex flex-col overflow-hidden flex-shrink-0">
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-100">
<span className="text-xs font-semibold text-gray-600 uppercase tracking-wide">Record</span>
<button onClick={closePanel} className="text-gray-300 hover:text-gray-500 leading-none text-lg">×</button>
</div>
{panelLoading && <p className="text-xs text-gray-400 p-3">Loading</p>}
{selectedRecord && !panelLoading && (
<div className="flex-1 overflow-y-auto flex flex-col min-h-0">
{panelMsg && (
<div className={`text-xs px-3 py-2 border-b border-gray-100 ${panelMsg.ok ? 'text-green-600' : 'text-red-500'}`}>
{panelMsg.text}
</div>
)}
{/* Raw fields — read only */}
<div className="border-b border-gray-100">
<div className="px-3 py-1.5 bg-gray-50 border-b border-gray-100">
<span className="text-xs font-medium text-gray-400 uppercase tracking-wide">Raw</span>
</div>
{Object.entries(selectedRecord.data || {}).map(([field, val]) => (
<div key={field} className="flex items-baseline gap-2 px-3 py-1 border-t border-gray-50 first:border-t-0">
<span className="text-xs font-mono text-gray-400 w-28 shrink-0 truncate">{field}</span>
<span className="text-xs font-mono text-gray-500 truncate">{formatVal(val) ?? <span className="text-gray-300"></span>}</span>
</div>
))}
</div>
{/* Transformed fields — read only delta */}
<div className="border-b border-gray-100">
<div className="px-3 py-1.5 bg-gray-50 border-b border-gray-100">
<span className="text-xs font-medium text-gray-400 uppercase tracking-wide">Transformed</span>
</div>
{Object.entries(selectedRecord.transformed || {}).filter(([k]) => !HIDDEN_COLS.has(k)).length === 0
? <div className="px-3 py-2 text-xs text-gray-300">No rule output yet.</div>
: Object.entries(selectedRecord.transformed || {}).filter(([k]) => !HIDDEN_COLS.has(k)).map(([field, val]) => (
<div key={field} className="flex items-baseline gap-2 px-3 py-1 border-t border-gray-50 first:border-t-0">
<span className="text-xs font-mono text-gray-400 w-28 shrink-0 truncate">{field}</span>
<span className="text-xs font-mono text-blue-600 truncate">{formatVal(val) ?? <span className="text-gray-300"></span>}</span>
</div>
))
}
</div>
{/* Overrides — editable */}
<div className="flex-1 border-b border-gray-100">
<div className="flex items-center justify-between px-3 py-1.5 bg-gray-50 border-b border-gray-100">
<span className="text-xs font-medium text-gray-400 uppercase tracking-wide">Overrides</span>
<button
onClick={() => setExtraCols(ec => [...ec, ''])}
className="text-gray-400 hover:text-gray-700 font-medium text-sm leading-none"
title="Add field">+</button>
</div>
<table className="w-full text-xs">
<tbody>
{[...new Set([
...Object.keys(selectedRecord.transformed || {}),
...Object.keys(selectedRecord.overrides || {}),
...overrideCols
])].filter(k => !HIDDEN_COLS.has(k)).map(col => {
const override = overrideDraft[col] ?? ''
const placeholder = formatVal(selectedRecord.transformed?.[col]) ?? ''
const suggestions = [...(globalValues[col] || [])].sort()
return (
<tr key={col} className="border-t border-gray-50">
<td className="px-3 py-1.5 w-28 shrink-0">
<span className="font-mono text-gray-500 truncate block">{col}</span>
</td>
<td className="px-1 py-1.5">
<AutocompleteInput
className={`w-full text-xs font-mono px-2 py-0.5 rounded border focus:outline-none ${
override ? 'border-amber-300 bg-amber-50 text-amber-800' : 'border-gray-200 text-gray-600'
}`}
value={override}
placeholder={placeholder}
onChange={v => setOverrideDraft(d => ({ ...d, [col]: v }))}
onEnter={handleSaveOverrides}
suggestions={suggestions}
/>
</td>
<td className="pr-2 text-center w-6">
{override && (
<button
onClick={() => setOverrideDraft(d => { const n = { ...d }; delete n[col]; return n })}
className="text-gray-300 hover:text-red-400 leading-none text-base">×</button>
)}
</td>
</tr>
)
})}
{extraCols.map((col, i) => {
const val = overrideDraft[col] ?? ''
const suggestions = [...(globalValues[col] || [])].sort()
return (
<tr key={`extra-${i}`} className="border-t border-gray-50">
<td className="px-3 py-1.5 w-28 shrink-0">
<input
className="w-full text-xs font-mono border border-gray-200 rounded px-1 py-0.5 focus:outline-none focus:border-blue-400"
value={col}
placeholder="field name"
onChange={e => {
const newName = e.target.value
setExtraCols(ec => { const c = [...ec]; c[i] = newName; return c })
if (val) setOverrideDraft(d => {
const n = { ...d }
delete n[col]
if (newName) n[newName] = val
return n
})
}}
/>
</td>
<td className="px-1 py-1.5">
<AutocompleteInput
className={`w-full text-xs font-mono px-2 py-0.5 rounded border focus:outline-none ${
val ? 'border-amber-300 bg-amber-50 text-amber-800' : 'border-gray-200 text-gray-600'
}`}
value={val}
onChange={v => setOverrideDraft(d => ({ ...d, [col]: v }))}
onEnter={handleSaveOverrides}
suggestions={suggestions}
/>
</td>
<td className="pr-2 text-center w-6">
{val && (
<button
onClick={() => setOverrideDraft(d => { const n = { ...d }; delete n[col]; return n })}
className="text-gray-300 hover:text-red-400 leading-none text-base">×</button>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
<div className="flex gap-2 px-3 py-2 border-t border-gray-100 shrink-0">
<button
onClick={handleSaveOverrides}
disabled={panelSaving || !isDirty}
className="flex-1 text-xs bg-blue-600 text-white rounded px-3 py-1.5 hover:bg-blue-700 disabled:opacity-40">
{panelSaving ? 'Saving…' : 'Save'}
</button>
{selectedRecord.overrides && Object.keys(selectedRecord.overrides).length > 0 && (
<button
onClick={handleClearOverrides}
disabled={panelSaving}
className="text-xs border border-gray-200 rounded px-3 py-1.5 text-gray-500 hover:border-red-300 hover:text-red-500 disabled:opacity-40">
Clear
</button>
)}
</div>
</div>
)}
</div>
)}
</div>
)
}