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.