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:
parent
e5b20c1b36
commit
9eaa85dc37
2 changed files with 119 additions and 73 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue