fix(dashboard): Fix production plans and reasoning modal issues

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 <noreply@anthropic.com>
This commit is contained in:
Urtzi Alfaro
2025-11-27 11:02:59 +01:00
parent ddf841bd70
commit b545d05d23
2 changed files with 679 additions and 1 deletions

View File

@@ -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<string, any>;
disabled?: boolean;
disabled_reason?: string;
estimated_time_minutes?: number;
consequence?: string;
}
// ============================================================
// Smart Action Handler Class
// ============================================================
export class SmartActionHandler {
private navigate: ReturnType<typeof useNavigate> | null = null;
private onSuccess?: (alertId?: string) => void;
private onError?: (error: string) => void;
constructor(
navigate?: ReturnType<typeof useNavigate>,
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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
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`;
}
}