PageSourceSearch

https://www.georgetowncoc.com/assets/controllers/sales_board_controller-8b7acf3c.js

js georgetowncoc.com collected 2026-09-25 04:05:50 UTC 7,626 bytes, 253 lines download raw bytes

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.