Subversion Repositories SmartDukaan

Rev

Details | Last modification | View Log | RSS feed

Rev Author Line No. Line
37526 amit 1
/**
2
 * Offer circular - resolve products.
3
 *
4
 * The product-resolution queue, split out of the review screen into its own page. The
5
 * editor used to be an expanded table row, which is why it never lined up: a form was
6
 * being laid out inside a grid built for six columns. Here the queue is the list and the
7
 * decision is a panel beside it.
8
 *
9
 * Namespaced .ocr-* / data-ocr-* throughout. The review screen delegates its handlers
10
 * from document on .oc-root, so sharing a prefix would let both screens' handlers fire on
11
 * whichever fragment happened to be injected.
12
 *
13
 * A decision writes offers.product_alias, which is read at INGEST time - so nothing on
14
 * this screen changes what a circular currently says until it is re-ingested.
15
 */
16
$(function () {
17
 
18
    var state = { decisions: [], tab: 'NAMING', selected: null, documents: [],
19
                  brand: '', huntTimer: null, picked: null, asNaming: {} };
20
 
21
    function ctx() {
22
        return (typeof context !== 'undefined' && context) ? context : (window.ocrContext || '');
23
    }
24
 
25
    function root() { return $('.ocr-root'); }
26
 
27
    function esc(value) {
28
        if (value === null || value === undefined) { return ''; }
29
        return $('<span>').text(String(value)).html();
30
    }
31
 
32
    function ocrGet(url, onDone, onFail) {
33
        $.ajax({ url: url, type: 'GET', cache: false }).done(onDone).fail(onFail || function () {});
34
    }
35
 
36
    function ocrPost(url, data, onDone, onFail) {
37
        $.ajax({ url: url, type: 'POST', data: data }).done(onDone).fail(onFail || function () {});
38
    }
39
 
40
    function payloadOf(response) {
41
        if (typeof response === 'string') {
42
            try { return JSON.parse(response); } catch (e) { return null; }
43
        }
44
        return response;
45
    }
46
 
47
    function failMessage(xhr) {
48
        if (xhr.status === 0) { return 'could not reach the server'; }
49
        if (xhr.status === 404) { return 'endpoint not found (404) - is this build deployed?'; }
50
        if (xhr.status === 403 || xhr.status === 401) { return 'not authorised (' + xhr.status + ')'; }
51
        return 'server returned status ' + xhr.status;
52
    }
53
 
54
    function msg(text, bad) {
55
        root().find('[data-ocr-msg]')
56
              .attr('class', 'ocr-msg ' + (text ? (bad ? 'ocr-msg-bad' : 'ocr-msg-ok') : ''))
57
              .text(text || '');
58
    }
59
 
60
    function currentDocumentId() {
61
        return root().find('[data-ocr-doc]').val() || '';
62
    }
63
 
64
    // ------------------------------------------------------------------ queue list
65
 
66
    function inTab(d) {
67
        return (d.decisionType || 'NAMING') === state.tab;
68
    }
69
 
70
    function visibleDecisions() {
71
        return state.decisions.filter(function (d) {
72
            return inTab(d) && (!state.brand || d.divisionLabel === state.brand);
73
        });
74
    }
75
 
76
    /*
77
     * Brands are listed per TAB, not across the whole queue, and each carries its own
78
     * count. A brand with nothing left in this tab should not be offered - picking it
79
     * would empty the list and read as a broken filter rather than as "no work here".
80
     * The selection is dropped when the brand leaves the tab, for the same reason.
81
     */
82
    function renderBrandFilter() {
83
        var counts = {};
84
        state.decisions.forEach(function (d) {
85
            if (!inTab(d)) { return; }
86
            var label = d.divisionLabel || '';
87
            counts[label] = (counts[label] || 0) + 1;
88
        });
89
        var labels = Object.keys(counts).sort();
90
        if (state.brand && counts[state.brand] === undefined) {
91
            state.brand = '';
92
        }
93
        var total = state.decisions.filter(inTab).length;
94
        var html = '<option value="">All OEMs (' + total + ')</option>'
95
            + labels.map(function (label) {
96
                return '<option value="' + esc(label) + '"'
97
                    + (state.brand === label ? ' selected' : '') + '>'
98
                    + esc(label) + ' (' + counts[label] + ')</option>';
99
            }).join('');
100
        root().find('[data-ocr-brand]').html(html).val(state.brand);
101
    }
102
 
103
    function decisionState(d) {
104
        if (!d.aliasAction) {
105
            return '<span class="ocr-pill ocr-pill-todo">undecided</span>';
106
        }
107
        var target = d.aliasCanonicalText
108
            || (d.aliasCatalogId ? '#' + d.aliasCatalogId : '')
109
            || (d.aliasSuperCatalogId ? 'model #' + d.aliasSuperCatalogId : '');
110
        // "pending" is literal: ProductAliases loads at ingest time, so a saved decision
111
        // genuinely has not changed anything yet.
112
        return '<span class="ocr-pill ocr-pill-done">' + esc(d.aliasAction) + '</span>'
113
            + (target ? ' <span class="ocr-num">' + esc(target) + '</span>' : '')
114
            + '<div class="ocr-num">pending re-ingest</div>';
115
    }
116
 
117
    function renderQueue() {
118
        var body = root().find('[data-ocr-body]');
119
        renderBrandFilter();
120
        var rows = visibleDecisions();
121
        var note = root().find('[data-ocr-listnote]');
122
 
123
        if (state.tab === 'COVERAGE') {
124
            note.text('an alias cannot answer these');
125
        } else {
126
            note.text(rows.length + (rows.length === 1 ? ' decision' : ' decisions'));
127
        }
128
 
129
        if (!rows.length) {
130
            var empty = state.brand
131
                ? 'Nothing left to resolve for ' + esc(state.brand) + ' here.'
132
                : (state.tab === 'COVERAGE'
133
                    ? 'No bundle rows on this circular.'
134
                    : 'Nothing left to resolve here.');
135
            body.html('<tr><td colspan="5" class="ocr-empty">' + empty + '</td></tr>');
136
            return;
137
        }
138
 
139
        body.html(rows.map(function (d) {
140
            var i = state.decisions.indexOf(d);
141
            var coverage = (d.decisionType || 'NAMING') === 'COVERAGE';
142
            // Coverage rows are reviewable too - they carry two safe actions - so they get
143
            // the same affordance. Previously they had no button and no pointer, which
144
            // read as "this row is inert".
145
            var action = '<button class="ocr-btn ocr-btn-sm" type="button" '
146
                + 'data-ocr-pick-row="' + i + '">'
147
                + (coverage ? 'Review' : (d.aliasAction ? 'Change' : 'Resolve')) + '</button>';
148
            // ONE class attribute. Emitting two made the browser ignore the second, so the
149
            // selected row never highlighted.
150
            var cls = ['ocr-rowlink'];
151
            if (state.selected === i) { cls.push('ocr-row-on'); }
152
            return '<tr data-ocr-row="' + i + '" class="' + cls.join(' ') + '">'
153
                + '<td class="ocr-product">' + esc(d.rawText) + '</td>'
154
                + '<td>' + esc(d.divisionLabel) + '</td>'
155
                + '<td class="ocr-affects">' + esc(d.rowCount) + ' rows &middot; '
156
                + esc(d.offerCount) + ' offers</td>'
157
                + '<td>' + (coverage
158
                    ? '<span class="ocr-pill ocr-pill-muted">coverage</span>'
159
                    : decisionState(d)) + '</td>'
160
                + '<td class="ocr-col-act">' + action + '</td>'
161
                + '</tr>';
162
        }).join(''));
163
    }
164
 
165
    // ------------------------------------------------------------------ decision panel
166
 
167
    function choice(value, title, hint, checked) {
168
        return '<label class="ocr-choice' + (checked ? ' is-on' : '') + '" data-ocr-choice="'
169
            + value + '">'
170
            + '<input type="radio" name="ocr-action" value="' + value + '" '
171
            + (checked ? 'checked' : '') + ' data-ocr-act />'
172
            + '<span class="ocr-choice-text"><b>' + title + '</b><span>' + hint + '</span></span>'
173
            + '</label>';
174
    }
175
 
176
    function renderPanel() {
177
        var wrap = root().find('[data-ocr-panel-body]');
178
        var d = state.selected === null ? null : state.decisions[state.selected];
179
        root().find('[data-ocr-panel-state]').text('');
180
 
181
        if (!d) {
182
            wrap.html('<div class="ocr-panel-empty">Pick a product on the left to resolve it.</div>');
183
            return;
184
        }
185
        // Coverage entries cannot be answered by an alias, but "no answer available" is
186
        // not the same as "nothing to do". Two things ARE safe and available today, so
187
        // they are offered rather than leaving the panel a dead end.
188
        if ((d.decisionType || 'NAMING') === 'COVERAGE' && !state.asNaming[state.selected]) {
189
            root().find('[data-ocr-panel-state]').text('needs a coverage call');
190
            wrap.html('<div class="ocr-panel-body">'
191
                + '<div class="ocr-subject">' + esc(d.rawText) + '</div>'
192
                + '<div class="ocr-context">' + esc(d.divisionLabel) + ' &middot; '
193
                + esc(d.rowCount) + ' rows &middot; ' + esc(d.offerCount) + ' offers</div>'
194
 
195
                + '<div class="ocr-note">The cashback here is quoted for the phone '
196
                + '<em>plus</em> the accessory. Whether it also applies to the phone sold '
197
                + 'on its own is a commercial call the circular does not make, and that '
198
                + 'needs a per-offer answer, which does not exist yet.</div>'
199
 
200
                + '<div class="ocr-choices" style="margin-top:14px;">'
201
                + '<label class="ocr-choice" data-ocr-cov-choice="IGNORE">'
202
                + '<input type="radio" name="ocr-coverage" value="IGNORE" data-ocr-cov />'
203
                + '<span class="ocr-choice-text"><b>We do not stock this bundle</b>'
204
                + '<span>Safe: it is a statement about the name, so it needs no coverage '
205
                + 'call, and it stops this reappearing every month.</span></span></label>'
206
                + '<label class="ocr-choice" data-ocr-cov-choice="NAMING">'
207
                + '<input type="radio" name="ocr-coverage" value="NAMING" data-ocr-cov />'
208
                + '<span class="ocr-choice-text"><b>This is not a bundle</b>'
209
                + '<span>The suffix was mis-read as an accessory. Resolve it as a normal '
210
                + 'name instead.</span></span></label>'
211
                + '</div>'
212
 
213
                + '<div class="ocr-field">'
214
                + '  <label>Why</label>'
215
                + '  <input class="ocr-input" type="text" data-ocr-note '
216
                + '         placeholder="optional \u2014 the next reviewer reads this" />'
217
                + '</div>'
218
 
219
                + '<div class="ocr-actions">'
220
                + '  <button class="ocr-btn ocr-btn-primary" type="button" data-ocr-cov-save>Save decision</button>'
221
                + '  <button class="ocr-btn" type="button" data-ocr-clear>Cancel</button>'
222
                + '</div>'
223
                + '<p class="ocr-help">Leaving it alone is a valid choice: the offer stays '
224
                + 'unmatched, so no partner is shown cashback we might not honour. Nothing '
225
                + 'is over-credited by waiting.</p>'
226
                + '<div class="ocr-msg" data-ocr-msg></div>'
227
                + '</div>');
228
            return;
229
        }
230
 
231
        var action = d.aliasAction || 'PIN';
232
        root().find('[data-ocr-panel-state]').text(d.aliasAction ? 'saved' : 'undecided');
233
 
234
        wrap.html('<div class="ocr-panel-body">'
235
            + '<div class="ocr-subject">' + esc(d.rawText) + '</div>'
236
            + '<div class="ocr-context">' + esc(d.divisionLabel) + ' &middot; '
237
            + esc(d.rowCount) + ' rows &middot; ' + esc(d.offerCount) + ' offers'
238
            + (d.closestCatalogName
239
                ? ' &middot; closest miss ' + esc(d.closestCatalogName)
240
                  + ' (' + esc(d.closestScore) + ')'
241
                : ' &middot; no candidate') + '</div>'
242
 
243
            + '<div class="ocr-choices">'
244
            + choice('PIN', 'Pin to a catalog product',
245
                     'The SKU exists under a different name', action === 'PIN')
246
            + choice('REWRITE', 'Rewrite to the catalog spelling',
247
                     'Same product, different wording', action === 'REWRITE')
248
            + choice('IGNORE', 'Not ours — ignore',
249
                     'Accessory, or a SKU we do not carry', action === 'IGNORE')
250
            + '</div>'
251
 
252
            + '<div data-ocr-pane="PIN"' + (action === 'PIN' ? '' : ' style="display:none;"') + '>'
253
            + '  <div class="ocr-field">'
254
            + '    <label>Find the product in ' + esc(d.divisionLabel) + '</label>'
255
            + '    <input class="ocr-input" type="text" data-ocr-hunt '
256
            + '           placeholder="type part of the model name" />'
257
            + '    <div class="ocr-hits" data-ocr-hits>'
258
            + '      <div class="ocr-hint">Type at least two characters to search this '
259
            + '        OEM’s catalog.</div>'
260
            + '    </div>'
261
            + (d.closestCatalogName
262
                ? '    <p class="ocr-help">The closest miss is shown for context only. When '
263
                  + 'the catalog genuinely lacks the SKU, Ignore is the right answer, not a '
264
                  + 'near-miss pin.</p>'
265
                : '')
266
            + '  </div>'
267
            + '</div>'
268
 
269
            + '<div data-ocr-pane="REWRITE"' + (action === 'REWRITE' ? '' : ' style="display:none;"') + '>'
270
            + '  <div class="ocr-field">'
271
            + '    <label>Catalog spelling</label>'
272
            + '    <input class="ocr-input" type="text" data-ocr-canonical '
273
            + '           value="' + esc(d.aliasCanonicalText || '') + '" '
274
            + '           placeholder="e.g. iPhone 17 Air" />'
275
            + '    <p class="ocr-help">The variant spec carries over automatically, so write '
276
            + '       the model name only.</p>'
277
            + '  </div>'
278
            + '</div>'
279
 
280
            + '<div class="ocr-field">'
281
            + '  <label>Why</label>'
282
            + '  <input class="ocr-input" type="text" data-ocr-note '
283
            + '         placeholder="optional — the next reviewer reads this" />'
284
            + '</div>'
285
 
286
            + '<div class="ocr-actions">'
287
            + '  <button class="ocr-btn ocr-btn-primary" type="button" data-ocr-save>Save decision</button>'
288
            + '  <button class="ocr-btn" type="button" data-ocr-clear>Cancel</button>'
289
            + (d.aliasAction
290
                ? '  <button class="ocr-btn" type="button" data-ocr-forget>Remove decision</button>'
291
                : '')
292
            + '</div>'
293
            + '<div class="ocr-msg" data-ocr-msg></div>'
294
            + '</div>');
295
 
296
        state.picked = d.aliasCatalogId || null;
297
        if (state.picked) {
298
            root().find('[data-ocr-hits]').html('<div class="ocr-hint">Currently pinned to #'
299
                + esc(state.picked) + '. Search to pin a different product.</div>');
300
        }
301
    }
302
 
303
    // ------------------------------------------------------------------ loading
304
 
305
    function loadQueue() {
306
        var documentId = currentDocumentId();
307
        ocrGet(ctx() + '/offerCircular/resolveQueue'
308
                + (documentId ? '?documentId=' + documentId : ''),
309
            function (response) {
310
                var payload = payloadOf(response);
311
                if (!payload) {
312
                    root().find('[data-ocr-body]').html('<tr><td colspan="5" class="ocr-empty">'
313
                        + 'Could not read the decision queue.</td></tr>');
314
                    return;
315
                }
316
                state.decisions = payload.decisions || [];
317
                state.selected = null;
318
                root().find('[data-ocr-count-naming]').text(payload.naming);
319
                root().find('[data-ocr-count-coverage]').text(payload.coverage);
320
                var pending = root().find('[data-ocr-pending]');
321
                if (payload.pendingReingest) {
322
                    pending.text(payload.pendingReingest + ' saved, awaiting re-ingest').show();
323
                } else {
324
                    pending.hide();
325
                }
326
                renderQueue();
327
                renderPanel();
328
            },
329
            function (xhr) {
330
                root().find('[data-ocr-body]').html('<tr><td colspan="5" class="ocr-empty">'
331
                    + 'Could not load decisions: ' + esc(failMessage(xhr)) + '</td></tr>');
332
            });
333
    }
334
 
335
    function loadDocuments() {
336
        ocrGet(ctx() + '/offerCircular/documents', function (response) {
337
            var payload = payloadOf(response);
338
            var docs = (payload && (payload.documents || payload)) || [];
339
            var sel = root().find('[data-ocr-doc]');
340
            sel.empty().append($('<option>').attr('value', '').text('All circulars'));
341
            docs.forEach(function (doc) {
342
                sel.append($('<option>').attr('value', doc.documentId)
343
                    .text(doc.title + ' (' + doc.status + ')'));
344
            });
345
            loadQueue();
346
        }, function () { loadQueue(); });
347
    }
348
 
349
    // ------------------------------------------------------------------ handlers
350
 
351
    $(document).on('click', '.ocr-root [data-ocr-tab]', function () {
352
        state.tab = $(this).data('ocr-tab');
353
        state.selected = null;
354
        root().find('[data-ocr-tab]').removeClass('is-on');
355
        $(this).addClass('is-on');
356
        renderQueue();
357
        renderPanel();
358
    });
359
 
360
    // Filtering never re-fetches: the whole queue is already in state, and a round trip
361
    // would drop the panel's unsaved selection. Clearing the selection is deliberate -
362
    // the selected row can be one the filter just hid.
363
    $(document).on('change', '.ocr-root [data-ocr-brand]', function () {
364
        state.brand = $(this).val() || '';
365
        state.selected = null;
366
        renderQueue();
367
        renderPanel();
368
    });
369
 
370
    $(document).on('change', '.ocr-root [data-ocr-doc]', loadQueue);
371
 
372
    function select(index) {
373
        state.selected = index;
374
        root().find('[data-ocr-row]').removeClass('ocr-row-on');
375
        root().find('[data-ocr-row="' + index + '"]').addClass('ocr-row-on');
376
        renderPanel();
377
    }
378
 
379
    $(document).on('click', '.ocr-root [data-ocr-pick-row]', function (e) {
380
        e.stopPropagation();
381
        select($(this).data('ocr-pick-row'));
382
    });
383
 
384
    $(document).on('click', '.ocr-root tr[data-ocr-row]', function () {
385
        select($(this).data('ocr-row'));
386
    });
387
 
388
    $(document).on('change', '.ocr-root [data-ocr-act]', function () {
389
        var chosen = $(this).val();
390
        root().find('[data-ocr-pane]').each(function () {
391
            $(this).toggle($(this).attr('data-ocr-pane') === chosen);
392
        });
393
        root().find('[data-ocr-choice]').removeClass('is-on');
394
        root().find('[data-ocr-choice="' + chosen + '"]').addClass('is-on');
395
    });
396
 
397
    $(document).on('input', '.ocr-root [data-ocr-hunt]', function () {
398
        var d = state.decisions[state.selected];
399
        var term = $(this).val();
400
        var hits = root().find('[data-ocr-hits]');
401
        if (!term || term.length < 2) {
402
            hits.html('<div class="ocr-hint">Type at least two characters to search this '
403
                + 'OEM’s catalog.</div>');
404
            return;
405
        }
406
        clearTimeout(state.huntTimer);
407
        state.huntTimer = setTimeout(function () {
408
            ocrGet(ctx() + '/offerCircular/catalogSearch?divisionLabel='
409
                    + encodeURIComponent(d.divisionLabel) + '&q=' + encodeURIComponent(term),
410
                function (response) {
411
                    var list = payloadOf(response) || [];
412
                    if (!list.length) {
413
                        hits.html('<div class="ocr-hint">No catalog product matches in '
414
                            + esc(d.divisionLabel) + '’s brand and category. If the SKU '
415
                            + 'genuinely does not exist, Ignore is the right answer.</div>');
416
                        return;
417
                    }
418
                    hits.html(list.map(function (c) {
419
                        return '<button type="button" class="ocr-hit" data-ocr-pick="'
420
                            + c.catalogId + '">' + esc(c.name)
421
                            + ' <span class="ocr-num">#' + c.catalogId + '</span>'
422
                            + (c.superCatalogName
423
                                ? ' <span class="ocr-num">&middot; ' + esc(c.superCatalogName)
424
                                    + '</span>' : '')
425
                            + '</button>';
426
                    }).join(''));
427
                },
428
                function (xhr) {
429
                    hits.html('<div class="ocr-hint">Search failed: '
430
                        + esc(failMessage(xhr)) + '</div>');
431
                });
432
        }, 250);
433
    });
434
 
435
    $(document).on('click', '.ocr-root [data-ocr-pick]', function () {
436
        root().find('[data-ocr-pick]').attr('data-picked', '0');
437
        $(this).attr('data-picked', '1');
438
        state.picked = $(this).data('ocr-pick');
439
        msg('');
440
    });
441
 
442
    $(document).on('change', '.ocr-root [data-ocr-cov]', function () {
443
        var chosen = $(this).val();
444
        root().find('[data-ocr-cov-choice]').removeClass('is-on');
445
        root().find('[data-ocr-cov-choice="' + chosen + '"]').addClass('is-on');
446
    });
447
 
448
    $(document).on('click', '.ocr-root [data-ocr-cov-save]', function () {
449
        var d = state.decisions[state.selected];
450
        var chosen = root().find('[data-ocr-cov]:checked').val();
451
        if (!chosen) {
452
            msg('Pick one of the two options first.', true);
453
            return;
454
        }
455
        // "not a bundle" is a reclassification, not a saved decision - it just hands the
456
        // entry to the normal naming flow for this session.
457
        if (chosen === 'NAMING') {
458
            state.asNaming[state.selected] = true;
459
            renderPanel();
460
            return;
461
        }
462
        var btn = $(this).prop('disabled', true).text('Saving\u2026');
463
        ocrPost(ctx() + '/offerCircular/alias',
464
            { divisionLabel: d.divisionLabel, rawText: d.rawText, action: 'IGNORE',
465
              note: root().find('[data-ocr-note]').val() || '' },
466
            function (response) {
467
                var r = payloadOf(response) || {};
468
                btn.prop('disabled', false).text('Save decision');
469
                if (r.success === false) { msg(r.message || 'Could not save', true); return; }
470
                loadQueue();
471
            },
472
            function (xhr) {
473
                btn.prop('disabled', false).text('Save decision');
474
                msg('Could not save: ' + failMessage(xhr), true);
475
            });
476
    });
477
 
478
    $(document).on('click', '.ocr-root [data-ocr-clear]', function () {
479
        state.selected = null;
480
        root().find('[data-ocr-row]').removeClass('ocr-row-on');
481
        renderPanel();
482
    });
483
 
484
    $(document).on('click', '.ocr-root [data-ocr-save]', function () {
485
        var d = state.decisions[state.selected];
486
        if (!d) { return; }
487
        var action = root().find('[data-ocr-act]:checked').val();
488
        var payload = {
489
            divisionLabel: d.divisionLabel,
490
            rawText: d.rawText,
491
            action: action,
492
            note: root().find('[data-ocr-note]').val() || ''
493
        };
494
        if (action === 'PIN') {
495
            if (!state.picked) {
496
                msg('Pick a catalog product first.', true);
497
                return;
498
            }
499
            payload.catalogId = state.picked;
500
            payload.matchLevel = 'VARIANT';
501
        } else if (action === 'REWRITE') {
502
            payload.canonicalText = root().find('[data-ocr-canonical]').val() || '';
503
        }
504
 
505
        var btn = $(this).prop('disabled', true).text('Saving…');
506
        ocrPost(ctx() + '/offerCircular/alias', payload, function (response) {
507
            var r = payloadOf(response) || {};
508
            btn.prop('disabled', false).text('Save decision');
509
            if (r.success === false) {
510
                msg(r.message || 'Could not save', true);
511
                return;
512
            }
513
            loadQueue();
514
        }, function (xhr) {
515
            btn.prop('disabled', false).text('Save decision');
516
            msg('Could not save: ' + failMessage(xhr), true);
517
        });
518
    });
519
 
520
    $(document).on('click', '.ocr-root [data-ocr-forget]', function () {
521
        var d = state.decisions[state.selected];
522
        if (!d) { return; }
523
        ocrPost(ctx() + '/offerCircular/aliasRemove',
524
            { divisionLabel: d.divisionLabel, rawText: d.rawText },
525
            function () { loadQueue(); },
526
            function (xhr) { msg('Could not remove: ' + failMessage(xhr), true); });
527
    });
528
 
529
    $(document).on('click', '.ocr-root [data-ocr-export]', function () {
530
        window.location = ctx() + '/offerCircular/aliasExport';
531
    });
532
 
533
    $(document).on('click', '.ocr-root [data-ocr-reingest]', function () {
534
        var documentId = currentDocumentId();
535
        if (!documentId) {
536
            alert('Pick a single circular first - re-ingest re-parses one circular.');
537
            return;
538
        }
539
        if (!window.confirm('Re-parse this circular so the saved decisions take effect?\n\n'
540
                + 'It re-reads the stored PDF and rebuilds this circular\'s rows. Saved '
541
                + 'decisions and scope config are not touched.')) {
542
            return;
543
        }
544
        var btn = $(this).prop('disabled', true).text('Re-ingesting…');
545
        ocrPost(ctx() + '/offerCircular/reingest', { documentId: documentId },
546
            function (response) {
547
                var r = payloadOf(response) || {};
548
                btn.prop('disabled', false).text('Re-ingest to apply');
549
                alert(r.message || 'Re-ingest queued.');
550
                loadQueue();
551
            },
552
            function (xhr) {
553
                btn.prop('disabled', false).text('Re-ingest to apply');
554
                alert('Could not re-ingest: ' + failMessage(xhr));
555
            });
556
    });
557
 
558
    function openOfferCircularResolve(domId) {
559
        ocrGet(ctx() + '/offerCircularResolve', function (response) {
560
            $('#' + (domId || 'main-content')).html(response);
561
            state = { decisions: [], tab: 'NAMING', selected: null, documents: [],
562
                      huntTimer: null, picked: null, asNaming: {} };
563
            loadDocuments();
564
        });
565
    }
566
    window.openOfferCircularResolve = openOfferCircularResolve;
567
    window.initOfferCircularResolve = loadDocuments;
568
 
569
    $(document).on('click', '.offer-circular-resolve', function (e) {
570
        e.preventDefault();
571
        openOfferCircularResolve('main-content');
572
    });
573
});