Skip to content

Instantly share code, notes, and snippets.

@apotema
Last active April 24, 2026 15:28
Show Gist options
  • Select an option

  • Save apotema/e65ac0e39535264855c19b37e17bb52c to your computer and use it in GitHub Desktop.

Select an option

Save apotema/e65ac0e39535264855c19b37e17bb52c to your computer and use it in GitHub Desktop.
RFC #349 — Mass formula updates · interactive prototype (Direction A modal)
<!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