diff --git a/Tiltfile b/Tiltfile index 0dcabd37..67b53edd 100644 --- a/Tiltfile +++ b/Tiltfile @@ -48,14 +48,42 @@ def python_live_update(service_name, service_path): # ============================================================================= # FRONTEND (React + Vite) # ============================================================================= +# Multi-stage build with optimized memory settings for large Vite builds +# Set FRONTEND_DEBUG=true environment variable to build with development mode (no minification) +# for easier debugging of React errors + +# Check for FRONTEND_DEBUG environment variable (Starlark uses os.getenv) +frontend_debug_env = os.getenv('FRONTEND_DEBUG', 'false') +frontend_debug = frontend_debug_env.lower() == 'true' + +# Log the build mode +if frontend_debug: + print(""" + 🐛 FRONTEND DEBUG MODE ENABLED + Building frontend with NO minification for easier debugging. + Full React error messages will be displayed. + To disable: unset FRONTEND_DEBUG or set FRONTEND_DEBUG=false + """) +else: + print(""" + 📦 FRONTEND PRODUCTION MODE + Building frontend with minification for optimized performance. + To enable debug mode: export FRONTEND_DEBUG=true + """) + docker_build( 'bakery/dashboard', context='./frontend', - dockerfile='./frontend/Dockerfile.kubernetes', + dockerfile='./frontend/Dockerfile.kubernetes.debug' if frontend_debug else './frontend/Dockerfile.kubernetes', + # Remove target to build the full image, including the build stage live_update=[ sync('./frontend/src', '/app/src'), sync('./frontend/public', '/app/public'), ], + # Increase Node.js memory for build stage + build_args={ + 'NODE_OPTIONS': '--max-old-space-size=8192' + }, # Ignore test artifacts and reports ignore=[ 'playwright-report/**', @@ -157,7 +185,7 @@ build_python_service('production-service', 'production') build_python_service('procurement-service', 'procurement') # NEW: Sprint 3 build_python_service('orchestrator-service', 'orchestrator') # NEW: Sprint 2 build_python_service('ai-insights-service', 'ai_insights') # NEW: AI Insights Platform -build_python_service('alert-processor', 'alert_processor') +build_python_service('alert-processor', 'alert_processor') # Unified Alert Service with enrichment build_python_service('demo-session-service', 'demo_session') # ============================================================================= @@ -181,7 +209,7 @@ k8s_resource('production-db', resource_deps=['security-setup'], labels=['databas k8s_resource('procurement-db', resource_deps=['security-setup'], labels=['databases']) # NEW: Sprint 3 k8s_resource('orchestrator-db', resource_deps=['security-setup'], labels=['databases']) # NEW: Sprint 2 k8s_resource('ai-insights-db', resource_deps=['security-setup'], labels=['databases']) # NEW: AI Insights Platform -k8s_resource('alert-processor-db', resource_deps=['security-setup'], labels=['databases']) +k8s_resource('alert-processor-db', resource_deps=['security-setup'], labels=['databases']) # Unified Alert Service k8s_resource('demo-session-db', resource_deps=['security-setup'], labels=['databases']) k8s_resource('redis', resource_deps=['security-setup'], labels=['infrastructure']) @@ -373,6 +401,11 @@ k8s_resource('demo-seed-orchestration-runs', resource_deps=['orchestrator-migration', 'demo-seed-tenants'], labels=['demo-init']) +# Weight 28: Seed alerts (alert processor service) - after orchestration runs as alerts reference recent data +k8s_resource('demo-seed-alerts', + resource_deps=['alert-processor-migration', 'demo-seed-tenants'], + labels=['demo-init']) + k8s_resource('demo-seed-pos-configs', resource_deps=['demo-seed-tenants'], labels=['demo-init']) @@ -526,8 +559,8 @@ k8s_resource('frontend', # Update check interval - how often Tilt checks for file changes update_settings( - max_parallel_updates=3, - k8s_upsert_timeout_secs=60 + max_parallel_updates=2, # Reduce parallel updates to avoid resource exhaustion on local machines + k8s_upsert_timeout_secs=120 # Increase timeout for slower local builds ) # Watch settings - configure file watching behavior diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 00000000..b4d72adc --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,41 @@ +# Dependencies +node_modules + +# Production build output (should be recreated during Docker build) +dist + +# Testing +coverage +test-results +playwright-report + +# Logs +logs +*.log + +# Environment variables +.env* +!.env.example + +# IDE files +.vscode +.idea +*.swp +*.swo + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Build outputs +build +.nyc_output + +# Local configuration files +.git +.gitignore \ No newline at end of file diff --git a/frontend/Dockerfile.kubernetes b/frontend/Dockerfile.kubernetes index 7fb24905..f82888b2 100644 --- a/frontend/Dockerfile.kubernetes +++ b/frontend/Dockerfile.kubernetes @@ -9,15 +9,31 @@ WORKDIR /app # Copy package files COPY package*.json ./ -# Install dependencies including dev dependencies for building +# Install all dependencies for building RUN npm ci --verbose && \ npm cache clean --force -# Copy source code +# Copy source code (excluding unnecessary files like node_modules, dist, etc.) COPY . . -# Build the application for production -# This will use environment variables available at build time +# Create a default runtime config in the public directory if it doesn't exist to satisfy the reference in index.html +RUN if [ ! -f public/runtime-config.js ]; then \ + mkdir -p public && \ + echo "window.__RUNTIME_CONFIG__ = {};" > public/runtime-config.js; \ + fi + +# Set build-time environment variables to prevent hanging on undefined variables +ENV NODE_ENV=production +ENV CI=true +ENV VITE_API_URL=/api +ENV VITE_APP_TITLE="BakeWise" +ENV VITE_APP_VERSION="1.0.0" +ENV VITE_PILOT_MODE_ENABLED="false" +ENV VITE_PILOT_COUPON_CODE="PILOT2025" +ENV VITE_PILOT_TRIAL_MONTHS="3" +ENV VITE_STRIPE_PUBLISHABLE_KEY="pk_test_" +# Set Node.js memory limit for the build process +ENV NODE_OPTIONS="--max-old-space-size=4096" RUN npm run build # Stage 2: Production server with Nginx @@ -26,6 +42,9 @@ FROM nginx:1.25-alpine AS production # Install curl for health checks RUN apk add --no-cache curl +# Copy main nginx configuration that sets the PID file location +COPY nginx-main.conf /etc/nginx/nginx.conf + # Remove default nginx configuration RUN rm /etc/nginx/conf.d/default.conf @@ -49,18 +68,7 @@ RUN chown -R nginx:nginx /usr/share/nginx/html && \ # Create nginx PID directory and fix permissions RUN mkdir -p /var/run/nginx /var/lib/nginx/tmp && \ - chown -R nginx:nginx /var/run/nginx /var/lib/nginx - -# Custom nginx.conf for running as non-root -RUN echo 'pid /var/run/nginx/nginx.pid;' > /etc/nginx/nginx.conf && \ - echo 'events { worker_connections 1024; }' >> /etc/nginx/nginx.conf && \ - echo 'http {' >> /etc/nginx/nginx.conf && \ - echo ' include /etc/nginx/mime.types;' >> /etc/nginx/nginx.conf && \ - echo ' default_type application/octet-stream;' >> /etc/nginx/nginx.conf && \ - echo ' sendfile on;' >> /etc/nginx/nginx.conf && \ - echo ' keepalive_timeout 65;' >> /etc/nginx/nginx.conf && \ - echo ' include /etc/nginx/conf.d/*.conf;' >> /etc/nginx/nginx.conf && \ - echo '}' >> /etc/nginx/nginx.conf + chown -R nginx:nginx /var/run/nginx /var/lib/nginx /etc/nginx # Switch to non-root user USER nginx @@ -74,3 +82,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ # Start nginx CMD ["nginx", "-g", "daemon off;"] + diff --git a/frontend/Dockerfile.kubernetes.debug b/frontend/Dockerfile.kubernetes.debug new file mode 100644 index 00000000..728815d5 --- /dev/null +++ b/frontend/Dockerfile.kubernetes.debug @@ -0,0 +1,89 @@ +# Kubernetes-optimized DEBUG Dockerfile for Frontend +# Multi-stage build for DEVELOPMENT/DEBUG deployment +# This build DISABLES minification and provides full React error messages + +# Stage 1: Build the application in DEVELOPMENT MODE +FROM node:18-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install all dependencies for building +RUN npm ci --verbose && \ + npm cache clean --force + +# Copy source code (excluding unnecessary files like node_modules, dist, etc.) +COPY . . + +# Create a default runtime config in the public directory if it doesn't exist to satisfy the reference in index.html +RUN if [ ! -f public/runtime-config.js ]; then \ + mkdir -p public && \ + echo "window.__RUNTIME_CONFIG__ = {};" > public/runtime-config.js; \ + fi + +# DEBUG BUILD SETTINGS - NO MINIFICATION +# This will produce larger bundles but with full error messages +ENV NODE_ENV=development +ENV CI=true +ENV VITE_API_URL=/api +ENV VITE_APP_TITLE="BakeWise (Debug)" +ENV VITE_APP_VERSION="1.0.0-debug" +ENV VITE_PILOT_MODE_ENABLED="false" +ENV VITE_PILOT_COUPON_CODE="PILOT2025" +ENV VITE_PILOT_TRIAL_MONTHS="3" +ENV VITE_STRIPE_PUBLISHABLE_KEY="pk_test_" + +# Set Node.js memory limit for the build process +ENV NODE_OPTIONS="--max-old-space-size=4096" + +# Build in development mode (no minification, full source maps) +RUN npm run build -- --mode development + +# Stage 2: Production server with Nginx (same as production) +FROM nginx:1.25-alpine AS production + +# Install curl for health checks +RUN apk add --no-cache curl + +# Copy main nginx configuration that sets the PID file location +COPY nginx-main.conf /etc/nginx/nginx.conf + +# Remove default nginx configuration +RUN rm /etc/nginx/conf.d/default.conf + +# Copy custom nginx configuration +COPY nginx.conf /etc/nginx/conf.d/ + +# Copy built application from builder stage +COPY --from=builder /app/dist /usr/share/nginx/html + +# Copy and setup environment substitution script +COPY substitute-env.sh /docker-entrypoint.d/30-substitute-env.sh + +# Make the script executable +RUN chmod +x /docker-entrypoint.d/30-substitute-env.sh + +# Set proper permissions +RUN chown -R nginx:nginx /usr/share/nginx/html && \ + chown -R nginx:nginx /var/cache/nginx && \ + chown -R nginx:nginx /var/log/nginx && \ + chown -R nginx:nginx /etc/nginx/conf.d + +# Create nginx PID directory and fix permissions +RUN mkdir -p /var/run/nginx /var/lib/nginx/tmp && \ + chown -R nginx:nginx /var/run/nginx /var/lib/nginx /etc/nginx + +# Switch to non-root user +USER nginx + +# Expose port 3000 (to match current setup) +EXPOSE 3000 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ + CMD curl -f http://localhost:3000/health || exit 1 + +# Start nginx +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/nginx-main.conf b/frontend/nginx-main.conf new file mode 100644 index 00000000..0dc97974 --- /dev/null +++ b/frontend/nginx-main.conf @@ -0,0 +1,12 @@ +pid /var/run/nginx/nginx.pid; +worker_processes auto; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + include /etc/nginx/conf.d/*.conf; +} \ No newline at end of file diff --git a/frontend/src/api/hooks/newDashboard.ts b/frontend/src/api/hooks/newDashboard.ts index 0e433860..ae4d3ce2 100644 --- a/frontend/src/api/hooks/newDashboard.ts +++ b/frontend/src/api/hooks/newDashboard.ts @@ -20,11 +20,13 @@ import { apiClient } from '../client'; // ============================================================ export interface HealthChecklistItem { - icon: 'check' | 'warning' | 'alert'; + icon: 'check' | 'warning' | 'alert' | 'ai_handled'; text?: string; // Deprecated: Use textKey instead textKey?: string; // i18n key for translation textParams?: Record; // Parameters for i18n translation actionRequired: boolean; + status: 'good' | 'ai_handled' | 'needs_you'; // Tri-state status + actionPath?: string; // Optional path to navigate for action } export interface HeadlineData { @@ -40,6 +42,7 @@ export interface BakeryHealthStatus { checklistItems: HealthChecklistItem[]; criticalIssues: number; pendingActions: number; + aiPreventedIssues: number; // Count of issues AI prevented } export interface ReasoningInputs { @@ -127,6 +130,53 @@ export interface ActionQueue { importantCount: number; } +// New unified action queue with time-based grouping +export interface EnrichedAlert { + id: string; + alert_type: string; + type_class: string; + priority_level: string; + priority_score: number; + title: string; + message: string; + actions: Array<{ + type: string; + label: string; + variant: 'primary' | 'secondary' | 'ghost'; + metadata?: Record; + disabled?: boolean; + estimated_time_minutes?: number; + }>; + urgency_context?: { + deadline?: string; + time_until_consequence_hours?: number; + }; + alert_metadata?: { + escalation?: { + original_score: number; + boost_applied: number; + escalated_at: string; + reason: string; + }; + }; + business_impact?: { + financial_impact_eur?: number; + affected_orders?: number; + }; + ai_reasoning_summary?: string; + hidden_from_ui?: boolean; +} + +export interface UnifiedActionQueue { + urgent: EnrichedAlert[]; // <6h to deadline or CRITICAL + today: EnrichedAlert[]; // <24h to deadline + week: EnrichedAlert[]; // <7d to deadline or escalated + totalActions: number; + urgentCount: number; + todayCount: number; + weekCount: number; +} + export interface ProductionTimelineItem { id: string; batchNumber: string; @@ -234,7 +284,7 @@ export function useOrchestrationSummary(tenantId: string, runId?: string) { } /** - * Get action queue + * Get action queue (LEGACY - use useUnifiedActionQueue for new implementation) * * Prioritized list of what requires user attention right now. * This is the core JTBD dashboard feature. @@ -254,6 +304,31 @@ export function useActionQueue(tenantId: string) { }); } +/** + * Get unified action queue with time-based grouping + * + * Returns all action-needed alerts grouped by urgency: + * - URGENT: <6h to deadline or CRITICAL priority + * - TODAY: <24h to deadline + * - THIS WEEK: <7d to deadline or escalated (>48h pending) + * + * This is the NEW implementation for the redesigned Action Queue Card. + */ +export function useUnifiedActionQueue(tenantId: string) { + return useQuery({ + queryKey: ['unified-action-queue', tenantId], + queryFn: async () => { + return await apiClient.get( + `/tenants/${tenantId}/dashboard/unified-action-queue` + ); + }, + enabled: !!tenantId, + refetchInterval: 30000, // Refresh every 30 seconds (more frequent than legacy) + staleTime: 15000, + retry: 2, + }); +} + /** * Get production timeline * @@ -294,6 +369,31 @@ export function useInsights(tenantId: string) { }); } +/** + * Get execution progress - plan vs actual for today + * + * Shows how today's execution is progressing: + * - Production: batches completed/in-progress/pending + * - Deliveries: received/pending/overdue + * - Approvals: pending count + * + * This is the NEW implementation for the ExecutionProgressTracker component. + */ +export function useExecutionProgress(tenantId: string) { + return useQuery({ + queryKey: ['execution-progress', tenantId], + queryFn: async () => { + return await apiClient.get( + `/tenants/${tenantId}/dashboard/execution-progress` + ); + }, + enabled: !!tenantId, + refetchInterval: 60000, // Refresh every minute + staleTime: 30000, + retry: 2, + }); +} + // ============================================================ // Action Mutations // ============================================================ diff --git a/frontend/src/components/dashboard/ActionQueueCard.tsx b/frontend/src/components/dashboard/ActionQueueCard.tsx deleted file mode 100644 index 14f132fb..00000000 --- a/frontend/src/components/dashboard/ActionQueueCard.tsx +++ /dev/null @@ -1,665 +0,0 @@ -// ================================================================ -// frontend/src/components/dashboard/ActionQueueCard.tsx -// ================================================================ -/** - * Action Queue Card - What needs your attention right now - * - * Prioritized list of actions the user needs to take, with context - * about why each action is needed and what happens if they don't do it. - */ - -import React, { useState, useMemo } from 'react'; -import { - FileText, - AlertCircle, - CheckCircle2, - Eye, - Edit, - Clock, - Euro, - ChevronDown, - ChevronUp, - X, - Package, - Building2, - Calendar, - Truck, -} from 'lucide-react'; -import { ActionItem, ActionQueue } from '../../api/hooks/newDashboard'; -import { useReasoningFormatter } from '../../hooks/useReasoningTranslation'; -import { useTranslation } from 'react-i18next'; -import { usePurchaseOrder } from '../../api/hooks/purchase-orders'; - -interface ActionQueueCardProps { - actionQueue: ActionQueue; - loading?: boolean; - onApprove?: (actionId: string) => void; - onReject?: (actionId: string, reason: string) => void; - onViewDetails?: (actionId: string) => void; - onModify?: (actionId: string) => void; - tenantId?: string; -} - -const urgencyConfig = { - critical: { - bgColor: 'var(--color-error-50)', - borderColor: 'var(--color-error-300)', - badgeBgColor: 'var(--color-error-100)', - badgeTextColor: 'var(--color-error-800)', - icon: AlertCircle, - iconColor: 'var(--color-error-700)', - }, - important: { - bgColor: 'var(--color-warning-50)', - borderColor: 'var(--color-warning-300)', - badgeBgColor: 'var(--color-warning-100)', - badgeTextColor: 'var(--color-warning-900)', - icon: AlertCircle, - iconColor: 'var(--color-warning-700)', - }, - normal: { - bgColor: 'var(--color-info-50)', - borderColor: 'var(--color-info-300)', - badgeBgColor: 'var(--color-info-100)', - badgeTextColor: 'var(--color-info-800)', - icon: FileText, - iconColor: 'var(--color-info-700)', - }, -}; - -/** - * Helper function to translate keys with proper namespace handling - * Maps backend key formats to correct i18next namespaces - */ -function translateKey( - key: string, - params: Record, - tDashboard: any, - tReasoning: any -): string { - // Preprocess parameters - join arrays into strings - const processedParams = { ...params }; - - // Convert product_names array to product_names_joined string - if ('product_names' in processedParams && Array.isArray(processedParams.product_names)) { - processedParams.product_names_joined = processedParams.product_names.join(', '); - } - - // Convert affected_products array to affected_products_joined string - if ('affected_products' in processedParams && Array.isArray(processedParams.affected_products)) { - processedParams.affected_products_joined = processedParams.affected_products.join(', '); - } - - // Determine namespace based on key prefix - if (key.startsWith('reasoning.')) { - // Remove 'reasoning.' prefix and use reasoning namespace - const translationKey = key.substring('reasoning.'.length); - // Use i18next-icu for interpolation with {variable} syntax - return String(tReasoning(translationKey, processedParams)); - } else if (key.startsWith('action_queue.') || key.startsWith('dashboard.') || key.startsWith('health.')) { - // Use dashboard namespace - return String(tDashboard(key, processedParams)); - } - - // Default to dashboard - return String(tDashboard(key, processedParams)); -} - -function ActionItemCard({ - action, - onApprove, - onReject, - onViewDetails, - onModify, - tenantId, -}: { - action: ActionItem; - onApprove?: (id: string) => void; - onReject?: (id: string, reason: string) => void; - onViewDetails?: (id: string) => void; - onModify?: (id: string) => void; - tenantId?: string; -}) { - const [expanded, setExpanded] = useState(false); - const [showDetails, setShowDetails] = useState(false); - const [showRejectModal, setShowRejectModal] = useState(false); - const [rejectionReason, setRejectionReason] = useState(''); - const config = urgencyConfig[action.urgency as keyof typeof urgencyConfig] || urgencyConfig.normal; - const UrgencyIcon = config.icon; - const { formatPOAction } = useReasoningFormatter(); - const { t: tReasoning } = useTranslation('reasoning'); - const { t: tDashboard } = useTranslation('dashboard'); - - // Fetch PO details if this is a PO action and details are expanded - const { data: poDetail } = usePurchaseOrder( - tenantId || '', - action.id, - { enabled: !!tenantId && showDetails && action.type === 'po_approval' } - ); - - // Translate i18n fields (or fallback to deprecated text fields or reasoning_data for alerts) - const reasoning = useMemo(() => { - if (action.reasoning_i18n) { - return translateKey(action.reasoning_i18n.key, action.reasoning_i18n.params, tDashboard, tReasoning); - } - if (action.reasoning_data) { - const formatted = formatPOAction(action.reasoning_data); - return formatted.reasoning; - } - return action.reasoning || ''; - }, [action.reasoning_i18n, action.reasoning_data, action.reasoning, tDashboard, tReasoning, formatPOAction]); - - const consequence = useMemo(() => { - if (action.consequence_i18n) { - return translateKey(action.consequence_i18n.key, action.consequence_i18n.params, tDashboard, tReasoning); - } - if (action.reasoning_data) { - const formatted = formatPOAction(action.reasoning_data); - return formatted.consequence; - } - return ''; - }, [action.consequence_i18n, action.reasoning_data, tDashboard, tReasoning, formatPOAction]); - - return ( -
- {/* Header */} -
- -
-
-

- {action.title_i18n ? translateKey(action.title_i18n.key, action.title_i18n.params, tDashboard, tReasoning) : (action.title || 'Action Required')} -

- - {action.urgency || 'normal'} - -
-

- {action.subtitle_i18n ? translateKey(action.subtitle_i18n.key, action.subtitle_i18n.params, tDashboard, tReasoning) : (action.subtitle || '')} -

-
-
- - {/* Amount (for POs) */} - {action.amount && ( -
- - - {action.amount.toFixed(2)} {action.currency} - -
- )} - - {/* Reasoning (always visible) */} -
-

- {tReasoning('jtbd.action_queue.why_needed')} -

-

{reasoning}

-
- - {/* Consequence (expandable) */} - {consequence && ( - <> - - - {expanded && ( -
-

{consequence}

-
- )} - - )} - - {/* Inline PO Details (expandable) */} - {action.type === 'po_approval' && ( - <> - - - {showDetails && poDetail && ( -
- {/* Supplier Info */} -
- -
-

- {poDetail.supplier?.name || 'Supplier'} -

- {poDetail.supplier?.contact_person && ( -

- Contact: {poDetail.supplier.contact_person} -

- )} - {poDetail.supplier?.email && ( -

- {poDetail.supplier.email} -

- )} -
-
- - {/* Delivery Date & Tracking */} - {poDetail.required_delivery_date && ( -
-
- -
-

- Required Delivery -

-

- {new Date(poDetail.required_delivery_date).toLocaleDateString()} -

-
-
- - {/* Estimated Delivery Date (shown after approval) */} - {poDetail.estimated_delivery_date && ( -
- -
-

- Expected Arrival -

-

- {new Date(poDetail.estimated_delivery_date).toLocaleDateString()} -

-
- {(() => { - const now = new Date(); - const estimatedDate = new Date(poDetail.estimated_delivery_date); - const daysUntil = Math.ceil((estimatedDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); - - let statusColor = 'var(--color-success-600)'; - let statusText = 'On Track'; - - if (daysUntil < 0) { - statusColor = 'var(--color-error-600)'; - statusText = `${Math.abs(daysUntil)}d Overdue`; - } else if (daysUntil === 0) { - statusColor = 'var(--color-warning-600)'; - statusText = 'Due Today'; - } else if (daysUntil <= 2) { - statusColor = 'var(--color-warning-600)'; - statusText = `${daysUntil}d Left`; - } else { - statusText = `${daysUntil}d Left`; - } - - return ( - - {statusText} - - ); - })()} -
- )} -
- )} - - {/* Line Items */} - {poDetail.items && poDetail.items.length > 0 && ( -
-

- Order Items ({poDetail.items.length}) -

-
- {poDetail.items.map((item, idx) => ( -
-
-

- {item.product_name || item.product_code || 'Product'} -

-

- {item.ordered_quantity} {item.unit_of_measure} × €{parseFloat(item.unit_price).toFixed(2)} -

-
-

- €{parseFloat(item.line_total).toFixed(2)} -

-
- ))} -
-
- )} - - {/* Total Amount */} -
-

Total Amount

-

- €{parseFloat(poDetail.total_amount).toFixed(2)} -

-
-
- )} - - )} - - {/* Time Estimate */} -
- - - {tReasoning('jtbd.action_queue.estimated_time')}: {action.estimatedTimeMinutes || 5} min - -
- - {/* Rejection Modal */} - {showRejectModal && ( -
setShowRejectModal(false)} - > -
e.stopPropagation()} - > -
-

- Reject Purchase Order -

- -
- -

- Please provide a reason for rejecting this purchase order: -

- -