PageSourceSearch

https://hush.sg/assets/controllers/po_virtual_table_controller-9caecaff.js

js hush.sg collected 2026-09-26 07:30:15 UTC 13,764 bytes, 367 lines download raw bytes

1import { Controller } from "@hotwired/stimulus"
2
3/**
4 * Purchase Orders Virtual Table Controller
5 *
6 * Implements virtual scrolling for purchase orders - only renders visible rows.
7 * Supports search-as-you-type and status filtering.
8 */
9export default class extends Controller {
10  static targets = [
11    "viewport",
12    "content",
13    "tbody",
14    "searchInput",
15    "statusFilter",
16    "rowCount",
17    "loading",
18    "emptyState",
19    "statusSummary"
20  ]
21
22  static values = {
23    rowHeight: { type: Number, default: 65 },
24    bufferSize: { type: Number, default: 10 },
25    chunkSize: { type: Number, default: 100 },
26    fetchUrl: String,
27    debounceMs: { type: Number, default: 300 },
28    currencySymbol: { type: String, default: "$" }
29  }
30
31  connect() {
32    this.totalRows = 0
33    this.cache = new Map()
34    this.pendingFetch = null
35    this.fetchController = null
36    this.filters = { search: "", status: "" }
37    this.searchTimeout = null
38    this.fetchDebounceTimeout = null
39    this.lastFetchRange = null
40
41    // Initial data fetch
42    this.fetchMetadata().then(() => {
43      this.render()
44    })
45
46    // Bind scroll handler
47    this.handleScroll = this.handleScroll.bind(this)
48    this.viewportTarget.addEventListener("scroll", this.handleScroll, { passive: true })
49  }
50
51  disconnect() {
52    this.viewportTarget.removeEventListener("scroll", this.handleScroll)
53    if (this.searchTimeout) clearTimeout(this.searchTimeout)
54    if (this.fetchDebounceTimeout) clearTimeout(this.fetchDebounceTimeout)
55    if (this.fetchController) this.fetchController.abort()
56  }
57
58  buildQueryString(extra = {}) {
59    const params = new URLSearchParams()
60    const allParams = { ...this.filters, ...extra }
61
62    Object.entries(allParams).forEach(([key, value]) => {
63      if (value !== null && value !== undefined && value !== "") {
64        params.set(key, value)
65      }
66    })
67
68    return params.toString()
69  }
70
71  async fetchMetadata() {
72    this.showLoading()
73
74    try {
75      const query = this.buildQueryString({ metadata_only: true })
76      const response = await fetch(`${this.fetchUrlValue}?${query}`)
77      const data = await response.json()
78
79      this.totalRows = data.total_count
80      this.updateRowCount()
81      this.updateContentHeight()
82
83      if (this.totalRows === 0) {
84        this.showEmptyState()
85      } else {
86        this.hideEmptyState()
87      }
88    } catch (error) {
89      console.error("Failed to fetch metadata:", error)
90    } finally {
91      this.hideLoading()
92    }
93  }
94
95  fetchRows(startIndex, endIndex) {
96    const uncachedIndices = []
97    for (let i = startIndex; i <= endIndex; i++) {
98      if (!this.cache.has(i)) {
99        uncachedIndices.push(i)
100      }
101    }
102
103    if (uncachedIndices.length === 0) return
104
105    const fetchStart = Math.floor(Math.min(...uncachedIndices) / this.chunkSizeValue) * this.chunkSizeValue
106    const fetchEnd = Math.min(
107      this.totalRows - 1,
108      Math.ceil((Math.max(...uncachedIndices) + 1) / this.chunkSizeValue) * this.chunkSizeValue - 1
109    )
110    const rangeKey = `${fetchStart}-${fetchEnd}`
111
112    if (this.lastFetchRange === rangeKey && this.pendingFetch) return
113
114    if (this.fetchDebounceTimeout) clearTimeout(this.fetchDebounceTimeout)
115
116    this.fetchDebounceTimeout = setTimeout(() => {
117      this.doFetchRows(fetchStart, fetchEnd, rangeKey)
118    }, 50)
119  }
120
121  async doFetchRows(fetchStart, fetchEnd, rangeKey) {
122    if (this.fetchController) this.fetchController.abort()
123
124    const controller = new AbortController()
125    this.fetchController = controller
126    this.pendingFetch = true
127    this.lastFetchRange = rangeKey
128
129    try {
130      const query = this.buildQueryString({
131        start_index: fetchStart,
132        end_index: fetchEnd
133      })
134
135      const response = await fetch(`${this.fetchUrlValue}?${query}`, {
136        signal: controller.signal
137      })
138
139      const data = await response.json()
140
141      data.rows.forEach((row, i) => {
142        this.cache.set(fetchStart + i, row)
143      })
144
145      this.render()
146    } catch (error) {
147      if (error.name !== "AbortError") {
148        console.error("Failed to fetch rows:", error)
149      }
150    } finally {
151      this.pendingFetch = false
152      this.fetchController = null
153    }
154  }
155
156  handleScroll() {
157    requestAnimationFrame(() => this.render())
158  }
159
160  render() {
161    if (this.totalRows === 0) return
162
163    const scrollTop = this.viewportTarget.scrollTop
164    const viewportHeight = this.viewportTarget.clientHeight
165
166    const startIndex = Math.max(0, Math.floor(scrollTop / this.rowHeightValue) - this.bufferSizeValue)
167    const visibleCount = Math.ceil(viewportHeight / this.rowHeightValue)
168    const endIndex = Math.min(this.totalRows - 1, startIndex + visibleCount + this.bufferSizeValue * 2)
169
170    this.fetchRows(startIndex, endIndex)
171
172    const rows = []
173    for (let i = startIndex; i <= endIndex; i++) {
174      const rowData = this.cache.get(i)
175      if (rowData) {
176        rows.push(this.renderRow(rowData))
177      } else {
178        rows.push(this.renderPlaceholderRow())
179      }
180    }
181
182    const offsetY = startIndex * this.rowHeightValue
183
184    this.tbodyTarget.style.transform = `translateY(${offsetY}px)`
185    this.tbodyTarget.innerHTML = rows.join("")
186  }
187
188  renderRow(data) {
189    const statusBadge = this.getStatusBadge(data.status)
190    const poNumber = `#${String(data.id).padStart(6, '0')}`
191    const amount = this.formatCurrency(data.total_amount)
192
193    const nextPhaseLink = data.next_phase
194      ? `<div class="mt-1">
195           <a href="/purchasing/purchase_orders/${data.id}/transition" class="text-xs text-primary-600 hover:text-primary-900">
196             <svg class="inline h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
197               <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"/>
198             </svg>
199             ${this.escapeHtml(data.next_phase)}
200           </a>
201         </div>`
202      : ""
203
204    const freightInfo = data.freight_supplier_name && data.freight_supplier_name !== data.supplier_name
205      ? `<div class="text-xs text-gray-500">Freight: ${this.escapeHtml(data.freight_supplier_name)}</div>`
206      : ""
207
208    return `
209      <tr class="hover:bg-gray-50" style="height: ${this.rowHeightValue}px; display: table; width: 100%; table-layout: fixed;">
210        <td class="px-6 py-4 whitespace-nowrap" style="width: 96px;">
211          <a href="/purchasing/purchase_orders/${data.id}" class="text-sm font-mono font-medium text-primary-600 hover:text-primary-900">${poNumber}</a>
212        </td>
213        <td class="px-6 py-4 whitespace-nowrap" style="width: 128px;">
214          ${statusBadge}
215          ${nextPhaseLink}
216        </td>
217        <td class="px-6 py-4 whitespace-nowrap">
218          <div class="text-sm font-medium text-gray-900">${this.escapeHtml(data.supplier_name) || "—"}</div>
219          ${freightInfo}
220        </td>
221        <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500" style="width: 112px;">${data.order_date || "—"}</td>
222        <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500" style="width: 112px;">${data.expected_date || "—"}</td>
223        <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 text-center" style="width: 80px;">${data.items_count}</td>
224        <td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 text-right" style="width: 128px;">${amount}</td>
225        <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500" style="width: 96px;">${this.escapeHtml(data.created_by) || "—"}</td>
226        <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium" style="width: 96px;">
227          <div class="flex items-center justify-end space-x-2">
228            <a href="/purchasing/purchase_orders/${data.id}" class="text-primary-600 hover:text-primary-900" title="View">
229              <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
230                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z"/>
231                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
232              </svg>
233            </a>
234            ${data.status !== 'Inventoried' ? `
235              <a href="/purchasing/purchase_orders/${data.id}/edit" class="text-gray-400 hover:text-gray-600" title="Edit">
236                <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
237                  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"/>
238                </svg>
239              </a>
240            ` : ''}
241            ${data.status === 'Planning' ? `
242              <a href="/purchasing/purchase_orders/${data.id}" data-turbo-method="delete" data-turbo-confirm="Are you sure you want to delete this purchase order?" class="text-gray-400 hover:text-red-500" title="Delete">
243                <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
244                  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"/>
245                </svg>
246              </a>
247            ` : ''}
248          </div>
249        </td>
250      </tr>
251    `
252  }
253
254  renderPlaceholderRow() {
255    return `
256      <tr style="height: ${this.rowHeightValue}px; display: table; width: 100%; table-layout: fixed;">
257        <td colspan="9" class="px-6 py-4">
258          <div class="animate-pulse flex space-x-4">
259            <div class="h-4 bg-gray-200 rounded w-16"></div>
260            <div class="h-4 bg-gray-200 rounded w-20"></div>
261            <div class="flex-1 h-4 bg-gray-200 rounded"></div>
262          </div>
263        </td>
264      </tr>
265    `
266  }
267
268  getStatusBadge(status) {
269    // Solid color badges with white text to match design system
270    const badges = {
271      'Planning': '<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold bg-amber-500 text-white">Planning</span>',
272      'Placed': '<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold bg-blue-500 text-white">Placed</span>',
273      'Incoming': '<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold bg-purple-500 text-white">Incoming</span>',
274      'Received': '<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold bg-cyan-500 text-white">Received</span>',
275      'Inventoried': '<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold bg-green-500 text-white">Inventoried</span>'
276    }
277    return badges[status] || `<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold bg-gray-500 text-white">${this.escapeHtml(status)}</span>`
278  }
279
280  formatCurrency(amount) {
281    if (amount === null || amount === undefined) return "—"
282    return `${this.currencySymbolValue}
282${parseFloat(amount).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
283  }
284
285  updateContentHeight() {
286    const totalHeight = this.totalRows * this.rowHeightValue
287    this.contentTarget.style.height = `${totalHeight}px`
288  }
289
290  updateRowCount() {
291    if (this.hasRowCountTarget) {
292      this.rowCountTarget.textContent = `${this.totalRows.toLocaleString()} purchase orders`
293    }
294  }
295
296  // Search input handler with debounce
297  search(event) {
298    const value = event.target.value
299
300    if (this.searchTimeout) clearTimeout(this.searchTimeout)
301
302    this.searchTimeout = setTimeout(() => {
303      this.filters.search = value
304      this.resetAndRefetch()
305    }, this.debounceMsValue)
306  }
307
308  // Status filter from dropdown
309  filterByStatus(event) {
310    this.filters.status = event.target.value
311    this.resetAndRefetch()
312  }
313
314  // Status filter from badge click
315  filterByStatusBadge(event) {
316    const status = event.currentTarget.dataset.status
317    this.filters.status = this.filters.status === status ? "" : status
318
319    // Update dropdown to match
320    if (this.hasStatusFilterTarget) {
321      this.statusFilterTarget.value = this.filters.status
322    }
323
324    this.resetAndRefetch()
325  }
326
327  clearFilters() {
328    this.filters = { search: "", status: "" }
329
330    if (this.hasSearchInputTarget) this.searchInputTarget.value = ""
331    if (this.hasStatusFilterTarget) this.statusFilterTarget.value = ""
332
333    this.resetAndRefetch()
334  }
335
336  resetAndRefetch() {
337    this.cache.clear()
338    this.viewportTarget.scrollTop = 0
339    this.fetchMetadata().then(() => {
340      this.render()
341    })
342  }
343
344  showLoading() {
345    if (this.hasLoadingTarget) this.loadingTarget.classList.remove("hidden")
346  }
347
348  hideLoading() {
349    if (this.hasLoadingTarget) this.loadingTarget.classList.add("hidden")
350  }
351
352  showEmptyState() {
353    if (this.hasEmptyStateTarget) this.emptyStateTarget.classList.remove("hidden")
354    this.tbodyTarget.innerHTML = ""
355  }
356
357  hideEmptyState() {
358    if (this.hasEmptyStateTarget) this.emptyStateTarget.classList.add("hidden")
359  }
360
361  escapeHtml(text) {
362    if (!text) return ""
363    const div = document.createElement("div")
364    div.textContent = text
365    return div.innerHTML
366  }
367}

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.