Subversion Repositories SmartDukaan

Rev

Rev 37335 | Go to most recent revision | Details | 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,
6
                  decisions: [], huntTimer: null };
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
 
370
            if (payload.documents) {
371
                var docSel = root().find('[data-oc-doc]');
372
                if (docSel.find('option').filter(function () { return this.value !== ''; }).length === 0) {
373
                    docSel.empty();
374
                    payload.documents.forEach(function (doc) {
375
                        docSel.append($('<option>').attr('value', doc.documentId)
376
                            .attr('data-downloadable', doc.downloadable ? '1' : '0')
377
                            .attr('data-period', doc.periodMonth || '')
378
                            .text(doc.title + '  (' + doc.rowCount + ' rows, ' + doc.status + ')'));
379
                    });
380
                    if (payload.documents.length === 0) {
381
                        docSel.append($('<option>').attr('value', '')
382
                            .text('No circulars loaded - upload a PDF, then run the ingest'));
383
                    }
384
                }
385
            }
386
 
387
            if (!state.brandsLoaded && payload.brands) {
388
                var select = root().find('[data-oc-filter="brand"]');
389
                var current = select.val();
390
                select.find('option').not(':first').remove();
391
                payload.brands.forEach(function (b) {
392
                    if (b) { select.append($('<option>').attr('value', b).text(b)); }
393
                });
394
                select.val(current);
395
                root().find('[data-oc-stat="brands"]').text(payload.brands.filter(Boolean).length);
396
                state.brandsLoaded = true;
397
            }
398
            render();
399
            // an empty result while circulars exist is nearly always a filter
400
            // mismatch, so name the months that actually hold rows
401
            if (state.total === 0 && payload.documents && payload.documents.length > 0) {
402
                root().find('[data-oc-summary]').text(
403
                    'No rows matched these filters. Loaded circulars: ' +
404
                    payload.documents.map(function (d) {
405
                        return (d.periodMonth || '?') + ' - ' + d.rowCount + ' rows';
406
                    }).join(', ') + '. Clear the filters to see everything.');
407
            }
408
        }, function (xhr) {
409
            // never leave the table sitting on "Loading..." - say what broke
410
            root().find('[data-oc-body]').html('<tr><td colspan="11" class="oc-empty">'
411
                + 'Could not load circular rows: ' + failMessage(xhr) + '.</td></tr>');
412
            root().find('[data-oc-doc]').empty()
413
                .append($('<option>').attr('value', '').text('Could not load circulars'));
414
        });
415
    }
416
 
417
    // sidebar entry / any trigger loads the fragment then initialises it
418
    function openOfferCircularReview(domId) {
419
        ocGet(ctx() + '/offerCircularReview', function (response) {
420
            $('#' + (domId || 'main-content')).html(response);
421
            state = { page: 1, rows: [], total: 0, pages: 1, pageSize: 50, brandsLoaded: false,
422
                      decisions: [], huntTimer: null };
423
            loadRows();
424
        });
425
    }
426
    window.openOfferCircularReview = openOfferCircularReview;
427
 
428
    $(document).on('click', '.offer-circular-review', function (e) {
429
        e.preventDefault();
430
        openOfferCircularReview('main-content');
431
    });
432
 
433
    $(document).on('click', '.oc-root [data-oc-expand]', function () {
434
        var index = $(this).data('oc-expand');
435
        var tr = root().find('[data-oc-row="' + index + '"]');
436
        var next = tr.next('.oc-detail-row');
437
        if (next.length) {
438
            next.remove();
439
            $(this).text('View raw');
440
        } else {
441
            var row = state.rows[index];
442
            tr.after(detailRow(row));
443
            $(this).text('Hide raw');
444
            var box = root().find('[data-oc-parsed="' + row.offerId + '"]').last();
445
            if (!row.offerId) {
446
                box.html('<span class="oc-pill oc-pill-warn">not parsed</span> '
447
                    + 'This row has no offer record yet, so there is nothing to compare.');
448
                return;
449
            }
450
            ocGet(ctx() + '/offerCircular/offerDetail?offerId=' + row.offerId,
451
                function (response) {
452
                    var d;
453
                    try { d = typeof response === 'string' ? JSON.parse(response) : response; }
454
                    catch (e) { box.text('Could not read the parsed rows.'); return; }
455
                    renderParsed(box, row, d);
456
                },
457
                function (xhr) { box.text('Could not load parsed rows: ' + failMessage(xhr)); });
458
        }
459
    });
460
 
461
    $(document).on('change', '.oc-root [data-oc-filter]', function () {
462
        state.page = 1;
463
        loadRows();
464
    });
465
 
466
    $(document).on('click', '.oc-root [data-oc-prev]', function () {
467
        if (state.page > 1) { state.page--; loadRows(); }
468
    });
469
 
470
    $(document).on('click', '.oc-root [data-oc-next]', function () {
471
        if (state.page < state.pages) { state.page++; loadRows(); }
472
    });
473
 
474
    $(document).on('click', '.oc-root [data-oc-reload]', function () {
475
        loadRows();
476
    });
477
 
478
    $(document).on('click', '.oc-root [data-oc-clear]', function () {
479
        root().find('[data-oc-filter]').each(function () {
480
            var el = $(this);
481
            if (el.is(':checkbox')) { el.prop('checked', false); } else { el.val(''); }
482
        });
483
        root().find('[data-oc-filter="pageSize"]').val('50');
484
        state.page = 1;
485
        loadRows();
486
    });
487
 
488
    // picking a circular filters the table to that circular's month, which is what
489
    // a reviewer expects the selector to do
490
    $(document).on('change', '.oc-root [data-oc-doc]', function () {
491
        var period = $(this).find('option:selected').attr('data-period') || '';
492
        root().find('[data-oc-filter="periodMonth"]').val(period ? period.substring(0, 10) : '');
493
        state.page = 1;
494
        loadRows();
495
        // the decision queue is scoped to the selected circular, so it moves with it
496
        if (root().find('[data-oc-resolve-wrap]').is(':visible')) {
497
            loadResolveQueue();
498
        }
499
    });
500
 
501
    // download hands back the exact PDF that was uploaded for the selected circular
502
    $(document).on('click', '.oc-root [data-oc-download]', function () {
503
        var opt = root().find('[data-oc-doc]').find('option:selected');
504
        var documentId = opt.val();
505
        if (!documentId) {
506
            alert('Select a circular first');
507
            return;
508
        }
509
        if (opt.attr('data-downloadable') !== '1') {
510
            alert('The source PDF was not retained for this circular, so it cannot be downloaded.');
511
            return;
512
        }
513
        window.location = ctx() + '/offerCircular/download?documentId=' + documentId;
514
    });
515
 
516
    $(document).on('click', '.oc-root [data-oc-upload]', function () {
517
        var input = root().find('[data-oc-upload-file]')[0];
518
        var month = root().find('[data-oc-upload-month]').val();
519
        var msg = root().find('[data-oc-upload-msg]');
520
        if (!input || !input.files || input.files.length === 0) {
521
            msg.text('Choose a PDF first.');
522
            return;
523
        }
524
        if (!month) {
525
            msg.text('Set the month this circular is for.');
526
            return;
527
        }
528
        var form = new FormData();
529
        form.append('file', input.files[0]);
530
        form.append('periodMonth', month);
531
 
532
        var btn = $(this).prop('disabled', true).text('Uploading...');
533
        msg.text('');
534
        $.ajax({
535
            url: ctx() + '/offerCircular/upload?periodMonth=' + month,
536
            type: 'POST',
537
            data: form,
538
            processData: false,
539
            contentType: false,
540
            success: function (response) {
541
                btn.prop('disabled', false).text('Upload');
542
                var r;
543
                try { r = typeof response === 'string' ? JSON.parse(response) : response; }
544
                catch (e) { msg.text('Upload finished but the response could not be read.'); return; }
545
                msg.text(r.message || '');
546
                if (r.success) {
547
                    input.value = '';
548
                    root().find('[data-oc-doc]').empty();
549
                    loadRows();
550
                }
551
            },
552
            error: function (xhr) {
553
                btn.prop('disabled', false).text('Upload');
554
                msg.text('Upload failed: ' + (xhr.status === 403 ? 'not authorised' : xhr.status));
555
            }
556
        });
557
    });
558
 
559
    // exports the rows currently on screen with the nine original columns intact
560
    $(document).on('click', '.oc-root [data-oc-export]', function () {
561
        var cols = ['pageNo', 'rowNo', 'oemName', 'brand', 'category', 'additionalCashback',
562
            'offerDescription', 'emiTenure', 'debitCards', 'creditCards', 'eligibleProducts',
563
            'startDateRaw', 'endDateRaw', 'parseStatus', 'offerStatus', 'productCount', 'unmappedCount'];
564
        var lines = [cols.join(',')];
565
        state.rows.forEach(function (row) {
566
            lines.push(cols.map(function (c) {
567
                var v = row[c];
568
                return '"' + String(v === null || v === undefined ? '' : v).replace(/"/g, '""') + '"';
569
            }).join(','));
570
        });
571
        var blob = new Blob([lines.join('\n')], { type: 'text/csv;charset=utf-8;' });
572
        var a = document.createElement('a');
573
        a.href = URL.createObjectURL(blob);
574
        a.download = 'offer-circular-rows.csv';
575
        a.click();
576
        URL.revokeObjectURL(a.href);
577
    });
578
 
579
    // ------------------------------------------------------------------ curation
580
    //
581
    // Everything here writes offers.product_alias only. An alias is keyed on
582
    // (OEM division, raw product text), so one answer resolves every row carrying that
583
    // text - this circular and every later one. That is why the queue is grouped by
584
    // distinct text: listing per row would ask the same question repeatedly.
585
 
586
    function ocPost(url, data, onDone, onFail) {
587
        $.ajax({ url: url, type: 'POST', data: data })
588
            .done(function (response) {
589
                var r;
590
                try { r = typeof response === 'string' ? JSON.parse(response) : response; }
591
                catch (e) { r = { success: false, message: 'Unreadable server response' }; }
592
                onDone(r);
593
            })
594
            .fail(function (xhr) {
595
                if (onFail) { onFail(xhr); }
596
            });
597
    }
598
 
599
    function resolveMsg(text) {
600
        root().find('[data-oc-resolve-msg]').text(text || '');
601
    }
602
 
603
    function currentDocumentId() {
604
        return root().find('[data-oc-doc]').val() || '';
605
    }
606
 
607
    function decisionCell(d) {
608
        if (!d.aliasAction) {
609
            return '<span class="oc-pill oc-pill-warn">undecided</span>';
610
        }
611
        var target = d.aliasCanonicalText
612
            || (d.aliasCatalogId ? '#' + d.aliasCatalogId : '')
613
            || (d.aliasSuperCatalogId ? 'model #' + d.aliasSuperCatalogId : '');
614
        // "pending" is literal, not decorative: ProductAliases is read at ingest time,
615
        // so a saved decision genuinely has not changed anything yet
616
        return '<span class="oc-pill oc-pill-info">' + esc(d.aliasAction) + '</span> '
617
            + (target ? '<span class="oc-num">' + esc(target) + '</span> ' : '')
618
            + '<span class="oc-num">pending re-ingest</span>';
619
    }
620
 
621
    function renderResolveQueue() {
622
        var body = root().find('[data-oc-resolve-body]');
623
        if (!state.decisions.length) {
624
            body.html('<tr><td colspan="6" class="oc-empty">'
625
                + 'Nothing left to resolve for this circular.</td></tr>');
626
            return;
627
        }
628
        body.html(state.decisions.map(function (d, i) {
629
            var coverage = d.decisionType === 'COVERAGE';
630
            var miss = d.closestCatalogName
631
                ? '<span class="oc-miss">' + esc(d.closestCatalogName)
632
                    + ' <span class="oc-num">#' + esc(d.closestCatalogId)
633
                    + ' &middot; ' + esc(d.closestScore) + '</span></span>'
634
                : '<span class="oc-miss">no candidate</span>';
635
            // A bundle offer's transfer to the bare SKU is a coverage decision, and an
636
            // alias cannot express it - parked rather than offered a naming answer.
637
            var action = coverage
638
                ? '<span class="oc-pill oc-pill-muted">coverage decision</span>'
639
                : '<button class="oc-expand" type="button" data-oc-resolve="' + i + '">'
640
                    + (d.aliasAction ? 'Change' : 'Resolve') + '</button>';
641
            return '<tr data-oc-decision="' + i + '">'
642
                + '<td class="oc-mono">' + esc(d.rawText) + '</td>'
643
                + '<td>' + esc(d.divisionLabel) + '</td>'
644
                + '<td class="oc-num">' + esc(d.rowCount) + ' row(s), '
645
                + esc(d.offerCount) + ' offer(s)</td>'
646
                + '<td>' + miss + '</td>'
647
                + '<td>' + (coverage
648
                    ? '<span class="oc-miss">bundle &mdash; needs a coverage call, '
649
                        + 'not a name</span>'
650
                    : decisionCell(d)) + '</td>'
651
                + '<td>' + action + '</td>'
652
                + '</tr>';
653
        }).join(''));
654
    }
655
 
656
    function editorRow(d, index) {
657
        var pinned = d.aliasCatalogId || '';
658
        return '<tr class="oc-detail-row"><td colspan="6" class="oc-edit" '
659
            + 'data-oc-editor="' + index + '">'
660
            + '<div class="oc-edit-actions">'
661
            + '<label><input type="radio" name="oc-act-' + index + '" value="PIN" '
662
            + (d.aliasAction === 'PIN' || !d.aliasAction ? 'checked' : '')
663
            + ' data-oc-act /> Pin to a catalog product</label>'
664
            + '<label><input type="radio" name="oc-act-' + index + '" value="REWRITE" '
665
            + (d.aliasAction === 'REWRITE' ? 'checked' : '')
666
            + ' data-oc-act /> Rewrite to the catalog&rsquo;s spelling</label>'
667
            + '<label><input type="radio" name="oc-act-' + index + '" value="IGNORE" '
668
            + (d.aliasAction === 'IGNORE' ? 'checked' : '')
669
            + ' data-oc-act /> Not ours &mdash; ignore</label>'
670
            + '</div>'
671
            + '<div data-oc-pane="PIN">'
672
            + '  <div class="oc-edit-row">'
673
            + '    <div class="oc-field" style="flex:1;min-width:240px;">'
674
            + '      <label>Find the product in ' + esc(d.divisionLabel) + '</label>'
675
            + '      <input class="oc-input" type="text" data-oc-hunt style="width:100%;" '
676
            + '             placeholder="type part of the model name" />'
677
            + '    </div>'
678
            + '    <span class="oc-num" data-oc-picked>'
679
            + (pinned ? 'pinned to #' + esc(pinned) : 'nothing selected') + '</span>'
680
            + '  </div>'
681
            + '  <div class="oc-hits" data-oc-hits>'
682
            + '    <div style="padding:8px 10px;" class="oc-miss">'
683
            + '      Search to list this OEM&rsquo;s catalog products.</div>'
684
            + '  </div>'
685
            + '</div>'
686
            + '<div data-oc-pane="REWRITE" style="display:none;">'
687
            + '  <div class="oc-edit-row"><div class="oc-field" style="flex:1;min-width:260px;">'
688
            + '    <label>Catalog spelling (the variant spec is carried over automatically)</label>'
689
            + '    <input class="oc-input" type="text" data-oc-canonical style="width:100%;" '
690
            + '           value="' + esc(d.aliasCanonicalText || '') + '" '
691
            + '           placeholder="e.g. iPhone 17 Air" />'
692
            + '  </div></div>'
693
            + '</div>'
694
            + '<div class="oc-edit-row">'
695
            + '  <div class="oc-field" style="flex:1;min-width:260px;">'
696
            + '    <label>Why (optional, but it is what the next reviewer reads)</label>'
697
            + '    <input class="oc-input" type="text" data-oc-note style="width:100%;" />'
698
            + '  </div>'
699
            + '  <button class="oc-btn" type="button" data-oc-save="' + index + '">Save decision</button>'
700
            + '  <button class="oc-btn oc-btn-ghost" type="button" data-oc-cancel>Cancel</button>'
701
            + (d.aliasAction
702
                ? '  <button class="oc-btn oc-btn-ghost" type="button" data-oc-forget="'
703
                    + index + '">Remove decision</button>'
704
                : '')
705
            + '</div>'
706
            + '</td></tr>';
707
    }
708
 
709
    function loadResolveQueue() {
710
        var documentId = currentDocumentId();
711
        ocGet(ctx() + '/offerCircular/resolveQueue'
712
                + (documentId ? '?documentId=' + documentId : ''),
713
            function (response) {
714
                var payload;
715
                try { payload = typeof response === 'string' ? JSON.parse(response) : response; }
716
                catch (e) {
717
                    root().find('[data-oc-resolve-body]').html('<tr><td colspan="6" '
718
                        + 'class="oc-empty">Could not read the decision queue.</td></tr>');
719
                    return;
720
                }
721
                state.decisions = payload.decisions || [];
722
                root().find('[data-oc-resolve-count]').text(
723
                    payload.naming + ' naming, ' + payload.coverage + ' coverage');
724
                root().find('[data-oc-resolve-pending]').text(
725
                    payload.pendingReingest
726
                        ? payload.pendingReingest + ' saved, awaiting re-ingest' : '');
727
                renderResolveQueue();
728
            },
729
            function (xhr) {
730
                root().find('[data-oc-resolve-body]').html('<tr><td colspan="6" '
731
                    + 'class="oc-empty">Could not load decisions: '
732
                    + failMessage(xhr) + '.</td></tr>');
733
            });
734
    }
735
 
736
    $(document).on('click', '.oc-root [data-oc-resolve-toggle]', function () {
737
        var wrap = root().find('[data-oc-resolve-wrap]');
738
        var showing = wrap.is(':visible');
739
        wrap.toggle(!showing);
740
        $(this).text(showing ? 'Show queue' : 'Hide queue');
741
        if (!showing) { loadResolveQueue(); }
742
    });
743
 
744
    $(document).on('click', '.oc-root [data-oc-resolve]', function () {
745
        var index = $(this).data('oc-resolve');
746
        var tr = root().find('[data-oc-decision="' + index + '"]');
747
        var next = tr.next('.oc-detail-row');
748
        if (next.length) {
749
            next.remove();
750
            return;
751
        }
752
        root().find('[data-oc-resolve-body] .oc-detail-row').remove();
753
        tr.after(editorRow(state.decisions[index], index));
754
    });
755
 
756
    $(document).on('click', '.oc-root [data-oc-cancel]', function () {
757
        $(this).closest('.oc-detail-row').remove();
758
    });
759
 
760
    // switching action shows only the fields that action actually needs
761
    $(document).on('change', '.oc-root [data-oc-act]', function () {
762
        var cell = $(this).closest('[data-oc-editor]');
763
        var chosen = $(this).val();
764
        cell.find('[data-oc-pane]').each(function () {
765
            $(this).toggle($(this).attr('data-oc-pane') === chosen);
766
        });
767
    });
768
 
769
    $(document).on('input', '.oc-root [data-oc-hunt]', function () {
770
        var cell = $(this).closest('[data-oc-editor]');
771
        var index = cell.attr('data-oc-editor');
772
        var decision = state.decisions[index];
773
        var term = $(this).val();
774
        var hits = cell.find('[data-oc-hits]');
775
        if (!term || term.length < 2) {
776
            hits.html('<div style="padding:8px 10px;" class="oc-miss">'
777
                + 'Type at least two characters.</div>');
778
            return;
779
        }
780
        clearTimeout(state.huntTimer);
781
        state.huntTimer = setTimeout(function () {
782
            ocGet(ctx() + '/offerCircular/catalogSearch?divisionLabel='
783
                    + encodeURIComponent(decision.divisionLabel)
784
                    + '&q=' + encodeURIComponent(term),
785
                function (response) {
786
                    var list;
787
                    try { list = typeof response === 'string' ? JSON.parse(response) : response; }
788
                    catch (e) { list = []; }
789
                    if (!list.length) {
790
                        hits.html('<div style="padding:8px 10px;" class="oc-miss">'
791
                            + 'No catalog product matches, in this OEM&rsquo;s brand and '
792
                            + 'category. If the SKU genuinely does not exist, the right '
793
                            + 'answer is Ignore, not a near-miss pin.</div>');
794
                        return;
795
                    }
796
                    hits.html(list.map(function (c) {
797
                        return '<button type="button" class="oc-hit" data-oc-pick="'
798
                            + c.catalogId + '" data-oc-super="' + (c.superCatalogId || '')
799
                            + '">' + esc(c.name)
800
                            + ' <span class="oc-num">#' + c.catalogId + '</span>'
801
                            + (c.superCatalogName
802
                                ? ' <span class="oc-num">&middot; ' + esc(c.superCatalogName)
803
                                    + '</span>' : '')
804
                            + '</button>';
805
                    }).join(''));
806
                },
807
                function (xhr) {
808
                    hits.html('<div style="padding:8px 10px;" class="oc-miss">'
809
                        + 'Search failed: ' + failMessage(xhr) + '</div>');
810
                });
811
        }, 250);
812
    });
813
 
814
    $(document).on('click', '.oc-root [data-oc-pick]', function () {
815
        var cell = $(this).closest('[data-oc-editor]');
816
        cell.find('[data-oc-pick]').attr('data-picked', '0');
817
        $(this).attr('data-picked', '1');
818
        cell.find('[data-oc-picked]').text('pinned to #' + $(this).data('oc-pick'));
819
    });
820
 
821
    $(document).on('click', '.oc-root [data-oc-save]', function () {
822
        var index = $(this).data('oc-save');
823
        var decision = state.decisions[index];
824
        var cell = root().find('[data-oc-editor="' + index + '"]');
825
        var action = cell.find('[data-oc-act]:checked').val();
826
 
827
        var payload = {
828
            divisionLabel: decision.divisionLabel,
829
            rawText: decision.rawText,
830
            action: action,
831
            note: cell.find('[data-oc-note]').val() || ''
832
        };
833
        if (action === 'PIN') {
834
            var picked = cell.find('[data-oc-pick][data-picked="1"]');
835
            if (!picked.length) {
836
                resolveMsg('Pick a catalog product first.');
837
                return;
838
            }
839
            payload.catalogId = picked.data('oc-pick');
840
            payload.matchLevel = 'VARIANT';
841
        } else if (action === 'REWRITE') {
842
            payload.canonicalText = cell.find('[data-oc-canonical]').val() || '';
843
        }
844
 
845
        var btn = $(this).prop('disabled', true).text('Saving...');
846
        ocPost(ctx() + '/offerCircular/alias', payload, function (r) {
847
            btn.prop('disabled', false).text('Save decision');
848
            resolveMsg(r.message || '');
849
            if (r.success) {
850
                cell.closest('.oc-detail-row').remove();
851
                loadResolveQueue();
852
            }
853
        }, function (xhr) {
854
            btn.prop('disabled', false).text('Save decision');
855
            resolveMsg('Could not save: ' + failMessage(xhr));
856
        });
857
    });
858
 
859
    $(document).on('click', '.oc-root [data-oc-forget]', function () {
860
        var decision = state.decisions[$(this).data('oc-forget')];
861
        ocPost(ctx() + '/offerCircular/aliasRemove',
862
            { divisionLabel: decision.divisionLabel, rawText: decision.rawText },
863
            function (r) {
864
                resolveMsg(r.message || '');
865
                root().find('[data-oc-resolve-body] .oc-detail-row').remove();
866
                loadResolveQueue();
867
            },
868
            function (xhr) { resolveMsg('Could not remove: ' + failMessage(xhr)); });
869
    });
870
 
871
    $(document).on('click', '.oc-root [data-oc-alias-export]', function () {
872
        window.location = ctx() + '/offerCircular/aliasExport';
873
    });
874
 
875
    $(document).on('click', '.oc-root [data-oc-reingest]', function () {
876
        var documentId = currentDocumentId();
877
        if (!documentId) {
878
            resolveMsg('Select a circular first.');
879
            return;
880
        }
881
        if (!window.confirm('Re-parse this circular so the saved decisions take effect?\n\n'
882
                + 'Everything derived from it is rebuilt from the stored PDF. Offer ids '
883
                + 'change, which nothing depends on today.')) {
884
            return;
885
        }
886
        var btn = $(this).prop('disabled', true).text('Queueing...');
887
        ocPost(ctx() + '/offerCircular/reingest', { documentId: documentId }, function (r) {
888
            btn.prop('disabled', false).text('Re-ingest to apply');
889
            resolveMsg(r.message || '');
890
        }, function (xhr) {
891
            btn.prop('disabled', false).text('Re-ingest to apply');
892
            resolveMsg('Could not queue re-ingest: ' + failMessage(xhr));
893
        });
894
    });
895
 
896
    // direct navigation: the fragment is already on the page
897
    if (root().length > 0) {
898
        loadRows();
899
    }
900
});