Subversion Repositories SmartDukaan

Rev

Rev 37337 | Blame | Compare with Previous | Last modification | View Log | RSS feed

$(function () {

    // The screen is an AJAX fragment injected into #main-content, so every handler
    // is delegated from document and state is re-initialised on each load.
    var state = { page: 1, rows: [], total: 0, pages: 1, pageSize: 50, brandsLoaded: false,
                  decisions: [], huntTimer: null, documents: [], pollTimer: null };

    function ctx() {
        return (typeof context !== 'undefined' && context) ? context : (window.ocContext || '');
    }

    /**
     * Deliberately NOT doGetAjaxRequestHandler: that lives in common.js, which is
     * only present when this screen is injected into admin.vm. Using $.ajax keeps
     * the page working when it is opened directly by URL too.
     */
    function ocGet(url, onDone, onFail) {
        $.ajax({ url: url, type: 'GET', cache: false })
            .done(onDone)
            .fail(function (xhr) {
                if (onFail) { onFail(xhr); }
            });
    }

    function failMessage(xhr) {
        if (xhr.status === 0) { return 'could not reach the server'; }
        if (xhr.status === 404) { return 'endpoint not found (status 404) - is this build deployed?'; }
        if (xhr.status === 403 || xhr.status === 401) { return 'not authorised (status ' + xhr.status + ')'; }
        return 'server returned status ' + xhr.status;
    }

    function root() {
        return $('.oc-root');
    }

    function esc(value) {
        if (value === null || value === undefined) {
            return '';
        }
        return $('<span>').text(String(value)).html();
    }

    function dash(value) {
        return (value === null || value === undefined || String(value).trim() === '')
            ? '<span class="oc-pill oc-pill-muted">not set</span>' : esc(value);
    }

    function filters() {
        var f = {};
        root().find('[data-oc-filter]').each(function () {
            var el = $(this);
            f[el.data('oc-filter')] = el.is(':checkbox') ? el.prop('checked') : (el.val() || '');
        });
        return f;
    }

    function queryString() {
        var f = filters();
        var parts = ['page=' + state.page];
        // an empty filter must not be sent - the repository appends a predicate for
        // every parameter it actually receives
        if (f.brand) { parts.push('brand=' + encodeURIComponent(f.brand)); }
        if (f.periodMonth) { parts.push('periodMonth=' + f.periodMonth); }
        if (f.fromDate) { parts.push('fromDate=' + f.fromDate); }
        if (f.toDate) { parts.push('toDate=' + f.toDate); }
        if (f.onlyUnmapped) { parts.push('onlyUnmapped=true'); }
        // scope defaults to true server-side, so only the opt-out needs sending
        if (f.inScopeOnly === false) { parts.push('inScopeOnly=false'); }
        if (f.pageSize) { parts.push('pageSize=' + f.pageSize); }
        return parts.join('&');
    }

    function statusPill(row) {
        if (row.parseStatus !== 'PARSED') {
            return '<span class="oc-pill oc-pill-warn">' + esc(row.parseStatus) + '</span>';
        }
        if (row.unmappedCount > 0) {
            return '<span class="oc-pill oc-pill-danger">' + row.unmappedCount + ' unmapped</span>';
        }
        var offerStatus = row.offerStatus || '';
        var cls = offerStatus === 'ACTIVE' ? 'oc-pill-ok' : 'oc-pill-muted';
        return '<span class="oc-pill ' + cls + '">' + esc(offerStatus || 'PARSED') + '</span>';
    }

    function validity(row) {
        if (!row.startDateRaw && !row.endDateRaw) {
            return '<span class="oc-pill oc-pill-muted">not set</span>';
        }
        return '<span class="oc-mono">' + esc(row.startDateRaw) + '<br>' + esc(row.endDateRaw) + '</span>';
    }

    /** Raw cells wrap to a couple of lines instead of being ellipsised into
     *  uselessness - the whole point of this screen is reading the source text. */
    function raw(value, lines) {
        var text = esc(value);
        if (!text) { return '<span class="oc-pill oc-pill-muted">not set</span>'; }
        return '<span class="oc-raw" style="-webkit-line-clamp:' + (lines || 2)
            + ';" title="' + text + '">' + text + '</span>';
    }

    var STATUS_CLASS = {
        AUTO_EXACT: 'oc-pill-ok', CONFIRMED: 'oc-pill-ok', AUTO_MODEL: 'oc-pill-info',
        REVIEW: 'oc-pill-warn', CATALOG_GAP: 'oc-pill-danger',
        CATEGORY_ALL: 'oc-pill-muted', IGNORED: 'oc-pill-muted'
    };

    // CONFIRMED and IGNORED both came from a human-confirmed exception in
    // offers.product_alias, so say so rather than showing a bare status
    var STATUS_NOTE = {
        CONFIRMED: 'via exception',
        IGNORED: 'ignored by exception'
    };

    /** Products shown as per-status chips, so an unmapped product is visible in the
     *  list without expanding the row. */
    function productChips(row) {
        if (!row.productCount) { return raw(row.eligibleProducts, 2); }
        var bits = [];
        if (row.productCount - row.unmappedCount > 0) {
            bits.push('<span class="oc-pill oc-pill-ok">'
                + (row.productCount - row.unmappedCount) + ' mapped</span>');
        }
        if (row.unmappedCount > 0) {
            bits.push('<span class="oc-pill oc-pill-danger">' + row.unmappedCount + ' unmapped</span>');
        }
        return bits.join(' ') + '<div style="margin-top:4px;">' + raw(row.eligibleProducts, 2) + '</div>';
    }

    function dl(pairs) {
        return pairs.map(function (p) {
            return '<dt>' + esc(p[0]) + '</dt><dd>' + dash(p[1]) + '</dd>';
        }).join('');
    }

    function money(b) {
        if (b.calcType === 'PERCENT') {
            return b.percent + '%' + (b.maxAmount ? ' capped at ' + fmt(b.maxAmount) : ' uncapped');
        }
        if (b.calcType === 'FLAT') { return fmt(b.flatAmount); }
        return '&mdash;';
    }

    function fmt(n) {
        var v = Number(n);
        return isNaN(v) ? esc(n) : 'Rs ' + v.toLocaleString('en-IN',
            { minimumFractionDigits: 0, maximumFractionDigits: 2 });
    }

    var MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
                  'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];

    /**
     * Dates arrive from Jackson as either epoch millis or "yyyy-MM-dd[ HH:mm:ss]"
     * depending on the serialiser, so handle both and render "01 Aug 2026".
     */
    function niceDate(value) {
        if (value === null || value === undefined || value === '') { return null; }
        var d;
        if (typeof value === 'number') {
            d = new Date(value);
        } else {
            var m = String(value).match(/^(\d{4})-(\d{2})-(\d{2})/);
            if (m) {
                d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
            } else if (/^\d+$/.test(String(value))) {
                d = new Date(Number(value));
            } else {
                return esc(value);
            }
        }
        if (isNaN(d.getTime())) { return esc(value); }
        return ('0' + d.getDate()).slice(-2) + ' ' + MONTHS[d.getMonth()] + ' ' + d.getFullYear();
    }

    function dayDiff(value) {
        var m = String(value).match(/^(\d{4})-(\d{2})-(\d{2})/);
        var end = m ? new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]))
                    : new Date(Number(value));
        if (isNaN(end.getTime())) { return null; }
        var today = new Date();
        today.setHours(0, 0, 0, 0);
        return Math.round((end - today) / 86400000);
    }

    /** "01 Aug 2026 to 26 Sep 2026 ยท live, 44 days left" */
    function validityLine(row) {
        var from = niceDate(row.startDate), to = niceDate(row.endDate);
        if (!from && !to) {
            return '<span class="oc-pill oc-pill-warn">dates not parsed</span>';
        }
        var left = dayDiff(row.endDate);
        var state;
        if (left === null) {
            state = '';
        } else if (left < 0) {
            state = ' <span class="oc-pill oc-pill-muted">expired ' + Math.abs(left) + ' day(s) ago</span>';
        } else if (left === 0) {
            state = ' <span class="oc-pill oc-pill-warn">last day</span>';
        } else {
            state = ' <span class="oc-pill oc-pill-ok">live, ' + left + ' day(s) left</span>';
        }
        return '<strong>' + from + '</strong> to <strong>' + to + '</strong>' + state;
    }

    /** The comparison that makes this screen worth having: the nine verbatim PDF
     *  columns beside the rows the parser actually produced from them. */
    function detailRow(row) {
        var original = dl([
            ['OEM Name', row.oemName],
            ['Additional Cashback', row.additionalCashback],
            ['Offer Description', row.offerDescription],
            ['EMI tenure', row.emiTenure],
            ['Debit Cards', row.debitCards],
            ['Credit cards', row.creditCards],
            ['Eligible products', row.eligibleProducts],
            ['Start Date', row.startDateRaw],
            ['End Date', row.endDateRaw]
        ]);
        return '<tr class="oc-detail-row"><td colspan="11" class="oc-detail">'
            + '<div class="oc-detail-grid">'
            + '<div><div class="oc-detail-title">Original row as printed &mdash; page '
            + esc(row.pageNo) + ', row ' + esc(row.rowNo)
            + '</div><dl class="oc-dl">' + original + '</dl></div>'
            + '<div><div class="oc-detail-title">What the parser produced</div>'
            + '<div data-oc-parsed="' + esc(row.offerId) + '" class="oc-parsed">'
            + 'Loading parsed rows&hellip;</div></div>'
            + '</div></td></tr>';
    }

    function renderParsed(container, row, d) {
        function tbl(title, headers, bodyRows) {
            if (!bodyRows.length) {
                return '<div class="oc-sub"><span class="oc-sub-h">' + title + '</span>'
                    + '<span class="oc-pill oc-pill-warn">nothing parsed</span></div>';
            }
            return '<div class="oc-sub"><span class="oc-sub-h">' + title
                + ' <span class="oc-num">(' + bodyRows.length + ')</span></span>'
                + '<table class="oc-mini"><thead><tr>'
                + headers.map(function (h) { return '<th>' + h + '</th>'; }).join('')
                + '</tr></thead><tbody>' + bodyRows.join('') + '</tbody></table></div>';
        }

        var benefits = (d.benefits || []).map(function (b) {
            return '<tr><td><code>' + esc(b.txnMode) + '</code></td>'
                + '<td>' + esc(b.calcType) + '</td><td><strong>' + money(b) + '</strong></td></tr>';
        });

        var byScheme = {};
        (d.tenures || []).forEach(function (t) {
            (byScheme[t.emiScheme] = byScheme[t.emiScheme] || []).push(t.tenureMonths);
        });
        var tenures = Object.keys(byScheme).map(function (s) {
            return '<tr><td><code>' + esc(s) + '</code></td><td>'
                + byScheme[s].join(', ') + ' months</td></tr>';
        });

        var byCard = {};
        (d.banks || []).forEach(function (b) {
            (byCard[b.cardType] = byCard[b.cardType] || []).push(
                (b.inclusion === 'EXCLUDE' ? 'NOT ' : '') + b.code);
        });
        var banks = Object.keys(byCard).map(function (c) {
            return '<tr><td><code>' + esc(c) + '</code></td><td>' + byCard[c].join(', ') + '</td></tr>';
        });

        var products = (d.products || []).map(function (p) {
            var cls = STATUS_CLASS[p.matchStatus] || 'oc-pill-muted';
            var note = STATUS_NOTE[p.matchStatus];
            var to = p.catalogVariant || p.catalogModel;
            var resolved;
            if (p.matchStatus === 'IGNORED') {
                resolved = '<em>not stocked - ignored</em>';
            } else if (to) {
                resolved = esc(to)
                    + (p.catalogId ? ' <span class="oc-num">#' + p.catalogId + '</span>' : '')
                    + (p.matchLevel === 'MODEL' ? ' <span class="oc-num">(all variants)</span>' : '');
            } else {
                resolved = '<em>not in catalog</em>';
            }
            return '<tr><td class="oc-mono">' + esc(p.rawText) + '</td>'
                + '<td><span class="oc-pill ' + cls + '">' + esc(p.matchStatus) + '</span>'
                + (note ? ' <span class="oc-num">' + note + '</span>' : '')
                + (p.matchScore && p.matchStatus === 'REVIEW'
                    ? ' <span class="oc-num">' + p.matchScore + '</span>' : '')
                + '</td><td>' + resolved + '</td></tr>';
        });

        var channels = (d.channels || []).map(function (c) {
            return '<tr><td><span class="oc-pill '
                + (c.inclusion === 'EXCLUDE' ? 'oc-pill-danger' : 'oc-pill-ok') + '">'
                + esc(c.inclusion) + '</span></td><td>' + esc(c.channelRef) + '</td></tr>';
        });

        var html = '<div class="oc-sub"><span class="oc-sub-h">Validity</span>'
                 + validityLine(row) + '</div>'
                 + tbl('Cashback by payment mode', ['Mode', 'Type', 'Value'], benefits)
            + tbl('EMI tenures', ['Scheme', 'Months'], tenures)
            + tbl('Banks', ['Card', 'Issuers'], banks)
            + tbl('Products', ['As printed', 'Match', 'Resolved to'], products);
        if (channels.length) {
            html += tbl('Store restrictions', ['Rule', 'Retailer'], channels);
        }
        if (row.parseError) {
            html += '<div class="oc-sub"><span class="oc-pill oc-pill-danger">parse error</span> '
                + esc(row.parseError) + '</div>';
        }
        container.html(html);
    }

    function render() {
        var r = root();
        var body = r.find('[data-oc-body]');
        if (state.rows.length === 0) {
            body.html('<tr><td colspan="11" class="oc-empty">No circular rows match these filters.</td></tr>');
        } else {
            body.html(state.rows.map(function (row, i) {
                return '<tr data-oc-row="' + i + '">'
                    + '<td class="oc-num">' + esc(row.pageNo) + '/' + esc(row.rowNo) + '</td>'
                    + '<td><strong>' + esc(row.oemName) + '</strong></td>'
                    + '<td>' + dash(row.brand) + '</td>'
                    + '<td>' + dash(row.category) + '</td>'
                    + '<td>' + dash(row.additionalCashback) + '</td>'
                    + '<td>' + raw(row.offerDescription, 3) + '</td>'
                    + '<td class="oc-mono">' + raw(row.emiTenure, 2) + '</td>'
                    + '<td>' + productChips(row) + '</td>'
                    + '<td>' + validity(row) + '</td>'
                    + '<td>' + statusPill(row) + '</td>'
                    + '<td><button class="oc-expand" type="button" data-oc-expand="' + i + '">View raw</button></td>'
                    + '</tr>';
            }).join(''));
        }

        var first = state.total === 0 ? 0 : ((state.page - 1) * state.pageSize) + 1;
        var last = Math.min(state.page * state.pageSize, state.total);
        r.find('[data-oc-range]').text(state.total === 0 ? '' : first + '-' + last + ' of ' + state.total);
        r.find('[data-oc-page]').text('Page ' + state.page + ' of ' + state.pages);
        r.find('[data-oc-summary]').text(state.total + ' row(s) matched');
        r.find('[data-oc-prev]').prop('disabled', state.page <= 1);
        r.find('[data-oc-next]').prop('disabled', state.page >= state.pages);

        var parsed = 0, pending = 0;
        state.rows.forEach(function (row) {
            if (row.parseStatus === 'PARSED' && !row.unmappedCount) { parsed++; }
            if (row.parseStatus !== 'PARSED' || row.unmappedCount > 0) { pending++; }
        });
        r.find('[data-oc-stat="total"]').text(state.total);
        r.find('[data-oc-stat="parsed"]').text(parsed);
        r.find('[data-oc-stat="pending"]').text(pending);
    }

    function loadRows() {
        root().find('[data-oc-body]')
            .html('<tr><td colspan="11" class="oc-spin">Loading circular rows...</td></tr>');
        ocGet(ctx() + '/offerCircular/rawRows?' + queryString(), function (response) {
            var payload;
            try {
                payload = typeof response === 'string' ? JSON.parse(response) : response;
            } catch (e) {
                root().find('[data-oc-body]').html('<tr><td colspan="11" class="oc-empty">'
                    + 'The server replied but the response was not readable JSON. '
                    + 'Open /offerCircular/rawRows directly to see it.</td></tr>');
                return;
            }
            state.rows = payload.rows || [];
            state.total = payload.total || 0;
            state.pages = payload.pages || 1;
            state.page = payload.page || 1;
            state.pageSize = payload.pageSize || 50;

            state.documents = payload.documents || state.documents || [];
            if (payload.documents) {
                var docSel = root().find('[data-oc-doc]');
                if (docSel.find('option').filter(function () { return this.value !== ''; }).length === 0) {
                    docSel.empty();
                    payload.documents.forEach(function (doc) {
                        docSel.append($('<option>').attr('value', doc.documentId)
                            .attr('data-downloadable', doc.downloadable ? '1' : '0')
                            .attr('data-period', doc.periodMonth || '')
                            .text(doc.title + '  (' + doc.rowCount + ' rows, ' + doc.status + ')'));
                    });
                    if (payload.documents.length === 0) {
                        docSel.append($('<option>').attr('value', '')
                            .text('No circulars loaded - upload a PDF, then run the ingest'));
                    }
                }
            }

            if (!state.brandsLoaded && payload.brands) {
                var select = root().find('[data-oc-filter="brand"]');
                var current = select.val();
                select.find('option').not(':first').remove();
                payload.brands.forEach(function (b) {
                    if (b) { select.append($('<option>').attr('value', b).text(b)); }
                });
                select.val(current);
                root().find('[data-oc-stat="brands"]').text(payload.brands.filter(Boolean).length);
                state.brandsLoaded = true;
            }
            render();
            renderIngestState();
            schedulePoll();
            // an empty result while circulars exist is nearly always a filter
            // mismatch, so name the months that actually hold rows
            if (state.total === 0 && payload.documents && payload.documents.length > 0) {
                root().find('[data-oc-summary]').text(
                    'No rows matched these filters. Loaded circulars: ' +
                    payload.documents.map(function (d) {
                        return (d.periodMonth || '?') + ' - ' + d.rowCount + ' rows';
                    }).join(', ') + '. Clear the filters to see everything.');
            }
        }, function (xhr) {
            // never leave the table sitting on "Loading..." - say what broke
            root().find('[data-oc-body]').html('<tr><td colspan="11" class="oc-empty">'
                + 'Could not load circular rows: ' + failMessage(xhr) + '.</td></tr>');
            root().find('[data-oc-doc]').empty()
                .append($('<option>').attr('value', '').text('Could not load circulars'));
        });
    }

    // sidebar entry / any trigger loads the fragment then initialises it
    function openOfferCircularReview(domId) {
        ocGet(ctx() + '/offerCircularReview', function (response) {
            $('#' + (domId || 'main-content')).html(response);
            state = { page: 1, rows: [], total: 0, pages: 1, pageSize: 50, brandsLoaded: false,
                      decisions: [], huntTimer: null, documents: [], pollTimer: null };
            loadRows();
        });
    }
    window.openOfferCircularReview = openOfferCircularReview;

    $(document).on('click', '.offer-circular-review', function (e) {
        e.preventDefault();
        openOfferCircularReview('main-content');
    });

    $(document).on('click', '.oc-root [data-oc-expand]', function () {
        var index = $(this).data('oc-expand');
        var tr = root().find('[data-oc-row="' + index + '"]');
        var next = tr.next('.oc-detail-row');
        if (next.length) {
            next.remove();
            $(this).text('View raw');
        } else {
            var row = state.rows[index];
            tr.after(detailRow(row));
            $(this).text('Hide raw');
            var box = root().find('[data-oc-parsed="' + row.offerId + '"]').last();
            if (!row.offerId) {
                box.html('<span class="oc-pill oc-pill-warn">not parsed</span> '
                    + 'This row has no offer record yet, so there is nothing to compare.');
                return;
            }
            ocGet(ctx() + '/offerCircular/offerDetail?offerId=' + row.offerId,
                function (response) {
                    var d;
                    try { d = typeof response === 'string' ? JSON.parse(response) : response; }
                    catch (e) { box.text('Could not read the parsed rows.'); return; }
                    renderParsed(box, row, d);
                },
                function (xhr) { box.text('Could not load parsed rows: ' + failMessage(xhr)); });
        }
    });

    $(document).on('change', '.oc-root [data-oc-filter]', function () {
        state.page = 1;
        loadRows();
    });

    $(document).on('click', '.oc-root [data-oc-prev]', function () {
        if (state.page > 1) { state.page--; loadRows(); }
    });

    $(document).on('click', '.oc-root [data-oc-next]', function () {
        if (state.page < state.pages) { state.page++; loadRows(); }
    });

    $(document).on('click', '.oc-root [data-oc-reload]', function () {
        loadRows();
    });

    $(document).on('click', '.oc-root [data-oc-clear]', function () {
        root().find('[data-oc-filter]').each(function () {
            var el = $(this);
            if (el.is(':checkbox')) { el.prop('checked', false); } else { el.val(''); }
        });
        root().find('[data-oc-filter="pageSize"]').val('50');
        state.page = 1;
        loadRows();
    });

    // picking a circular filters the table to that circular's month, which is what
    // a reviewer expects the selector to do
    $(document).on('change', '.oc-root [data-oc-doc]', function () {
        var period = $(this).find('option:selected').attr('data-period') || '';
        root().find('[data-oc-filter="periodMonth"]').val(period ? period.substring(0, 10) : '');
        state.page = 1;
        loadRows();
        // the decision queue is scoped to the selected circular, so it moves with it
        if (root().find('[data-oc-resolve-wrap]').is(':visible')) {
            loadResolveQueue();
        }
    });

    // download hands back the exact PDF that was uploaded for the selected circular
    $(document).on('click', '.oc-root [data-oc-download]', function () {
        var opt = root().find('[data-oc-doc]').find('option:selected');
        var documentId = opt.val();
        if (!documentId) {
            alert('Select a circular first');
            return;
        }
        if (opt.attr('data-downloadable') !== '1') {
            alert('The source PDF was not retained for this circular, so it cannot be downloaded.');
            return;
        }
        window.location = ctx() + '/offerCircular/download?documentId=' + documentId;
    });

    $(document).on('click', '.oc-root [data-oc-upload]', function () {
        var input = root().find('[data-oc-upload-file]')[0];
        var month = root().find('[data-oc-upload-month]').val();
        var msg = root().find('[data-oc-upload-msg]');
        if (!input || !input.files || input.files.length === 0) {
            msg.text('Choose a PDF first.');
            return;
        }
        if (!month) {
            msg.text('Set the month this circular is for.');
            return;
        }
        var form = new FormData();
        form.append('file', input.files[0]);
        form.append('periodMonth', month);

        var btn = $(this).prop('disabled', true).text('Uploading...');
        msg.text('');
        $.ajax({
            url: ctx() + '/offerCircular/upload?periodMonth=' + month,
            type: 'POST',
            data: form,
            processData: false,
            contentType: false,
            success: function (response) {
                btn.prop('disabled', false).text('Upload');
                var r;
                try { r = typeof response === 'string' ? JSON.parse(response) : response; }
                catch (e) { msg.text('Upload finished but the response could not be read.'); return; }
                msg.text(r.message || '');
                if (r.success) {
                    input.value = '';
                    root().find('[data-oc-doc]').empty();
                    loadRows();
                }
            },
            error: function (xhr) {
                btn.prop('disabled', false).text('Upload');
                msg.text('Upload failed: ' + (xhr.status === 403 ? 'not authorised' : xhr.status));
            }
        });
    });

    // exports the rows currently on screen with the nine original columns intact
    $(document).on('click', '.oc-root [data-oc-export]', function () {
        var cols = ['pageNo', 'rowNo', 'oemName', 'brand', 'category', 'additionalCashback',
            'offerDescription', 'emiTenure', 'debitCards', 'creditCards', 'eligibleProducts',
            'startDateRaw', 'endDateRaw', 'parseStatus', 'offerStatus', 'productCount', 'unmappedCount'];
        var lines = [cols.join(',')];
        state.rows.forEach(function (row) {
            lines.push(cols.map(function (c) {
                var v = row[c];
                return '"' + String(v === null || v === undefined ? '' : v).replace(/"/g, '""') + '"';
            }).join(','));
        });
        var blob = new Blob([lines.join('\n')], { type: 'text/csv;charset=utf-8;' });
        var a = document.createElement('a');
        a.href = URL.createObjectURL(blob);
        a.download = 'offer-circular-rows.csv';
        a.click();
        URL.revokeObjectURL(a.href);
    });

    // ------------------------------------------------------------------ curation
    //
    // Everything here writes offers.product_alias only. An alias is keyed on
    // (OEM division, raw product text), so one answer resolves every row carrying that
    // text - this circular and every later one. That is why the queue is grouped by
    // distinct text: listing per row would ask the same question repeatedly.

    function ocPost(url, data, onDone, onFail) {
        $.ajax({ url: url, type: 'POST', data: data })
            .done(function (response) {
                var r;
                try { r = typeof response === 'string' ? JSON.parse(response) : response; }
                catch (e) { r = { success: false, message: 'Unreadable server response' }; }
                onDone(r);
            })
            .fail(function (xhr) {
                if (onFail) { onFail(xhr); }
            });
    }

    function resolveMsg(text) {
        root().find('[data-oc-resolve-msg]').text(text || '');
    }

    function currentDocumentId() {
        return root().find('[data-oc-doc]').val() || '';
    }

    function decisionCell(d) {
        if (!d.aliasAction) {
            return '<span class="oc-pill oc-pill-warn">undecided</span>';
        }
        var target = d.aliasCanonicalText
            || (d.aliasCatalogId ? '#' + d.aliasCatalogId : '')
            || (d.aliasSuperCatalogId ? 'model #' + d.aliasSuperCatalogId : '');
        // "pending" is literal, not decorative: ProductAliases is read at ingest time,
        // so a saved decision genuinely has not changed anything yet
        return '<span class="oc-pill oc-pill-info">' + esc(d.aliasAction) + '</span> '
            + (target ? '<span class="oc-num">' + esc(target) + '</span> ' : '')
            + '<span class="oc-num">pending re-ingest</span>';
    }

    function renderResolveQueue() {
        var body = root().find('[data-oc-resolve-body]');
        if (!state.decisions.length) {
            body.html('<tr><td colspan="6" class="oc-empty">'
                + 'Nothing left to resolve for this circular.</td></tr>');
            return;
        }
        body.html(state.decisions.map(function (d, i) {
            var coverage = d.decisionType === 'COVERAGE';
            var miss = d.closestCatalogName
                ? '<span class="oc-miss">' + esc(d.closestCatalogName)
                    + ' <span class="oc-num">#' + esc(d.closestCatalogId)
                    + ' &middot; ' + esc(d.closestScore) + '</span></span>'
                : '<span class="oc-miss">no candidate</span>';
            // A bundle offer's transfer to the bare SKU is a coverage decision, and an
            // alias cannot express it - parked rather than offered a naming answer.
            var action = coverage
                ? '<span class="oc-pill oc-pill-muted">coverage decision</span>'
                : '<button class="oc-expand" type="button" data-oc-resolve="' + i + '">'
                    + (d.aliasAction ? 'Change' : 'Resolve') + '</button>';
            return '<tr data-oc-decision="' + i + '">'
                + '<td class="oc-mono">' + esc(d.rawText) + '</td>'
                + '<td>' + esc(d.divisionLabel) + '</td>'
                + '<td class="oc-num">' + esc(d.rowCount) + ' row(s), '
                + esc(d.offerCount) + ' offer(s)</td>'
                + '<td>' + miss + '</td>'
                + '<td>' + (coverage
                    ? '<span class="oc-miss">bundle &mdash; needs a coverage call, '
                        + 'not a name</span>'
                    : decisionCell(d)) + '</td>'
                + '<td>' + action + '</td>'
                + '</tr>';
        }).join(''));
    }

    function editorRow(d, index) {
        var pinned = d.aliasCatalogId || '';
        return '<tr class="oc-detail-row"><td colspan="6" class="oc-edit" '
            + 'data-oc-editor="' + index + '">'
            + '<div class="oc-edit-actions">'
            + '<label><input type="radio" name="oc-act-' + index + '" value="PIN" '
            + (d.aliasAction === 'PIN' || !d.aliasAction ? 'checked' : '')
            + ' data-oc-act /> Pin to a catalog product</label>'
            + '<label><input type="radio" name="oc-act-' + index + '" value="REWRITE" '
            + (d.aliasAction === 'REWRITE' ? 'checked' : '')
            + ' data-oc-act /> Rewrite to the catalog&rsquo;s spelling</label>'
            + '<label><input type="radio" name="oc-act-' + index + '" value="IGNORE" '
            + (d.aliasAction === 'IGNORE' ? 'checked' : '')
            + ' data-oc-act /> Not ours &mdash; ignore</label>'
            + '</div>'
            + '<div data-oc-pane="PIN">'
            + '  <div class="oc-edit-row">'
            + '    <div class="oc-field" style="flex:1;min-width:240px;">'
            + '      <label>Find the product in ' + esc(d.divisionLabel) + '</label>'
            + '      <input class="oc-input" type="text" data-oc-hunt style="width:100%;" '
            + '             placeholder="type part of the model name" />'
            + '    </div>'
            + '    <span class="oc-num" data-oc-picked>'
            + (pinned ? 'pinned to #' + esc(pinned) : 'nothing selected') + '</span>'
            + '  </div>'
            + '  <div class="oc-hits" data-oc-hits>'
            + '    <div style="padding:8px 10px;" class="oc-miss">'
            + '      Search to list this OEM&rsquo;s catalog products.</div>'
            + '  </div>'
            + '</div>'
            + '<div data-oc-pane="REWRITE" style="display:none;">'
            + '  <div class="oc-edit-row"><div class="oc-field" style="flex:1;min-width:260px;">'
            + '    <label>Catalog spelling (the variant spec is carried over automatically)</label>'
            + '    <input class="oc-input" type="text" data-oc-canonical style="width:100%;" '
            + '           value="' + esc(d.aliasCanonicalText || '') + '" '
            + '           placeholder="e.g. iPhone 17 Air" />'
            + '  </div></div>'
            + '</div>'
            + '<div class="oc-edit-row">'
            + '  <div class="oc-field" style="flex:1;min-width:260px;">'
            + '    <label>Why (optional, but it is what the next reviewer reads)</label>'
            + '    <input class="oc-input" type="text" data-oc-note style="width:100%;" />'
            + '  </div>'
            + '  <button class="oc-btn" type="button" data-oc-save="' + index + '">Save decision</button>'
            + '  <button class="oc-btn oc-btn-ghost" type="button" data-oc-cancel>Cancel</button>'
            + (d.aliasAction
                ? '  <button class="oc-btn oc-btn-ghost" type="button" data-oc-forget="'
                    + index + '">Remove decision</button>'
                : '')
            + '</div>'
            + '</td></tr>';
    }

    function loadResolveQueue() {
        var documentId = currentDocumentId();
        ocGet(ctx() + '/offerCircular/resolveQueue'
                + (documentId ? '?documentId=' + documentId : ''),
            function (response) {
                var payload;
                try { payload = typeof response === 'string' ? JSON.parse(response) : response; }
                catch (e) {
                    root().find('[data-oc-resolve-body]').html('<tr><td colspan="6" '
                        + 'class="oc-empty">Could not read the decision queue.</td></tr>');
                    return;
                }
                state.decisions = payload.decisions || [];
                root().find('[data-oc-resolve-count]').text(
                    payload.naming + ' naming, ' + payload.coverage + ' coverage');
                root().find('[data-oc-resolve-pending]').text(
                    payload.pendingReingest
                        ? payload.pendingReingest + ' saved, awaiting re-ingest' : '');
                renderResolveQueue();
            },
            function (xhr) {
                root().find('[data-oc-resolve-body]').html('<tr><td colspan="6" '
                    + 'class="oc-empty">Could not load decisions: '
                    + failMessage(xhr) + '.</td></tr>');
            });
    }

    $(document).on('click', '.oc-root [data-oc-resolve-toggle]', function () {
        var wrap = root().find('[data-oc-resolve-wrap]');
        var showing = wrap.is(':visible');
        wrap.toggle(!showing);
        $(this).text(showing ? 'Show queue' : 'Hide queue');
        if (!showing) { loadResolveQueue(); }
    });

    $(document).on('click', '.oc-root [data-oc-resolve]', function () {
        var index = $(this).data('oc-resolve');
        var tr = root().find('[data-oc-decision="' + index + '"]');
        var next = tr.next('.oc-detail-row');
        if (next.length) {
            next.remove();
            return;
        }
        root().find('[data-oc-resolve-body] .oc-detail-row').remove();
        tr.after(editorRow(state.decisions[index], index));
    });

    $(document).on('click', '.oc-root [data-oc-cancel]', function () {
        $(this).closest('.oc-detail-row').remove();
    });

    // switching action shows only the fields that action actually needs
    $(document).on('change', '.oc-root [data-oc-act]', function () {
        var cell = $(this).closest('[data-oc-editor]');
        var chosen = $(this).val();
        cell.find('[data-oc-pane]').each(function () {
            $(this).toggle($(this).attr('data-oc-pane') === chosen);
        });
    });

    $(document).on('input', '.oc-root [data-oc-hunt]', function () {
        var cell = $(this).closest('[data-oc-editor]');
        var index = cell.attr('data-oc-editor');
        var decision = state.decisions[index];
        var term = $(this).val();
        var hits = cell.find('[data-oc-hits]');
        if (!term || term.length < 2) {
            hits.html('<div style="padding:8px 10px;" class="oc-miss">'
                + 'Type at least two characters.</div>');
            return;
        }
        clearTimeout(state.huntTimer);
        state.huntTimer = setTimeout(function () {
            ocGet(ctx() + '/offerCircular/catalogSearch?divisionLabel='
                    + encodeURIComponent(decision.divisionLabel)
                    + '&q=' + encodeURIComponent(term),
                function (response) {
                    var list;
                    try { list = typeof response === 'string' ? JSON.parse(response) : response; }
                    catch (e) { list = []; }
                    if (!list.length) {
                        hits.html('<div style="padding:8px 10px;" class="oc-miss">'
                            + 'No catalog product matches, in this OEM&rsquo;s brand and '
                            + 'category. If the SKU genuinely does not exist, the right '
                            + 'answer is Ignore, not a near-miss pin.</div>');
                        return;
                    }
                    hits.html(list.map(function (c) {
                        return '<button type="button" class="oc-hit" data-oc-pick="'
                            + c.catalogId + '" data-oc-super="' + (c.superCatalogId || '')
                            + '">' + esc(c.name)
                            + ' <span class="oc-num">#' + c.catalogId + '</span>'
                            + (c.superCatalogName
                                ? ' <span class="oc-num">&middot; ' + esc(c.superCatalogName)
                                    + '</span>' : '')
                            + '</button>';
                    }).join(''));
                },
                function (xhr) {
                    hits.html('<div style="padding:8px 10px;" class="oc-miss">'
                        + 'Search failed: ' + failMessage(xhr) + '</div>');
                });
        }, 250);
    });

    $(document).on('click', '.oc-root [data-oc-pick]', function () {
        var cell = $(this).closest('[data-oc-editor]');
        cell.find('[data-oc-pick]').attr('data-picked', '0');
        $(this).attr('data-picked', '1');
        cell.find('[data-oc-picked]').text('pinned to #' + $(this).data('oc-pick'));
    });

    $(document).on('click', '.oc-root [data-oc-save]', function () {
        var index = $(this).data('oc-save');
        var decision = state.decisions[index];
        var cell = root().find('[data-oc-editor="' + index + '"]');
        var action = cell.find('[data-oc-act]:checked').val();

        var payload = {
            divisionLabel: decision.divisionLabel,
            rawText: decision.rawText,
            action: action,
            note: cell.find('[data-oc-note]').val() || ''
        };
        if (action === 'PIN') {
            var picked = cell.find('[data-oc-pick][data-picked="1"]');
            if (!picked.length) {
                resolveMsg('Pick a catalog product first.');
                return;
            }
            payload.catalogId = picked.data('oc-pick');
            payload.matchLevel = 'VARIANT';
        } else if (action === 'REWRITE') {
            payload.canonicalText = cell.find('[data-oc-canonical]').val() || '';
        }

        var btn = $(this).prop('disabled', true).text('Saving...');
        ocPost(ctx() + '/offerCircular/alias', payload, function (r) {
            btn.prop('disabled', false).text('Save decision');
            resolveMsg(r.message || '');
            if (r.success) {
                cell.closest('.oc-detail-row').remove();
                loadResolveQueue();
            }
        }, function (xhr) {
            btn.prop('disabled', false).text('Save decision');
            resolveMsg('Could not save: ' + failMessage(xhr));
        });
    });

    $(document).on('click', '.oc-root [data-oc-forget]', function () {
        var decision = state.decisions[$(this).data('oc-forget')];
        ocPost(ctx() + '/offerCircular/aliasRemove',
            { divisionLabel: decision.divisionLabel, rawText: decision.rawText },
            function (r) {
                resolveMsg(r.message || '');
                root().find('[data-oc-resolve-body] .oc-detail-row').remove();
                loadResolveQueue();
            },
            function (xhr) { resolveMsg('Could not remove: ' + failMessage(xhr)); });
    });

    $(document).on('click', '.oc-root [data-oc-alias-export]', function () {
        window.location = ctx() + '/offerCircular/aliasExport';
    });

    $(document).on('click', '.oc-root [data-oc-reingest]', function () {
        var documentId = currentDocumentId();
        if (!documentId) {
            resolveMsg('Select a circular first.');
            return;
        }
        if (!window.confirm('Re-parse this circular so the saved decisions take effect?\n\n'
                + 'Everything derived from it is rebuilt from the stored PDF. Offer ids '
                + 'change, which nothing depends on today.')) {
            return;
        }
        var btn = $(this).prop('disabled', true).text('Queueing...');
        ocPost(ctx() + '/offerCircular/reingest', { documentId: documentId }, function (r) {
            btn.prop('disabled', false).text('Re-ingest to apply');
            resolveMsg(r.message || '');
        }, function (xhr) {
            btn.prop('disabled', false).text('Re-ingest to apply');
            resolveMsg('Could not queue re-ingest: ' + failMessage(xhr));
        });
    });

    // ------------------------------------------------------- ingest status polling
    //
    // Parsing runs on a background thread in this application and takes seconds, but
    // it is still asynchronous, so the screen must say where it has got to. Without
    // this a reviewer cannot tell "not parsed yet" from "parsed and produced nothing".

    var PENDING_STATES = { DRAFT: 'about to parse', PROCESSING: 'parsing now' };

    function selectedDoc() {
        var id = currentDocumentId();
        if (!id) { return null; }
        for (var i = 0; i < (state.documents || []).length; i++) {
            if (String(state.documents[i].documentId) === String(id)) {
                return state.documents[i];
            }
        }
        return null;
    }

    /**
     * The ingest summary is a Java Map.toString():
     *   {rowsInPdf=334, offers=298, ...} products={AUTO_EXACT=332, ...} dropped={...} warnings=1157
     * One unbreakable line that ran off the card. Parsed into chips instead, with the
     * drop reasons - by far the longest part, and the part you only want when a number
     * looks wrong - behind a disclosure.
     */
    function parsePairs(text) {
        var out = [];
        (text || '').split(', ').forEach(function (part) {
            var at = part.lastIndexOf('=');
            if (at > 0) {
                out.push({ key: part.slice(0, at).trim(), value: part.slice(at + 1).trim() });
            }
        });
        return out;
    }

    var COUNT_LABEL = {
        rowsInPdf: 'rows in PDF', rowsLoaded: 'loaded', offers: 'offers',
        benefits: 'benefits', tenures: 'tenures', banks: 'bank links',
        products: 'products', channelScopes: 'channel scopes', conditions: 'conditions'
    };

    function chip(label, value, warn) {
        return '<span class="oc-sum-chip' + (warn ? ' oc-sum-chip-warn' : '') + '">'
            + '<b>' + esc(value) + '</b> ' + esc(label) + '</span>';
    }

    function summaryHtml(summary) {
        if (!summary) { return ''; }
        var counts = /^\{([^}]*)\}/.exec(summary);
        var products = /products=\{([^}]*)\}/.exec(summary);
        var dropped = /dropped=\{([^}]*)\}/.exec(summary);
        var warnings = /warnings=(\d+)/.exec(summary);

        // nothing recognised - show it, but let it wrap instead of overflowing
        if (!counts) {
            return '<span class="oc-num oc-sum-raw">' + esc(summary) + '</span>';
        }

        var html = '<span class="oc-sum">';
        parsePairs(counts[1]).forEach(function (p) {
            // benefits/tenures/banks at zero means the masters are unseeded - the ingest
            // reports what it TRIED to write, so a zero here is worth flagging, not hiding
            var zeroMoney = p.value === '0'
                && (p.key === 'benefits' || p.key === 'tenures' || p.key === 'banks');
            html += chip(COUNT_LABEL[p.key] || p.key, p.value, zeroMoney);
        });
        if (products) {
            parsePairs(products[1]).forEach(function (p) {
                var needsWork = p.key === 'REVIEW' || p.key === 'CATALOG_GAP';
                html += chip(p.key.toLowerCase().replace(/_/g, ' '), p.value, needsWork);
            });
        }
        if (warnings) { html += chip('warnings', warnings[1], false); }

        var drops = dropped ? parsePairs(dropped[1]) : [];
        if (drops.length) {
            html += '<button type="button" class="oc-sum-toggle" data-oc-sum-toggle>'
                 + drops.length + ' drop reasons</button>';
        }
        html += '</span>';

        if (drops.length) {
            html += '<div class="oc-sum-detail" data-oc-sum-detail>'
                 + drops.sort(function (a, b) { return Number(b.value) - Number(a.value); })
                        .map(function (d) {
                            return '<div><span>' + esc(d.key) + '</span>'
                                 + '<span>' + esc(d.value) + '</span></div>';
                        }).join('')
                 + '</div>';
        }
        return html;
    }

    $(document).on('click', '.oc-root [data-oc-sum-toggle]', function () {
        var detail = root().find('[data-oc-sum-detail]');
        var showing = detail.is(':visible');
        detail.toggle(!showing);
    });

    function renderIngestState() {
        var box = root().find('[data-oc-ingest-state]');
        var doc = selectedDoc();
        if (!doc) { box.empty(); return; }

        if (PENDING_STATES[doc.status]) {
            box.html('<span class="oc-pill oc-pill-warn">' + esc(PENDING_STATES[doc.status])
                + '</span> <span class="oc-num">usually a few seconds; '
                + 'this refreshes itself</span>');
        } else if (doc.status === 'FAILED') {
            box.html('<span class="oc-pill oc-pill-danger">ingest failed</span> '
                + '<span class="oc-num oc-sum-raw">'
                + esc(doc.ingestError || 'no reason recorded') + '</span>');
        } else if (doc.status === 'PUBLISHED') {
            box.html('<span class="oc-pill oc-pill-ok">parsed</span> '
                + summaryHtml(doc.ingestSummary));
        } else {
            box.html('<span class="oc-pill oc-pill-muted">' + esc(doc.status) + '</span>');
        }
    }

    function schedulePoll() {
        clearTimeout(state.pollTimer);
        var doc = selectedDoc();
        if (!doc || !PENDING_STATES[doc.status]) {
            return;                      // settled - stop polling, do not spin forever
        }
        state.pollTimer = setTimeout(function () {
            ocGet(ctx() + '/offerCircular/documents', function (response) {
                var docs;
                try { docs = typeof response === 'string' ? JSON.parse(response) : response; }
                catch (e) { return; }
                var was = selectedDoc();
                state.documents = docs || [];
                var now = selectedDoc();
                renderIngestState();
                // it just finished: pull in the rows the parse produced
                if (was && now && was.status !== now.status && !PENDING_STATES[now.status]) {
                    loadRows();
                    return;
                }
                schedulePoll();
            }, function () { schedulePoll(); });   // transient failure - keep waiting
        }, 3000);
    }

    // direct navigation: the fragment is already on the page
    if (root().length > 0) {
        loadRows();
    }
});