diff --git a/abandoned-call-poller.js b/abandoned-call-poller.js new file mode 100644 index 0000000..65852d6 --- /dev/null +++ b/abandoned-call-poller.js @@ -0,0 +1,143 @@ +const config = require('./config'); +const apiClient = require('./api-client'); + +class AbandonedCallPoller { + constructor(database, printer) { + this.db = database; + this.printer = printer; + this.intervalId = null; + this.isPolling = false; + } + + async start() { + console.log('Starting abandoned call poller...'); + this.poll(); + this.scheduleNextPoll(); + } + + scheduleNextPoll() { + const appConfig = config.getAll(); + const interval = parseInt(appConfig.abandonedCallPollingInterval, 10) || 30000; + + if (this.intervalId) { + clearTimeout(this.intervalId); + } + + this.intervalId = setTimeout(() => { + this.poll(); + this.scheduleNextPoll(); + }, interval); + } + + async poll() { + if (this.isPolling) return; + this.isPolling = true; + + try { + const appConfig = config.getAll(); + + if (!appConfig.authToken || !appConfig.selectedBotId) { + this.isPolling = false; + return; + } + + if (apiClient.isTokenNearExpiry(appConfig.tokenExpiry, 7)) { + const refreshed = await apiClient.ensureValidToken(); + if (!refreshed) { + this.isPolling = false; + return; + } + Object.assign(appConfig, config.getAll()); + } + + const result = await apiClient.getAbandonedCalls( + appConfig.authToken, + appConfig.selectedBotId, + { limit: 50 } + ); + + if (result.error) { + console.error('Abandoned calls poll error:', result.message); + this.isPolling = false; + return; + } + + const calls = result.calls || []; + + for (const call of calls) { + this.db.cacheAbandonedCall(call.id, call); + } + + // Also fetch callback queue for the management page cache + const queueResult = await apiClient.getAbandonedCallbackQueue( + appConfig.authToken, + appConfig.selectedBotId, + 50 + ); + + if (!queueResult.error && queueResult.queue) { + for (const call of queueResult.queue) { + this.db.cacheAbandonedCall(call.id, call); + } + } + + // Print new abandoned calls that haven't been printed yet + await this.printNewAbandonedCalls(calls, appConfig); + + // Clean old cache periodically + this.db.cleanOldAbandonedCallCache(7); + + } catch (error) { + console.error('Abandoned call poll error:', error.message); + } + + this.isPolling = false; + } + + async printNewAbandonedCalls(calls, appConfig) { + const printerConfigs = this.db.getAbandonedCallPrinters(); + if (!printerConfigs || printerConfigs.length === 0) return; + + const cooldownSeconds = parseInt(appConfig.abandonedCallPrintCooldown, 10) || 300; + const lastPrintTime = this.db.getLastAbandonedCallPrintTime(); + const now = Math.floor(Date.now() / 1000); + + const printablePriorities = new Set(['critical', 'high', 'medium']); + + for (const call of calls) { + if (this.db.hasAbandonedCallPrint(call.id)) continue; + + const priority = call.callback_priority || 'none'; + if (!printablePriorities.has(priority)) continue; + + if (lastPrintTime && (now - lastPrintTime) < cooldownSeconds) { + console.log(`Abandoned call #${call.id}: skipping print (cooldown active)`); + continue; + } + + try { + const result = await this.printer.printAbandonedCallReceipt(call, printerConfigs); + const printedCount = result ? (result.successCount || printerConfigs.length) : 0; + this.db.addAbandonedCallPrint(call.id, printedCount); + console.log(`Abandoned call #${call.id}: printed on ${printedCount} printer(s)`); + } catch (err) { + console.error(`Abandoned call #${call.id}: print failed:`, err.message); + } + } + } + + stop() { + if (this.intervalId) { + clearTimeout(this.intervalId); + this.intervalId = null; + } + console.log('Abandoned call poller stopped'); + } + + restart() { + this.stop(); + this.start(); + } +} + +module.exports = AbandonedCallPoller; diff --git a/api-client.js b/api-client.js index 8cd499c..896e34c 100644 --- a/api-client.js +++ b/api-client.js @@ -3,6 +3,7 @@ const fetch = require('node-fetch'); class APIClient { constructor(baseUrl = process.env.API_URL || 'https://api.thinklink.ai') { this.baseUrl = baseUrl; + this._refreshInFlight = null; } async request(endpoint, options = {}) { @@ -82,12 +83,117 @@ class APIClient { return this.request('/food-order/modify', { body }); } + async refreshToken(token) { + return this.request('/user/refresh-token', { + body: { token } + }); + } + + async getAbandonedCalls(token, botId, options = {}) { + const body = { + token, + botId: parseInt(botId, 10) || 0, + limit: options.limit || 50, + offset: options.offset || 0 + }; + if (options.stage) body.stage = options.stage; + if (options.priority) body.priority = options.priority; + if (options.startDate) body.startDate = options.startDate; + if (options.endDate) body.endDate = options.endDate; + return this.request('/abandoned-calls/list', { body }); + } + + async getAbandonedCallbackQueue(token, botId, limit = 20, offset = 0) { + return this.request('/abandoned-calls/callback-queue', { + body: { token, botId: parseInt(botId, 10) || 0, limit, offset } + }); + } + + async updateAbandonedCallback(token, abandonedCallId, action, notes = '') { + return this.request('/abandoned-calls/update-callback', { + body: { token, abandonedCallId: parseInt(abandonedCallId, 10), action, notes } + }); + } + + async getAbandonedCallMetrics(token, botId, startDate = 0, endDate = 0) { + return this.request('/abandoned-calls/metrics', { + body: { token, botId: parseInt(botId, 10) || 0, startDate, endDate } + }); + } + isTokenExpired(expirationDate) { if (!expirationDate) return true; const expiry = new Date(expirationDate); + if (Number.isNaN(expiry.getTime())) return true; const now = new Date(); return now >= expiry; } + + isTokenNearExpiry(expirationDate, thresholdDays = 7) { + if (!expirationDate) return true; + const expiry = new Date(expirationDate); + if (Number.isNaN(expiry.getTime())) return true; + const now = new Date(); + const msRemaining = expiry.getTime() - now.getTime(); + const thresholdMs = thresholdDays * 24 * 60 * 60 * 1000; + return msRemaining < thresholdMs; + } + + async ensureValidToken() { + const config = require('./config'); + const token = config.get('authToken'); + const expiry = config.get('tokenExpiry'); + + if (!token || !expiry) return null; + + if (!this.isTokenNearExpiry(expiry, 7)) return token; + + if (this._refreshInFlight) { + return this._refreshInFlight; + } + + this._refreshInFlight = (async () => { + // Re-read in case another caller refreshed between checks. + const currentToken = config.get('authToken'); + const currentExpiry = config.get('tokenExpiry'); + if (!currentToken || !currentExpiry) return null; + if (!this.isTokenNearExpiry(currentExpiry, 7)) return currentToken; + + console.log('Token near expiry or expired, attempting refresh...'); + + try { + const result = await this.refreshToken(currentToken); + + if (result.error) { + // If another concurrent refresh already updated config, don't treat this as fatal. + const latestToken = config.get('authToken'); + const latestExpiry = config.get('tokenExpiry'); + if (latestToken && latestToken !== currentToken && !this.isTokenExpired(latestExpiry)) { + return latestToken; + } + + console.error('Token refresh failed:', result.message); + return null; + } + + config.set('previousAuthToken', currentToken); + config.set('authToken', result.token); + config.set('tokenExpiry', result.expirationDate); + + console.log('Token refreshed successfully, new expiry:', result.expirationDate); + return result.token; + } catch (error) { + console.error('Token refresh error:', error.message); + return null; + } + })(); + + try { + return await this._refreshInFlight; + } finally { + this._refreshInFlight = null; + } + } } module.exports = new APIClient(); diff --git a/config.js b/config.js index 0a5eeaa..1e20f9b 100644 --- a/config.js +++ b/config.js @@ -74,8 +74,7 @@ class ConfigManager { try { const value = database.getConfig(key); - // Decrypt token if it's the auth token - if (key === 'authToken' && value) { + if ((key === 'authToken' || key === 'previousAuthToken') && value) { return this.decrypt(value); } @@ -88,8 +87,7 @@ class ConfigManager { set(key, value) { try { - // Encrypt token if it's the auth token - if (key === 'authToken' && value) { + if ((key === 'authToken' || key === 'previousAuthToken') && value) { value = this.encrypt(value); } @@ -103,10 +101,12 @@ class ConfigManager { try { const config = database.getConfig(); - // Decrypt auth token if present if (config.authToken) { config.authToken = this.decrypt(config.authToken); } + if (config.previousAuthToken) { + config.previousAuthToken = this.decrypt(config.previousAuthToken); + } return config; } catch (error) { @@ -117,10 +117,12 @@ class ConfigManager { setMultiple(configObj) { try { - // Encrypt auth token if present if (configObj.authToken) { configObj.authToken = this.encrypt(configObj.authToken); } + if (configObj.previousAuthToken) { + configObj.previousAuthToken = this.encrypt(configObj.previousAuthToken); + } database.setConfigMultiple(configObj); } catch (error) { @@ -149,6 +151,7 @@ class ConfigManager { try { this.set('authToken', ''); this.set('tokenExpiry', ''); + this.set('previousAuthToken', ''); } catch (error) { console.error('Failed to clear auth:', error.message); } diff --git a/database.js b/database.js index 63af416..76b5672 100644 --- a/database.js +++ b/database.js @@ -100,6 +100,30 @@ class DatabaseManager { ) `); + // Abandoned call print tracking + this.db.exec(` + CREATE TABLE IF NOT EXISTS abandoned_call_prints ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + abandoned_call_id INTEGER UNIQUE NOT NULL, + printed_at INTEGER NOT NULL, + printer_count INTEGER NOT NULL DEFAULT 0 + ) + `); + + // Abandoned calls local cache + this.db.exec(` + CREATE TABLE IF NOT EXISTS abandoned_calls_cache ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + remote_id INTEGER UNIQUE NOT NULL, + data TEXT NOT NULL, + callback_status TEXT, + callback_priority TEXT, + callback_score INTEGER DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + `); + // Create indexes this.db.exec(` CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status); @@ -108,6 +132,9 @@ class DatabaseManager { CREATE INDEX IF NOT EXISTS idx_print_queue_status ON print_queue(status); CREATE INDEX IF NOT EXISTS idx_printers_enabled ON printers(is_enabled); CREATE INDEX IF NOT EXISTS idx_printers_default ON printers(is_default); + CREATE INDEX IF NOT EXISTS idx_abandoned_prints_call_id ON abandoned_call_prints(abandoned_call_id); + CREATE INDEX IF NOT EXISTS idx_abandoned_cache_remote ON abandoned_calls_cache(remote_id); + CREATE INDEX IF NOT EXISTS idx_abandoned_cache_status ON abandoned_calls_cache(callback_status); `); // Initialize default config values if not exists @@ -115,6 +142,9 @@ class DatabaseManager { // Migrate old printer config to new table if needed this.migrateOldPrinterConfig(); + + // Add print_abandoned_calls column to printers if missing + this.migratePrintersAddAbandonedCalls(); } setConfigDefaults() { @@ -474,9 +504,9 @@ class DatabaseManager { header_text, footer_text, business_name, business_address, business_phone, business_website, business_email, business_contact_size, show_customer_info, show_order_items, show_prices, show_timestamps, - logo_path, logo_max_width_dots, created_at, updated_at + logo_path, logo_max_width_dots, print_abandoned_calls, created_at, updated_at ) VALUES ( - ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ) `); @@ -509,6 +539,7 @@ class DatabaseManager { config.show_timestamps !== false ? 1 : 0, config.logo_path || null, config.logo_max_width_dots || null, + config.print_abandoned_calls !== false ? 1 : 0, now, now ); @@ -531,7 +562,7 @@ class DatabaseManager { header_text = ?, footer_text = ?, business_name = ?, business_address = ?, business_phone = ?, business_website = ?, business_email = ?, business_contact_size = ?, show_customer_info = ?, show_order_items = ?, show_prices = ?, show_timestamps = ?, - logo_path = ?, logo_max_width_dots = ?, updated_at = ? + logo_path = ?, logo_max_width_dots = ?, print_abandoned_calls = ?, updated_at = ? WHERE id = ? `); @@ -564,6 +595,7 @@ class DatabaseManager { config.show_timestamps !== false ? 1 : 0, config.logo_path || null, config.logo_max_width_dots || null, + config.print_abandoned_calls !== false ? 1 : 0, now, id ); @@ -647,6 +679,7 @@ class DatabaseManager { show_timestamps: row.show_timestamps === 1, logo_path: row.logo_path, logo_max_width_dots: row.logo_max_width_dots, + print_abandoned_calls: row.print_abandoned_calls == null ? true : row.print_abandoned_calls === 1, created_at: row.created_at, updated_at: row.updated_at }; @@ -710,6 +743,128 @@ class DatabaseManager { } } + migratePrintersAddAbandonedCalls() { + try { + const cols = this.db.pragma('table_info(printers)'); + const hasCol = cols.some(c => c.name === 'print_abandoned_calls'); + if (!hasCol) { + this.db.exec('ALTER TABLE printers ADD COLUMN print_abandoned_calls INTEGER NOT NULL DEFAULT 1'); + console.log('Migration: added print_abandoned_calls column to printers'); + } + } catch (err) { + console.error('Migration error (print_abandoned_calls):', err.message); + } + } + + getAbandonedCallPrinters() { + const rows = this.db.prepare( + 'SELECT * FROM printers WHERE is_enabled = 1 AND print_abandoned_calls = 1 ORDER BY is_default DESC, name ASC' + ).all(); + return rows.map(row => this.mapPrinterRow(row)); + } + + // Abandoned call print tracking + hasAbandonedCallPrint(abandonedCallId) { + const row = this.db.prepare( + 'SELECT 1 FROM abandoned_call_prints WHERE abandoned_call_id = ? LIMIT 1' + ).get(abandonedCallId); + return !!row; + } + + addAbandonedCallPrint(abandonedCallId, printerCount = 0) { + const now = Math.floor(Date.now() / 1000); + try { + this.db.prepare( + 'INSERT OR IGNORE INTO abandoned_call_prints (abandoned_call_id, printed_at, printer_count) VALUES (?, ?, ?)' + ).run(abandonedCallId, now, printerCount); + } catch (_) {} + } + + getLastAbandonedCallPrintTime() { + const row = this.db.prepare( + 'SELECT printed_at FROM abandoned_call_prints ORDER BY printed_at DESC LIMIT 1' + ).get(); + return row ? row.printed_at : 0; + } + + // Abandoned calls cache + cacheAbandonedCall(remoteId, data) { + const now = Math.floor(Date.now() / 1000); + const json = typeof data === 'string' ? data : JSON.stringify(data); + const callbackStatus = (typeof data === 'object' ? data.callback_status : null) || null; + const callbackPriority = (typeof data === 'object' ? data.callback_priority : null) || null; + const callbackScore = (typeof data === 'object' ? Number(data.callback_score) : 0) || 0; + this.db.prepare(` + INSERT INTO abandoned_calls_cache (remote_id, data, callback_status, callback_priority, callback_score, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(remote_id) DO UPDATE SET + data = excluded.data, + callback_status = excluded.callback_status, + callback_priority = excluded.callback_priority, + callback_score = excluded.callback_score, + updated_at = excluded.updated_at + `).run(remoteId, json, callbackStatus, callbackPriority, callbackScore, now, now); + } + + getCachedAbandonedCalls(options = {}) { + let query = 'SELECT * FROM abandoned_calls_cache WHERE 1=1'; + const params = []; + if (options.status) { + query += ' AND callback_status = ?'; + params.push(options.status); + } + if (options.priority) { + query += ' AND callback_priority = ?'; + params.push(options.priority); + } + query += ' ORDER BY callback_score DESC, updated_at DESC'; + if (options.limit) { + query += ' LIMIT ?'; + params.push(options.limit); + } + const rows = this.db.prepare(query).all(...params); + return rows.map(row => { + try { return JSON.parse(row.data); } catch (_) { return null; } + }).filter(Boolean); + } + + updateCachedCallbackStatus(remoteId, status) { + const now = Math.floor(Date.now() / 1000); + this.db.prepare( + 'UPDATE abandoned_calls_cache SET callback_status = ?, updated_at = ? WHERE remote_id = ?' + ).run(status, now, remoteId); + } + + getPendingAbandonedCallCount() { + const row = this.db.prepare( + "SELECT COUNT(*) as count FROM abandoned_calls_cache WHERE callback_status IN ('queued', 'deferred')" + ).get(); + return row ? row.count : 0; + } + + getAbandonedCallStats() { + const row = this.db.prepare(` + SELECT + COUNT(*) as total, + SUM(CASE WHEN callback_status IN ('queued','deferred') THEN 1 ELSE 0 END) as pending, + SUM(CASE WHEN callback_status = 'converted' THEN 1 ELSE 0 END) as converted, + SUM(CASE WHEN callback_status = 'dismissed' THEN 1 ELSE 0 END) as dismissed + FROM abandoned_calls_cache + `).get(); + return { + total: (row && row.total) || 0, + pending: (row && row.pending) || 0, + converted: (row && row.converted) || 0, + dismissed: (row && row.dismissed) || 0 + }; + } + + cleanOldAbandonedCallCache(maxAgeDays = 7) { + const cutoff = Math.floor(Date.now() / 1000) - (maxAgeDays * 86400); + this.db.prepare('DELETE FROM abandoned_calls_cache WHERE updated_at < ?').run(cutoff); + this.db.prepare('DELETE FROM abandoned_call_prints WHERE printed_at < ?').run(cutoff); + } + close() { if (this.db) { this.db.close(); diff --git a/package.json b/package.json index 5c437f3..600c768 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kitchen-agent", - "version": "1.0.5", + "version": "1.0.6", "description": "Kitchen Agent for ThinkLink Food Order Management", "main": "server.js", "scripts": { diff --git a/printer.js b/printer.js index eb90dbe..17c2b24 100644 --- a/printer.js +++ b/printer.js @@ -1380,16 +1380,25 @@ class PrinterManager { doc.moveDown(0.4); doc.fontSize(12).text('ITEMS:'); doc.fontSize(11); + const includePrices = (cfg.showPrices === 'true' || !cfg.showPrices); order.order.items.forEach(item => { const itemName = item.itemName || item.name || 'Unknown Item'; const qty = item.qty || 1; const price = item.price || 0; - const line = (cfg.showPrices === 'true' || !cfg.showPrices) + const line = includePrices ? `${qty}x ${itemName} - $${(price * qty).toFixed(2)}` : `${qty}x ${itemName}`; doc.text(line, { width: maxWidth }); if (item.addons && Array.isArray(item.addons)) { - item.addons.forEach(addon => doc.text(` + ${addon.name || addon}`)); + item.addons.forEach(addon => { + const addonName = addon.name || addon; + const addonPrice = Number(addon && addon.price); + if (includePrices && isFinite(addonPrice) && addonPrice > 0) { + doc.text(` + ${addonName} - $${addonPrice.toFixed(2)}`); + } else { + doc.text(` + ${addonName}`); + } + }); } if (item.exclude && Array.isArray(item.exclude)) { item.exclude.forEach(ex => doc.text(` - NO ${ex.name || ex}`)); @@ -1540,7 +1549,15 @@ class PrinterManager { : `${qty}x ${itemName}`; doc.text(line, { width: maxWidth }); if (item.addons && Array.isArray(item.addons)) { - item.addons.forEach(addon => doc.text(` + ${addon.name || addon}`)); + item.addons.forEach(addon => { + const addonName = addon.name || addon; + const addonPrice = Number(addon && addon.price); + if (mergedShowPrices && isFinite(addonPrice) && addonPrice > 0) { + doc.text(` + ${addonName} - $${addonPrice.toFixed(2)}`); + } else { + doc.text(` + ${addonName}`); + } + }); } if (item.exclude && Array.isArray(item.exclude)) { item.exclude.forEach(ex => doc.text(` - NO ${ex.name || ex}`)); @@ -1751,6 +1768,480 @@ class PrinterManager { return []; } } + + // ===== Abandoned Call Receipt ===== + + async renderAbandonedCallReceipt(instance, call, cfg) { + const width = parseInt(cfg.paper_width, 10) || 48; + const priority = call.callback_priority || 'low'; + const score = Number(call.callback_score) || 0; + const isNarrow = width < 40; + + this.safeCallOn(instance, 'clear'); + + // Logo + const logoPath = cfg.logo_path || cfg.logoPath || null; + if (logoPath && fs.existsSync(logoPath)) { + try { + const maxDots = this.getPrinterMaxDotsWidthFromConfig(cfg); + await this.printLogoWithFitCustom(instance, logoPath, maxDots); + this.safeCallOn(instance, 'newLine'); + } catch (_) {} + } + + // Top border + this.safeCallOn(instance, 'drawLine'); + + // Title: "ABANDONED CALL" in large centered text + this.safeCallOn(instance, 'alignCenter'); + this.safeCallOn(instance, 'setTextSize', 1, 1); + this.safeCallOn(instance, 'bold', true); + instance.println('ABANDONED CALL'); + this.safeCallOn(instance, 'bold', false); + this.safeCallOn(instance, 'setTextNormal'); + + // Priority badge using inverted text for visual prominence + this.safeCallOn(instance, 'newLine'); + this.safeCallOn(instance, 'alignCenter'); + this.safeCallOn(instance, 'bold', true); + if (priority === 'critical') { + this.safeCallOn(instance, 'setTextSize', 1, 1); + this.safeCallOn(instance, 'invert', true); + const pad = isNarrow ? ' ' : ' '; + instance.println(`${pad}CRITICAL PRIORITY${pad}`); + this.safeCallOn(instance, 'invert', false); + this.safeCallOn(instance, 'setTextNormal'); + } else if (priority === 'high') { + this.safeCallOn(instance, 'invert', true); + const pad = isNarrow ? ' ' : ' '; + instance.println(`${pad}HIGH PRIORITY${pad}`); + this.safeCallOn(instance, 'invert', false); + } else if (priority === 'medium') { + instance.println('MEDIUM PRIORITY'); + } else { + instance.println(`Priority: ${priority.toUpperCase()}`); + } + this.safeCallOn(instance, 'bold', false); + + this.safeCallOn(instance, 'drawLine'); + + // Timestamp + const callTime = call.call_started_at + ? new Date(Number(call.call_started_at) * 1000) + : new Date(); + const timeStr = callTime.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }); + const dateStr = callTime.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }); + + this.safeCallOn(instance, 'alignCenter'); + instance.println(`${dateStr} ${timeStr}`); + this.safeCallOn(instance, 'newLine'); + + // Caller section + this.safeCallOn(instance, 'alignCenter'); + this.safeCallOn(instance, 'bold', true); + instance.println('CALLER'); + this.safeCallOn(instance, 'bold', false); + this.safeCallOn(instance, 'drawLine'); + this.safeCallOn(instance, 'alignLeft'); + + const phone = call.caller_phone_normalized || call.caller_phone || 'Unknown'; + const name = call.caller_name || ''; + + this.safeCallOn(instance, 'setTextSize', 1, 0); + this.safeCallOn(instance, 'bold', true); + instance.println(`Phone: ${phone}`); + this.safeCallOn(instance, 'bold', false); + this.safeCallOn(instance, 'setTextNormal'); + if (name) { + instance.println(`Name: ${name}`); + } + + if (call.is_known_customer) { + this.safeCallOn(instance, 'newLine'); + this.safeCallOn(instance, 'invert', true); + this.safeCallOn(instance, 'bold', true); + const orderCount = Number(call.previous_order_count || 0); + instance.println(` RETURNING CUSTOMER ${orderCount} previous order${orderCount !== 1 ? 's' : ''} `); + this.safeCallOn(instance, 'bold', false); + this.safeCallOn(instance, 'invert', false); + } + + instance.println(`Callback Score: ${score}/100`); + + this.safeCallOn(instance, 'newLine'); + + // What happened section + this.safeCallOn(instance, 'alignCenter'); + this.safeCallOn(instance, 'bold', true); + instance.println('WHAT HAPPENED'); + this.safeCallOn(instance, 'bold', false); + this.safeCallOn(instance, 'drawLine'); + this.safeCallOn(instance, 'alignLeft'); + + const stageMessages = { + ring_only: 'Rang but hung up before AI could answer.', + greeting_hangup: 'Heard the greeting then disconnected.', + silent_post_greeting: `Stayed on for ${Math.round(Number(call.time_after_greeting_sec || 0))}s without speaking.`, + minimal_speech: 'Spoke briefly then disconnected.', + pre_intent: 'Began speaking but intent was unclear.', + intent_identified: 'Expressed interest in ordering but disconnected.', + partial_order: 'Started placing an order then disconnected.', + partial_appointment: 'Was booking an appointment then disconnected.', + pre_confirmation: 'Order was nearly complete before disconnecting.', + system_failure: 'A system error caused the disconnection.' + }; + + const stageMsg = stageMessages[call.abandonment_stage] || 'Call ended before completion.'; + instance.println(stageMsg); + + const dur = Number(call.duration_seconds || 0); + const durLabel = dur >= 60 + ? `${Math.floor(dur / 60)}m ${dur % 60}s` + : `${dur}s`; + instance.println(`Call duration: ${durLabel}`); + + this.safeCallOn(instance, 'newLine'); + + // Partial order items + const items = call.items || []; + let parsedSnapshot = null; + if (items.length === 0 && call.partial_order_snapshot) { + try { + parsedSnapshot = typeof call.partial_order_snapshot === 'string' + ? JSON.parse(call.partial_order_snapshot) + : call.partial_order_snapshot; + } catch (_) {} + } + + const displayItems = items.length > 0 + ? items + : (parsedSnapshot && parsedSnapshot.orderData && parsedSnapshot.orderData.items) || []; + + if (displayItems.length > 0) { + this.safeCallOn(instance, 'alignCenter'); + this.safeCallOn(instance, 'bold', true); + instance.println('ITEMS MENTIONED'); + this.safeCallOn(instance, 'bold', false); + this.safeCallOn(instance, 'drawLine'); + this.safeCallOn(instance, 'alignLeft'); + + const colWidths = this.getColumnWidths(cfg); + for (const item of displayItems) { + const itemName = item.item_name || item.itemName || item.name || 'Unknown'; + const qty = item.quantity || item.qty || 1; + const price = item.unit_price || item.price || null; + + if (price && colWidths.useTable) { + try { + instance.tableCustom([ + { text: `${qty}x ${itemName}`, align: 'LEFT', width: colWidths.itemWidth }, + { text: `$${Number(price).toFixed(2)}`, align: 'RIGHT', width: colWidths.priceWidth } + ]); + } catch (_) { + instance.println(` ${qty}x ${itemName} $${Number(price).toFixed(2)}`); + } + } else { + instance.println(` ${qty}x ${itemName}`); + } + } + + if (call.partial_order_value || call.estimated_order_value) { + const est = Number(call.partial_order_value || call.estimated_order_value); + if (est > 0) { + this.safeCallOn(instance, 'newLine'); + this.safeCallOn(instance, 'alignRight'); + this.safeCallOn(instance, 'bold', true); + instance.println(`Est. Value: $${est.toFixed(2)}`); + this.safeCallOn(instance, 'bold', false); + this.safeCallOn(instance, 'alignLeft'); + } + } + this.safeCallOn(instance, 'newLine'); + } + + // LLM callback script + if (call.llm_callback_script) { + this.safeCallOn(instance, 'alignCenter'); + this.safeCallOn(instance, 'bold', true); + instance.println('SUGGESTED CALLBACK SCRIPT'); + this.safeCallOn(instance, 'bold', false); + this.safeCallOn(instance, 'drawLine'); + this.safeCallOn(instance, 'alignLeft'); + + const script = String(call.llm_callback_script); + const maxLineLen = width || 48; + const words = script.split(' '); + let line = ''; + for (const word of words) { + if ((line + ' ' + word).trim().length > maxLineLen) { + instance.println(line.trim()); + line = word; + } else { + line += (line ? ' ' : '') + word; + } + } + if (line.trim()) instance.println(line.trim()); + this.safeCallOn(instance, 'newLine'); + } + + // Action section + if (score >= 60) { + this.safeCallOn(instance, 'drawLine'); + this.safeCallOn(instance, 'newLine'); + this.safeCallOn(instance, 'alignCenter'); + this.safeCallOn(instance, 'setTextSize', 1, 1); + this.safeCallOn(instance, 'invert', true); + this.safeCallOn(instance, 'bold', true); + instance.println(' CALLBACK RECOMMENDED '); + this.safeCallOn(instance, 'bold', false); + this.safeCallOn(instance, 'invert', false); + this.safeCallOn(instance, 'setTextNormal'); + this.safeCallOn(instance, 'newLine'); + this.safeCallOn(instance, 'alignLeft'); + instance.println('[ ] Called back'); + instance.println('[ ] No answer'); + instance.println('[ ] Skipped'); + } + + // Footer + this.safeCallOn(instance, 'drawLine'); + this.safeCallOn(instance, 'alignCenter'); + instance.println(`Ref #${call.id || ''}`); + instance.println('Powered by Think Link AI'); + this.safeCallOn(instance, 'drawLine'); + + this.safeCallOn(instance, 'newLine'); + this.safeCallOn(instance, 'newLine'); + this.safeCallOn(instance, 'partialCut'); + } + + async printAbandonedCallReceipt(call, printerConfigs) { + if (!printerConfigs || printerConfigs.length === 0) { + console.log('[PrinterManager] No printers configured for abandoned call receipts'); + return { success: false, successCount: 0 }; + } + + const callId = call && call.id ? call.id : 'unknown'; + console.log(`[PrinterManager] Printing abandoned call #${callId} to ${printerConfigs.length} printer(s)`); + + const jobs = printerConfigs.map(async (cfg, idx) => { + console.log(`[PrinterManager] Abandoned call print ${idx + 1}/${printerConfigs.length}: ${cfg.name}`); + + if (cfg.type === 'system') { + const filePath = path.join(os.tmpdir(), `kitchen-agent-abandoned-${callId}-${Date.now()}-${Math.random().toString(36).slice(2)}.pdf`); + try { + await this.writeAbandonedCallPdfToPath(filePath, call, cfg); + await pdfPrinter.print(filePath, { printer: cfg.interface }); + await new Promise(resolve => setTimeout(resolve, 2000)); + } finally { + try { fs.unlinkSync(filePath); } catch (_) {} + } + } else { + const instance = this.createPrinterInstanceFromConfig(cfg); + await this.renderAbandonedCallReceipt(instance, call, cfg); + + let normalizedInterface = cfg.interface; + if (cfg.type === 'network') { + if (!normalizedInterface.startsWith('tcp://')) { + const hasPort = normalizedInterface.includes(':'); + normalizedInterface = hasPort ? `tcp://${normalizedInterface}` : `tcp://${normalizedInterface}:9100`; + } + } else if (cfg.type === 'com' || cfg.type === 'serial') { + if (/^COM\d+$/i.test(normalizedInterface)) { + normalizedInterface = `\\\\.\\${normalizedInterface.toUpperCase()}`; + } + } + + await this.executeForInstance(instance, normalizedInterface); + } + console.log(`[PrinterManager] Abandoned call printed to: ${cfg.name}`); + }); + + const results = await Promise.allSettled(jobs); + const successCount = results.filter(r => r.status === 'fulfilled').length; + const failCount = results.length - successCount; + console.log(`[PrinterManager] Abandoned call #${callId}: ${successCount} succeeded, ${failCount} failed`); + + return { success: successCount > 0, successCount }; + } + + async writeAbandonedCallPdfToPath(filePath, call, cfg) { + const doc = new PDFDocument({ size: [226, 800], margin: 10 }); + const pageWidth = 206; // 226 - 2*10 margin + return new Promise((resolve, reject) => { + const stream = fs.createWriteStream(filePath); + doc.pipe(stream); + + const priority = call.callback_priority || 'low'; + const score = Number(call.callback_score) || 0; + + // Top rule + doc.moveTo(10, doc.y).lineTo(216, doc.y).stroke(); + doc.moveDown(0.4); + + // Title + doc.fontSize(16).font('Helvetica-Bold').text('ABANDONED CALL', { align: 'center' }); + doc.moveDown(0.2); + + // Priority badge - draw a filled rectangle behind text for critical/high + if (priority === 'critical' || priority === 'high') { + const badgeText = priority === 'critical' ? 'CRITICAL PRIORITY' : 'HIGH PRIORITY'; + const badgeWidth = doc.widthOfString(badgeText, { fontSize: 11 }) + 16; + const badgeX = (226 - badgeWidth) / 2; + const badgeY = doc.y; + doc.save(); + doc.rect(badgeX, badgeY, badgeWidth, 16).fill('#000'); + doc.fontSize(11).font('Helvetica-Bold').fillColor('#fff') + .text(badgeText, 10, badgeY + 3, { align: 'center', width: pageWidth }); + doc.fillColor('#000').restore(); + doc.y = badgeY + 20; + } else { + const label = priority === 'medium' ? 'MEDIUM PRIORITY' : `Priority: ${priority.toUpperCase()}`; + doc.fontSize(10).font('Helvetica-Bold').text(label, { align: 'center' }); + } + doc.moveDown(0.3); + + // Divider + doc.moveTo(10, doc.y).lineTo(216, doc.y).stroke(); + doc.moveDown(0.3); + + // Timestamp + const callTime = call.call_started_at ? new Date(Number(call.call_started_at) * 1000) : new Date(); + const timeStr = callTime.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }); + const dateStr = callTime.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }); + doc.fontSize(9).font('Helvetica').text(`${dateStr} ${timeStr}`, { align: 'center' }); + doc.moveDown(0.3); + + // Caller section header + doc.fontSize(10).font('Helvetica-Bold').text('CALLER', { align: 'center' }); + doc.moveTo(10, doc.y + 2).lineTo(216, doc.y + 2).stroke(); + doc.moveDown(0.3); + + // Phone number prominent + doc.fontSize(12).font('Helvetica-Bold').text(`Phone: ${call.caller_phone_normalized || call.caller_phone || 'Unknown'}`, { align: 'left' }); + doc.font('Helvetica'); + if (call.caller_name) { + doc.fontSize(10).text(`Name: ${call.caller_name}`); + } + + if (call.is_known_customer) { + doc.moveDown(0.15); + const custText = `RETURNING CUSTOMER ${call.previous_order_count || 0} previous orders`; + const custWidth = doc.widthOfString(custText, { fontSize: 8 }) + 10; + const custX = 10; + const custY = doc.y; + doc.save(); + doc.rect(custX, custY, Math.min(custWidth, pageWidth), 13).fill('#000'); + doc.fontSize(8).font('Helvetica-Bold').fillColor('#fff') + .text(custText, custX + 4, custY + 3, { width: pageWidth - 8 }); + doc.fillColor('#000').restore(); + doc.y = custY + 16; + } + + doc.fontSize(9).font('Helvetica').text(`Callback Score: ${score}/100`); + doc.moveDown(0.3); + + // What happened section + doc.fontSize(10).font('Helvetica-Bold').text('WHAT HAPPENED', { align: 'center' }); + doc.moveTo(10, doc.y + 2).lineTo(216, doc.y + 2).stroke(); + doc.moveDown(0.3); + + const stageMessages = { + ring_only: 'Rang but hung up before AI could answer.', + greeting_hangup: 'Heard the greeting then disconnected.', + silent_post_greeting: 'Stayed on without speaking.', + minimal_speech: 'Spoke briefly then disconnected.', + pre_intent: 'Began speaking but intent was unclear.', + intent_identified: 'Expressed interest in ordering but disconnected.', + partial_order: 'Started placing an order then disconnected.', + partial_appointment: 'Was booking an appointment then disconnected.', + pre_confirmation: 'Order was nearly complete before disconnecting.', + system_failure: 'A system error caused the disconnection.' + }; + + doc.fontSize(9).font('Helvetica').text(stageMessages[call.abandonment_stage] || 'Call ended before completion.'); + const dur = Number(call.duration_seconds || 0); + const durLabel = dur >= 60 ? `${Math.floor(dur / 60)}m ${dur % 60}s` : `${dur}s`; + doc.text(`Call duration: ${durLabel}`); + doc.moveDown(0.3); + + // Items mentioned + const items = call.items || []; + let parsedSnapshot = null; + if (items.length === 0 && call.partial_order_snapshot) { + try { + parsedSnapshot = typeof call.partial_order_snapshot === 'string' + ? JSON.parse(call.partial_order_snapshot) + : call.partial_order_snapshot; + } catch (_) {} + } + const displayItems = items.length > 0 + ? items + : (parsedSnapshot && parsedSnapshot.orderData && parsedSnapshot.orderData.items) || []; + + if (displayItems.length > 0) { + doc.fontSize(10).font('Helvetica-Bold').text('ITEMS MENTIONED', { align: 'center' }); + doc.moveTo(10, doc.y + 2).lineTo(216, doc.y + 2).stroke(); + doc.moveDown(0.3); + doc.fontSize(9).font('Helvetica'); + for (const item of displayItems) { + const itemName = item.item_name || item.itemName || item.name || 'Unknown'; + const qty = item.quantity || item.qty || 1; + const price = item.unit_price || item.price || null; + const line = price ? `${qty}x ${itemName} $${Number(price).toFixed(2)}` : `${qty}x ${itemName}`; + doc.text(line); + } + if (call.partial_order_value || call.estimated_order_value) { + const est = Number(call.partial_order_value || call.estimated_order_value); + if (est > 0) { + doc.moveDown(0.15); + doc.font('Helvetica-Bold').text(`Est. Value: $${est.toFixed(2)}`, { align: 'right' }); + doc.font('Helvetica'); + } + } + doc.moveDown(0.3); + } + + // Callback script + if (call.llm_callback_script) { + doc.fontSize(10).font('Helvetica-Bold').text('SUGGESTED CALLBACK SCRIPT', { align: 'center' }); + doc.moveTo(10, doc.y + 2).lineTo(216, doc.y + 2).stroke(); + doc.moveDown(0.3); + doc.fontSize(9).font('Helvetica').text(call.llm_callback_script, { width: pageWidth }); + doc.moveDown(0.3); + } + + // Action section + if (score >= 60) { + doc.moveTo(10, doc.y).lineTo(216, doc.y).stroke(); + doc.moveDown(0.3); + const actionText = 'CALLBACK RECOMMENDED'; + const actionWidth = doc.widthOfString(actionText, { fontSize: 12 }) + 20; + const actionX = (226 - actionWidth) / 2; + const actionY = doc.y; + doc.save(); + doc.rect(actionX, actionY, actionWidth, 18).fill('#000'); + doc.fontSize(12).font('Helvetica-Bold').fillColor('#fff') + .text(actionText, 10, actionY + 4, { align: 'center', width: pageWidth }); + doc.fillColor('#000').restore(); + doc.y = actionY + 22; + doc.moveDown(0.2); + doc.fontSize(9).font('Helvetica').fillColor('#000'); + doc.text('[ ] Called back [ ] No answer [ ] Skipped'); + } + + // Footer + doc.moveDown(0.3); + doc.moveTo(10, doc.y).lineTo(216, doc.y).stroke(); + doc.moveDown(0.2); + doc.fontSize(8).font('Helvetica').text(`Ref #${call.id || ''}`, { align: 'center' }); + doc.text('Powered by Think Link AI', { align: 'center' }); + + doc.end(); + stream.on('finish', resolve); + stream.on('error', reject); + }); + } } module.exports = new PrinterManager(); diff --git a/public/css/style.css b/public/css/style.css index a62e43e..6d39229 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -1602,3 +1602,388 @@ button, a, input, select, textarea { } } +/* ===== Abandoned Calls ===== */ + +/* Dashboard notification badge */ +.abandoned-calls-link { + position: relative; + display: inline-flex; + align-items: center; + gap: 6px; +} + +.abandoned-calls-icon { + font-size: 16px; +} + +.abandoned-calls-badge { + background: #dc3545; + color: #fff; + font-size: 11px; + font-weight: 700; + min-width: 20px; + height: 20px; + line-height: 20px; + text-align: center; + border-radius: 10px; + padding: 0 6px; +} + +.abandoned-calls-link.pulse { + animation: badgePulse 0.6s ease-in-out 3; +} + +@keyframes badgePulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.1); } +} + +/* Back link in header */ +.header-title-group { + display: flex; + align-items: center; + gap: 12px; +} + +.back-link { + font-size: 24px; + color: #fff; + text-decoration: none; + display: flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + border-radius: 8px; + background: rgba(255,255,255,0.15); + transition: background 0.2s; +} + +.back-link:hover { + background: rgba(255,255,255,0.25); +} + +/* Abandoned call cards */ +.abandoned-call-card { + border-left-width: 5px; + border-left-style: solid; + cursor: pointer; + transition: box-shadow 0.2s, transform 0.15s; +} + +.abandoned-call-card:active { + transform: scale(0.99); +} + +.abandoned-call-card.priority-critical { border-left-color: #dc3545; } +.abandoned-call-card.priority-high { border-left-color: #fd7e14; } +.abandoned-call-card.priority-medium { border-left-color: #ffc107; } +.abandoned-call-card.priority-low { border-left-color: #adb5bd; } +.abandoned-call-card.priority-none { border-left-color: #dee2e6; } + +.abandoned-call-card.ac-done { + opacity: 0.65; +} + +/* Card header */ +.ac-header { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 8px; +} + +.ac-header-left { + display: flex; + align-items: center; + gap: 8px; +} + +.ac-priority-badge { + display: inline-block; + padding: 3px 10px; + border-radius: 12px; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.badge-critical { background: #dc3545; color: #fff; } +.badge-high { background: #fd7e14; color: #fff; } +.badge-medium { background: #ffc107; color: #333; } +.badge-low { background: #e9ecef; color: #666; } +.badge-none { background: #f8f9fa; color: #999; } + +.ac-time-ago { + font-size: 13px; + color: #888; +} + +.ac-score { + font-size: 13px; + font-weight: 600; + color: #555; +} + +/* Caller info */ +.ac-caller-info { + margin: 10px 0 6px; +} + +.ac-phone { + font-size: 18px; + font-weight: 700; + color: #333; + letter-spacing: 0.5px; +} + +.ac-name { + font-size: 14px; + color: #555; + margin-top: 2px; +} + +.known-customer-badge { + display: inline-block; + margin-top: 4px; + padding: 2px 8px; + background: #e8f5e9; + color: #2e7d32; + border-radius: 10px; + font-size: 12px; + font-weight: 600; +} + +/* Stage and duration */ +.ac-stage { + font-size: 14px; + color: #444; + margin: 8px 0 2px; + line-height: 1.4; +} + +.ac-duration { + font-size: 12px; + color: #999; + margin-bottom: 6px; +} + +/* Items */ +.ac-items { + background: #f8f9fa; + border-radius: 6px; + padding: 8px 10px; + margin: 6px 0; +} + +.ac-items-title { + font-size: 12px; + font-weight: 600; + color: #666; + margin-bottom: 4px; +} + +.ac-item { + font-size: 13px; + color: #333; + padding: 1px 0; +} + +.ac-item-value { + font-size: 13px; + font-weight: 600; + color: #28a745; + margin-top: 4px; +} + +/* LLM summary */ +.ac-summary { + font-size: 13px; + color: #555; + font-style: italic; + margin: 6px 0; + line-height: 1.4; +} + +/* Callback script */ +.ac-callback-script { + background: #fff8e1; + border-left: 3px solid #ffc107; + border-radius: 4px; + padding: 8px 10px; + margin: 8px 0; +} + +.ac-script-label { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + color: #e65100; + margin-bottom: 3px; +} + +.ac-script-text { + font-size: 13px; + color: #333; + line-height: 1.4; +} + +/* Actions */ +.ac-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid #eee; +} + +.ac-action-btn { + min-height: 44px; + min-width: 44px; + padding: 8px 14px; + font-size: 13px; + font-weight: 600; + border-radius: 8px; + flex: 1 1 auto; +} + +.ac-action-converted { + border: 2px solid #28a745; +} + +.ac-action-reprint { + flex: 0 0 44px; + padding: 8px; + font-size: 16px; +} + +.ac-status-done { + font-size: 13px; + font-weight: 600; + color: #666; + text-transform: capitalize; + padding: 8px 0; +} + +/* Stats for abandoned calls page */ +.stat-abandoned-pending { + border-left: 4px solid #dc3545; +} + +.stat-converted { + border-left: 4px solid #28a745; +} + +/* Controls layout */ +.controls-right { + display: flex; + align-items: center; + gap: 12px; +} + +/* Toggle switch */ +.toggle-switch { + display: inline-flex; + align-items: center; + gap: 8px; + cursor: pointer; + user-select: none; + -webkit-tap-highlight-color: transparent; + padding: 6px 0; +} + +.toggle-switch input { + display: none; +} + +.toggle-slider { + position: relative; + width: 44px; + height: 24px; + background: #ccc; + border-radius: 12px; + transition: background 0.25s; + flex-shrink: 0; +} + +.toggle-slider::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 20px; + height: 20px; + background: #fff; + border-radius: 50%; + transition: transform 0.25s; + box-shadow: 0 1px 3px rgba(0,0,0,0.2); +} + +.toggle-switch input:checked + .toggle-slider { + background: #667eea; +} + +.toggle-switch input:checked + .toggle-slider::after { + transform: translateX(20px); +} + +.toggle-label { + font-size: 13px; + color: #666; + white-space: nowrap; +} + +/* Filter button colors for abandoned calls page */ +.filter-critical { color: #dc3545; } +.filter-critical.active { background: #dc3545; color: #fff; } +.filter-high { color: #fd7e14; } +.filter-high.active { background: #fd7e14; color: #fff; } +.filter-medium { color: #856404; } +.filter-medium.active { background: #ffc107; color: #333; } + +/* Responsive */ +@media (max-width: 768px) { + .ac-phone { + font-size: 16px; + } + + .ac-action-btn { + font-size: 12px; + padding: 8px 10px; + } + + .back-link { + width: 38px; + height: 38px; + font-size: 20px; + } +} + +@media (max-width: 480px) { + .ac-actions { + gap: 4px; + } + + .ac-action-btn { + min-height: 40px; + padding: 6px 8px; + font-size: 11px; + } + + .abandoned-calls-link span:not(.abandoned-calls-badge):not(.abandoned-calls-icon) { + display: none; + } + + .controls-right { + flex-wrap: wrap; + gap: 8px; + } + + .toggle-label { + font-size: 12px; + } +} + diff --git a/public/js/abandoned-calls.js b/public/js/abandoned-calls.js new file mode 100644 index 0000000..639dc83 --- /dev/null +++ b/public/js/abandoned-calls.js @@ -0,0 +1,499 @@ +let currentFilter = 'all'; +let showProcessed = false; +let previousCallIds = new Set(); +let isFirstLoad = true; +let currentDetailCall = null; + +const PROCESSED_STATUSES = new Set(['converted', 'dismissed', 'reached', 'no_answer', 'attempted']); + +const connectionStatus = { + local: { status: 'checking', lastCheck: null, consecutiveFailures: 0 }, + api: { status: 'checking', lastCheck: null, consecutiveFailures: 0, responseTime: null } +}; + +const audioNotification = { + sound: null, + enabled: true, + init: function() { + fetch('/api/notification-settings') + .then(r => r.json()) + .then(data => { + if (!data.error) { + this.enabled = data.soundNotificationsEnabled !== 'false'; + const soundPath = data.newOrderSoundPath || '/public/sounds/new-order-notification.mp3'; + this.sound = new Audio(soundPath); + if (data.soundVolume) this.sound.volume = parseInt(data.soundVolume, 10) / 100; + } + }) + .catch(() => { this.sound = new Audio('/public/sounds/new-order-notification.mp3'); }); + }, + play: function() { + if (this.enabled && this.sound) { + this.sound.currentTime = 0; + this.sound.play().catch(() => {}); + } + } +}; + +document.addEventListener('DOMContentLoaded', function() { + setupFilterButtons(); + setupShowProcessedToggle(); + audioNotification.init(); + refreshAbandonedCalls(); + setInterval(refreshAbandonedCalls, config.refreshInterval || 15000); + checkConnectionStatus(); + setInterval(checkConnectionStatus, 15000); +}); + +function setupShowProcessedToggle() { + const checkbox = document.getElementById('showProcessedCheckbox'); + if (checkbox) { + checkbox.checked = showProcessed; + checkbox.addEventListener('change', function() { + showProcessed = this.checked; + refreshAbandonedCalls(); + }); + } +} + +function setupFilterButtons() { + document.querySelectorAll('.filter-btn').forEach(button => { + button.addEventListener('click', function() { + document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active')); + this.classList.add('active'); + currentFilter = this.dataset.filter; + refreshAbandonedCalls(); + }); + }); +} + +function refreshAbandonedCalls() { + const syncButton = document.getElementById('syncButton'); + const syncText = syncButton ? syncButton.querySelector('.sync-text') : null; + if (syncButton) { syncButton.classList.add('loading'); syncButton.disabled = true; } + if (syncText) syncText.textContent = 'Loading...'; + + let url; + if (currentFilter === 'queued') { + url = '/api/abandoned-calls/callback-queue?limit=50'; + } else if (currentFilter === 'all') { + url = '/api/abandoned-calls?limit=100'; + } else { + url = '/api/abandoned-calls?limit=100&priority=' + currentFilter; + } + + fetch(url) + .then(r => r.json()) + .then(data => { + if (!data.error) { + let calls = data.queue || data.calls || []; + + if (!showProcessed) { + calls = calls.filter(c => !PROCESSED_STATUSES.has(c.callback_status)); + } + + updateCards(calls); + refreshStats(); + } + }) + .catch(err => console.error('Failed to load abandoned calls:', err)) + .finally(() => { + if (syncButton) { syncButton.classList.remove('loading'); syncButton.disabled = false; } + if (syncText) syncText.textContent = 'Refresh'; + }); +} + +function refreshStats() { + fetch('/api/abandoned-calls/pending-count') + .then(r => r.json()) + .then(data => { + if (!data.error) { + const el = document.getElementById('stat-pending'); + if (el) el.textContent = data.count || 0; + } + }) + .catch(() => {}); +} + +function updateCards(calls) { + const container = document.getElementById('abandonedCallsContainer'); + if (!calls || calls.length === 0) { + container.innerHTML = '
Est. Value: $${Number(call.partial_order_value).toFixed(2)}
`; + } + } + + content.innerHTML = ` +ID: ${call.id}
+Time: ${timeStr}
+Duration: ${call.duration_seconds || 0}s
+Priority: ${priority.toUpperCase()}
+Score: ${score}/100
+Stage: ${call.abandonment_stage || 'N/A'}
+Intent: ${call.detected_intent || 'N/A'}
+Status: ${status}
+Phone: ${escapeHtml(call.caller_phone_normalized || call.caller_phone || 'Unknown')}
+ ${call.caller_name ? `Name: ${escapeHtml(call.caller_name)}
` : ''} +Known Customer: ${call.is_known_customer ? 'Yes' : 'No'}
+ ${call.is_known_customer ? `Previous Orders: ${call.previous_order_count || 0}
` : ''} +${escapeHtml(call.caller_speech_text)}
+Summary: ${escapeHtml(call.llm_summary)}
+ ${call.llm_sentiment ? `Sentiment: ${call.llm_sentiment}
` : ''} + ${call.llm_hesitancy_analysis ? `Hesitancy: ${escapeHtml(call.llm_hesitancy_analysis)}
` : ''} + ${call.llm_recovery_suggestion ? `Recovery: ${escapeHtml(call.llm_recovery_suggestion)}
` : ''} +${typeof call.score_breakdown === 'string' ? call.score_breakdown : JSON.stringify(call.score_breakdown, null, 2)}
+