Subversion Repositories SmartDukaan

Rev

Blame | Last modification | View Log | RSS feed

/**
 * Offer circular - resolve products.
 *
 * The product-resolution queue, split out of the review screen into its own page. The
 * editor used to be an expanded table row, which is why it never lined up: a form was
 * being laid out inside a grid built for six columns. Here the queue is the list and the
 * decision is a panel beside it.
 *
 * Namespaced .ocr-* / data-ocr-* throughout. The review screen delegates its handlers
 * from document on .oc-root, so sharing a prefix would let both screens' handlers fire on
 * whichever fragment happened to be injected.
 *
 * A decision writes offers.product_alias, which is read at INGEST time - so nothing on
 * this screen changes what a circular currently says until it is re-ingested.
 */
$(function () {

    var state = { decisions: [], tab: 'NAMING', selected: null, documents: [],
                  brand: '', huntTimer: null, picked: null, asNaming: {} };

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

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

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

    function ocrGet(url, onDone, onFail) {
        $.ajax({ url: url, type: 'GET', cache: false }).done(onDone).fail(onFail || function () {});
    }

    function ocrPost(url, data, onDone, onFail) {
        $.ajax({ url: url, type: 'POST', data: data }).done(onDone).fail(onFail || function () {});
    }

    function payloadOf(response) {
        if (typeof response === 'string') {
            try { return JSON.parse(response); } catch (e) { return null; }
        }
        return response;
    }

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

    function msg(text, bad) {
        root().find('[data-ocr-msg]')
              .attr('class', 'ocr-msg ' + (text ? (bad ? 'ocr-msg-bad' : 'ocr-msg-ok') : ''))
              .text(text || '');
    }

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

    // ------------------------------------------------------------------ queue list

    function inTab(d) {
        return (d.decisionType || 'NAMING') === state.tab;
    }

    function visibleDecisions() {
        return state.decisions.filter(function (d) {
            return inTab(d) && (!state.brand || d.divisionLabel === state.brand);
        });
    }

    /*
     * Brands are listed per TAB, not across the whole queue, and each carries its own
     * count. A brand with nothing left in this tab should not be offered - picking it
     * would empty the list and read as a broken filter rather than as "no work here".
     * The selection is dropped when the brand leaves the tab, for the same reason.
     */
    function renderBrandFilter() {
        var counts = {};
        state.decisions.forEach(function (d) {
            if (!inTab(d)) { return; }
            var label = d.divisionLabel || '';
            counts[label] = (counts[label] || 0) + 1;
        });
        var labels = Object.keys(counts).sort();
        if (state.brand && counts[state.brand] === undefined) {
            state.brand = '';
        }
        var total = state.decisions.filter(inTab).length;
        var html = '<option value="">All OEMs (' + total + ')</option>'
            + labels.map(function (label) {
                return '<option value="' + esc(label) + '"'
                    + (state.brand === label ? ' selected' : '') + '>'
                    + esc(label) + ' (' + counts[label] + ')</option>';
            }).join('');
        root().find('[data-ocr-brand]').html(html).val(state.brand);
    }

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

    function renderQueue() {
        var body = root().find('[data-ocr-body]');
        renderBrandFilter();
        var rows = visibleDecisions();
        var note = root().find('[data-ocr-listnote]');

        if (state.tab === 'COVERAGE') {
            note.text('an alias cannot answer these');
        } else {
            note.text(rows.length + (rows.length === 1 ? ' decision' : ' decisions'));
        }

        if (!rows.length) {
            var empty = state.brand
                ? 'Nothing left to resolve for ' + esc(state.brand) + ' here.'
                : (state.tab === 'COVERAGE'
                    ? 'No bundle rows on this circular.'
                    : 'Nothing left to resolve here.');
            body.html('<tr><td colspan="5" class="ocr-empty">' + empty + '</td></tr>');
            return;
        }

        body.html(rows.map(function (d) {
            var i = state.decisions.indexOf(d);
            var coverage = (d.decisionType || 'NAMING') === 'COVERAGE';
            // Coverage rows are reviewable too - they carry two safe actions - so they get
            // the same affordance. Previously they had no button and no pointer, which
            // read as "this row is inert".
            var action = '<button class="ocr-btn ocr-btn-sm" type="button" '
                + 'data-ocr-pick-row="' + i + '">'
                + (coverage ? 'Review' : (d.aliasAction ? 'Change' : 'Resolve')) + '</button>';
            // ONE class attribute. Emitting two made the browser ignore the second, so the
            // selected row never highlighted.
            var cls = ['ocr-rowlink'];
            if (state.selected === i) { cls.push('ocr-row-on'); }
            return '<tr data-ocr-row="' + i + '" class="' + cls.join(' ') + '">'
                + '<td class="ocr-product">' + esc(d.rawText) + '</td>'
                + '<td>' + esc(d.divisionLabel) + '</td>'
                + '<td class="ocr-affects">' + esc(d.rowCount) + ' rows &middot; '
                + esc(d.offerCount) + ' offers</td>'
                + '<td>' + (coverage
                    ? '<span class="ocr-pill ocr-pill-muted">coverage</span>'
                    : decisionState(d)) + '</td>'
                + '<td class="ocr-col-act">' + action + '</td>'
                + '</tr>';
        }).join(''));
    }

    // ------------------------------------------------------------------ decision panel

    function choice(value, title, hint, checked) {
        return '<label class="ocr-choice' + (checked ? ' is-on' : '') + '" data-ocr-choice="'
            + value + '">'
            + '<input type="radio" name="ocr-action" value="' + value + '" '
            + (checked ? 'checked' : '') + ' data-ocr-act />'
            + '<span class="ocr-choice-text"><b>' + title + '</b><span>' + hint + '</span></span>'
            + '</label>';
    }

    function renderPanel() {
        var wrap = root().find('[data-ocr-panel-body]');
        var d = state.selected === null ? null : state.decisions[state.selected];
        root().find('[data-ocr-panel-state]').text('');

        if (!d) {
            wrap.html('<div class="ocr-panel-empty">Pick a product on the left to resolve it.</div>');
            return;
        }
        // Coverage entries cannot be answered by an alias, but "no answer available" is
        // not the same as "nothing to do". Two things ARE safe and available today, so
        // they are offered rather than leaving the panel a dead end.
        if ((d.decisionType || 'NAMING') === 'COVERAGE' && !state.asNaming[state.selected]) {
            root().find('[data-ocr-panel-state]').text('needs a coverage call');
            wrap.html('<div class="ocr-panel-body">'
                + '<div class="ocr-subject">' + esc(d.rawText) + '</div>'
                + '<div class="ocr-context">' + esc(d.divisionLabel) + ' &middot; '
                + esc(d.rowCount) + ' rows &middot; ' + esc(d.offerCount) + ' offers</div>'

                + '<div class="ocr-note">The cashback here is quoted for the phone '
                + '<em>plus</em> the accessory. Whether it also applies to the phone sold '
                + 'on its own is a commercial call the circular does not make, and that '
                + 'needs a per-offer answer, which does not exist yet.</div>'

                + '<div class="ocr-choices" style="margin-top:14px;">'
                + '<label class="ocr-choice" data-ocr-cov-choice="IGNORE">'
                + '<input type="radio" name="ocr-coverage" value="IGNORE" data-ocr-cov />'
                + '<span class="ocr-choice-text"><b>We do not stock this bundle</b>'
                + '<span>Safe: it is a statement about the name, so it needs no coverage '
                + 'call, and it stops this reappearing every month.</span></span></label>'
                + '<label class="ocr-choice" data-ocr-cov-choice="NAMING">'
                + '<input type="radio" name="ocr-coverage" value="NAMING" data-ocr-cov />'
                + '<span class="ocr-choice-text"><b>This is not a bundle</b>'
                + '<span>The suffix was mis-read as an accessory. Resolve it as a normal '
                + 'name instead.</span></span></label>'
                + '</div>'

                + '<div class="ocr-field">'
                + '  <label>Why</label>'
                + '  <input class="ocr-input" type="text" data-ocr-note '
                + '         placeholder="optional \u2014 the next reviewer reads this" />'
                + '</div>'

                + '<div class="ocr-actions">'
                + '  <button class="ocr-btn ocr-btn-primary" type="button" data-ocr-cov-save>Save decision</button>'
                + '  <button class="ocr-btn" type="button" data-ocr-clear>Cancel</button>'
                + '</div>'
                + '<p class="ocr-help">Leaving it alone is a valid choice: the offer stays '
                + 'unmatched, so no partner is shown cashback we might not honour. Nothing '
                + 'is over-credited by waiting.</p>'
                + '<div class="ocr-msg" data-ocr-msg></div>'
                + '</div>');
            return;
        }

        var action = d.aliasAction || 'PIN';
        root().find('[data-ocr-panel-state]').text(d.aliasAction ? 'saved' : 'undecided');

        wrap.html('<div class="ocr-panel-body">'
            + '<div class="ocr-subject">' + esc(d.rawText) + '</div>'
            + '<div class="ocr-context">' + esc(d.divisionLabel) + ' &middot; '
            + esc(d.rowCount) + ' rows &middot; ' + esc(d.offerCount) + ' offers'
            + (d.closestCatalogName
                ? ' &middot; closest miss ' + esc(d.closestCatalogName)
                  + ' (' + esc(d.closestScore) + ')'
                : ' &middot; no candidate') + '</div>'

            + '<div class="ocr-choices">'
            + choice('PIN', 'Pin to a catalog product',
                     'The SKU exists under a different name', action === 'PIN')
            + choice('REWRITE', 'Rewrite to the catalog spelling',
                     'Same product, different wording', action === 'REWRITE')
            + choice('IGNORE', 'Not ours — ignore',
                     'Accessory, or a SKU we do not carry', action === 'IGNORE')
            + '</div>'

            + '<div data-ocr-pane="PIN"' + (action === 'PIN' ? '' : ' style="display:none;"') + '>'
            + '  <div class="ocr-field">'
            + '    <label>Find the product in ' + esc(d.divisionLabel) + '</label>'
            + '    <input class="ocr-input" type="text" data-ocr-hunt '
            + '           placeholder="type part of the model name" />'
            + '    <div class="ocr-hits" data-ocr-hits>'
            + '      <div class="ocr-hint">Type at least two characters to search this '
            + '        OEM’s catalog.</div>'
            + '    </div>'
            + (d.closestCatalogName
                ? '    <p class="ocr-help">The closest miss is shown for context only. When '
                  + 'the catalog genuinely lacks the SKU, Ignore is the right answer, not a '
                  + 'near-miss pin.</p>'
                : '')
            + '  </div>'
            + '</div>'

            + '<div data-ocr-pane="REWRITE"' + (action === 'REWRITE' ? '' : ' style="display:none;"') + '>'
            + '  <div class="ocr-field">'
            + '    <label>Catalog spelling</label>'
            + '    <input class="ocr-input" type="text" data-ocr-canonical '
            + '           value="' + esc(d.aliasCanonicalText || '') + '" '
            + '           placeholder="e.g. iPhone 17 Air" />'
            + '    <p class="ocr-help">The variant spec carries over automatically, so write '
            + '       the model name only.</p>'
            + '  </div>'
            + '</div>'

            + '<div class="ocr-field">'
            + '  <label>Why</label>'
            + '  <input class="ocr-input" type="text" data-ocr-note '
            + '         placeholder="optional — the next reviewer reads this" />'
            + '</div>'

            + '<div class="ocr-actions">'
            + '  <button class="ocr-btn ocr-btn-primary" type="button" data-ocr-save>Save decision</button>'
            + '  <button class="ocr-btn" type="button" data-ocr-clear>Cancel</button>'
            + (d.aliasAction
                ? '  <button class="ocr-btn" type="button" data-ocr-forget>Remove decision</button>'
                : '')
            + '</div>'
            + '<div class="ocr-msg" data-ocr-msg></div>'
            + '</div>');

        state.picked = d.aliasCatalogId || null;
        if (state.picked) {
            root().find('[data-ocr-hits]').html('<div class="ocr-hint">Currently pinned to #'
                + esc(state.picked) + '. Search to pin a different product.</div>');
        }
    }

    // ------------------------------------------------------------------ loading

    function loadQueue() {
        var documentId = currentDocumentId();
        ocrGet(ctx() + '/offerCircular/resolveQueue'
                + (documentId ? '?documentId=' + documentId : ''),
            function (response) {
                var payload = payloadOf(response);
                if (!payload) {
                    root().find('[data-ocr-body]').html('<tr><td colspan="5" class="ocr-empty">'
                        + 'Could not read the decision queue.</td></tr>');
                    return;
                }
                state.decisions = payload.decisions || [];
                state.selected = null;
                root().find('[data-ocr-count-naming]').text(payload.naming);
                root().find('[data-ocr-count-coverage]').text(payload.coverage);
                var pending = root().find('[data-ocr-pending]');
                if (payload.pendingReingest) {
                    pending.text(payload.pendingReingest + ' saved, awaiting re-ingest').show();
                } else {
                    pending.hide();
                }
                renderQueue();
                renderPanel();
            },
            function (xhr) {
                root().find('[data-ocr-body]').html('<tr><td colspan="5" class="ocr-empty">'
                    + 'Could not load decisions: ' + esc(failMessage(xhr)) + '</td></tr>');
            });
    }

    function loadDocuments() {
        ocrGet(ctx() + '/offerCircular/documents', function (response) {
            var payload = payloadOf(response);
            var docs = (payload && (payload.documents || payload)) || [];
            var sel = root().find('[data-ocr-doc]');
            sel.empty().append($('<option>').attr('value', '').text('All circulars'));
            docs.forEach(function (doc) {
                sel.append($('<option>').attr('value', doc.documentId)
                    .text(doc.title + ' (' + doc.status + ')'));
            });
            loadQueue();
        }, function () { loadQueue(); });
    }

    // ------------------------------------------------------------------ handlers

    $(document).on('click', '.ocr-root [data-ocr-tab]', function () {
        state.tab = $(this).data('ocr-tab');
        state.selected = null;
        root().find('[data-ocr-tab]').removeClass('is-on');
        $(this).addClass('is-on');
        renderQueue();
        renderPanel();
    });

    // Filtering never re-fetches: the whole queue is already in state, and a round trip
    // would drop the panel's unsaved selection. Clearing the selection is deliberate -
    // the selected row can be one the filter just hid.
    $(document).on('change', '.ocr-root [data-ocr-brand]', function () {
        state.brand = $(this).val() || '';
        state.selected = null;
        renderQueue();
        renderPanel();
    });

    $(document).on('change', '.ocr-root [data-ocr-doc]', loadQueue);

    function select(index) {
        state.selected = index;
        root().find('[data-ocr-row]').removeClass('ocr-row-on');
        root().find('[data-ocr-row="' + index + '"]').addClass('ocr-row-on');
        renderPanel();
    }

    $(document).on('click', '.ocr-root [data-ocr-pick-row]', function (e) {
        e.stopPropagation();
        select($(this).data('ocr-pick-row'));
    });

    $(document).on('click', '.ocr-root tr[data-ocr-row]', function () {
        select($(this).data('ocr-row'));
    });

    $(document).on('change', '.ocr-root [data-ocr-act]', function () {
        var chosen = $(this).val();
        root().find('[data-ocr-pane]').each(function () {
            $(this).toggle($(this).attr('data-ocr-pane') === chosen);
        });
        root().find('[data-ocr-choice]').removeClass('is-on');
        root().find('[data-ocr-choice="' + chosen + '"]').addClass('is-on');
    });

    $(document).on('input', '.ocr-root [data-ocr-hunt]', function () {
        var d = state.decisions[state.selected];
        var term = $(this).val();
        var hits = root().find('[data-ocr-hits]');
        if (!term || term.length < 2) {
            hits.html('<div class="ocr-hint">Type at least two characters to search this '
                + 'OEM’s catalog.</div>');
            return;
        }
        clearTimeout(state.huntTimer);
        state.huntTimer = setTimeout(function () {
            ocrGet(ctx() + '/offerCircular/catalogSearch?divisionLabel='
                    + encodeURIComponent(d.divisionLabel) + '&q=' + encodeURIComponent(term),
                function (response) {
                    var list = payloadOf(response) || [];
                    if (!list.length) {
                        hits.html('<div class="ocr-hint">No catalog product matches in '
                            + esc(d.divisionLabel) + '’s brand and category. If the SKU '
                            + 'genuinely does not exist, Ignore is the right answer.</div>');
                        return;
                    }
                    hits.html(list.map(function (c) {
                        return '<button type="button" class="ocr-hit" data-ocr-pick="'
                            + c.catalogId + '">' + esc(c.name)
                            + ' <span class="ocr-num">#' + c.catalogId + '</span>'
                            + (c.superCatalogName
                                ? ' <span class="ocr-num">&middot; ' + esc(c.superCatalogName)
                                    + '</span>' : '')
                            + '</button>';
                    }).join(''));
                },
                function (xhr) {
                    hits.html('<div class="ocr-hint">Search failed: '
                        + esc(failMessage(xhr)) + '</div>');
                });
        }, 250);
    });

    $(document).on('click', '.ocr-root [data-ocr-pick]', function () {
        root().find('[data-ocr-pick]').attr('data-picked', '0');
        $(this).attr('data-picked', '1');
        state.picked = $(this).data('ocr-pick');
        msg('');
    });

    $(document).on('change', '.ocr-root [data-ocr-cov]', function () {
        var chosen = $(this).val();
        root().find('[data-ocr-cov-choice]').removeClass('is-on');
        root().find('[data-ocr-cov-choice="' + chosen + '"]').addClass('is-on');
    });

    $(document).on('click', '.ocr-root [data-ocr-cov-save]', function () {
        var d = state.decisions[state.selected];
        var chosen = root().find('[data-ocr-cov]:checked').val();
        if (!chosen) {
            msg('Pick one of the two options first.', true);
            return;
        }
        // "not a bundle" is a reclassification, not a saved decision - it just hands the
        // entry to the normal naming flow for this session.
        if (chosen === 'NAMING') {
            state.asNaming[state.selected] = true;
            renderPanel();
            return;
        }
        var btn = $(this).prop('disabled', true).text('Saving\u2026');
        ocrPost(ctx() + '/offerCircular/alias',
            { divisionLabel: d.divisionLabel, rawText: d.rawText, action: 'IGNORE',
              note: root().find('[data-ocr-note]').val() || '' },
            function (response) {
                var r = payloadOf(response) || {};
                btn.prop('disabled', false).text('Save decision');
                if (r.success === false) { msg(r.message || 'Could not save', true); return; }
                loadQueue();
            },
            function (xhr) {
                btn.prop('disabled', false).text('Save decision');
                msg('Could not save: ' + failMessage(xhr), true);
            });
    });

    $(document).on('click', '.ocr-root [data-ocr-clear]', function () {
        state.selected = null;
        root().find('[data-ocr-row]').removeClass('ocr-row-on');
        renderPanel();
    });

    $(document).on('click', '.ocr-root [data-ocr-save]', function () {
        var d = state.decisions[state.selected];
        if (!d) { return; }
        var action = root().find('[data-ocr-act]:checked').val();
        var payload = {
            divisionLabel: d.divisionLabel,
            rawText: d.rawText,
            action: action,
            note: root().find('[data-ocr-note]').val() || ''
        };
        if (action === 'PIN') {
            if (!state.picked) {
                msg('Pick a catalog product first.', true);
                return;
            }
            payload.catalogId = state.picked;
            payload.matchLevel = 'VARIANT';
        } else if (action === 'REWRITE') {
            payload.canonicalText = root().find('[data-ocr-canonical]').val() || '';
        }

        var btn = $(this).prop('disabled', true).text('Saving…');
        ocrPost(ctx() + '/offerCircular/alias', payload, function (response) {
            var r = payloadOf(response) || {};
            btn.prop('disabled', false).text('Save decision');
            if (r.success === false) {
                msg(r.message || 'Could not save', true);
                return;
            }
            loadQueue();
        }, function (xhr) {
            btn.prop('disabled', false).text('Save decision');
            msg('Could not save: ' + failMessage(xhr), true);
        });
    });

    $(document).on('click', '.ocr-root [data-ocr-forget]', function () {
        var d = state.decisions[state.selected];
        if (!d) { return; }
        ocrPost(ctx() + '/offerCircular/aliasRemove',
            { divisionLabel: d.divisionLabel, rawText: d.rawText },
            function () { loadQueue(); },
            function (xhr) { msg('Could not remove: ' + failMessage(xhr), true); });
    });

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

    $(document).on('click', '.ocr-root [data-ocr-reingest]', function () {
        var documentId = currentDocumentId();
        if (!documentId) {
            alert('Pick a single circular first - re-ingest re-parses one circular.');
            return;
        }
        if (!window.confirm('Re-parse this circular so the saved decisions take effect?\n\n'
                + 'It re-reads the stored PDF and rebuilds this circular\'s rows. Saved '
                + 'decisions and scope config are not touched.')) {
            return;
        }
        var btn = $(this).prop('disabled', true).text('Re-ingesting…');
        ocrPost(ctx() + '/offerCircular/reingest', { documentId: documentId },
            function (response) {
                var r = payloadOf(response) || {};
                btn.prop('disabled', false).text('Re-ingest to apply');
                alert(r.message || 'Re-ingest queued.');
                loadQueue();
            },
            function (xhr) {
                btn.prop('disabled', false).text('Re-ingest to apply');
                alert('Could not re-ingest: ' + failMessage(xhr));
            });
    });

    function openOfferCircularResolve(domId) {
        ocrGet(ctx() + '/offerCircularResolve', function (response) {
            $('#' + (domId || 'main-content')).html(response);
            state = { decisions: [], tab: 'NAMING', selected: null, documents: [],
                      huntTimer: null, picked: null, asNaming: {} };
            loadDocuments();
        });
    }
    window.openOfferCircularResolve = openOfferCircularResolve;
    window.initOfferCircularResolve = loadDocuments;

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