Subversion Repositories SmartDukaan

Rev

Details | Last modification | View Log | RSS feed

Rev Author Line No. Line
37651 vikas 1
##  Meta lead-ads import — upload, preview, choose distribution, commit.
2
##  Model: recentImports (List<MetaLeadImport>)
3
##  The preview step exists because roughly half a typical export cannot be resolved to a sales
4
##  territory; the user needs to see that before writing, not after.
5
 
6
<style>
7
    .meta-import { --navy:#1f2a5e; --coral:#f0483e; --ink:#26324d; --muted:#7683a4; --line:#e5e9f2; --bg:#f8fafd; }
8
    .meta-import { color:var(--ink); }
9
 
10
    /* ---- page header ---- */
11
    .meta-import .mi-head { margin-bottom:18px; }
12
    .meta-import .mi-head h2 { margin:0; font-size:22px; color:var(--navy); font-weight:700; }
13
    .meta-import .mi-head p { margin:5px 0 0; font-size:13px; color:#59668a; max-width:70ch; }
14
 
15
    /* ---- cards: one per step, never nested ---- */
16
    .meta-import .mi-card { border:1px solid var(--line); border-radius:12px; padding:18px 20px; margin-bottom:16px; background:#fff; }
17
    .meta-import .mi-card > h4 { margin:0 0 14px; font-size:12px; color:var(--muted); font-weight:700;
18
        text-transform:uppercase; letter-spacing:.6px; display:flex; align-items:baseline; gap:8px; }
19
    .meta-import .mi-card > h4 .mi-step { display:inline-flex; align-items:center; justify-content:center;
20
        width:20px; height:20px; border-radius:50%; background:var(--navy); color:#fff; font-size:11px; flex:none; }
21
    .meta-import .mi-note { font-size:12px; color:var(--muted); margin-top:10px; line-height:1.5; }
22
    .meta-import code { background:#f1f4fa; color:#8d2119; padding:1px 5px; border-radius:4px; font-size:11.5px; }
23
 
24
    /* ---- step 1: file row. Flex so the button never wraps under the input. ---- */
25
    .meta-import .mi-filerow { display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
26
    .meta-import .mi-hidden-file { position:absolute; width:1px; height:1px; opacity:0; pointer-events:none; }
27
    .meta-import .mi-choose { display:inline-flex; align-items:center; gap:7px; height:38px; padding:0 15px;
28
        border:1px solid #c9d3e6; border-radius:8px; background:#fff; color:var(--navy); font-size:13px;
29
        font-weight:600; cursor:pointer; margin:0; white-space:nowrap; }
30
    .meta-import .mi-choose:hover { background:var(--bg); border-color:var(--navy); }
31
    /* Takes the leftover space so the filename truncates instead of pushing the button to a new line. */
32
    .meta-import .mi-filename { flex:1 1 200px; min-width:0; font-size:13px; color:var(--muted);
33
        white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
34
    .meta-import .mi-filename.has-file { color:var(--ink); font-weight:600; }
35
    .meta-import .mi-btn { display:inline-flex; align-items:center; gap:7px; height:38px; padding:0 18px;
36
        border:1px solid transparent; border-radius:8px; font-size:13px; font-weight:600; cursor:pointer;
37
        white-space:nowrap; flex:none; }
38
    .meta-import .mi-btn[disabled] { opacity:.5; cursor:not-allowed; }
39
    .meta-import .mi-btn-navy  { background:var(--navy); color:#fff; }
40
    .meta-import .mi-btn-coral { background:var(--coral); color:#fff; }
41
    .meta-import .mi-btn-ghost { background:#fff; color:var(--navy); border-color:#c9d3e6; }
42
 
43
    /* ---- tallies ---- */
44
    .meta-import .mi-counts { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:10px; margin-bottom:14px; }
45
    .meta-import .mi-count { border:1px solid var(--line); border-radius:10px; padding:11px 13px; background:var(--bg); }
46
    .meta-import .mi-count .n { font-size:22px; font-weight:800; line-height:1.15; }
47
    .meta-import .mi-count .l { font-size:10.5px; color:var(--muted); text-transform:uppercase; letter-spacing:.4px; margin-top:2px; }
48
    .meta-import .mi-count.created .n { color:#1a8f4c; }
49
    .meta-import .mi-count.duplicate .n { color:#a9720a; }
50
    .meta-import .mi-count.blocked .n, .meta-import .mi-count.rejected .n { color:var(--coral); }
51
 
52
    /* ---- outcome pills ---- */
53
    .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; }
54
    .meta-import .mi-CREATED     { background:#e5f6ec; color:#1a8f4c; }
55
    .meta-import .mi-DUPLICATE   { background:#fff3dc; color:#a9720a; }
56
    .meta-import .mi-BLOCKED_DND { background:#fde7e5; color:var(--coral); }
57
    .meta-import .mi-REJECTED    { background:#fde7e5; color:var(--coral); }
58
    .meta-import .mi-hold { background:#eef1f6; color:var(--muted); font-size:10.5px; padding:2px 8px; border-radius:20px; white-space:nowrap; }
59
 
60
    /* ---- tables ---- */
61
    .meta-import table.mi-rows { width:100%; font-size:12.5px; border-collapse:collapse; }
62
    .meta-import table.mi-rows th { text-align:left; font-size:10.5px; color:var(--muted); text-transform:uppercase;
63
        letter-spacing:.4px; padding:8px; border-bottom:1px solid var(--line); background:var(--bg);
64
        position:sticky; top:0; z-index:1; }
65
    .meta-import table.mi-rows td { padding:8px; border-bottom:1px solid var(--line); vertical-align:top; }
66
    .meta-import table.mi-rows tbody tr:hover { background:#fbfcfe; }
67
    .meta-import table.mi-rows td.num { text-align:right; font-variant-numeric:tabular-nums; }
68
    .meta-import .mi-scroll { max-height:440px; overflow:auto; border:1px solid var(--line); border-radius:8px; }
69
 
70
    /* ---- step 3: distribution ---- */
71
    .meta-import .mi-fields { display:flex; gap:14px; flex-wrap:wrap; align-items:flex-end; }
72
    .meta-import .mi-field { display:flex; flex-direction:column; gap:5px; }
73
    .meta-import .mi-field label { font-size:11px; color:var(--muted); text-transform:uppercase; letter-spacing:.4px; font-weight:600; }
74
    .meta-import .mi-select { height:38px; padding:0 10px; border:1px solid #c9d3e6; border-radius:8px;
75
        background:#fff; font-size:13px; color:var(--ink); min-width:270px; }
76
 
77
    .meta-import .mi-dist-grid { display:grid; grid-template-columns:auto minmax(220px,1fr); gap:20px; align-items:center; }
78
    .meta-import .mi-explain { font-size:12.5px; color:var(--muted); line-height:1.55; padding-left:20px;
79
        border-left:2px solid var(--line); min-height:38px; display:flex; align-items:center; }
80
    .meta-import .mi-select-narrow { min-width:220px; }
81
    @media (max-width:900px) {
82
        .meta-import .mi-dist-grid { grid-template-columns:1fr; }
83
        .meta-import .mi-explain { padding-left:0; border-left:0; }
84
    }
85
 
86
    /* ---- footer bar ---- */
87
    .meta-import .mi-actions { display:flex; align-items:center; justify-content:space-between; gap:14px;
88
        flex-wrap:wrap; margin-top:16px; padding-top:14px; border-top:1px solid var(--line); }
89
    .meta-import .mi-actions .mi-note { margin-top:0; }
90
    /* Belt and braces: pins the button right even if justify-content is lost. */
91
    .meta-import .mi-actions .mi-btn { margin-left:auto; }
92
 
93
    .meta-import .mi-err { background:#fde7e5; border:1px solid #f5c6c2; color:#8d2119; border-radius:8px;
94
        padding:11px 13px; font-size:13px; margin-top:12px; }
95
    .meta-import .mi-warn { color:#8d2119; font-weight:600; }
96
</style>
97
 
98
<section class="wrapper meta-import">
99
 
100
    <ol class="breadcrumb">
101
        <li><i class="fa fa-home"></i><a href="${rc.contextPath}/dashboard">Home</a></li>
102
        <li><a href="#" class="lead">Leads</a></li>
103
        <li>Import Meta Leads</li>
104
    </ol>
105
 
106
    <div class="mi-head">
107
        <h2>Import Meta Leads</h2>
108
        <p>Upload a lead-ads export from Meta. You will see exactly what happens to every row, and
109
           choose who works the new leads, before anything is saved.</p>
110
    </div>
111
 
112
    ## ---- Step 1 : file ----
113
    <div class="mi-card">
114
        <h4><span class="mi-step">1</span> Choose the export</h4>
115
        <div class="mi-filerow">
116
            <label class="mi-choose" for="miFile"><i class="fa fa-folder-open"></i> Choose file</label>
117
            <input type="file" id="miFile" class="mi-hidden-file" accept=".xls,.xlsx,.csv">
118
            <span class="mi-filename" id="miFileName">No file chosen</span>
119
            <button type="button" class="mi-btn mi-btn-navy" id="miPreviewBtn" disabled>
120
                <i class="fa fa-search"></i> Preview
121
            </button>
122
        </div>
123
        <div class="mi-note">
124
            Meta's export is named <code>.xls</code> but is really XML — that is handled.
125
            <code>.xlsx</code> and <code>.csv</code> exports work too.
126
        </div>
127
        <div id="miError" class="mi-err" style="display:none;"></div>
128
    </div>
129
 
130
    ## ---- Steps 2 and 3 appear together once a file has been previewed ----
131
    <div id="miResults" style="display:none;">
132
 
133
        <div class="mi-card">
134
            <h4><span class="mi-step">2</span> <span id="miResultTitle">What will happen</span></h4>
135
 
136
            <div class="mi-counts">
137
                <div class="mi-count"><div class="n" id="miTotal">0</div><div class="l">Rows in file</div></div>
138
                <div class="mi-count created"><div class="n" id="miCreated">0</div><div class="l">New leads</div></div>
139
                <div class="mi-count duplicate"><div class="n" id="miDuplicate">0</div><div class="l">Already known</div></div>
140
                <div class="mi-count blocked"><div class="n" id="miBlocked">0</div><div class="l">Do not call</div></div>
141
                <div class="mi-count rejected"><div class="n" id="miRejected">0</div><div class="l">Unusable</div></div>
142
            </div>
143
 
144
            <div class="mi-note mi-warn" id="miBalanceWarn" style="display:none;"></div>
145
            <div class="mi-note" id="miHoldNote" style="margin-top:0;margin-bottom:12px;"></div>
146
 
147
            <div class="mi-scroll">
148
                <table class="mi-rows">
149
                    <thead>
150
                    <tr>
151
                        <th>Row</th><th>Name</th><th>Mobile</th><th>Shop</th>
152
                        <th>City</th><th>State</th><th>Region</th><th>Value</th><th>Outcome</th>
153
                    </tr>
154
                    </thead>
155
                    <tbody id="miRows"></tbody>
156
                </table>
157
            </div>
158
        </div>
159
 
160
        <div class="mi-card" id="miDistCard">
161
            <h4><span class="mi-step">3</span> Who should work these leads?</h4>
162
            <div class="mi-dist-grid">
163
                <div class="mi-fields">
164
                    <div class="mi-field">
165
                        <label for="miDistribution">Distribution</label>
166
                        <select class="mi-select" id="miDistribution">
167
                            <option value="ROUND_ROBIN">Share evenly between all Call Center agents</option>
168
                            <option value="BALANCED">Give more to whoever has the fewest open leads</option>
169
                            <option value="RANDOM">Random agent per lead</option>
170
                            <option value="SINGLE">All to one agent&hellip;</option>
171
                            <option value="NONE">Leave unassigned for now</option>
172
                        </select>
173
                    </div>
174
                    <div class="mi-field" id="miAgentWrap" style="display:none;">
175
                        <label for="miAgent">Agent</label>
176
                        <select class="mi-select mi-select-narrow" id="miAgent">
177
                            <option value="">Loading agents&hellip;</option>
178
                        </select>
179
                    </div>
180
                </div>
181
                ## The explanation sits beside the control rather than under it, so the right half of
182
                ## the row is used and the effect of the choice is read at the same time as the choice.
183
                <div class="mi-explain" id="miDistNote"></div>
184
            </div>
185
 
186
            <div class="mi-actions" id="miCommitBar">
187
                <span class="mi-note" id="miCommitHint"></span>
188
                <button type="button" class="mi-btn mi-btn-coral" id="miCommitBtn">
189
                    <i class="fa fa-upload"></i> Import these leads
190
                </button>
191
            </div>
192
        </div>
193
 
194
        <div class="mi-card" id="miSplitCard" style="display:none;">
195
            <h4>Who got what</h4>
196
            <div id="miSplit"></div>
197
        </div>
198
 
199
    </div>
200
 
201
    #if($recentImports && !$recentImports.isEmpty())
202
    <div class="mi-card">
203
        <h4>Recent imports</h4>
204
        <table class="mi-rows">
205
            <thead>
206
            <tr>
207
                <th>File</th><th>When</th><th class="num">Rows</th><th class="num">Created</th>
208
                <th class="num">Duplicate</th><th class="num">DND</th><th class="num">Rejected</th>
209
            </tr>
210
            </thead>
211
            <tbody>
212
            #foreach($imp in $recentImports)
213
                <tr>
214
                    <td>$!imp.getFileName()</td>
215
                    <td>#if($imp.getCreatedTimestamp())$imp.getCreatedTimestamp()#end</td>
216
                    <td class="num">$imp.getTotalRows()</td>
217
                    <td class="num">$imp.getCreatedCount()</td>
218
                    <td class="num">$imp.getDuplicateCount()</td>
219
                    <td class="num">$imp.getBlockedCount()</td>
220
                    <td class="num">$imp.getRejectedCount()</td>
221
                </tr>
222
            #end
223
            </tbody>
224
        </table>
225
    </div>
226
    #end
227
 
228
</section>
229
 
230
<script type="text/javascript">
231
(function () {
232
    // Raw fetch rather than the common.js helpers: those attach a global loader modal and an
233
    // alert-on-error to every request, which is the wrong shape for a form that reports its own
234
    // per-row errors inline.
235
    var previewed = false;
236
    var lastCreated = 0;
237
 
238
    function el(id) { return document.getElementById(id); }
239
    function esc(s) {
240
        if (s === null || s === undefined) { return ''; }
241
        return ('' + s).replace(/[&<>"]/g, function (c) {
242
            return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c];
243
        });
244
    }
245
    function showError(msg) {
246
        el('miError').textContent = msg;
247
        el('miError').style.display = 'block';
248
        el('miResults').style.display = 'none';
249
    }
250
    function clearError() { el('miError').style.display = 'none'; }
251
 
252
    function post(url, file, extra) {
253
        var fd = new FormData();
254
        fd.append('file', file);
255
        if (extra) { Object.keys(extra).forEach(function (k) { if (extra[k] !== null && extra[k] !== '') { fd.append(k, extra[k]); } }); }
256
        return fetch(context + url, { method: 'POST', body: fd, credentials: 'same-origin',
257
                                      headers: { 'X-Requested-With': 'XMLHttpRequest' } })
258
            .then(function (r) {
259
                return r.json().then(function (j) {
260
                    if (!r.ok) { throw new Error(j && j.error ? j.error : ('Request failed: ' + r.status)); }
261
                    return j;
262
                });
263
            });
264
    }
265
 
266
    function render(data, committed) {
267
        clearError();
268
        el('miResults').style.display = 'block';
269
        el('miResultTitle').textContent = committed ? 'Imported' : 'What will happen';
270
        el('miTotal').textContent = data.total;
271
        el('miCreated').textContent = data.created;
272
        el('miDuplicate').textContent = data.duplicate;
273
        el('miBlocked').textContent = data.blocked;
274
        el('miRejected').textContent = data.rejected;
275
 
276
        // If the file stops accounting for itself, the person importing it is the one who needs
277
        // to know — not just the log.
278
        if (data.balanced === false) {
279
            el('miBalanceWarn').style.display = 'block';
280
            el('miBalanceWarn').textContent =
281
                'Warning: the outcomes do not add up to the number of rows. Please report this before relying on the import.';
282
        } else {
283
            el('miBalanceWarn').style.display = 'none';
284
        }
285
 
286
        var held = 0;
287
        var html = '';
288
        (data.rows || []).forEach(function (r) {
289
            if (r.outcome === 'CREATED' && !r.region) { held++; }
290
            html += '<tr>'
291
                + '<td>' + esc(r.rowNumber) + '</td>'
292
                + '<td>' + esc(r.name) + '</td>'
293
                + '<td>' + esc(r.mobile) + '</td>'
294
                + '<td>' + esc(r.outlet) + '</td>'
295
                + '<td>' + esc(r.city) + '</td>'
296
                + '<td>' + esc(r.state) + '</td>'
297
                + '<td>' + (r.region ? esc(r.region)
298
                                     : '<span class="mi-hold">hold &mdash; assign manually</span>') + '</td>'
299
                + '<td>' + (r.band ? esc(r.band) : '&mdash;') + '</td>'
300
                + '<td><span class="mi-pill mi-' + esc(r.outcome) + '">' + esc(r.outcome) + '</span>'
301
                + (r.reason ? '<div class="mi-note" style="margin-top:2px;">' + esc(r.reason) + '</div>' : '')
302
                + '</td>'
303
                + '</tr>';
304
        });
305
        el('miRows').innerHTML = html;
306
 
307
        el('miHoldNote').innerHTML = held > 0
308
            ? held + ' of the new leads have no sales region — their state maps to more than one '
309
              + 'territory, or to none. They import into the <b>HOLD queue</b> for manual assignment.'
310
            : '';
311
 
312
        if (committed) {
313
            el('miDistCard').style.display = 'none';
314
            el('miCommitBar').style.display = 'none';
315
            el('miCommitHint').textContent = '';
316
        } else {
317
            el('miDistCard').style.display = 'block';
318
            // Cleared rather than set to a value: an inline display would override the stylesheet's
319
            // flex layout and left-pack the hint and button.
320
            el('miCommitBar').style.display = '';
321
            el('miCommitBtn').disabled = data.created === 0;
322
            el('miCommitHint').textContent = data.created === 0
323
                ? 'Nothing new to import from this file.'
324
                : 'Nothing has been saved yet.';
325
        }
326
        if (data.truncated) {
327
            el('miHoldNote').innerHTML += ' <em>(showing the first ' + (data.rows || []).length + ' rows)</em>';
328
        }
329
    }
330
 
331
    var agentsLoaded = false;
332
    function loadAgents() {
333
        if (agentsLoaded) { return Promise.resolve(); }
334
        return fetch(context + '/lms/meta-import/agents', { credentials: 'same-origin',
335
                headers: { 'X-Requested-With': 'XMLHttpRequest' } })
336
            .then(function (r) { return r.json(); })
337
            .then(function (j) {
338
                var sel = el('miAgent');
339
                var list = (j && j.agents) || [];
340
                if (!list.length) {
341
                    sel.innerHTML = '<option value="">No active Call Center agents</option>';
342
                } else {
343
                    sel.innerHTML = list.map(function (a) {
344
                        return '<option value="' + a.authId + '">' + esc(a.name || a.email) + '</option>';
345
                    }).join('');
346
                }
347
                agentsLoaded = true;
348
            })
349
            .catch(function () { el('miAgent').innerHTML = '<option value="">Could not load agents</option>'; });
350
    }
351
 
352
    function describeDistribution() {
353
        var v = el('miDistribution').value;
354
        var n = lastCreated;
355
        var notes = {
356
            ROUND_ROBIN: 'Each agent gets a turn, so the split is even and predictable.',
357
            BALANCED: 'Counts every agent\u2019s current open leads first, then tops up whoever is carrying least.',
358
            RANDOM: 'Matches how single leads are assigned elsewhere; over a batch the split will be uneven.',
359
            SINGLE: 'Everything in this file goes to one agent.',
360
            NONE: 'Imported with no owner \u2014 they will sit in the HOLD queue until someone hands them out.'
361
        };
362
        el('miDistNote').textContent = (notes[v] || '') + (n ? ' ' + n + ' lead(s) to assign.' : '');
363
        // Cleared, not set to 'block' — .mi-field is a flex column and an inline display would
364
        // drop its label/select gap.
365
        el('miAgentWrap').style.display = (v === 'SINGLE') ? '' : 'none';
366
        if (v === 'SINGLE') { loadAgents(); }
367
    }
368
    el('miDistribution').addEventListener('change', describeDistribution);
369
 
370
    function renderSplit(perAgent) {
371
        if (!perAgent || !perAgent.length) { el('miSplitCard').style.display = 'none'; return; }
372
        el('miSplitCard').style.display = 'block';
373
        el('miSplit').innerHTML = '<table class="mi-rows"><thead><tr><th>Agent</th><th>Leads</th></tr></thead><tbody>'
374
            + perAgent.map(function (a) {
375
                return '<tr><td>' + esc(a.name) + '</td><td>' + esc(a.count) + '</td></tr>';
376
            }).join('') + '</tbody></table>';
377
    }
378
 
379
    el('miPreviewBtn').addEventListener('click', function () {
380
        var file = el('miFile').files[0];
381
        if (!file) { showError('Choose a file first.'); return; }
382
        clearError();
383
        el('miPreviewBtn').disabled = true;
384
        post('/lms/meta-import/preview', file)
385
            .then(function (data) { previewed = true; lastCreated = data.created; render(data, false); describeDistribution(); })
386
            .catch(function (e) { previewed = false; showError(e.message); })
387
            .then(function () { el('miPreviewBtn').disabled = false; });
388
    });
389
 
390
    el('miCommitBtn').addEventListener('click', function () {
391
        var file = el('miFile').files[0];
392
        if (!file) { showError('Choose a file first.'); return; }
393
        if (!previewed) { showError('Preview the file before importing.'); return; }
394
        if (!confirm('Import these leads? This cannot be undone.')) { return; }
395
        el('miCommitBtn').disabled = true;
396
        // The file is posted again rather than the server holding state between the two steps, and
397
        // the commit re-evaluates from scratch — so a lead someone else created since the preview
398
        // is caught rather than duplicated.
399
        var dist = el('miDistribution').value;
400
        var agentId = (dist === 'SINGLE') ? el('miAgent').value : null;
401
        if (dist === 'SINGLE' && !agentId) { showError('Choose the agent these leads should go to.'); el('miCommitBtn').disabled = false; return; }
402
        post('/lms/meta-import/commit', file, { distribution: dist, agentId: agentId })
403
            .then(function (data) { render(data, true); renderSplit(data.perAgent); })
404
            .catch(function (e) { showError(e.message); el('miCommitBtn').disabled = false; });
405
    });
406
 
407
    // Native file inputs cannot be aligned reliably across browsers, so the real input is hidden
408
    // behind a styled label and the chosen name is rendered here instead.
409
    el('miFile').addEventListener('change', function () {
410
        var file = this.files[0];
411
        var label = el('miFileName');
412
        label.textContent = file ? file.name : 'No file chosen';
413
        label.classList.toggle('has-file', !!file);
414
        label.title = file ? file.name : '';
415
        el('miPreviewBtn').disabled = !file;
416
        // A different file invalidates the preview.
417
        previewed = false;
418
        el('miResults').style.display = 'none';
419
        el('miSplitCard').style.display = 'none';
420
        clearError();
421
    });
422
})();
423
</script>