Bera
initial deploy
14356bb
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)' }
}