[object Object]

← 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 →