| 37331 |
amit |
1 |
$(function () {
|
|
|
2 |
|
|
|
3 |
// The screen is an AJAX fragment injected into #main-content, so every handler
|
|
|
4 |
// is delegated from document and state is re-initialised on each load.
|
|
|
5 |
var state = { page: 1, rows: [], total: 0, pages: 1, pageSize: 50, brandsLoaded: false,
|
| 37335 |
amit |
6 |
decisions: [], huntTimer: null, documents: [], pollTimer: null };
|
| 37331 |
amit |
7 |
|
|
|
8 |
function ctx() {
|
|
|
9 |
return (typeof context !== 'undefined' && context) ? context : (window.ocContext || '');
|
|
|
10 |
}
|
|
|
11 |
|
|
|
12 |
/**
|
|
|
13 |
* Deliberately NOT doGetAjaxRequestHandler: that lives in common.js, which is
|
|
|
14 |
* only present when this screen is injected into admin.vm. Using $.ajax keeps
|
|
|
15 |
* the page working when it is opened directly by URL too.
|
|
|
16 |
*/
|
|
|
17 |
function ocGet(url, onDone, onFail) {
|
|
|
18 |
$.ajax({ url: url, type: 'GET', cache: false })
|
|
|
19 |
.done(onDone)
|
|
|
20 |
.fail(function (xhr) {
|
|
|
21 |
if (onFail) { onFail(xhr); }
|
|
|
22 |
});
|
|
|
23 |
}
|
|
|
24 |
|
|
|
25 |
function failMessage(xhr) {
|
|
|
26 |
if (xhr.status === 0) { return 'could not reach the server'; }
|
|
|
27 |
if (xhr.status === 404) { return 'endpoint not found (status 404) - is this build deployed?'; }
|
|
|
28 |
if (xhr.status === 403 || xhr.status === 401) { return 'not authorised (status ' + xhr.status + ')'; }
|
|
|
29 |
return 'server returned status ' + xhr.status;
|
|
|
30 |
}
|
|
|
31 |
|
|
|
32 |
function root() {
|
|
|
33 |
return $('.oc-root');
|
|
|
34 |
}
|
|
|
35 |
|
|
|
36 |
function esc(value) {
|
|
|
37 |
if (value === null || value === undefined) {
|
|
|
38 |
return '';
|
|
|
39 |
}
|
|
|
40 |
return $('<span>').text(String(value)).html();
|
|
|
41 |
}
|
|
|
42 |
|
|
|
43 |
function dash(value) {
|
|
|
44 |
return (value === null || value === undefined || String(value).trim() === '')
|
|
|
45 |
? '<span class="oc-pill oc-pill-muted">not set</span>' : esc(value);
|
|
|
46 |
}
|
|
|
47 |
|
|
|
48 |
function filters() {
|
|
|
49 |
var f = {};
|
|
|
50 |
root().find('[data-oc-filter]').each(function () {
|
|
|
51 |
var el = $(this);
|
|
|
52 |
f[el.data('oc-filter')] = el.is(':checkbox') ? el.prop('checked') : (el.val() || '');
|
|
|
53 |
});
|
|
|
54 |
return f;
|
|
|
55 |
}
|
|
|
56 |
|
|
|
57 |
function queryString() {
|
|
|
58 |
var f = filters();
|
|
|
59 |
var parts = ['page=' + state.page];
|
|
|
60 |
// an empty filter must not be sent - the repository appends a predicate for
|
|
|
61 |
// every parameter it actually receives
|
|
|
62 |
if (f.brand) { parts.push('brand=' + encodeURIComponent(f.brand)); }
|
|
|
63 |
if (f.periodMonth) { parts.push('periodMonth=' + f.periodMonth); }
|
|
|
64 |
if (f.fromDate) { parts.push('fromDate=' + f.fromDate); }
|
|
|
65 |
if (f.toDate) { parts.push('toDate=' + f.toDate); }
|
|
|
66 |
if (f.onlyUnmapped) { parts.push('onlyUnmapped=true'); }
|
|
|
67 |
// scope defaults to true server-side, so only the opt-out needs sending
|
|
|
68 |
if (f.inScopeOnly === false) { parts.push('inScopeOnly=false'); }
|
|
|
69 |
if (f.pageSize) { parts.push('pageSize=' + f.pageSize); }
|
|
|
70 |
return parts.join('&');
|
|
|
71 |
}
|
|
|
72 |
|
|
|
73 |
function statusPill(row) {
|
|
|
74 |
if (row.parseStatus !== 'PARSED') {
|
|
|
75 |
return '<span class="oc-pill oc-pill-warn">' + esc(row.parseStatus) + '</span>';
|
|
|
76 |
}
|
|
|
77 |
if (row.unmappedCount > 0) {
|
|
|
78 |
return '<span class="oc-pill oc-pill-danger">' + row.unmappedCount + ' unmapped</span>';
|
|
|
79 |
}
|
|
|
80 |
var offerStatus = row.offerStatus || '';
|
|
|
81 |
var cls = offerStatus === 'ACTIVE' ? 'oc-pill-ok' : 'oc-pill-muted';
|
|
|
82 |
return '<span class="oc-pill ' + cls + '">' + esc(offerStatus || 'PARSED') + '</span>';
|
|
|
83 |
}
|
|
|
84 |
|
|
|
85 |
function validity(row) {
|
|
|
86 |
if (!row.startDateRaw && !row.endDateRaw) {
|
|
|
87 |
return '<span class="oc-pill oc-pill-muted">not set</span>';
|
|
|
88 |
}
|
|
|
89 |
return '<span class="oc-mono">' + esc(row.startDateRaw) + '<br>' + esc(row.endDateRaw) + '</span>';
|
|
|
90 |
}
|
|
|
91 |
|
|
|
92 |
/** Raw cells wrap to a couple of lines instead of being ellipsised into
|
|
|
93 |
* uselessness - the whole point of this screen is reading the source text. */
|
|
|
94 |
function raw(value, lines) {
|
|
|
95 |
var text = esc(value);
|
|
|
96 |
if (!text) { return '<span class="oc-pill oc-pill-muted">not set</span>'; }
|
|
|
97 |
return '<span class="oc-raw" style="-webkit-line-clamp:' + (lines || 2)
|
|
|
98 |
+ ';" title="' + text + '">' + text + '</span>';
|
|
|
99 |
}
|
|
|
100 |
|
|
|
101 |
var STATUS_CLASS = {
|
|
|
102 |
AUTO_EXACT: 'oc-pill-ok', CONFIRMED: 'oc-pill-ok', AUTO_MODEL: 'oc-pill-info',
|
|
|
103 |
REVIEW: 'oc-pill-warn', CATALOG_GAP: 'oc-pill-danger',
|
|
|
104 |
CATEGORY_ALL: 'oc-pill-muted', IGNORED: 'oc-pill-muted'
|
|
|
105 |
};
|
|
|
106 |
|
|
|
107 |
// CONFIRMED and IGNORED both came from a human-confirmed exception in
|
|
|
108 |
// offers.product_alias, so say so rather than showing a bare status
|
|
|
109 |
var STATUS_NOTE = {
|
|
|
110 |
CONFIRMED: 'via exception',
|
|
|
111 |
IGNORED: 'ignored by exception'
|
|
|
112 |
};
|
|
|
113 |
|
|
|
114 |
/** Products shown as per-status chips, so an unmapped product is visible in the
|
|
|
115 |
* list without expanding the row. */
|
|
|
116 |
function productChips(row) {
|
|
|
117 |
if (!row.productCount) { return raw(row.eligibleProducts, 2); }
|
|
|
118 |
var bits = [];
|
|
|
119 |
if (row.productCount - row.unmappedCount > 0) {
|
|
|
120 |
bits.push('<span class="oc-pill oc-pill-ok">'
|
|
|
121 |
+ (row.productCount - row.unmappedCount) + ' mapped</span>');
|
|
|
122 |
}
|
|
|
123 |
if (row.unmappedCount > 0) {
|
|
|
124 |
bits.push('<span class="oc-pill oc-pill-danger">' + row.unmappedCount + ' unmapped</span>');
|
|
|
125 |
}
|
|
|
126 |
return bits.join(' ') + '<div style="margin-top:4px;">' + raw(row.eligibleProducts, 2) + '</div>';
|
|
|
127 |
}
|
|
|
128 |
|
|
|
129 |
function dl(pairs) {
|
|
|
130 |
return pairs.map(function (p) {
|
|
|
131 |
return '<dt>' + esc(p[0]) + '</dt><dd>' + dash(p[1]) + '</dd>';
|
|
|
132 |
}).join('');
|
|
|
133 |
}
|
|
|
134 |
|
|
|
135 |
function money(b) {
|
|
|
136 |
if (b.calcType === 'PERCENT') {
|
|
|
137 |
return b.percent + '%' + (b.maxAmount ? ' capped at ' + fmt(b.maxAmount) : ' uncapped');
|
|
|
138 |
}
|
|
|
139 |
if (b.calcType === 'FLAT') { return fmt(b.flatAmount); }
|
|
|
140 |
return '—';
|
|
|
141 |
}
|
|
|
142 |
|
|
|
143 |
function fmt(n) {
|
|
|
144 |
var v = Number(n);
|
|
|
145 |
return isNaN(v) ? esc(n) : 'Rs ' + v.toLocaleString('en-IN',
|
|
|
146 |
{ minimumFractionDigits: 0, maximumFractionDigits: 2 });
|
|
|
147 |
}
|
|
|
148 |
|
|
|
149 |
var MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
|
|
150 |
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
|
151 |
|
|
|
152 |
/**
|
|
|
153 |
* Dates arrive from Jackson as either epoch millis or "yyyy-MM-dd[ HH:mm:ss]"
|
|
|
154 |
* depending on the serialiser, so handle both and render "01 Aug 2026".
|
|
|
155 |
*/
|
|
|
156 |
function niceDate(value) {
|
|
|
157 |
if (value === null || value === undefined || value === '') { return null; }
|
|
|
158 |
var d;
|
|
|
159 |
if (typeof value === 'number') {
|
|
|
160 |
d = new Date(value);
|
|
|
161 |
} else {
|
|
|
162 |
var m = String(value).match(/^(\d{4})-(\d{2})-(\d{2})/);
|
|
|
163 |
if (m) {
|
|
|
164 |
d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
|
|
165 |
} else if (/^\d+$/.test(String(value))) {
|
|
|
166 |
d = new Date(Number(value));
|
|
|
167 |
} else {
|
|
|
168 |
return esc(value);
|
|
|
169 |
}
|
|
|
170 |
}
|
|
|
171 |
if (isNaN(d.getTime())) { return esc(value); }
|
|
|
172 |
return ('0' + d.getDate()).slice(-2) + ' ' + MONTHS[d.getMonth()] + ' ' + d.getFullYear();
|
|
|
173 |
}
|
|
|
174 |
|
|
|
175 |
function dayDiff(value) {
|
|
|
176 |
var m = String(value).match(/^(\d{4})-(\d{2})-(\d{2})/);
|
|
|
177 |
var end = m ? new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]))
|
|
|
178 |
: new Date(Number(value));
|
|
|
179 |
if (isNaN(end.getTime())) { return null; }
|
|
|
180 |
var today = new Date();
|
|
|
181 |
today.setHours(0, 0, 0, 0);
|
|
|
182 |
return Math.round((end - today) / 86400000);
|
|
|
183 |
}
|
|
|
184 |
|
|
|
185 |
/** "01 Aug 2026 to 26 Sep 2026 ยท live, 44 days left" */
|
|
|
186 |
function validityLine(row) {
|
|
|
187 |
var from = niceDate(row.startDate), to = niceDate(row.endDate);
|
|
|
188 |
if (!from && !to) {
|
|
|
189 |
return '<span class="oc-pill oc-pill-warn">dates not parsed</span>';
|
|
|
190 |
}
|
|
|
191 |
var left = dayDiff(row.endDate);
|
|
|
192 |
var state;
|
|
|
193 |
if (left === null) {
|
|
|
194 |
state = '';
|
|
|
195 |
} else if (left < 0) {
|
|
|
196 |
state = ' <span class="oc-pill oc-pill-muted">expired ' + Math.abs(left) + ' day(s) ago</span>';
|
|
|
197 |
} else if (left === 0) {
|
|
|
198 |
state = ' <span class="oc-pill oc-pill-warn">last day</span>';
|
|
|
199 |
} else {
|
|
|
200 |
state = ' <span class="oc-pill oc-pill-ok">live, ' + left + ' day(s) left</span>';
|
|
|
201 |
}
|
|
|
202 |
return '<strong>' + from + '</strong> to <strong>' + to + '</strong>' + state;
|
|
|
203 |
}
|
|
|
204 |
|
|
|
205 |
/** The comparison that makes this screen worth having: the nine verbatim PDF
|
|
|
206 |
* columns beside the rows the parser actually produced from them. */
|
|
|
207 |
function detailRow(row) {
|
|
|
208 |
var original = dl([
|
|
|
209 |
['OEM Name', row.oemName],
|
|
|
210 |
['Additional Cashback', row.additionalCashback],
|
|
|
211 |
['Offer Description', row.offerDescription],
|
|
|
212 |
['EMI tenure', row.emiTenure],
|
|
|
213 |
['Debit Cards', row.debitCards],
|
|
|
214 |
['Credit cards', row.creditCards],
|
|
|
215 |
['Eligible products', row.eligibleProducts],
|
|
|
216 |
['Start Date', row.startDateRaw],
|
|
|
217 |
['End Date', row.endDateRaw]
|
|
|
218 |
]);
|
|
|
219 |
return '<tr class="oc-detail-row"><td colspan="11" class="oc-detail">'
|
|
|
220 |
+ '<div class="oc-detail-grid">'
|
|
|
221 |
+ '<div><div class="oc-detail-title">Original row as printed — page '
|
|
|
222 |
+ esc(row.pageNo) + ', row ' + esc(row.rowNo)
|
|
|
223 |
+ '</div><dl class="oc-dl">' + original + '</dl></div>'
|
|
|
224 |
+ '<div><div class="oc-detail-title">What the parser produced</div>'
|
|
|
225 |
+ '<div data-oc-parsed="' + esc(row.offerId) + '" class="oc-parsed">'
|
|
|
226 |
+ 'Loading parsed rows…</div></div>'
|
|
|
227 |
+ '</div></td></tr>';
|
|
|
228 |
}
|
|
|
229 |
|
|
|
230 |
function renderParsed(container, row, d) {
|
|
|
231 |
function tbl(title, headers, bodyRows) {
|
|
|
232 |
if (!bodyRows.length) {
|
|
|
233 |
return '<div class="oc-sub"><span class="oc-sub-h">' + title + '</span>'
|
|
|
234 |
+ '<span class="oc-pill oc-pill-warn">nothing parsed</span></div>';
|
|
|
235 |
}
|
|
|
236 |
return '<div class="oc-sub"><span class="oc-sub-h">' + title
|
|
|
237 |
+ ' <span class="oc-num">(' + bodyRows.length + ')</span></span>'
|
|
|
238 |
+ '<table class="oc-mini"><thead><tr>'
|
|
|
239 |
+ headers.map(function (h) { return '<th>' + h + '</th>'; }).join('')
|
|
|
240 |
+ '</tr></thead><tbody>' + bodyRows.join('') + '</tbody></table></div>';
|
|
|
241 |
}
|
|
|
242 |
|
|
|
243 |
var benefits = (d.benefits || []).map(function (b) {
|
|
|
244 |
return '<tr><td><code>' + esc(b.txnMode) + '</code></td>'
|
|
|
245 |
+ '<td>' + esc(b.calcType) + '</td><td><strong>' + money(b) + '</strong></td></tr>';
|
|
|
246 |
});
|
|
|
247 |
|
|
|
248 |
var byScheme = {};
|
|
|
249 |
(d.tenures || []).forEach(function (t) {
|
|
|
250 |
(byScheme[t.emiScheme] = byScheme[t.emiScheme] || []).push(t.tenureMonths);
|
|
|
251 |
});
|
|
|
252 |
var tenures = Object.keys(byScheme).map(function (s) {
|
|
|
253 |
return '<tr><td><code>' + esc(s) + '</code></td><td>'
|
|
|
254 |
+ byScheme[s].join(', ') + ' months</td></tr>';
|
|
|
255 |
});
|
|
|
256 |
|
|
|
257 |
var byCard = {};
|
|
|
258 |
(d.banks || []).forEach(function (b) {
|
|
|
259 |
(byCard[b.cardType] = byCard[b.cardType] || []).push(
|
|
|
260 |
(b.inclusion === 'EXCLUDE' ? 'NOT ' : '') + b.code);
|
|
|
261 |
});
|
|
|
262 |
var banks = Object.keys(byCard).map(function (c) {
|
|
|
263 |
return '<tr><td><code>' + esc(c) + '</code></td><td>' + byCard[c].join(', ') + '</td></tr>';
|
|
|
264 |
});
|
|
|
265 |
|
|
|
266 |
var products = (d.products || []).map(function (p) {
|
|
|
267 |
var cls = STATUS_CLASS[p.matchStatus] || 'oc-pill-muted';
|
|
|
268 |
var note = STATUS_NOTE[p.matchStatus];
|
|
|
269 |
var to = p.catalogVariant || p.catalogModel;
|
|
|
270 |
var resolved;
|
|
|
271 |
if (p.matchStatus === 'IGNORED') {
|
|
|
272 |
resolved = '<em>not stocked - ignored</em>';
|
|
|
273 |
} else if (to) {
|
|
|
274 |
resolved = esc(to)
|
|
|
275 |
+ (p.catalogId ? ' <span class="oc-num">#' + p.catalogId + '</span>' : '')
|
|
|
276 |
+ (p.matchLevel === 'MODEL' ? ' <span class="oc-num">(all variants)</span>' : '');
|
|
|
277 |
} else {
|
|
|
278 |
resolved = '<em>not in catalog</em>';
|
|
|
279 |
}
|
|
|
280 |
return '<tr><td class="oc-mono">' + esc(p.rawText) + '</td>'
|
|
|
281 |
+ '<td><span class="oc-pill ' + cls + '">' + esc(p.matchStatus) + '</span>'
|
|
|
282 |
+ (note ? ' <span class="oc-num">' + note + '</span>' : '')
|
|
|
283 |
+ (p.matchScore && p.matchStatus === 'REVIEW'
|
|
|
284 |
? ' <span class="oc-num">' + p.matchScore + '</span>' : '')
|
|
|
285 |
+ '</td><td>' + resolved + '</td></tr>';
|
|
|
286 |
});
|
|
|
287 |
|
|
|
288 |
var channels = (d.channels || []).map(function (c) {
|
|
|
289 |
return '<tr><td><span class="oc-pill '
|
|
|
290 |
+ (c.inclusion === 'EXCLUDE' ? 'oc-pill-danger' : 'oc-pill-ok') + '">'
|
|
|
291 |
+ esc(c.inclusion) + '</span></td><td>' + esc(c.channelRef) + '</td></tr>';
|
|
|
292 |
});
|
|
|
293 |
|
|
|
294 |
var html = '<div class="oc-sub"><span class="oc-sub-h">Validity</span>'
|
|
|
295 |
+ validityLine(row) + '</div>'
|
|
|
296 |
+ tbl('Cashback by payment mode', ['Mode', 'Type', 'Value'], benefits)
|
|
|
297 |
+ tbl('EMI tenures', ['Scheme', 'Months'], tenures)
|
|
|
298 |
+ tbl('Banks', ['Card', 'Issuers'], banks)
|
|
|
299 |
+ tbl('Products', ['As printed', 'Match', 'Resolved to'], products);
|
|
|
300 |
if (channels.length) {
|
|
|
301 |
html += tbl('Store restrictions', ['Rule', 'Retailer'], channels);
|
|
|
302 |
}
|
|
|
303 |
if (row.parseError) {
|
|
|
304 |
html += '<div class="oc-sub"><span class="oc-pill oc-pill-danger">parse error</span> '
|
|
|
305 |
+ esc(row.parseError) + '</div>';
|
|
|
306 |
}
|
|
|
307 |
container.html(html);
|
|
|
308 |
}
|
|
|
309 |
|
|
|
310 |
function render() {
|
|
|
311 |
var r = root();
|
|
|
312 |
var body = r.find('[data-oc-body]');
|
|
|
313 |
if (state.rows.length === 0) {
|
|
|
314 |
body.html('<tr><td colspan="11" class="oc-empty">No circular rows match these filters.</td></tr>');
|
|
|
315 |
} else {
|
|
|
316 |
body.html(state.rows.map(function (row, i) {
|
|
|
317 |
return '<tr data-oc-row="' + i + '">'
|
|
|
318 |
+ '<td class="oc-num">' + esc(row.pageNo) + '/' + esc(row.rowNo) + '</td>'
|
|
|
319 |
+ '<td><strong>' + esc(row.oemName) + '</strong></td>'
|
|
|
320 |
+ '<td>' + dash(row.brand) + '</td>'
|
|
|
321 |
+ '<td>' + dash(row.category) + '</td>'
|
|
|
322 |
+ '<td>' + dash(row.additionalCashback) + '</td>'
|
|
|
323 |
+ '<td>' + raw(row.offerDescription, 3) + '</td>'
|
|
|
324 |
+ '<td class="oc-mono">' + raw(row.emiTenure, 2) + '</td>'
|
|
|
325 |
+ '<td>' + productChips(row) + '</td>'
|
|
|
326 |
+ '<td>' + validity(row) + '</td>'
|
|
|
327 |
+ '<td>' + statusPill(row) + '</td>'
|
|
|
328 |
+ '<td><button class="oc-expand" type="button" data-oc-expand="' + i + '">View raw</button></td>'
|
|
|
329 |
+ '</tr>';
|
|
|
330 |
}).join(''));
|
|
|
331 |
}
|
|
|
332 |
|
|
|
333 |
var first = state.total === 0 ? 0 : ((state.page - 1) * state.pageSize) + 1;
|
|
|
334 |
var last = Math.min(state.page * state.pageSize, state.total);
|
|
|
335 |
r.find('[data-oc-range]').text(state.total === 0 ? '' : first + '-' + last + ' of ' + state.total);
|
|
|
336 |
r.find('[data-oc-page]').text('Page ' + state.page + ' of ' + state.pages);
|
|
|
337 |
r.find('[data-oc-summary]').text(state.total + ' row(s) matched');
|
|
|
338 |
r.find('[data-oc-prev]').prop('disabled', state.page <= 1);
|
|
|
339 |
r.find('[data-oc-next]').prop('disabled', state.page >= state.pages);
|
|
|
340 |
|
|
|
341 |
var parsed = 0, pending = 0;
|
|
|
342 |
state.rows.forEach(function (row) {
|
|
|
343 |
if (row.parseStatus === 'PARSED' && !row.unmappedCount) { parsed++; }
|
|
|
344 |
if (row.parseStatus !== 'PARSED' || row.unmappedCount > 0) { pending++; }
|
|
|
345 |
});
|
|
|
346 |
r.find('[data-oc-stat="total"]').text(state.total);
|
|
|
347 |
r.find('[data-oc-stat="parsed"]').text(parsed);
|
|
|
348 |
r.find('[data-oc-stat="pending"]').text(pending);
|
|
|
349 |
}
|
|
|
350 |
|
|
|
351 |
function loadRows() {
|
|
|
352 |
root().find('[data-oc-body]')
|
|
|
353 |
.html('<tr><td colspan="11" class="oc-spin">Loading circular rows...</td></tr>');
|
|
|
354 |
ocGet(ctx() + '/offerCircular/rawRows?' + queryString(), function (response) {
|
|
|
355 |
var payload;
|
|
|
356 |
try {
|
|
|
357 |
payload = typeof response === 'string' ? JSON.parse(response) : response;
|
|
|
358 |
} catch (e) {
|
|
|
359 |
root().find('[data-oc-body]').html('<tr><td colspan="11" class="oc-empty">'
|
|
|
360 |
+ 'The server replied but the response was not readable JSON. '
|
|
|
361 |
+ 'Open /offerCircular/rawRows directly to see it.</td></tr>');
|
|
|
362 |
return;
|
|
|
363 |
}
|
|
|
364 |
state.rows = payload.rows || [];
|
|
|
365 |
state.total = payload.total || 0;
|
|
|
366 |
state.pages = payload.pages || 1;
|
|
|
367 |
state.page = payload.page || 1;
|
|
|
368 |
state.pageSize = payload.pageSize || 50;
|
|
|
369 |
|
| 37335 |
amit |
370 |
state.documents = payload.documents || state.documents || [];
|
| 37331 |
amit |
371 |
if (payload.documents) {
|
|
|
372 |
var docSel = root().find('[data-oc-doc]');
|
|
|
373 |
if (docSel.find('option').filter(function () { return this.value !== ''; }).length === 0) {
|
|
|
374 |
docSel.empty();
|
|
|
375 |
payload.documents.forEach(function (doc) {
|
|
|
376 |
docSel.append($('<option>').attr('value', doc.documentId)
|
|
|
377 |
.attr('data-downloadable', doc.downloadable ? '1' : '0')
|
|
|
378 |
.attr('data-period', doc.periodMonth || '')
|
|
|
379 |
.text(doc.title + ' (' + doc.rowCount + ' rows, ' + doc.status + ')'));
|
|
|
380 |
});
|
|
|
381 |
if (payload.documents.length === 0) {
|
|
|
382 |
docSel.append($('<option>').attr('value', '')
|
|
|
383 |
.text('No circulars loaded - upload a PDF, then run the ingest'));
|
|
|
384 |
}
|
|
|
385 |
}
|
|
|
386 |
}
|
|
|
387 |
|
|
|
388 |
if (!state.brandsLoaded && payload.brands) {
|
|
|
389 |
var select = root().find('[data-oc-filter="brand"]');
|
|
|
390 |
var current = select.val();
|
|
|
391 |
select.find('option').not(':first').remove();
|
|
|
392 |
payload.brands.forEach(function (b) {
|
|
|
393 |
if (b) { select.append($('<option>').attr('value', b).text(b)); }
|
|
|
394 |
});
|
|
|
395 |
select.val(current);
|
|
|
396 |
root().find('[data-oc-stat="brands"]').text(payload.brands.filter(Boolean).length);
|
|
|
397 |
state.brandsLoaded = true;
|
|
|
398 |
}
|
|
|
399 |
render();
|
| 37335 |
amit |
400 |
renderIngestState();
|
|
|
401 |
schedulePoll();
|
| 37331 |
amit |
402 |
// an empty result while circulars exist is nearly always a filter
|
|
|
403 |
// mismatch, so name the months that actually hold rows
|
|
|
404 |
if (state.total === 0 && payload.documents && payload.documents.length > 0) {
|
|
|
405 |
root().find('[data-oc-summary]').text(
|
|
|
406 |
'No rows matched these filters. Loaded circulars: ' +
|
|
|
407 |
payload.documents.map(function (d) {
|
|
|
408 |
return (d.periodMonth || '?') + ' - ' + d.rowCount + ' rows';
|
|
|
409 |
}).join(', ') + '. Clear the filters to see everything.');
|
|
|
410 |
}
|
|
|
411 |
}, function (xhr) {
|
|
|
412 |
// never leave the table sitting on "Loading..." - say what broke
|
|
|
413 |
root().find('[data-oc-body]').html('<tr><td colspan="11" class="oc-empty">'
|
|
|
414 |
+ 'Could not load circular rows: ' + failMessage(xhr) + '.</td></tr>');
|
|
|
415 |
root().find('[data-oc-doc]').empty()
|
|
|
416 |
.append($('<option>').attr('value', '').text('Could not load circulars'));
|
|
|
417 |
});
|
|
|
418 |
}
|
|
|
419 |
|
|
|
420 |
// sidebar entry / any trigger loads the fragment then initialises it
|
|
|
421 |
function openOfferCircularReview(domId) {
|
|
|
422 |
ocGet(ctx() + '/offerCircularReview', function (response) {
|
|
|
423 |
$('#' + (domId || 'main-content')).html(response);
|
|
|
424 |
state = { page: 1, rows: [], total: 0, pages: 1, pageSize: 50, brandsLoaded: false,
|
| 37335 |
amit |
425 |
decisions: [], huntTimer: null, documents: [], pollTimer: null };
|
| 37331 |
amit |
426 |
loadRows();
|
|
|
427 |
});
|
|
|
428 |
}
|
|
|
429 |
window.openOfferCircularReview = openOfferCircularReview;
|
|
|
430 |
|
|
|
431 |
$(document).on('click', '.offer-circular-review', function (e) {
|
|
|
432 |
e.preventDefault();
|
|
|
433 |
openOfferCircularReview('main-content');
|
|
|
434 |
});
|
|
|
435 |
|
|
|
436 |
$(document).on('click', '.oc-root [data-oc-expand]', function () {
|
|
|
437 |
var index = $(this).data('oc-expand');
|
|
|
438 |
var tr = root().find('[data-oc-row="' + index + '"]');
|
|
|
439 |
var next = tr.next('.oc-detail-row');
|
|
|
440 |
if (next.length) {
|
|
|
441 |
next.remove();
|
|
|
442 |
$(this).text('View raw');
|
|
|
443 |
} else {
|
|
|
444 |
var row = state.rows[index];
|
|
|
445 |
tr.after(detailRow(row));
|
|
|
446 |
$(this).text('Hide raw');
|
|
|
447 |
var box = root().find('[data-oc-parsed="' + row.offerId + '"]').last();
|
|
|
448 |
if (!row.offerId) {
|
|
|
449 |
box.html('<span class="oc-pill oc-pill-warn">not parsed</span> '
|
|
|
450 |
+ 'This row has no offer record yet, so there is nothing to compare.');
|
|
|
451 |
return;
|
|
|
452 |
}
|
|
|
453 |
ocGet(ctx() + '/offerCircular/offerDetail?offerId=' + row.offerId,
|
|
|
454 |
function (response) {
|
|
|
455 |
var d;
|
|
|
456 |
try { d = typeof response === 'string' ? JSON.parse(response) : response; }
|
|
|
457 |
catch (e) { box.text('Could not read the parsed rows.'); return; }
|
|
|
458 |
renderParsed(box, row, d);
|
|
|
459 |
},
|
|
|
460 |
function (xhr) { box.text('Could not load parsed rows: ' + failMessage(xhr)); });
|
|
|
461 |
}
|
|
|
462 |
});
|
|
|
463 |
|
|
|
464 |
$(document).on('change', '.oc-root [data-oc-filter]', function () {
|
|
|
465 |
state.page = 1;
|
|
|
466 |
loadRows();
|
|
|
467 |
});
|
|
|
468 |
|
|
|
469 |
$(document).on('click', '.oc-root [data-oc-prev]', function () {
|
|
|
470 |
if (state.page > 1) { state.page--; loadRows(); }
|
|
|
471 |
});
|
|
|
472 |
|
|
|
473 |
$(document).on('click', '.oc-root [data-oc-next]', function () {
|
|
|
474 |
if (state.page < state.pages) { state.page++; loadRows(); }
|
|
|
475 |
});
|
|
|
476 |
|
|
|
477 |
$(document).on('click', '.oc-root [data-oc-reload]', function () {
|
|
|
478 |
loadRows();
|
|
|
479 |
});
|
|
|
480 |
|
|
|
481 |
$(document).on('click', '.oc-root [data-oc-clear]', function () {
|
|
|
482 |
root().find('[data-oc-filter]').each(function () {
|
|
|
483 |
var el = $(this);
|
|
|
484 |
if (el.is(':checkbox')) { el.prop('checked', false); } else { el.val(''); }
|
|
|
485 |
});
|
|
|
486 |
root().find('[data-oc-filter="pageSize"]').val('50');
|
|
|
487 |
state.page = 1;
|
|
|
488 |
loadRows();
|
|
|
489 |
});
|
|
|
490 |
|
|
|
491 |
// picking a circular filters the table to that circular's month, which is what
|
|
|
492 |
// a reviewer expects the selector to do
|
|
|
493 |
$(document).on('change', '.oc-root [data-oc-doc]', function () {
|
|
|
494 |
var period = $(this).find('option:selected').attr('data-period') || '';
|
|
|
495 |
root().find('[data-oc-filter="periodMonth"]').val(period ? period.substring(0, 10) : '');
|
|
|
496 |
state.page = 1;
|
|
|
497 |
loadRows();
|
|
|
498 |
// the decision queue is scoped to the selected circular, so it moves with it
|
|
|
499 |
if (root().find('[data-oc-resolve-wrap]').is(':visible')) {
|
|
|
500 |
loadResolveQueue();
|
|
|
501 |
}
|
|
|
502 |
});
|
|
|
503 |
|
|
|
504 |
// download hands back the exact PDF that was uploaded for the selected circular
|
|
|
505 |
$(document).on('click', '.oc-root [data-oc-download]', function () {
|
|
|
506 |
var opt = root().find('[data-oc-doc]').find('option:selected');
|
|
|
507 |
var documentId = opt.val();
|
|
|
508 |
if (!documentId) {
|
|
|
509 |
alert('Select a circular first');
|
|
|
510 |
return;
|
|
|
511 |
}
|
|
|
512 |
if (opt.attr('data-downloadable') !== '1') {
|
|
|
513 |
alert('The source PDF was not retained for this circular, so it cannot be downloaded.');
|
|
|
514 |
return;
|
|
|
515 |
}
|
|
|
516 |
window.location = ctx() + '/offerCircular/download?documentId=' + documentId;
|
|
|
517 |
});
|
|
|
518 |
|
|
|
519 |
$(document).on('click', '.oc-root [data-oc-upload]', function () {
|
|
|
520 |
var input = root().find('[data-oc-upload-file]')[0];
|
|
|
521 |
var month = root().find('[data-oc-upload-month]').val();
|
|
|
522 |
var msg = root().find('[data-oc-upload-msg]');
|
|
|
523 |
if (!input || !input.files || input.files.length === 0) {
|
|
|
524 |
msg.text('Choose a PDF first.');
|
|
|
525 |
return;
|
|
|
526 |
}
|
|
|
527 |
if (!month) {
|
|
|
528 |
msg.text('Set the month this circular is for.');
|
|
|
529 |
return;
|
|
|
530 |
}
|
|
|
531 |
var form = new FormData();
|
|
|
532 |
form.append('file', input.files[0]);
|
|
|
533 |
form.append('periodMonth', month);
|
|
|
534 |
|
|
|
535 |
var btn = $(this).prop('disabled', true).text('Uploading...');
|
|
|
536 |
msg.text('');
|
|
|
537 |
$.ajax({
|
|
|
538 |
url: ctx() + '/offerCircular/upload?periodMonth=' + month,
|
|
|
539 |
type: 'POST',
|
|
|
540 |
data: form,
|
|
|
541 |
processData: false,
|
|
|
542 |
contentType: false,
|
|
|
543 |
success: function (response) {
|
|
|
544 |
btn.prop('disabled', false).text('Upload');
|
|
|
545 |
var r;
|
|
|
546 |
try { r = typeof response === 'string' ? JSON.parse(response) : response; }
|
|
|
547 |
catch (e) { msg.text('Upload finished but the response could not be read.'); return; }
|
|
|
548 |
msg.text(r.message || '');
|
|
|
549 |
if (r.success) {
|
|
|
550 |
input.value = '';
|
|
|
551 |
root().find('[data-oc-doc]').empty();
|
|
|
552 |
loadRows();
|
|
|
553 |
}
|
|
|
554 |
},
|
|
|
555 |
error: function (xhr) {
|
|
|
556 |
btn.prop('disabled', false).text('Upload');
|
|
|
557 |
msg.text('Upload failed: ' + (xhr.status === 403 ? 'not authorised' : xhr.status));
|
|
|
558 |
}
|
|
|
559 |
});
|
|
|
560 |
});
|
|
|
561 |
|
|
|
562 |
// exports the rows currently on screen with the nine original columns intact
|
|
|
563 |
$(document).on('click', '.oc-root [data-oc-export]', function () {
|
|
|
564 |
var cols = ['pageNo', 'rowNo', 'oemName', 'brand', 'category', 'additionalCashback',
|
|
|
565 |
'offerDescription', 'emiTenure', 'debitCards', 'creditCards', 'eligibleProducts',
|
|
|
566 |
'startDateRaw', 'endDateRaw', 'parseStatus', 'offerStatus', 'productCount', 'unmappedCount'];
|
|
|
567 |
var lines = [cols.join(',')];
|
|
|
568 |
state.rows.forEach(function (row) {
|
|
|
569 |
lines.push(cols.map(function (c) {
|
|
|
570 |
var v = row[c];
|
|
|
571 |
return '"' + String(v === null || v === undefined ? '' : v).replace(/"/g, '""') + '"';
|
|
|
572 |
}).join(','));
|
|
|
573 |
});
|
|
|
574 |
var blob = new Blob([lines.join('\n')], { type: 'text/csv;charset=utf-8;' });
|
|
|
575 |
var a = document.createElement('a');
|
|
|
576 |
a.href = URL.createObjectURL(blob);
|
|
|
577 |
a.download = 'offer-circular-rows.csv';
|
|
|
578 |
a.click();
|
|
|
579 |
URL.revokeObjectURL(a.href);
|
|
|
580 |
});
|
|
|
581 |
|
|
|
582 |
// ------------------------------------------------------------------ curation
|
|
|
583 |
//
|
|
|
584 |
// Everything here writes offers.product_alias only. An alias is keyed on
|
|
|
585 |
// (OEM division, raw product text), so one answer resolves every row carrying that
|
|
|
586 |
// text - this circular and every later one. That is why the queue is grouped by
|
|
|
587 |
// distinct text: listing per row would ask the same question repeatedly.
|
|
|
588 |
|
|
|
589 |
function ocPost(url, data, onDone, onFail) {
|
|
|
590 |
$.ajax({ url: url, type: 'POST', data: data })
|
|
|
591 |
.done(function (response) {
|
|
|
592 |
var r;
|
|
|
593 |
try { r = typeof response === 'string' ? JSON.parse(response) : response; }
|
|
|
594 |
catch (e) { r = { success: false, message: 'Unreadable server response' }; }
|
|
|
595 |
onDone(r);
|
|
|
596 |
})
|
|
|
597 |
.fail(function (xhr) {
|
|
|
598 |
if (onFail) { onFail(xhr); }
|
|
|
599 |
});
|
|
|
600 |
}
|
|
|
601 |
|
|
|
602 |
function resolveMsg(text) {
|
|
|
603 |
root().find('[data-oc-resolve-msg]').text(text || '');
|
|
|
604 |
}
|
|
|
605 |
|
|
|
606 |
function currentDocumentId() {
|
|
|
607 |
return root().find('[data-oc-doc]').val() || '';
|
|
|
608 |
}
|
|
|
609 |
|
|
|
610 |
function decisionCell(d) {
|
|
|
611 |
if (!d.aliasAction) {
|
|
|
612 |
return '<span class="oc-pill oc-pill-warn">undecided</span>';
|
|
|
613 |
}
|
|
|
614 |
var target = d.aliasCanonicalText
|
|
|
615 |
|| (d.aliasCatalogId ? '#' + d.aliasCatalogId : '')
|
|
|
616 |
|| (d.aliasSuperCatalogId ? 'model #' + d.aliasSuperCatalogId : '');
|
|
|
617 |
// "pending" is literal, not decorative: ProductAliases is read at ingest time,
|
|
|
618 |
// so a saved decision genuinely has not changed anything yet
|
|
|
619 |
return '<span class="oc-pill oc-pill-info">' + esc(d.aliasAction) + '</span> '
|
|
|
620 |
+ (target ? '<span class="oc-num">' + esc(target) + '</span> ' : '')
|
|
|
621 |
+ '<span class="oc-num">pending re-ingest</span>';
|
|
|
622 |
}
|
|
|
623 |
|
|
|
624 |
function renderResolveQueue() {
|
|
|
625 |
var body = root().find('[data-oc-resolve-body]');
|
|
|
626 |
if (!state.decisions.length) {
|
|
|
627 |
body.html('<tr><td colspan="6" class="oc-empty">'
|
|
|
628 |
+ 'Nothing left to resolve for this circular.</td></tr>');
|
|
|
629 |
return;
|
|
|
630 |
}
|
|
|
631 |
body.html(state.decisions.map(function (d, i) {
|
|
|
632 |
var coverage = d.decisionType === 'COVERAGE';
|
|
|
633 |
var miss = d.closestCatalogName
|
|
|
634 |
? '<span class="oc-miss">' + esc(d.closestCatalogName)
|
|
|
635 |
+ ' <span class="oc-num">#' + esc(d.closestCatalogId)
|
|
|
636 |
+ ' · ' + esc(d.closestScore) + '</span></span>'
|
|
|
637 |
: '<span class="oc-miss">no candidate</span>';
|
|
|
638 |
// A bundle offer's transfer to the bare SKU is a coverage decision, and an
|
|
|
639 |
// alias cannot express it - parked rather than offered a naming answer.
|
|
|
640 |
var action = coverage
|
|
|
641 |
? '<span class="oc-pill oc-pill-muted">coverage decision</span>'
|
|
|
642 |
: '<button class="oc-expand" type="button" data-oc-resolve="' + i + '">'
|
|
|
643 |
+ (d.aliasAction ? 'Change' : 'Resolve') + '</button>';
|
|
|
644 |
return '<tr data-oc-decision="' + i + '">'
|
|
|
645 |
+ '<td class="oc-mono">' + esc(d.rawText) + '</td>'
|
|
|
646 |
+ '<td>' + esc(d.divisionLabel) + '</td>'
|
|
|
647 |
+ '<td class="oc-num">' + esc(d.rowCount) + ' row(s), '
|
|
|
648 |
+ esc(d.offerCount) + ' offer(s)</td>'
|
|
|
649 |
+ '<td>' + miss + '</td>'
|
|
|
650 |
+ '<td>' + (coverage
|
|
|
651 |
? '<span class="oc-miss">bundle — needs a coverage call, '
|
|
|
652 |
+ 'not a name</span>'
|
|
|
653 |
: decisionCell(d)) + '</td>'
|
|
|
654 |
+ '<td>' + action + '</td>'
|
|
|
655 |
+ '</tr>';
|
|
|
656 |
}).join(''));
|
|
|
657 |
}
|
|
|
658 |
|
|
|
659 |
function editorRow(d, index) {
|
|
|
660 |
var pinned = d.aliasCatalogId || '';
|
|
|
661 |
return '<tr class="oc-detail-row"><td colspan="6" class="oc-edit" '
|
|
|
662 |
+ 'data-oc-editor="' + index + '">'
|
|
|
663 |
+ '<div class="oc-edit-actions">'
|
|
|
664 |
+ '<label><input type="radio" name="oc-act-' + index + '" value="PIN" '
|
|
|
665 |
+ (d.aliasAction === 'PIN' || !d.aliasAction ? 'checked' : '')
|
|
|
666 |
+ ' data-oc-act /> Pin to a catalog product</label>'
|
|
|
667 |
+ '<label><input type="radio" name="oc-act-' + index + '" value="REWRITE" '
|
|
|
668 |
+ (d.aliasAction === 'REWRITE' ? 'checked' : '')
|
|
|
669 |
+ ' data-oc-act /> Rewrite to the catalog’s spelling</label>'
|
|
|
670 |
+ '<label><input type="radio" name="oc-act-' + index + '" value="IGNORE" '
|
|
|
671 |
+ (d.aliasAction === 'IGNORE' ? 'checked' : '')
|
|
|
672 |
+ ' data-oc-act /> Not ours — ignore</label>'
|
|
|
673 |
+ '</div>'
|
|
|
674 |
+ '<div data-oc-pane="PIN">'
|
|
|
675 |
+ ' <div class="oc-edit-row">'
|
|
|
676 |
+ ' <div class="oc-field" style="flex:1;min-width:240px;">'
|
|
|
677 |
+ ' <label>Find the product in ' + esc(d.divisionLabel) + '</label>'
|
|
|
678 |
+ ' <input class="oc-input" type="text" data-oc-hunt style="width:100%;" '
|
|
|
679 |
+ ' placeholder="type part of the model name" />'
|
|
|
680 |
+ ' </div>'
|
|
|
681 |
+ ' <span class="oc-num" data-oc-picked>'
|
|
|
682 |
+ (pinned ? 'pinned to #' + esc(pinned) : 'nothing selected') + '</span>'
|
|
|
683 |
+ ' </div>'
|
|
|
684 |
+ ' <div class="oc-hits" data-oc-hits>'
|
|
|
685 |
+ ' <div style="padding:8px 10px;" class="oc-miss">'
|
|
|
686 |
+ ' Search to list this OEM’s catalog products.</div>'
|
|
|
687 |
+ ' </div>'
|
|
|
688 |
+ '</div>'
|
|
|
689 |
+ '<div data-oc-pane="REWRITE" style="display:none;">'
|
|
|
690 |
+ ' <div class="oc-edit-row"><div class="oc-field" style="flex:1;min-width:260px;">'
|
|
|
691 |
+ ' <label>Catalog spelling (the variant spec is carried over automatically)</label>'
|
|
|
692 |
+ ' <input class="oc-input" type="text" data-oc-canonical style="width:100%;" '
|
|
|
693 |
+ ' value="' + esc(d.aliasCanonicalText || '') + '" '
|
|
|
694 |
+ ' placeholder="e.g. iPhone 17 Air" />'
|
|
|
695 |
+ ' </div></div>'
|
|
|
696 |
+ '</div>'
|
|
|
697 |
+ '<div class="oc-edit-row">'
|
|
|
698 |
+ ' <div class="oc-field" style="flex:1;min-width:260px;">'
|
|
|
699 |
+ ' <label>Why (optional, but it is what the next reviewer reads)</label>'
|
|
|
700 |
+ ' <input class="oc-input" type="text" data-oc-note style="width:100%;" />'
|
|
|
701 |
+ ' </div>'
|
|
|
702 |
+ ' <button class="oc-btn" type="button" data-oc-save="' + index + '">Save decision</button>'
|
|
|
703 |
+ ' <button class="oc-btn oc-btn-ghost" type="button" data-oc-cancel>Cancel</button>'
|
|
|
704 |
+ (d.aliasAction
|
|
|
705 |
? ' <button class="oc-btn oc-btn-ghost" type="button" data-oc-forget="'
|
|
|
706 |
+ index + '">Remove decision</button>'
|
|
|
707 |
: '')
|
|
|
708 |
+ '</div>'
|
|
|
709 |
+ '</td></tr>';
|
|
|
710 |
}
|
|
|
711 |
|
|
|
712 |
function loadResolveQueue() {
|
|
|
713 |
var documentId = currentDocumentId();
|
|
|
714 |
ocGet(ctx() + '/offerCircular/resolveQueue'
|
|
|
715 |
+ (documentId ? '?documentId=' + documentId : ''),
|
|
|
716 |
function (response) {
|
|
|
717 |
var payload;
|
|
|
718 |
try { payload = typeof response === 'string' ? JSON.parse(response) : response; }
|
|
|
719 |
catch (e) {
|
|
|
720 |
root().find('[data-oc-resolve-body]').html('<tr><td colspan="6" '
|
|
|
721 |
+ 'class="oc-empty">Could not read the decision queue.</td></tr>');
|
|
|
722 |
return;
|
|
|
723 |
}
|
|
|
724 |
state.decisions = payload.decisions || [];
|
|
|
725 |
root().find('[data-oc-resolve-count]').text(
|
|
|
726 |
payload.naming + ' naming, ' + payload.coverage + ' coverage');
|
|
|
727 |
root().find('[data-oc-resolve-pending]').text(
|
|
|
728 |
payload.pendingReingest
|
|
|
729 |
? payload.pendingReingest + ' saved, awaiting re-ingest' : '');
|
|
|
730 |
renderResolveQueue();
|
|
|
731 |
},
|
|
|
732 |
function (xhr) {
|
|
|
733 |
root().find('[data-oc-resolve-body]').html('<tr><td colspan="6" '
|
|
|
734 |
+ 'class="oc-empty">Could not load decisions: '
|
|
|
735 |
+ failMessage(xhr) + '.</td></tr>');
|
|
|
736 |
});
|
|
|
737 |
}
|
|
|
738 |
|
|
|
739 |
$(document).on('click', '.oc-root [data-oc-resolve-toggle]', function () {
|
|
|
740 |
var wrap = root().find('[data-oc-resolve-wrap]');
|
|
|
741 |
var showing = wrap.is(':visible');
|
|
|
742 |
wrap.toggle(!showing);
|
|
|
743 |
$(this).text(showing ? 'Show queue' : 'Hide queue');
|
|
|
744 |
if (!showing) { loadResolveQueue(); }
|
|
|
745 |
});
|
|
|
746 |
|
|
|
747 |
$(document).on('click', '.oc-root [data-oc-resolve]', function () {
|
|
|
748 |
var index = $(this).data('oc-resolve');
|
|
|
749 |
var tr = root().find('[data-oc-decision="' + index + '"]');
|
|
|
750 |
var next = tr.next('.oc-detail-row');
|
|
|
751 |
if (next.length) {
|
|
|
752 |
next.remove();
|
|
|
753 |
return;
|
|
|
754 |
}
|
|
|
755 |
root().find('[data-oc-resolve-body] .oc-detail-row').remove();
|
|
|
756 |
tr.after(editorRow(state.decisions[index], index));
|
|
|
757 |
});
|
|
|
758 |
|
|
|
759 |
$(document).on('click', '.oc-root [data-oc-cancel]', function () {
|
|
|
760 |
$(this).closest('.oc-detail-row').remove();
|
|
|
761 |
});
|
|
|
762 |
|
|
|
763 |
// switching action shows only the fields that action actually needs
|
|
|
764 |
$(document).on('change', '.oc-root [data-oc-act]', function () {
|
|
|
765 |
var cell = $(this).closest('[data-oc-editor]');
|
|
|
766 |
var chosen = $(this).val();
|
|
|
767 |
cell.find('[data-oc-pane]').each(function () {
|
|
|
768 |
$(this).toggle($(this).attr('data-oc-pane') === chosen);
|
|
|
769 |
});
|
|
|
770 |
});
|
|
|
771 |
|
|
|
772 |
$(document).on('input', '.oc-root [data-oc-hunt]', function () {
|
|
|
773 |
var cell = $(this).closest('[data-oc-editor]');
|
|
|
774 |
var index = cell.attr('data-oc-editor');
|
|
|
775 |
var decision = state.decisions[index];
|
|
|
776 |
var term = $(this).val();
|
|
|
777 |
var hits = cell.find('[data-oc-hits]');
|
|
|
778 |
if (!term || term.length < 2) {
|
|
|
779 |
hits.html('<div style="padding:8px 10px;" class="oc-miss">'
|
|
|
780 |
+ 'Type at least two characters.</div>');
|
|
|
781 |
return;
|
|
|
782 |
}
|
|
|
783 |
clearTimeout(state.huntTimer);
|
|
|
784 |
state.huntTimer = setTimeout(function () {
|
|
|
785 |
ocGet(ctx() + '/offerCircular/catalogSearch?divisionLabel='
|
|
|
786 |
+ encodeURIComponent(decision.divisionLabel)
|
|
|
787 |
+ '&q=' + encodeURIComponent(term),
|
|
|
788 |
function (response) {
|
|
|
789 |
var list;
|
|
|
790 |
try { list = typeof response === 'string' ? JSON.parse(response) : response; }
|
|
|
791 |
catch (e) { list = []; }
|
|
|
792 |
if (!list.length) {
|
|
|
793 |
hits.html('<div style="padding:8px 10px;" class="oc-miss">'
|
|
|
794 |
+ 'No catalog product matches, in this OEM’s brand and '
|
|
|
795 |
+ 'category. If the SKU genuinely does not exist, the right '
|
|
|
796 |
+ 'answer is Ignore, not a near-miss pin.</div>');
|
|
|
797 |
return;
|
|
|
798 |
}
|
|
|
799 |
hits.html(list.map(function (c) {
|
|
|
800 |
return '<button type="button" class="oc-hit" data-oc-pick="'
|
|
|
801 |
+ c.catalogId + '" data-oc-super="' + (c.superCatalogId || '')
|
|
|
802 |
+ '">' + esc(c.name)
|
|
|
803 |
+ ' <span class="oc-num">#' + c.catalogId + '</span>'
|
|
|
804 |
+ (c.superCatalogName
|
|
|
805 |
? ' <span class="oc-num">· ' + esc(c.superCatalogName)
|
|
|
806 |
+ '</span>' : '')
|
|
|
807 |
+ '</button>';
|
|
|
808 |
}).join(''));
|
|
|
809 |
},
|
|
|
810 |
function (xhr) {
|
|
|
811 |
hits.html('<div style="padding:8px 10px;" class="oc-miss">'
|
|
|
812 |
+ 'Search failed: ' + failMessage(xhr) + '</div>');
|
|
|
813 |
});
|
|
|
814 |
}, 250);
|
|
|
815 |
});
|
|
|
816 |
|
|
|
817 |
$(document).on('click', '.oc-root [data-oc-pick]', function () {
|
|
|
818 |
var cell = $(this).closest('[data-oc-editor]');
|
|
|
819 |
cell.find('[data-oc-pick]').attr('data-picked', '0');
|
|
|
820 |
$(this).attr('data-picked', '1');
|
|
|
821 |
cell.find('[data-oc-picked]').text('pinned to #' + $(this).data('oc-pick'));
|
|
|
822 |
});
|
|
|
823 |
|
|
|
824 |
$(document).on('click', '.oc-root [data-oc-save]', function () {
|
|
|
825 |
var index = $(this).data('oc-save');
|
|
|
826 |
var decision = state.decisions[index];
|
|
|
827 |
var cell = root().find('[data-oc-editor="' + index + '"]');
|
|
|
828 |
var action = cell.find('[data-oc-act]:checked').val();
|
|
|
829 |
|
|
|
830 |
var payload = {
|
|
|
831 |
divisionLabel: decision.divisionLabel,
|
|
|
832 |
rawText: decision.rawText,
|
|
|
833 |
action: action,
|
|
|
834 |
note: cell.find('[data-oc-note]').val() || ''
|
|
|
835 |
};
|
|
|
836 |
if (action === 'PIN') {
|
|
|
837 |
var picked = cell.find('[data-oc-pick][data-picked="1"]');
|
|
|
838 |
if (!picked.length) {
|
|
|
839 |
resolveMsg('Pick a catalog product first.');
|
|
|
840 |
return;
|
|
|
841 |
}
|
|
|
842 |
payload.catalogId = picked.data('oc-pick');
|
|
|
843 |
payload.matchLevel = 'VARIANT';
|
|
|
844 |
} else if (action === 'REWRITE') {
|
|
|
845 |
payload.canonicalText = cell.find('[data-oc-canonical]').val() || '';
|
|
|
846 |
}
|
|
|
847 |
|
|
|
848 |
var btn = $(this).prop('disabled', true).text('Saving...');
|
|
|
849 |
ocPost(ctx() + '/offerCircular/alias', payload, function (r) {
|
|
|
850 |
btn.prop('disabled', false).text('Save decision');
|
|
|
851 |
resolveMsg(r.message || '');
|
|
|
852 |
if (r.success) {
|
|
|
853 |
cell.closest('.oc-detail-row').remove();
|
|
|
854 |
loadResolveQueue();
|
|
|
855 |
}
|
|
|
856 |
}, function (xhr) {
|
|
|
857 |
btn.prop('disabled', false).text('Save decision');
|
|
|
858 |
resolveMsg('Could not save: ' + failMessage(xhr));
|
|
|
859 |
});
|
|
|
860 |
});
|
|
|
861 |
|
|
|
862 |
$(document).on('click', '.oc-root [data-oc-forget]', function () {
|
|
|
863 |
var decision = state.decisions[$(this).data('oc-forget')];
|
|
|
864 |
ocPost(ctx() + '/offerCircular/aliasRemove',
|
|
|
865 |
{ divisionLabel: decision.divisionLabel, rawText: decision.rawText },
|
|
|
866 |
function (r) {
|
|
|
867 |
resolveMsg(r.message || '');
|
|
|
868 |
root().find('[data-oc-resolve-body] .oc-detail-row').remove();
|
|
|
869 |
loadResolveQueue();
|
|
|
870 |
},
|
|
|
871 |
function (xhr) { resolveMsg('Could not remove: ' + failMessage(xhr)); });
|
|
|
872 |
});
|
|
|
873 |
|
|
|
874 |
$(document).on('click', '.oc-root [data-oc-alias-export]', function () {
|
|
|
875 |
window.location = ctx() + '/offerCircular/aliasExport';
|
|
|
876 |
});
|
|
|
877 |
|
|
|
878 |
$(document).on('click', '.oc-root [data-oc-reingest]', function () {
|
|
|
879 |
var documentId = currentDocumentId();
|
|
|
880 |
if (!documentId) {
|
|
|
881 |
resolveMsg('Select a circular first.');
|
|
|
882 |
return;
|
|
|
883 |
}
|
|
|
884 |
if (!window.confirm('Re-parse this circular so the saved decisions take effect?\n\n'
|
|
|
885 |
+ 'Everything derived from it is rebuilt from the stored PDF. Offer ids '
|
|
|
886 |
+ 'change, which nothing depends on today.')) {
|
|
|
887 |
return;
|
|
|
888 |
}
|
|
|
889 |
var btn = $(this).prop('disabled', true).text('Queueing...');
|
|
|
890 |
ocPost(ctx() + '/offerCircular/reingest', { documentId: documentId }, function (r) {
|
|
|
891 |
btn.prop('disabled', false).text('Re-ingest to apply');
|
|
|
892 |
resolveMsg(r.message || '');
|
|
|
893 |
}, function (xhr) {
|
|
|
894 |
btn.prop('disabled', false).text('Re-ingest to apply');
|
|
|
895 |
resolveMsg('Could not queue re-ingest: ' + failMessage(xhr));
|
|
|
896 |
});
|
|
|
897 |
});
|
|
|
898 |
|
| 37335 |
amit |
899 |
// ------------------------------------------------------- ingest status polling
|
|
|
900 |
//
|
| 37337 |
amit |
901 |
// Parsing runs on a background thread in this application and takes seconds, but
|
|
|
902 |
// it is still asynchronous, so the screen must say where it has got to. Without
|
|
|
903 |
// this a reviewer cannot tell "not parsed yet" from "parsed and produced nothing".
|
| 37335 |
amit |
904 |
|
| 37337 |
amit |
905 |
var PENDING_STATES = { DRAFT: 'about to parse', PROCESSING: 'parsing now' };
|
| 37335 |
amit |
906 |
|
|
|
907 |
function selectedDoc() {
|
|
|
908 |
var id = currentDocumentId();
|
|
|
909 |
if (!id) { return null; }
|
|
|
910 |
for (var i = 0; i < (state.documents || []).length; i++) {
|
|
|
911 |
if (String(state.documents[i].documentId) === String(id)) {
|
|
|
912 |
return state.documents[i];
|
|
|
913 |
}
|
|
|
914 |
}
|
|
|
915 |
return null;
|
|
|
916 |
}
|
|
|
917 |
|
|
|
918 |
function renderIngestState() {
|
|
|
919 |
var box = root().find('[data-oc-ingest-state]');
|
|
|
920 |
var doc = selectedDoc();
|
|
|
921 |
if (!doc) { box.empty(); return; }
|
|
|
922 |
|
|
|
923 |
if (PENDING_STATES[doc.status]) {
|
|
|
924 |
box.html('<span class="oc-pill oc-pill-warn">' + esc(PENDING_STATES[doc.status])
|
| 37337 |
amit |
925 |
+ '</span> <span class="oc-num">usually a few seconds; '
|
| 37335 |
amit |
926 |
+ 'this refreshes itself</span>');
|
|
|
927 |
} else if (doc.status === 'FAILED') {
|
|
|
928 |
box.html('<span class="oc-pill oc-pill-danger">ingest failed</span> '
|
|
|
929 |
+ '<span class="oc-num">' + esc(doc.ingestError || 'no reason recorded')
|
|
|
930 |
+ '</span>');
|
|
|
931 |
} else if (doc.status === 'PUBLISHED') {
|
|
|
932 |
box.html('<span class="oc-pill oc-pill-ok">parsed</span> '
|
|
|
933 |
+ '<span class="oc-num">' + esc(doc.ingestSummary || '') + '</span>');
|
|
|
934 |
} else {
|
|
|
935 |
box.html('<span class="oc-pill oc-pill-muted">' + esc(doc.status) + '</span>');
|
|
|
936 |
}
|
|
|
937 |
}
|
|
|
938 |
|
|
|
939 |
function schedulePoll() {
|
|
|
940 |
clearTimeout(state.pollTimer);
|
|
|
941 |
var doc = selectedDoc();
|
|
|
942 |
if (!doc || !PENDING_STATES[doc.status]) {
|
|
|
943 |
return; // settled - stop polling, do not spin forever
|
|
|
944 |
}
|
|
|
945 |
state.pollTimer = setTimeout(function () {
|
|
|
946 |
ocGet(ctx() + '/offerCircular/documents', function (response) {
|
|
|
947 |
var docs;
|
|
|
948 |
try { docs = typeof response === 'string' ? JSON.parse(response) : response; }
|
|
|
949 |
catch (e) { return; }
|
|
|
950 |
var was = selectedDoc();
|
|
|
951 |
state.documents = docs || [];
|
|
|
952 |
var now = selectedDoc();
|
|
|
953 |
renderIngestState();
|
|
|
954 |
// it just finished: pull in the rows the parse produced
|
|
|
955 |
if (was && now && was.status !== now.status && !PENDING_STATES[now.status]) {
|
|
|
956 |
loadRows();
|
|
|
957 |
return;
|
|
|
958 |
}
|
|
|
959 |
schedulePoll();
|
|
|
960 |
}, function () { schedulePoll(); }); // transient failure - keep waiting
|
| 37337 |
amit |
961 |
}, 3000);
|
| 37335 |
amit |
962 |
}
|
|
|
963 |
|
| 37331 |
amit |
964 |
// direct navigation: the fragment is already on the page
|
|
|
965 |
if (root().length > 0) {
|
|
|
966 |
loadRows();
|
|
|
967 |
}
|
|
|
968 |
});
|