Subversion Repositories SmartDukaan

Rev

Rev 37331 | Rev 37337 | Go to most recent revision | Details | Compare with Previous | Last modification | View Log | RSS feed

Rev Author Line No. Line
37331 amit 1
$(function () {
2
 
3
    // The screen is an AJAX fragment injected into #main-content, so every handler
4
    // is delegated from document and state is re-initialised on each load.
5
    var state = { page: 1, rows: [], total: 0, pages: 1, pageSize: 50, brandsLoaded: false,
37335 amit 6
                  decisions: [], huntTimer: null, documents: [], pollTimer: null };
37331 amit 7
 
8
    function ctx() {
9
        return (typeof context !== 'undefined' && context) ? context : (window.ocContext || '');
10
    }
11
 
12
    /**
13
     * Deliberately NOT doGetAjaxRequestHandler: that lives in common.js, which is
14
     * only present when this screen is injected into admin.vm. Using $.ajax keeps
15
     * the page working when it is opened directly by URL too.
16
     */
17
    function ocGet(url, onDone, onFail) {
18
        $.ajax({ url: url, type: 'GET', cache: false })
19
            .done(onDone)
20
            .fail(function (xhr) {
21
                if (onFail) { onFail(xhr); }
22
            });
23
    }
24
 
25
    function failMessage(xhr) {
26
        if (xhr.status === 0) { return 'could not reach the server'; }
27
        if (xhr.status === 404) { return 'endpoint not found (status 404) - is this build deployed?'; }
28
        if (xhr.status === 403 || xhr.status === 401) { return 'not authorised (status ' + xhr.status + ')'; }
29
        return 'server returned status ' + xhr.status;
30
    }
31
 
32
    function root() {
33
        return $('.oc-root');
34
    }
35
 
36
    function esc(value) {
37
        if (value === null || value === undefined) {
38
            return '';
39
        }
40
        return $('<span>').text(String(value)).html();
41
    }
42
 
43
    function dash(value) {
44
        return (value === null || value === undefined || String(value).trim() === '')
45
            ? '<span class="oc-pill oc-pill-muted">not set</span>' : esc(value);
46
    }
47
 
48
    function filters() {
49
        var f = {};
50
        root().find('[data-oc-filter]').each(function () {
51
            var el = $(this);
52
            f[el.data('oc-filter')] = el.is(':checkbox') ? el.prop('checked') : (el.val() || '');
53
        });
54
        return f;
55
    }
56
 
57
    function queryString() {
58
        var f = filters();
59
        var parts = ['page=' + state.page];
60
        // an empty filter must not be sent - the repository appends a predicate for
61
        // every parameter it actually receives
62
        if (f.brand) { parts.push('brand=' + encodeURIComponent(f.brand)); }
63
        if (f.periodMonth) { parts.push('periodMonth=' + f.periodMonth); }
64
        if (f.fromDate) { parts.push('fromDate=' + f.fromDate); }
65
        if (f.toDate) { parts.push('toDate=' + f.toDate); }
66
        if (f.onlyUnmapped) { parts.push('onlyUnmapped=true'); }
67
        // scope defaults to true server-side, so only the opt-out needs sending
68
        if (f.inScopeOnly === false) { parts.push('inScopeOnly=false'); }
69
        if (f.pageSize) { parts.push('pageSize=' + f.pageSize); }
70
        return parts.join('&');
71
    }
72
 
73
    function statusPill(row) {
74
        if (row.parseStatus !== 'PARSED') {
75
            return '<span class="oc-pill oc-pill-warn">' + esc(row.parseStatus) + '</span>';
76
        }
77
        if (row.unmappedCount > 0) {
78
            return '<span class="oc-pill oc-pill-danger">' + row.unmappedCount + ' unmapped</span>';
79
        }
80
        var offerStatus = row.offerStatus || '';
81
        var cls = offerStatus === 'ACTIVE' ? 'oc-pill-ok' : 'oc-pill-muted';
82
        return '<span class="oc-pill ' + cls + '">' + esc(offerStatus || 'PARSED') + '</span>';
83
    }
84
 
85
    function validity(row) {
86
        if (!row.startDateRaw && !row.endDateRaw) {
87
            return '<span class="oc-pill oc-pill-muted">not set</span>';
88
        }
89
        return '<span class="oc-mono">' + esc(row.startDateRaw) + '<br>' + esc(row.endDateRaw) + '</span>';
90
    }
91
 
92
    /** Raw cells wrap to a couple of lines instead of being ellipsised into
93
     *  uselessness - the whole point of this screen is reading the source text. */
94
    function raw(value, lines) {
95
        var text = esc(value);
96
        if (!text) { return '<span class="oc-pill oc-pill-muted">not set</span>'; }
97
        return '<span class="oc-raw" style="-webkit-line-clamp:' + (lines || 2)
98
            + ';" title="' + text + '">' + text + '</span>';
99
    }
100
 
101
    var STATUS_CLASS = {
102
        AUTO_EXACT: 'oc-pill-ok', CONFIRMED: 'oc-pill-ok', AUTO_MODEL: 'oc-pill-info',
103
        REVIEW: 'oc-pill-warn', CATALOG_GAP: 'oc-pill-danger',
104
        CATEGORY_ALL: 'oc-pill-muted', IGNORED: 'oc-pill-muted'
105
    };
106
 
107
    // CONFIRMED and IGNORED both came from a human-confirmed exception in
108
    // offers.product_alias, so say so rather than showing a bare status
109
    var STATUS_NOTE = {
110
        CONFIRMED: 'via exception',
111
        IGNORED: 'ignored by exception'
112
    };
113
 
114
    /** Products shown as per-status chips, so an unmapped product is visible in the
115
     *  list without expanding the row. */
116
    function productChips(row) {
117
        if (!row.productCount) { return raw(row.eligibleProducts, 2); }
118
        var bits = [];
119
        if (row.productCount - row.unmappedCount > 0) {
120
            bits.push('<span class="oc-pill oc-pill-ok">'
121
                + (row.productCount - row.unmappedCount) + ' mapped</span>');
122
        }
123
        if (row.unmappedCount > 0) {
124
            bits.push('<span class="oc-pill oc-pill-danger">' + row.unmappedCount + ' unmapped</span>');
125
        }
126
        return bits.join(' ') + '<div style="margin-top:4px;">' + raw(row.eligibleProducts, 2) + '</div>';
127
    }
128
 
129
    function dl(pairs) {
130
        return pairs.map(function (p) {
131
            return '<dt>' + esc(p[0]) + '</dt><dd>' + dash(p[1]) + '</dd>';
132
        }).join('');
133
    }
134
 
135
    function money(b) {
136
        if (b.calcType === 'PERCENT') {
137
            return b.percent + '%' + (b.maxAmount ? ' capped at ' + fmt(b.maxAmount) : ' uncapped');
138
        }
139
        if (b.calcType === 'FLAT') { return fmt(b.flatAmount); }
140
        return '&mdash;';
141
    }
142
 
143
    function fmt(n) {
144
        var v = Number(n);
145
        return isNaN(v) ? esc(n) : 'Rs ' + v.toLocaleString('en-IN',
146
            { minimumFractionDigits: 0, maximumFractionDigits: 2 });
147
    }
148
 
149
    var MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
150
                  'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
151
 
152
    /**
153
     * Dates arrive from Jackson as either epoch millis or "yyyy-MM-dd[ HH:mm:ss]"
154
     * depending on the serialiser, so handle both and render "01 Aug 2026".
155
     */
156
    function niceDate(value) {
157
        if (value === null || value === undefined || value === '') { return null; }
158
        var d;
159
        if (typeof value === 'number') {
160
            d = new Date(value);
161
        } else {
162
            var m = String(value).match(/^(\d{4})-(\d{2})-(\d{2})/);
163
            if (m) {
164
                d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
165
            } else if (/^\d+$/.test(String(value))) {
166
                d = new Date(Number(value));
167
            } else {
168
                return esc(value);
169
            }
170
        }
171
        if (isNaN(d.getTime())) { return esc(value); }
172
        return ('0' + d.getDate()).slice(-2) + ' ' + MONTHS[d.getMonth()] + ' ' + d.getFullYear();
173
    }
174
 
175
    function dayDiff(value) {
176
        var m = String(value).match(/^(\d{4})-(\d{2})-(\d{2})/);
177
        var end = m ? new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]))
178
                    : new Date(Number(value));
179
        if (isNaN(end.getTime())) { return null; }
180
        var today = new Date();
181
        today.setHours(0, 0, 0, 0);
182
        return Math.round((end - today) / 86400000);
183
    }
184
 
185
    /** "01 Aug 2026 to 26 Sep 2026 ยท live, 44 days left" */
186
    function validityLine(row) {
187
        var from = niceDate(row.startDate), to = niceDate(row.endDate);
188
        if (!from && !to) {
189
            return '<span class="oc-pill oc-pill-warn">dates not parsed</span>';
190
        }
191
        var left = dayDiff(row.endDate);
192
        var state;
193
        if (left === null) {
194
            state = '';
195
        } else if (left < 0) {
196
            state = ' <span class="oc-pill oc-pill-muted">expired ' + Math.abs(left) + ' day(s) ago</span>';
197
        } else if (left === 0) {
198
            state = ' <span class="oc-pill oc-pill-warn">last day</span>';
199
        } else {
200
            state = ' <span class="oc-pill oc-pill-ok">live, ' + left + ' day(s) left</span>';
201
        }
202
        return '<strong>' + from + '</strong> to <strong>' + to + '</strong>' + state;
203
    }
204
 
205
    /** The comparison that makes this screen worth having: the nine verbatim PDF
206
     *  columns beside the rows the parser actually produced from them. */
207
    function detailRow(row) {
208
        var original = dl([
209
            ['OEM Name', row.oemName],
210
            ['Additional Cashback', row.additionalCashback],
211
            ['Offer Description', row.offerDescription],
212
            ['EMI tenure', row.emiTenure],
213
            ['Debit Cards', row.debitCards],
214
            ['Credit cards', row.creditCards],
215
            ['Eligible products', row.eligibleProducts],
216
            ['Start Date', row.startDateRaw],
217
            ['End Date', row.endDateRaw]
218
        ]);
219
        return '<tr class="oc-detail-row"><td colspan="11" class="oc-detail">'
220
            + '<div class="oc-detail-grid">'
221
            + '<div><div class="oc-detail-title">Original row as printed &mdash; page '
222
            + esc(row.pageNo) + ', row ' + esc(row.rowNo)
223
            + '</div><dl class="oc-dl">' + original + '</dl></div>'
224
            + '<div><div class="oc-detail-title">What the parser produced</div>'
225
            + '<div data-oc-parsed="' + esc(row.offerId) + '" class="oc-parsed">'
226
            + 'Loading parsed rows&hellip;</div></div>'
227
            + '</div></td></tr>';
228
    }
229
 
230
    function renderParsed(container, row, d) {
231
        function tbl(title, headers, bodyRows) {
232
            if (!bodyRows.length) {
233
                return '<div class="oc-sub"><span class="oc-sub-h">' + title + '</span>'
234
                    + '<span class="oc-pill oc-pill-warn">nothing parsed</span></div>';
235
            }
236
            return '<div class="oc-sub"><span class="oc-sub-h">' + title
237
                + ' <span class="oc-num">(' + bodyRows.length + ')</span></span>'
238
                + '<table class="oc-mini"><thead><tr>'
239
                + headers.map(function (h) { return '<th>' + h + '</th>'; }).join('')
240
                + '</tr></thead><tbody>' + bodyRows.join('') + '</tbody></table></div>';
241
        }
242
 
243
        var benefits = (d.benefits || []).map(function (b) {
244
            return '<tr><td><code>' + esc(b.txnMode) + '</code></td>'
245
                + '<td>' + esc(b.calcType) + '</td><td><strong>' + money(b) + '</strong></td></tr>';
246
        });
247
 
248
        var byScheme = {};
249
        (d.tenures || []).forEach(function (t) {
250
            (byScheme[t.emiScheme] = byScheme[t.emiScheme] || []).push(t.tenureMonths);
251
        });
252
        var tenures = Object.keys(byScheme).map(function (s) {
253
            return '<tr><td><code>' + esc(s) + '</code></td><td>'
254
                + byScheme[s].join(', ') + ' months</td></tr>';
255
        });
256
 
257
        var byCard = {};
258
        (d.banks || []).forEach(function (b) {
259
            (byCard[b.cardType] = byCard[b.cardType] || []).push(
260
                (b.inclusion === 'EXCLUDE' ? 'NOT ' : '') + b.code);
261
        });
262
        var banks = Object.keys(byCard).map(function (c) {
263
            return '<tr><td><code>' + esc(c) + '</code></td><td>' + byCard[c].join(', ') + '</td></tr>';
264
        });
265
 
266
        var products = (d.products || []).map(function (p) {
267
            var cls = STATUS_CLASS[p.matchStatus] || 'oc-pill-muted';
268
            var note = STATUS_NOTE[p.matchStatus];
269
            var to = p.catalogVariant || p.catalogModel;
270
            var resolved;
271
            if (p.matchStatus === 'IGNORED') {
272
                resolved = '<em>not stocked - ignored</em>';
273
            } else if (to) {
274
                resolved = esc(to)
275
                    + (p.catalogId ? ' <span class="oc-num">#' + p.catalogId + '</span>' : '')
276
                    + (p.matchLevel === 'MODEL' ? ' <span class="oc-num">(all variants)</span>' : '');
277
            } else {
278
                resolved = '<em>not in catalog</em>';
279
            }
280
            return '<tr><td class="oc-mono">' + esc(p.rawText) + '</td>'
281
                + '<td><span class="oc-pill ' + cls + '">' + esc(p.matchStatus) + '</span>'
282
                + (note ? ' <span class="oc-num">' + note + '</span>' : '')
283
                + (p.matchScore && p.matchStatus === 'REVIEW'
284
                    ? ' <span class="oc-num">' + p.matchScore + '</span>' : '')
285
                + '</td><td>' + resolved + '</td></tr>';
286
        });
287
 
288
        var channels = (d.channels || []).map(function (c) {
289
            return '<tr><td><span class="oc-pill '
290
                + (c.inclusion === 'EXCLUDE' ? 'oc-pill-danger' : 'oc-pill-ok') + '">'
291
                + esc(c.inclusion) + '</span></td><td>' + esc(c.channelRef) + '</td></tr>';
292
        });
293
 
294
        var html = '<div class="oc-sub"><span class="oc-sub-h">Validity</span>'
295
                 + validityLine(row) + '</div>'
296
                 + tbl('Cashback by payment mode', ['Mode', 'Type', 'Value'], benefits)
297
            + tbl('EMI tenures', ['Scheme', 'Months'], tenures)
298
            + tbl('Banks', ['Card', 'Issuers'], banks)
299
            + tbl('Products', ['As printed', 'Match', 'Resolved to'], products);
300
        if (channels.length) {
301
            html += tbl('Store restrictions', ['Rule', 'Retailer'], channels);
302
        }
303
        if (row.parseError) {
304
            html += '<div class="oc-sub"><span class="oc-pill oc-pill-danger">parse error</span> '
305
                + esc(row.parseError) + '</div>';
306
        }
307
        container.html(html);
308
    }
309
 
310
    function render() {
311
        var r = root();
312
        var body = r.find('[data-oc-body]');
313
        if (state.rows.length === 0) {
314
            body.html('<tr><td colspan="11" class="oc-empty">No circular rows match these filters.</td></tr>');
315
        } else {
316
            body.html(state.rows.map(function (row, i) {
317
                return '<tr data-oc-row="' + i + '">'
318
                    + '<td class="oc-num">' + esc(row.pageNo) + '/' + esc(row.rowNo) + '</td>'
319
                    + '<td><strong>' + esc(row.oemName) + '</strong></td>'
320
                    + '<td>' + dash(row.brand) + '</td>'
321
                    + '<td>' + dash(row.category) + '</td>'
322
                    + '<td>' + dash(row.additionalCashback) + '</td>'
323
                    + '<td>' + raw(row.offerDescription, 3) + '</td>'
324
                    + '<td class="oc-mono">' + raw(row.emiTenure, 2) + '</td>'
325
                    + '<td>' + productChips(row) + '</td>'
326
                    + '<td>' + validity(row) + '</td>'
327
                    + '<td>' + statusPill(row) + '</td>'
328
                    + '<td><button class="oc-expand" type="button" data-oc-expand="' + i + '">View raw</button></td>'
329
                    + '</tr>';
330
            }).join(''));
331
        }
332
 
333
        var first = state.total === 0 ? 0 : ((state.page - 1) * state.pageSize) + 1;
334
        var last = Math.min(state.page * state.pageSize, state.total);
335
        r.find('[data-oc-range]').text(state.total === 0 ? '' : first + '-' + last + ' of ' + state.total);
336
        r.find('[data-oc-page]').text('Page ' + state.page + ' of ' + state.pages);
337
        r.find('[data-oc-summary]').text(state.total + ' row(s) matched');
338
        r.find('[data-oc-prev]').prop('disabled', state.page <= 1);
339
        r.find('[data-oc-next]').prop('disabled', state.page >= state.pages);
340
 
341
        var parsed = 0, pending = 0;
342
        state.rows.forEach(function (row) {
343
            if (row.parseStatus === 'PARSED' && !row.unmappedCount) { parsed++; }
344
            if (row.parseStatus !== 'PARSED' || row.unmappedCount > 0) { pending++; }
345
        });
346
        r.find('[data-oc-stat="total"]').text(state.total);
347
        r.find('[data-oc-stat="parsed"]').text(parsed);
348
        r.find('[data-oc-stat="pending"]').text(pending);
349
    }
350
 
351
    function loadRows() {
352
        root().find('[data-oc-body]')
353
            .html('<tr><td colspan="11" class="oc-spin">Loading circular rows...</td></tr>');
354
        ocGet(ctx() + '/offerCircular/rawRows?' + queryString(), function (response) {
355
            var payload;
356
            try {
357
                payload = typeof response === 'string' ? JSON.parse(response) : response;
358
            } catch (e) {
359
                root().find('[data-oc-body]').html('<tr><td colspan="11" class="oc-empty">'
360
                    + 'The server replied but the response was not readable JSON. '
361
                    + 'Open /offerCircular/rawRows directly to see it.</td></tr>');
362
                return;
363
            }
364
            state.rows = payload.rows || [];
365
            state.total = payload.total || 0;
366
            state.pages = payload.pages || 1;
367
            state.page = payload.page || 1;
368
            state.pageSize = payload.pageSize || 50;
369
 
37335 amit 370
            state.documents = payload.documents || state.documents || [];
37331 amit 371
            if (payload.documents) {
372
                var docSel = root().find('[data-oc-doc]');
373
                if (docSel.find('option').filter(function () { return this.value !== ''; }).length === 0) {
374
                    docSel.empty();
375
                    payload.documents.forEach(function (doc) {
376
                        docSel.append($('<option>').attr('value', doc.documentId)
377
                            .attr('data-downloadable', doc.downloadable ? '1' : '0')
378
                            .attr('data-period', doc.periodMonth || '')
379
                            .text(doc.title + '  (' + doc.rowCount + ' rows, ' + doc.status + ')'));
380
                    });
381
                    if (payload.documents.length === 0) {
382
                        docSel.append($('<option>').attr('value', '')
383
                            .text('No circulars loaded - upload a PDF, then run the ingest'));
384
                    }
385
                }
386
            }
387
 
388
            if (!state.brandsLoaded && payload.brands) {
389
                var select = root().find('[data-oc-filter="brand"]');
390
                var current = select.val();
391
                select.find('option').not(':first').remove();
392
                payload.brands.forEach(function (b) {
393
                    if (b) { select.append($('<option>').attr('value', b).text(b)); }
394
                });
395
                select.val(current);
396
                root().find('[data-oc-stat="brands"]').text(payload.brands.filter(Boolean).length);
397
                state.brandsLoaded = true;
398
            }
399
            render();
37335 amit 400
            renderIngestState();
401
            schedulePoll();
37331 amit 402
            // an empty result while circulars exist is nearly always a filter
403
            // mismatch, so name the months that actually hold rows
404
            if (state.total === 0 && payload.documents && payload.documents.length > 0) {
405
                root().find('[data-oc-summary]').text(
406
                    'No rows matched these filters. Loaded circulars: ' +
407
                    payload.documents.map(function (d) {
408
                        return (d.periodMonth || '?') + ' - ' + d.rowCount + ' rows';
409
                    }).join(', ') + '. Clear the filters to see everything.');
410
            }
411
        }, function (xhr) {
412
            // never leave the table sitting on "Loading..." - say what broke
413
            root().find('[data-oc-body]').html('<tr><td colspan="11" class="oc-empty">'
414
                + 'Could not load circular rows: ' + failMessage(xhr) + '.</td></tr>');
415
            root().find('[data-oc-doc]').empty()
416
                .append($('<option>').attr('value', '').text('Could not load circulars'));
417
        });
418
    }
419
 
420
    // sidebar entry / any trigger loads the fragment then initialises it
421
    function openOfferCircularReview(domId) {
422
        ocGet(ctx() + '/offerCircularReview', function (response) {
423
            $('#' + (domId || 'main-content')).html(response);
424
            state = { page: 1, rows: [], total: 0, pages: 1, pageSize: 50, brandsLoaded: false,
37335 amit 425
                      decisions: [], huntTimer: null, documents: [], pollTimer: null };
37331 amit 426
            loadRows();
427
        });
428
    }
429
    window.openOfferCircularReview = openOfferCircularReview;
430
 
431
    $(document).on('click', '.offer-circular-review', function (e) {
432
        e.preventDefault();
433
        openOfferCircularReview('main-content');
434
    });
435
 
436
    $(document).on('click', '.oc-root [data-oc-expand]', function () {
437
        var index = $(this).data('oc-expand');
438
        var tr = root().find('[data-oc-row="' + index + '"]');
439
        var next = tr.next('.oc-detail-row');
440
        if (next.length) {
441
            next.remove();
442
            $(this).text('View raw');
443
        } else {
444
            var row = state.rows[index];
445
            tr.after(detailRow(row));
446
            $(this).text('Hide raw');
447
            var box = root().find('[data-oc-parsed="' + row.offerId + '"]').last();
448
            if (!row.offerId) {
449
                box.html('<span class="oc-pill oc-pill-warn">not parsed</span> '
450
                    + 'This row has no offer record yet, so there is nothing to compare.');
451
                return;
452
            }
453
            ocGet(ctx() + '/offerCircular/offerDetail?offerId=' + row.offerId,
454
                function (response) {
455
                    var d;
456
                    try { d = typeof response === 'string' ? JSON.parse(response) : response; }
457
                    catch (e) { box.text('Could not read the parsed rows.'); return; }
458
                    renderParsed(box, row, d);
459
                },
460
                function (xhr) { box.text('Could not load parsed rows: ' + failMessage(xhr)); });
461
        }
462
    });
463
 
464
    $(document).on('change', '.oc-root [data-oc-filter]', function () {
465
        state.page = 1;
466
        loadRows();
467
    });
468
 
469
    $(document).on('click', '.oc-root [data-oc-prev]', function () {
470
        if (state.page > 1) { state.page--; loadRows(); }
471
    });
472
 
473
    $(document).on('click', '.oc-root [data-oc-next]', function () {
474
        if (state.page < state.pages) { state.page++; loadRows(); }
475
    });
476
 
477
    $(document).on('click', '.oc-root [data-oc-reload]', function () {
478
        loadRows();
479
    });
480
 
481
    $(document).on('click', '.oc-root [data-oc-clear]', function () {
482
        root().find('[data-oc-filter]').each(function () {
483
            var el = $(this);
484
            if (el.is(':checkbox')) { el.prop('checked', false); } else { el.val(''); }
485
        });
486
        root().find('[data-oc-filter="pageSize"]').val('50');
487
        state.page = 1;
488
        loadRows();
489
    });
490
 
491
    // picking a circular filters the table to that circular's month, which is what
492
    // a reviewer expects the selector to do
493
    $(document).on('change', '.oc-root [data-oc-doc]', function () {
494
        var period = $(this).find('option:selected').attr('data-period') || '';
495
        root().find('[data-oc-filter="periodMonth"]').val(period ? period.substring(0, 10) : '');
496
        state.page = 1;
497
        loadRows();
498
        // the decision queue is scoped to the selected circular, so it moves with it
499
        if (root().find('[data-oc-resolve-wrap]').is(':visible')) {
500
            loadResolveQueue();
501
        }
502
    });
503
 
504
    // download hands back the exact PDF that was uploaded for the selected circular
505
    $(document).on('click', '.oc-root [data-oc-download]', function () {
506
        var opt = root().find('[data-oc-doc]').find('option:selected');
507
        var documentId = opt.val();
508
        if (!documentId) {
509
            alert('Select a circular first');
510
            return;
511
        }
512
        if (opt.attr('data-downloadable') !== '1') {
513
            alert('The source PDF was not retained for this circular, so it cannot be downloaded.');
514
            return;
515
        }
516
        window.location = ctx() + '/offerCircular/download?documentId=' + documentId;
517
    });
518
 
519
    $(document).on('click', '.oc-root [data-oc-upload]', function () {
520
        var input = root().find('[data-oc-upload-file]')[0];
521
        var month = root().find('[data-oc-upload-month]').val();
522
        var msg = root().find('[data-oc-upload-msg]');
523
        if (!input || !input.files || input.files.length === 0) {
524
            msg.text('Choose a PDF first.');
525
            return;
526
        }
527
        if (!month) {
528
            msg.text('Set the month this circular is for.');
529
            return;
530
        }
531
        var form = new FormData();
532
        form.append('file', input.files[0]);
533
        form.append('periodMonth', month);
534
 
535
        var btn = $(this).prop('disabled', true).text('Uploading...');
536
        msg.text('');
537
        $.ajax({
538
            url: ctx() + '/offerCircular/upload?periodMonth=' + month,
539
            type: 'POST',
540
            data: form,
541
            processData: false,
542
            contentType: false,
543
            success: function (response) {
544
                btn.prop('disabled', false).text('Upload');
545
                var r;
546
                try { r = typeof response === 'string' ? JSON.parse(response) : response; }
547
                catch (e) { msg.text('Upload finished but the response could not be read.'); return; }
548
                msg.text(r.message || '');
549
                if (r.success) {
550
                    input.value = '';
551
                    root().find('[data-oc-doc]').empty();
552
                    loadRows();
553
                }
554
            },
555
            error: function (xhr) {
556
                btn.prop('disabled', false).text('Upload');
557
                msg.text('Upload failed: ' + (xhr.status === 403 ? 'not authorised' : xhr.status));
558
            }
559
        });
560
    });
561
 
562
    // exports the rows currently on screen with the nine original columns intact
563
    $(document).on('click', '.oc-root [data-oc-export]', function () {
564
        var cols = ['pageNo', 'rowNo', 'oemName', 'brand', 'category', 'additionalCashback',
565
            'offerDescription', 'emiTenure', 'debitCards', 'creditCards', 'eligibleProducts',
566
            'startDateRaw', 'endDateRaw', 'parseStatus', 'offerStatus', 'productCount', 'unmappedCount'];
567
        var lines = [cols.join(',')];
568
        state.rows.forEach(function (row) {
569
            lines.push(cols.map(function (c) {
570
                var v = row[c];
571
                return '"' + String(v === null || v === undefined ? '' : v).replace(/"/g, '""') + '"';
572
            }).join(','));
573
        });
574
        var blob = new Blob([lines.join('\n')], { type: 'text/csv;charset=utf-8;' });
575
        var a = document.createElement('a');
576
        a.href = URL.createObjectURL(blob);
577
        a.download = 'offer-circular-rows.csv';
578
        a.click();
579
        URL.revokeObjectURL(a.href);
580
    });
581
 
582
    // ------------------------------------------------------------------ curation
583
    //
584
    // Everything here writes offers.product_alias only. An alias is keyed on
585
    // (OEM division, raw product text), so one answer resolves every row carrying that
586
    // text - this circular and every later one. That is why the queue is grouped by
587
    // distinct text: listing per row would ask the same question repeatedly.
588
 
589
    function ocPost(url, data, onDone, onFail) {
590
        $.ajax({ url: url, type: 'POST', data: data })
591
            .done(function (response) {
592
                var r;
593
                try { r = typeof response === 'string' ? JSON.parse(response) : response; }
594
                catch (e) { r = { success: false, message: 'Unreadable server response' }; }
595
                onDone(r);
596
            })
597
            .fail(function (xhr) {
598
                if (onFail) { onFail(xhr); }
599
            });
600
    }
601
 
602
    function resolveMsg(text) {
603
        root().find('[data-oc-resolve-msg]').text(text || '');
604
    }
605
 
606
    function currentDocumentId() {
607
        return root().find('[data-oc-doc]').val() || '';
608
    }
609
 
610
    function decisionCell(d) {
611
        if (!d.aliasAction) {
612
            return '<span class="oc-pill oc-pill-warn">undecided</span>';
613
        }
614
        var target = d.aliasCanonicalText
615
            || (d.aliasCatalogId ? '#' + d.aliasCatalogId : '')
616
            || (d.aliasSuperCatalogId ? 'model #' + d.aliasSuperCatalogId : '');
617
        // "pending" is literal, not decorative: ProductAliases is read at ingest time,
618
        // so a saved decision genuinely has not changed anything yet
619
        return '<span class="oc-pill oc-pill-info">' + esc(d.aliasAction) + '</span> '
620
            + (target ? '<span class="oc-num">' + esc(target) + '</span> ' : '')
621
            + '<span class="oc-num">pending re-ingest</span>';
622
    }
623
 
624
    function renderResolveQueue() {
625
        var body = root().find('[data-oc-resolve-body]');
626
        if (!state.decisions.length) {
627
            body.html('<tr><td colspan="6" class="oc-empty">'
628
                + 'Nothing left to resolve for this circular.</td></tr>');
629
            return;
630
        }
631
        body.html(state.decisions.map(function (d, i) {
632
            var coverage = d.decisionType === 'COVERAGE';
633
            var miss = d.closestCatalogName
634
                ? '<span class="oc-miss">' + esc(d.closestCatalogName)
635
                    + ' <span class="oc-num">#' + esc(d.closestCatalogId)
636
                    + ' &middot; ' + esc(d.closestScore) + '</span></span>'
637
                : '<span class="oc-miss">no candidate</span>';
638
            // A bundle offer's transfer to the bare SKU is a coverage decision, and an
639
            // alias cannot express it - parked rather than offered a naming answer.
640
            var action = coverage
641
                ? '<span class="oc-pill oc-pill-muted">coverage decision</span>'
642
                : '<button class="oc-expand" type="button" data-oc-resolve="' + i + '">'
643
                    + (d.aliasAction ? 'Change' : 'Resolve') + '</button>';
644
            return '<tr data-oc-decision="' + i + '">'
645
                + '<td class="oc-mono">' + esc(d.rawText) + '</td>'
646
                + '<td>' + esc(d.divisionLabel) + '</td>'
647
                + '<td class="oc-num">' + esc(d.rowCount) + ' row(s), '
648
                + esc(d.offerCount) + ' offer(s)</td>'
649
                + '<td>' + miss + '</td>'
650
                + '<td>' + (coverage
651
                    ? '<span class="oc-miss">bundle &mdash; needs a coverage call, '
652
                        + 'not a name</span>'
653
                    : decisionCell(d)) + '</td>'
654
                + '<td>' + action + '</td>'
655
                + '</tr>';
656
        }).join(''));
657
    }
658
 
659
    function editorRow(d, index) {
660
        var pinned = d.aliasCatalogId || '';
661
        return '<tr class="oc-detail-row"><td colspan="6" class="oc-edit" '
662
            + 'data-oc-editor="' + index + '">'
663
            + '<div class="oc-edit-actions">'
664
            + '<label><input type="radio" name="oc-act-' + index + '" value="PIN" '
665
            + (d.aliasAction === 'PIN' || !d.aliasAction ? 'checked' : '')
666
            + ' data-oc-act /> Pin to a catalog product</label>'
667
            + '<label><input type="radio" name="oc-act-' + index + '" value="REWRITE" '
668
            + (d.aliasAction === 'REWRITE' ? 'checked' : '')
669
            + ' data-oc-act /> Rewrite to the catalog&rsquo;s spelling</label>'
670
            + '<label><input type="radio" name="oc-act-' + index + '" value="IGNORE" '
671
            + (d.aliasAction === 'IGNORE' ? 'checked' : '')
672
            + ' data-oc-act /> Not ours &mdash; ignore</label>'
673
            + '</div>'
674
            + '<div data-oc-pane="PIN">'
675
            + '  <div class="oc-edit-row">'
676
            + '    <div class="oc-field" style="flex:1;min-width:240px;">'
677
            + '      <label>Find the product in ' + esc(d.divisionLabel) + '</label>'
678
            + '      <input class="oc-input" type="text" data-oc-hunt style="width:100%;" '
679
            + '             placeholder="type part of the model name" />'
680
            + '    </div>'
681
            + '    <span class="oc-num" data-oc-picked>'
682
            + (pinned ? 'pinned to #' + esc(pinned) : 'nothing selected') + '</span>'
683
            + '  </div>'
684
            + '  <div class="oc-hits" data-oc-hits>'
685
            + '    <div style="padding:8px 10px;" class="oc-miss">'
686
            + '      Search to list this OEM&rsquo;s catalog products.</div>'
687
            + '  </div>'
688
            + '</div>'
689
            + '<div data-oc-pane="REWRITE" style="display:none;">'
690
            + '  <div class="oc-edit-row"><div class="oc-field" style="flex:1;min-width:260px;">'
691
            + '    <label>Catalog spelling (the variant spec is carried over automatically)</label>'
692
            + '    <input class="oc-input" type="text" data-oc-canonical style="width:100%;" '
693
            + '           value="' + esc(d.aliasCanonicalText || '') + '" '
694
            + '           placeholder="e.g. iPhone 17 Air" />'
695
            + '  </div></div>'
696
            + '</div>'
697
            + '<div class="oc-edit-row">'
698
            + '  <div class="oc-field" style="flex:1;min-width:260px;">'
699
            + '    <label>Why (optional, but it is what the next reviewer reads)</label>'
700
            + '    <input class="oc-input" type="text" data-oc-note style="width:100%;" />'
701
            + '  </div>'
702
            + '  <button class="oc-btn" type="button" data-oc-save="' + index + '">Save decision</button>'
703
            + '  <button class="oc-btn oc-btn-ghost" type="button" data-oc-cancel>Cancel</button>'
704
            + (d.aliasAction
705
                ? '  <button class="oc-btn oc-btn-ghost" type="button" data-oc-forget="'
706
                    + index + '">Remove decision</button>'
707
                : '')
708
            + '</div>'
709
            + '</td></tr>';
710
    }
711
 
712
    function loadResolveQueue() {
713
        var documentId = currentDocumentId();
714
        ocGet(ctx() + '/offerCircular/resolveQueue'
715
                + (documentId ? '?documentId=' + documentId : ''),
716
            function (response) {
717
                var payload;
718
                try { payload = typeof response === 'string' ? JSON.parse(response) : response; }
719
                catch (e) {
720
                    root().find('[data-oc-resolve-body]').html('<tr><td colspan="6" '
721
                        + 'class="oc-empty">Could not read the decision queue.</td></tr>');
722
                    return;
723
                }
724
                state.decisions = payload.decisions || [];
725
                root().find('[data-oc-resolve-count]').text(
726
                    payload.naming + ' naming, ' + payload.coverage + ' coverage');
727
                root().find('[data-oc-resolve-pending]').text(
728
                    payload.pendingReingest
729
                        ? payload.pendingReingest + ' saved, awaiting re-ingest' : '');
730
                renderResolveQueue();
731
            },
732
            function (xhr) {
733
                root().find('[data-oc-resolve-body]').html('<tr><td colspan="6" '
734
                    + 'class="oc-empty">Could not load decisions: '
735
                    + failMessage(xhr) + '.</td></tr>');
736
            });
737
    }
738
 
739
    $(document).on('click', '.oc-root [data-oc-resolve-toggle]', function () {
740
        var wrap = root().find('[data-oc-resolve-wrap]');
741
        var showing = wrap.is(':visible');
742
        wrap.toggle(!showing);
743
        $(this).text(showing ? 'Show queue' : 'Hide queue');
744
        if (!showing) { loadResolveQueue(); }
745
    });
746
 
747
    $(document).on('click', '.oc-root [data-oc-resolve]', function () {
748
        var index = $(this).data('oc-resolve');
749
        var tr = root().find('[data-oc-decision="' + index + '"]');
750
        var next = tr.next('.oc-detail-row');
751
        if (next.length) {
752
            next.remove();
753
            return;
754
        }
755
        root().find('[data-oc-resolve-body] .oc-detail-row').remove();
756
        tr.after(editorRow(state.decisions[index], index));
757
    });
758
 
759
    $(document).on('click', '.oc-root [data-oc-cancel]', function () {
760
        $(this).closest('.oc-detail-row').remove();
761
    });
762
 
763
    // switching action shows only the fields that action actually needs
764
    $(document).on('change', '.oc-root [data-oc-act]', function () {
765
        var cell = $(this).closest('[data-oc-editor]');
766
        var chosen = $(this).val();
767
        cell.find('[data-oc-pane]').each(function () {
768
            $(this).toggle($(this).attr('data-oc-pane') === chosen);
769
        });
770
    });
771
 
772
    $(document).on('input', '.oc-root [data-oc-hunt]', function () {
773
        var cell = $(this).closest('[data-oc-editor]');
774
        var index = cell.attr('data-oc-editor');
775
        var decision = state.decisions[index];
776
        var term = $(this).val();
777
        var hits = cell.find('[data-oc-hits]');
778
        if (!term || term.length < 2) {
779
            hits.html('<div style="padding:8px 10px;" class="oc-miss">'
780
                + 'Type at least two characters.</div>');
781
            return;
782
        }
783
        clearTimeout(state.huntTimer);
784
        state.huntTimer = setTimeout(function () {
785
            ocGet(ctx() + '/offerCircular/catalogSearch?divisionLabel='
786
                    + encodeURIComponent(decision.divisionLabel)
787
                    + '&q=' + encodeURIComponent(term),
788
                function (response) {
789
                    var list;
790
                    try { list = typeof response === 'string' ? JSON.parse(response) : response; }
791
                    catch (e) { list = []; }
792
                    if (!list.length) {
793
                        hits.html('<div style="padding:8px 10px;" class="oc-miss">'
794
                            + 'No catalog product matches, in this OEM&rsquo;s brand and '
795
                            + 'category. If the SKU genuinely does not exist, the right '
796
                            + 'answer is Ignore, not a near-miss pin.</div>');
797
                        return;
798
                    }
799
                    hits.html(list.map(function (c) {
800
                        return '<button type="button" class="oc-hit" data-oc-pick="'
801
                            + c.catalogId + '" data-oc-super="' + (c.superCatalogId || '')
802
                            + '">' + esc(c.name)
803
                            + ' <span class="oc-num">#' + c.catalogId + '</span>'
804
                            + (c.superCatalogName
805
                                ? ' <span class="oc-num">&middot; ' + esc(c.superCatalogName)
806
                                    + '</span>' : '')
807
                            + '</button>';
808
                    }).join(''));
809
                },
810
                function (xhr) {
811
                    hits.html('<div style="padding:8px 10px;" class="oc-miss">'
812
                        + 'Search failed: ' + failMessage(xhr) + '</div>');
813
                });
814
        }, 250);
815
    });
816
 
817
    $(document).on('click', '.oc-root [data-oc-pick]', function () {
818
        var cell = $(this).closest('[data-oc-editor]');
819
        cell.find('[data-oc-pick]').attr('data-picked', '0');
820
        $(this).attr('data-picked', '1');
821
        cell.find('[data-oc-picked]').text('pinned to #' + $(this).data('oc-pick'));
822
    });
823
 
824
    $(document).on('click', '.oc-root [data-oc-save]', function () {
825
        var index = $(this).data('oc-save');
826
        var decision = state.decisions[index];
827
        var cell = root().find('[data-oc-editor="' + index + '"]');
828
        var action = cell.find('[data-oc-act]:checked').val();
829
 
830
        var payload = {
831
            divisionLabel: decision.divisionLabel,
832
            rawText: decision.rawText,
833
            action: action,
834
            note: cell.find('[data-oc-note]').val() || ''
835
        };
836
        if (action === 'PIN') {
837
            var picked = cell.find('[data-oc-pick][data-picked="1"]');
838
            if (!picked.length) {
839
                resolveMsg('Pick a catalog product first.');
840
                return;
841
            }
842
            payload.catalogId = picked.data('oc-pick');
843
            payload.matchLevel = 'VARIANT';
844
        } else if (action === 'REWRITE') {
845
            payload.canonicalText = cell.find('[data-oc-canonical]').val() || '';
846
        }
847
 
848
        var btn = $(this).prop('disabled', true).text('Saving...');
849
        ocPost(ctx() + '/offerCircular/alias', payload, function (r) {
850
            btn.prop('disabled', false).text('Save decision');
851
            resolveMsg(r.message || '');
852
            if (r.success) {
853
                cell.closest('.oc-detail-row').remove();
854
                loadResolveQueue();
855
            }
856
        }, function (xhr) {
857
            btn.prop('disabled', false).text('Save decision');
858
            resolveMsg('Could not save: ' + failMessage(xhr));
859
        });
860
    });
861
 
862
    $(document).on('click', '.oc-root [data-oc-forget]', function () {
863
        var decision = state.decisions[$(this).data('oc-forget')];
864
        ocPost(ctx() + '/offerCircular/aliasRemove',
865
            { divisionLabel: decision.divisionLabel, rawText: decision.rawText },
866
            function (r) {
867
                resolveMsg(r.message || '');
868
                root().find('[data-oc-resolve-body] .oc-detail-row').remove();
869
                loadResolveQueue();
870
            },
871
            function (xhr) { resolveMsg('Could not remove: ' + failMessage(xhr)); });
872
    });
873
 
874
    $(document).on('click', '.oc-root [data-oc-alias-export]', function () {
875
        window.location = ctx() + '/offerCircular/aliasExport';
876
    });
877
 
878
    $(document).on('click', '.oc-root [data-oc-reingest]', function () {
879
        var documentId = currentDocumentId();
880
        if (!documentId) {
881
            resolveMsg('Select a circular first.');
882
            return;
883
        }
884
        if (!window.confirm('Re-parse this circular so the saved decisions take effect?\n\n'
885
                + 'Everything derived from it is rebuilt from the stored PDF. Offer ids '
886
                + 'change, which nothing depends on today.')) {
887
            return;
888
        }
889
        var btn = $(this).prop('disabled', true).text('Queueing...');
890
        ocPost(ctx() + '/offerCircular/reingest', { documentId: documentId }, function (r) {
891
            btn.prop('disabled', false).text('Re-ingest to apply');
892
            resolveMsg(r.message || '');
893
        }, function (xhr) {
894
            btn.prop('disabled', false).text('Re-ingest to apply');
895
            resolveMsg('Could not queue re-ingest: ' + failMessage(xhr));
896
        });
897
    });
898
 
37335 amit 899
    // ------------------------------------------------------- ingest status polling
900
    //
901
    // Upload and parsing run in different processes: the portal stores the PDF as
902
    // DRAFT, and the cron job claims and parses it on its own schedule. Without this
903
    // the screen looks identical before and after a successful ingest, so a reviewer
904
    // has no way to tell "not parsed yet" from "parsed and produced nothing".
905
 
906
    var PENDING_STATES = { DRAFT: 'queued for parsing', PROCESSING: 'parsing now' };
907
 
908
    function selectedDoc() {
909
        var id = currentDocumentId();
910
        if (!id) { return null; }
911
        for (var i = 0; i < (state.documents || []).length; i++) {
912
            if (String(state.documents[i].documentId) === String(id)) {
913
                return state.documents[i];
914
            }
915
        }
916
        return null;
917
    }
918
 
919
    function renderIngestState() {
920
        var box = root().find('[data-oc-ingest-state]');
921
        var doc = selectedDoc();
922
        if (!doc) { box.empty(); return; }
923
 
924
        if (PENDING_STATES[doc.status]) {
925
            box.html('<span class="oc-pill oc-pill-warn">' + esc(PENDING_STATES[doc.status])
926
                + '</span> <span class="oc-num">the ingest job runs every 5 minutes; '
927
                + 'this refreshes itself</span>');
928
        } else if (doc.status === 'FAILED') {
929
            box.html('<span class="oc-pill oc-pill-danger">ingest failed</span> '
930
                + '<span class="oc-num">' + esc(doc.ingestError || 'no reason recorded')
931
                + '</span>');
932
        } else if (doc.status === 'PUBLISHED') {
933
            box.html('<span class="oc-pill oc-pill-ok">parsed</span> '
934
                + '<span class="oc-num">' + esc(doc.ingestSummary || '') + '</span>');
935
        } else {
936
            box.html('<span class="oc-pill oc-pill-muted">' + esc(doc.status) + '</span>');
937
        }
938
    }
939
 
940
    function schedulePoll() {
941
        clearTimeout(state.pollTimer);
942
        var doc = selectedDoc();
943
        if (!doc || !PENDING_STATES[doc.status]) {
944
            return;                      // settled - stop polling, do not spin forever
945
        }
946
        state.pollTimer = setTimeout(function () {
947
            ocGet(ctx() + '/offerCircular/documents', function (response) {
948
                var docs;
949
                try { docs = typeof response === 'string' ? JSON.parse(response) : response; }
950
                catch (e) { return; }
951
                var was = selectedDoc();
952
                state.documents = docs || [];
953
                var now = selectedDoc();
954
                renderIngestState();
955
                // it just finished: pull in the rows the parse produced
956
                if (was && now && was.status !== now.status && !PENDING_STATES[now.status]) {
957
                    loadRows();
958
                    return;
959
                }
960
                schedulePoll();
961
            }, function () { schedulePoll(); });   // transient failure - keep waiting
962
        }, 15000);
963
    }
964
 
37331 amit 965
    // direct navigation: the fragment is already on the page
966
    if (root().length > 0) {
967
        loadRows();
968
    }
969
});