3-step upload flow: select files → set tiers → extract

- Step 1: Select files (shown with gold bullet points after selection)
- Step 2: Tier mapping is REQUIRED - must click "None" or a preset
  before extraction is enabled. Red asterisk indicates required.
- Step 3: Choose Normal/Deep extraction mode, then click "Extract Assets"
- Upload no longer auto-triggers extraction on file select
- Uploaded filenames shown persistently on the Upload tab
- Tier confirmation state tracked (tierConfirmed)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
DJP 2026-04-10 10:52:51 -04:00
parent e5b20c1b36
commit 9eaa85dc37
2 changed files with 119 additions and 73 deletions

View file

@ -145,6 +145,42 @@
cursor: pointer;
}
.uploaded-files {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 12px 16px;
margin-top: 12px;
}
.uploaded-files-label {
font-size: 11px;
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 6px;
}
.uploaded-file-item {
font-size: 13px;
color: var(--color-text);
padding: 3px 0;
padding-left: 14px;
position: relative;
}
.uploaded-file-item::before {
content: '';
position: absolute;
left: 0;
top: 10px;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--color-primary);
}
.upload-timer {
font-size: 12px;
color: var(--color-text-muted);

View file

@ -59,6 +59,10 @@ export default function ProjectView() {
const [uploadStage, setUploadStage] = useState('');
const [extractionMode, setExtractionMode] = useState<'normal' | 'deep'>('normal');
const [uploadTimer, setUploadTimer] = useState(0);
const [uploadedFiles, setUploadedFiles] = useState<string[]>([]);
const [filesReady, setFilesReady] = useState(false);
const [pendingFiles, setPendingFiles] = useState<FileList | null>(null);
const [tierConfirmed, setTierConfirmed] = useState(false);
const [refineInput, setRefineInput] = useState('');
const [refining, setRefining] = useState(false);
const [refineLog, setRefineLog] = useState<string[]>([]);
@ -86,6 +90,9 @@ export default function ProjectView() {
]);
setProject(projRes.data);
setAssets(assetsRes.data);
if (projRes.data.source_filename) {
setUploadedFiles(projRes.data.source_filename.split(', ').filter(Boolean));
}
if (assetsRes.data.length > 0) {
const matchRes = await api.get(`/projects/${id}/matches`);
@ -144,19 +151,31 @@ export default function ProjectView() {
loadEfficiency();
}, []);
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
const files = e.target.files;
if (!files || files.length === 0) return;
setPendingFiles(files);
setFilesReady(true);
setUploadedFiles(Array.from(files).map(f => f.name));
}
async function handleExtract() {
if (!pendingFiles || pendingFiles.length === 0) return;
if (!tierConfirmed) {
alert('Please select a tier mapping (or "None") before extracting.');
return;
}
setUploading(true);
setUploadTimer(0);
const timerInterval = setInterval(() => setUploadTimer(t => t + 1), 1000);
const names = Array.from(files).map(f => f.name).join(', ');
setUploadStage(`Uploading ${files.length} file(s): ${names}...`);
const names = Array.from(pendingFiles).map(f => f.name).join(', ');
setUploadStage(`Uploading ${pendingFiles.length} file(s): ${names}...`);
try {
const form = new FormData();
for (let i = 0; i < files.length; i++) {
form.append('files', files[i]);
for (let i = 0; i < pendingFiles.length; i++) {
form.append('files', pendingFiles[i]);
}
await api.post(`/projects/${id}/upload?mode=${extractionMode}`, form);
} catch (err: any) {
@ -179,6 +198,8 @@ export default function ProjectView() {
clearInterval(timerInterval);
setUploading(false);
setUploadStage('');
setPendingFiles(null);
setFilesReady(false);
await loadProject();
setTab('matches');
}
@ -498,6 +519,7 @@ export default function ProjectView() {
{tab === 'upload' && (
<div className="tab-content">
{/* Step 1: File Selection */}
<div className={`upload-zone ${uploading ? 'upload-active' : ''}`}>
{uploading ? (
<>
@ -512,100 +534,88 @@ export default function ProjectView() {
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>
</svg>
</div>
<p className="upload-title">Upload Client Document</p>
<p className="upload-desc">Word (.docx) or Excel (.xlsx) file with the client's asset brief</p>
<div className="extraction-mode-toggle">
<button
onClick={() => setExtractionMode('normal')}
className={`eff-btn ${extractionMode === 'normal' ? 'eff-btn-active' : ''}`}
>Normal</button>
<button
onClick={() => setExtractionMode('deep')}
className={`eff-btn ${extractionMode === 'deep' ? 'eff-btn-active' : ''}`}
>Deep Extraction</button>
</div>
<p className="extraction-mode-desc">
{extractionMode === 'normal'
? 'Fast extraction — best for clean asset lists and simple briefs.'
: 'Two-pass AI analysis — best for complex spreadsheets with tiers, merged cells, and mixed data. Takes longer, costs more (~$0.15-0.30).'}
</p>
<label className="btn btn-primary upload-btn">
Choose File
<input type="file" accept=".docx,.xlsx,.txt" onChange={handleUpload} hidden multiple />
<p className="upload-title">Step 1: Select Client Documents</p>
<p className="upload-desc">Word (.docx) or Excel (.xlsx) select one or multiple files</p>
<label className="btn btn-secondary upload-btn">
{filesReady ? 'Change Files' : 'Choose Files'}
<input type="file" accept=".docx,.xlsx,.txt" onChange={handleFileSelect} hidden multiple />
</label>
{project.source_filename && (
<p className="upload-file">Current: {project.source_filename}</p>
)}
</>
)}
</div>
{/* Tier Mapping - set BEFORE matching */}
<div className="tier-mapping-box" style={{ marginTop: 20 }}>
{/* Show uploaded/selected files */}
{(uploadedFiles.length > 0 || filesReady) && !uploading && (
<div className="uploaded-files">
<div className="uploaded-files-label">Selected Documents:</div>
{uploadedFiles.map((name, i) => (
<div key={i} className="uploaded-file-item">{name}</div>
))}
</div>
)}
{/* Step 2: Tier Mapping (required selection) */}
{!uploading && (
<div className="tier-mapping-box" style={{ marginTop: 16 }}>
<div className="tier-mapping-header">
<span className="efficiency-label">Client Tier Mapping (optional):</span>
<span className="efficiency-label">Step 2: Client Tier Mapping <span style={{ color: 'var(--color-danger)' }}>*</span></span>
<div className="efficiency-buttons">
<button onClick={() => { setTierPreset(''); saveTierMapping([]); }} className={`eff-btn ${tierMapping.tiers.length === 0 ? 'eff-btn-active' : ''}`}>None</button>
<button onClick={() => applyTierPreset('abc')} className={`eff-btn ${tierPreset === 'abc' ? 'eff-btn-active' : ''}`}>A/B/C</button>
<button onClick={() => applyTierPreset('123')} className={`eff-btn ${tierPreset === '123' ? 'eff-btn-active' : ''}`}>1/2/3</button>
<button onClick={() => applyTierPreset('sml')} className={`eff-btn ${tierPreset === 'sml' ? 'eff-btn-active' : ''}`}>S/M/L</button>
<button onClick={() => applyTierPreset('small_med_large')} className={`eff-btn ${tierPreset === 'small_med_large' ? 'eff-btn-active' : ''}`}>Small/Med/Large</button>
<button onClick={() => applyTierPreset('gsb')} className={`eff-btn ${tierPreset === 'gsb' ? 'eff-btn-active' : ''}`}>Gold/Silver/Bronze</button>
<button onClick={() => { setTierPreset('none'); setTierConfirmed(true); saveTierMapping([]); }} className={`eff-btn ${tierPreset === 'none' || (tierConfirmed && tierMapping.tiers.length === 0) ? 'eff-btn-active' : ''}`}>None (no tiers)</button>
<button onClick={() => { applyTierPreset('abc'); setTierConfirmed(true); }} className={`eff-btn ${tierPreset === 'abc' ? 'eff-btn-active' : ''}`}>A/B/C</button>
<button onClick={() => { applyTierPreset('123'); setTierConfirmed(true); }} className={`eff-btn ${tierPreset === '123' ? 'eff-btn-active' : ''}`}>1/2/3</button>
<button onClick={() => { applyTierPreset('sml'); setTierConfirmed(true); }} className={`eff-btn ${tierPreset === 'sml' ? 'eff-btn-active' : ''}`}>S/M/L</button>
<button onClick={() => { applyTierPreset('small_med_large'); setTierConfirmed(true); }} className={`eff-btn ${tierPreset === 'small_med_large' ? 'eff-btn-active' : ''}`}>Small/Med/Large</button>
<button onClick={() => { applyTierPreset('gsb'); setTierConfirmed(true); }} className={`eff-btn ${tierPreset === 'gsb' ? 'eff-btn-active' : ''}`}>Gold/Silver/Bronze</button>
</div>
</div>
{tierMapping.tiers.length > 0 && (
<div className="tier-editor">
{tierMapping.tiers.map((t, i) => (
<div key={i} className="tier-editor-row">
<input
className="input tier-input"
value={t.label}
onChange={e => {
const updated = [...tierMapping.tiers];
updated[i] = { ...updated[i], label: e.target.value };
saveTierMapping(updated);
}}
placeholder="Label (e.g. Tier A)"
/>
<input className="input tier-input" value={t.label}
onChange={e => { const u = [...tierMapping.tiers]; u[i] = { ...u[i], label: e.target.value }; saveTierMapping(u); }}
placeholder="Label (e.g. Tier A)" />
<span className="tier-arrow"></span>
<select
className="input tier-select"
value={t.complexity}
onChange={e => {
const updated = [...tierMapping.tiers];
updated[i] = { ...updated[i], complexity: e.target.value };
saveTierMapping(updated);
}}
>
<select className="input tier-select" value={t.complexity}
onChange={e => { const u = [...tierMapping.tiers]; u[i] = { ...u[i], complexity: e.target.value }; saveTierMapping(u); }}>
<option value="simple">Simple</option>
<option value="medium">Medium</option>
<option value="complex">Complex</option>
</select>
<button
className="tier-remove"
onClick={() => {
const updated = tierMapping.tiers.filter((_, idx) => idx !== i);
saveTierMapping(updated);
setTierPreset('');
}}
>×</button>
<button className="tier-remove" onClick={() => { saveTierMapping(tierMapping.tiers.filter((_, idx) => idx !== i)); setTierPreset(''); }}>×</button>
</div>
))}
<button
className="btn btn-secondary btn-sm"
style={{ marginTop: 6 }}
onClick={() => saveTierMapping([...tierMapping.tiers, { label: '', complexity: 'medium' }])}
>
<button className="btn btn-secondary btn-sm" style={{ marginTop: 6 }}
onClick={() => saveTierMapping([...tierMapping.tiers, { label: '', complexity: 'medium' }])}>
+ Add Tier
</button>
</div>
)}
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 8 }}>
Set tiers before running AI matching. The AI will extract tier labels from the client document and match each to the correct GMAL complexity variant.
</div>
)}
{/* Step 3: Extraction Mode + Extract Button */}
{filesReady && tierConfirmed && !uploading && (
<div className="extract-step" style={{ marginTop: 16 }}>
<div className="tier-mapping-box">
<div className="tier-mapping-header">
<span className="efficiency-label">Step 3: Extraction Mode</span>
<div className="efficiency-buttons">
<button onClick={() => setExtractionMode('normal')} className={`eff-btn ${extractionMode === 'normal' ? 'eff-btn-active' : ''}`}>Normal</button>
<button onClick={() => setExtractionMode('deep')} className={`eff-btn ${extractionMode === 'deep' ? 'eff-btn-active' : ''}`}>Deep Extraction</button>
</div>
</div>
<p className="extraction-mode-desc">
{extractionMode === 'normal'
? 'Fast single-pass extraction — best for clean asset lists.'
: 'Two-pass AI analysis — best for complex spreadsheets. Takes longer (~$0.15-0.30).'}
</p>
<button onClick={handleExtract} className="btn btn-primary" style={{ marginTop: 8 }}>
Extract Assets from {uploadedFiles.length} File(s)
</button>
</div>
</div>
)}
{assets.length > 0 && (
<div className="assets-section">