Spaces:
Sleeping
Sleeping
| import { useState, useMemo, useEffect } from 'react'; | |
| import { useAppState, useAppDispatch } from '../context/AppContext.jsx'; | |
| import ColFilterDropdown from '../components/ColFilterDropdown.jsx'; | |
| import { DISP_COLS, DICT_COLS, TOOLTIP_COLS, TOOLTIP_LABELS, STEP_LABELS } from '../utils/constants.js'; | |
| const CheckSvg = () => ( | |
| <svg width="8" height="8" viewBox="0 0 12 12" fill="none" stroke="#fff" strokeWidth="2.5" strokeLinecap="round"> | |
| <path d="M2 6l3 3 5-5"/> | |
| </svg> | |
| ); | |
| function DictTooltip({ row }) { | |
| if (!row) return null; | |
| return ( | |
| <div className="tt-dict"> | |
| {TOOLTIP_COLS.map(col => { | |
| const val = (row[col] || '').trim(); | |
| if (!val) return null; | |
| return ( | |
| <div key={col} className="tt-dict-row"> | |
| <span className="tt-dict-key">{TOOLTIP_LABELS[col] || col}</span> | |
| <span className="tt-dict-val">{val}</span> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| ); | |
| } | |
| export default function Step2Variables({ onNext, onBack, onNav }) { | |
| const state = useAppState(); | |
| const dispatch = useAppDispatch(); | |
| const { allRows, dictCSV, _dictToken, _rVars } = state; | |
| const [search, setSearch] = useState(''); | |
| const [colFilters, setColFilters] = useState({}); | |
| const [ddCol, setDdCol] = useState(null); | |
| const [ddAnchor, setDdAnchor] = useState(null); | |
| const [tooltip, setTooltip] = useState({ visible: false, x: 0, y: 0, content: null }); | |
| const [summaryWidth, setSummaryWidth] = useState(280); | |
| const [resizing, setResizing] = useState(false); | |
| // Build/rebuild allRows when entering this step | |
| useEffect(() => { | |
| if (!dictCSV) return; | |
| const token = dictCSV.rows.length + '|' + (dictCSV.headers || []).join(','); | |
| if (allRows.length && _dictToken === token) return; // preserve selections | |
| const rows = dictCSV.rows.map((r, i) => { | |
| const o = { __id: i + 1 }; | |
| DICT_COLS.forEach(c => { o[c] = (r[c] || '').trim(); }); | |
| o._sel = _rVars ? _rVars.has((r.VARIABLE || '').trim()) | |
| : (r.PRIMARY_METRICS || '').toUpperCase() === 'Y'; | |
| return o; | |
| }); | |
| dispatch({ type: 'SET_ALL_ROWS', payload: rows, token }); | |
| if (_rVars) dispatch({ type: 'SET_RESTORE_BUFFERS', payload: { _rVars: null } }); | |
| }, [dictCSV]); | |
| function rowPasses(row) { | |
| for (const [col, vals] of Object.entries(colFilters)) { | |
| if (vals && vals.size && !vals.has(row[col])) return false; | |
| } | |
| if (search) { | |
| const q = search.toLowerCase(); | |
| if (!DICT_COLS.some(c => (row[c] || '').toLowerCase().includes(q))) return false; | |
| } | |
| return true; | |
| } | |
| const visible = useMemo(() => allRows.filter(rowPasses), [allRows, colFilters, search]); | |
| function toggleSel(id) { dispatch({ type: 'TOGGLE_VAR_SEL', id }); } | |
| function selectAll(on) { | |
| const ids = new Set(visible.map(r => r.__id)); | |
| dispatch({ type: 'SET_ALL_VARS_SEL', ids, on }); | |
| } | |
| function toggleColFilter(col, val, on) { | |
| const next = { ...colFilters }; | |
| if (!next[col]) next[col] = new Set(); else next[col] = new Set(next[col]); | |
| if (on) next[col].add(val); else next[col].delete(val); | |
| if (!next[col].size) delete next[col]; | |
| setColFilters(next); | |
| } | |
| function clearFilters() { setColFilters({}); setSearch(''); } | |
| // Summary — group by activity | |
| const { selRows, allActs, groupSel, totalPerAct } = useMemo(() => { | |
| const selRows = allRows.filter(r => r._sel); | |
| const groupSel = {}; | |
| selRows.forEach(r => { | |
| const a = r.SN_ACTIVITY_LEVEL_1 || '(Unassigned)'; | |
| if (!groupSel[a]) groupSel[a] = []; | |
| groupSel[a].push(r.VARIABLE); | |
| }); | |
| const totalPerAct = {}; | |
| allRows.forEach(r => { | |
| const a = r.SN_ACTIVITY_LEVEL_1 || '(Unassigned)'; | |
| totalPerAct[a] = (totalPerAct[a] || 0) + 1; | |
| }); | |
| const allActs = [...new Set(allRows.map(r => r.SN_ACTIVITY_LEVEL_1 || '(Unassigned)'))]; | |
| return { selRows, allActs, groupSel, totalPerAct }; | |
| }, [allRows]); | |
| const selCount = allRows.filter(r => r._sel).length; | |
| const hasFilters = Object.values(colFilters).some(v => v && v.size > 0) || !!search; | |
| // Tooltip handlers | |
| function showTT(e, row) { | |
| const x = Math.min(e.clientX + 11, window.innerWidth - 320); | |
| const y = Math.min(e.clientY + 14, window.innerHeight - 200); | |
| setTooltip({ visible: true, x, y, content: <DictTooltip row={row} /> }); | |
| } | |
| function moveTT(e) { | |
| const x = Math.min(e.clientX + 11, window.innerWidth - 320); | |
| const y = Math.min(e.clientY + 14, window.innerHeight - 200); | |
| setTooltip(t => ({ ...t, x, y })); | |
| } | |
| function hideTT() { setTooltip(t => ({ ...t, visible: false })); } | |
| // Resize summary panel | |
| function startResize(e) { | |
| e.preventDefault(); | |
| const startX = e.clientX, startW = summaryWidth; | |
| function onMove(ev) { setSummaryWidth(Math.max(180, Math.min(520, startW + (startX - ev.clientX)))); } | |
| function onUp() { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); } | |
| document.addEventListener('mousemove', onMove); | |
| document.addEventListener('mouseup', onUp); | |
| } | |
| // col values for dropdown | |
| function colVals(col) { | |
| return [...new Set(allRows.map(r => r[col]).filter(v => v))].sort(); | |
| } | |
| const zeroSel = allActs.filter(a => !groupSel[a]).sort(); | |
| const hasSel = allActs.filter(a => groupSel[a]?.length > 0).sort(); | |
| return ( | |
| <div className="page"> | |
| {tooltip.visible && ( | |
| <div className="tt" style={{ left: tooltip.x, top: tooltip.y, display: 'block' }}> | |
| {tooltip.content} | |
| </div> | |
| )} | |
| {ddCol && ( | |
| <ColFilterDropdown | |
| col={ddCol} | |
| values={colVals(ddCol)} | |
| selected={colFilters[ddCol] || new Set()} | |
| onToggle={(v, on) => toggleColFilter(ddCol, v, on)} | |
| onClose={() => { setDdCol(null); setDdAnchor(null); }} | |
| anchorRect={ddAnchor} | |
| /> | |
| )} | |
| <div className="p2-hdr"> | |
| <div /> | |
| <div style={{ textAlign: 'center' }}> | |
| <div className="p2-title">Variable Selection</div> | |
| <div style={{ fontSize: 9.5, color: 'rgba(255,255,255,.5)', marginTop: 1 }}>Check variables to include — PRIMARY_METRICS = Y</div> | |
| </div> | |
| <div /> | |
| </div> | |
| <div className="stepper"> | |
| {STEP_LABELS.map((label, i) => { | |
| const n = i + 1; | |
| const isDone = n < 2; | |
| const isActive = n === 2; | |
| return ( | |
| <span key={n} style={{ display: 'contents' }}> | |
| <div | |
| className={`stp${isDone ? ' done clickable' : isActive ? ' active' : ' pend'}`} | |
| style={{ cursor: isDone ? 'pointer' : 'default' }} | |
| onClick={() => isDone && onNav(n)} | |
| > | |
| <div className="stp-n">{isDone ? <CheckSvg /> : n}</div> | |
| <span className="stp-l">{label}</span> | |
| </div> | |
| {n < STEP_LABELS.length && <div className="stp-line" />} | |
| </span> | |
| ); | |
| })} | |
| </div> | |
| <div className="p2-body" style={{ flex: 1, display: 'grid', gridTemplateColumns: `1fr ${summaryWidth}px`, overflow: 'hidden', minHeight: 0 }}> | |
| {/* LEFT */} | |
| <div className="p2-pane" style={{ borderRight: '1px solid var(--bl)' }}> | |
| <div className="pane-hd"> | |
| <span className="pane-title">All Variables</span> | |
| <span className="pane-ct">({allRows.length})</span> | |
| <button className="btn btn-sm btn-sec" style={{ marginLeft: 'auto' }} onClick={() => selectAll(true)}>Select All</button> | |
| <button className="btn btn-sm btn-sec" onClick={() => selectAll(false)}>Deselect All</button> | |
| </div> | |
| <div className="pane-srch-row"> | |
| <svg className="pane-srch-ico" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="6.5" cy="6.5" r="4.5"/><path d="M10.5 10.5l3 3"/></svg> | |
| <input className="pane-srch" placeholder="Search variables…" value={search} onChange={e => setSearch(e.target.value)} /> | |
| </div> | |
| <div className={`ftags-bar${hasFilters ? ' has' : ''}`}> | |
| {Object.entries(colFilters).flatMap(([col, vals]) => | |
| [...vals].map(v => ( | |
| <span key={`${col}:${v}`} className="ftag"> | |
| <span>{col.replace(/^SN_/,'').replace(/_/g,' ')}: <b>{v}</b></span> | |
| <button onClick={() => toggleColFilter(col, v, false)}>×</button> | |
| </span> | |
| )) | |
| )} | |
| {hasFilters && <button className="ftag-clr" onClick={clearFilters}>Clear</button>} | |
| </div> | |
| <div className="tbl-outer"> | |
| <table className="dict-tbl"> | |
| <thead> | |
| <tr> | |
| <th style={{ width: 24, minWidth: 24 }}><div className="th-inner" style={{ cursor: 'default' }} /></th> | |
| {DISP_COLS.map(col => ( | |
| <th key={col} style={{ width: col === 'VARIABLE' ? 125 : col === 'SN_KPI' ? 80 : 100, minWidth: 45 }}> | |
| <div | |
| className={`th-inner${colFilters[col]?.size ? ' filtered' : ''}`} | |
| onClick={e => { setDdCol(col); setDdAnchor(e.currentTarget.getBoundingClientRect()); }} | |
| > | |
| <span className="th-lbl">{col.replace(/^SN_/,'').replace(/_/g,' ')}</span> | |
| <svg className="th-fic" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.8"><path d="M1 3h10M3 6h6M5 9h2"/></svg> | |
| </div> | |
| </th> | |
| ))} | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {visible.length === 0 ? ( | |
| <tr><td colSpan={DISP_COLS.length + 1}> | |
| <div className="empty-st"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35M8 11h6"/></svg> | |
| <div className="es-t">No results</div><div className="es-s">Try clearing filters</div> | |
| <button className="btn btn-sm btn-sec" style={{ marginTop: 6 }} onClick={clearFilters}>Clear</button> | |
| </div> | |
| </td></tr> | |
| ) : visible.map(row => ( | |
| <tr key={row.__id} className={row._sel ? 'hl' : ''} onClick={() => toggleSel(row.__id)}> | |
| <td className="cb-cell"> | |
| <input type="checkbox" className="row-cb" checked={!!row._sel} | |
| onChange={() => toggleSel(row.__id)} onClick={e => e.stopPropagation()} /> | |
| </td> | |
| {DISP_COLS.map(col => ( | |
| <td key={col} title={row[col]} | |
| onMouseEnter={col === 'VARIABLE' ? (e => showTT(e, row)) : undefined} | |
| onMouseMove={col === 'VARIABLE' ? moveTT : undefined} | |
| onMouseLeave={col === 'VARIABLE' ? hideTT : undefined} | |
| > | |
| {row[col] || ''} | |
| </td> | |
| ))} | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| {/* RESIZE HANDLE + SUMMARY */} | |
| <div style={{ display: 'flex', overflow: 'hidden' }}> | |
| <div | |
| style={{ width: 4, cursor: 'col-resize', background: resizing ? 'var(--g)' : 'var(--bl)', flexShrink: 0, transition: 'background .15s' }} | |
| onMouseDown={startResize} | |
| /> | |
| <div style={{ display: 'flex', flexDirection: 'column', overflow: 'hidden', background: 'var(--bgs)', flex: 1 }}> | |
| <div style={{ padding: '9px 12px', borderBottom: '1px solid var(--bl)', flexShrink: 0 }}> | |
| <div style={{ fontSize: 11, fontWeight: 700, color: 'var(--tx)', marginBottom: 3 }}>Variable Summary</div> | |
| <div style={{ display: 'flex', gap: 10, fontSize: 10 }}> | |
| <span><strong style={{ color: 'var(--g)' }}>{selCount}</strong> selected</span> | |
| <span style={{ color: 'var(--mt)' }}><strong style={{ color: 'var(--mt)' }}>{allRows.length - selCount}</strong> not selected</span> | |
| </div> | |
| </div> | |
| <div style={{ flex: 1, overflowY: 'auto', padding: '8px 10px', fontSize: 11 }}> | |
| {!allRows.length ? <div style={{ color: 'var(--mt)', fontSize: 10.5, padding: '8px 0' }}>No variables loaded</div> | |
| : [...zeroSel, ...hasSel].map(act => { | |
| const selVars = groupSel[act] || []; | |
| const total = totalPerAct[act] || 0; | |
| const isZero = selVars.length === 0; | |
| return ( | |
| <div key={act} className="p2-summ-group"> | |
| <div className={`p2-summ-act${isZero ? ' warn' : ''}`} title={act}> | |
| {act} <span style={{ fontWeight: 400, textTransform: 'none', letterSpacing: 0 }}>({selVars.length}/{total})</span> | |
| {isZero && ' ⚠️'} | |
| </div> | |
| {selVars.length ? selVars.map(v => ( | |
| <div key={v} className="p2-summ-var" title={v}>{v}</div> | |
| )) : ( | |
| <div style={{ fontSize: 10, color: 'var(--or)', padding: '2px 0 2px 8px', fontStyle: 'italic' }}>No variables selected</div> | |
| )} | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div className="nav-foot"> | |
| <button className="btn btn-sec" onClick={onBack}> | |
| <svg width="9" height="9" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2.3" strokeLinecap="round"><path d="M10 6H2M5 2L1 6l4 4"/></svg> Back | |
| </button> | |
| <button className="btn btn-pri" disabled={selCount === 0} onClick={onNext}> | |
| Proceed to Contributions | |
| <svg width="10" height="10" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="2.3" strokeLinecap="round"><path d="M2 7h10M8 3l4 4-4 4"/></svg> | |
| </button> | |
| </div> | |
| </div> | |
| ); | |
| } | |