1import { Controller } from "@hotwired/stimulus" 2 3// Drag-and-drop pipeline board. Cards are HTML5 draggable; dropping one on a 4// stage column or the Won / Not-a-fit zones PATCHes the deal's stage, then 5// column counts and values refresh from the JSON response. On any failure the 6// card snaps back where it came from. 7export default class extends Controller { 8 static targets = ["card", "cardList", "columnCount", "columnValue", "dropZone", "emptyHint", "notice", "metric", "closedSummary"] 9 static values = { moveUrlTemplate: String, canWrite: Boolean } 10 11 connect() { 12 this.dragging = null 13 } 14 15 disconnect() { 16 this.clearDragState() 17 } 18 19 dragStart(event) { 20 if (!this.canWriteValue) { 21 event.preventDefault() 22 return 23 } 24 25 const card = event.currentTarget 26 this.dragging = { 27 card, 28 fromList: card.closest("[data-sales-board-target='cardList']"), 29 nextSibling: card.nextElementSibling 30 } 31 event.dataTransfer.effectAllowed = "move" 32 event.dataTransfer.setData("text/plain", card.dataset.dealId || "") 33 requestAnimationFrame(() => card.classList.add("opacity-40")) 34 this.setDropTargetsReady(true) 35 } 36 37 dragEnd() { 38 this.clearDragState() 39 } 40 41 dragOver(event) { 42 if (!this.dragging) return 43 44 event.preventDefault() 45 event.dataTransfer.dropEffect = "move" 46 this.highlight(event.currentTarget, true) 47 } 48 49 dragLeave(event) { 50 if (event.currentTarget.contains(event.relatedTarget)) return 51 52 this.highlight(event.currentTarget, false) 53 } 54 55 drop(event) { 56 if (!this.dragging) return 57 58 event.preventDefault() 59 const target = event.currentTarget 60 const stage = target.dataset.stage 61 const { card } = this.dragging 62 this.highlight(target, false) 63 64 if (!stage || stage === card.dataset.stage) { 65 this.clearDragState() 66 return 67 } 68 69 this.clearDragState() 70 this.applyMove(card, stage) 71 } 72 73 // Accessible alternative to drag-and-drop: the compact per-card Move select. 74 moveViaSelect(event) { 75 const select = event.currentTarget 76 const stage = select.value 77 select.value = "" 78 if (!stage) return 79 80 const card = select.closest("[data-sales-board-target='card']") 81 if (card && stage !== card.dataset.stage) this.applyMove(card, stage) 82 } 83 84 applyMove(card, stage) { 85 const fromStage = card.dataset.stage 86 const fromList = card.closest("[data-sales-board-target='cardList']") 87 const nextSibling = card.nextElementSibling 88 89 const targetList = this.cardListTargets.find((list) => list.dataset.stage === stage) 90 if (targetList) { 91 const hint = targetList.querySelector("[data-sales-board-target='emptyHint']") 92 targetList.insertBefore(card, hint) 93 } else { 94 card.remove() 95 } 96 card.dataset.stage = stage 97 this.updateEmptyHints() 98 99 this.persistMove(card, stage, () => { 100 card.dataset.stage = fromStage 101 if (nextSibling && nextSibling.parentElement === fromList) { 102 fromList.insertBefore(card, nextSibling) 103 } else if (fromList) { 104 const hint = fromList.querySelector("[data-sales-board-target='emptyHint']") 105 fromList.insertBefore(card, hint) 106 } 107 this.updateEmptyHints() 108 }) 109 } 110 111 async persistMove(card, stage, revert) { 112 const url = this.moveUrlTemplateValue.replace("__ID__", card.dataset.dealId) 113 const token = document.querySelector("meta[name='csrf-token']")?.content 114 115 try { 116 const response = await fetch(url, { 117 method: "PATCH", 118 headers: { 119 "X-CSRF-Token": token, 120 "Content-Type": "application/json", 121 "Accept": "application/json" 122 }, 123 body: JSON.stringify({ stage }) 124 }) 125 126 if (response.redirected || !response.ok) { 127 let message = "That change didn't save. Refresh the page and try again." 128 try { 129 const body = await response.json() 130 if (body?.error) message = body.error 131 } catch { /* non-JSON response body */ } 132 revert() 133 this.showNotice("error", message) 134 return 135 } 136 137 const body = await response.json() 138 this.updateColumnSummaries(body.columns || {}) 139 this.updateMetrics(body.metrics || {}) 140 this.updateClosedSummaries(body.closed || {}) 141 142 if (stage === "won") { 143 this.showNotice("success", "Moved to Won ð", { 144 href: body.lead_path, 145 label: "Open the lead" 146 }) 147 } else if (stage === "not_fit") { 148 this.showNotice("neutral", "Marked not a fit. Find it any time in Leads.") 149 } else { 150 this.hideNotice() 151 } 152 } catch { 153 revert() 154 this.showNotice("error", "That change didn't save. Check your connection and try again.") 155 } 156 } 157 158 updateColumnSummaries(columns) { 159 for (const [stage, summary] of Object.entries(columns)) { 160 this.columnCountTargets 161 .filter((el) => el.dataset.stage === stage) 162 .forEach((el) => { el.textContent = summary.count }) 163 this.columnValueTargets 164 .filter((el) => el.dataset.stage === stage) 165 .forEach((el) => { el.textContent = summary.value_label }) 166 } 167 } 168 169 updateMetrics(metrics) {
170 this.metricTargets.forEach((el) => { 171 const value = metrics[el.dataset.metric] 172 if (value !== undefined && value !== null) el.textContent = value 173 }) 174 } 175 176 updateClosedSummaries(closed) { 177 this.closedSummaryTargets.forEach((el) => { 178 const summary = closed[el.dataset.stage] 179 if (!summary) return 180 181 el.textContent = summary.value_label 182 ? `${summary.count} this month · ${summary.value_label}` 183 : `${summary.count} this month` 184 }) 185 } 186 187 updateEmptyHints() { 188 this.cardListTargets.forEach((list) => { 189 const hint = list.querySelector("[data-sales-board-target='emptyHint']") 190 if (!hint) return 191 192 const hasCards = list.querySelector("[data-sales-board-target='card']") !== null 193 hint.classList.toggle("hidden", hasCards) 194 }) 195 } 196 197 setDropTargetsReady(ready) { 198 const targets = [...this.cardListTargets, ...this.dropZoneTargets] 199 targets.forEach((el) => { 200 el.classList.toggle("ring-1", ready) 201 el.classList.toggle("ring-brand-200", ready) 202 }) 203 } 204 205 highlight(el, active) { 206 el.classList.toggle("ring-2", active) 207 el.classList.toggle("ring-brand-300", active) 208 el.classList.toggle("bg-amber-50/40", active) 209 } 210 211 clearDragState() { 212 if (this.dragging?.card) this.dragging.card.classList.remove("opacity-40") 213 this.dragging = null 214 this.setDropTargetsReady(false) 215 const targets = [...this.cardListTargets, ...this.dropZoneTargets] 216 targets.forEach((el) => this.highlight(el, false)) 217 } 218 219 showNotice(kind, message, link = null) { 220 if (!this.hasNoticeTarget) return 221 222 const el = this.noticeTarget 223 el.textContent = "" 224 225 const base = "mb-3 flex items-center gap-2 rounded-lg border px-4 py-3 text-sm" 226 const tones = { 227 success: "border-emerald-200 bg-emerald-50 text-emerald-900", 228 error: "border-rose-200 bg-rose-50 text-rose-800", 229 neutral: "border-zinc-200 bg-zinc-50 text-zinc-700" 230 } 231 el.className = `${base} ${tones[kind] || tones.neutral}` 232 233 const text = document.createElement("span") 234 text.textContent = message 235 el.appendChild(text) 236 237 if (link?.href) { 238 const anchor = document.createElement("a") 239 anchor.href = link.href 240 anchor.textContent = link.label 241 anchor.className = "font-medium underline" 242 el.appendChild(anchor) 243 } 244 245 el.classList.remove("hidden") 246 } 247 248 hideNotice() { 249 if (!this.hasNoticeTarget) return 250 251 this.noticeTarget.classList.add("hidden") 252 } 253}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.