Last active
April 24, 2026 15:28
-
-
Save apotema/e65ac0e39535264855c19b37e17bb52c to your computer and use it in GitHub Desktop.
RFC #349 — Mass formula updates · interactive prototype (Direction A modal)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!doctype html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <title>RFC #349 — Mass Formula Updates · Prototype</title> | |
| <meta name="viewport" content="width=device-width,initial-scale=1"> | |
| <!-- Pull the actual compiled stylesheet from manwah-development so the | |
| prototype matches the live app exactly. Fingerprint pins to a | |
| specific deploy; regenerate if assets change. --> | |
| <link rel="stylesheet" href="https://manwah-development-347fcf73ac86.herokuapp.com/assets/application-ca2e3da3adfa8c9f708138ae51f034a16a24f5872db68607a923b4da7f9954de.css"> | |
| <!-- Font Awesome for topbar icons --> | |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css"> | |
| <style> | |
| /* Prototype-only additions on top of the real Angle theme CSS */ | |
| .proto-banner { background: #fff8e1; border-bottom: 1px solid #ffd54f; | |
| padding: 8px 24px; font-size: 12px; color: #7c5b00; position: relative; z-index: 1100; } | |
| .proto-banner code { background: rgba(0,0,0,.05); padding: 1px 5px; border-radius: 3px; } | |
| .num { text-align: right; font-variant-numeric: tabular-nums; } | |
| .pill-count { background: #eef4fc; color: #3a7fc7; padding: 4px 10px; border-radius: 12px; | |
| font-size: 12px; font-weight: 600; } | |
| .preview-summary { background: #eef4fc; border-radius: 4px; padding: 10px 14px; | |
| margin: 12px 0; color: #3a7fc7; font-size: 13px; } | |
| .preview-summary strong { color: #2a5d8f; font-weight: 600; } | |
| .change-up { color: #27c24c; font-weight: 600; } | |
| .change-down { color: #f05050; font-weight: 600; } | |
| .help-text { font-size: 12px; color: #909fa7; margin: 8px 0 0; } | |
| .confirm-input { max-width: 140px; } | |
| .toast-undo { position: fixed; bottom: 24px; right: 24px; background: #dff5e3; | |
| color: #186a3b; border: 1px solid #27c24c; border-radius: 6px; | |
| padding: 12px 16px; font-size: 13px; display: none; max-width: 380px; | |
| box-shadow: 0 4px 14px rgba(0,0,0,.08); z-index: 2000; } | |
| .toast-undo.open { display: block; } | |
| .toast-undo a { color: #186a3b; font-weight: 600; cursor: pointer; text-decoration: underline; } | |
| /* Minimal topbar shell — matches the Angle topnavbar class names used in | |
| layouts/application.html.erb + _topnavbar.html.erb. No sidebar. */ | |
| .proto-topbar-wrapper { position: relative; z-index: 1000; } | |
| .proto-topbar-wrapper .topnavbar { background: #fff; border-bottom: 1px solid #cfdbe2; | |
| padding: 10px 20px; display: flex; align-items: center; gap: 20px; margin: 0; } | |
| .proto-topbar-wrapper .brand-label { font-weight: 700; color: #3a7fc7; font-size: 18px; } | |
| .proto-topbar-wrapper .user { margin-left: auto; color: #6c757d; font-size: 13px; } | |
| .proto-layout { background: #f5f7fa; min-height: calc(100vh - 60px); padding: 0; } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="proto-banner"> | |
| <strong>Prototype</strong> — RFC #349 Direction A (formula-based bulk price update). | |
| This page is a <strong>representation of how the UX would work</strong>, not a final design: the filter row stands in for the full per-column search the real grid supports, data is hard-coded, and "Apply" writes only to in-memory state. | |
| Stylesheet is loaded from <code>manwah-development</code> so the prototype matches the live app exactly. | |
| </div> | |
| <div class="proto-topbar-wrapper"> | |
| <nav class="navbar topnavbar"> | |
| <span class="brand-label">ManWah Portal</span> | |
| <span class="text-muted small">Admin · Prices</span> | |
| <span class="user">Signed in as <strong>alexandre@yohdev.com</strong></span> | |
| </nav> | |
| </div> | |
| <section class="section-container"> | |
| <div class="content-wrapper proto-layout"> | |
| <div class="content-heading"> | |
| Prices <small>Apply a formula to a filtered subset</small> | |
| </div> | |
| <div class="container-fluid"> | |
| <div class="card" id="prices-card"> | |
| <div class="card-header"> | |
| <div class="row no-gutters"> | |
| <!-- Mirrors the existing toolbar that holds "New Price" + import buttons --> | |
| <div class="col-auto mr-2"> | |
| <button class="btn btn-primary" disabled title="(Existing button — disabled in prototype)"> | |
| <i>+</i> New Price | |
| </button> | |
| </div> | |
| <div class="col-auto mr-2"> | |
| <button class="btn btn-secondary" disabled title="(Existing button — disabled in prototype)"> | |
| Import / Export | |
| </button> | |
| </div> | |
| <!-- New button — the entry point for this RFC --> | |
| <div class="col-auto ml-auto"> | |
| <button class="btn btn-success" id="open-modal" data-toggle="modal" data-target="#bulkModal"> | |
| <i>∑</i> Bulk Update Matching | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="card-body"> | |
| <!-- Lightweight filter row (the real app uses DataTables filters; this stands in) --> | |
| <div class="filter-row mb-3"> | |
| <div class="form-group"> | |
| <label class="small text-muted mb-1">Model</label> | |
| <select class="form-control form-control-sm" id="filter-model"> | |
| <option value="">All models</option> | |
| </select> | |
| </div> | |
| <div class="form-group"> | |
| <label class="small text-muted mb-1">Factory</label> | |
| <select class="form-control form-control-sm" id="filter-factory"> | |
| <option value="">All</option> | |
| <option value="Vietnam" selected>Vietnam</option> | |
| <option value="China">China</option> | |
| <option value="Mexico">Mexico</option> | |
| </select> | |
| </div> | |
| <div class="form-group"> | |
| <label class="small text-muted mb-1">Cover Type</label> | |
| <select class="form-control form-control-sm" id="filter-cover-type"> | |
| <option value="">All</option> | |
| <option value="Fabric" selected>Fabric</option> | |
| <option value="L/PVC">L/PVC</option> | |
| <option value="All Leather">All Leather</option> | |
| </select> | |
| </div> | |
| <div class="form-group ml-auto"> | |
| <span class="pill-count" id="row-count">— matches</span> | |
| </div> | |
| </div> | |
| <table class="table table-striped table-bordered table-hover" id="prices-table" width="100%"> | |
| <thead> | |
| <tr> | |
| <th>Model</th> | |
| <th>Cover Grade</th> | |
| <th>SKU</th> | |
| <th class="num">Cubes</th> | |
| <th class="num">Length</th> | |
| <th class="num">Depth</th> | |
| <th class="num">Height</th> | |
| <th class="num">Price</th> | |
| <th>Cover Type</th> | |
| <th>FOB</th> | |
| <th>Factory</th> | |
| <th>Active</th> | |
| <th>Tier</th> | |
| <th>Edit</th> | |
| </tr> | |
| </thead> | |
| <tbody></tbody> | |
| </table> | |
| <p class="help-text"> | |
| Showing first 8 matches. The real grid uses DataTables with server-side pagination | |
| (<code>app/datatables/price_datatable.rb</code>) and supports per-column search/sort. | |
| </p> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- ============================================================ --> | |
| <!-- Bulk Update modal (modal-xl matches existing prices form modal) --> | |
| <div class="modal fade" id="bulkModal" tabindex="-1" role="dialog" aria-hidden="true"> | |
| <div class="modal-dialog modal-xl" role="document"> | |
| <div class="modal-content"> | |
| <div class="modal-header"> | |
| <h4 class="modal-title">Apply Formula to <span id="modal-count">0</span> Prices</h4> | |
| <button type="button" class="close" data-dismiss="modal" aria-label="Close"> | |
| <span aria-hidden="true">×</span> | |
| </button> | |
| </div> | |
| <div class="modal-body"> | |
| <p class="text-muted small mb-3" id="modal-filters">—</p> | |
| <div class="form-row"> | |
| <div class="form-group col-md-3"> | |
| <label>Operation</label> | |
| <select class="form-control form-control-sm" id="op-operation"> | |
| <option value="pct_increase" selected>Increase by %</option> | |
| <option value="pct_decrease">Decrease by %</option> | |
| <option value="add">Add fixed amount</option> | |
| <option value="subtract">Subtract fixed amount</option> | |
| <option value="set">Set to value</option> | |
| </select> | |
| </div> | |
| <div class="form-group col-md-3"> | |
| <label>Value</label> | |
| <input class="form-control form-control-sm" id="op-value" type="number" value="5" step="0.01" min="0"> | |
| </div> | |
| <div class="form-group col-md-3"> | |
| <label>Apply to field</label> | |
| <select class="form-control form-control-sm" id="op-field"> | |
| <option value="price" selected>Price</option> | |
| <option value="special_price" disabled>Special Price (future)</option> | |
| </select> | |
| </div> | |
| <div class="form-group col-md-3"> | |
| <label>Rounding</label> | |
| <select class="form-control form-control-sm" id="op-rounding"> | |
| <option value="none">None</option> | |
| <option value="cent">Nearest cent</option> | |
| <option value="dollar" selected>Nearest dollar</option> | |
| <option value="99">Nearest .99 (charm)</option> | |
| <option value="95">Nearest .95 (charm)</option> | |
| <option value="5">Nearest $5</option> | |
| <option value="10">Nearest $10</option> | |
| </select> | |
| </div> | |
| </div> | |
| <div class="preview-summary" id="preview-summary">—</div> | |
| <table class="table table-striped table-bordered table-sm" id="preview-table"> | |
| <thead> | |
| <tr> | |
| <th>Model</th> | |
| <th>Cover Grade</th> | |
| <th>SKU</th> | |
| <th>Factory</th> | |
| <th>Cover Type</th> | |
| <th class="num">Current</th> | |
| <th class="num">New</th> | |
| <th class="num">Change</th> | |
| </tr> | |
| </thead> | |
| <tbody></tbody> | |
| </table> | |
| <p class="help-text" id="preview-truncated"></p> | |
| </div> | |
| <div class="modal-footer"> | |
| <input class="form-control form-control-sm confirm-input mr-auto" id="confirm-text" placeholder='Type "APPLY"'> | |
| <button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button> | |
| <button type="button" class="btn btn-success" id="modal-apply" disabled>Apply</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- ============================================================ --> | |
| <div class="toast-undo" id="toast"></div> | |
| <!-- jQuery + Bootstrap JS for the modal show/hide behavior --> | |
| <script src="https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.slim.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/bootstrap@4.6.2/dist/js/bootstrap.bundle.min.js"></script> | |
| <script> | |
| // --- Sample data (mirrors the column set used in admin/prices index) ---- | |
| const PRICES = [ | |
| { model: '71513', cover_grade: 'Grade A', sku: 'TS1', cubes: 14, length: 38, depth: 36, height: 40, price: 620.00, cover_type: 'Fabric', fob: true, factory: 'Vietnam', active: true, tier: 'Standard' }, | |
| { model: '71513', cover_grade: 'Grade B', sku: 'TS1', cubes: 14, length: 38, depth: 36, height: 40, price: 680.00, cover_type: 'Fabric', fob: true, factory: 'Vietnam', active: true, tier: 'Standard' }, | |
| { model: '71514', cover_grade: 'Grade A', sku: 'TS2', cubes: 18, length: 76, depth: 36, height: 40, price: 740.00, cover_type: 'Fabric', fob: true, factory: 'Vietnam', active: true, tier: 'Standard' }, | |
| { model: '71514', cover_grade: 'Grade B', sku: 'TS2', cubes: 18, length: 76, depth: 36, height: 40, price: 810.00, cover_type: 'Fabric', fob: true, factory: 'Vietnam', active: true, tier: 'Standard' }, | |
| { model: '71520', cover_grade: 'Grade A', sku: 'TS3', cubes: 12, length: 38, depth: 34, height: 38, price: 590.00, cover_type: 'Fabric', fob: true, factory: 'Vietnam', active: true, tier: 'Standard' }, | |
| { model: '71520', cover_grade: 'Grade B', sku: 'TS3', cubes: 12, length: 38, depth: 34, height: 38, price: 650.00, cover_type: 'L/PVC', fob: true, factory: 'Vietnam', active: true, tier: 'Standard' }, | |
| { model: '71531', cover_grade: 'Grade A', sku: 'TS4', cubes: 16, length: 76, depth: 36, height: 40, price: 845.00, cover_type: 'Fabric', fob: true, factory: 'Vietnam', active: true, tier: 'Premium' }, | |
| { model: '71531', cover_grade: 'Grade B', sku: 'TS4', cubes: 16, length: 76, depth: 36, height: 40, price: 920.00, cover_type: 'Fabric', fob: true, factory: 'Vietnam', active: true, tier: 'Premium' }, | |
| { model: '60012', cover_grade: 'Grade A', sku: 'CH1', cubes: 10, length: 38, depth: 34, height: 38, price: 410.00, cover_type: 'Fabric', fob: false, factory: 'China', active: true, tier: 'Standard' }, | |
| { model: '60012', cover_grade: 'Grade B', sku: 'CH1', cubes: 10, length: 38, depth: 34, height: 38, price: 460.00, cover_type: 'Fabric', fob: false, factory: 'China', active: true, tier: 'Standard' }, | |
| { model: '60013', cover_grade: 'Grade A', sku: 'CH2', cubes: 11, length: 38, depth: 34, height: 38, price: 480.00, cover_type: 'L/PVC', fob: false, factory: 'China', active: true, tier: 'Standard' }, | |
| { model: '80101', cover_grade: 'Grade A', sku: 'MX1', cubes: 22, length: 76, depth: 38, height: 42, price: 1240.00, cover_type: 'All Leather', fob: true, factory: 'Mexico', active: true, tier: 'Premium' }, | |
| { model: '80101', cover_grade: 'Grade B', sku: 'MX1', cubes: 22, length: 76, depth: 38, height: 42, price: 1380.00, cover_type: 'All Leather', fob: true, factory: 'Mexico', active: true, tier: 'Premium' }, | |
| ...Array.from({length: 14}, (_, i) => ({ | |
| model: '71600', cover_grade: i % 2 ? 'Grade B' : 'Grade A', sku: 'TS' + (10 + i), | |
| cubes: 14, length: 76, depth: 36, height: 40, | |
| price: 550 + i * 17.5, cover_type: 'Fabric', fob: true, factory: 'Vietnam', | |
| active: true, tier: 'Standard' | |
| })) | |
| ]; | |
| const $ = (id) => document.getElementById(id); | |
| const modelSel = $('filter-model'); | |
| const factorySel = $('filter-factory'); | |
| const coverSel = $('filter-cover-type'); | |
| const tableBody = $('prices-table').querySelector('tbody'); | |
| const rowCount = $('row-count'); | |
| // Populate the Model dropdown from the sample data (distinct + sorted). | |
| [...new Set(PRICES.map(p => p.model))].sort().forEach(m => { | |
| const opt = document.createElement('option'); | |
| opt.value = m; opt.textContent = m; | |
| modelSel.appendChild(opt); | |
| }); | |
| function currentFilters() { | |
| return { model: modelSel.value, factory: factorySel.value, cover_type: coverSel.value }; | |
| } | |
| function filtered() { | |
| const { model, factory, cover_type } = currentFilters(); | |
| return PRICES.filter(p => | |
| (!model || p.model === model) && | |
| (!factory || p.factory === factory) && | |
| (!cover_type || p.cover_type === cover_type) | |
| ); | |
| } | |
| function fmtBool(b) { | |
| return b | |
| ? '<span class="badge badge-success">✓</span>' | |
| : '<span class="badge badge-secondary">—</span>'; | |
| } | |
| function renderTable() { | |
| const rows = filtered(); | |
| rowCount.textContent = rows.length + ' matches'; | |
| tableBody.innerHTML = rows.slice(0, 8).map(p => ` | |
| <tr> | |
| <td>${p.model}</td> | |
| <td>${p.cover_grade}</td> | |
| <td>${p.sku}</td> | |
| <td class="num">${p.cubes}</td> | |
| <td class="num">${p.length}</td> | |
| <td class="num">${p.depth}</td> | |
| <td class="num">${p.height}</td> | |
| <td class="num">$${p.price.toFixed(2)}</td> | |
| <td>${p.cover_type}</td> | |
| <td>${fmtBool(p.fob)}</td> | |
| <td>${p.factory}</td> | |
| <td>${fmtBool(p.active)}</td> | |
| <td>${p.tier}</td> | |
| <td><a href="#" class="btn btn-sm btn-outline-secondary" tabindex="-1">Edit</a></td> | |
| </tr> | |
| `).join('') + (rows.length > 8 | |
| ? `<tr><td colspan="14" class="text-muted text-center small">…and ${rows.length - 8} more (full grid uses DataTables pagination)</td></tr>` | |
| : ''); | |
| $('open-modal').disabled = rows.length === 0; | |
| } | |
| modelSel.addEventListener('change', renderTable); | |
| factorySel.addEventListener('change', renderTable); | |
| coverSel.addEventListener('change', renderTable); | |
| renderTable(); | |
| // --- Formula application ----------------------------------------------- | |
| function applyFormula(price, op, value, rounding) { | |
| let next = price; | |
| switch (op) { | |
| case 'pct_increase': next = price * (1 + value / 100); break; | |
| case 'pct_decrease': next = price * (1 - value / 100); break; | |
| case 'add': next = price + value; break; | |
| case 'subtract': next = price - value; break; | |
| case 'set': next = value; break; | |
| } | |
| switch (rounding) { | |
| case 'cent': next = Math.round(next * 100) / 100; break; | |
| case 'dollar': next = Math.round(next); break; | |
| case '99': next = Math.floor(next) + 0.99; break; // charm: round down, .99 tail | |
| case '95': next = Math.floor(next) + 0.95; break; // charm: round down, .95 tail | |
| case '5': next = Math.round(next / 5) * 5; break; // nearest $5 | |
| case '10': next = Math.round(next / 10) * 10; break; // nearest $10 | |
| } | |
| return Math.max(0, next); | |
| } | |
| // --- Modal -------------------------------------------------------------- | |
| const modalCount = $('modal-count'); | |
| const modalFilters = $('modal-filters'); | |
| const previewBody = $('preview-table').querySelector('tbody'); | |
| const previewSummary = $('preview-summary'); | |
| const previewTruncated = $('preview-truncated'); | |
| const opOperation = $('op-operation'); | |
| const opValue = $('op-value'); | |
| const opRounding = $('op-rounding'); | |
| const confirmText = $('confirm-text'); | |
| const applyBtn = $('modal-apply'); | |
| const PREVIEW_LIMIT = 6; | |
| $('open-modal').addEventListener('click', () => { | |
| const rows = filtered(); | |
| modalCount.textContent = rows.length; | |
| const f = currentFilters(); | |
| const parts = []; | |
| if (f.model) parts.push('Model = ' + f.model); | |
| if (f.factory) parts.push('Factory = ' + f.factory); | |
| if (f.cover_type) parts.push('Cover Type = ' + f.cover_type); | |
| modalFilters.textContent = parts.length ? 'Filters: ' + parts.join(', ') : 'No filters — entire price book'; | |
| confirmText.value = ''; | |
| applyBtn.disabled = true; | |
| renderPreview(); | |
| }); | |
| function renderPreview() { | |
| const rows = filtered(); | |
| const op = opOperation.value; | |
| const val = parseFloat(opValue.value) || 0; | |
| const rnd = opRounding.value; | |
| const computed = rows.map(p => { | |
| const next = applyFormula(p.price, op, val, rnd); | |
| return { ...p, next, delta: next - p.price }; | |
| }); | |
| const totalDelta = computed.reduce((s, r) => s + r.delta, 0); | |
| const avgDelta = computed.length ? totalDelta / computed.length : 0; | |
| const minDelta = computed.length ? Math.min(...computed.map(r => r.delta)) : 0; | |
| const maxDelta = computed.length ? Math.max(...computed.map(r => r.delta)) : 0; | |
| const fmt = (n) => (n >= 0 ? '+' : '') + '$' + n.toFixed(2); | |
| previewSummary.innerHTML = ` | |
| <strong>${computed.length} prices</strong> · avg change <strong>${fmt(avgDelta)}</strong> | |
| · range <strong>${fmt(minDelta)}</strong> to <strong>${fmt(maxDelta)}</strong> | |
| · total impact <strong>${fmt(totalDelta)}</strong>`; | |
| previewBody.innerHTML = computed.slice(0, PREVIEW_LIMIT).map(r => ` | |
| <tr> | |
| <td>${r.model}</td><td>${r.cover_grade}</td><td>${r.sku}</td> | |
| <td>${r.factory}</td><td>${r.cover_type}</td> | |
| <td class="num">$${r.price.toFixed(2)}</td> | |
| <td class="num">$${r.next.toFixed(2)}</td> | |
| <td class="num ${r.delta >= 0 ? 'change-up' : 'change-down'}">${fmt(r.delta)}</td> | |
| </tr> | |
| `).join(''); | |
| previewTruncated.textContent = computed.length > PREVIEW_LIMIT | |
| ? `Showing first ${PREVIEW_LIMIT} of ${computed.length}.` | |
| : ''; | |
| } | |
| [opOperation, opValue, opRounding].forEach(el => el.addEventListener('input', renderPreview)); | |
| // --- Confirm + apply ---------------------------------------------------- | |
| confirmText.addEventListener('input', () => { | |
| applyBtn.disabled = confirmText.value.trim().toUpperCase() !== 'APPLY'; | |
| }); | |
| applyBtn.addEventListener('click', () => { | |
| const snapshot = JSON.parse(JSON.stringify(PRICES)); | |
| const op = opOperation.value; | |
| const val = parseFloat(opValue.value) || 0; | |
| const rnd = opRounding.value; | |
| const rows = filtered(); | |
| rows.forEach(p => { p.price = applyFormula(p.price, op, val, rnd); }); | |
| // Bootstrap modal close (jQuery is loaded for the bootstrap bundle) | |
| // eslint-disable-next-line no-undef | |
| jQuery('#bulkModal').modal('hide'); | |
| renderTable(); | |
| showToast(rows.length + ' prices updated. <a id="undo-link">Undo</a>', snapshot); | |
| }); | |
| const toast = $('toast'); | |
| function showToast(html, snapshot) { | |
| toast.innerHTML = html; | |
| toast.classList.add('open'); | |
| setTimeout(() => toast.classList.remove('open'), 10000); | |
| setTimeout(() => { | |
| const undo = $('undo-link'); | |
| if (undo) undo.addEventListener('click', () => { | |
| PRICES.length = 0; | |
| snapshot.forEach(p => PRICES.push(p)); | |
| renderTable(); | |
| toast.classList.remove('open'); | |
| }); | |
| }, 0); | |
| } | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment