mmm-modeler_app / src /pages /Step1Setup.jsx
aashish-bindal's picture
Fix layout structure to match original HTML exactly
bf3dec4
import { useState, useCallback, useMemo } from 'react';
import { useAppState, useAppDispatch } from '../context/AppContext.jsx';
import Header from '../components/Header.jsx';
import Loader from '../components/Loader.jsx';
import ColFilterDropdown from '../components/ColFilterDropdown.jsx';
import { showWarnToast, showInfoToast } from '../components/Toast.jsx';
import { parseCSVWithProgress, fmtSize, pKey } from '../utils/csvParser.js';
import { parsePastXLSX } from '../utils/pastResultParser.js';
const UploadIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round">
<rect x="3" y="2" width="12" height="16" rx="2"/><path d="M6 7h8M6 10.5h8M6 14h5"/>
</svg>
);
const PanelIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round">
<rect x="2" y="4" width="16" height="12" rx="2"/><path d="M2 8h16M7 8v8M13 8v8"/>
</svg>
);
const DownloadIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round">
<path d="M3 14v2a1 1 0 001 1h12a1 1 0 001-1v-2M10 3v9M7 9l3 3 3-3"/>
</svg>
);
function FileCard({ id, label, color, hint, accept, file, onFile, onRemove, children }) {
const [drag, setDrag] = useState(false);
const [err, setErr] = useState(false);
function handleFile(f) {
setErr(false);
if (id === 'r' && !f.name.match(/\.xlsx$/i)) { setErr(true); return; }
if (id !== 'r' && !f.name.toLowerCase().endsWith('.csv')) { setErr(true); return; }
onFile(f);
}
return (
<div className="card">
<div className="card-hd">
<div className="card-hd-t"><span className="dot" style={{ background: color }} />{label}</div>
<span className={`tag ${id === 'r' ? 'tag-gy' : 'tag-r'}`}>{id === 'r' ? 'Optional' : 'Required'}</span>
</div>
<div className="up-item">
{!file ? (
<div
className={`dz${drag ? ' drag' : ''}`}
onDragOver={e => { e.preventDefault(); setDrag(true); }}
onDragLeave={() => setDrag(false)}
onDrop={e => { e.preventDefault(); setDrag(false); const f = e.dataTransfer.files[0]; if (f) handleFile(f); }}
onClick={() => document.getElementById(`inp-${id}`).click()}
>
<input
type="file" id={`inp-${id}`} accept={accept} style={{ display: 'none' }}
onChange={e => { const f = e.target.files[0]; if (f) handleFile(f); e.target.value = ''; }}
/>
<div className="dz-ico">
{id === 'd' ? <UploadIcon /> : id === 'p' ? <PanelIcon /> : <DownloadIcon />}
</div>
<div className="dz-main">Drop {id === 'r' ? 'Excel' : 'CSV'} or browse</div>
<div className="dz-sub">{hint}</div>
</div>
) : (
<div className="file-chip">
<svg width="11" height="11" viewBox="0 0 12 12" fill="none" stroke={id === 'r' ? 'var(--or)' : 'var(--g)'} strokeWidth="2.2" strokeLinecap="round"><path d="M1 6l4 4 6-6"/></svg>
<span className="fc-name">{file === true ? '(from past result)' : file.name}</span>
<span className="fc-size">{file === true ? '' : fmtSize(file.size)}</span>
<button className="fc-rm" onClick={onRemove}>×</button>
</div>
)}
{err && <div className="file-err">Only {id === 'r' ? '.xlsx' : '.csv'} accepted</div>}
{children}
</div>
</div>
);
}
export default function Step1Setup({ onNext, onNav }) {
const state = useAppState();
const dispatch = useAppDispatch();
const { files, dictCSV, panelCSV, selectedPanels, p1ColFilters, fromPastResult } = state;
const [loading, setLoading] = useState(false);
const [loadMsg, setLoadMsg] = useState('');
const [loadPct, setLoadPct] = useState(null);
const [restoreMsg, setRestoreMsg] = useState(null);
const [ddCol, setDdCol] = useState(null);
const [ddAnchor, setDdAnchor] = useState(null);
const [panelSearch, setPanelSearch] = useState('');
// cascading filter options for P1
function cascadeVals(col) {
if (!panelCSV) return [];
const others = Object.entries(p1ColFilters).filter(([c]) => c !== col);
return [...new Set(
panelCSV.rows
.filter(row => others.every(([c, vals]) => !vals || !vals.size || vals.has((row[c] || '').trim())))
.map(row => (row[col] || '').trim())
.filter(Boolean)
)].sort();
}
function panelRowPasses(row) {
for (const [col, vals] of Object.entries(p1ColFilters)) {
if (vals && vals.size && !vals.has((row[col] || '').trim())) return false;
}
if (panelSearch) {
const q = panelSearch.toLowerCase();
if (!panelCSV.headers.some(h => String(row[h] || '').toLowerCase().includes(q))) return false;
}
return true;
}
const filteredRows = useMemo(() => {
if (!panelCSV) return [];
return panelCSV.rows.filter(panelRowPasses);
}, [panelCSV, p1ColFilters, panelSearch]);
async function handleDictFile(file) {
setLoadMsg('Reading ' + file.name + '…'); setLoading(true); setLoadPct(0);
const parsed = await parseCSVWithProgress(await file.text(), (pct) => setLoadPct(pct));
setLoading(false);
// Validate
const required = ['VARIABLE','PRIMARY_METRICS','SN_ACTIVITY_LEVEL_1'];
const missing = required.filter(c => !parsed.headers.includes(c));
if (missing.length) showWarnToast('Dictionary missing columns: ' + missing.join(', '));
dispatch({ type: 'SET_DICT_CSV', payload: parsed });
dispatch({ type: 'SET_FILE', key: 'd', value: file });
// Apply restored vars if pending
if (state._rVars) {
const rows = parsed.rows.map(r => ({ ...r, _sel: state._rVars.has((r.VARIABLE || '').trim()) }));
const token = rows.length + '|' + parsed.headers.join(',');
dispatch({ type: 'SET_ALL_ROWS', payload: rows, token });
dispatch({ type: 'SET_RESTORE_BUFFERS', payload: { _rVars: null } });
}
}
async function handlePanelFile(file) {
setLoadMsg('Reading ' + file.name + '…'); setLoading(true); setLoadPct(0);
const parsed = await parseCSVWithProgress(await file.text(), (pct) => setLoadPct(pct));
setLoading(false);
let newSelected = new Set();
if (state._rPanels && state._rPanels.length) {
const pHeaders = parsed.headers;
const firstCol = pHeaders[0] || '';
parsed.rows.forEach(row => {
const k = pKey(row);
const bestScore = state._rPanels.reduce((best, rp) => {
const rv = (row[firstCol] || '').trim().toLowerCase();
const rpv = (rp[firstCol] || '').toLowerCase();
if (firstCol && rpv && rv !== rpv) return best;
const nonEmpty = pHeaders.filter(h => rp[h]);
if (!nonEmpty.length) return best;
const matches = nonEmpty.filter(h => (row[h] || '').trim().toLowerCase() === rp[h].toLowerCase());
return Math.max(best, matches.length / nonEmpty.length);
}, 0);
if (bestScore >= 0.5) newSelected.add(k);
});
if (newSelected.size === 0) showWarnToast('No panels matched past result — check your Panel CSV.');
dispatch({ type: 'SET_RESTORE_BUFFERS', payload: { _rPanels: null } });
}
dispatch({ type: 'SET_PANEL_CSV', payload: parsed });
dispatch({ type: 'SET_SELECTED_PANELS', payload: newSelected });
dispatch({ type: 'SET_P1_COL_FILTERS', payload: {} });
dispatch({ type: 'SET_FILE', key: 'p', value: file });
}
async function handleResultFile(file) {
setLoadMsg('Reading past result…'); setLoading(true);
const ab = await file.arrayBuffer();
setLoading(false);
const result = parsePastXLSX(ab);
if (!result) { showWarnToast('Failed to read past result file.'); return; }
dispatch({ type: 'SET_FILE', key: 'r', value: file });
const buffers = {};
if (result.panels) buffers._rPanels = result.panels;
if (result.vars) buffers._rVars = result.vars;
if (result.contributions) buffers._rC = result.contributions;
if (result.mapping) buffers._rMap = result.mapping;
if (result.fixedVars) buffers._rFix = result.fixedVars;
dispatch({ type: 'SET_RESTORE_BUFFERS', payload: buffers });
if (result.config) dispatch({ type: 'SET_CONFIG', payload: result.config });
if (result.synthPanelCSV) {
dispatch({ type: 'SET_PANEL_CSV', payload: result.synthPanelCSV });
dispatch({ type: 'SET_SELECTED_PANELS', payload: new Set(result.synthPanelCSV.rows.map(r => String(r.__id))) });
dispatch({ type: 'SET_FILE', key: 'p', value: true });
dispatch({ type: 'SET_FROM_PAST_RESULT', payload: true });
}
if (result.synthDictCSV) {
dispatch({ type: 'SET_DICT_CSV', payload: result.synthDictCSV });
const token = result.synthDictCSV.rows.length + '|' + result.synthDictCSV.headers.join(',');
dispatch({ type: 'SET_ALL_ROWS', payload: result.synthDictCSV.rows, token });
dispatch({ type: 'SET_FILE', key: 'd', value: true });
// _sel flags already applied in synthDictCSV rows — clear _rVars buffer to avoid double-apply
dispatch({ type: 'SET_RESTORE_BUFFERS', payload: { _rVars: null } });
}
const ok = !!(result.panels || result.vars);
const hasBoth = !!(result.synthPanelCSV && result.synthDictCSV);
setRestoreMsg({ ok, text: ok
? hasBoth
? '✓ All data restored — proceed directly or upload new files to override'
: '✓ Pre-fill ready — upload Dictionary & Panel List CSVs to apply'
: '⚠ Could not read past result'
});
if (ok) showInfoToast('Past result loaded');
}
function removeFile(id) {
dispatch({ type: 'SET_FILE', key: id, value: null });
if (id === 'd') dispatch({ type: 'SET_DICT_CSV', payload: null });
if (id === 'p') {
dispatch({ type: 'SET_PANEL_CSV', payload: null });
dispatch({ type: 'SET_SELECTED_PANELS', payload: new Set() });
dispatch({ type: 'SET_P1_COL_FILTERS', payload: {} });
}
if (id === 'r') {
setRestoreMsg(null);
if (fromPastResult) {
dispatch({ type: 'SET_FROM_PAST_RESULT', payload: false });
dispatch({ type: 'SET_PANEL_CSV', payload: null });
dispatch({ type: 'SET_DICT_CSV', payload: null });
dispatch({ type: 'SET_SELECTED_PANELS', payload: new Set() });
dispatch({ type: 'SET_P1_COL_FILTERS', payload: {} });
dispatch({ type: 'SET_FILE', key: 'd', value: null });
dispatch({ type: 'SET_FILE', key: 'p', value: null });
dispatch({ type: 'SET_RESTORE_BUFFERS', payload: { _rPanels: null, _rVars: null, _rC: null, _rMap: null, _rFix: null } });
}
}
}
function togglePanel(k) {
const next = new Set(selectedPanels);
if (next.has(k)) next.delete(k); else next.add(k);
dispatch({ type: 'SET_SELECTED_PANELS', payload: next });
}
function selAll(on) {
const next = new Set(selectedPanels);
filteredRows.forEach(r => { const k = pKey(r); if (on) next.add(k); else next.delete(k); });
dispatch({ type: 'SET_SELECTED_PANELS', payload: next });
}
function toggleColFilter(col, val, on) {
const next = { ...p1ColFilters };
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];
dispatch({ type: 'SET_P1_COL_FILTERS', payload: next });
}
function clearFilters() {
dispatch({ type: 'SET_P1_COL_FILTERS', payload: {} });
setPanelSearch('');
}
const canProceed = files.d && files.p && selectedPanels.size > 0;
const hasFilters = Object.values(p1ColFilters).some(v => v && v.size > 0) || !!panelSearch;
const headers = panelCSV?.headers || [];
const allVisChecked = filteredRows.length > 0 && filteredRows.every(r => selectedPanels.has(pKey(r)));
const anyVisChecked = filteredRows.some(r => selectedPanels.has(pKey(r)));
return (
<div className="page" id="p1">
{loading && <Loader message={loadMsg} progress={loadPct} />}
{ddCol && (
<ColFilterDropdown
col={ddCol}
values={cascadeVals(ddCol)}
selected={p1ColFilters[ddCol] || new Set()}
onToggle={(v, on) => toggleColFilter(ddCol, v, on)}
onClose={() => { setDdCol(null); setDdAnchor(null); }}
anchorRect={ddAnchor}
/>
)}
<Header step={1} onNav={onNav} sub="Model Config" />
<div className="body">
{/* LEFT: uploads */}
<div className="upload-panel">
<FileCard id="d" label="Dictionary" color="var(--g)" hint="Variable definitions" accept=".csv"
file={files.d} onFile={handleDictFile} onRemove={() => removeFile('d')} />
<FileCard id="p" label="Panel List" color="var(--blue)" hint="Panel dataset rows" accept=".csv"
file={files.p} onFile={handlePanelFile} onRemove={() => removeFile('p')} />
<FileCard id="r" label="Past Result" color="var(--or)" hint="Restore previous run" accept=".xlsx"
file={files.r} onFile={handleResultFile} onRemove={() => removeFile('r')}>
{restoreMsg && (
<div className="restore-ok" style={{ color: restoreMsg.ok ? 'var(--or)' : 'var(--red)' }}>
{restoreMsg.text}
</div>
)}
</FileCard>
</div>
{/* RIGHT: panel selection */}
<div className="panel-panel card">
<div className="card-hd">
<div className="card-hd-t"><span className="dot" style={{ background: 'var(--teal)' }} />Panel Selection</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
<svg style={{ position: 'absolute', left: 7, width: 10, height: 10, color: 'var(--mt)', pointerEvents: 'none' }} 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="inp" placeholder="Search panels…" value={panelSearch}
onChange={e => setPanelSearch(e.target.value)}
style={{ paddingLeft: 22, width: 160, fontSize: 11 }} />
</div>
<button className="btn btn-sm btn-sec" onClick={() => selAll(true)}>All</button>
<button className="btn btn-sm btn-sec" onClick={() => selAll(false)}>None</button>
{hasFilters && (
<button className="btn btn-sm btn-sec" onClick={clearFilters}>Clear filters</button>
)}
</div>
</div>
<div className="panel-sel-bar">
<span style={{ fontSize: 10.5, color: 'var(--mt)' }}>
<strong style={{ color: 'var(--g)' }}>{selectedPanels.size}</strong> selected &nbsp;·&nbsp;
<span>{panelCSV ? panelCSV.rows.length : 0} total</span>
</span>
<span className={`tag ${selectedPanels.size > 0 ? 'tag-g' : 'tag-gy'}`}>
{selectedPanels.size > 0 ? `${selectedPanels.size} ready` : 'Select ≥ 1'}
</span>
</div>
<div className="panel-tbl-wrap">
{!panelCSV ? (
<div className="empty-tbl">Upload a Panel List CSV to see rows here</div>
) : (
<table className="ptbl">
<thead>
<tr>
<th style={{ width: 32, minWidth: 32 }}>
<div className="pth-inner" style={{ cursor: 'default', justifyContent: 'center' }}>
<input type="checkbox" className="pcb"
checked={allVisChecked}
ref={el => { if (el) el.indeterminate = !allVisChecked && anyVisChecked; }}
onChange={e => selAll(e.target.checked)} />
</div>
</th>
{headers.map(h => (
<th key={h}>
<div
className={`pth-inner${p1ColFilters[h] && p1ColFilters[h].size ? ' filtered' : ''}`}
onClick={e => { setDdCol(h); setDdAnchor(e.currentTarget.getBoundingClientRect()); }}
>
<span className="pth-lbl" title={h}>{h}</span>
<svg style={{ width: 8, height: 8, flexShrink: 0, opacity: p1ColFilters[h]?.size ? 1 : 0.45 }} viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.8"><path d="M1 3h10M3 6h6M5 9h2"/></svg>
{p1ColFilters[h]?.size > 0 && (
<span style={{ background: 'var(--g)', color: '#fff', borderRadius: 10, fontSize: 8, fontWeight: 700, padding: '0 4px' }}>
{p1ColFilters[h].size}
</span>
)}
</div>
</th>
))}
</tr>
</thead>
<tbody>
{filteredRows.map(row => {
const k = pKey(row);
return (
<tr key={k} className={selectedPanels.has(k) ? 'sel' : ''} onClick={() => togglePanel(k)}>
<td><input type="checkbox" className="pcb" checked={selectedPanels.has(k)} onChange={() => togglePanel(k)} onClick={e => e.stopPropagation()} /></td>
{headers.map(h => <td key={h} title={row[h]}>{row[h]}</td>)}
</tr>
);
})}
</tbody>
</table>
)}
</div>
</div>
</div>
<div className="nav-foot">
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="nav-foot-info">
{!files.d || !files.p ? 'Upload required files to continue'
: !selectedPanels.size ? 'Select at least 1 panel'
: <><strong>{selectedPanels.size} panel(s)</strong> selected — ready</>}
</span>
<button className="btn btn-sm btn-sec btn-danger" onClick={() => dispatch({ type: 'RESET_ALL' })}>
Reset All
</button>
</div>
<button className="btn btn-pri" disabled={!canProceed} onClick={onNext}>
Proceed to Variables
<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>
);
}