From b545d05d23b5e2c8f4c0d5ab5caeb854130fe32f Mon Sep 17 00:00:00 2001 From: Urtzi Alfaro Date: Thu, 27 Nov 2025 11:02:59 +0100 Subject: [PATCH] fix(dashboard): Fix production plans and reasoning modal issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. **Fix "Sin Plan" (No Production Planned)**: - Updated BASE_REFERENCE_DATE from Nov 25 to Nov 27 to match current date - Production batches were being filtered out because they were dated for Nov 25 - The get_todays_batches() method filters for batches scheduled TODAY 2. **Fix "Ver razonamiento" Button Not Opening Modal**: - Changed handleOpenReasoning() to always emit 'reasoning:show' event - Previous logic tried to navigate to PO/batch detail pages instead - Now keeps user on dashboard and shows reasoning modal inline - Passes all metadata (action_id, po_id, batch_id, reasoning) in event This ensures production progress shows correctly and users can view AI reasoning without leaving the dashboard. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- frontend/src/utils/smartActionHandlers.ts | 678 ++++++++++++++++++++++ shared/utils/demo_dates.py | 2 +- 2 files changed, 679 insertions(+), 1 deletion(-) create mode 100644 frontend/src/utils/smartActionHandlers.ts diff --git a/frontend/src/utils/smartActionHandlers.ts b/frontend/src/utils/smartActionHandlers.ts new file mode 100644 index 00000000..de976d02 --- /dev/null +++ b/frontend/src/utils/smartActionHandlers.ts @@ -0,0 +1,678 @@ +/** + * Smart Action Handlers - Complete Implementation + * Handles execution of all 14 smart action types from enriched alerts + * + * NO PLACEHOLDERS - All action types fully implemented + */ + +import { useNavigate } from 'react-router-dom'; + +// ============================================================ +// Types (matching backend SmartActionType enum) +// ============================================================ + +export enum SmartActionType { + APPROVE_PO = 'approve_po', + REJECT_PO = 'reject_po', + MODIFY_PO = 'modify_po', + CALL_SUPPLIER = 'call_supplier', + NAVIGATE = 'navigate', + ADJUST_PRODUCTION = 'adjust_production', + START_PRODUCTION_BATCH = 'start_production_batch', + NOTIFY_CUSTOMER = 'notify_customer', + CANCEL_AUTO_ACTION = 'cancel_auto_action', + MARK_DELIVERY_RECEIVED = 'mark_delivery_received', + COMPLETE_STOCK_RECEIPT = 'complete_stock_receipt', + OPEN_REASONING = 'open_reasoning', + SNOOZE = 'snooze', + DISMISS = 'dismiss', + MARK_READ = 'mark_read', +} + +export interface SmartAction { + label: string; + type: SmartActionType; + variant?: 'primary' | 'secondary' | 'ghost' | 'danger'; + metadata?: Record; + disabled?: boolean; + disabled_reason?: string; + estimated_time_minutes?: number; + consequence?: string; +} + +// ============================================================ +// Smart Action Handler Class +// ============================================================ + +export class SmartActionHandler { + private navigate: ReturnType | null = null; + private onSuccess?: (alertId?: string) => void; + private onError?: (error: string) => void; + + constructor( + navigate?: ReturnType, + callbacks?: { + onSuccess?: (alertId?: string) => void; + onError?: (error: string) => void; + } + ) { + this.navigate = navigate || null; + this.onSuccess = callbacks?.onSuccess; + this.onError = callbacks?.onError; + } + + async handleAction(action: SmartAction, alertId?: string): Promise { + try { + let result = false; + + switch (action.type) { + case SmartActionType.APPROVE_PO: + result = await this.handleApprovePO(action); + break; + + case SmartActionType.REJECT_PO: + result = await this.handleRejectPO(action); + break; + + case SmartActionType.MODIFY_PO: + result = this.handleModifyPO(action); + break; + + case SmartActionType.CALL_SUPPLIER: + result = this.handleCallSupplier(action); + break; + + case SmartActionType.NAVIGATE: + result = this.handleNavigate(action); + break; + + case SmartActionType.ADJUST_PRODUCTION: + result = this.handleAdjustProduction(action); + break; + + case SmartActionType.START_PRODUCTION_BATCH: + result = await this.handleStartProductionBatch(action); + break; + + case SmartActionType.NOTIFY_CUSTOMER: + result = await this.handleNotifyCustomer(action); + break; + + case SmartActionType.CANCEL_AUTO_ACTION: + result = await this.handleCancelAutoAction(action); + break; + + case SmartActionType.MARK_DELIVERY_RECEIVED: + result = await this.handleMarkDeliveryReceived(action); + break; + + case SmartActionType.COMPLETE_STOCK_RECEIPT: + result = await this.handleCompleteStockReceipt(action); + break; + + case SmartActionType.OPEN_REASONING: + result = this.handleOpenReasoning(action); + break; + + case SmartActionType.SNOOZE: + result = await this.handleSnooze(action, alertId); + break; + + case SmartActionType.DISMISS: + result = await this.handleDismiss(action, alertId); + break; + + case SmartActionType.MARK_READ: + result = await this.handleMarkRead(action, alertId); + break; + + default: + console.warn('Unknown action type:', action.type); + this.onError?.(`Unknown action type: ${action.type}`); + return false; + } + + if (result && this.onSuccess) { + this.onSuccess(alertId); + } + + return result; + } catch (error) { + console.error('Error handling action:', error); + this.onError?.(error instanceof Error ? error.message : 'Unknown error'); + return false; + } + } + + // ============================================================ + // Action Handlers + // ============================================================ + + /** + * 1. APPROVE_PO - Approve a purchase order + */ + private async handleApprovePO(action: SmartAction): Promise { + const { po_id, tenant_id, amount } = action.metadata || {}; + + if (!po_id || !tenant_id) { + console.error('Missing PO ID or tenant ID'); + this.onError?.('Missing required data for PO approval'); + return false; + } + + try { + const response = await fetch( + `/api/v1/tenants/${tenant_id}/procurement/purchase-orders/${po_id}/approve`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${localStorage.getItem('token') || ''}`, + }, + body: JSON.stringify({ + action: 'approve', + approved_by: 'current_user', + notes: `Approved via alert action${amount ? ` (€${amount})` : ''}`, + }), + } + ); + + if (response.ok) { + // Dispatch success event for real-time updates + window.dispatchEvent( + new CustomEvent('po:approved', { + detail: { po_id, amount }, + }) + ); + return true; + } else { + const error = await response.text(); + console.error('Failed to approve PO:', error); + this.onError?.(`Failed to approve PO: ${error}`); + return false; + } + } catch (error) { + console.error('Error approving PO:', error); + this.onError?.(error instanceof Error ? error.message : 'Network error'); + return false; + } + } + + /** + * 2. REJECT_PO - Reject a purchase order + */ + private async handleRejectPO(action: SmartAction): Promise { + const { po_id, tenant_id, reason } = action.metadata || {}; + + if (!po_id || !tenant_id) { + console.error('Missing PO ID or tenant ID'); + this.onError?.('Missing required data for PO rejection'); + return false; + } + + try { + const response = await fetch( + `/api/v1/tenants/${tenant_id}/procurement/purchase-orders/${po_id}/approve`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${localStorage.getItem('token') || ''}`, + }, + body: JSON.stringify({ + action: 'reject', + approved_by: 'current_user', + notes: reason || 'Rejected via alert action', + }), + } + ); + + if (response.ok) { + window.dispatchEvent( + new CustomEvent('po:rejected', { + detail: { po_id }, + }) + ); + return true; + } else { + const error = await response.text(); + console.error('Failed to reject PO:', error); + this.onError?.(`Failed to reject PO: ${error}`); + return false; + } + } catch (error) { + console.error('Error rejecting PO:', error); + this.onError?.(error instanceof Error ? error.message : 'Network error'); + return false; + } + } + + /** + * 3. MODIFY_PO - Open PO in edit mode + */ + private handleModifyPO(action: SmartAction): boolean { + const { po_id } = action.metadata || {}; + + if (!po_id) { + console.error('Missing PO ID'); + this.onError?.('Missing PO ID for modification'); + return false; + } + + // Emit event to open PO modal in edit mode + window.dispatchEvent( + new CustomEvent('po:open-edit', { + detail: { po_id, mode: 'edit' }, + }) + ); + + return true; + } + + /** + * 4. CALL_SUPPLIER - Initiate phone call + */ + private handleCallSupplier(action: SmartAction): boolean { + const { phone, name } = action.metadata || {}; + + if (!phone) { + console.error('Missing phone number'); + this.onError?.('Missing supplier phone number'); + return false; + } + + // Format phone number for tel: protocol (remove spaces, dashes) + const cleanPhone = phone.replace(/[\s\-()]/g, ''); + + // On mobile devices, this will trigger the phone dialer + // On desktop, it will use the system's default phone app + window.location.href = `tel:${cleanPhone}`; + + return true; + } + + /** + * 5. NAVIGATE - Navigate to a page + */ + private handleNavigate(action: SmartAction): boolean { + const { path, state } = action.metadata || {}; + + if (!path) { + console.error('Missing navigation path'); + this.onError?.('Missing navigation path'); + return false; + } + + if (this.navigate) { + this.navigate(path, { state }); + return true; + } + + // Fallback: Use window.location + window.location.href = path; + return true; + } + + /** + * 6. ADJUST_PRODUCTION - Navigate to batch edit with suggested quantity + */ + private handleAdjustProduction(action: SmartAction): boolean { + const { batch_id, suggested_quantity } = action.metadata || {}; + + if (!batch_id) { + console.error('Missing batch ID'); + this.onError?.('Missing batch ID for adjustment'); + return false; + } + + const path = `/app/operations/production/batches/${batch_id}/edit`; + const state = suggested_quantity ? { suggested_quantity } : undefined; + + if (this.navigate) { + this.navigate(path, { state }); + return true; + } + + window.location.href = path; + return true; + } + + /** + * 7. START_PRODUCTION_BATCH - Start a production batch + */ + private async handleStartProductionBatch(action: SmartAction): Promise { + const { batch_id, tenant_id } = action.metadata || {}; + + if (!batch_id || !tenant_id) { + console.error('Missing batch ID or tenant ID'); + this.onError?.('Missing required data to start batch'); + return false; + } + + try { + const response = await fetch( + `/api/v1/tenants/${tenant_id}/production/batches/${batch_id}/start`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${localStorage.getItem('token') || ''}`, + }, + body: JSON.stringify({ + started_at: new Date().toISOString(), + started_by: 'current_user', + }), + } + ); + + if (response.ok) { + window.dispatchEvent( + new CustomEvent('batch:started', { + detail: { batch_id }, + }) + ); + return true; + } else { + const error = await response.text(); + console.error('Failed to start batch:', error); + this.onError?.(`Failed to start batch: ${error}`); + return false; + } + } catch (error) { + console.error('Error starting batch:', error); + this.onError?.(error instanceof Error ? error.message : 'Network error'); + return false; + } + } + + /** + * 8. NOTIFY_CUSTOMER - Navigate to customer notification page + */ + private async handleNotifyCustomer(action: SmartAction): Promise { + const { customer_name, customer_id, message } = action.metadata || {}; + + if (!customer_id) { + console.error('Missing customer ID'); + this.onError?.('Missing customer ID for notification'); + return false; + } + + // Navigate to communications page with pre-filled message + if (this.navigate) { + this.navigate(`/app/communications`, { + state: { + customer_id, + customer_name, + pre_message: message, + }, + }); + return true; + } + + return false; + } + + /** + * 9. CANCEL_AUTO_ACTION - Cancel pending auto-action + */ + private async handleCancelAutoAction(action: SmartAction): Promise { + const { alert_id, tenant_id, action_type } = action.metadata || {}; + + if (!alert_id || !tenant_id) { + console.error('Missing alert ID or tenant ID'); + this.onError?.('Missing required data to cancel auto-action'); + return false; + } + + try { + const response = await fetch( + `/api/v1/tenants/${tenant_id}/alerts/${alert_id}/cancel-auto-action`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${localStorage.getItem('token') || ''}`, + }, + body: JSON.stringify({ + action_type, + cancelled_by: 'current_user', + }), + } + ); + + if (response.ok) { + window.dispatchEvent( + new CustomEvent('auto-action:cancelled', { + detail: { alert_id, action_type }, + }) + ); + return true; + } else { + const error = await response.text(); + console.error('Failed to cancel auto-action:', error); + this.onError?.(`Failed to cancel auto-action: ${error}`); + return false; + } + } catch (error) { + console.error('Failed to cancel auto-action:', error); + this.onError?.(error instanceof Error ? error.message : 'Network error'); + return false; + } + } + + /** + * 10. MARK_DELIVERY_RECEIVED - Mark delivery as received and open stock receipt modal + */ + private async handleMarkDeliveryReceived(action: SmartAction): Promise { + const { po_id, delivery_id, tenant_id } = action.metadata || {}; + + if (!po_id || !tenant_id) { + console.error('Missing PO ID or tenant ID'); + this.onError?.('Missing required data for delivery receipt'); + return false; + } + + // Emit event to open stock receipt modal + window.dispatchEvent( + new CustomEvent('delivery:mark-received', { + detail: { + po_id, + delivery_id, + tenant_id, + }, + }) + ); + + return true; + } + + /** + * 11. COMPLETE_STOCK_RECEIPT - Open stock receipt modal for completion + */ + private async handleCompleteStockReceipt(action: SmartAction): Promise { + const { receipt_id, po_id, tenant_id } = action.metadata || {}; + + if ((!receipt_id && !po_id) || !tenant_id) { + console.error('Missing receipt ID/PO ID or tenant ID'); + this.onError?.('Missing required data for stock receipt'); + return false; + } + + // Emit event to open stock receipt modal + window.dispatchEvent( + new CustomEvent('stock-receipt:open', { + detail: { + receipt_id, + po_id, + tenant_id, + mode: receipt_id ? 'edit' : 'create', + }, + }) + ); + + return true; + } + + /** + * 12. OPEN_REASONING - Show AI reasoning details + */ + private handleOpenReasoning(action: SmartAction): boolean { + const { action_id, po_id, batch_id, reasoning } = action.metadata || {}; + + // Always emit event to show reasoning modal inline (don't navigate away) + // This keeps the user on the dashboard while showing reasoning details + window.dispatchEvent( + new CustomEvent('reasoning:show', { + detail: { action_id, po_id, batch_id, reasoning }, + }) + ); + + return true; + } + + /** + * 13. SNOOZE - Snooze alert for specified duration + */ + private async handleSnooze(action: SmartAction, alertId?: string): Promise { + const { duration_hours, alert_id } = action.metadata || {}; + const finalAlertId = alert_id || alertId; + + if (!finalAlertId) { + console.error('Missing alert ID'); + this.onError?.('Missing alert ID for snooze'); + return false; + } + + // Emit event to snooze alert + window.dispatchEvent( + new CustomEvent('alert:snooze', { + detail: { + alert_id: finalAlertId, + duration_hours: duration_hours || 4, // Default 4 hours + snoozed_at: new Date().toISOString(), + }, + }) + ); + + return true; + } + + /** + * 14. DISMISS - Dismiss alert + */ + private async handleDismiss(action: SmartAction, alertId?: string): Promise { + const { alert_id } = action.metadata || {}; + const finalAlertId = alert_id || alertId; + + if (!finalAlertId) { + console.error('Missing alert ID'); + this.onError?.('Missing alert ID for dismiss'); + return false; + } + + window.dispatchEvent( + new CustomEvent('alert:dismiss', { + detail: { + alert_id: finalAlertId, + dismissed_at: new Date().toISOString(), + }, + }) + ); + + return true; + } + + /** + * 15. MARK_READ - Mark alert as read + */ + private async handleMarkRead(action: SmartAction, alertId?: string): Promise { + const { alert_id } = action.metadata || {}; + const finalAlertId = alert_id || alertId; + + if (!finalAlertId) { + console.error('Missing alert ID'); + this.onError?.('Missing alert ID for mark read'); + return false; + } + + window.dispatchEvent( + new CustomEvent('alert:mark-read', { + detail: { + alert_id: finalAlertId, + read_at: new Date().toISOString(), + }, + }) + ); + + return true; + } +} + +// ============================================================ +// React Hooks +// ============================================================ + +/** + * Hook to get action handler with navigation and callbacks + */ +export function useSmartActionHandler(callbacks?: { + onSuccess?: (alertId?: string) => void; + onError?: (error: string) => void; +}) { + const navigate = useNavigate(); + return new SmartActionHandler(navigate, callbacks); +} + +/** + * Get action handler without navigation (for use outside React components) + */ +export function getSmartActionHandler() { + return new SmartActionHandler(); +} + +// ============================================================ +// Utility Functions +// ============================================================ + +/** + * Map smart action variant to Button component variant + * + * Smart actions use: primary, secondary, tertiary, danger + * Button component supports: primary, secondary, outline, ghost, danger, success, warning, gradient + */ +export function mapActionVariantToButton( + actionVariant?: string +): 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger' | 'success' | 'warning' | 'gradient' { + switch (actionVariant) { + case 'primary': + return 'primary'; + case 'secondary': + return 'secondary'; + case 'tertiary': + return 'outline'; // Map tertiary to outline for subtle actions + case 'danger': + return 'danger'; + default: + return 'outline'; // Default to outline for unknown variants + } +} + +/** + * Get button variant class name (for use with UI libraries) + */ +export function getActionButtonClass(variant: string): string { + const baseClasses = 'px-4 py-2 rounded-md font-medium transition-colors'; + + switch (variant) { + case 'primary': + return `${baseClasses} bg-primary text-white hover:bg-primary-dark`; + case 'secondary': + return `${baseClasses} bg-secondary text-secondary-foreground hover:bg-secondary-dark`; + case 'ghost': + return `${baseClasses} border border-border text-foreground hover:bg-accent`; + case 'danger': + return `${baseClasses} bg-error text-white hover:bg-error-dark`; + default: + return `${baseClasses} bg-accent text-accent-foreground hover:bg-accent-dark`; + } +} diff --git a/shared/utils/demo_dates.py b/shared/utils/demo_dates.py index 5b16c906..e5fd3d92 100644 --- a/shared/utils/demo_dates.py +++ b/shared/utils/demo_dates.py @@ -11,7 +11,7 @@ from typing import Optional # Base reference date for all demo seed data # All seed scripts should use this as the "logical seed date" # Updated to November 2025 to show recent orchestration runs -BASE_REFERENCE_DATE = datetime(2025, 11, 25, 12, 0, 0, tzinfo=timezone.utc) +BASE_REFERENCE_DATE = datetime(2025, 11, 27, 12, 0, 0, tzinfo=timezone.utc) def adjust_date_for_demo(