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 listfunction 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 · '+ 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 panelfunction 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) + ' · '+ esc(d.rowCount) + ' rows · ' + 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) + ' · '+ esc(d.rowCount) + ' rows · ' + esc(d.offerCount) + ' offers'+ (d.closestCatalogName? ' · closest miss ' + esc(d.closestCatalogName)+ ' (' + esc(d.closestScore) + ')': ' · 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>');}}// ------------------------------------------------------------------ loadingfunction 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">· ' + 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');});});