Blame | Last modification | View Log | RSS feed
## Meta lead-ads import — upload, preview, choose distribution, commit.## Model: recentImports (List<MetaLeadImport>)## The preview step exists because roughly half a typical export cannot be resolved to a sales## territory; the user needs to see that before writing, not after.<style>.meta-import { --navy:#1f2a5e; --coral:#f0483e; --ink:#26324d; --muted:#7683a4; --line:#e5e9f2; --bg:#f8fafd; }.meta-import { color:var(--ink); }/* ---- page header ---- */.meta-import .mi-head { margin-bottom:18px; }.meta-import .mi-head h2 { margin:0; font-size:22px; color:var(--navy); font-weight:700; }.meta-import .mi-head p { margin:5px 0 0; font-size:13px; color:#59668a; max-width:70ch; }/* ---- cards: one per step, never nested ---- */.meta-import .mi-card { border:1px solid var(--line); border-radius:12px; padding:18px 20px; margin-bottom:16px; background:#fff; }.meta-import .mi-card > h4 { margin:0 0 14px; font-size:12px; color:var(--muted); font-weight:700;text-transform:uppercase; letter-spacing:.6px; display:flex; align-items:baseline; gap:8px; }.meta-import .mi-card > h4 .mi-step { display:inline-flex; align-items:center; justify-content:center;width:20px; height:20px; border-radius:50%; background:var(--navy); color:#fff; font-size:11px; flex:none; }.meta-import .mi-note { font-size:12px; color:var(--muted); margin-top:10px; line-height:1.5; }.meta-import code { background:#f1f4fa; color:#8d2119; padding:1px 5px; border-radius:4px; font-size:11.5px; }/* ---- step 1: file row. Flex so the button never wraps under the input. ---- */.meta-import .mi-filerow { display:flex; align-items:center; gap:10px; flex-wrap:wrap; }.meta-import .mi-hidden-file { position:absolute; width:1px; height:1px; opacity:0; pointer-events:none; }.meta-import .mi-choose { display:inline-flex; align-items:center; gap:7px; height:38px; padding:0 15px;border:1px solid #c9d3e6; border-radius:8px; background:#fff; color:var(--navy); font-size:13px;font-weight:600; cursor:pointer; margin:0; white-space:nowrap; }.meta-import .mi-choose:hover { background:var(--bg); border-color:var(--navy); }/* Takes the leftover space so the filename truncates instead of pushing the button to a new line. */.meta-import .mi-filename { flex:1 1 200px; min-width:0; font-size:13px; color:var(--muted);white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }.meta-import .mi-filename.has-file { color:var(--ink); font-weight:600; }.meta-import .mi-btn { display:inline-flex; align-items:center; gap:7px; height:38px; padding:0 18px;border:1px solid transparent; border-radius:8px; font-size:13px; font-weight:600; cursor:pointer;white-space:nowrap; flex:none; }.meta-import .mi-btn[disabled] { opacity:.5; cursor:not-allowed; }.meta-import .mi-btn-navy { background:var(--navy); color:#fff; }.meta-import .mi-btn-coral { background:var(--coral); color:#fff; }.meta-import .mi-btn-ghost { background:#fff; color:var(--navy); border-color:#c9d3e6; }/* ---- tallies ---- */.meta-import .mi-counts { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:10px; margin-bottom:14px; }.meta-import .mi-count { border:1px solid var(--line); border-radius:10px; padding:11px 13px; background:var(--bg); }.meta-import .mi-count .n { font-size:22px; font-weight:800; line-height:1.15; }.meta-import .mi-count .l { font-size:10.5px; color:var(--muted); text-transform:uppercase; letter-spacing:.4px; margin-top:2px; }.meta-import .mi-count.created .n { color:#1a8f4c; }.meta-import .mi-count.duplicate .n { color:#a9720a; }.meta-import .mi-count.blocked .n, .meta-import .mi-count.rejected .n { color:var(--coral); }/* ---- outcome pills ---- */.meta-import .mi-pill { display:inline-block; padding:2px 9px; border-radius:20px; font-size:10.5px; font-weight:700; white-space:nowrap; letter-spacing:.3px; }.meta-import .mi-CREATED { background:#e5f6ec; color:#1a8f4c; }.meta-import .mi-DUPLICATE { background:#fff3dc; color:#a9720a; }.meta-import .mi-BLOCKED_DND { background:#fde7e5; color:var(--coral); }.meta-import .mi-REJECTED { background:#fde7e5; color:var(--coral); }.meta-import .mi-hold { background:#eef1f6; color:var(--muted); font-size:10.5px; padding:2px 8px; border-radius:20px; white-space:nowrap; }/* ---- tables ---- */.meta-import table.mi-rows { width:100%; font-size:12.5px; border-collapse:collapse; }.meta-import table.mi-rows th { text-align:left; font-size:10.5px; color:var(--muted); text-transform:uppercase;letter-spacing:.4px; padding:8px; border-bottom:1px solid var(--line); background:var(--bg);position:sticky; top:0; z-index:1; }.meta-import table.mi-rows td { padding:8px; border-bottom:1px solid var(--line); vertical-align:top; }.meta-import table.mi-rows tbody tr:hover { background:#fbfcfe; }.meta-import table.mi-rows td.num { text-align:right; font-variant-numeric:tabular-nums; }.meta-import .mi-scroll { max-height:440px; overflow:auto; border:1px solid var(--line); border-radius:8px; }/* ---- step 3: distribution ---- */.meta-import .mi-fields { display:flex; gap:14px; flex-wrap:wrap; align-items:flex-end; }.meta-import .mi-field { display:flex; flex-direction:column; gap:5px; }.meta-import .mi-field label { font-size:11px; color:var(--muted); text-transform:uppercase; letter-spacing:.4px; font-weight:600; }.meta-import .mi-select { height:38px; padding:0 10px; border:1px solid #c9d3e6; border-radius:8px;background:#fff; font-size:13px; color:var(--ink); min-width:270px; }.meta-import .mi-dist-grid { display:grid; grid-template-columns:auto minmax(220px,1fr); gap:20px; align-items:center; }.meta-import .mi-explain { font-size:12.5px; color:var(--muted); line-height:1.55; padding-left:20px;border-left:2px solid var(--line); min-height:38px; display:flex; align-items:center; }.meta-import .mi-select-narrow { min-width:220px; }@media (max-width:900px) {.meta-import .mi-dist-grid { grid-template-columns:1fr; }.meta-import .mi-explain { padding-left:0; border-left:0; }}/* ---- footer bar ---- */.meta-import .mi-actions { display:flex; align-items:center; justify-content:space-between; gap:14px;flex-wrap:wrap; margin-top:16px; padding-top:14px; border-top:1px solid var(--line); }.meta-import .mi-actions .mi-note { margin-top:0; }/* Belt and braces: pins the button right even if justify-content is lost. */.meta-import .mi-actions .mi-btn { margin-left:auto; }.meta-import .mi-err { background:#fde7e5; border:1px solid #f5c6c2; color:#8d2119; border-radius:8px;padding:11px 13px; font-size:13px; margin-top:12px; }.meta-import .mi-warn { color:#8d2119; font-weight:600; }</style><section class="wrapper meta-import"><ol class="breadcrumb"><li><i class="fa fa-home"></i><a href="${rc.contextPath}/dashboard">Home</a></li><li><a href="#" class="lead">Leads</a></li><li>Import Meta Leads</li></ol><div class="mi-head"><h2>Import Meta Leads</h2><p>Upload a lead-ads export from Meta. You will see exactly what happens to every row, andchoose who works the new leads, before anything is saved.</p></div>## ---- Step 1 : file ----<div class="mi-card"><h4><span class="mi-step">1</span> Choose the export</h4><div class="mi-filerow"><label class="mi-choose" for="miFile"><i class="fa fa-folder-open"></i> Choose file</label><input type="file" id="miFile" class="mi-hidden-file" accept=".xls,.xlsx,.csv"><span class="mi-filename" id="miFileName">No file chosen</span><button type="button" class="mi-btn mi-btn-navy" id="miPreviewBtn" disabled><i class="fa fa-search"></i> Preview</button></div><div class="mi-note">Meta's export is named <code>.xls</code> but is really XML — that is handled.<code>.xlsx</code> and <code>.csv</code> exports work too.</div><div id="miError" class="mi-err" style="display:none;"></div></div>## ---- Steps 2 and 3 appear together once a file has been previewed ----<div id="miResults" style="display:none;"><div class="mi-card"><h4><span class="mi-step">2</span> <span id="miResultTitle">What will happen</span></h4><div class="mi-counts"><div class="mi-count"><div class="n" id="miTotal">0</div><div class="l">Rows in file</div></div><div class="mi-count created"><div class="n" id="miCreated">0</div><div class="l">New leads</div></div><div class="mi-count duplicate"><div class="n" id="miDuplicate">0</div><div class="l">Already known</div></div><div class="mi-count blocked"><div class="n" id="miBlocked">0</div><div class="l">Do not call</div></div><div class="mi-count rejected"><div class="n" id="miRejected">0</div><div class="l">Unusable</div></div></div><div class="mi-note mi-warn" id="miBalanceWarn" style="display:none;"></div><div class="mi-note" id="miHoldNote" style="margin-top:0;margin-bottom:12px;"></div><div class="mi-scroll"><table class="mi-rows"><thead><tr><th>Row</th><th>Name</th><th>Mobile</th><th>Shop</th><th>City</th><th>State</th><th>Region</th><th>Value</th><th>Outcome</th></tr></thead><tbody id="miRows"></tbody></table></div></div><div class="mi-card" id="miDistCard"><h4><span class="mi-step">3</span> Who should work these leads?</h4><div class="mi-dist-grid"><div class="mi-fields"><div class="mi-field"><label for="miDistribution">Distribution</label><select class="mi-select" id="miDistribution"><option value="ROUND_ROBIN">Share evenly between all Call Center agents</option><option value="BALANCED">Give more to whoever has the fewest open leads</option><option value="RANDOM">Random agent per lead</option><option value="SINGLE">All to one agent…</option><option value="NONE">Leave unassigned for now</option></select></div><div class="mi-field" id="miAgentWrap" style="display:none;"><label for="miAgent">Agent</label><select class="mi-select mi-select-narrow" id="miAgent"><option value="">Loading agents…</option></select></div></div>## The explanation sits beside the control rather than under it, so the right half of## the row is used and the effect of the choice is read at the same time as the choice.<div class="mi-explain" id="miDistNote"></div></div><div class="mi-actions" id="miCommitBar"><span class="mi-note" id="miCommitHint"></span><button type="button" class="mi-btn mi-btn-coral" id="miCommitBtn"><i class="fa fa-upload"></i> Import these leads</button></div></div><div class="mi-card" id="miSplitCard" style="display:none;"><h4>Who got what</h4><div id="miSplit"></div></div></div>#if($recentImports && !$recentImports.isEmpty())<div class="mi-card"><h4>Recent imports</h4><table class="mi-rows"><thead><tr><th>File</th><th>When</th><th class="num">Rows</th><th class="num">Created</th><th class="num">Duplicate</th><th class="num">DND</th><th class="num">Rejected</th></tr></thead><tbody>#foreach($imp in $recentImports)<tr><td>$!imp.getFileName()</td><td>#if($imp.getCreatedTimestamp())$imp.getCreatedTimestamp()#end</td><td class="num">$imp.getTotalRows()</td><td class="num">$imp.getCreatedCount()</td><td class="num">$imp.getDuplicateCount()</td><td class="num">$imp.getBlockedCount()</td><td class="num">$imp.getRejectedCount()</td></tr>#end</tbody></table></div>#end</section><script type="text/javascript">(function () {// Raw fetch rather than the common.js helpers: those attach a global loader modal and an// alert-on-error to every request, which is the wrong shape for a form that reports its own// per-row errors inline.var previewed = false;var lastCreated = 0;function el(id) { return document.getElementById(id); }function esc(s) {if (s === null || s === undefined) { return ''; }return ('' + s).replace(/[&<>"]/g, function (c) {return { '&': '&', '<': '<', '>': '>', '"': '"' }[c];});}function showError(msg) {el('miError').textContent = msg;el('miError').style.display = 'block';el('miResults').style.display = 'none';}function clearError() { el('miError').style.display = 'none'; }function post(url, file, extra) {var fd = new FormData();fd.append('file', file);if (extra) { Object.keys(extra).forEach(function (k) { if (extra[k] !== null && extra[k] !== '') { fd.append(k, extra[k]); } }); }return fetch(context + url, { method: 'POST', body: fd, credentials: 'same-origin',headers: { 'X-Requested-With': 'XMLHttpRequest' } }).then(function (r) {return r.json().then(function (j) {if (!r.ok) { throw new Error(j && j.error ? j.error : ('Request failed: ' + r.status)); }return j;});});}function render(data, committed) {clearError();el('miResults').style.display = 'block';el('miResultTitle').textContent = committed ? 'Imported' : 'What will happen';el('miTotal').textContent = data.total;el('miCreated').textContent = data.created;el('miDuplicate').textContent = data.duplicate;el('miBlocked').textContent = data.blocked;el('miRejected').textContent = data.rejected;// If the file stops accounting for itself, the person importing it is the one who needs// to know — not just the log.if (data.balanced === false) {el('miBalanceWarn').style.display = 'block';el('miBalanceWarn').textContent ='Warning: the outcomes do not add up to the number of rows. Please report this before relying on the import.';} else {el('miBalanceWarn').style.display = 'none';}var held = 0;var html = '';(data.rows || []).forEach(function (r) {if (r.outcome === 'CREATED' && !r.region) { held++; }html += '<tr>'+ '<td>' + esc(r.rowNumber) + '</td>'+ '<td>' + esc(r.name) + '</td>'+ '<td>' + esc(r.mobile) + '</td>'+ '<td>' + esc(r.outlet) + '</td>'+ '<td>' + esc(r.city) + '</td>'+ '<td>' + esc(r.state) + '</td>'+ '<td>' + (r.region ? esc(r.region): '<span class="mi-hold">hold — assign manually</span>') + '</td>'+ '<td>' + (r.band ? esc(r.band) : '—') + '</td>'+ '<td><span class="mi-pill mi-' + esc(r.outcome) + '">' + esc(r.outcome) + '</span>'+ (r.reason ? '<div class="mi-note" style="margin-top:2px;">' + esc(r.reason) + '</div>' : '')+ '</td>'+ '</tr>';});el('miRows').innerHTML = html;el('miHoldNote').innerHTML = held > 0? held + ' of the new leads have no sales region — their state maps to more than one '+ 'territory, or to none. They import into the <b>HOLD queue</b> for manual assignment.': '';if (committed) {el('miDistCard').style.display = 'none';el('miCommitBar').style.display = 'none';el('miCommitHint').textContent = '';} else {el('miDistCard').style.display = 'block';// Cleared rather than set to a value: an inline display would override the stylesheet's// flex layout and left-pack the hint and button.el('miCommitBar').style.display = '';el('miCommitBtn').disabled = data.created === 0;el('miCommitHint').textContent = data.created === 0? 'Nothing new to import from this file.': 'Nothing has been saved yet.';}if (data.truncated) {el('miHoldNote').innerHTML += ' <em>(showing the first ' + (data.rows || []).length + ' rows)</em>';}}var agentsLoaded = false;function loadAgents() {if (agentsLoaded) { return Promise.resolve(); }return fetch(context + '/lms/meta-import/agents', { credentials: 'same-origin',headers: { 'X-Requested-With': 'XMLHttpRequest' } }).then(function (r) { return r.json(); }).then(function (j) {var sel = el('miAgent');var list = (j && j.agents) || [];if (!list.length) {sel.innerHTML = '<option value="">No active Call Center agents</option>';} else {sel.innerHTML = list.map(function (a) {return '<option value="' + a.authId + '">' + esc(a.name || a.email) + '</option>';}).join('');}agentsLoaded = true;}).catch(function () { el('miAgent').innerHTML = '<option value="">Could not load agents</option>'; });}function describeDistribution() {var v = el('miDistribution').value;var n = lastCreated;var notes = {ROUND_ROBIN: 'Each agent gets a turn, so the split is even and predictable.',BALANCED: 'Counts every agent\u2019s current open leads first, then tops up whoever is carrying least.',RANDOM: 'Matches how single leads are assigned elsewhere; over a batch the split will be uneven.',SINGLE: 'Everything in this file goes to one agent.',NONE: 'Imported with no owner \u2014 they will sit in the HOLD queue until someone hands them out.'};el('miDistNote').textContent = (notes[v] || '') + (n ? ' ' + n + ' lead(s) to assign.' : '');// Cleared, not set to 'block' — .mi-field is a flex column and an inline display would// drop its label/select gap.el('miAgentWrap').style.display = (v === 'SINGLE') ? '' : 'none';if (v === 'SINGLE') { loadAgents(); }}el('miDistribution').addEventListener('change', describeDistribution);function renderSplit(perAgent) {if (!perAgent || !perAgent.length) { el('miSplitCard').style.display = 'none'; return; }el('miSplitCard').style.display = 'block';el('miSplit').innerHTML = '<table class="mi-rows"><thead><tr><th>Agent</th><th>Leads</th></tr></thead><tbody>'+ perAgent.map(function (a) {return '<tr><td>' + esc(a.name) + '</td><td>' + esc(a.count) + '</td></tr>';}).join('') + '</tbody></table>';}el('miPreviewBtn').addEventListener('click', function () {var file = el('miFile').files[0];if (!file) { showError('Choose a file first.'); return; }clearError();el('miPreviewBtn').disabled = true;post('/lms/meta-import/preview', file).then(function (data) { previewed = true; lastCreated = data.created; render(data, false); describeDistribution(); }).catch(function (e) { previewed = false; showError(e.message); }).then(function () { el('miPreviewBtn').disabled = false; });});el('miCommitBtn').addEventListener('click', function () {var file = el('miFile').files[0];if (!file) { showError('Choose a file first.'); return; }if (!previewed) { showError('Preview the file before importing.'); return; }if (!confirm('Import these leads? This cannot be undone.')) { return; }el('miCommitBtn').disabled = true;// The file is posted again rather than the server holding state between the two steps, and// the commit re-evaluates from scratch — so a lead someone else created since the preview// is caught rather than duplicated.var dist = el('miDistribution').value;var agentId = (dist === 'SINGLE') ? el('miAgent').value : null;if (dist === 'SINGLE' && !agentId) { showError('Choose the agent these leads should go to.'); el('miCommitBtn').disabled = false; return; }post('/lms/meta-import/commit', file, { distribution: dist, agentId: agentId }).then(function (data) { render(data, true); renderSplit(data.perAgent); }).catch(function (e) { showError(e.message); el('miCommitBtn').disabled = false; });});// Native file inputs cannot be aligned reliably across browsers, so the real input is hidden// behind a styled label and the chosen name is rendered here instead.el('miFile').addEventListener('change', function () {var file = this.files[0];var label = el('miFileName');label.textContent = file ? file.name : 'No file chosen';label.classList.toggle('has-file', !!file);label.title = file ? file.name : '';el('miPreviewBtn').disabled = !file;// A different file invalidates the preview.previewed = false;el('miResults').style.display = 'none';el('miSplitCard').style.display = 'none';clearError();});})();</script>