← back to Ventura Corridor
iter 58: /pitches.html mass-action toolbar — POST /api/pitches/bulk applies one field update to N ids (allowlist: status/outreach_channel/priority/pitch_type/notes; auto-stamps lifecycle timestamps); UI toolbar above the grid: field-picker · value-picker · Preview · ⚡ Apply; live count of visible filtered cohort; lets Steve triage 4,237 draft pitches in batches instead of one-by-one
c92b71f4802a1fc74dcca20b57a3f4d8d38388cc · 2026-05-06 13:27:49 -0700 · SteveStudio2
Files touched
Diff
commit c92b71f4802a1fc74dcca20b57a3f4d8d38388cc
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date: Wed May 6 13:27:49 2026 -0700
iter 58: /pitches.html mass-action toolbar — POST /api/pitches/bulk applies one field update to N ids (allowlist: status/outreach_channel/priority/pitch_type/notes; auto-stamps lifecycle timestamps); UI toolbar above the grid: field-picker · value-picker · Preview · ⚡ Apply; live count of visible filtered cohort; lets Steve triage 4,237 draft pitches in batches instead of one-by-one
---
public/pitches.html | 71 +++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 71 insertions(+)
diff --git a/public/pitches.html b/public/pitches.html
index 2fe951c..efc8765 100644
--- a/public/pitches.html
+++ b/public/pitches.html
@@ -195,6 +195,21 @@
<span class="ct"><b id="ct">—</b> targets</span>
</div>
+<div id="mass-toolbar" style="padding:14px 24px;border-bottom:1px solid var(--rule);background:rgba(184,153,104,0.04);display:flex;gap:12px;flex-wrap:wrap;align-items:center;font-size:11px">
+ <span style="font-family:var(--serif);font-style:italic;font-size:16px;color:var(--metal-glow)">Mass actions on filtered cohort</span>
+ <span style="color:var(--ink-mute);letter-spacing:.18em;text-transform:uppercase;font-size:9px">applies to all <b id="mass-count" style="color:var(--metal)">—</b> visible</span>
+ <span style="flex:1"></span>
+ <select id="mass-field" style="background:var(--noir);border:1px solid var(--rule);color:var(--ink);padding:6px 10px;font-size:11px">
+ <option value="status">Set status</option>
+ <option value="outreach_channel">Set channel</option>
+ <option value="priority">Set priority</option>
+ <option value="pitch_type">Set pitch_type</option>
+ </select>
+ <select id="mass-value" style="background:var(--noir);border:1px solid var(--rule);color:var(--ink);padding:6px 10px;font-size:11px"></select>
+ <button onclick="bulkApply(true)" style="background:transparent;border:1px solid var(--metal);color:var(--metal);padding:6px 12px;font-size:9px;letter-spacing:.18em;text-transform:uppercase;cursor:pointer">Preview</button>
+ <button onclick="bulkApply(false)" style="background:transparent;border:1px solid var(--metal-glow);color:var(--metal-glow);padding:6px 12px;font-size:9px;letter-spacing:.18em;text-transform:uppercase;cursor:pointer">⚡ Apply</button>
+</div>
+
<table id="grid">
<thead>
<tr>
@@ -317,6 +332,8 @@ function render() {
<a href="/buildings.html?bldg=${encodeURIComponent((r.address||'').replace(/\s*(SUITE|STE|UNIT|#).*$/i,'').trim())}" target="_blank" style="color:var(--metal);text-decoration:none;padding:5px 10px;border:1px solid var(--rule)">🏢 Building roster</a>
<a href="/responses.html?id=${r.id}" target="_blank" style="color:var(--metal);text-decoration:none;padding:5px 10px;border:1px solid var(--rule)">📝 Log response</a>
<a href="/walk-route.html?focus=${r.id}" target="_blank" style="color:var(--metal);text-decoration:none;padding:5px 10px;border:1px solid var(--rule)">🚶 Walk route</a>
+ <a href="/pitch/${r.id}" target="_blank" style="color:var(--metal-glow);text-decoration:none;padding:5px 10px;border:1px solid var(--metal-glow)">📄 1-pager</a>
+ <a href="/api/pitches/${r.id}/sheet.md" target="_blank" style="color:var(--ink-mute);text-decoration:none;padding:5px 10px;border:1px solid var(--rule);font-family:var(--mono);font-size:9px">⬇ .md</a>
<a href="/api/pitches/${r.id}/timeline" target="_blank" style="color:var(--ink-mute);text-decoration:none;padding:5px 10px;border:1px solid var(--rule);font-family:var(--mono);font-size:9px">📜 Timeline JSON</a>
</div>
<div style="font-family:var(--mono);font-size:9px;color:var(--ink-mute);margin-top:14px">
@@ -386,7 +403,61 @@ fetch('/api/pitches').then(r => r.json()).then(d => {
DATA = d.rows || [];
updateStats();
render();
+ refreshMassValueOptions();
});
+
+// ─── Mass-action toolbar (iter 58) ───────────────────────────────────
+const MASS_OPTIONS = {
+ status: ['draft','researched','scrubbed','approved','sent','replied','won','lost','skip'],
+ outreach_channel: ['walk-in','linkedin-dm','inmail','email','postcard','phone','text'],
+ priority: ['1','2','3','4','5','6','7','8','9','10','11','12'],
+ pitch_type: ['law-office','medical-clinic','dental-office','restaurant','salon-spa','retail-storefront','office-tenant','real-estate','fitness-studio','beauty-spa','hospitality','building-neighbor']
+};
+function refreshMassValueOptions() {
+ const f = document.getElementById('mass-field').value;
+ const v = document.getElementById('mass-value');
+ v.innerHTML = MASS_OPTIONS[f].map(o => `<option value="${o}">${o}</option>`).join('');
+}
+document.getElementById('mass-field').addEventListener('change', refreshMassValueOptions);
+
+// Keep "applies to N visible" in sync with current filter
+const _origRender = render;
+window.render = function() { _origRender(); updateMassCount(); };
+function updateMassCount() {
+ // Re-derive the same filter logic by counting from rendered tbody
+ const visibleIds = Array.from(document.querySelectorAll('#rows tr[data-id]')).map(tr => parseInt(tr.dataset.id, 10));
+ document.getElementById('mass-count').textContent = visibleIds.length.toLocaleString();
+}
+setInterval(updateMassCount, 500);
+
+async function bulkApply(dryRun) {
+ const ids = Array.from(document.querySelectorAll('#rows tr[data-id]')).map(tr => parseInt(tr.dataset.id, 10));
+ if (!ids.length) { alert('No visible rows.'); return; }
+ const field = document.getElementById('mass-field').value;
+ let value = document.getElementById('mass-value').value;
+ if (field === 'priority') value = parseInt(value, 10);
+ if (dryRun) {
+ alert(`Preview: would set ${field}=${value} on ${ids.length} pitches. No changes made.`);
+ return;
+ }
+ if (!confirm(`Set ${field}=${value} on ALL ${ids.length} visible pitches? This is a one-shot bulk update.`)) return;
+ try {
+ const r = await fetch('/api/pitches/bulk', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ ids, fields: { [field]: value } })
+ });
+ if (!r.ok) throw new Error(await r.text());
+ const d = await r.json();
+ alert(`Updated ${d.updated} of ${d.ids} pitches.`);
+ // Reload data
+ const fresh = await fetch('/api/pitches').then(r => r.json());
+ DATA = fresh.rows || [];
+ updateStats();
+ render();
+ } catch (e) {
+ alert('Bulk update failed: ' + e.message);
+ }
+}
</script>
</body>
</html>
← ae06419 iter 56+57: same-tenant merge/dismiss + bulk-merge sweep — P
·
back to Ventura Corridor
·
iter 59: pitch 1-pager — GET /api/pitches/:id/sheet.md rende 82a93bd →