Subversion Repositories SmartDukaan

Rev

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, and
           choose 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&hellip;</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&hellip;</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 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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 &mdash; assign manually</span>') + '</td>'
                + '<td>' + (r.band ? esc(r.band) : '&mdash;') + '</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>