Spaces:
Sleeping
Sleeping
| import React, { useState, useRef, useCallback, useEffect } from 'react' | |
| const STATUS_POLL_MS = 1500 // poll /api/upload/status every 1.5s while running | |
| export default function UploadModal({ open, onClose, onSuccess }) { | |
| const [phase, setPhase] = useState('idle') // idle | uploading | running | done | error | |
| const [message, setMessage] = useState('') | |
| const [logs, setLogs] = useState('') | |
| const [showLogs, setShowLogs] = useState(false) | |
| const [dragOver, setDragOver] = useState(false) | |
| const [filename, setFilename] = useState('') | |
| const fileRef = useRef(null) | |
| const pollRef = useRef(null) | |
| const logsRef = useRef(null) | |
| // Auto-scroll logs | |
| useEffect(() => { | |
| if (logsRef.current) logsRef.current.scrollTop = logsRef.current.scrollHeight | |
| }, [logs]) | |
| // Stop polling on unmount | |
| useEffect(() => () => clearInterval(pollRef.current), []) | |
| // Reset when modal opens | |
| useEffect(() => { | |
| if (open) { | |
| setPhase('idle'); setMessage(''); setLogs('') | |
| setShowLogs(false); setFilename(''); setDragOver(false) | |
| // Also reset server-side job state | |
| fetch('/api/upload/reset', { method: 'POST' }).catch(() => {}) | |
| } | |
| }, [open]) | |
| function startPolling() { | |
| clearInterval(pollRef.current) | |
| pollRef.current = setInterval(async () => { | |
| try { | |
| const [sRes, lRes] = await Promise.all([ | |
| fetch('/api/upload/status'), | |
| fetch('/api/upload/logs'), | |
| ]) | |
| const s = await sRes.json() | |
| const l = await lRes.json() | |
| setLogs(l.logs || '') | |
| setMessage(s.message || '') | |
| if (s.status === 'done') { | |
| clearInterval(pollRef.current) | |
| setPhase('done') | |
| } else if (s.status === 'error') { | |
| clearInterval(pollRef.current) | |
| setPhase('error') | |
| setShowLogs(true) | |
| } | |
| } catch { | |
| // network hiccup — keep polling | |
| } | |
| }, STATUS_POLL_MS) | |
| } | |
| async function handleFile(file) { | |
| if (!file) return | |
| if (!file.name.match(/\.(xlsx|xlsm|xls)$/i)) { | |
| setPhase('error') | |
| setMessage('Only .xlsx / .xlsm / .xls files are accepted.') | |
| return | |
| } | |
| setFilename(file.name) | |
| setPhase('uploading') | |
| setMessage('Uploading file…') | |
| const form = new FormData() | |
| form.append('file', file) | |
| try { | |
| const res = await fetch('/api/upload', { method: 'POST', body: form }) | |
| const json = await res.json() | |
| if (!res.ok) { | |
| setPhase('error') | |
| setMessage(json.detail || 'Upload failed.') | |
| return | |
| } | |
| setPhase('running') | |
| setMessage('Pipeline started — processing data…') | |
| startPolling() | |
| } catch (err) { | |
| setPhase('error') | |
| setMessage('Cannot reach upload server. Is it running on port 8000?') | |
| } | |
| } | |
| const onDrop = useCallback(e => { | |
| e.preventDefault(); setDragOver(false) | |
| handleFile(e.dataTransfer.files[0]) | |
| }, []) | |
| const onDragOver = e => { e.preventDefault(); setDragOver(true) } | |
| const onDragLeave = () => setDragOver(false) | |
| function handleReload() { | |
| onClose() | |
| onSuccess(filename) // pass filename back to caller | |
| } | |
| if (!open) return null | |
| const isRunning = phase === 'uploading' || phase === 'running' | |
| return ( | |
| <div style={{ | |
| position: 'fixed', inset: 0, zIndex: 1000, | |
| background: 'rgba(0,0,0,.45)', | |
| display: 'flex', alignItems: 'center', justifyContent: 'center', | |
| padding: 20, | |
| }} onClick={e => { if (e.target === e.currentTarget && !isRunning) onClose() }}> | |
| <div style={{ | |
| background: '#fff', borderRadius: 14, width: '100%', maxWidth: 540, | |
| boxShadow: '0 8px 40px rgba(0,0,0,.18)', | |
| display: 'flex', flexDirection: 'column', maxHeight: '90vh', | |
| overflow: 'hidden', | |
| }}> | |
| {/* Header */} | |
| <div style={{ padding: '18px 22px 14px', borderBottom: '1px solid var(--bd)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> | |
| <div> | |
| <div style={{ fontWeight: 700, fontSize: 15, color: 'var(--gn)' }}>Upload New Data</div> | |
| <div style={{ fontSize: 10, color: 'var(--mt)', marginTop: 2 }}>Brand Onboarding Template (.xlsx)</div> | |
| </div> | |
| {!isRunning && ( | |
| <button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 18, color: 'var(--mt)', lineHeight: 1 }}>✕</button> | |
| )} | |
| </div> | |
| <div style={{ padding: '18px 22px', overflowY: 'auto', flex: 1 }}> | |
| {/* Drop zone — only shown when idle */} | |
| {phase === 'idle' && ( | |
| <div | |
| onDrop={onDrop} onDragOver={onDragOver} onDragLeave={onDragLeave} | |
| onClick={() => fileRef.current?.click()} | |
| style={{ | |
| border: `2px dashed ${dragOver ? 'var(--gn)' : 'var(--bd)'}`, | |
| borderRadius: 10, | |
| background: dragOver ? 'rgba(26,107,47,.04)' : 'var(--sf)', | |
| padding: '36px 20px', | |
| textAlign: 'center', | |
| cursor: 'pointer', | |
| transition: 'border-color .15s, background .15s', | |
| }}> | |
| <div style={{ fontSize: 32, marginBottom: 10 }}>📂</div> | |
| <div style={{ fontWeight: 600, fontSize: 13, color: 'var(--tx)' }}> | |
| Drag & drop your Excel file here | |
| </div> | |
| <div style={{ fontSize: 11, color: 'var(--mt)', marginTop: 4 }}> | |
| or click to browse — accepts .xlsx / .xlsm / .xls | |
| </div> | |
| <div style={{ marginTop: 14, fontSize: 10, color: 'var(--mt)', fontFamily: 'var(--mono)' }}> | |
| Required sheets: 1_Source_Data · 2_Brand_Config · 3_PPA_MT · 4_PPA_TT | |
| </div> | |
| <input ref={fileRef} type="file" accept=".xlsx,.xlsm,.xls" | |
| style={{ display: 'none' }} | |
| onChange={e => handleFile(e.target.files[0])} /> | |
| </div> | |
| )} | |
| {/* Status area */} | |
| {phase !== 'idle' && ( | |
| <div style={{ marginBottom: 16 }}> | |
| {/* File badge */} | |
| {filename && ( | |
| <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, | |
| background: 'var(--sf)', border: '1px solid var(--bd)', borderRadius: 8, padding: '8px 12px' }}> | |
| <span style={{ fontSize: 18 }}>📄</span> | |
| <span style={{ fontFamily: 'var(--mono)', fontSize: 11, color: 'var(--tx)', fontWeight: 600 }}>{filename}</span> | |
| </div> | |
| )} | |
| {/* Status pill */} | |
| <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}> | |
| {isRunning && <Spinner />} | |
| {phase === 'done' && <span style={{ fontSize: 18 }}>✅</span>} | |
| {phase === 'error' && <span style={{ fontSize: 18 }}>❌</span>} | |
| <div> | |
| <div style={{ fontSize: 12, fontWeight: 600, color: phase === 'error' ? 'var(--rd)' : phase === 'done' ? 'var(--gn)' : 'var(--tx)' }}> | |
| {phase === 'uploading' && 'Uploading…'} | |
| {phase === 'running' && 'Pipeline running…'} | |
| {phase === 'done' && 'Complete!'} | |
| {phase === 'error' && 'Error'} | |
| </div> | |
| <div style={{ fontSize: 10, color: 'var(--mt)', marginTop: 1 }}>{message}</div> | |
| </div> | |
| </div> | |
| {/* Progress steps */} | |
| <Steps phase={phase} /> | |
| </div> | |
| )} | |
| {/* Logs toggle */} | |
| {(phase === 'running' || phase === 'done' || phase === 'error') && ( | |
| <div> | |
| <button onClick={() => setShowLogs(v => !v)} style={{ | |
| background: 'none', border: '1px solid var(--bd)', borderRadius: 6, | |
| cursor: 'pointer', fontSize: 10, color: 'var(--mt)', padding: '4px 10px', | |
| fontFamily: 'var(--mono)', marginBottom: showLogs ? 8 : 0, | |
| }}> | |
| {showLogs ? '▲ Hide' : '▼ Show'} pipeline logs | |
| </button> | |
| {showLogs && ( | |
| <pre ref={logsRef} style={{ | |
| background: '#1a1a1a', color: '#d4e6c3', borderRadius: 8, | |
| padding: '12px 14px', fontSize: 9.5, fontFamily: 'var(--mono)', | |
| maxHeight: 220, overflowY: 'auto', lineHeight: 1.5, | |
| whiteSpace: 'pre-wrap', wordBreak: 'break-all', | |
| }}> | |
| {logs || '(waiting for output…)'} | |
| </pre> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| {/* Footer buttons */} | |
| <div style={{ padding: '12px 22px', borderTop: '1px solid var(--bd)', display: 'flex', gap: 10, justifyContent: 'flex-end' }}> | |
| {phase === 'idle' && ( | |
| <button onClick={onClose} style={btnStyle('secondary')}>Cancel</button> | |
| )} | |
| {phase === 'error' && ( | |
| <> | |
| <button onClick={() => { setPhase('idle'); setMessage(''); setLogs('') }} style={btnStyle('secondary')}>Try Again</button> | |
| <button onClick={onClose} style={btnStyle('secondary')}>Close</button> | |
| </> | |
| )} | |
| {phase === 'done' && ( | |
| <button onClick={handleReload} style={btnStyle('primary')}> | |
| Reload Dashboard | |
| </button> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| ) | |
| } | |
| function Steps({ phase }) { | |
| const steps = [ | |
| { key: 'upload', label: 'File uploaded' }, | |
| { key: 'pipeline', label: 'Pipeline running' }, | |
| { key: 'copy', label: 'JSON files updated' }, | |
| ] | |
| const activeIdx = phase === 'uploading' ? 0 : phase === 'running' ? 1 : phase === 'done' ? 3 : -1 | |
| return ( | |
| <div style={{ display: 'flex', gap: 0, marginBottom: 12 }}> | |
| {steps.map((s, i) => { | |
| const done = phase === 'done' || (phase === 'running' && i === 0) | |
| const active = (phase === 'uploading' && i === 0) || (phase === 'running' && i === 1) | |
| const color = done ? 'var(--gn)' : active ? 'var(--saf)' : 'var(--bd2)' | |
| return ( | |
| <div key={s.key} style={{ display: 'flex', alignItems: 'center', gap: 0, flex: 1 }}> | |
| <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', minWidth: 0 }}> | |
| <div style={{ width: 20, height: 20, borderRadius: '50%', background: color, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 10, color: '#fff', fontWeight: 700, flexShrink: 0 }}> | |
| {done ? '✓' : i + 1} | |
| </div> | |
| <div style={{ fontSize: 9, color: done ? 'var(--gn)' : 'var(--mt)', marginTop: 3, textAlign: 'center', whiteSpace: 'nowrap' }}>{s.label}</div> | |
| </div> | |
| {i < steps.length - 1 && ( | |
| <div style={{ flex: 1, height: 2, background: done ? 'var(--gn)' : 'var(--bd)', margin: '0 4px', marginBottom: 14 }} /> | |
| )} | |
| </div> | |
| ) | |
| })} | |
| </div> | |
| ) | |
| } | |
| function Spinner() { | |
| return ( | |
| <div style={{ | |
| width: 18, height: 18, borderRadius: '50%', flexShrink: 0, | |
| border: '2.5px solid var(--bd)', | |
| borderTopColor: 'var(--gn)', | |
| animation: 'spin .7s linear infinite', | |
| }} /> | |
| ) | |
| } | |
| function btnStyle(variant) { | |
| const base = { border: 'none', borderRadius: 8, cursor: 'pointer', fontWeight: 600, fontSize: 12, padding: '8px 18px' } | |
| if (variant === 'primary') return { ...base, background: 'var(--gn)', color: '#fff' } | |
| return { ...base, background: 'var(--sf3)', color: 'var(--mt)', border: '1px solid var(--bd)' } | |
| } | |