Spaces:
Sleeping
Sleeping
| 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 · | |
| <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> | |
| ); | |
| } | |