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 receivesif (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 sendingif (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 statusvar 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 '—';}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 — 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…</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 rowsif (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 brokeroot().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 itfunction 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 itif (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 yetreturn '<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)+ ' · ' + 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 — 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’s spelling</label>'+ '<label><input type="radio" name="oc-act-' + index + '" value="IGNORE" '+ (d.aliasAction === 'IGNORE' ? 'checked' : '')+ ' data-oc-act /> Not ours — 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’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’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">· ' + 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 overflowingif (!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 hidingvar 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 producedif (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 pageif (root().length > 0) {loadRows();}});