674 lines
27 KiB
JavaScript
674 lines
27 KiB
JavaScript
import { useState, useEffect, useRef } from 'react'
|
||
import { api, authHeaders } 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
|
||
const item = listRef.current.children[highlighted]
|
||
item?.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 (!open && 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-48 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>
|
||
)
|
||
}
|
||
|
||
function valueKey(v) {
|
||
return Array.isArray(v) ? JSON.stringify(v) : String(v)
|
||
}
|
||
|
||
function displayValue(v) {
|
||
if (Array.isArray(v)) return v.join(' · ')
|
||
return String(v ?? '')
|
||
}
|
||
|
||
function SortHeader({ col, label, sortBy, onSort, className = '' }) {
|
||
const active = sortBy?.col === col
|
||
return (
|
||
<th
|
||
className={`px-3 py-2 font-medium cursor-pointer select-none hover:text-gray-600 ${className}`}
|
||
onClick={() => onSort(col)}
|
||
>
|
||
{label}
|
||
<span className="ml-1 text-gray-300">{active ? (sortBy.dir === 'asc' ? '↑' : '↓') : '↕'}</span>
|
||
</th>
|
||
)
|
||
}
|
||
|
||
export default function Mappings({ source, onNeedsReprocess }) {
|
||
const [rules, setRules] = useState([])
|
||
const [selectedRule, setSelectedRule] = useState('')
|
||
const [allValues, setAllValues] = useState([])
|
||
const [filter, setFilter] = useState('all')
|
||
const [drafts, setDrafts] = useState({})
|
||
const [extraCols, setExtraCols] = useState([])
|
||
const [saving, setSaving] = useState({})
|
||
const [sampleOpen, setSampleOpen] = useState({})
|
||
const [loading, setLoading] = useState(false)
|
||
const [importing, setImporting] = useState(false)
|
||
const [sortBy, setSortBy] = useState(null)
|
||
const [globalValues, setGlobalValues] = useState({})
|
||
const [selected, setSelected] = useState(new Set())
|
||
const [bulkDraft, setBulkDraft] = useState({})
|
||
const [cursorKey, setCursorKey] = useState(null)
|
||
const [rowFilter, setRowFilter] = useState('')
|
||
const rowRefs = useRef({})
|
||
|
||
useEffect(() => {
|
||
if (!source) return
|
||
api.getGlobalValues().then(setGlobalValues).catch(() => {})
|
||
api.getRules(source).then(r => setRules(r)).catch(() => {})
|
||
}, [source])
|
||
|
||
useEffect(() => {
|
||
if (!source || !selectedRule) {
|
||
setAllValues([])
|
||
return
|
||
}
|
||
setLoading(true)
|
||
api.getAllValues(source, selectedRule)
|
||
.then(a => {
|
||
setAllValues(a)
|
||
setDrafts({})
|
||
setExtraCols([])
|
||
setSelected(new Set())
|
||
setBulkDraft({})
|
||
setCursorKey(null)
|
||
setRowFilter('')
|
||
})
|
||
.catch(() => {})
|
||
.finally(() => setLoading(false))
|
||
}, [source, selectedRule])
|
||
|
||
// 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 tabF = filter === 'unmapped' ? allValues.filter(r => !r.is_mapped)
|
||
: filter === 'mapped' ? allValues.filter(r => r.is_mapped)
|
||
: allValues
|
||
const matches = tabF.filter(r => re.test(displayValue(r.extracted_value)))
|
||
setSelected(new Set(matches.map(r => valueKey(r.extracted_value))))
|
||
}, [rowFilter, filter, allValues])
|
||
|
||
// Derive output columns and datalist suggestions from mapped rows + global pool
|
||
const existingCols = []
|
||
const valuesByCol = {}
|
||
allValues.forEach(row => {
|
||
if (!row.is_mapped) return
|
||
Object.entries(row.output || {}).forEach(([k, v]) => {
|
||
if (!existingCols.includes(k)) existingCols.push(k)
|
||
if (!valuesByCol[k]) valuesByCol[k] = new Set()
|
||
valuesByCol[k].add(String(v))
|
||
})
|
||
})
|
||
// Merge global picklist values into suggestions
|
||
Object.entries(globalValues).forEach(([k, vals]) => {
|
||
if (!valuesByCol[k]) valuesByCol[k] = new Set()
|
||
vals.forEach(v => valuesByCol[k].add(v))
|
||
})
|
||
const cols = [...existingCols, ...extraCols]
|
||
|
||
const unmappedCount = allValues.filter(r => !r.is_mapped).length
|
||
const mappedCount = allValues.filter(r => r.is_mapped).length
|
||
|
||
const tabFiltered = filter === 'unmapped'
|
||
? allValues.filter(r => !r.is_mapped)
|
||
: filter === 'mapped'
|
||
? allValues.filter(r => r.is_mapped)
|
||
: allValues
|
||
|
||
let rowFilterRe = null
|
||
let rowFilterError = false
|
||
if (rowFilter) {
|
||
try { rowFilterRe = new RegExp(rowFilter, 'i') } catch { rowFilterError = true }
|
||
}
|
||
const filteredRows = rowFilterRe
|
||
? tabFiltered.filter(r => rowFilterRe.test(displayValue(r.extracted_value)))
|
||
: tabFiltered
|
||
|
||
function toggleSort(col) {
|
||
setSortBy(s => {
|
||
if (s?.col === col) return { col, dir: s.dir === 'asc' ? 'desc' : 'asc' }
|
||
return { col, dir: 'asc' }
|
||
})
|
||
}
|
||
|
||
function sortedRows(rows) {
|
||
if (!sortBy) return rows
|
||
return [...rows].sort((a, b) => {
|
||
if (sortBy.col === 'count') {
|
||
const av = Number(a.record_count) || 0
|
||
const bv = Number(b.record_count) || 0
|
||
return sortBy.dir === 'asc' ? av - bv : bv - av
|
||
}
|
||
let av, bv
|
||
if (sortBy.col === 'input_value') {
|
||
av = displayValue(a.extracted_value)
|
||
bv = displayValue(b.extracted_value)
|
||
} else {
|
||
av = a.is_mapped ? String(a.output?.[sortBy.col] ?? '') : ''
|
||
bv = b.is_mapped ? String(b.output?.[sortBy.col] ?? '') : ''
|
||
}
|
||
return sortBy.dir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av)
|
||
})
|
||
}
|
||
|
||
function setCellValue(extractedValue, col, value) {
|
||
const k = valueKey(extractedValue)
|
||
const targets = selected.has(k) && selected.size > 1 ? [...selected] : [k]
|
||
setDrafts(d => {
|
||
const next = { ...d }
|
||
for (const sk of targets) next[sk] = { ...(next[sk] || {}), [col]: value }
|
||
return next
|
||
})
|
||
}
|
||
|
||
async function saveRow(row) {
|
||
const k = valueKey(row.extracted_value)
|
||
const output = Object.fromEntries(
|
||
cols.map(col => {
|
||
const drafted = drafts[k]?.[col]
|
||
const val = drafted !== undefined ? drafted : (row.is_mapped ? String(row.output?.[col] ?? '') : '')
|
||
return [col, val]
|
||
}).filter(([, v]) => v.trim())
|
||
)
|
||
if (Object.keys(output).length === 0) return
|
||
|
||
setSaving(s => ({ ...s, [k]: true }))
|
||
try {
|
||
if (row.is_mapped && row.mapping_id) {
|
||
const updated = await api.updateMapping(row.mapping_id, { output })
|
||
setAllValues(av => av.map(x =>
|
||
valueKey(x.extracted_value) === k ? { ...x, output: updated.output } : x
|
||
))
|
||
} else {
|
||
const created = await api.createMapping({
|
||
source_name: source,
|
||
rule_name: row.rule_name,
|
||
input_value: row.extracted_value,
|
||
output
|
||
})
|
||
setAllValues(av => av.map(x =>
|
||
valueKey(x.extracted_value) === k ? { ...x, is_mapped: true, mapping_id: created.id, output } : x
|
||
))
|
||
}
|
||
onNeedsReprocess?.(source)
|
||
setDrafts(d => { const n = { ...d }; delete n[k]; return n })
|
||
} catch (err) {
|
||
alert(err.message)
|
||
} finally {
|
||
setSaving(s => ({ ...s, [k]: false }))
|
||
}
|
||
}
|
||
|
||
const dirtyCount = filteredRows.filter(row => {
|
||
const k = valueKey(row.extracted_value)
|
||
return drafts[k] && Object.keys(drafts[k]).length > 0
|
||
}).length
|
||
|
||
async function saveAllPending() {
|
||
const dirty = filteredRows.filter(row => {
|
||
const k = valueKey(row.extracted_value)
|
||
return drafts[k] && Object.keys(drafts[k]).length > 0
|
||
})
|
||
await Promise.all(dirty.map(row => saveRow(row)))
|
||
setRowFilter('')
|
||
}
|
||
|
||
async function applyBulk() {
|
||
const output = Object.fromEntries(
|
||
Object.entries(bulkDraft).filter(([, v]) => v.trim())
|
||
)
|
||
if (Object.keys(output).length === 0) return
|
||
const rows = sortedRows(filteredRows).filter(r => selected.has(valueKey(r.extracted_value)))
|
||
await Promise.all(rows.map(async row => {
|
||
const k = valueKey(row.extracted_value)
|
||
const merged = { ...(row.is_mapped ? row.output : {}), ...output }
|
||
setSaving(s => ({ ...s, [k]: true }))
|
||
try {
|
||
if (row.is_mapped && row.mapping_id) {
|
||
const updated = await api.updateMapping(row.mapping_id, { output: merged })
|
||
setAllValues(av => av.map(x => valueKey(x.extracted_value) === k ? { ...x, output: updated.output } : x))
|
||
} else {
|
||
const created = await api.createMapping({ source_name: source, rule_name: row.rule_name, input_value: row.extracted_value, output: merged })
|
||
setAllValues(av => av.map(x => valueKey(x.extracted_value) === k ? { ...x, is_mapped: true, mapping_id: created.id, output: merged } : x))
|
||
}
|
||
} catch (err) {
|
||
alert(err.message)
|
||
} finally {
|
||
setSaving(s => ({ ...s, [k]: false }))
|
||
}
|
||
}))
|
||
onNeedsReprocess?.(source)
|
||
setSelected(new Set())
|
||
setBulkDraft({})
|
||
}
|
||
|
||
async function deleteRow(row) {
|
||
if (!row.mapping_id) return
|
||
try {
|
||
await api.deleteMapping(row.mapping_id)
|
||
onNeedsReprocess?.(source)
|
||
setAllValues(av => av.map(x =>
|
||
valueKey(x.extracted_value) === valueKey(row.extracted_value)
|
||
? { ...x, is_mapped: false, mapping_id: null, output: null }
|
||
: x
|
||
))
|
||
} catch (err) {
|
||
alert(err.message)
|
||
}
|
||
}
|
||
|
||
async function handleImportCSV(e) {
|
||
const file = e.target.files[0]
|
||
if (!file) return
|
||
e.target.value = ''
|
||
setImporting(true)
|
||
try {
|
||
const result = await api.importMappingsCSV(source, file)
|
||
alert(`Imported ${result.count} mapping${result.count !== 1 ? 's' : ''}.`)
|
||
const a = await api.getAllValues(source, selectedRule)
|
||
setAllValues(a)
|
||
setDrafts({})
|
||
setExtraCols([])
|
||
} catch (err) {
|
||
alert(err.message)
|
||
} finally {
|
||
setImporting(false)
|
||
}
|
||
}
|
||
|
||
if (!source) return <div className="p-6 text-sm text-gray-400">Select a source first.</div>
|
||
|
||
const displayRows = sortedRows(filteredRows)
|
||
|
||
return (
|
||
<div>
|
||
{/* Sticky control bar */}
|
||
<div className="sticky top-0 z-10 bg-white border-b border-gray-200 px-6 py-3 flex items-center gap-3 flex-wrap">
|
||
<span className="text-sm font-medium text-gray-700">{source}</span>
|
||
|
||
<select
|
||
className="text-sm border border-gray-200 rounded px-2 py-1.5 focus:outline-none focus:border-blue-400"
|
||
value={selectedRule}
|
||
onChange={e => setSelectedRule(e.target.value)}
|
||
>
|
||
<option value="">Select a rule…</option>
|
||
{rules.map(r => <option key={r.name} value={r.name}>{r.name}</option>)}
|
||
</select>
|
||
|
||
{selectedRule && (
|
||
<div className="flex bg-gray-100 rounded p-0.5">
|
||
{[
|
||
{ key: 'all', label: `All (${allValues.length})` },
|
||
{ key: 'unmapped', label: `Unmapped (${unmappedCount})` },
|
||
{ key: 'mapped', label: `Mapped (${mappedCount})` },
|
||
].map(({ key, label }) => (
|
||
<button key={key} onClick={() => setFilter(key)}
|
||
className={`text-xs px-3 py-1 rounded transition-colors ${
|
||
filter === key ? 'bg-white text-gray-800 shadow-sm' : 'text-gray-500'
|
||
}`}>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{selectedRule && (
|
||
<div className="relative">
|
||
<input
|
||
className={`text-xs font-mono border rounded px-2 py-1.5 w-44 focus:outline-none focus:border-blue-400 ${
|
||
rowFilterError ? 'border-red-400 bg-red-50' : rowFilter ? 'border-blue-300' : 'border-gray-200'
|
||
}`}
|
||
placeholder="filter regex…"
|
||
value={rowFilter}
|
||
onChange={e => setRowFilter(e.target.value)}
|
||
/>
|
||
{rowFilter && !rowFilterError && (
|
||
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-gray-400">
|
||
{filteredRows.length}
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{dirtyCount > 0 && (
|
||
<button
|
||
onClick={saveAllPending}
|
||
className="text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700"
|
||
>
|
||
Save all ({dirtyCount})
|
||
</button>
|
||
)}
|
||
|
||
<div className="ml-auto flex items-center gap-2">
|
||
{selectedRule && (
|
||
<button
|
||
onClick={async () => {
|
||
try {
|
||
const url = api.exportMappingsUrl(source, selectedRule)
|
||
const res = await fetch(url, { headers: authHeaders() })
|
||
if (!res.ok) throw new Error('Export failed')
|
||
const blob = await res.blob()
|
||
const a = document.createElement('a')
|
||
a.href = URL.createObjectURL(blob)
|
||
a.download = `mappings_${source}.tsv`
|
||
a.click()
|
||
URL.revokeObjectURL(a.href)
|
||
} catch (err) {
|
||
alert(err.message)
|
||
}
|
||
}}
|
||
className="text-sm px-3 py-1.5 border border-gray-200 rounded hover:bg-gray-50 text-gray-600"
|
||
>
|
||
Export TSV
|
||
</button>
|
||
)}
|
||
<label className={`text-sm px-3 py-1.5 border border-gray-200 rounded cursor-pointer hover:bg-gray-50 text-gray-600 ${importing ? 'opacity-50 pointer-events-none' : ''}`}>
|
||
{importing ? 'Importing…' : 'Import TSV'}
|
||
<input type="file" accept=".tsv,.txt" className="hidden" onChange={handleImportCSV} />
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Content */}
|
||
<div className="p-6">
|
||
{!selectedRule && (
|
||
<p className="text-sm text-gray-400">Select a rule to view mappings.</p>
|
||
)}
|
||
{selectedRule && loading && (
|
||
<p className="text-sm text-gray-400">Loading…</p>
|
||
)}
|
||
{selectedRule && !loading && allValues.length === 0 && (
|
||
<p className="text-sm text-gray-400">No extracted values for this rule. Run a transform first.</p>
|
||
)}
|
||
{selectedRule && !loading && allValues.length > 0 && (
|
||
<div className="overflow-x-auto">
|
||
{/* Bulk assign bar */}
|
||
{selected.size > 0 && (
|
||
<div className="flex items-center gap-2 mb-2 p-2 bg-blue-50 border border-blue-200 rounded flex-wrap">
|
||
<span className="text-xs text-blue-700 font-medium whitespace-nowrap">{selected.size} selected</span>
|
||
{cols.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={[...(valuesByCol[col] || [])].sort()}
|
||
/>
|
||
))}
|
||
<button
|
||
onClick={applyBulk}
|
||
disabled={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({}) }}
|
||
className="text-xs text-blue-400 hover:text-blue-600"
|
||
>
|
||
cancel
|
||
</button>
|
||
</div>
|
||
)}
|
||
<table className="w-full text-xs bg-white border border-gray-200 rounded">
|
||
<thead>
|
||
<tr className="text-left text-gray-400 border-b border-gray-100 bg-gray-50">
|
||
<th className="px-2 py-2 w-6">
|
||
<input
|
||
type="checkbox"
|
||
className="cursor-pointer"
|
||
checked={displayRows.length > 0 && displayRows.every(r => selected.has(valueKey(r.extracted_value)))}
|
||
onChange={e => {
|
||
if (e.target.checked) setSelected(new Set(displayRows.map(r => valueKey(r.extracted_value))))
|
||
else setSelected(new Set())
|
||
}}
|
||
/>
|
||
</th>
|
||
<SortHeader col="input_value" label="input_value" sortBy={sortBy} onSort={toggleSort} />
|
||
<SortHeader col="count" label="count" sortBy={sortBy} onSort={toggleSort} className="text-right" />
|
||
{existingCols.map(col => (
|
||
<SortHeader key={col} col={col} label={col} sortBy={sortBy} onSort={toggleSort} />
|
||
))}
|
||
{extraCols.map((col, idx) => (
|
||
<th key={`extra-${idx}`} className="px-3 py-2 font-medium">
|
||
<input
|
||
className="border border-gray-200 rounded px-1 py-0.5 w-24 focus:outline-none focus:border-blue-400 font-normal"
|
||
value={col}
|
||
placeholder="new key"
|
||
onChange={e => setExtraCols(ec => { const c = [...ec]; c[idx] = e.target.value; return c })}
|
||
/>
|
||
</th>
|
||
))}
|
||
<th className="px-2 py-2">
|
||
<button
|
||
onClick={() => setExtraCols(ec => [...ec, ''])}
|
||
className="text-gray-400 hover:text-gray-700 font-medium"
|
||
title="Add column"
|
||
>+</button>
|
||
</th>
|
||
<th className="px-3 py-2"></th>
|
||
<th className="px-3 py-2"></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{displayRows.map(row => {
|
||
const k = valueKey(row.extracted_value)
|
||
const rowIdx = displayRows.indexOf(row)
|
||
const isSaving = saving[k]
|
||
const isSelected = selected.has(k)
|
||
const hasDraft = !!(drafts[k] && Object.keys(drafts[k]).length > 0)
|
||
const rowBg = isSelected ? 'bg-blue-50' : hasDraft ? 'bg-blue-50' : row.is_mapped ? '' : 'bg-yellow-50'
|
||
|
||
function handleRowClick(e) {
|
||
if (e.target.closest('input,button,a,select')) return
|
||
setSelected(s => { const n = new Set(s); n.has(k) ? n.delete(k) : n.add(k); return n })
|
||
setCursorKey(k)
|
||
}
|
||
|
||
function handleRowKeyDown(e) {
|
||
if (!e.shiftKey || (e.key !== 'ArrowDown' && e.key !== 'ArrowUp')) return
|
||
e.preventDefault()
|
||
const delta = e.key === 'ArrowDown' ? 1 : -1
|
||
const curIdx = cursorKey ? displayRows.findIndex(r => valueKey(r.extracted_value) === cursorKey) : rowIdx
|
||
const nextIdx = Math.max(0, Math.min(displayRows.length - 1, curIdx + delta))
|
||
const nextKey = valueKey(displayRows[nextIdx].extracted_value)
|
||
setSelected(s => new Set([...s, nextKey]))
|
||
setCursorKey(nextKey)
|
||
rowRefs.current[nextKey]?.focus()
|
||
}
|
||
const samples = row.sample
|
||
? (Array.isArray(row.sample) ? row.sample : [row.sample])
|
||
: []
|
||
|
||
const cellVal = col => {
|
||
const drafted = drafts[k]?.[col]
|
||
if (drafted !== undefined) return drafted
|
||
return row.is_mapped ? String(row.output?.[col] ?? '') : ''
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<tr
|
||
key={k}
|
||
ref={el => rowRefs.current[k] = el}
|
||
tabIndex={0}
|
||
className={`border-t border-gray-50 hover:bg-gray-50 cursor-pointer outline-none ${rowBg}`}
|
||
onClick={handleRowClick}
|
||
onKeyDown={handleRowKeyDown}
|
||
>
|
||
<td className="px-2 py-1.5">
|
||
<input
|
||
type="checkbox"
|
||
className="cursor-pointer"
|
||
checked={isSelected}
|
||
onChange={() => {
|
||
setSelected(s => { const n = new Set(s); n.has(k) ? n.delete(k) : n.add(k); return n })
|
||
setCursorKey(k)
|
||
}}
|
||
/>
|
||
</td>
|
||
<td className="px-3 py-1.5 font-mono text-gray-800 whitespace-nowrap">{displayValue(row.extracted_value)}</td>
|
||
<td className="px-3 py-1.5 text-right text-gray-400">{row.record_count}</td>
|
||
{cols.map(col => (
|
||
<td key={col} className="px-3 py-1.5">
|
||
<AutocompleteInput
|
||
className={`border rounded px-2 py-1 w-full min-w-24 focus:outline-none focus:border-blue-400 ${
|
||
hasDraft ? 'border-blue-300' : row.is_mapped ? 'border-gray-200' : 'border-yellow-300'
|
||
}`}
|
||
value={cellVal(col)}
|
||
onChange={v => setCellValue(row.extracted_value, col, v)}
|
||
onEnter={() => saveRow(row)}
|
||
suggestions={[...(valuesByCol[col] || [])].sort()}
|
||
/>
|
||
</td>
|
||
))}
|
||
<td />
|
||
<td className="px-3 py-1.5 whitespace-nowrap">
|
||
{samples.length > 0 && (
|
||
<button
|
||
className="text-blue-400 hover:text-blue-600"
|
||
onClick={() => setSampleOpen(s => ({ ...s, [k]: !s[k] }))}
|
||
>
|
||
{sampleOpen[k] ? 'hide' : 'show'}
|
||
</button>
|
||
)}
|
||
</td>
|
||
<td className="px-3 py-1.5">
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={() => saveRow(row)}
|
||
disabled={isSaving}
|
||
className="bg-blue-600 text-white px-2 py-1 rounded hover:bg-blue-700 disabled:opacity-50 whitespace-nowrap"
|
||
>
|
||
{isSaving ? '…' : 'Save'}
|
||
</button>
|
||
{row.is_mapped && (
|
||
<button
|
||
onClick={() => deleteRow(row)}
|
||
className="text-red-400 hover:text-red-600 text-base leading-none"
|
||
title="Remove mapping"
|
||
>×</button>
|
||
)}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
{sampleOpen[k] && (() => {
|
||
const sampleCols = [...new Set(samples.flatMap(r => Object.keys(r)))]
|
||
return (
|
||
<tr key={`${k}-sample`} className="border-t border-gray-50 bg-gray-50">
|
||
<td colSpan={3 + cols.length + 4} className="px-3 py-2">
|
||
<table className="w-full text-xs border border-gray-100 rounded bg-white">
|
||
<thead>
|
||
<tr className="bg-gray-50 border-b border-gray-100">
|
||
{sampleCols.map(c => (
|
||
<th key={c} className="px-2 py-1 text-left font-medium text-gray-400 whitespace-nowrap">{c}</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{samples.map((rec, i) => (
|
||
<tr key={i} className="border-t border-gray-50">
|
||
{sampleCols.map(c => (
|
||
<td key={c} className="px-2 py-1 font-mono text-gray-600 whitespace-nowrap">
|
||
{rec[c] != null ? String(rec[c]) : ''}
|
||
</td>
|
||
))}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</td>
|
||
</tr>
|
||
)
|
||
})()}
|
||
</>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|