mmm-modeler_app / src /pages /Step2Variables.jsx
aashish-bindal's picture
Fix layout structure to match original HTML exactly
bf3dec4
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>
);
}