From 8d3017248397bcd1e4fc2507138ac34a07d8441f Mon Sep 17 00:00:00 2001 From: Urtzi Alfaro Date: Tue, 21 Oct 2025 19:50:07 +0200 Subject: [PATCH] Improve the frontend --- Tiltfile | 15 + frontend/src/api/hooks/dashboard.ts | 246 ++ frontend/src/api/hooks/purchase-orders.ts | 259 ++ frontend/src/api/services/alert_analytics.ts | 125 + frontend/src/api/services/orders.ts | 6 +- frontend/src/api/services/production.ts | 2 +- frontend/src/api/services/purchase_orders.ts | 239 ++ frontend/src/api/services/suppliers.ts | 13 + .../domain/dashboard/AlertBulkActions.tsx | 126 + .../components/domain/dashboard/AlertCard.tsx | 446 +++ .../domain/dashboard/AlertContextActions.tsx | 49 + .../domain/dashboard/AlertFilters.tsx | 306 +++ .../domain/dashboard/AlertGroupHeader.tsx | 84 + .../domain/dashboard/AlertSnoozeMenu.tsx | 118 + .../domain/dashboard/AlertTrends.tsx | 179 ++ .../domain/dashboard/PendingPOApprovals.tsx | 425 +++ .../dashboard/ProcurementPlansToday.tsx | 297 -- .../domain/dashboard/ProductionPlansToday.tsx | 665 ----- .../dashboard/PurchaseOrdersTracking.tsx | 210 -- .../domain/dashboard/RealTimeAlerts.tsx | 700 +++-- .../domain/dashboard/TodayProduction.tsx | 413 +++ .../src/components/domain/dashboard/index.ts | 6 +- .../domain/pos/CreatePOSConfigModal.tsx | 325 +++ .../src/components/domain/pos/POSCart.tsx | 177 ++ .../src/components/domain/pos/POSPayment.tsx | 256 ++ .../components/domain/pos/POSProductCard.tsx | 154 ++ frontend/src/components/domain/pos/index.ts | 4 + .../procurement/CreatePurchaseOrderModal.tsx | 107 +- .../src/components/layout/Header/Header.tsx | 12 +- .../src/components/ui/AddModal/AddModal.tsx | 33 +- frontend/src/components/ui/Badge/Badge.tsx | 92 +- .../components/ui/StatusCard/StatusCard.tsx | 40 +- frontend/src/hooks/useAlertActions.ts | 71 + frontend/src/hooks/useAlertAnalytics.ts | 181 ++ frontend/src/hooks/useAlertFilters.ts | 112 + frontend/src/hooks/useAlertGrouping.ts | 102 + frontend/src/hooks/useKeyboardNavigation.ts | 153 ++ frontend/src/hooks/useNotifications.ts | 232 +- frontend/src/locales/en/dashboard.json | 21 + frontend/src/locales/en/landing.json | 15 +- frontend/src/locales/en/procurement.json | 124 + frontend/src/locales/es/dashboard.json | 64 +- frontend/src/locales/es/landing.json | 15 +- frontend/src/locales/es/procurement.json | 124 + frontend/src/locales/eu/dashboard.json | 21 + frontend/src/locales/eu/landing.json | 15 +- frontend/src/locales/eu/procurement.json | 124 + frontend/src/pages/app/DashboardPage.tsx | 293 +- .../src/pages/app/operations/pos/POSPage.tsx | 948 ++----- .../procurement/ProcurementPage.tsx | 2441 ++++++----------- .../procurement/ProcurementPage.tsx.backup | 2302 ++++++++++++++++ .../operations/production/ProductionPage.tsx | 55 +- .../src/pages/app/settings/team/TeamPage.tsx | 82 +- frontend/src/pages/public/LandingPage.tsx | 137 +- frontend/src/utils/alertHelpers.ts | 601 ++++ frontend/src/utils/numberFormatting.ts | 306 +++ gateway/app/main.py | 3 +- gateway/app/routes/tenant.py | 30 +- .../alert-processor/alert-processor-api.yaml | 112 + infrastructure/kubernetes/base/configmap.yaml | 5 + .../base/jobs/demo-seed-pos-configs-job.yaml | 58 + .../jobs/demo-seed-purchase-orders-job.yaml | 55 + .../kubernetes/base/kustomization.yaml | 3 + .../kubernetes/overlays/dev/dev-ingress.yaml | 2 + services/alert_processor/app/api/__init__.py | 7 + services/alert_processor/app/api/analytics.py | 238 ++ services/alert_processor/app/api_server.py | 84 + services/alert_processor/app/main.py | 32 +- services/alert_processor/app/models/alerts.py | 46 +- .../app/repositories/__init__.py | 7 + .../app/repositories/analytics_repository.py | 382 +++ .../20251019_1430_add_alert_interactions.py | 51 + .../app/services/clone_orchestrator.py | 10 +- .../demo_session/app/services/data_cloner.py | 2 +- services/inventory/app/api/internal_demo.py | 26 +- .../app/services/dashboard_service.py | 3 - .../app/services/food_safety_service.py | 32 +- .../app/services/inventory_service.py | 18 +- services/orders/app/api/orders.py | 4 +- services/orders/app/models/enums.py | 1 + .../app/services/approval_rules_service.py | 200 ++ .../procurement_notification_service.py | 257 ++ .../services/procurement_scheduler_service.py | 82 +- .../app/services/procurement_service.py | 161 +- services/pos/app/api/internal_demo.py | 226 ++ services/pos/app/main.py | 2 + .../pos/scripts/demo/seed_demo_pos_configs.py | 301 ++ services/suppliers/app/api/internal_demo.py | 44 +- services/suppliers/app/api/purchase_orders.py | 47 +- services/suppliers/app/api/suppliers.py | 54 +- services/suppliers/app/models/suppliers.py | 12 +- .../repositories/purchase_order_repository.py | 479 ++-- .../purchase_order_repository.py.bak | 376 +++ .../supplier_performance_repository.py | 289 ++ .../app/services/purchase_order_service.py | 4 +- ...0251020_1200_add_supplier_trust_metrics.py | 84 + .../scripts/demo/seed_demo_purchase_orders.py | 463 ++++ .../scripts/demo/seed_demo_suppliers.py | 116 +- .../training/app/api/training_operations.py | 9 +- services/training/app/ml/trainer.py | 4 +- .../training/app/utils/time_estimation.py | 28 +- shared/clients/suppliers_client.py | 78 +- shared/config/base.py | 32 +- shared/service_base.py | 7 +- shared/utils/alert_generator.py | 95 +- 105 files changed, 14699 insertions(+), 4630 deletions(-) create mode 100644 frontend/src/api/hooks/dashboard.ts create mode 100644 frontend/src/api/hooks/purchase-orders.ts create mode 100644 frontend/src/api/services/alert_analytics.ts create mode 100644 frontend/src/api/services/purchase_orders.ts create mode 100644 frontend/src/components/domain/dashboard/AlertBulkActions.tsx create mode 100644 frontend/src/components/domain/dashboard/AlertCard.tsx create mode 100644 frontend/src/components/domain/dashboard/AlertContextActions.tsx create mode 100644 frontend/src/components/domain/dashboard/AlertFilters.tsx create mode 100644 frontend/src/components/domain/dashboard/AlertGroupHeader.tsx create mode 100644 frontend/src/components/domain/dashboard/AlertSnoozeMenu.tsx create mode 100644 frontend/src/components/domain/dashboard/AlertTrends.tsx create mode 100644 frontend/src/components/domain/dashboard/PendingPOApprovals.tsx delete mode 100644 frontend/src/components/domain/dashboard/ProcurementPlansToday.tsx delete mode 100644 frontend/src/components/domain/dashboard/ProductionPlansToday.tsx delete mode 100644 frontend/src/components/domain/dashboard/PurchaseOrdersTracking.tsx create mode 100644 frontend/src/components/domain/dashboard/TodayProduction.tsx create mode 100644 frontend/src/components/domain/pos/CreatePOSConfigModal.tsx create mode 100644 frontend/src/components/domain/pos/POSCart.tsx create mode 100644 frontend/src/components/domain/pos/POSPayment.tsx create mode 100644 frontend/src/components/domain/pos/POSProductCard.tsx create mode 100644 frontend/src/components/domain/pos/index.ts create mode 100644 frontend/src/hooks/useAlertActions.ts create mode 100644 frontend/src/hooks/useAlertAnalytics.ts create mode 100644 frontend/src/hooks/useAlertFilters.ts create mode 100644 frontend/src/hooks/useAlertGrouping.ts create mode 100644 frontend/src/hooks/useKeyboardNavigation.ts create mode 100644 frontend/src/locales/en/procurement.json create mode 100644 frontend/src/locales/es/procurement.json create mode 100644 frontend/src/locales/eu/procurement.json create mode 100644 frontend/src/pages/app/operations/procurement/ProcurementPage.tsx.backup create mode 100644 frontend/src/utils/alertHelpers.ts create mode 100644 frontend/src/utils/numberFormatting.ts create mode 100644 infrastructure/kubernetes/base/components/alert-processor/alert-processor-api.yaml create mode 100644 infrastructure/kubernetes/base/jobs/demo-seed-pos-configs-job.yaml create mode 100644 infrastructure/kubernetes/base/jobs/demo-seed-purchase-orders-job.yaml create mode 100644 services/alert_processor/app/api/__init__.py create mode 100644 services/alert_processor/app/api/analytics.py create mode 100644 services/alert_processor/app/api_server.py create mode 100644 services/alert_processor/app/repositories/__init__.py create mode 100644 services/alert_processor/app/repositories/analytics_repository.py create mode 100644 services/alert_processor/migrations/versions/20251019_1430_add_alert_interactions.py create mode 100644 services/orders/app/services/approval_rules_service.py create mode 100644 services/orders/app/services/procurement_notification_service.py create mode 100644 services/pos/app/api/internal_demo.py create mode 100644 services/pos/scripts/demo/seed_demo_pos_configs.py create mode 100644 services/suppliers/app/repositories/purchase_order_repository.py.bak create mode 100644 services/suppliers/app/repositories/supplier_performance_repository.py create mode 100644 services/suppliers/migrations/versions/20251020_1200_add_supplier_trust_metrics.py create mode 100644 services/suppliers/scripts/demo/seed_demo_purchase_orders.py diff --git a/Tiltfile b/Tiltfile index 532f4f0c..96e724b3 100644 --- a/Tiltfile +++ b/Tiltfile @@ -194,6 +194,7 @@ k8s_resource('alert-processor-db', labels=['databases']) # Weight 15: demo-seed-inventory → Creates ingredients & finished products (depends on tenants) # Weight 15: demo-seed-recipes → Creates recipes using ingredient IDs (depends on inventory) # Weight 15: demo-seed-suppliers → Creates suppliers with price lists for ingredients (depends on inventory) +# Weight 21: demo-seed-purchase-orders → Creates demo POs in various states (depends on suppliers) # Weight 15: demo-seed-sales → Creates historical sales data using finished product IDs (depends on inventory) # Weight 15: demo-seed-ai-models → Creates fake AI model entries (depends on inventory) # Weight 20: demo-seed-stock → Creates stock batches with expiration dates (depends on inventory) @@ -245,6 +246,11 @@ k8s_resource('demo-seed-suppliers', resource_deps=['suppliers-migration', 'demo-seed-inventory'], labels=['demo-init']) +# Weight 21: Seed purchase orders (uses suppliers and demonstrates auto-approval workflow) +k8s_resource('demo-seed-purchase-orders', + resource_deps=['suppliers-migration', 'demo-seed-suppliers'], + labels=['demo-init']) + # Weight 15: Seed sales (uses finished product IDs from inventory) k8s_resource('demo-seed-sales', resource_deps=['sales-migration', 'demo-seed-inventory'], @@ -290,6 +296,11 @@ k8s_resource('demo-seed-procurement', resource_deps=['orders-migration', 'demo-seed-tenants'], labels=['demo-init']) +# Weight 35: Seed POS configurations (pos service) +k8s_resource('demo-seed-pos-configs', + resource_deps=['pos-migration', 'demo-seed-tenants'], + labels=['demo-init']) + # Weight 40: Seed demand forecasts (forecasting service) k8s_resource('demo-seed-forecasts', resource_deps=['forecasting-migration', 'demo-seed-tenants'], @@ -356,6 +367,10 @@ k8s_resource('alert-processor-service', resource_deps=['alert-processor-migration', 'redis', 'rabbitmq'], labels=['services']) +k8s_resource('alert-processor-api', + resource_deps=['alert-processor-migration', 'redis'], + labels=['services']) + k8s_resource('demo-session-service', resource_deps=['demo-session-migration', 'redis'], labels=['services']) diff --git a/frontend/src/api/hooks/dashboard.ts b/frontend/src/api/hooks/dashboard.ts new file mode 100644 index 00000000..f83bd013 --- /dev/null +++ b/frontend/src/api/hooks/dashboard.ts @@ -0,0 +1,246 @@ +/** + * Dashboard React Query hooks + * Aggregates data from multiple services for dashboard metrics + */ + +import { useQuery, UseQueryOptions } from '@tanstack/react-query'; +import { useSalesAnalytics } from './sales'; +import { useOrdersDashboard } from './orders'; +import { inventoryService } from '../services/inventory'; +import { getAlertAnalytics } from '../services/alert_analytics'; +import { ApiError } from '../client/apiClient'; +import type { InventoryDashboardSummary } from '../types/dashboard'; +import type { AlertAnalytics } from '../services/alert_analytics'; +import type { SalesAnalytics } from '../types/sales'; +import type { OrdersDashboardSummary } from '../types/orders'; + +export interface DashboardStats { + // Alert metrics + activeAlerts: number; + criticalAlerts: number; + + // Order metrics + pendingOrders: number; + ordersToday: number; + ordersTrend: number; // percentage change + + // Sales metrics + salesToday: number; + salesTrend: number; // percentage change + salesCurrency: string; + + // Inventory metrics + criticalStock: number; + lowStockCount: number; + outOfStockCount: number; + expiringSoon: number; + + // Production metrics + productsSoldToday: number; + productsSoldTrend: number; + + // Data freshness + lastUpdated: string; +} + +interface AggregatedDashboardData { + alerts?: AlertAnalytics; + orders?: OrdersDashboardSummary; + sales?: SalesAnalytics; + inventory?: InventoryDashboardSummary; +} + +// Query Keys +export const dashboardKeys = { + all: ['dashboard'] as const, + stats: (tenantId: string) => [...dashboardKeys.all, 'stats', tenantId] as const, + inventory: (tenantId: string) => [...dashboardKeys.all, 'inventory', tenantId] as const, +} as const; + +/** + * Fetch inventory dashboard summary + */ +export const useInventoryDashboard = ( + tenantId: string, + options?: Omit, 'queryKey' | 'queryFn'> +) => { + return useQuery({ + queryKey: dashboardKeys.inventory(tenantId), + queryFn: () => inventoryService.getDashboardSummary(tenantId), + enabled: !!tenantId, + staleTime: 30 * 1000, // 30 seconds + ...options, + }); +}; + +/** + * Fetch alert analytics + */ +export const useAlertAnalytics = ( + tenantId: string, + days: number = 7, + options?: Omit, 'queryKey' | 'queryFn'> +) => { + return useQuery({ + queryKey: ['alerts', 'analytics', tenantId, days], + queryFn: () => getAlertAnalytics(tenantId, days), + enabled: !!tenantId, + staleTime: 30 * 1000, // 30 seconds + ...options, + }); +}; + +/** + * Calculate percentage change between two values + */ +function calculateTrend(current: number, previous: number): number { + if (previous === 0) return current > 0 ? 100 : 0; + return Math.round(((current - previous) / previous) * 100); +} + +/** + * Calculate today's sales from sales records + */ +function calculateTodaySales(salesData?: SalesAnalytics): { amount: number; trend: number; productsSold: number; productsTrend: number } { + if (!salesData) { + return { amount: 0, trend: 0, productsSold: 0, productsTrend: 0 }; + } + + // Sales data should have today's revenue and comparison + const todayRevenue = salesData.total_revenue || 0; + const previousRevenue = salesData.previous_period_revenue || 0; + const todayUnits = salesData.total_units_sold || 0; + const previousUnits = salesData.previous_period_units_sold || 0; + + return { + amount: todayRevenue, + trend: calculateTrend(todayRevenue, previousRevenue), + productsSold: todayUnits, + productsTrend: calculateTrend(todayUnits, previousUnits), + }; +} + +/** + * Calculate orders metrics + */ +function calculateOrdersMetrics(ordersData?: OrdersDashboardSummary): { pending: number; today: number; trend: number } { + if (!ordersData) { + return { pending: 0, today: 0, trend: 0 }; + } + + const pendingCount = ordersData.pending_orders_count || 0; + const todayCount = ordersData.orders_today_count || 0; + const yesterdayCount = ordersData.orders_yesterday_count || 0; + + return { + pending: pendingCount, + today: todayCount, + trend: calculateTrend(todayCount, yesterdayCount), + }; +} + +/** + * Aggregate dashboard data from all services + */ +function aggregateDashboardStats(data: AggregatedDashboardData): DashboardStats { + const sales = calculateTodaySales(data.sales); + const orders = calculateOrdersMetrics(data.orders); + + const criticalStockCount = + (data.inventory?.low_stock_count || 0) + + (data.inventory?.out_of_stock_count || 0); + + return { + // Alerts + activeAlerts: data.alerts?.activeAlerts || 0, + criticalAlerts: data.alerts?.totalAlerts || 0, + + // Orders + pendingOrders: orders.pending, + ordersToday: orders.today, + ordersTrend: orders.trend, + + // Sales + salesToday: sales.amount, + salesTrend: sales.trend, + salesCurrency: '€', // Default to EUR for bakery + + // Inventory + criticalStock: criticalStockCount, + lowStockCount: data.inventory?.low_stock_count || 0, + outOfStockCount: data.inventory?.out_of_stock_count || 0, + expiringSoon: data.inventory?.expiring_soon_count || 0, + + // Products + productsSoldToday: sales.productsSold, + productsSoldTrend: sales.productsTrend, + + // Metadata + lastUpdated: new Date().toISOString(), + }; +} + +/** + * Main hook to fetch aggregated dashboard statistics + * Combines data from multiple services into a single cohesive dashboard view + */ +export const useDashboardStats = ( + tenantId: string, + options?: Omit, 'queryKey' | 'queryFn'> +) => { + // Get today's date range for sales + const today = new Date(); + const todayStr = today.toISOString().split('T')[0]; + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + const yesterdayStr = yesterday.toISOString().split('T')[0]; + + return useQuery({ + queryKey: dashboardKeys.stats(tenantId), + queryFn: async () => { + // Fetch all data in parallel + const [alertsData, ordersData, salesData, inventoryData] = await Promise.allSettled([ + getAlertAnalytics(tenantId, 7), + // Note: OrdersService methods are static + import('../services/orders').then(({ OrdersService }) => + OrdersService.getDashboardSummary(tenantId) + ), + // Fetch today's sales with comparison to yesterday + import('../services/sales').then(({ salesService }) => + salesService.getSalesAnalytics(tenantId, todayStr, todayStr) + ), + inventoryService.getDashboardSummary(tenantId), + ]); + + // Extract data or use undefined for failed requests + const aggregatedData: AggregatedDashboardData = { + alerts: alertsData.status === 'fulfilled' ? alertsData.value : undefined, + orders: ordersData.status === 'fulfilled' ? ordersData.value : undefined, + sales: salesData.status === 'fulfilled' ? salesData.value : undefined, + inventory: inventoryData.status === 'fulfilled' ? inventoryData.value : undefined, + }; + + // Log any failures for debugging + if (alertsData.status === 'rejected') { + console.warn('[Dashboard] Failed to fetch alerts:', alertsData.reason); + } + if (ordersData.status === 'rejected') { + console.warn('[Dashboard] Failed to fetch orders:', ordersData.reason); + } + if (salesData.status === 'rejected') { + console.warn('[Dashboard] Failed to fetch sales:', salesData.reason); + } + if (inventoryData.status === 'rejected') { + console.warn('[Dashboard] Failed to fetch inventory:', inventoryData.reason); + } + + return aggregateDashboardStats(aggregatedData); + }, + enabled: !!tenantId, + staleTime: 30 * 1000, // 30 seconds + refetchInterval: 60 * 1000, // Auto-refresh every minute + retry: 2, // Retry failed requests twice + retryDelay: 1000, // Wait 1s between retries + ...options, + }); +}; diff --git a/frontend/src/api/hooks/purchase-orders.ts b/frontend/src/api/hooks/purchase-orders.ts new file mode 100644 index 00000000..85b97c93 --- /dev/null +++ b/frontend/src/api/hooks/purchase-orders.ts @@ -0,0 +1,259 @@ +/** + * Purchase Orders React Query hooks + * Handles data fetching and mutations for purchase orders + */ + +import { useQuery, useMutation, useQueryClient, UseQueryOptions, UseMutationOptions } from '@tanstack/react-query'; +import { ApiError } from '../client/apiClient'; +import type { + PurchaseOrderSummary, + PurchaseOrderDetail, + PurchaseOrderSearchParams, + PurchaseOrderUpdateData, + PurchaseOrderStatus +} from '../services/purchase_orders'; +import { + listPurchaseOrders, + getPurchaseOrder, + getPendingApprovalPurchaseOrders, + getPurchaseOrdersByStatus, + updatePurchaseOrder, + approvePurchaseOrder, + rejectPurchaseOrder, + bulkApprovePurchaseOrders, + deletePurchaseOrder +} from '../services/purchase_orders'; + +// Query Keys +export const purchaseOrderKeys = { + all: ['purchase-orders'] as const, + lists: () => [...purchaseOrderKeys.all, 'list'] as const, + list: (tenantId: string, params?: PurchaseOrderSearchParams) => + [...purchaseOrderKeys.lists(), tenantId, params] as const, + details: () => [...purchaseOrderKeys.all, 'detail'] as const, + detail: (tenantId: string, poId: string) => + [...purchaseOrderKeys.details(), tenantId, poId] as const, + byStatus: (tenantId: string, status: PurchaseOrderStatus) => + [...purchaseOrderKeys.lists(), tenantId, 'status', status] as const, + pendingApproval: (tenantId: string) => + [...purchaseOrderKeys.lists(), tenantId, 'pending-approval'] as const, +} as const; + +/** + * Hook to list purchase orders with optional filters + */ +export const usePurchaseOrders = ( + tenantId: string, + params?: PurchaseOrderSearchParams, + options?: Omit, 'queryKey' | 'queryFn'> +) => { + return useQuery({ + queryKey: purchaseOrderKeys.list(tenantId, params), + queryFn: () => listPurchaseOrders(tenantId, params), + enabled: !!tenantId, + staleTime: 30 * 1000, // 30 seconds + ...options, + }); +}; + +/** + * Hook to get pending approval purchase orders + */ +export const usePendingApprovalPurchaseOrders = ( + tenantId: string, + limit: number = 50, + options?: Omit, 'queryKey' | 'queryFn'> +) => { + return useQuery({ + queryKey: purchaseOrderKeys.pendingApproval(tenantId), + queryFn: () => getPendingApprovalPurchaseOrders(tenantId, limit), + enabled: !!tenantId, + staleTime: 15 * 1000, // 15 seconds - more frequent for pending approvals + refetchInterval: 30 * 1000, // Auto-refetch every 30 seconds + ...options, + }); +}; + +/** + * Hook to get purchase orders by status + */ +export const usePurchaseOrdersByStatus = ( + tenantId: string, + status: PurchaseOrderStatus, + limit: number = 50, + options?: Omit, 'queryKey' | 'queryFn'> +) => { + return useQuery({ + queryKey: purchaseOrderKeys.byStatus(tenantId, status), + queryFn: () => getPurchaseOrdersByStatus(tenantId, status, limit), + enabled: !!tenantId, + staleTime: 30 * 1000, // 30 seconds + ...options, + }); +}; + +/** + * Hook to get a single purchase order detail + */ +export const usePurchaseOrder = ( + tenantId: string, + poId: string, + options?: Omit, 'queryKey' | 'queryFn'> +) => { + return useQuery({ + queryKey: purchaseOrderKeys.detail(tenantId, poId), + queryFn: () => getPurchaseOrder(tenantId, poId), + enabled: !!tenantId && !!poId, + staleTime: 30 * 1000, // 30 seconds + ...options, + }); +}; + +/** + * Hook to update a purchase order + */ +export const useUpdatePurchaseOrder = ( + options?: UseMutationOptions< + PurchaseOrderDetail, + ApiError, + { tenantId: string; poId: string; data: PurchaseOrderUpdateData } + > +) => { + const queryClient = useQueryClient(); + + return useMutation< + PurchaseOrderDetail, + ApiError, + { tenantId: string; poId: string; data: PurchaseOrderUpdateData } + >({ + mutationFn: ({ tenantId, poId, data }) => updatePurchaseOrder(tenantId, poId, data), + onSuccess: (data, variables) => { + // Invalidate and refetch related queries + queryClient.invalidateQueries({ queryKey: purchaseOrderKeys.lists() }); + queryClient.invalidateQueries({ + queryKey: purchaseOrderKeys.detail(variables.tenantId, variables.poId) + }); + }, + ...options, + }); +}; + +/** + * Hook to approve a purchase order + */ +export const useApprovePurchaseOrder = ( + options?: UseMutationOptions< + PurchaseOrderDetail, + ApiError, + { tenantId: string; poId: string; notes?: string } + > +) => { + const queryClient = useQueryClient(); + + return useMutation< + PurchaseOrderDetail, + ApiError, + { tenantId: string; poId: string; notes?: string } + >({ + mutationFn: ({ tenantId, poId, notes }) => approvePurchaseOrder(tenantId, poId, notes), + onSuccess: (data, variables) => { + // Invalidate pending approvals list + queryClient.invalidateQueries({ + queryKey: purchaseOrderKeys.pendingApproval(variables.tenantId) + }); + // Invalidate all lists + queryClient.invalidateQueries({ queryKey: purchaseOrderKeys.lists() }); + // Invalidate detail + queryClient.invalidateQueries({ + queryKey: purchaseOrderKeys.detail(variables.tenantId, variables.poId) + }); + }, + ...options, + }); +}; + +/** + * Hook to reject a purchase order + */ +export const useRejectPurchaseOrder = ( + options?: UseMutationOptions< + PurchaseOrderDetail, + ApiError, + { tenantId: string; poId: string; reason: string } + > +) => { + const queryClient = useQueryClient(); + + return useMutation< + PurchaseOrderDetail, + ApiError, + { tenantId: string; poId: string; reason: string } + >({ + mutationFn: ({ tenantId, poId, reason }) => rejectPurchaseOrder(tenantId, poId, reason), + onSuccess: (data, variables) => { + // Invalidate pending approvals list + queryClient.invalidateQueries({ + queryKey: purchaseOrderKeys.pendingApproval(variables.tenantId) + }); + // Invalidate all lists + queryClient.invalidateQueries({ queryKey: purchaseOrderKeys.lists() }); + // Invalidate detail + queryClient.invalidateQueries({ + queryKey: purchaseOrderKeys.detail(variables.tenantId, variables.poId) + }); + }, + ...options, + }); +}; + +/** + * Hook to bulk approve purchase orders + */ +export const useBulkApprovePurchaseOrders = ( + options?: UseMutationOptions< + PurchaseOrderDetail[], + ApiError, + { tenantId: string; poIds: string[]; notes?: string } + > +) => { + const queryClient = useQueryClient(); + + return useMutation< + PurchaseOrderDetail[], + ApiError, + { tenantId: string; poIds: string[]; notes?: string } + >({ + mutationFn: ({ tenantId, poIds, notes }) => bulkApprovePurchaseOrders(tenantId, poIds, notes), + onSuccess: (data, variables) => { + // Invalidate all PO queries + queryClient.invalidateQueries({ queryKey: purchaseOrderKeys.all }); + }, + ...options, + }); +}; + +/** + * Hook to delete a purchase order + */ +export const useDeletePurchaseOrder = ( + options?: UseMutationOptions< + { message: string }, + ApiError, + { tenantId: string; poId: string } + > +) => { + const queryClient = useQueryClient(); + + return useMutation< + { message: string }, + ApiError, + { tenantId: string; poId: string } + >({ + mutationFn: ({ tenantId, poId }) => deletePurchaseOrder(tenantId, poId), + onSuccess: (data, variables) => { + // Invalidate all PO queries + queryClient.invalidateQueries({ queryKey: purchaseOrderKeys.all }); + }, + ...options, + }); +}; diff --git a/frontend/src/api/services/alert_analytics.ts b/frontend/src/api/services/alert_analytics.ts new file mode 100644 index 00000000..0140901f --- /dev/null +++ b/frontend/src/api/services/alert_analytics.ts @@ -0,0 +1,125 @@ +/** + * Alert Analytics API Client + * Handles all API calls for alert analytics and interaction tracking + */ + +import { apiClient } from '../client'; + +export interface AlertTrendData { + date: string; + count: number; + urgentCount: number; + highCount: number; + mediumCount: number; + lowCount: number; +} + +export interface AlertCategory { + category: string; + count: number; + percentage: number; +} + +export interface AlertAnalytics { + trends: AlertTrendData[]; + averageResponseTime: number; + topCategories: AlertCategory[]; + totalAlerts: number; + resolvedAlerts: number; + activeAlerts: number; + resolutionRate: number; + predictedDailyAverage: number; + busiestDay: string; +} + +export interface AlertInteraction { + alert_id: string; + interaction_type: 'acknowledged' | 'resolved' | 'snoozed' | 'dismissed'; + metadata?: Record; +} + +export interface InteractionResponse { + id: string; + alert_id: string; + interaction_type: string; + interacted_at: string; + response_time_seconds: number; +} + +export interface BatchInteractionResponse { + created_count: number; + interactions: Array<{ + id: string; + alert_id: string; + interaction_type: string; + interacted_at: string; + }>; +} + +/** + * Track a single alert interaction + */ +export async function trackAlertInteraction( + tenantId: string, + alertId: string, + interactionType: 'acknowledged' | 'resolved' | 'snoozed' | 'dismissed', + metadata?: Record +): Promise { + return apiClient.post( + `/tenants/${tenantId}/alerts/${alertId}/interactions`, + { + alert_id: alertId, + interaction_type: interactionType, + metadata + } + ); +} + +/** + * Track multiple alert interactions in batch + */ +export async function trackAlertInteractionsBatch( + tenantId: string, + interactions: AlertInteraction[] +): Promise { + return apiClient.post( + `/tenants/${tenantId}/alerts/interactions/batch`, + { + interactions + } + ); +} + +/** + * Get comprehensive alert analytics + */ +export async function getAlertAnalytics( + tenantId: string, + days: number = 7 +): Promise { + console.log('[getAlertAnalytics] Calling API:', `/tenants/${tenantId}/alerts/analytics`, 'with days:', days); + const data = await apiClient.get( + `/tenants/${tenantId}/alerts/analytics`, + { + params: { days } + } + ); + console.log('[getAlertAnalytics] Received data:', data); + console.log('[getAlertAnalytics] Data type:', typeof data); + return data; // apiClient.get() already returns data, not response.data +} + +/** + * Get alert trends only + */ +export async function getAlertTrends( + tenantId: string, + days: number = 7 +): Promise { + return apiClient.get( + `/tenants/${tenantId}/alerts/analytics/trends`, + { + params: { days } + } + ); +} diff --git a/frontend/src/api/services/orders.ts b/frontend/src/api/services/orders.ts index 99ace6a5..b428c156 100644 --- a/frontend/src/api/services/orders.ts +++ b/frontend/src/api/services/orders.ts @@ -84,7 +84,7 @@ export class OrdersService { */ static async createOrder(orderData: OrderCreate): Promise { const { tenant_id, ...data } = orderData; - return apiClient.post(`/tenants/${tenant_id}/orders/orders`, data); + return apiClient.post(`/tenants/${tenant_id}/orders`, data); } /** @@ -92,7 +92,7 @@ export class OrdersService { * GET /tenants/{tenant_id}/orders/{order_id} */ static async getOrder(tenantId: string, orderId: string): Promise { - return apiClient.get(`/tenants/${tenantId}/orders/orders/${orderId}`); + return apiClient.get(`/tenants/${tenantId}/orders/${orderId}`); } /** @@ -117,7 +117,7 @@ export class OrdersService { queryParams.append('end_date', end_date); } - return apiClient.get(`/tenants/${tenant_id}/orders/orders?${queryParams.toString()}`); + return apiClient.get(`/tenants/${tenant_id}/orders?${queryParams.toString()}`); } /** diff --git a/frontend/src/api/services/production.ts b/frontend/src/api/services/production.ts index 1be57452..7c1ace4d 100644 --- a/frontend/src/api/services/production.ts +++ b/frontend/src/api/services/production.ts @@ -381,7 +381,7 @@ export class ProductionService { } async getProductionRequirements(tenantId: string, date: string): Promise { - return apiClient.get(`${this.baseUrl}/${tenantId}/production/dashboard/requirements/${date}`); + return apiClient.get(`${this.baseUrl}/${tenantId}/production/dashboard/requirements?date=${date}`); } async getCapacityOverview(tenantId: string, date?: string): Promise { diff --git a/frontend/src/api/services/purchase_orders.ts b/frontend/src/api/services/purchase_orders.ts new file mode 100644 index 00000000..57e9816f --- /dev/null +++ b/frontend/src/api/services/purchase_orders.ts @@ -0,0 +1,239 @@ +/** + * Purchase Orders API Client + * Handles all API calls for purchase orders in the suppliers service + */ + +import { apiClient } from '../client'; + +export type PurchaseOrderStatus = + | 'DRAFT' + | 'PENDING_APPROVAL' + | 'APPROVED' + | 'SENT_TO_SUPPLIER' + | 'CONFIRMED' + | 'RECEIVED' + | 'COMPLETED' + | 'CANCELLED' + | 'DISPUTED'; + +export type PurchaseOrderPriority = 'urgent' | 'high' | 'normal' | 'low'; + +export interface PurchaseOrderItem { + id: string; + inventory_product_id: string; + product_code?: string; + ordered_quantity: number; + unit_of_measure: string; + unit_price: string; // Decimal as string + line_total: string; // Decimal as string + received_quantity: number; + remaining_quantity: number; + quality_requirements?: string; + item_notes?: string; +} + +export interface SupplierSummary { + id: string; + name: string; + supplier_code: string; + supplier_type: string; + status: string; + contact_person?: string; + email?: string; + phone?: string; +} + +export interface PurchaseOrderSummary { + id: string; + po_number: string; + supplier_id: string; + supplier_name?: string; + status: PurchaseOrderStatus; + priority: PurchaseOrderPriority; + order_date: string; + required_delivery_date?: string; + total_amount: string; // Decimal as string + currency: string; + created_at: string; +} + +export interface PurchaseOrderDetail extends PurchaseOrderSummary { + reference_number?: string; + estimated_delivery_date?: string; + + // Financial information + subtotal: string; + tax_amount: string; + shipping_cost: string; + discount_amount: string; + + // Delivery information + delivery_address?: string; + delivery_instructions?: string; + delivery_contact?: string; + delivery_phone?: string; + + // Approval workflow + requires_approval: boolean; + approved_by?: string; + approved_at?: string; + rejection_reason?: string; + + // Communication tracking + sent_to_supplier_at?: string; + supplier_confirmation_date?: string; + supplier_reference?: string; + + // Additional information + notes?: string; + internal_notes?: string; + terms_and_conditions?: string; + + // Audit fields + updated_at: string; + created_by: string; + updated_by: string; + + // Related data + supplier?: SupplierSummary; + items?: PurchaseOrderItem[]; +} + +export interface PurchaseOrderSearchParams { + supplier_id?: string; + status?: PurchaseOrderStatus; + priority?: PurchaseOrderPriority; + date_from?: string; // YYYY-MM-DD + date_to?: string; // YYYY-MM-DD + search_term?: string; + limit?: number; + offset?: number; +} + +export interface PurchaseOrderUpdateData { + status?: PurchaseOrderStatus; + priority?: PurchaseOrderPriority; + notes?: string; + rejection_reason?: string; + internal_notes?: string; +} + +/** + * Get list of purchase orders with optional filters + */ +export async function listPurchaseOrders( + tenantId: string, + params?: PurchaseOrderSearchParams +): Promise { + return apiClient.get( + `/tenants/${tenantId}/purchase-orders`, + { params } + ); +} + +/** + * Get purchase orders by status + */ +export async function getPurchaseOrdersByStatus( + tenantId: string, + status: PurchaseOrderStatus, + limit: number = 50 +): Promise { + return listPurchaseOrders(tenantId, { status, limit }); +} + +/** + * Get pending approval purchase orders + */ +export async function getPendingApprovalPurchaseOrders( + tenantId: string, + limit: number = 50 +): Promise { + return getPurchaseOrdersByStatus(tenantId, 'PENDING_APPROVAL', limit); +} + +/** + * Get a single purchase order by ID with full details + */ +export async function getPurchaseOrder( + tenantId: string, + poId: string +): Promise { + return apiClient.get( + `/tenants/${tenantId}/purchase-orders/${poId}` + ); +} + +/** + * Update purchase order + */ +export async function updatePurchaseOrder( + tenantId: string, + poId: string, + data: PurchaseOrderUpdateData +): Promise { + return apiClient.put( + `/tenants/${tenantId}/purchase-orders/${poId}`, + data + ); +} + +/** + * Approve a purchase order + */ +export async function approvePurchaseOrder( + tenantId: string, + poId: string, + notes?: string +): Promise { + return apiClient.post( + `/tenants/${tenantId}/purchase-orders/${poId}/approve`, + { + action: 'approve', + notes: notes || 'Approved from dashboard' + } + ); +} + +/** + * Reject a purchase order + */ +export async function rejectPurchaseOrder( + tenantId: string, + poId: string, + reason: string +): Promise { + return apiClient.post( + `/tenants/${tenantId}/purchase-orders/${poId}/approve`, + { + action: 'reject', + notes: reason + } + ); +} + +/** + * Bulk approve purchase orders + */ +export async function bulkApprovePurchaseOrders( + tenantId: string, + poIds: string[], + notes?: string +): Promise { + const approvalPromises = poIds.map(poId => + approvePurchaseOrder(tenantId, poId, notes) + ); + return Promise.all(approvalPromises); +} + +/** + * Delete purchase order + */ +export async function deletePurchaseOrder( + tenantId: string, + poId: string +): Promise<{ message: string }> { + return apiClient.delete<{ message: string }>( + `/tenants/${tenantId}/purchase-orders/${poId}` + ); +} diff --git a/frontend/src/api/services/suppliers.ts b/frontend/src/api/services/suppliers.ts index bd8270db..039e0dc8 100644 --- a/frontend/src/api/services/suppliers.ts +++ b/frontend/src/api/services/suppliers.ts @@ -104,6 +104,19 @@ class SuppliersService { ); } + async getSupplierProducts( + tenantId: string, + supplierId: string, + isActive: boolean = true + ): Promise> { + const params = new URLSearchParams(); + params.append('is_active', isActive.toString()); + + return apiClient.get>( + `${this.baseUrl}/${tenantId}/suppliers/suppliers/${supplierId}/products?${params.toString()}` + ); + } + // =================================================================== // ATOMIC: Purchase Orders CRUD // Backend: services/suppliers/app/api/purchase_orders.py diff --git a/frontend/src/components/domain/dashboard/AlertBulkActions.tsx b/frontend/src/components/domain/dashboard/AlertBulkActions.tsx new file mode 100644 index 00000000..ca51b00d --- /dev/null +++ b/frontend/src/components/domain/dashboard/AlertBulkActions.tsx @@ -0,0 +1,126 @@ +import React, { useState } from 'react'; +import { Check, Trash2, Clock, X } from 'lucide-react'; +import { Button } from '../../ui/Button'; +import AlertSnoozeMenu from './AlertSnoozeMenu'; + +export interface AlertBulkActionsProps { + selectedCount: number; + onMarkAsRead: () => void; + onRemove: () => void; + onSnooze: (duration: '15min' | '1hr' | '4hr' | 'tomorrow' | number) => void; + onDeselectAll: () => void; + onSelectAll: () => void; + totalCount: number; +} + +const AlertBulkActions: React.FC = ({ + selectedCount, + onMarkAsRead, + onRemove, + onSnooze, + onDeselectAll, + onSelectAll, + totalCount, +}) => { + const [showSnoozeMenu, setShowSnoozeMenu] = useState(false); + + if (selectedCount === 0) { + return null; + } + + const handleSnooze = (duration: '15min' | '1hr' | '4hr' | 'tomorrow' | number) => { + onSnooze(duration); + setShowSnoozeMenu(false); + }; + + const allSelected = selectedCount === totalCount; + + return ( +
+
+
+ {selectedCount} + + {selectedCount === 1 ? 'seleccionado' : 'seleccionados'} + +
+ + {!allSelected && totalCount > selectedCount && ( + + )} +
+ +
+ {/* Quick Actions */} + + +
+ + + {showSnoozeMenu && ( + <> +
setShowSnoozeMenu(false)} + aria-hidden="true" + /> +
+ setShowSnoozeMenu(false)} + /> +
+ + )} +
+ + + + {/* Close button */} + +
+
+ ); +}; + +export default AlertBulkActions; diff --git a/frontend/src/components/domain/dashboard/AlertCard.tsx b/frontend/src/components/domain/dashboard/AlertCard.tsx new file mode 100644 index 00000000..933a0419 --- /dev/null +++ b/frontend/src/components/domain/dashboard/AlertCard.tsx @@ -0,0 +1,446 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + AlertTriangle, + AlertCircle, + Info, + CheckCircle, + Check, + Trash2, + Clock, + MoreVertical, + ChevronDown, + ChevronUp, +} from 'lucide-react'; +import { Badge } from '../../ui/Badge'; +import { Button } from '../../ui/Button'; +import type { NotificationData } from '../../../hooks/useNotifications'; +import { getSnoozedTimeRemaining, categorizeAlert } from '../../../utils/alertHelpers'; +import AlertContextActions from './AlertContextActions'; +import AlertSnoozeMenu from './AlertSnoozeMenu'; + +export interface AlertCardProps { + alert: NotificationData; + isExpanded: boolean; + isSelected: boolean; + isSnoozed: boolean; + snoozedUntil?: number; + onToggleExpand: () => void; + onToggleSelect: () => void; + onMarkAsRead: () => void; + onRemove: () => void; + onSnooze: (duration: '15min' | '1hr' | '4hr' | 'tomorrow' | number) => void; + onUnsnooze: () => void; + showCheckbox?: boolean; +} + +const getSeverityIcon = (severity: string) => { + switch (severity) { + case 'urgent': + return AlertTriangle; + case 'high': + return AlertCircle; + case 'medium': + return Info; + case 'low': + return CheckCircle; + default: + return Info; + } +}; + +const getSeverityColor = (severity: string) => { + switch (severity) { + case 'urgent': + return 'var(--color-error)'; + case 'high': + return 'var(--color-warning)'; + case 'medium': + return 'var(--color-info)'; + case 'low': + return 'var(--color-success)'; + default: + return 'var(--color-info)'; + } +}; + +const getSeverityBadge = (severity: string): 'error' | 'warning' | 'info' | 'success' => { + switch (severity) { + case 'urgent': + return 'error'; + case 'high': + return 'warning'; + case 'medium': + return 'info'; + case 'low': + return 'success'; + default: + return 'info'; + } +}; + +const formatTimestamp = (timestamp: string, t: any) => { + const date = new Date(timestamp); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / (1000 * 60)); + + if (diffMins < 1) return t('dashboard:alerts.time.now', 'Ahora'); + if (diffMins < 60) return t('dashboard:alerts.time.minutes_ago', 'hace {{count}} min', { count: diffMins }); + const diffHours = Math.floor(diffMins / 60); + if (diffHours < 24) return t('dashboard:alerts.time.hours_ago', 'hace {{count}} h', { count: diffHours }); + return date.toLocaleDateString() === new Date(now.getTime() - 24 * 60 * 60 * 1000).toLocaleDateString() + ? t('dashboard:alerts.time.yesterday', 'Ayer') + : date.toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit' }); +}; + +const AlertCard: React.FC = ({ + alert, + isExpanded, + isSelected, + isSnoozed, + snoozedUntil, + onToggleExpand, + onToggleSelect, + onMarkAsRead, + onRemove, + onSnooze, + onUnsnooze, + showCheckbox = false, +}) => { + const { t } = useTranslation(['dashboard']); + const [showSnoozeMenu, setShowSnoozeMenu] = useState(false); + const [showActions, setShowActions] = useState(false); + const [isHovered, setIsHovered] = useState(false); + + const SeverityIcon = getSeverityIcon(alert.severity); + const severityColor = getSeverityColor(alert.severity); + + const handleSnooze = (duration: '15min' | '1hr' | '4hr' | 'tomorrow' | number) => { + onSnooze(duration); + setShowSnoozeMenu(false); + }; + + const category = categorizeAlert(alert); + + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + {/* Left severity accent border */} +
+ + {/* Compact Card Header */} +
+ {/* Checkbox for selection */} + {showCheckbox && ( +
+ { + e.stopPropagation(); + onToggleSelect(); + }} + className="w-4 h-4 rounded border-[var(--border-primary)] text-[var(--color-primary)] focus:ring-[var(--color-primary)] focus:ring-offset-0 cursor-pointer" + aria-label={`Select alert: ${alert.title}`} + /> +
+ )} + + {/* Severity Icon */} +
+ +
+ + {/* Alert Content */} +
+ {/* Title and Status */} +
+
+

+ {alert.title} +

+
+ {/* Single primary severity badge */} + + {t(`dashboard:alerts.severity.${alert.severity}`, alert.severity.toUpperCase())} + + + {/* Unread indicator */} + {!alert.read && ( + + + {t('dashboard:alerts.status.new', 'Nuevo')} + + )} + + {/* Snoozed indicator */} + {isSnoozed && snoozedUntil && ( + + + {getSnoozedTimeRemaining(alert.id, new Map([[alert.id, { alertId: alert.id, until: snoozedUntil }]]))} + + )} +
+
+ + {/* Timestamp */} + + {formatTimestamp(alert.timestamp, t)} + +
+ + {/* Preview message when collapsed */} + {!isExpanded && alert.message && ( +

+ {alert.message} +

+ )} +
+ + {/* Actions - shown on hover or when expanded */} +
+ {/* Quick action buttons */} + {!alert.read && !isExpanded && ( + + )} + + + + +
+
+ + {/* Quick Actions Menu - Better positioning */} + {showActions && ( + <> +
setShowActions(false)} + aria-hidden="true" + /> +
+ {!alert.read && ( + + )} + + {isSnoozed && ( + + )} +
+ +
+ + )} + + {/* Snooze Menu - Better positioning */} + {showSnoozeMenu && ( + <> +
setShowSnoozeMenu(false)} + aria-hidden="true" + /> +
+ setShowSnoozeMenu(false)} + /> +
+ + )} + + {/* Expanded Details */} + {isExpanded && ( +
+ {/* Full Message */} +
+

+ {alert.message} +

+
+ + {/* Metadata */} + {alert.metadata && Object.keys(alert.metadata).length > 0 && ( +
+

+ {t('dashboard:alerts.additional_details', 'Detalles Adicionales')} +

+
+ {Object.entries(alert.metadata).map(([key, value]) => ( +
+ {key.replace(/_/g, ' ')}: + {String(value)} +
+ ))} +
+
+ )} + + {/* Contextual Actions */} +
+ +
+ + {/* Action Buttons */} +
+ {!alert.read && ( + + )} + + +
+
+ )} +
+ ); +}; + +export default AlertCard; diff --git a/frontend/src/components/domain/dashboard/AlertContextActions.tsx b/frontend/src/components/domain/dashboard/AlertContextActions.tsx new file mode 100644 index 00000000..3fe61198 --- /dev/null +++ b/frontend/src/components/domain/dashboard/AlertContextActions.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { Button } from '../../ui/Button'; +import type { NotificationData } from '../../../hooks/useNotifications'; +import { useAlertActions } from '../../../hooks/useAlertActions'; + +export interface AlertContextActionsProps { + alert: NotificationData; +} + +const AlertContextActions: React.FC = ({ alert }) => { + const { getActions, executeAction } = useAlertActions(); + const actions = getActions(alert); + + if (actions.length === 0) { + return null; + } + + return ( +
+

+ Acciones Recomendadas +

+
+ {actions.map((action, index) => { + const variantMap: Record = { + primary: 'primary', + secondary: 'secondary', + outline: 'outline', + }; + + return ( + + ); + })} +
+
+ ); +}; + +export default AlertContextActions; diff --git a/frontend/src/components/domain/dashboard/AlertFilters.tsx b/frontend/src/components/domain/dashboard/AlertFilters.tsx new file mode 100644 index 00000000..ab93895a --- /dev/null +++ b/frontend/src/components/domain/dashboard/AlertFilters.tsx @@ -0,0 +1,306 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Search, X, Filter, ChevronDown } from 'lucide-react'; +import { Badge } from '../../ui/Badge'; +import { Button } from '../../ui/Button'; +import { Input } from '../../ui/Input'; +import type { AlertSeverity, AlertCategory, TimeGroup } from '../../../utils/alertHelpers'; +import { getCategoryName, getCategoryIcon } from '../../../utils/alertHelpers'; + +export interface AlertFiltersProps { + selectedSeverities: AlertSeverity[]; + selectedCategories: AlertCategory[]; + selectedTimeRange: TimeGroup | 'all'; + searchQuery: string; + showSnoozed: boolean; + onToggleSeverity: (severity: AlertSeverity) => void; + onToggleCategory: (category: AlertCategory) => void; + onSetTimeRange: (range: TimeGroup | 'all') => void; + onSearchChange: (query: string) => void; + onToggleShowSnoozed: () => void; + onClearFilters: () => void; + hasActiveFilters: boolean; + activeFilterCount: number; +} + +const SEVERITY_CONFIG: Record = { + urgent: { label: 'Urgente', color: 'bg-red-500', variant: 'error' }, + high: { label: 'Alta', color: 'bg-orange-500', variant: 'warning' }, + medium: { label: 'Media', color: 'bg-blue-500', variant: 'info' }, + low: { label: 'Baja', color: 'bg-green-500', variant: 'success' }, +}; + +const TIME_RANGES: Array<{ value: TimeGroup | 'all'; label: string }> = [ + { value: 'all', label: 'Todos' }, + { value: 'today', label: 'Hoy' }, + { value: 'yesterday', label: 'Ayer' }, + { value: 'this_week', label: 'Esta semana' }, + { value: 'older', label: 'Anteriores' }, +]; + +const CATEGORIES: AlertCategory[] = ['inventory', 'production', 'orders', 'equipment', 'quality', 'suppliers']; + +const AlertFilters: React.FC = ({ + selectedSeverities, + selectedCategories, + selectedTimeRange, + searchQuery, + showSnoozed, + onToggleSeverity, + onToggleCategory, + onSetTimeRange, + onSearchChange, + onToggleShowSnoozed, + onClearFilters, + hasActiveFilters, + activeFilterCount, +}) => { + const { t, i18n } = useTranslation(['dashboard']); + // Start collapsed by default for cleaner UI + const [showFilters, setShowFilters] = useState(false); + + return ( +
+ {/* Search and Filter Toggle */} +
+
+ onSearchChange(e.target.value)} + leftIcon={} + rightIcon={ + searchQuery ? ( + + ) : undefined + } + className="pr-8 h-10" + /> +
+ + + + {hasActiveFilters && ( + + )} +
+ + {/* Expandable Filters Panel - Animated */} + {showFilters && ( +
+ {/* Severity Filters */} +
+ +
+ {(Object.keys(SEVERITY_CONFIG) as AlertSeverity[]).map((severity) => { + const config = SEVERITY_CONFIG[severity]; + const isSelected = selectedSeverities.includes(severity); + + return ( + + ); + })} +
+
+ + {/* Category Filters */} +
+ +
+ {CATEGORIES.map((category) => { + const isSelected = selectedCategories.includes(category); + + return ( + + ); + })} +
+
+ + {/* Time Range Filters */} +
+ +
+ {TIME_RANGES.map((range) => { + const isSelected = selectedTimeRange === range.value; + + return ( + + ); + })} +
+
+ + {/* Show Snoozed Toggle */} +
+ + +
+
+ )} + + {/* Active Filters Summary - Chips */} + {hasActiveFilters && !showFilters && ( +
+ + Filtros activos: + + + {selectedSeverities.map((severity) => ( + + ))} + + {selectedCategories.map((category) => ( + + ))} + + {selectedTimeRange !== 'all' && ( + + )} +
+ )} +
+ ); +}; + +export default AlertFilters; diff --git a/frontend/src/components/domain/dashboard/AlertGroupHeader.tsx b/frontend/src/components/domain/dashboard/AlertGroupHeader.tsx new file mode 100644 index 00000000..d6714fc3 --- /dev/null +++ b/frontend/src/components/domain/dashboard/AlertGroupHeader.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { ChevronDown, ChevronUp } from 'lucide-react'; +import { Badge } from '../../ui/Badge'; +import type { AlertGroup } from '../../../utils/alertHelpers'; + +export interface AlertGroupHeaderProps { + group: AlertGroup; + isCollapsed: boolean; + onToggleCollapse: () => void; +} + +const SEVERITY_COLORS: Record = { + urgent: 'text-red-600 bg-red-50 border-red-200', + high: 'text-orange-600 bg-orange-50 border-orange-200', + medium: 'text-blue-600 bg-blue-50 border-blue-200', + low: 'text-green-600 bg-green-50 border-green-200', +}; + +const SEVERITY_BADGE_VARIANTS: Record = { + urgent: 'error', + high: 'warning', + medium: 'info', + low: 'success', +}; + +const AlertGroupHeader: React.FC = ({ + group, + isCollapsed, + onToggleCollapse, +}) => { + const severityConfig = SEVERITY_COLORS[group.severity] || SEVERITY_COLORS.low; + const badgeVariant = SEVERITY_BADGE_VARIANTS[group.severity] || 'info'; + + return ( + + ); +}; + +export default AlertGroupHeader; diff --git a/frontend/src/components/domain/dashboard/AlertSnoozeMenu.tsx b/frontend/src/components/domain/dashboard/AlertSnoozeMenu.tsx new file mode 100644 index 00000000..d103dbd7 --- /dev/null +++ b/frontend/src/components/domain/dashboard/AlertSnoozeMenu.tsx @@ -0,0 +1,118 @@ +import React, { useState } from 'react'; +import { Clock, X } from 'lucide-react'; +import { Button } from '../../ui/Button'; + +export interface AlertSnoozeMenuProps { + onSnooze: (duration: '15min' | '1hr' | '4hr' | 'tomorrow' | number) => void; + onCancel: () => void; +} + +const PRESET_DURATIONS = [ + { value: '15min' as const, label: '15 minutos', icon: '⏰' }, + { value: '1hr' as const, label: '1 hora', icon: '🕐' }, + { value: '4hr' as const, label: '4 horas', icon: '🕓' }, + { value: 'tomorrow' as const, label: 'Mañana (9 AM)', icon: '☀️' }, +]; + +const AlertSnoozeMenu: React.FC = ({ + onSnooze, + onCancel, +}) => { + const [showCustom, setShowCustom] = useState(false); + const [customHours, setCustomHours] = useState(1); + + const handleCustomSnooze = () => { + const milliseconds = customHours * 60 * 60 * 1000; + onSnooze(milliseconds); + }; + + return ( +
+ {/* Header */} +
+
+ + + Posponer hasta + +
+ +
+ + {!showCustom ? ( + <> + {/* Preset Options */} +
+ {PRESET_DURATIONS.map((preset) => ( + + ))} +
+ + {/* Custom Option */} + + + ) : ( + <> + {/* Custom Time Input */} +
+
+ + setCustomHours(parseInt(e.target.value) || 1)} + className="w-full px-3 py-2 border border-[var(--border-primary)] rounded-lg text-sm bg-[var(--bg-primary)] text-[var(--text-primary)]" + autoFocus + /> +

+ Máximo 168 horas (7 días) +

+
+ +
+ + +
+
+ + )} +
+ ); +}; + +export default AlertSnoozeMenu; diff --git a/frontend/src/components/domain/dashboard/AlertTrends.tsx b/frontend/src/components/domain/dashboard/AlertTrends.tsx new file mode 100644 index 00000000..3ed845d3 --- /dev/null +++ b/frontend/src/components/domain/dashboard/AlertTrends.tsx @@ -0,0 +1,179 @@ +import React from 'react'; +import { TrendingUp, Clock, AlertTriangle, BarChart3 } from 'lucide-react'; +import { Badge } from '../../ui/Badge'; +import type { AlertAnalytics } from '../../../hooks/useAlertAnalytics'; + +export interface AlertTrendsProps { + analytics: AlertAnalytics | undefined; + className?: string; +} + +const AlertTrends: React.FC = ({ analytics, className }) => { + // Debug logging + console.log('[AlertTrends] Received analytics:', analytics); + console.log('[AlertTrends] Has trends?', analytics?.trends); + console.log('[AlertTrends] Is array?', Array.isArray(analytics?.trends)); + + // Safety check: handle undefined or missing analytics data + if (!analytics || !analytics.trends || !Array.isArray(analytics.trends)) { + console.log('[AlertTrends] Showing loading state'); + return ( +
+
+ Cargando analíticas... +
+
+ ); + } + + console.log('[AlertTrends] Rendering analytics with', analytics.trends.length, 'trends'); + + // Ensure we have valid trend data + const validTrends = analytics.trends.filter(t => t && typeof t.count === 'number'); + const maxCount = validTrends.length > 0 ? Math.max(...validTrends.map(t => t.count), 1) : 1; + + const formatDate = (dateStr: string) => { + const date = new Date(dateStr); + const today = new Date(); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + + if (date.toDateString() === today.toDateString()) { + return 'Hoy'; + } + if (date.toDateString() === yesterday.toDateString()) { + return 'Ayer'; + } + return date.toLocaleDateString('es-ES', { weekday: 'short', day: 'numeric' }); + }; + + return ( +
+ {/* Header */} +
+
+ +

+ Tendencias (7 días) +

+
+ + {analytics.totalAlerts} total + +
+ + {/* Chart */} +
+
+ {analytics.trends.map((trend, index) => { + const heightPercentage = maxCount > 0 ? (trend.count / maxCount) * 100 : 0; + + return ( +
+
+ {/* Bar */} +
0 ? '4px' : '0' }} + title={`${trend.count} alertas`} + > + {/* Tooltip on hover */} +
+
+ {trend.count} alertas +
+ 🔴 {trend.urgentCount} • 🟠 {trend.highCount} • 🔵 {trend.mediumCount} • 🟢 {trend.lowCount} +
+
+
+
+
+ + {/* Label */} + + {formatDate(trend.date)} + +
+ ); + })} +
+
+ + {/* Stats Grid */} +
+ {/* Average Response Time */} +
+
+ +
+
+
Respuesta promedio
+
+ {analytics.averageResponseTime > 0 ? `${analytics.averageResponseTime} min` : 'N/A'} +
+
+
+ + {/* Daily Average */} +
+
+ +
+
+
Promedio diario
+
+ {analytics.predictedDailyAverage} alertas +
+
+
+ + {/* Resolution Rate */} +
+
+ +
+
+
Tasa de resolución
+
+ {analytics.resolutionRate}% +
+
+
+ + {/* Busiest Day */} +
+
+ +
+
+
Día más activo
+
+ {analytics.busiestDay} +
+
+
+
+ + {/* Top Categories */} + {analytics.topCategories.length > 0 && ( +
+
+ Categorías principales +
+
+ {analytics.topCategories.map((cat) => ( + + {cat.count} ({cat.percentage}%) + + ))} +
+
+ )} +
+ ); +}; + +export default AlertTrends; diff --git a/frontend/src/components/domain/dashboard/PendingPOApprovals.tsx b/frontend/src/components/domain/dashboard/PendingPOApprovals.tsx new file mode 100644 index 00000000..9fdc84e7 --- /dev/null +++ b/frontend/src/components/domain/dashboard/PendingPOApprovals.tsx @@ -0,0 +1,425 @@ +import React, { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Card, CardHeader, CardBody } from '../../ui/Card'; +import { StatusCard } from '../../ui/StatusCard/StatusCard'; +import { Badge } from '../../ui/Badge'; +import { Button } from '../../ui/Button'; +import { useCurrentTenant } from '../../../stores/tenant.store'; +import { usePendingApprovalPurchaseOrders, useApprovePurchaseOrder, useRejectPurchaseOrder } from '../../../api/hooks/purchase-orders'; +import { + ShoppingCart, + Clock, + CheckCircle, + XCircle, + AlertTriangle, + ChevronRight, + Calendar, + Package, + TruckIcon, + Euro, + FileCheck +} from 'lucide-react'; + +export interface PendingPOApprovalsProps { + className?: string; + maxPOs?: number; + onApprovePO?: (poId: string) => void; + onRejectPO?: (poId: string) => void; + onViewDetails?: (poId: string) => void; + onViewAllPOs?: () => void; +} + +const PendingPOApprovals: React.FC = ({ + className, + maxPOs = 5, + onApprovePO, + onRejectPO, + onViewDetails, + onViewAllPOs +}) => { + const { t } = useTranslation(['dashboard']); + const currentTenant = useCurrentTenant(); + const tenantId = currentTenant?.id || ''; + + const [approvingPO, setApprovingPO] = useState(null); + const [rejectingPO, setRejectingPO] = useState(null); + + // Fetch pending approval POs + const { data: pendingPOs, isLoading, error } = usePendingApprovalPurchaseOrders( + tenantId, + 50, + { + enabled: !!tenantId, + } + ); + + // Mutations + const approveMutation = useApprovePurchaseOrder({ + onSuccess: () => { + setApprovingPO(null); + if (approvingPO && onApprovePO) { + onApprovePO(approvingPO); + } + }, + onError: (error) => { + console.error('Failed to approve PO:', error); + setApprovingPO(null); + } + }); + + const rejectMutation = useRejectPurchaseOrder({ + onSuccess: () => { + setRejectingPO(null); + if (rejectingPO && onRejectPO) { + onRejectPO(rejectingPO); + } + }, + onError: (error) => { + console.error('Failed to reject PO:', error); + setRejectingPO(null); + } + }); + + const handleApprovePO = async (poId: string) => { + setApprovingPO(poId); + await approveMutation.mutateAsync({ + tenantId, + poId, + notes: 'Approved from dashboard' + }); + }; + + const handleRejectPO = async (poId: string) => { + setRejectingPO(poId); + await rejectMutation.mutateAsync({ + tenantId, + poId, + reason: 'Rejected from dashboard - requires review' + }); + }; + + const getPOPriorityConfig = (priority: string) => { + switch (priority.toLowerCase()) { + case 'urgent': + return { + color: 'var(--color-error)', + text: 'Urgente', + icon: AlertTriangle, + isCritical: true, + isHighlight: false + }; + case 'high': + return { + color: 'var(--color-warning)', + text: 'Alta', + icon: Clock, + isCritical: false, + isHighlight: true + }; + case 'normal': + return { + color: 'var(--color-info)', + text: 'Normal', + icon: Package, + isCritical: false, + isHighlight: false + }; + case 'low': + return { + color: 'var(--color-success)', + text: 'Baja', + icon: Clock, + isCritical: false, + isHighlight: false + }; + default: + return { + color: 'var(--color-info)', + text: 'Normal', + icon: Package, + isCritical: false, + isHighlight: false + }; + } + }; + + const formatCurrency = (amount: string, currency: string = 'EUR') => { + const value = parseFloat(amount); + if (currency === 'EUR') { + return `€${value.toFixed(2)}`; + } + return `${value.toFixed(2)} ${currency}`; + }; + + const formatDate = (dateStr: string) => { + const date = new Date(dateStr); + const now = new Date(); + const diffDays = Math.ceil((date.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); + + if (diffDays < 0) { + return `Vencido hace ${Math.abs(diffDays)} días`; + } else if (diffDays === 0) { + return 'Hoy'; + } else if (diffDays === 1) { + return 'Mañana'; + } else if (diffDays <= 7) { + return `En ${diffDays} días`; + } + return date.toLocaleDateString('es-ES', { month: 'short', day: 'numeric' }); + }; + + // Process POs and sort by priority and delivery date + const displayPOs = useMemo(() => { + if (!pendingPOs || !Array.isArray(pendingPOs)) return []; + + const pos = [...pendingPOs]; + + // Sort by priority and delivery date + const priorityOrder = { urgent: 0, high: 1, normal: 2, low: 3 }; + pos.sort((a, b) => { + // First by priority + const aPriority = priorityOrder[a.priority.toLowerCase() as keyof typeof priorityOrder] ?? 4; + const bPriority = priorityOrder[b.priority.toLowerCase() as keyof typeof priorityOrder] ?? 4; + + if (aPriority !== bPriority) return aPriority - bPriority; + + // Then by delivery date (earliest first) + if (a.required_delivery_date && b.required_delivery_date) { + const aDate = new Date(a.required_delivery_date).getTime(); + const bDate = new Date(b.required_delivery_date).getTime(); + return aDate - bDate; + } + + // Finally by created date (oldest first - longest waiting) + const aCreated = new Date(a.created_at).getTime(); + const bCreated = new Date(b.created_at).getTime(); + return aCreated - bCreated; + }); + + return pos.slice(0, maxPOs); + }, [pendingPOs, maxPOs]); + + const urgentPOs = pendingPOs?.filter(po => po.priority === 'urgent' || po.priority === 'high').length || 0; + const totalPOs = pendingPOs?.length || 0; + + // Calculate total amount pending approval + const totalAmount = useMemo(() => { + if (!displayPOs || displayPOs.length === 0) return '0.00'; + const sum = displayPOs.reduce((acc, po) => acc + parseFloat(po.total_amount || '0'), 0); + return sum.toFixed(2); + }, [displayPOs]); + + if (isLoading) { + return ( + + +
+
+ +
+
+

+ {t('dashboard:sections.pending_po_approvals', 'Órdenes de Compra Pendientes')} +

+

+ {t('dashboard:po_approvals.title', '¿Qué órdenes debo aprobar?')} +

+
+
+
+ +
+
+
+
+
+ ); + } + + if (error) { + return ( + + +
+
+ +
+
+

+ {t('dashboard:sections.pending_po_approvals', 'Órdenes de Compra Pendientes')} +

+

+ {t('dashboard:po_approvals.title', '¿Qué órdenes debo aprobar?')} +

+
+
+
+ +
+

+ {t('dashboard:messages.error_loading', 'Error al cargar los datos')} +

+
+
+
+ ); + } + + return ( + + +
+
+
+ +
+
+

+ {t('dashboard:sections.pending_po_approvals', 'Órdenes de Compra Pendientes')} +

+

+ {t('dashboard:po_approvals.title', '¿Qué órdenes debo aprobar?')} +

+
+
+ +
+ {urgentPOs > 0 && ( + + {urgentPOs} urgentes + + )} + {totalPOs > 0 && ( + + {totalPOs} pendientes + + )} +
+ + {formatCurrency(totalAmount)} +
+
+
+
+ + + {displayPOs.length === 0 ? ( +
+
+ +
+

+ {t('dashboard:po_approvals.empty', 'Sin órdenes pendientes de aprobación')} +

+

+ Todas las órdenes de compra están aprobadas o en proceso +

+
+ ) : ( +
+ {displayPOs.map((po) => { + const priorityConfig = getPOPriorityConfig(po.priority); + const deliveryDate = po.required_delivery_date + ? formatDate(po.required_delivery_date) + : 'Sin fecha'; + + const isApproving = approvingPO === po.id; + const isRejecting = rejectingPO === po.id; + + return ( + handleApprovePO(po.id), + priority: 'primary' as const, + disabled: isApproving || isRejecting + }, + { + label: isRejecting ? 'Rechazando...' : 'Rechazar', + icon: XCircle, + variant: 'outline' as const, + onClick: () => handleRejectPO(po.id), + priority: 'secondary' as const, + destructive: true, + disabled: isApproving || isRejecting + }, + { + label: 'Ver Detalles', + icon: ChevronRight, + variant: 'outline' as const, + onClick: () => onViewDetails?.(po.id), + priority: 'secondary' as const + } + ]} + compact={true} + className="border-l-4" + /> + ); + })} +
+ )} + + {displayPOs.length > 0 && ( +
+
+
+ + {totalPOs} {t('dashboard:po_approvals.pos_pending', 'órdenes pendientes de aprobación')} + + {urgentPOs > 0 && ( + + • {urgentPOs} urgentes + + )} +
+ + {onViewAllPOs && ( + + )} +
+
+ )} +
+
+ ); +}; + +export default PendingPOApprovals; diff --git a/frontend/src/components/domain/dashboard/ProcurementPlansToday.tsx b/frontend/src/components/domain/dashboard/ProcurementPlansToday.tsx deleted file mode 100644 index 5dabf183..00000000 --- a/frontend/src/components/domain/dashboard/ProcurementPlansToday.tsx +++ /dev/null @@ -1,297 +0,0 @@ -import React from 'react'; -import { Card, CardHeader, CardBody } from '../../ui/Card'; -import { StatusCard } from '../../ui/StatusCard/StatusCard'; -import { Badge } from '../../ui/Badge'; -import { Button } from '../../ui/Button'; -import { - ShoppingCart, - Clock, - Package, - AlertTriangle, - CheckCircle, - ChevronRight, - Calendar, - User, - Euro, - Truck -} from 'lucide-react'; - -export interface ProcurementItem { - id: string; - ingredient: string; - quantity: number; - unit: string; - supplier: string; - priority: 'urgent' | 'high' | 'medium' | 'low'; - estimatedCost: number; - deliveryTime: string; - currentStock: number; - minStock: number; - plannedFor: string; - status: 'pending' | 'ordered' | 'in_transit' | 'delivered'; - notes?: string; -} - -export interface ProcurementPlansProps { - className?: string; - items?: ProcurementItem[]; - onOrderItem?: (itemId: string) => void; - onViewDetails?: (itemId: string) => void; - onViewAllPlans?: () => void; -} - -const ProcurementPlansToday: React.FC = ({ - className, - items = [], - onOrderItem, - onViewDetails, - onViewAllPlans -}) => { - const defaultItems: ProcurementItem[] = [ - { - id: '1', - ingredient: 'Harina de Trigo', - quantity: 50, - unit: 'kg', - supplier: 'Molinos San José', - priority: 'urgent', - estimatedCost: 87.50, - deliveryTime: '10:00', - currentStock: 3, - minStock: 15, - plannedFor: '09:00', - status: 'pending', - notes: 'Stock crítico - necesario para producción matutina' - }, - { - id: '2', - ingredient: 'Levadura Fresca', - quantity: 5, - unit: 'kg', - supplier: 'Distribuidora Alba', - priority: 'urgent', - estimatedCost: 32.50, - deliveryTime: '11:30', - currentStock: 1, - minStock: 3, - plannedFor: '09:30', - status: 'pending' - }, - { - id: '3', - ingredient: 'Mantequilla', - quantity: 15, - unit: 'kg', - supplier: 'Lácteos Premium', - priority: 'high', - estimatedCost: 105.00, - deliveryTime: '14:00', - currentStock: 8, - minStock: 12, - plannedFor: '10:00', - status: 'ordered' - }, - { - id: '4', - ingredient: 'Azúcar Blanco', - quantity: 25, - unit: 'kg', - supplier: 'Azucarera Local', - priority: 'medium', - estimatedCost: 62.50, - deliveryTime: '16:00', - currentStock: 18, - minStock: 20, - plannedFor: '11:00', - status: 'pending' - } - ]; - - const displayItems = items.length > 0 ? items : defaultItems; - - const getItemStatusConfig = (item: ProcurementItem) => { - const baseConfig = { - isCritical: item.priority === 'urgent', - isHighlight: item.priority === 'high' || item.status === 'pending', - }; - - switch (item.status) { - case 'pending': - return { - ...baseConfig, - color: 'var(--color-warning)', - text: 'Pendiente', - icon: Clock - }; - case 'ordered': - return { - ...baseConfig, - color: 'var(--color-info)', - text: 'Pedido', - icon: CheckCircle - }; - case 'in_transit': - return { - ...baseConfig, - color: 'var(--color-primary)', - text: 'En Camino', - icon: Truck - }; - case 'delivered': - return { - ...baseConfig, - color: 'var(--color-success)', - text: 'Entregado', - icon: Package - }; - default: - return { - ...baseConfig, - color: 'var(--color-warning)', - text: 'Pendiente', - icon: Clock - }; - } - }; - - const urgentItems = displayItems.filter(item => item.priority === 'urgent').length; - const pendingItems = displayItems.filter(item => item.status === 'pending').length; - const totalValue = displayItems.reduce((sum, item) => sum + item.estimatedCost, 0); - - return ( - - -
-
-
- -
-
-

- Planes de Compra - Hoy -

-

- Gestiona los pedidos programados para hoy -

-
-
- -
- {urgentItems > 0 && ( - - {urgentItems} urgentes - - )} - - €{totalValue.toFixed(2)} - -
-
-
- - - {displayItems.length === 0 ? ( -
-
- -
-

- No hay compras programadas -

-

- Todos los suministros están al día -

-
- ) : ( -
- {displayItems.map((item) => { - const statusConfig = getItemStatusConfig(item); - const stockPercentage = Math.round((item.currentStock / item.minStock) * 100); - - return ( - onOrderItem?.(item.id), - priority: 'primary' as const - }] : []), - { - label: 'Ver Detalles', - icon: ChevronRight, - variant: 'outline' as const, - onClick: () => onViewDetails?.(item.id), - priority: 'secondary' as const - } - ]} - compact={true} - className="border-l-4" - /> - ); - })} -
- )} - - {displayItems.length > 0 && ( -
-
-
- - {pendingItems} pendientes de {displayItems.length} total - -
- - -
-
- )} -
-
- ); -}; - -export default ProcurementPlansToday; \ No newline at end of file diff --git a/frontend/src/components/domain/dashboard/ProductionPlansToday.tsx b/frontend/src/components/domain/dashboard/ProductionPlansToday.tsx deleted file mode 100644 index 3ae6a292..00000000 --- a/frontend/src/components/domain/dashboard/ProductionPlansToday.tsx +++ /dev/null @@ -1,665 +0,0 @@ -import React from 'react'; -import { Card, CardHeader, CardBody } from '../../ui/Card'; -import { StatusCard } from '../../ui/StatusCard/StatusCard'; -import { Badge } from '../../ui/Badge'; -import { Button } from '../../ui/Button'; -import { - Factory, - Clock, - Users, - Thermometer, - Play, - Pause, - CheckCircle, - AlertTriangle, - ChevronRight, - Timer, - Package, - Flame, - ChefHat, - Eye, - Scale, - FlaskRound, - CircleDot, - ArrowRight, - CheckSquare, - XSquare, - Zap, - Snowflake, - Box -} from 'lucide-react'; - -export interface QualityCheckRequirement { - id: string; - name: string; - stage: ProcessStage; - isRequired: boolean; - isCritical: boolean; - status: 'pending' | 'completed' | 'failed' | 'skipped'; - checkType: 'visual' | 'measurement' | 'temperature' | 'weight' | 'boolean'; -} - -export interface ProcessStageInfo { - current: ProcessStage; - history: Array<{ - stage: ProcessStage; - timestamp: string; - duration?: number; - }>; - pendingQualityChecks: QualityCheckRequirement[]; - completedQualityChecks: QualityCheckRequirement[]; -} - -export type ProcessStage = 'mixing' | 'proofing' | 'shaping' | 'baking' | 'cooling' | 'packaging' | 'finishing'; - -export interface ProductionOrder { - id: string; - product: string; - quantity: number; - unit: string; - priority: 'urgent' | 'high' | 'medium' | 'low'; - status: 'pending' | 'in_progress' | 'completed' | 'paused' | 'delayed'; - startTime: string; - estimatedDuration: number; // in minutes - assignedBaker: string; - ovenNumber?: number; - temperature?: number; - progress: number; // 0-100 - notes?: string; - recipe: string; - ingredients: Array<{ - name: string; - quantity: number; - unit: string; - available: boolean; - }>; - processStage?: ProcessStageInfo; -} - -export interface ProductionPlansProps { - className?: string; - orders?: ProductionOrder[]; - onStartOrder?: (orderId: string) => void; - onPauseOrder?: (orderId: string) => void; - onViewDetails?: (orderId: string) => void; - onViewAllPlans?: () => void; -} - -const getProcessStageIcon = (stage: ProcessStage) => { - switch (stage) { - case 'mixing': return ChefHat; - case 'proofing': return Timer; - case 'shaping': return Package; - case 'baking': return Flame; - case 'cooling': return Snowflake; - case 'packaging': return Box; - case 'finishing': return CheckCircle; - default: return CircleDot; - } -}; - -const getProcessStageColor = (stage: ProcessStage) => { - switch (stage) { - case 'mixing': return 'var(--color-info)'; - case 'proofing': return 'var(--color-warning)'; - case 'shaping': return 'var(--color-primary)'; - case 'baking': return 'var(--color-error)'; - case 'cooling': return 'var(--color-info)'; - case 'packaging': return 'var(--color-success)'; - case 'finishing': return 'var(--color-success)'; - default: return 'var(--color-gray)'; - } -}; - -const getProcessStageLabel = (stage: ProcessStage) => { - switch (stage) { - case 'mixing': return 'Mezclado'; - case 'proofing': return 'Fermentado'; - case 'shaping': return 'Formado'; - case 'baking': return 'Horneado'; - case 'cooling': return 'Enfriado'; - case 'packaging': return 'Empaquetado'; - case 'finishing': return 'Acabado'; - default: return 'Sin etapa'; - } -}; - -const getQualityCheckIcon = (checkType: string) => { - switch (checkType) { - case 'visual': return Eye; - case 'measurement': return Scale; - case 'temperature': return Thermometer; - case 'weight': return Scale; - case 'boolean': return CheckSquare; - default: return FlaskRound; - } -}; - -const ProductionPlansToday: React.FC = ({ - className, - orders = [], - onStartOrder, - onPauseOrder, - onViewDetails, - onViewAllPlans -}) => { - const defaultOrders: ProductionOrder[] = [ - { - id: '1', - product: 'Pan de Molde Integral', - quantity: 20, - unit: 'unidades', - priority: 'urgent', - status: 'in_progress', - startTime: '06:00', - estimatedDuration: 180, - assignedBaker: 'María González', - ovenNumber: 1, - temperature: 220, - progress: 65, - recipe: 'Receta Estándar Integral', - ingredients: [ - { name: 'Harina integral', quantity: 5, unit: 'kg', available: true }, - { name: 'Levadura', quantity: 0.5, unit: 'kg', available: true }, - { name: 'Sal', quantity: 0.2, unit: 'kg', available: true }, - { name: 'Agua', quantity: 3, unit: 'L', available: true } - ], - processStage: { - current: 'baking', - history: [ - { stage: 'mixing', timestamp: '06:00', duration: 30 }, - { stage: 'proofing', timestamp: '06:30', duration: 90 }, - { stage: 'shaping', timestamp: '08:00', duration: 15 }, - { stage: 'baking', timestamp: '08:15' } - ], - pendingQualityChecks: [ - { - id: 'qc1', - name: 'Control de temperatura interna', - stage: 'baking', - isRequired: true, - isCritical: true, - status: 'pending', - checkType: 'temperature' - } - ], - completedQualityChecks: [ - { - id: 'qc2', - name: 'Inspección visual de masa', - stage: 'mixing', - isRequired: true, - isCritical: false, - status: 'completed', - checkType: 'visual' - } - ] - } - }, - { - id: '2', - product: 'Croissants de Mantequilla', - quantity: 50, - unit: 'unidades', - priority: 'high', - status: 'pending', - startTime: '07:30', - estimatedDuration: 240, - assignedBaker: 'Carlos Rodríguez', - ovenNumber: 2, - temperature: 200, - progress: 0, - recipe: 'Croissant Francés', - notes: 'Masa preparada ayer, lista para horneado', - ingredients: [ - { name: 'Masa de croissant', quantity: 3, unit: 'kg', available: true }, - { name: 'Mantequilla', quantity: 1, unit: 'kg', available: false }, - { name: 'Huevo', quantity: 6, unit: 'unidades', available: true } - ], - processStage: { - current: 'shaping', - history: [ - { stage: 'proofing', timestamp: '07:30', duration: 120 } - ], - pendingQualityChecks: [ - { - id: 'qc3', - name: 'Verificar formado de hojaldre', - stage: 'shaping', - isRequired: true, - isCritical: false, - status: 'pending', - checkType: 'visual' - }, - { - id: 'qc4', - name: 'Control de peso individual', - stage: 'shaping', - isRequired: false, - isCritical: false, - status: 'pending', - checkType: 'weight' - } - ], - completedQualityChecks: [] - } - }, - { - id: '3', - product: 'Baguettes Tradicionales', - quantity: 30, - unit: 'unidades', - priority: 'medium', - status: 'completed', - startTime: '05:00', - estimatedDuration: 240, - assignedBaker: 'Ana Martín', - ovenNumber: 3, - temperature: 240, - progress: 100, - recipe: 'Baguette Francesa', - ingredients: [ - { name: 'Harina blanca', quantity: 4, unit: 'kg', available: true }, - { name: 'Levadura', quantity: 0.3, unit: 'kg', available: true }, - { name: 'Sal', quantity: 0.15, unit: 'kg', available: true }, - { name: 'Agua', quantity: 2.5, unit: 'L', available: true } - ], - processStage: { - current: 'finishing', - history: [ - { stage: 'mixing', timestamp: '05:00', duration: 20 }, - { stage: 'proofing', timestamp: '05:20', duration: 120 }, - { stage: 'shaping', timestamp: '07:20', duration: 30 }, - { stage: 'baking', timestamp: '07:50', duration: 45 }, - { stage: 'cooling', timestamp: '08:35', duration: 30 }, - { stage: 'finishing', timestamp: '09:05' } - ], - pendingQualityChecks: [], - completedQualityChecks: [ - { - id: 'qc5', - name: 'Inspección visual final', - stage: 'finishing', - isRequired: true, - isCritical: false, - status: 'completed', - checkType: 'visual' - }, - { - id: 'qc6', - name: 'Control de temperatura de cocción', - stage: 'baking', - isRequired: true, - isCritical: true, - status: 'completed', - checkType: 'temperature' - } - ] - } - }, - { - id: '4', - product: 'Magdalenas de Vainilla', - quantity: 100, - unit: 'unidades', - priority: 'medium', - status: 'delayed', - startTime: '09:00', - estimatedDuration: 90, - assignedBaker: 'Luis Fernández', - ovenNumber: 4, - temperature: 180, - progress: 0, - recipe: 'Magdalenas Clásicas', - notes: 'Retraso por falta de moldes', - ingredients: [ - { name: 'Harina', quantity: 2, unit: 'kg', available: true }, - { name: 'Azúcar', quantity: 1.5, unit: 'kg', available: true }, - { name: 'Huevos', quantity: 24, unit: 'unidades', available: true }, - { name: 'Mantequilla', quantity: 1, unit: 'kg', available: false }, - { name: 'Vainilla', quantity: 50, unit: 'ml', available: true } - ], - processStage: { - current: 'mixing', - history: [], - pendingQualityChecks: [ - { - id: 'qc7', - name: 'Verificar consistencia de masa', - stage: 'mixing', - isRequired: true, - isCritical: false, - status: 'pending', - checkType: 'visual' - } - ], - completedQualityChecks: [] - } - } - ]; - - const displayOrders = orders.length > 0 ? orders : defaultOrders; - - const getOrderStatusConfig = (order: ProductionOrder) => { - const baseConfig = { - isCritical: order.status === 'delayed' || order.priority === 'urgent', - isHighlight: order.status === 'in_progress' || order.priority === 'high', - }; - - switch (order.status) { - case 'pending': - return { - ...baseConfig, - color: 'var(--color-warning)', - text: 'Pendiente', - icon: Clock - }; - case 'in_progress': - return { - ...baseConfig, - color: 'var(--color-info)', - text: 'En Proceso', - icon: Play - }; - case 'completed': - return { - ...baseConfig, - color: 'var(--color-success)', - text: 'Completado', - icon: CheckCircle - }; - case 'paused': - return { - ...baseConfig, - color: 'var(--color-warning)', - text: 'Pausado', - icon: Pause - }; - case 'delayed': - return { - ...baseConfig, - color: 'var(--color-error)', - text: 'Retrasado', - icon: AlertTriangle - }; - default: - return { - ...baseConfig, - color: 'var(--color-warning)', - text: 'Pendiente', - icon: Clock - }; - } - }; - - const formatDuration = (minutes: number) => { - const hours = Math.floor(minutes / 60); - const mins = minutes % 60; - if (hours > 0) { - return `${hours}h ${mins}m`; - } - return `${mins}m`; - }; - - const inProgressOrders = displayOrders.filter(order => order.status === 'in_progress').length; - const completedOrders = displayOrders.filter(order => order.status === 'completed').length; - const delayedOrders = displayOrders.filter(order => order.status === 'delayed').length; - - // Cross-batch quality overview calculations - const totalPendingQualityChecks = displayOrders.reduce((total, order) => - total + (order.processStage?.pendingQualityChecks.length || 0), 0); - const criticalPendingQualityChecks = displayOrders.reduce((total, order) => - total + (order.processStage?.pendingQualityChecks.filter(qc => qc.isCritical).length || 0), 0); - const ordersBlockedByQuality = displayOrders.filter(order => - order.processStage?.pendingQualityChecks.some(qc => qc.isCritical && qc.isRequired) || false).length; - - // Helper function to create enhanced metadata with process stage info - const createEnhancedMetadata = (order: ProductionOrder) => { - const baseMetadata = [ - `⏰ Inicio: ${order.startTime}`, - `⏱️ Duración: ${formatDuration(order.estimatedDuration)}`, - ...(order.ovenNumber ? [`🔥 Horno ${order.ovenNumber} - ${order.temperature}°C`] : []) - ]; - - if (order.processStage) { - const { current, pendingQualityChecks, completedQualityChecks } = order.processStage; - const currentStageIcon = getProcessStageIcon(current); - const currentStageLabel = getProcessStageLabel(current); - - // Add current stage info - baseMetadata.push(`🔄 Etapa: ${currentStageLabel}`); - - // Add quality check info - if (pendingQualityChecks.length > 0) { - const criticalPending = pendingQualityChecks.filter(qc => qc.isCritical).length; - const requiredPending = pendingQualityChecks.filter(qc => qc.isRequired).length; - - if (criticalPending > 0) { - baseMetadata.push(`🚨 ${criticalPending} controles críticos pendientes`); - } else if (requiredPending > 0) { - baseMetadata.push(`✅ ${requiredPending} controles requeridos pendientes`); - } else { - baseMetadata.push(`📋 ${pendingQualityChecks.length} controles opcionales pendientes`); - } - } - - if (completedQualityChecks.length > 0) { - baseMetadata.push(`✅ ${completedQualityChecks.length} controles completados`); - } - } - - // Add ingredients info - const availableIngredients = order.ingredients.filter(ing => ing.available).length; - const totalIngredients = order.ingredients.length; - const ingredientsReady = availableIngredients === totalIngredients; - baseMetadata.push(`📋 Ingredientes: ${availableIngredients}/${totalIngredients} ${ingredientsReady ? '✅' : '❌'}`); - - // Add notes if any - if (order.notes) { - baseMetadata.push(`📝 ${order.notes}`); - } - - return baseMetadata; - }; - - return ( - - -
-
-
- -
-
-

- Planes de Producción - Hoy -

-

- Gestiona la producción programada para hoy -

-
-
- -
- {ordersBlockedByQuality > 0 && ( - - 🚨 {ordersBlockedByQuality} bloqueadas por calidad - - )} - {criticalPendingQualityChecks > 0 && ordersBlockedByQuality === 0 && ( - - 🔍 {criticalPendingQualityChecks} controles críticos - - )} - {totalPendingQualityChecks > 0 && criticalPendingQualityChecks === 0 && ( - - 📋 {totalPendingQualityChecks} controles pendientes - - )} - {delayedOrders > 0 && ( - - {delayedOrders} retrasadas - - )} - {inProgressOrders > 0 && ( - - {inProgressOrders} activas - - )} - - {completedOrders} completadas - -
-
-
- - - {displayOrders.length === 0 ? ( -
-
- -
-

- No hay producción programada -

-

- Día libre de producción -

-
- ) : ( -
- {displayOrders.map((order) => { - const statusConfig = getOrderStatusConfig(order); - const enhancedMetadata = createEnhancedMetadata(order); - - // Enhanced secondary info that includes stage information - const getSecondaryInfo = () => { - if (order.processStage) { - const currentStageLabel = getProcessStageLabel(order.processStage.current); - return { - label: 'Etapa actual', - value: `${currentStageLabel} • ${order.assignedBaker}` - }; - } - return { - label: 'Panadero asignado', - value: order.assignedBaker - }; - }; - - // Enhanced status indicator with process stage color for active orders - const getEnhancedStatusConfig = () => { - if (order.processStage && order.status === 'in_progress') { - return { - ...statusConfig, - color: getProcessStageColor(order.processStage.current) - }; - } - return statusConfig; - }; - - return ( - 70 ? 'var(--color-info)' : - order.progress > 30 ? 'var(--color-warning)' : 'var(--color-error)' - } : undefined} - metadata={enhancedMetadata} - actions={[ - ...(order.status === 'pending' ? [{ - label: 'Iniciar', - icon: Play, - variant: 'primary' as const, - onClick: () => onStartOrder?.(order.id), - priority: 'primary' as const - }] : []), - ...(order.status === 'in_progress' ? [{ - label: 'Pausar', - icon: Pause, - variant: 'outline' as const, - onClick: () => onPauseOrder?.(order.id), - priority: 'primary' as const, - destructive: true - }] : []), - // Add quality check action if there are pending quality checks - ...(order.processStage?.pendingQualityChecks.length > 0 ? [{ - label: `${order.processStage.pendingQualityChecks.filter(qc => qc.isCritical).length > 0 ? '🚨 ' : ''}Controles Calidad`, - icon: FlaskRound, - variant: order.processStage.pendingQualityChecks.filter(qc => qc.isCritical).length > 0 ? 'primary' as const : 'outline' as const, - onClick: () => onViewDetails?.(order.id), // This would open the quality check modal - priority: order.processStage.pendingQualityChecks.filter(qc => qc.isCritical).length > 0 ? 'primary' as const : 'secondary' as const - }] : []), - // Add next stage action for orders that can progress - ...(order.status === 'in_progress' && order.processStage && order.processStage.pendingQualityChecks.length === 0 ? [{ - label: 'Siguiente Etapa', - icon: ArrowRight, - variant: 'primary' as const, - onClick: () => console.log(`Advancing stage for order ${order.id}`), - priority: 'primary' as const - }] : []), - { - label: 'Ver Detalles', - icon: ChevronRight, - variant: 'outline' as const, - onClick: () => onViewDetails?.(order.id), - priority: 'secondary' as const - } - ]} - compact={true} - className="border-l-4" - /> - ); - })} -
- )} - - {displayOrders.length > 0 && ( -
-
-
- - {completedOrders} de {displayOrders.length} órdenes completadas - -
- - -
-
- )} -
-
- ); -}; - -export default ProductionPlansToday; \ No newline at end of file diff --git a/frontend/src/components/domain/dashboard/PurchaseOrdersTracking.tsx b/frontend/src/components/domain/dashboard/PurchaseOrdersTracking.tsx deleted file mode 100644 index ce3eec27..00000000 --- a/frontend/src/components/domain/dashboard/PurchaseOrdersTracking.tsx +++ /dev/null @@ -1,210 +0,0 @@ -import React from 'react'; -import { useNavigate } from 'react-router-dom'; -import { Card, CardHeader, CardBody } from '../../ui/Card'; -import { Button } from '../../ui/Button'; -import { - FileText, - CheckCircle, - Clock, - Truck, - AlertCircle, - ChevronRight, - Euro, - Calendar, - Package -} from 'lucide-react'; -import { useProcurementDashboard } from '../../../api/hooks/orders'; -import { useCurrentTenant } from '../../../stores/tenant.store'; - -const PurchaseOrdersTracking: React.FC = () => { - const navigate = useNavigate(); - const currentTenant = useCurrentTenant(); - const tenantId = currentTenant?.id || ''; - - const { data: dashboard, isLoading } = useProcurementDashboard(tenantId); - - const getStatusIcon = (status: string) => { - switch (status) { - case 'draft': - return ; - case 'pending_approval': - return ; - case 'approved': - return ; - case 'in_execution': - return ; - case 'completed': - return ; - default: - return ; - } - }; - - const getStatusColor = (status: string) => { - switch (status) { - case 'draft': - return 'text-[var(--text-tertiary)] bg-[var(--bg-tertiary)]'; - case 'pending_approval': - return 'text-yellow-700 bg-yellow-100'; - case 'approved': - return 'text-green-700 bg-green-100'; - case 'in_execution': - return 'text-blue-700 bg-blue-100'; - case 'completed': - return 'text-green-700 bg-green-100'; - case 'cancelled': - return 'text-red-700 bg-red-100'; - default: - return 'text-[var(--text-secondary)] bg-[var(--bg-secondary)]'; - } - }; - - const getStatusLabel = (status: string) => { - const labels: Record = { - draft: 'Borrador', - pending_approval: 'Pendiente Aprobación', - approved: 'Aprobado', - in_execution: 'En Ejecución', - completed: 'Completado', - cancelled: 'Cancelado' - }; - return labels[status] || status; - }; - - const handleViewAllPOs = () => { - navigate('/app/operations/procurement'); - }; - - const handleViewPODetails = (planId: string) => { - navigate(`/app/operations/procurement?plan=${planId}`); - }; - - if (isLoading) { - return ( - - -
-
- -

Órdenes de Compra

-
-
-

Seguimiento de órdenes de compra

-
- -
-
-
-
-
- ); - } - - const recentPlans = dashboard?.recent_plans || []; - - return ( - - -
-
- -

Órdenes de Compra

-
- -
-

Seguimiento de órdenes de compra

-
- - {recentPlans.length === 0 ? ( -
- -

No hay órdenes de compra recientes

- -
- ) : ( -
- {recentPlans.slice(0, 5).map((plan: any) => ( -
handleViewPODetails(plan.id)} - > -
-
- {getStatusIcon(plan.status)} -
-
-
- - {plan.plan_number} - - - {getStatusLabel(plan.status)} - -
-
-
- - {new Date(plan.plan_date).toLocaleDateString('es-ES')} -
-
- - {plan.total_requirements} items -
-
- - €{plan.total_estimated_cost?.toFixed(2) || '0.00'} -
-
-
-
- -
- ))} -
- )} - - {/* Summary Stats */} - {dashboard?.stats && ( -
-
-
- {dashboard.stats.total_plans || 0} -
-
Total Planes
-
-
-
- {dashboard.stats.approved_plans || 0} -
-
Aprobados
-
-
-
- {dashboard.stats.pending_plans || 0} -
-
Pendientes
-
-
- )} -
-
- ); -}; - -export default PurchaseOrdersTracking; diff --git a/frontend/src/components/domain/dashboard/RealTimeAlerts.tsx b/frontend/src/components/domain/dashboard/RealTimeAlerts.tsx index 866bab90..6963650f 100644 --- a/frontend/src/components/domain/dashboard/RealTimeAlerts.tsx +++ b/frontend/src/components/domain/dashboard/RealTimeAlerts.tsx @@ -1,165 +1,288 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useMemo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { Card, CardHeader, CardBody } from '../../ui/Card'; import { Badge } from '../../ui/Badge'; import { Button } from '../../ui/Button'; import { useNotifications } from '../../../hooks/useNotifications'; +import { useAlertFilters } from '../../../hooks/useAlertFilters'; +import { useAlertGrouping, type GroupingMode } from '../../../hooks/useAlertGrouping'; +import { useAlertAnalytics, useAlertAnalyticsTracking } from '../../../hooks/useAlertAnalytics'; +import { useKeyboardNavigation } from '../../../hooks/useKeyboardNavigation'; +import { filterAlerts, getAlertStatistics, getTimeGroup } from '../../../utils/alertHelpers'; import { - AlertTriangle, - AlertCircle, - Info, - CheckCircle, - Clock, - X, + Bell, Wifi, WifiOff, - Bell, - ChevronDown, - ChevronUp, - Check, - Trash2 + CheckCircle, + BarChart3, + AlertTriangle, + AlertCircle, + Clock, } from 'lucide-react'; - -export interface Alert { - id: string; - item_type: 'alert' | 'recommendation'; - type: string; - severity: 'urgent' | 'high' | 'medium' | 'low'; - title: string; - message: string; - timestamp: string; - actions?: string[]; - metadata?: any; - status?: 'active' | 'resolved' | 'acknowledged'; -} +import AlertFilters from './AlertFilters'; +import AlertGroupHeader from './AlertGroupHeader'; +import AlertCard from './AlertCard'; +import AlertTrends from './AlertTrends'; +import AlertBulkActions from './AlertBulkActions'; export interface RealTimeAlertsProps { className?: string; maxAlerts?: number; + showAnalytics?: boolean; + showGrouping?: boolean; } +/** + * RealTimeAlerts - Dashboard component for displaying today's active alerts + * + * IMPORTANT: This component shows ONLY TODAY'S alerts (from 00:00 UTC today onwards) + * to prevent flooding the dashboard with historical data. + * + * For historical alert data, use the Analytics panel or API endpoints: + * - showAnalytics=true: Shows AlertTrends component with historical data (7 days, 30 days, etc.) + * - API: /api/v1/tenants/{tenant_id}/alerts/analytics for historical analytics + * + * Alert scopes across the application: + * - Dashboard (this component): TODAY'S alerts only + * - Notification Bell: Last 24 hours + * - Analytics Panel: Historical data (configurable: 7 days, 30 days, etc.) + * - localStorage: Auto-cleanup of alerts >24h old on load + * - Redis cache (initial_items): TODAY'S alerts only + */ const RealTimeAlerts: React.FC = ({ className, - maxAlerts = 10 + maxAlerts = 50, + showAnalytics = true, + showGrouping = true, }) => { const { t } = useTranslation(['dashboard']); - const [expandedAlert, setExpandedAlert] = useState(null); + const [expandedAlerts, setExpandedAlerts] = useState>(new Set()); + const [selectedAlerts, setSelectedAlerts] = useState>(new Set()); + const [showBulkActions, setShowBulkActions] = useState(false); + const [showAnalyticsPanel, setShowAnalyticsPanel] = useState(false); - const { notifications, isConnected, markAsRead, removeNotification } = useNotifications(); + const { + notifications, + isConnected, + markAsRead, + removeNotification, + snoozeAlert, + unsnoozeAlert, + isAlertSnoozed, + snoozedAlerts, + markMultipleAsRead, + removeMultiple, + snoozeMultiple, + } = useNotifications(); - // Convert notifications to alerts format and limit them - const alerts = notifications.slice(0, maxAlerts).map(notification => ({ - id: notification.id, - item_type: notification.item_type, - type: notification.item_type, // Use item_type as type - severity: notification.severity, - title: notification.title, - message: notification.message, - timestamp: notification.timestamp, - status: notification.read ? 'acknowledged' as const : 'active' as const, - })); + const { + filters, + toggleSeverity, + toggleCategory, + setTimeRange, + setSearch, + toggleShowSnoozed, + clearFilters, + hasActiveFilters, + activeFilterCount, + } = useAlertFilters(); - const getSeverityIcon = (severity: string) => { - switch (severity) { - case 'urgent': - return AlertTriangle; - case 'high': - return AlertCircle; - case 'medium': - return Info; - case 'low': - return CheckCircle; - default: - return Info; - } - }; + // Dashboard shows only TODAY's alerts + // Analytics panel shows historical data (configured separately) + const filteredNotifications = useMemo(() => { + // Filter to today's alerts only for dashboard display + // This prevents showing yesterday's or older alerts on the main dashboard + const todayAlerts = notifications.filter(alert => { + const timeGroup = getTimeGroup(alert.timestamp); + return timeGroup === 'today'; + }); - const getSeverityColor = (severity: string) => { - switch (severity) { - case 'urgent': - return 'var(--color-error)'; - case 'high': - return 'var(--color-warning)'; - case 'medium': - return 'var(--color-info)'; - case 'low': - return 'var(--color-success)'; - default: - return 'var(--color-info)'; - } - }; + return filterAlerts(todayAlerts, filters, snoozedAlerts).slice(0, maxAlerts); + }, [notifications, filters, snoozedAlerts, maxAlerts]); - const getSeverityBadge = (severity: string) => { - switch (severity) { - case 'urgent': - return 'error'; - case 'high': - return 'warning'; - case 'medium': - return 'info'; - case 'low': - return 'success'; - default: - return 'info'; - } - }; + const { + groupedAlerts, + groupingMode, + setGroupingMode, + toggleGroupCollapse, + isGroupCollapsed, + } = useAlertGrouping(filteredNotifications, 'time'); - const formatTimestamp = (timestamp: string) => { - const date = new Date(timestamp); - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - const diffMins = Math.floor(diffMs / (1000 * 60)); + const analytics = useAlertAnalytics(notifications); + const { trackAcknowledgment, trackResolution } = useAlertAnalyticsTracking(); - if (diffMins < 1) return t('dashboard:alerts.time.now', 'Ahora'); - if (diffMins < 60) return t('dashboard:alerts.time.minutes_ago', 'hace {{count}} min', { count: diffMins }); - const diffHours = Math.floor(diffMins / 60); - if (diffHours < 24) return t('dashboard:alerts.time.hours_ago', 'hace {{count}} h', { count: diffHours }); - return date.toLocaleDateString() === new Date(now.getTime() - 24 * 60 * 60 * 1000).toLocaleDateString() - ? t('dashboard:alerts.time.yesterday', 'Ayer') - : date.toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit' }); - }; + const stats = useMemo(() => { + return getAlertStatistics(filteredNotifications, snoozedAlerts); + }, [filteredNotifications, snoozedAlerts]); - const toggleExpanded = (alertId: string) => { - setExpandedAlert(prev => prev === alertId ? null : alertId); - }; + const flatAlerts = useMemo(() => { + return groupedAlerts.flatMap(group => + isGroupCollapsed(group.id) ? [] : group.alerts + ); + }, [groupedAlerts, isGroupCollapsed]); - const handleMarkAsRead = (alertId: string) => { + const { focusedIndex } = useKeyboardNavigation( + flatAlerts.length, + { + onMoveUp: () => {}, + onMoveDown: () => {}, + onSelect: () => { + if (flatAlerts[focusedIndex]) { + toggleAlertSelection(flatAlerts[focusedIndex].id); + } + }, + onExpand: () => { + if (flatAlerts[focusedIndex]) { + toggleAlertExpansion(flatAlerts[focusedIndex].id); + } + }, + onMarkAsRead: () => { + if (flatAlerts[focusedIndex]) { + handleMarkAsRead(flatAlerts[focusedIndex].id); + } + }, + onDismiss: () => { + if (flatAlerts[focusedIndex]) { + handleRemoveAlert(flatAlerts[focusedIndex].id); + } + }, + onSnooze: () => { + if (flatAlerts[focusedIndex]) { + handleSnoozeAlert(flatAlerts[focusedIndex].id, '1hr'); + } + }, + onEscape: () => { + setExpandedAlerts(new Set()); + setSelectedAlerts(new Set()); + }, + onSelectAll: () => { + handleSelectAll(); + }, + onSearch: () => {}, + }, + true + ); + + const toggleAlertExpansion = useCallback((alertId: string) => { + setExpandedAlerts(prev => { + const next = new Set(prev); + if (next.has(alertId)) { + next.delete(alertId); + } else { + next.add(alertId); + } + return next; + }); + }, []); + + const toggleAlertSelection = useCallback((alertId: string) => { + setSelectedAlerts(prev => { + const next = new Set(prev); + if (next.has(alertId)) { + next.delete(alertId); + } else { + next.add(alertId); + } + return next; + }); + }, []); + + const handleMarkAsRead = useCallback((alertId: string) => { markAsRead(alertId); - }; + trackAcknowledgment(alertId).catch(err => + console.error('Failed to track acknowledgment:', err) + ); + }, [markAsRead, trackAcknowledgment]); - const handleRemoveAlert = (alertId: string) => { + const handleRemoveAlert = useCallback((alertId: string) => { removeNotification(alertId); - if (expandedAlert === alertId) { - setExpandedAlert(null); - } - }; + trackResolution(alertId).catch(err => + console.error('Failed to track resolution:', err) + ); + setExpandedAlerts(prev => { + const next = new Set(prev); + next.delete(alertId); + return next; + }); + setSelectedAlerts(prev => { + const next = new Set(prev); + next.delete(alertId); + return next; + }); + }, [removeNotification, trackResolution]); - const activeAlerts = alerts.filter(alert => alert.status === 'active'); - const urgentCount = activeAlerts.filter(alert => alert.severity === 'urgent').length; - const highCount = activeAlerts.filter(alert => alert.severity === 'high').length; + const handleSnoozeAlert = useCallback((alertId: string, duration: '15min' | '1hr' | '4hr' | 'tomorrow' | number) => { + snoozeAlert(alertId, duration); + }, [snoozeAlert]); + + const handleUnsnoozeAlert = useCallback((alertId: string) => { + unsnoozeAlert(alertId); + }, [unsnoozeAlert]); + + const handleSelectAll = useCallback(() => { + setSelectedAlerts(new Set(flatAlerts.map(a => a.id))); + setShowBulkActions(true); + }, [flatAlerts]); + + const handleDeselectAll = useCallback(() => { + setSelectedAlerts(new Set()); + setShowBulkActions(false); + }, []); + + const handleBulkMarkAsRead = useCallback(() => { + const ids = Array.from(selectedAlerts); + markMultipleAsRead(ids); + ids.forEach(id => + trackAcknowledgment(id).catch(err => + console.error('Failed to track acknowledgment:', err) + ) + ); + handleDeselectAll(); + }, [selectedAlerts, markMultipleAsRead, trackAcknowledgment, handleDeselectAll]); + + const handleBulkRemove = useCallback(() => { + const ids = Array.from(selectedAlerts); + removeMultiple(ids); + ids.forEach(id => + trackResolution(id).catch(err => + console.error('Failed to track resolution:', err) + ) + ); + handleDeselectAll(); + }, [selectedAlerts, removeMultiple, trackResolution, handleDeselectAll]); + + const handleBulkSnooze = useCallback((duration: '15min' | '1hr' | '4hr' | 'tomorrow' | number) => { + const ids = Array.from(selectedAlerts); + snoozeMultiple(ids, duration); + handleDeselectAll(); + }, [selectedAlerts, snoozeMultiple, handleDeselectAll]); + + const activeAlerts = filteredNotifications.filter(a => a.status !== 'acknowledged' && !isAlertSnoozed(a.id)); + const urgentCount = activeAlerts.filter(a => a.severity === 'urgent').length; + const highCount = activeAlerts.filter(a => a.severity === 'high').length; return ( - -
+ +
- +
-

+

{t('dashboard:alerts.title', 'Alertas')}

{isConnected ? ( - + ) : ( - + )} - + {isConnected ? t('dashboard:alerts.live', 'En vivo') : t('dashboard:alerts.offline', 'Desconectado') @@ -169,204 +292,181 @@ const RealTimeAlerts: React.FC = ({
-
- {urgentCount > 0 && ( - - {urgentCount} - +
+ {/* Alert count badges */} +
+ {urgentCount > 0 && ( + } + > + {urgentCount} Alto + + )} + {highCount > 0 && ( + } + > + {highCount} Medio + + )} +
+ + {/* Controls */} + {showAnalytics && ( + )} - {highCount > 0 && ( - - {highCount} - + + {showGrouping && ( + )}
- {activeAlerts.length === 0 ? ( -
- -

- {t('dashboard:alerts.no_alerts', 'No hay alertas activas')} -

-
- ) : ( -
- {activeAlerts.map((alert) => { - const isExpanded = expandedAlert === alert.id; - const SeverityIcon = getSeverityIcon(alert.severity); - - return ( -
- {/* Compact Card Header */} -
toggleExpanded(alert.id)} - > - {/* Severity Icon */} -
- -
- - {/* Alert Content */} -
- {/* Title and Timestamp Row */} -
-

- {alert.title} -

- - {formatTimestamp(alert.timestamp)} - -
- - {/* Badges Row */} -
- - {alert.severity.toUpperCase()} - - - {alert.item_type === 'alert' - ? `🚨 ${t('dashboard:alerts.types.alert', 'Alerta')}` - : `💡 ${t('dashboard:alerts.types.recommendation', 'Recomendación')}` - } - -
- - {/* Preview message when collapsed */} - {!isExpanded && ( -

- {alert.message} -

- )} -
- - {/* Expand/Collapse Button */} -
- {isExpanded ? ( - - ) : ( - - )} -
-
- - {/* Expanded Details */} - {isExpanded && ( -
- {/* Full Message */} -
-

- {alert.message} -

-
- - {/* Actions Section */} - {alert.actions && alert.actions.length > 0 && ( -
-

- {t('dashboard:alerts.recommended_actions', 'Acciones Recomendadas')} -

-
- {alert.actions.map((action, index) => ( -
- - • - - - {action} - -
- ))} -
-
- )} - - {/* Metadata */} - {alert.metadata && Object.keys(alert.metadata).length > 0 && ( -
-

- {t('dashboard:alerts.additional_details', 'Detalles Adicionales')} -

-
- {Object.entries(alert.metadata).map(([key, value]) => ( -
- {key.replace(/_/g, ' ')}: - {String(value)} -
- ))} -
-
- )} - - {/* Action Buttons */} -
- - -
-
- )} -
- ); - })} + {showAnalyticsPanel && ( +
+
)} - {activeAlerts.length > 0 && ( +
+ +
+ + {selectedAlerts.size > 0 && ( +
+ +
+ )} + + {filteredNotifications.length === 0 ? ( +
+
+ +
+

+ {hasActiveFilters ? 'Sin resultados' : 'Todo despejado'} +

+

+ {hasActiveFilters + ? 'No hay alertas que coincidan con los filtros seleccionados' + : t('dashboard:alerts.no_alerts', 'No hay alertas activas en este momento') + } +

+
+ ) : ( +
+ {groupedAlerts.map((group) => ( +
+ {(group.count > 1 || groupingMode !== 'none') && ( +
+ toggleGroupCollapse(group.id)} + /> +
+ )} + + {!isGroupCollapsed(group.id) && ( +
+ {group.alerts.map((alert) => ( + toggleAlertExpansion(alert.id)} + onToggleSelect={() => toggleAlertSelection(alert.id)} + onMarkAsRead={() => handleMarkAsRead(alert.id)} + onRemove={() => handleRemoveAlert(alert.id)} + onSnooze={(duration) => handleSnoozeAlert(alert.id, duration)} + onUnsnooze={() => handleUnsnoozeAlert(alert.id)} + showCheckbox={showBulkActions || selectedAlerts.size > 0} + /> + ))} +
+ )} +
+ ))} +
+ )} + + {filteredNotifications.length > 0 && (
-

- {t('dashboard:alerts.active_count', '{{count}} alertas activas', { count: activeAlerts.length })} -

+
+ + Mostrando {filteredNotifications.length} de {notifications.length} alertas + +
+ {stats.unread > 0 && ( + + + {stats.unread} sin leer + + )} + {stats.snoozed > 0 && ( + + + {stats.snoozed} pospuestas + + )} +
+
)} @@ -374,4 +474,4 @@ const RealTimeAlerts: React.FC = ({ ); }; -export default RealTimeAlerts; \ No newline at end of file +export default RealTimeAlerts; diff --git a/frontend/src/components/domain/dashboard/TodayProduction.tsx b/frontend/src/components/domain/dashboard/TodayProduction.tsx new file mode 100644 index 00000000..ef42f622 --- /dev/null +++ b/frontend/src/components/domain/dashboard/TodayProduction.tsx @@ -0,0 +1,413 @@ +import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Card, CardHeader, CardBody } from '../../ui/Card'; +import { StatusCard } from '../../ui/StatusCard/StatusCard'; +import { Badge } from '../../ui/Badge'; +import { Button } from '../../ui/Button'; +import { useCurrentTenant } from '../../../stores/tenant.store'; +import { useActiveBatches } from '../../../api/hooks/production'; +import { + Factory, + Clock, + Play, + Pause, + CheckCircle, + AlertTriangle, + ChevronRight, + Timer, + ChefHat, + Flame, + Calendar +} from 'lucide-react'; + +export interface TodayProductionProps { + className?: string; + maxBatches?: number; + onStartBatch?: (batchId: string) => void; + onPauseBatch?: (batchId: string) => void; + onViewDetails?: (batchId: string) => void; + onViewAllPlans?: () => void; +} + +const TodayProduction: React.FC = ({ + className, + maxBatches = 5, + onStartBatch, + onPauseBatch, + onViewDetails, + onViewAllPlans +}) => { + const { t } = useTranslation(['dashboard']); + const currentTenant = useCurrentTenant(); + const tenantId = currentTenant?.id || ''; + + // Get today's date + const todayDate = useMemo(() => { + return new Date().toISOString().split('T')[0]; + }, []); + + // Fetch active production batches + const { data: productionData, isLoading, error } = useActiveBatches( + tenantId, + { + enabled: !!tenantId, + } + ); + + const getBatchStatusConfig = (batch: any) => { + const baseConfig = { + isCritical: batch.status === 'FAILED' || batch.priority === 'URGENT', + isHighlight: batch.status === 'IN_PROGRESS' || batch.priority === 'HIGH', + }; + + switch (batch.status) { + case 'PENDING': + return { + ...baseConfig, + color: 'var(--color-warning)', + text: 'Pendiente', + icon: Clock + }; + case 'IN_PROGRESS': + return { + ...baseConfig, + color: 'var(--color-info)', + text: 'En Proceso', + icon: Flame + }; + case 'COMPLETED': + return { + ...baseConfig, + color: 'var(--color-success)', + text: 'Completado', + icon: CheckCircle + }; + case 'ON_HOLD': + return { + ...baseConfig, + color: 'var(--color-warning)', + text: 'Pausado', + icon: Pause + }; + case 'FAILED': + return { + ...baseConfig, + color: 'var(--color-error)', + text: 'Fallido', + icon: AlertTriangle + }; + case 'QUALITY_CHECK': + return { + ...baseConfig, + color: 'var(--color-info)', + text: 'Control de Calidad', + icon: CheckCircle + }; + default: + return { + ...baseConfig, + color: 'var(--color-warning)', + text: 'Pendiente', + icon: Clock + }; + } + }; + + const formatDuration = (minutes: number) => { + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + if (hours > 0) { + return `${hours}h ${mins}m`; + } + return `${mins}m`; + }; + + // Process batches and sort by priority + const displayBatches = useMemo(() => { + if (!productionData?.batches || !Array.isArray(productionData.batches)) return []; + + const batches = [...productionData.batches]; + + // Filter for today's batches only + const todayBatches = batches.filter(batch => { + const batchDate = new Date(batch.planned_start_time || batch.created_at); + return batchDate.toISOString().split('T')[0] === todayDate; + }); + + // Sort by priority and start time + const priorityOrder = { URGENT: 0, HIGH: 1, MEDIUM: 2, LOW: 3 }; + todayBatches.sort((a, b) => { + // First sort by status (pending/in_progress first) + const statusOrder = { PENDING: 0, IN_PROGRESS: 1, QUALITY_CHECK: 2, ON_HOLD: 3, COMPLETED: 4, FAILED: 5, CANCELLED: 6 }; + const aStatus = statusOrder[a.status as keyof typeof statusOrder] ?? 7; + const bStatus = statusOrder[b.status as keyof typeof statusOrder] ?? 7; + + if (aStatus !== bStatus) return aStatus - bStatus; + + // Then by priority + const aPriority = priorityOrder[a.priority as keyof typeof priorityOrder] ?? 4; + const bPriority = priorityOrder[b.priority as keyof typeof priorityOrder] ?? 4; + + if (aPriority !== bPriority) return aPriority - bPriority; + + // Finally by start time + const aTime = new Date(a.planned_start_time || a.created_at).getTime(); + const bTime = new Date(b.planned_start_time || b.created_at).getTime(); + return aTime - bTime; + }); + + return todayBatches.slice(0, maxBatches); + }, [productionData, todayDate, maxBatches]); + + const inProgressBatches = productionData?.batches?.filter( + b => b.status === 'IN_PROGRESS' + ).length || 0; + + const completedBatches = productionData?.batches?.filter( + b => b.status === 'COMPLETED' + ).length || 0; + + const delayedBatches = productionData?.batches?.filter( + b => b.status === 'FAILED' + ).length || 0; + + const pendingBatches = productionData?.batches?.filter( + b => b.status === 'PENDING' + ).length || 0; + + if (isLoading) { + return ( + + +
+
+ +
+
+

+ {t('dashboard:sections.production_today', 'Producción de Hoy')} +

+

+ {t('dashboard:production.title', '¿Qué necesito producir hoy?')} +

+
+
+
+ +
+
+
+
+
+ ); + } + + if (error) { + return ( + + +
+
+ +
+
+

+ {t('dashboard:sections.production_today', 'Producción de Hoy')} +

+

+ {t('dashboard:production.title', '¿Qué necesito producir hoy?')} +

+
+
+
+ +
+

+ {t('dashboard:messages.error_loading', 'Error al cargar los datos')} +

+
+
+
+ ); + } + + return ( + + +
+
+
+ +
+
+

+ {t('dashboard:sections.production_today', 'Producción de Hoy')} +

+

+ {t('dashboard:production.title', '¿Qué necesito producir hoy?')} +

+
+
+ +
+ {delayedBatches > 0 && ( + + {delayedBatches} retrasados + + )} + {inProgressBatches > 0 && ( + + {inProgressBatches} activos + + )} + {completedBatches > 0 && ( + + {completedBatches} completados + + )} +
+ + {new Date(todayDate).toLocaleDateString('es-ES')} +
+
+
+
+ + + {displayBatches.length === 0 ? ( +
+
+ +
+

+ {t('dashboard:production.empty', 'Sin producción programada para hoy')} +

+

+ No hay lotes programados para iniciar hoy +

+
+ ) : ( +
+ {displayBatches.map((batch) => { + const statusConfig = getBatchStatusConfig(batch); + + // Calculate progress based on status and time + let progress = 0; + if (batch.status === 'COMPLETED') { + progress = 100; + } else if (batch.status === 'IN_PROGRESS' && batch.actual_start_time && batch.planned_duration_minutes) { + const elapsed = Date.now() - new Date(batch.actual_start_time).getTime(); + const elapsedMinutes = elapsed / (1000 * 60); + progress = Math.min(Math.round((elapsedMinutes / batch.planned_duration_minutes) * 100), 99); + } else if (batch.status === 'QUALITY_CHECK') { + progress = 95; + } + + const startTime = batch.planned_start_time + ? new Date(batch.planned_start_time).toLocaleTimeString('es-ES', { + hour: '2-digit', + minute: '2-digit' + }) + : 'No programado'; + + const assignedStaff = batch.staff_assigned && batch.staff_assigned.length > 0 + ? batch.staff_assigned[0] + : 'Sin asignar'; + + return ( + 70 ? 'var(--color-info)' : + progress > 30 ? 'var(--color-warning)' : 'var(--color-error)' + } : undefined} + metadata={[ + `⏰ Inicio: ${startTime}`, + ...(batch.planned_duration_minutes ? [`⏱️ Duración: ${formatDuration(batch.planned_duration_minutes)}`] : []), + ...(batch.station_id ? [`🏭 Estación: ${batch.station_id}`] : []), + ...(batch.priority === 'URGENT' ? [`⚠️ URGENTE`] : []), + ...(batch.production_notes ? [`📋 ${batch.production_notes}`] : []) + ]} + actions={[ + ...(batch.status === 'PENDING' ? [{ + label: 'Iniciar', + icon: Play, + variant: 'primary' as const, + onClick: () => onStartBatch?.(batch.id), + priority: 'primary' as const + }] : []), + ...(batch.status === 'IN_PROGRESS' ? [{ + label: 'Pausar', + icon: Pause, + variant: 'outline' as const, + onClick: () => onPauseBatch?.(batch.id), + priority: 'primary' as const, + destructive: true + }] : []), + { + label: 'Ver Detalles', + icon: ChevronRight, + variant: 'outline' as const, + onClick: () => onViewDetails?.(batch.id), + priority: 'secondary' as const + } + ]} + compact={true} + className="border-l-4" + /> + ); + })} +
+ )} + + {displayBatches.length > 0 && ( +
+
+
+ + {pendingBatches} {t('dashboard:production.batches_pending', 'lotes pendientes')} de {productionData?.batches?.length || 0} total + +
+ + {onViewAllPlans && ( + + )} +
+
+ )} +
+
+ ); +}; + +export default TodayProduction; diff --git a/frontend/src/components/domain/dashboard/index.ts b/frontend/src/components/domain/dashboard/index.ts index b65c6fe9..a587adfd 100644 --- a/frontend/src/components/domain/dashboard/index.ts +++ b/frontend/src/components/domain/dashboard/index.ts @@ -2,9 +2,9 @@ // Existing dashboard components export { default as RealTimeAlerts } from './RealTimeAlerts'; -export { default as ProcurementPlansToday } from './ProcurementPlansToday'; -export { default as ProductionPlansToday } from './ProductionPlansToday'; -export { default as PurchaseOrdersTracking } from './PurchaseOrdersTracking'; +export { default as PendingPOApprovals } from './PendingPOApprovals'; +export { default as TodayProduction } from './TodayProduction'; +export { default as AlertTrends } from './AlertTrends'; // Production Management Dashboard Widgets export { default as ProductionCostMonitor } from './ProductionCostMonitor'; diff --git a/frontend/src/components/domain/pos/CreatePOSConfigModal.tsx b/frontend/src/components/domain/pos/CreatePOSConfigModal.tsx new file mode 100644 index 00000000..695a12d2 --- /dev/null +++ b/frontend/src/components/domain/pos/CreatePOSConfigModal.tsx @@ -0,0 +1,325 @@ +import React, { useState, useMemo } from 'react'; +import { Zap, Key, Settings as SettingsIcon, RefreshCw } from 'lucide-react'; +import { AddModal, AddModalSection, AddModalField } from '../../ui/AddModal/AddModal'; +import { posService } from '../../../api/services/pos'; +import { POSProviderConfig, POSSystem, POSEnvironment } from '../../../api/types/pos'; +import { useToast } from '../../../hooks/ui/useToast'; +import { statusColors } from '../../../styles/colors'; + +interface CreatePOSConfigModalProps { + isOpen: boolean; + onClose: () => void; + tenantId: string; + onSuccess?: () => void; + existingConfig?: any; // For edit mode + mode?: 'create' | 'edit'; +} + +/** + * CreatePOSConfigModal - Modal for creating/editing POS configurations + * Uses the standard AddModal component for consistency across the application + */ +export const CreatePOSConfigModal: React.FC = ({ + isOpen, + onClose, + tenantId, + onSuccess, + existingConfig, + mode = 'create' +}) => { + const [loading, setLoading] = useState(false); + const [selectedProvider, setSelectedProvider] = useState(''); + const { addToast } = useToast(); + + // Supported POS providers configuration + const supportedProviders: POSProviderConfig[] = [ + { + id: 'toast', + name: 'Toast POS', + logo: '🍞', + description: 'Sistema POS líder para restaurantes y panaderías. Muy popular en España.', + features: ['Gestión de pedidos', 'Sincronización de inventario', 'Pagos integrados', 'Reportes en tiempo real'], + required_fields: [ + { field: 'api_key', label: 'API Key', type: 'password', required: true, help_text: 'Obten tu API key desde Toast Dashboard > Settings > Integrations' }, + { field: 'restaurant_guid', label: 'Restaurant GUID', type: 'text', required: true, help_text: 'ID único del restaurante en Toast' }, + { field: 'location_id', label: 'Location ID', type: 'text', required: true, help_text: 'ID de la ubicación específica' }, + { field: 'environment', label: 'Entorno', type: 'select', required: true, options: [ + { value: 'sandbox', label: 'Sandbox (Pruebas)' }, + { value: 'production', label: 'Producción' } + ]}, + ], + }, + { + id: 'square', + name: 'Square POS', + logo: '⬜', + description: 'Solución POS completa con tarifas transparentes. Ampliamente utilizada por pequeñas empresas.', + features: ['Procesamiento de pagos', 'Gestión de inventario', 'Análisis de ventas', 'Integración con e-commerce'], + required_fields: [ + { field: 'application_id', label: 'Application ID', type: 'text', required: true, help_text: 'ID de aplicación de Square Developer Dashboard' }, + { field: 'access_token', label: 'Access Token', type: 'password', required: true, help_text: 'Token de acceso para la API de Square' }, + { field: 'location_id', label: 'Location ID', type: 'text', required: true, help_text: 'ID de la ubicación de Square' }, + { field: 'webhook_signature_key', label: 'Webhook Signature Key', type: 'password', required: false, help_text: 'Clave para verificar webhooks (opcional)' }, + { field: 'environment', label: 'Entorno', type: 'select', required: true, options: [ + { value: 'sandbox', label: 'Sandbox (Pruebas)' }, + { value: 'production', label: 'Producción' } + ]}, + ], + }, + { + id: 'lightspeed', + name: 'Lightspeed POS', + logo: '⚡', + description: 'Sistema POS empresarial con API abierta e integración con múltiples herramientas.', + features: ['API REST completa', 'Gestión multi-ubicación', 'Reportes avanzados', 'Integración con contabilidad'], + required_fields: [ + { field: 'api_key', label: 'API Key', type: 'password', required: true, help_text: 'Clave API de Lightspeed Retail' }, + { field: 'api_secret', label: 'API Secret', type: 'password', required: true, help_text: 'Secreto API de Lightspeed Retail' }, + { field: 'account_id', label: 'Account ID', type: 'text', required: true, help_text: 'ID de cuenta de Lightspeed' }, + { field: 'shop_id', label: 'Shop ID', type: 'text', required: true, help_text: 'ID de la tienda específica' }, + { field: 'server_region', label: 'Región del Servidor', type: 'select', required: true, options: [ + { value: 'eu', label: 'Europa' }, + { value: 'us', label: 'Estados Unidos' }, + { value: 'ca', label: 'Canadá' } + ]}, + ], + }, + ]; + + // Initialize from existing config in edit mode + const initialData = useMemo(() => { + if (mode === 'edit' && existingConfig) { + // Extract credentials from existing config + const credentials: Record = {}; + const provider = supportedProviders.find(p => p.id === existingConfig.pos_system); + + if (provider && existingConfig.provider_settings) { + provider.required_fields.forEach(field => { + if (existingConfig.provider_settings[field.field]) { + credentials[`credential_${field.field}`] = existingConfig.provider_settings[field.field]; + } + }); + } + + return { + provider: existingConfig.pos_system || '', + config_name: existingConfig.provider_name || '', + auto_sync_enabled: existingConfig.sync_enabled ?? true, + sync_interval_minutes: existingConfig.sync_interval_minutes || '5', + sync_sales: existingConfig.auto_sync_transactions ?? true, + sync_inventory: existingConfig.auto_sync_products ?? true, + ...credentials + }; + } + return { + provider: '', + config_name: '', + auto_sync_enabled: true, + sync_interval_minutes: '5', + sync_sales: true, + sync_inventory: true, + }; + }, [mode, existingConfig, supportedProviders]); + + // Build dynamic sections based on selected provider + const sections: AddModalSection[] = useMemo(() => { + const baseSections: AddModalSection[] = [ + { + title: 'Información del Proveedor', + icon: Zap, + columns: 1, + fields: [ + { + label: 'Sistema POS', + name: 'provider', + type: 'select', + required: true, + placeholder: 'Selecciona un sistema POS', + options: supportedProviders.map(provider => ({ + value: provider.id, + label: `${provider.logo} ${provider.name}` + })), + span: 2 + }, + { + label: 'Nombre de la Configuración', + name: 'config_name', + type: 'text', + required: true, + placeholder: 'Ej: Mi Square POS 2025', + helpText: 'Un nombre descriptivo para identificar esta configuración', + span: 2 + } + ] + } + ]; + + // Add credentials section if provider is selected + const provider = supportedProviders.find(p => p.id === selectedProvider); + if (provider) { + const credentialFields: AddModalField[] = provider.required_fields.map(field => ({ + label: field.label, + name: `credential_${field.field}`, + type: field.type === 'select' ? 'select' : (field.type === 'password' ? 'text' : field.type), + required: field.required, + placeholder: field.placeholder || `Ingresa ${field.label}`, + helpText: field.help_text, + options: field.options, + span: field.type === 'select' ? 2 : 1 + })); + + baseSections.push({ + title: 'Credenciales de API', + icon: Key, + columns: 2, + fields: credentialFields + }); + } + + // Add sync settings section + baseSections.push({ + title: 'Configuración de Sincronización', + icon: RefreshCw, + columns: 2, + fields: [ + { + label: 'Sincronización Automática', + name: 'auto_sync_enabled', + type: 'select', + required: true, + options: [ + { value: 'true', label: 'Activada' }, + { value: 'false', label: 'Desactivada' } + ], + defaultValue: 'true' + }, + { + label: 'Intervalo de Sincronización', + name: 'sync_interval_minutes', + type: 'select', + required: true, + options: [ + { value: '5', label: '5 minutos' }, + { value: '15', label: '15 minutos' }, + { value: '30', label: '30 minutos' }, + { value: '60', label: '1 hora' } + ], + defaultValue: '5' + }, + { + label: 'Sincronizar Ventas', + name: 'sync_sales', + type: 'select', + required: true, + options: [ + { value: 'true', label: 'Sí' }, + { value: 'false', label: 'No' } + ], + defaultValue: 'true' + }, + { + label: 'Sincronizar Inventario', + name: 'sync_inventory', + type: 'select', + required: true, + options: [ + { value: 'true', label: 'Sí' }, + { value: 'false', label: 'No' } + ], + defaultValue: 'true' + } + ] + }); + + return baseSections; + }, [selectedProvider, supportedProviders]); + + const handleSave = async (formData: Record) => { + try { + setLoading(true); + + // Find selected provider + const provider = supportedProviders.find(p => p.id === formData.provider); + if (!provider) { + addToast('Por favor selecciona un sistema POS', { type: 'error' }); + return; + } + + // Extract credentials + const credentials: Record = {}; + provider.required_fields.forEach(field => { + const credKey = `credential_${field.field}`; + if (formData[credKey]) { + credentials[field.field] = formData[credKey]; + } + }); + + // Build request payload + const payload = { + tenant_id: tenantId, + provider: formData.provider, + config_name: formData.config_name, + credentials, + sync_settings: { + auto_sync_enabled: formData.auto_sync_enabled === 'true' || formData.auto_sync_enabled === true, + sync_interval_minutes: parseInt(formData.sync_interval_minutes), + sync_sales: formData.sync_sales === 'true' || formData.sync_sales === true, + sync_inventory: formData.sync_inventory === 'true' || formData.sync_inventory === true, + sync_customers: false + } + }; + + // Create or update configuration + if (mode === 'edit' && existingConfig) { + await posService.updatePOSConfiguration({ + ...payload, + config_id: existingConfig.id + }); + addToast('Configuración actualizada correctamente', { type: 'success' }); + } else { + await posService.createPOSConfiguration(payload); + addToast('Configuración creada correctamente', { type: 'success' }); + } + + onSuccess?.(); + onClose(); + } catch (error: any) { + console.error('Error saving POS configuration:', error); + addToast(error?.message || 'Error al guardar la configuración', { type: 'error' }); + throw error; // Let AddModal handle error state + } finally { + setLoading(false); + } + }; + + return ( + { + // Custom validation if needed + if (errors && Object.keys(errors).length > 0) { + const firstError = Object.values(errors)[0]; + addToast(firstError, { type: 'error' }); + } + }} + /> + ); +}; + +export default CreatePOSConfigModal; diff --git a/frontend/src/components/domain/pos/POSCart.tsx b/frontend/src/components/domain/pos/POSCart.tsx new file mode 100644 index 00000000..de52c635 --- /dev/null +++ b/frontend/src/components/domain/pos/POSCart.tsx @@ -0,0 +1,177 @@ +import React from 'react'; +import { ShoppingCart, Plus, Minus, Trash2, X } from 'lucide-react'; +import { Card } from '../../ui/Card'; +import { Button } from '../../ui/Button'; + +interface CartItem { + id: string; + name: string; + price: number; + quantity: number; + category: string; + stock: number; +} + +interface POSCartProps { + cart: CartItem[]; + onUpdateQuantity: (id: string, quantity: number) => void; + onClearCart: () => void; + taxRate?: number; +} + +/** + * POSCart - Fixed sidebar cart component with clear totals and item management + * Optimized for quick checkout operations + */ +export const POSCart: React.FC = ({ + cart, + onUpdateQuantity, + onClearCart, + taxRate = 0.21, // 21% IVA by default +}) => { + // Calculate totals + const subtotal = cart.reduce((sum, item) => sum + item.price * item.quantity, 0); + const tax = subtotal * taxRate; + const total = subtotal + tax; + const itemCount = cart.reduce((sum, item) => sum + item.quantity, 0); + + return ( +
+ {/* Cart Header */} +
+

+ + Carrito ({itemCount}) +

+ {cart.length > 0 && ( + + )} +
+ + {/* Cart Items */} +
+ {cart.length === 0 ? ( +
+ +

Carrito vacío

+

+ Agrega productos para comenzar +

+
+ ) : ( + cart.map((item) => ( + +
+ {/* Item Info */} +
+

+ {item.name} +

+
+ + €{item.price.toFixed(2)} + + c/u +
+

+ Stock: {item.stock} +

+
+ + {/* Remove Button */} + +
+ + {/* Quantity Controls */} +
+
+ + + {item.quantity} + + +
+ + {/* Item Subtotal */} +
+

+ €{(item.price * item.quantity).toFixed(2)} +

+
+
+
+ )) + )} +
+ + {/* Cart Totals */} + {cart.length > 0 && ( + +
+ {/* Subtotal */} +
+ Subtotal: + + €{subtotal.toFixed(2)} + +
+ + {/* Tax */} +
+ IVA ({(taxRate * 100).toFixed(0)}%): + + €{tax.toFixed(2)} + +
+ + {/* Divider */} +
+ {/* Total */} +
+ TOTAL: + + €{total.toFixed(2)} + +
+
+
+
+ )} +
+ ); +}; + +export default POSCart; diff --git a/frontend/src/components/domain/pos/POSPayment.tsx b/frontend/src/components/domain/pos/POSPayment.tsx new file mode 100644 index 00000000..5ccfeac0 --- /dev/null +++ b/frontend/src/components/domain/pos/POSPayment.tsx @@ -0,0 +1,256 @@ +import React, { useState } from 'react'; +import { CreditCard, Banknote, ArrowRightLeft, Receipt, User } from 'lucide-react'; +import { Card } from '../../ui/Card'; +import { Button } from '../../ui/Button'; +import { Input } from '../../ui/Input'; + +interface CustomerInfo { + name: string; + email: string; + phone: string; +} + +interface POSPaymentProps { + total: number; + onProcessPayment: (paymentData: { + paymentMethod: 'cash' | 'card' | 'transfer'; + cashReceived?: number; + change?: number; + customerInfo?: CustomerInfo; + }) => void; + disabled?: boolean; +} + +/** + * POSPayment - Color-coded payment section with customer info + * Optimized for quick checkout with visual payment method selection + */ +export const POSPayment: React.FC = ({ + total, + onProcessPayment, + disabled = false, +}) => { + const [paymentMethod, setPaymentMethod] = useState<'cash' | 'card' | 'transfer'>('cash'); + const [cashReceived, setCashReceived] = useState(''); + const [customerInfo, setCustomerInfo] = useState({ + name: '', + email: '', + phone: '', + }); + const [showCustomerForm, setShowCustomerForm] = useState(false); + + const change = cashReceived ? Math.max(0, parseFloat(cashReceived) - total) : 0; + const canProcessPayment = + !disabled && + (paymentMethod === 'card' || + paymentMethod === 'transfer' || + (paymentMethod === 'cash' && cashReceived && parseFloat(cashReceived) >= total)); + + const handleProcessPayment = () => { + if (!canProcessPayment) return; + + onProcessPayment({ + paymentMethod, + cashReceived: paymentMethod === 'cash' ? parseFloat(cashReceived) : undefined, + change: paymentMethod === 'cash' ? change : undefined, + customerInfo: showCustomerForm ? customerInfo : undefined, + }); + + // Reset form + setCashReceived(''); + setCustomerInfo({ name: '', email: '', phone: '' }); + setShowCustomerForm(false); + }; + + // Payment method configurations with colors + const paymentMethods = [ + { + id: 'cash' as const, + name: 'Efectivo', + icon: Banknote, + color: 'var(--color-success)', + bgColor: 'var(--color-success-light)', + borderColor: 'var(--color-success-dark)', + }, + { + id: 'card' as const, + name: 'Tarjeta', + icon: CreditCard, + color: 'var(--color-info)', + bgColor: 'var(--color-info-light)', + borderColor: 'var(--color-info-dark)', + }, + { + id: 'transfer' as const, + name: 'Transferencia', + icon: ArrowRightLeft, + color: 'var(--color-secondary)', + bgColor: 'var(--color-secondary-light)', + borderColor: 'var(--color-secondary-dark)', + }, + ]; + + return ( +
+ {/* Customer Info Toggle */} + + + + {showCustomerForm && ( +
+ + setCustomerInfo((prev) => ({ ...prev, name: e.target.value })) + } + /> + + setCustomerInfo((prev) => ({ ...prev, email: e.target.value })) + } + /> + + setCustomerInfo((prev) => ({ ...prev, phone: e.target.value })) + } + /> +
+ )} +
+ + {/* Payment Method Selection */} + +

+ Método de Pago +

+ +
+ {paymentMethods.map((method) => { + const Icon = method.icon; + const isSelected = paymentMethod === method.id; + + return ( + + ); + })} +
+ + {/* Cash Input */} + {paymentMethod === 'cash' && ( +
+
+ + setCashReceived(e.target.value)} + className="text-lg font-semibold" + /> +
+ + {/* Change Display */} + {cashReceived && parseFloat(cashReceived) >= total && ( + +
+ + Cambio: + + + €{change.toFixed(2)} + +
+
+ )} + + {/* Insufficient Cash Warning */} + {cashReceived && parseFloat(cashReceived) < total && ( + +

+ Efectivo insuficiente: falta €{(total - parseFloat(cashReceived)).toFixed(2)} +

+
+ )} +
+ )} +
+ + {/* Process Payment Button */} + +
+ ); +}; + +export default POSPayment; diff --git a/frontend/src/components/domain/pos/POSProductCard.tsx b/frontend/src/components/domain/pos/POSProductCard.tsx new file mode 100644 index 00000000..6a09fa83 --- /dev/null +++ b/frontend/src/components/domain/pos/POSProductCard.tsx @@ -0,0 +1,154 @@ +import React from 'react'; +import { Plus, Package } from 'lucide-react'; +import { Card } from '../../ui/Card'; +import { Button } from '../../ui/Button'; +import { Badge } from '../../ui/Badge'; + +interface POSProductCardProps { + id: string; + name: string; + price: number; + category: string; + stock: number; + cartQuantity?: number; + onAddToCart: () => void; + onClick?: () => void; +} + +/** + * POSProductCard - Large, touch-friendly product card optimized for POS operations + * Designed for 80cm+ viewing distance with clear visual hierarchy + */ +export const POSProductCard: React.FC = ({ + name, + price, + category, + stock, + cartQuantity = 0, + onAddToCart, + onClick, +}) => { + const remainingStock = stock - cartQuantity; + const isOutOfStock = remainingStock <= 0; + const isLowStock = remainingStock > 0 && remainingStock <= 5; + + // Stock status configuration + const getStockConfig = () => { + if (isOutOfStock) { + return { + color: 'var(--color-error)', + bgColor: 'var(--color-error-light)', + text: 'Sin Stock', + icon: '🚫', + }; + } else if (isLowStock) { + return { + color: 'var(--color-warning)', + bgColor: 'var(--color-warning-light)', + text: `${remainingStock} disponibles`, + icon: '⚠️', + }; + } else { + return { + color: 'var(--color-success)', + bgColor: 'var(--color-success-light)', + text: `${remainingStock} disponibles`, + icon: '✓', + }; + } + }; + + const stockConfig = getStockConfig(); + + return ( + +
+ {/* Product Image Placeholder with Category Icon */} +
+ +
+ + {/* Product Name */} +
+

+ {name} +

+

+ {category} +

+
+ + {/* Price - Large and prominent */} +
+ + €{price.toFixed(2)} + + c/u +
+ + {/* Stock Status Badge */} +
+ {stockConfig.icon} + {stockConfig.text} +
+ + {/* In Cart Indicator */} + {cartQuantity > 0 && ( +
+ + En carrito: {cartQuantity} + +
+ )} + + {/* Add to Cart Button - Large and prominent */} + +
+ + {/* Out of Stock Overlay */} + {isOutOfStock && ( +
+
+ AGOTADO +
+
+ )} +
+ ); +}; + +export default POSProductCard; diff --git a/frontend/src/components/domain/pos/index.ts b/frontend/src/components/domain/pos/index.ts new file mode 100644 index 00000000..a744a359 --- /dev/null +++ b/frontend/src/components/domain/pos/index.ts @@ -0,0 +1,4 @@ +export { POSProductCard } from './POSProductCard'; +export { POSCart } from './POSCart'; +export { POSPayment } from './POSPayment'; +export { CreatePOSConfigModal } from './CreatePOSConfigModal'; diff --git a/frontend/src/components/domain/procurement/CreatePurchaseOrderModal.tsx b/frontend/src/components/domain/procurement/CreatePurchaseOrderModal.tsx index 55e9f6c9..d3f37e80 100644 --- a/frontend/src/components/domain/procurement/CreatePurchaseOrderModal.tsx +++ b/frontend/src/components/domain/procurement/CreatePurchaseOrderModal.tsx @@ -5,6 +5,7 @@ import { useSuppliers } from '../../../api/hooks/suppliers'; import { useCreatePurchaseOrder } from '../../../api/hooks/suppliers'; import { useIngredients } from '../../../api/hooks/inventory'; import { useTenantStore } from '../../../stores/tenant.store'; +import { suppliersService } from '../../../api/services/suppliers'; import type { ProcurementRequirementResponse, PurchaseOrderItem } from '../../../api/types/orders'; import type { SupplierSummary } from '../../../api/types/suppliers'; import type { IngredientResponse } from '../../../api/types/inventory'; @@ -31,6 +32,7 @@ export const CreatePurchaseOrderModal: React.FC = }) => { const [loading, setLoading] = useState(false); const [selectedSupplier, setSelectedSupplier] = useState(''); + const [formData, setFormData] = useState>({}); // Get current tenant const { currentTenant } = useTenantStore(); @@ -44,13 +46,49 @@ export const CreatePurchaseOrderModal: React.FC = ); const suppliers = (suppliersData || []).filter(supplier => supplier.status === 'active'); - // Fetch ingredients filtered by selected supplier (only when manually adding products) - const { data: ingredientsData = [] } = useIngredients( + // State for supplier products + const [supplierProductIds, setSupplierProductIds] = useState([]); + const [isLoadingSupplierProducts, setIsLoadingSupplierProducts] = useState(false); + + // Fetch ALL ingredients (we'll filter client-side based on supplier products) + const { data: allIngredientsData = [], isLoading: isLoadingIngredients } = useIngredients( tenantId, - selectedSupplier ? { supplier_id: selectedSupplier } : {}, - { enabled: !!tenantId && isOpen && !requirements?.length && !!selectedSupplier } + {}, + { enabled: !!tenantId && isOpen && !requirements?.length } ); + // Fetch supplier products when supplier is selected + useEffect(() => { + const fetchSupplierProducts = async () => { + if (!selectedSupplier || !tenantId) { + setSupplierProductIds([]); + return; + } + + setIsLoadingSupplierProducts(true); + try { + const products = await suppliersService.getSupplierProducts(tenantId, selectedSupplier); + const productIds = products.map(p => p.inventory_product_id); + setSupplierProductIds(productIds); + } catch (error) { + console.error('Error fetching supplier products:', error); + setSupplierProductIds([]); + } finally { + setIsLoadingSupplierProducts(false); + } + }; + + fetchSupplierProducts(); + }, [selectedSupplier, tenantId]); + + // Filter ingredients based on supplier products + const ingredientsData = useMemo(() => { + if (!selectedSupplier || supplierProductIds.length === 0) { + return []; + } + return allIngredientsData.filter(ing => supplierProductIds.includes(ing.id)); + }, [allIngredientsData, supplierProductIds, selectedSupplier]); + // Create purchase order mutation const createPurchaseOrderMutation = useCreatePurchaseOrder(); @@ -66,6 +104,14 @@ export const CreatePurchaseOrderModal: React.FC = data: ingredient // Store full ingredient data for later use })), [ingredientsData]); + // Reset selected supplier when modal closes + useEffect(() => { + if (!isOpen) { + setSelectedSupplier(''); + setFormData({}); + } + }, [isOpen]); + // Unit options for select field const unitOptions = [ { value: 'kg', label: 'Kilogramos' }, @@ -80,11 +126,6 @@ export const CreatePurchaseOrderModal: React.FC = const handleSave = async (formData: Record) => { setLoading(true); - // Update selectedSupplier if it changed - if (formData.supplier_id && formData.supplier_id !== selectedSupplier) { - setSelectedSupplier(formData.supplier_id); - } - try { let items: PurchaseOrderItem[] = []; @@ -187,8 +228,9 @@ export const CreatePurchaseOrderModal: React.FC = }; - const sections = [ - { + // Build sections dynamically based on selectedSupplier + const sections = useMemo(() => { + const supplierSection = { title: 'Información del Proveedor', icon: Building2, fields: [ @@ -199,11 +241,19 @@ export const CreatePurchaseOrderModal: React.FC = required: true, options: supplierOptions, placeholder: 'Seleccionar proveedor...', - span: 2 + span: 2, + validation: (value: any) => { + // Update selectedSupplier when supplier changes + if (value && value !== selectedSupplier) { + setTimeout(() => setSelectedSupplier(value), 0); + } + return null; + } } ] - }, - { + }; + + const orderDetailsSection = { title: 'Detalles de la Orden', icon: Calendar, fields: [ @@ -222,8 +272,9 @@ export const CreatePurchaseOrderModal: React.FC = helpText: 'Información adicional o instrucciones especiales' } ] - }, - { + }; + + const ingredientsSection = { title: requirements && requirements.length > 0 ? 'Ingredientes Requeridos' : 'Productos a Comprar', icon: Package, fields: [ @@ -281,7 +332,7 @@ export const CreatePurchaseOrderModal: React.FC = }, helpText: 'Revisa y ajusta las cantidades y precios de los ingredientes requeridos' } : { - label: 'Productos a Comprar', + label: selectedSupplier ? 'Productos a Comprar' : 'Selecciona un proveedor primero', name: 'manual_products', type: 'list' as const, span: 2, @@ -294,8 +345,8 @@ export const CreatePurchaseOrderModal: React.FC = type: 'select', required: true, options: ingredientOptions, - placeholder: 'Seleccionar ingrediente...', - disabled: false + placeholder: isLoadingSupplierProducts || isLoadingIngredients ? 'Cargando ingredientes...' : ingredientOptions.length === 0 ? 'No hay ingredientes disponibles para este proveedor' : 'Seleccionar ingrediente...', + disabled: !selectedSupplier || isLoadingIngredients || isLoadingSupplierProducts }, { name: 'quantity', @@ -322,16 +373,26 @@ export const CreatePurchaseOrderModal: React.FC = } ], addButtonLabel: 'Agregar Ingrediente', - emptyStateText: 'No hay ingredientes disponibles para este proveedor', + emptyStateText: !selectedSupplier + ? 'Selecciona un proveedor para agregar ingredientes' + : isLoadingSupplierProducts || isLoadingIngredients + ? 'Cargando ingredientes del proveedor...' + : ingredientOptions.length === 0 + ? 'Este proveedor no tiene ingredientes asignados en su lista de precios' + : 'No hay ingredientes agregados', showSubtotals: true, subtotalFields: { quantity: 'quantity', price: 'unit_price' }, disabled: !selectedSupplier }, - helpText: 'Selecciona ingredientes disponibles del proveedor seleccionado' + helpText: !selectedSupplier + ? 'Primero selecciona un proveedor en la sección anterior' + : 'Selecciona ingredientes disponibles del proveedor seleccionado' } ] - }, - ]; + }; + + return [supplierSection, orderDetailsSection, ingredientsSection]; + }, [requirements, supplierOptions, ingredientOptions, selectedSupplier, isLoadingIngredients, unitOptions]); return ( <> diff --git a/frontend/src/components/layout/Header/Header.tsx b/frontend/src/components/layout/Header/Header.tsx index d45f1ee5..7266f8e1 100644 --- a/frontend/src/components/layout/Header/Header.tsx +++ b/frontend/src/components/layout/Header/Header.tsx @@ -102,6 +102,16 @@ export const Header = forwardRef(({ const [isNotificationPanelOpen, setIsNotificationPanelOpen] = useState(false); + // Filter notifications to last 24 hours for the notification bell + // This prevents showing old/stale alerts in the notification panel + const recentNotifications = React.useMemo(() => { + const oneDayAgo = Date.now() - (24 * 60 * 60 * 1000); + return notifications.filter(n => { + const alertTime = new Date(n.timestamp).getTime(); + return alertTime > oneDayAgo; + }); + }, [notifications]); + const defaultSearchPlaceholder = searchPlaceholder || t('common:forms.search_placeholder', 'Search...'); // Expose ref methods @@ -259,7 +269,7 @@ export const Header = forwardRef(({ setIsNotificationPanelOpen(false)} onMarkAsRead={markAsRead} diff --git a/frontend/src/components/ui/AddModal/AddModal.tsx b/frontend/src/components/ui/AddModal/AddModal.tsx index 4b0f35a9..36752c9a 100644 --- a/frontend/src/components/ui/AddModal/AddModal.tsx +++ b/frontend/src/components/ui/AddModal/AddModal.tsx @@ -63,6 +63,7 @@ const ListFieldRenderer: React.FC = ({ field, value, onC const renderItemField = (item: any, itemIndex: number, fieldConfig: any) => { const fieldValue = item[fieldConfig.name] ?? ''; + const isFieldDisabled = fieldConfig.disabled ?? false; switch (fieldConfig.type) { case 'select': @@ -70,10 +71,11 @@ const ListFieldRenderer: React.FC = ({ field, value, onC setPosFormData((prev: any) => ({ ...prev, provider: value as string, credentials: {} }))} - placeholder="Selecciona un sistema POS" - options={supportedProviders.map(provider => ({ - value: provider.id, - label: `${provider.logo} ${provider.name}`, - description: provider.description - }))} - /> -
- - {selectedProvider && ( - <> -
-

{selectedProvider.name}

-

{selectedProvider.description}

-
- {selectedProvider.features.map((feature, idx) => ( - - {feature} - - ))} -
-
- -
- - setPosFormData((prev: any) => ({ ...prev, config_name: e.target.value }))} - placeholder={`Mi ${selectedProvider.name} ${new Date().getFullYear()}`} - /> -
- -
-

Credenciales de API

- {selectedProvider.required_fields.map(field => ( -
-
- - {field.type === 'password' && ( - - )} -
- - {field.type === 'select' ? ( - setPosFormData(prev => ({ - ...prev, - credentials: { ...prev.credentials, [field.field]: e.target.value } - }))} - placeholder={field.placeholder} - /> - )} - - {field.help_text && ( -

- - {field.help_text} -

- )} -
- ))} -
- -
-

Configuración de Sincronización

-
-
- -
-
- - setPosFormData((prev: any) => ({ - ...prev, - sync_settings: { ...prev.sync_settings, sync_sales: e.target.checked } - }))} - /> - Sincronizar ventas - -
-
- -
-
-
- - )} -
- ); - }; return (
@@ -605,7 +342,7 @@ const POSPage: React.FC = () => { {/* POS Mode Toggle */} -
+

Modo de Operación

@@ -650,291 +387,131 @@ const POSPage: React.FC = () => { {posMode === 'manual' ? ( <> - {/* Stats Grid */} - - -

- {/* Products Section */} -
- {/* Categories */} + {/* Collapsible Stats Grid */} -
- {categories.map(category => ( - - ))} -
-
- - {/* Products Grid */} -
- {filteredProducts.map(product => { - const cartItem = cart.find(item => item.id === product.id); - const inCart = !!cartItem; - const cartQuantity = cartItem?.quantity || 0; - const remainingStock = product.stock - cartQuantity; - - const getStockStatusConfig = () => { - if (remainingStock <= 0) { - return { - color: getStatusColor('cancelled'), - text: 'Sin Stock', - icon: Package, - isCritical: true, - isHighlight: false - }; - } else if (remainingStock <= 5) { - return { - color: getStatusColor('pending'), - text: `${remainingStock} disponibles`, - icon: Package, - isCritical: false, - isHighlight: true - }; - } else { - return { - color: getStatusColor('completed'), - text: `${remainingStock} disponibles`, - icon: Package, - isCritical: false, - isHighlight: false - }; - } - }; - - return ( - addToCart(product) - } - ]} - /> - ); - })} -
- - {/* Empty State */} - {filteredProducts.length === 0 && ( -
- -

- No hay productos disponibles -

-

- {selectedCategory === 'all' - ? 'No hay productos en stock en este momento' - : `No hay productos en la categoría "${categories.find(c => c.id === selectedCategory)?.name}"` - } -

-
- )} -
- - {/* Cart and Checkout Section */} -
- {/* Cart */} - -
-

- - Carrito ({cart.length}) -

- {cart.length > 0 && ( - - )} -
- -
- {cart.length === 0 ? ( -

Carrito vacío

+ - {item.quantity} - -
-
-

€{(item.price * item.quantity).toFixed(2)}

-
-
- ); - }) + )} -
- - {cart.length > 0 && ( -
-
-
- Subtotal: - €{subtotal.toFixed(2)} -
-
- IVA (21%): - €{tax.toFixed(2)} -
-
- Total: - €{total.toFixed(2)} -
-
+ + {showStats && ( +
+
)} - {/* Customer Info */} - -

- - Cliente (Opcional) -

-
- setCustomerInfo(prev => ({ ...prev, name: e.target.value }))} - /> - setCustomerInfo(prev => ({ ...prev, email: e.target.value }))} - /> - setCustomerInfo(prev => ({ ...prev, phone: e.target.value }))} - /> -
-
+ {/* Main 2-Column Layout */} +
+ {/* Left Column: Products (2/3 width on desktop) */} +
+ {/* Category Pills with Bakery Colors */} + +
+ {categories.map(category => ( + + ))} +
+
- {/* Payment */} - -

- - Método de Pago -

- -
-
- - - + {/* Products Grid - Large Touch-Friendly Cards */} +
+ {filteredProducts.map(product => { + const cartItem = cart.find(item => item.id === product.id); + const cartQuantity = cartItem?.quantity || 0; + + return ( + addToCart(product)} + /> + ); + })}
- {paymentMethod === 'cash' && ( -
- setCashReceived(e.target.value)} - /> - {cashReceived && parseFloat(cashReceived) >= total && ( -
-

- Cambio: €{change.toFixed(2)} -

-
- )} + {/* Empty State */} + {filteredProducts.length === 0 && ( +
+ +

+ No hay productos disponibles +

+

+ {selectedCategory === 'all' + ? 'No hay productos en stock en este momento' + : `No hay productos en la categoría "${categories.find(c => c.id === selectedCategory)?.name}"` + } +

)} - -
- -
-
+ + {/* Right Column: Cart & Payment (1/3 width on desktop) */} +
+ {/* Cart Component */} + + + + + {/* Payment Component */} + +
+
) : ( - /* Automatic POS Integration Section */ + /* Automatic POS Integration Section with StatusCard */
@@ -961,7 +538,7 @@ const POSPage: React.FC = () => {
) : posData.configurations.length === 0 ? ( -
+
@@ -977,88 +554,48 @@ const POSPage: React.FC = () => {
{posData.configurations.map(config => { const provider = posData.supportedSystems.find(p => p.id === config.pos_system); + const isConnected = config.is_connected && config.is_active; + return ( - -
-
-
📊
-
-

{config.provider_name}

-

{provider?.name || config.pos_system}

-
-
-
- {config.is_connected ? ( - - ) : ( - - )} - - {config.is_active ? 'Activo' : 'Inactivo'} - -
-
- -
-
- Estado de conexión: -
- {config.is_active ? ( - <> - - Conectado - - ) : ( - <> - - Desconectado - - )} -
-
- - {config.last_sync_at && ( -
- Última sincronización: - {new Date(config.last_sync_at).toLocaleString('es-ES')} -
- )} -
- -
- - - -
-
+ handleTestPosConnection(config.id), + priority: 'secondary' as const, + disabled: testingConnection === config.id, + }, + { + label: 'Editar', + icon: Settings, + onClick: () => handleEditPosConfiguration(config), + priority: 'secondary' as const, + }, + { + label: 'Eliminar', + icon: Trash2, + onClick: () => handleDeletePosConfiguration(config.id), + priority: 'tertiary' as const, + destructive: true, + }, + ]} + /> ); })}
@@ -1067,48 +604,17 @@ const POSPage: React.FC = () => {
)} - {/* POS Configuration Modals */} - {/* Add Configuration Modal */} - setShowAddPosModal(false)} - title="Agregar Sistema POS" - size="lg" - > -
- {renderPosConfigurationForm()} -
- - -
-
-
- - {/* Edit Configuration Modal */} - setShowEditPosModal(false)} - title="Editar Sistema POS" - size="lg" - > -
- {renderPosConfigurationForm()} -
- - -
-
-
+ {/* POS Configuration Modal */} + setShowPosConfigModal(false)} + tenantId={tenantId} + onSuccess={handlePosConfigSuccess} + existingConfig={selectedPosConfig} + mode={posConfigMode} + />
); }; -export default POSPage; \ No newline at end of file +export default POSPage; diff --git a/frontend/src/pages/app/operations/procurement/ProcurementPage.tsx b/frontend/src/pages/app/operations/procurement/ProcurementPage.tsx index e52f05d8..d46ac3aa 100644 --- a/frontend/src/pages/app/operations/procurement/ProcurementPage.tsx +++ b/frontend/src/pages/app/operations/procurement/ProcurementPage.tsx @@ -1,1688 +1,951 @@ -import React, { useState } from 'react'; -import { Plus, ShoppingCart, Truck, Euro, Calendar, Clock, CheckCircle, AlertCircle, Package, Eye, Loader, Edit, ArrowRight, X, Save, Building2, Play, Zap, User } from 'lucide-react'; +import React, { useState, useMemo } from 'react'; +import { Plus, ShoppingCart, Euro, Calendar, CheckCircle, AlertCircle, Package, Eye, X, Send, Building2, Play, FileText, Star, TrendingUp, TrendingDown, Minus } from 'lucide-react'; import { Button, Card, StatsGrid, StatusCard, getStatusColor, EditViewModal, SearchAndFilter, Input, type FilterConfig } from '../../../../components/ui'; import { formatters } from '../../../../components/ui/Stats/StatsPresets'; import { PageHeader } from '../../../../components/layout'; import { CreatePurchaseOrderModal } from '../../../../components/domain/procurement/CreatePurchaseOrderModal'; import { - useProcurementDashboard, - useProcurementPlans, - usePlanRequirements, - useGenerateProcurementPlan, - useUpdateProcurementPlanStatus, - useTriggerDailyScheduler, - useRecalculateProcurementPlan, - useApproveProcurementPlan, - useRejectProcurementPlan, - useCreatePurchaseOrdersFromPlan, - useLinkRequirementToPurchaseOrder, - useUpdateRequirementDeliveryStatus -} from '../../../../api'; + usePurchaseOrders, + usePurchaseOrder, + useApprovePurchaseOrder, + useRejectPurchaseOrder, + useUpdatePurchaseOrder +} from '../../../../api/hooks/purchase-orders'; +import { useTriggerDailyScheduler } from '../../../../api'; +import type { PurchaseOrderStatus, PurchaseOrderPriority, PurchaseOrderDetail } from '../../../../api/services/purchase_orders'; import { useTenantStore } from '../../../../stores/tenant.store'; +import { useUserById } from '../../../../api/hooks/user'; +import toast from 'react-hot-toast'; const ProcurementPage: React.FC = () => { + // State const [searchTerm, setSearchTerm] = useState(''); - const [statusFilter, setStatusFilter] = useState(''); - const [showForm, setShowForm] = useState(false); - const [modalMode, setModalMode] = useState<'view' | 'edit'>('view'); - const [selectedPlan, setSelectedPlan] = useState(null); - const [editingPlan, setEditingPlan] = useState(null); - const [editFormData, setEditFormData] = useState({}); - const [selectedPlanForRequirements, setSelectedPlanForRequirements] = useState(null); - const [showCriticalRequirements, setShowCriticalRequirements] = useState(false); - const [showGeneratePlanModal, setShowGeneratePlanModal] = useState(false); - const [showRequirementDetailsModal, setShowRequirementDetailsModal] = useState(false); - const [selectedRequirement, setSelectedRequirement] = useState(null); - const [showCreatePurchaseOrderModal, setShowCreatePurchaseOrderModal] = useState(false); - const [selectedRequirementsForPO, setSelectedRequirementsForPO] = useState([]); - const [isAIMode, setIsAIMode] = useState(true); - const [generatePlanForm, setGeneratePlanForm] = useState({ - plan_date: new Date().toISOString().split('T')[0], - planning_horizon_days: 14, - include_safety_stock: true, - safety_stock_percentage: 20, - force_regenerate: false - }); - - // New feature state + const [statusFilter, setStatusFilter] = useState(''); + const [priorityFilter, setPriorityFilter] = useState(''); + const [showArchived, setShowArchived] = useState(false); + const [showCreatePOModal, setShowCreatePOModal] = useState(false); + const [showDetailsModal, setShowDetailsModal] = useState(false); + const [selectedPOId, setSelectedPOId] = useState(null); const [showApprovalModal, setShowApprovalModal] = useState(false); const [approvalAction, setApprovalAction] = useState<'approve' | 'reject'>('approve'); const [approvalNotes, setApprovalNotes] = useState(''); - const [planForApproval, setPlanForApproval] = useState(null); - const [showDeliveryUpdateModal, setShowDeliveryUpdateModal] = useState(false); - const [requirementForDelivery, setRequirementForDelivery] = useState(null); - const [deliveryUpdateForm, setDeliveryUpdateForm] = useState({ - delivery_status: 'pending', - received_quantity: 0, - actual_delivery_date: '', - quality_rating: 5 - }); - - - // Requirement details functionality - const handleViewRequirementDetails = (requirement: any) => { - setSelectedRequirement(requirement); - setShowRequirementDetailsModal(true); - }; const { currentTenant } = useTenantStore(); const tenantId = currentTenant?.id || ''; - // Real API data hooks - const { data: dashboardData, isLoading: isDashboardLoading } = useProcurementDashboard(tenantId); - const { data: procurementPlans, isLoading: isPlansLoading } = useProcurementPlans({ - tenant_id: tenantId, - limit: 50, - offset: 0 - }); + // API Hooks + const { data: purchaseOrdersData = [], isLoading: isPOsLoading, refetch: refetchPOs } = usePurchaseOrders( + tenantId, + { + status: statusFilter || undefined, + priority: priorityFilter || undefined, + search_term: searchTerm || undefined, + limit: 100, + offset: 0 + }, + { + enabled: !!tenantId + } + ); - // Get plan requirements for selected plan - const { data: allPlanRequirements, isLoading: isPlanRequirementsLoading } = usePlanRequirements({ - tenant_id: tenantId, - plan_id: selectedPlanForRequirements || '' - // Remove status filter to get all requirements - }, { - enabled: !!selectedPlanForRequirements && !!tenantId - }); + const { data: poDetails, isLoading: isLoadingDetails } = usePurchaseOrder( + tenantId, + selectedPOId || '', + { + enabled: !!tenantId && !!selectedPOId && showDetailsModal + } + ); - // Filter critical requirements client-side - const planRequirements = allPlanRequirements?.filter(req => { - // Check various conditions that might make a requirement critical - const isLowStock = req.current_stock_level && req.required_quantity && - (req.current_stock_level / req.required_quantity) < 0.5; - const isNearDeadline = req.required_by_date && - (new Date(req.required_by_date).getTime() - Date.now()) / (1000 * 60 * 60 * 24) < 7; - const hasHighPriority = req.priority === 'high'; - - return isLowStock || isNearDeadline || hasHighPriority; - }); - - - const generatePlanMutation = useGenerateProcurementPlan(); - const updatePlanStatusMutation = useUpdateProcurementPlanStatus(); + const approvePOMutation = useApprovePurchaseOrder(); + const rejectPOMutation = useRejectPurchaseOrder(); + const updatePOMutation = useUpdatePurchaseOrder(); const triggerSchedulerMutation = useTriggerDailyScheduler(); - // New feature mutations - const recalculatePlanMutation = useRecalculateProcurementPlan(); - const approvePlanMutation = useApproveProcurementPlan(); - const rejectPlanMutation = useRejectProcurementPlan(); - const createPOsMutation = useCreatePurchaseOrdersFromPlan(); - const updateDeliveryMutation = useUpdateRequirementDeliveryStatus(); - - // Helper functions for stage transitions and edit functionality - const getNextStage = (currentStatus: string): string | null => { - const stageFlow: { [key: string]: string } = { - 'draft': 'pending_approval', - 'pending_approval': 'approved', - 'approved': 'in_execution', - 'in_execution': 'completed' - }; - return stageFlow[currentStatus] || null; - }; - - const getStageActionConfig = (status: string) => { - const configs: { [key: string]: { label: string; icon: any; variant: 'primary' | 'outline'; color?: string } } = { - 'draft': { label: 'Enviar a Aprobación', icon: ArrowRight, variant: 'primary' }, - 'pending_approval': { label: 'Aprobar', icon: CheckCircle, variant: 'primary' }, - 'approved': { label: 'Iniciar Ejecución', icon: Play, variant: 'primary' }, - 'in_execution': { label: 'Completar', icon: CheckCircle, variant: 'primary' } - }; - return configs[status]; - }; - - const canEdit = (status: string): boolean => { - return status === 'draft'; - }; - - const handleStageTransition = (planId: string, currentStatus: string) => { - const nextStage = getNextStage(currentStatus); - if (nextStage) { - updatePlanStatusMutation.mutate({ - tenant_id: tenantId, - plan_id: planId, - status: nextStage as any - }); - } - }; - - const handleCancelPlan = (planId: string) => { - updatePlanStatusMutation.mutate({ - tenant_id: tenantId, - plan_id: planId, - status: 'cancelled' + // Filter POs + const filteredPOs = useMemo(() => { + return purchaseOrdersData.filter(po => { + // Hide archived POs by default + if (!showArchived && (po.status === 'COMPLETED' || po.status === 'CANCELLED')) { + return false; + } + return true; }); + }, [purchaseOrdersData, showArchived]); + + // Calculate stats + const poStats = useMemo(() => { + const total = filteredPOs.length; + // API returns lowercase status values (e.g., 'pending_approval' not 'PENDING_APPROVAL') + const pendingApproval = filteredPOs.filter(po => po.status === 'pending_approval').length; + const approved = filteredPOs.filter(po => po.status === 'approved').length; + const inProgress = filteredPOs.filter(po => + ['sent_to_supplier', 'confirmed'].includes(po.status) + ).length; + const received = filteredPOs.filter(po => po.status === 'received').length; + const totalAmount = filteredPOs.reduce((sum, po) => { + // API returns total_amount as a string, so parse it + const amount = typeof po.total_amount === 'string' + ? parseFloat(po.total_amount) || 0 + : typeof po.total_amount === 'number' + ? po.total_amount + : 0; + return sum + amount; + }, 0); + + return { + total, + pendingApproval, + approved, + inProgress, + received, + totalAmount + }; + }, [filteredPOs]); + + // Handlers + const handleViewDetails = (po: any) => { + setSelectedPOId(po.id); + setShowDetailsModal(true); }; - const handleEditPlan = (plan: any) => { - setEditingPlan(plan); - setEditFormData({ - special_requirements: plan.special_requirements || '', - planning_horizon_days: plan.planning_horizon_days || 14, - priority: plan.priority || 'medium' - }); - }; - - const handleSaveEdit = () => { - // For now, we'll just update the special requirements since that's the main editable field - // In a real implementation, you might have a separate API endpoint for updating plan details - setEditingPlan(null); - setEditFormData({}); - // Here you would typically call an update API - }; - - const handleCancelEdit = () => { - setEditingPlan(null); - setEditFormData({}); - }; - - const handleShowCriticalRequirements = (planId: string) => { - setSelectedPlanForRequirements(planId); - setShowCriticalRequirements(true); - }; - - const handleCloseCriticalRequirements = () => { - setShowCriticalRequirements(false); - setSelectedPlanForRequirements(null); - }; - - // NEW FEATURE HANDLERS - const handleRecalculatePlan = (plan: any) => { - if (window.confirm('¿Recalcular el plan con el inventario actual? Esto puede cambiar las cantidades requeridas.')) { - recalculatePlanMutation.mutate({ tenantId, planId: plan.id }); - } - }; - - const handleOpenApprovalModal = (plan: any, action: 'approve' | 'reject') => { - setPlanForApproval(plan); - setApprovalAction(action); + const handleApprovePO = (po: any) => { + setSelectedPOId(po.id); + setApprovalAction('approve'); setApprovalNotes(''); setShowApprovalModal(true); }; - const handleConfirmApproval = () => { - if (!planForApproval) return; + const handleRejectPO = (po: any) => { + setSelectedPOId(po.id); + setApprovalAction('reject'); + setApprovalNotes(''); + setShowApprovalModal(true); + }; - if (approvalAction === 'approve') { - approvePlanMutation.mutate({ + const handleSendToSupplier = async (po: any) => { + try { + await updatePOMutation.mutateAsync({ tenantId, - planId: planForApproval.id, - approval_notes: approvalNotes || undefined - }, { - onSuccess: () => { - setShowApprovalModal(false); - setPlanForApproval(null); - setApprovalNotes(''); + poId: po.id, + data: { status: 'SENT_TO_SUPPLIER' } + }); + toast.success('Orden enviada al proveedor'); + refetchPOs(); + } catch (error) { + console.error('Error sending PO to supplier:', error); + toast.error('Error al enviar orden al proveedor'); + } + }; + + const handleConfirmPO = async (po: any) => { + try { + await updatePOMutation.mutateAsync({ + tenantId, + poId: po.id, + data: { status: 'CONFIRMED' } + }); + toast.success('Orden confirmada'); + refetchPOs(); + } catch (error) { + console.error('Error confirming PO:', error); + toast.error('Error al confirmar orden'); + } + }; + + const handleApprovalSubmit = async () => { + if (!selectedPOId) return; + + try { + if (approvalAction === 'approve') { + await approvePOMutation.mutateAsync({ + tenantId, + poId: selectedPOId, + notes: approvalNotes || undefined + }); + toast.success('Orden aprobada exitosamente'); + } else { + if (!approvalNotes.trim()) { + toast.error('Debes proporcionar una razón para rechazar'); + return; } - }); - } else { - rejectPlanMutation.mutate({ - tenantId, - planId: planForApproval.id, - rejection_notes: approvalNotes || undefined - }, { - onSuccess: () => { - setShowApprovalModal(false); - setPlanForApproval(null); - setApprovalNotes(''); - } - }); - } - }; - - const handleCreatePurchaseOrders = (plan: any) => { - if (plan.status !== 'approved') { - alert('El plan debe estar aprobado antes de crear órdenes de compra'); - return; - } - - if (window.confirm(`¿Crear órdenes de compra automáticamente para ${plan.total_requirements} requerimientos?`)) { - createPOsMutation.mutate({ - tenantId, - planId: plan.id, - autoApprove: false - }); - } - }; - - const handleOpenDeliveryUpdate = (requirement: any) => { - setRequirementForDelivery(requirement); - setDeliveryUpdateForm({ - delivery_status: requirement.delivery_status || 'pending', - received_quantity: requirement.received_quantity || 0, - actual_delivery_date: requirement.actual_delivery_date || '', - quality_rating: requirement.quality_rating || 5 - }); - setShowDeliveryUpdateModal(true); - }; - - const handleConfirmDeliveryUpdate = () => { - if (!requirementForDelivery) return; - - updateDeliveryMutation.mutate({ - tenantId, - requirementId: requirementForDelivery.id, - request: { - delivery_status: deliveryUpdateForm.delivery_status, - received_quantity: deliveryUpdateForm.received_quantity || undefined, - actual_delivery_date: deliveryUpdateForm.actual_delivery_date || undefined, - quality_rating: deliveryUpdateForm.quality_rating || undefined + await rejectPOMutation.mutateAsync({ + tenantId, + poId: selectedPOId, + reason: approvalNotes + }); + toast.success('Orden rechazada'); } - }, { - onSuccess: () => { - setShowDeliveryUpdateModal(false); - setRequirementForDelivery(null); - } - }); + setShowApprovalModal(false); + setShowDetailsModal(false); + setApprovalNotes(''); + refetchPOs(); + } catch (error) { + console.error('Error in approval action:', error); + toast.error('Error al procesar aprobación'); + } }; - if (!tenantId) { - return ( -
-
-

- No hay tenant seleccionado -

-

- Selecciona un tenant para ver los datos de procurement -

-
-
- ); - } + const handleTriggerScheduler = async () => { + try { + await triggerSchedulerMutation.mutateAsync({ tenantId }); + toast.success('Scheduler ejecutado exitosamente'); + refetchPOs(); + } catch (error) { + console.error('Error triggering scheduler:', error); + toast.error('Error al ejecutar scheduler'); + } + }; + // Get PO status configuration + const getPOStatusConfig = (status: PurchaseOrderStatus | string) => { + // API returns lowercase status, normalize it + const normalizedStatus = status?.toUpperCase().replace(/_/g, '_') as PurchaseOrderStatus; - const getPlanStatusConfig = (status: string) => { - const statusConfig = { - draft: { text: 'Borrador', icon: Clock }, - pending_approval: { text: 'Pendiente Aprobación', icon: Clock }, - approved: { text: 'Aprobado', icon: CheckCircle }, - in_execution: { text: 'En Ejecución', icon: Truck }, - completed: { text: 'Completado', icon: CheckCircle }, - cancelled: { text: 'Cancelado', icon: AlertCircle }, + const configs: Record = { + DRAFT: { + color: getStatusColor('neutral'), + text: 'Borrador', + icon: FileText, + isCritical: false + }, + PENDING_APPROVAL: { + color: getStatusColor('warning'), + text: 'Pendiente de Aprobación', + icon: AlertCircle, + isCritical: true + }, + APPROVED: { + color: getStatusColor('approved'), + text: 'Aprobado', + icon: CheckCircle, + isCritical: false + }, + SENT_TO_SUPPLIER: { + color: getStatusColor('inProgress'), + text: 'Enviado al Proveedor', + icon: Send, + isCritical: false + }, + CONFIRMED: { + color: getStatusColor('approved'), + text: 'Confirmado', + icon: CheckCircle, + isCritical: false + }, + RECEIVED: { + color: getStatusColor('delivered'), + text: 'Recibido', + icon: Package, + isCritical: false + }, + COMPLETED: { + color: getStatusColor('completed'), + text: 'Completado', + icon: CheckCircle, + isCritical: false + }, + CANCELLED: { + color: getStatusColor('cancelled'), + text: 'Cancelado', + icon: X, + isCritical: false + }, + DISPUTED: { + color: getStatusColor('expired'), + text: 'En Disputa', + icon: AlertCircle, + isCritical: true + } }; - - const config = statusConfig[status as keyof typeof statusConfig]; - const Icon = config?.icon; - - return { - color: getStatusColor(status === 'in_execution' ? 'inTransit' : status === 'pending_approval' ? 'pending' : status), - text: config?.text || status, - icon: Icon, - isCritical: status === 'cancelled', - isHighlight: status === 'pending_approval' + + // Return config or default if status is undefined/invalid + return configs[normalizedStatus] || { + color: getStatusColor('neutral'), + text: status || 'Desconocido', + icon: FileText, + isCritical: false }; }; - const filteredPlans = procurementPlans?.plans?.filter(plan => { - const matchesSearch = plan.plan_number.toLowerCase().includes(searchTerm.toLowerCase()) || - plan.status.toLowerCase().includes(searchTerm.toLowerCase()) || - (plan.special_requirements && plan.special_requirements.toLowerCase().includes(searchTerm.toLowerCase())); + // Get quick actions for a PO based on status + const getPOQuickActions = (po: any) => { + const actions = [ + { + label: 'Ver Detalles', + icon: Eye, + onClick: () => handleViewDetails(po), + priority: 'primary' as const + } + ]; - const matchesStatus = !statusFilter || plan.status === statusFilter; + if (po.status === 'pending_approval') { + actions.push( + { + label: 'Aprobar', + icon: CheckCircle, + onClick: () => handleApprovePO(po), + priority: 'primary' as const, + variant: 'primary' as const + }, + { + label: 'Rechazar', + icon: X, + onClick: () => handleRejectPO(po), + priority: 'secondary' as const, + variant: 'outline' as const, + destructive: true + } + ); + } else if (po.status === 'approved') { + actions.push({ + label: 'Enviar al Proveedor', + icon: Send, + onClick: () => handleSendToSupplier(po), + priority: 'primary' as const, + variant: 'primary' as const + }); + } else if (po.status === 'sent_to_supplier') { + actions.push({ + label: 'Confirmar', + icon: CheckCircle, + onClick: () => handleConfirmPO(po), + priority: 'primary' as const, + variant: 'primary' as const + }); + } - return matchesSearch && matchesStatus; - }) || []; - - - const stats = { - totalPlans: dashboardData?.summary?.total_plans || 0, - activePlans: dashboardData?.summary?.active_plans || 0, - pendingRequirements: dashboardData?.summary?.pending_requirements || 0, - criticalRequirements: dashboardData?.summary?.critical_requirements || 0, - totalEstimatedCost: dashboardData?.summary?.total_estimated_cost || 0, - totalApprovedCost: dashboardData?.summary?.total_approved_cost || 0, + return actions; }; - const procurementStats = [ - { - title: 'Planes Totales', - value: stats.totalPlans, - variant: 'default' as const, - icon: Package, - }, - { - title: 'Planes Activos', - value: stats.activePlans, - variant: 'success' as const, - icon: CheckCircle, - }, - { - title: 'Requerimientos Pendientes', - value: stats.pendingRequirements, - variant: 'warning' as const, - icon: Clock, - }, - { - title: 'Críticos', - value: stats.criticalRequirements, - variant: 'warning' as const, - icon: AlertCircle, - }, - { - title: 'Costo Estimado', - value: formatters.currency(stats.totalEstimatedCost), - variant: 'info' as const, - icon: Euro, - }, - { - title: 'Costo Aprobado', - value: formatters.currency(stats.totalApprovedCost), - variant: 'success' as const, - icon: Euro, - }, - ]; + // Component to display user name with data fetching + const UserName: React.FC<{ userId: string | undefined | null }> = ({ userId }) => { + // Handle null/undefined + if (!userId) return <>N/A; - return ( -
-
- -
- {/* AI/Manual Mode Segmented Control */} -
- - -
+ // Check for system/demo UUID patterns + if (userId === '00000000-0000-0000-0000-000000000001' || userId === '00000000-0000-0000-0000-000000000000') { + return <>Sistema; + } - {/* Action Buttons */} - {!isAIMode && ( - - )} + // Fetch user data + const { data: user, isLoading } = useUserById(userId, { + retry: 1, + staleTime: 10 * 60 * 1000, // 10 minutes + }); - {/* Testing button - keep for development */} - -
-
+ if (isLoading) return <>Cargando...; + if (!user) return <>Usuario Desconocido; - {/* Stats Grid */} - {isDashboardLoading ? ( -
- -
- ) : ( - - )} + return <>{user.full_name || user.email || 'Usuario'}; + }; - - { + const sections = [ + { + title: 'Información General', + icon: FileText, + fields: [ + { + label: 'Número de Orden', + value: po.po_number, + type: 'text' as const + }, { - key: 'status', label: 'Estado', - type: 'dropdown', - value: statusFilter, - onChange: (value) => setStatusFilter(value as string), - placeholder: 'Todos los estados', - options: [ - { value: 'draft', label: 'Borrador' }, - { value: 'pending_approval', label: 'Pendiente Aprobación' }, - { value: 'approved', label: 'Aprobado' }, - { value: 'in_execution', label: 'En Ejecución' }, - { value: 'completed', label: 'Completado' }, - { value: 'cancelled', label: 'Cancelado' } - ] + value: getPOStatusConfig(po.status).text, + type: 'badge' as const, + badgeColor: getPOStatusConfig(po.status).color + }, + { + label: 'Prioridad', + value: po.priority === 'urgent' ? 'Urgente' : po.priority === 'high' ? 'Alta' : po.priority === 'low' ? 'Baja' : 'Normal', + type: 'text' as const + }, + { + label: 'Fecha de Creación', + value: new Date(po.created_at).toLocaleDateString('es-ES', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }), + type: 'text' as const } - ] as FilterConfig[]} - /> + ] + }, + { + title: 'Información del Proveedor', + icon: Building2, + fields: [ + { + label: 'Proveedor', + value: po.supplier?.name || 'N/A', + type: 'text' as const + }, + { + label: 'Código de Proveedor', + value: po.supplier?.supplier_code || 'N/A', + type: 'text' as const + }, + { + label: 'Email', + value: po.supplier?.contact_email || 'N/A', + type: 'text' as const + }, + { + label: 'Teléfono', + value: po.supplier?.contact_phone || 'N/A', + type: 'text' as const + } + ] + }, + { + title: 'Resumen Financiero', + icon: Euro, + fields: [ + { + label: 'Subtotal', + value: `€${(() => { + const val = typeof po.subtotal === 'string' ? parseFloat(po.subtotal) : typeof po.subtotal === 'number' ? po.subtotal : 0; + return val.toFixed(2); + })()}`, + type: 'text' as const + }, + { + label: 'Impuestos', + value: `€${(() => { + const val = typeof po.tax_amount === 'string' ? parseFloat(po.tax_amount) : typeof po.tax_amount === 'number' ? po.tax_amount : 0; + return val.toFixed(2); + })()}`, + type: 'text' as const + }, + { + label: 'Descuentos', + value: `€${(() => { + const val = typeof po.discount_amount === 'string' ? parseFloat(po.discount_amount) : typeof po.discount_amount === 'number' ? po.discount_amount : 0; + return val.toFixed(2); + })()}`, + type: 'text' as const + }, + { + label: 'TOTAL', + value: `€${(() => { + const val = typeof po.total_amount === 'string' ? parseFloat(po.total_amount) : typeof po.total_amount === 'number' ? po.total_amount : 0; + return val.toFixed(2); + })()}`, + type: 'text' as const, + valueClassName: 'text-xl font-bold text-primary-600' + } + ] + }, + { + title: 'Artículos del Pedido', + icon: Package, + fields: [ + { + label: '', + value: , + type: 'component' as const, + span: 2 + } + ] + }, + { + title: 'Entrega', + icon: Calendar, + fields: [ + { + label: 'Fecha de Entrega Requerida', + value: po.required_delivery_date + ? new Date(po.required_delivery_date).toLocaleDateString('es-ES', { year: 'numeric', month: 'long', day: 'numeric' }) + : 'No especificada', + type: 'text' as const + }, + { + label: 'Fecha de Entrega Esperada', + value: po.expected_delivery_date + ? new Date(po.expected_delivery_date).toLocaleDateString('es-ES', { year: 'numeric', month: 'long', day: 'numeric' }) + : 'No especificada', + type: 'text' as const + }, + { + label: 'Fecha de Entrega Real', + value: po.actual_delivery_date + ? new Date(po.actual_delivery_date).toLocaleDateString('es-ES', { year: 'numeric', month: 'long', day: 'numeric' }) + : 'Pendiente', + type: 'text' as const + } + ] + }, + { + title: 'Aprobación', + icon: CheckCircle, + fields: [ + { + label: 'Aprobado Por', + value: , + type: 'component' as const + }, + { + label: 'Fecha de Aprobación', + value: po.approved_at + ? new Date(po.approved_at).toLocaleDateString('es-ES', { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' }) + : 'N/A', + type: 'text' as const + }, + { + label: 'Notas de Aprobación', + value: po.approval_notes || 'N/A', + type: 'textarea' as const + } + ] + }, + { + title: 'Notas', + icon: FileText, + fields: [ + { + label: 'Notas de la Orden', + value: po.notes || 'Sin notas', + type: 'textarea' as const + }, + { + label: 'Notas Internas', + value: po.internal_notes || 'Sin notas internas', + type: 'textarea' as const + } + ] + }, + { + title: 'Auditoría', + icon: FileText, + fields: [ + { + label: 'Creado Por', + value: , + type: 'component' as const + }, + { + label: 'Última Actualización', + value: new Date(po.updated_at).toLocaleDateString('es-ES', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }), + type: 'text' as const + } + ] + } + ]; - {/* Procurement Plans Grid - Mobile-Optimized */} -
- {isPlansLoading ? ( -
- -
- ) : ( - filteredPlans.map((plan) => { - const statusConfig = getPlanStatusConfig(plan.status); - const nextStageConfig = getStageActionConfig(plan.status); - const isEditing = editingPlan?.id === plan.id; - const isEditable = canEdit(plan.status); - - // Build actions array with proper priority hierarchy for better UX - const actions = []; - - // Edit mode actions (highest priority when editing) - if (isEditing) { - actions.push( - { - label: 'Guardar', - icon: Save, - variant: 'primary' as const, - priority: 'primary' as const, - onClick: handleSaveEdit - }, - { - label: 'Cancelar', - icon: X, - variant: 'outline' as const, - priority: 'primary' as const, - destructive: true, - onClick: handleCancelEdit - } - ); - } else { - // NEW FEATURES: Recalculate and Approval actions for draft/pending - if (plan.status === 'draft') { - const planAgeHours = (new Date().getTime() - new Date(plan.created_at).getTime()) / (1000 * 60 * 60); + return sections; + }; - actions.push({ - label: planAgeHours > 24 ? '⚠️ Recalcular' : 'Recalcular', - icon: ArrowRight, - variant: 'outline' as const, - priority: 'primary' as const, - onClick: () => handleRecalculatePlan(plan) - }); - - actions.push({ - label: 'Aprobar', - icon: CheckCircle, - variant: 'primary' as const, - priority: 'primary' as const, - onClick: () => handleOpenApprovalModal(plan, 'approve') - }); - - actions.push({ - label: 'Rechazar', - icon: X, - variant: 'outline' as const, - priority: 'secondary' as const, - destructive: true, - onClick: () => handleOpenApprovalModal(plan, 'reject') - }); - } else if (plan.status === 'pending_approval') { - actions.push({ - label: 'Aprobar', - icon: CheckCircle, - variant: 'primary' as const, - priority: 'primary' as const, - onClick: () => handleOpenApprovalModal(plan, 'approve') - }); - - actions.push({ - label: 'Rechazar', - icon: X, - variant: 'outline' as const, - priority: 'secondary' as const, - destructive: true, - onClick: () => handleOpenApprovalModal(plan, 'reject') - }); - } - - // NEW FEATURE: Auto-create POs for approved plans - if (plan.status === 'approved') { - actions.push({ - label: 'Crear Órdenes de Compra', - icon: ShoppingCart, - variant: 'primary' as const, - priority: 'primary' as const, - onClick: () => handleCreatePurchaseOrders(plan) - }); - - actions.push({ - label: 'Iniciar Ejecución', - icon: Play, - variant: 'outline' as const, - priority: 'secondary' as const, - onClick: () => handleStageTransition(plan.id, plan.status) - }); - } - - // Original stage transition for other statuses - if (nextStageConfig && !['draft', 'pending_approval', 'approved'].includes(plan.status)) { - actions.push({ - label: nextStageConfig.label, - icon: nextStageConfig.icon, - variant: nextStageConfig.variant, - priority: 'primary' as const, - onClick: () => handleStageTransition(plan.id, plan.status) - }); - } - - // Secondary actions: Edit and View - if (isEditable) { - actions.push({ - label: 'Editar', - icon: Edit, - variant: 'outline' as const, - priority: 'secondary' as const, - onClick: () => handleEditPlan(plan) - }); - } - - actions.push({ - label: 'Ver', - icon: Eye, - variant: 'outline' as const, - priority: 'secondary' as const, - onClick: () => { - setSelectedPlan(plan); - setModalMode('view'); - setShowForm(true); - } - }); - - // Show Critical Requirements button - actions.push({ - label: 'Req. Críticos', - icon: AlertCircle, - variant: 'outline' as const, - priority: 'secondary' as const, - onClick: () => handleShowCriticalRequirements(plan.id) - }); - - // Tertiary action: Cancel (least prominent, destructive) - if (!['completed', 'cancelled'].includes(plan.status)) { - actions.push({ - label: 'Cancelar Plan', - icon: X, - variant: 'outline' as const, - priority: 'tertiary' as const, - destructive: true, - onClick: () => handleCancelPlan(plan.id) - }); - } - } - - return ( -
- 14 ? '#10b981' : plan.planning_horizon_days > 7 ? '#f59e0b' : '#ef4444' - } : undefined} - metadata={[ - `Período: ${new Date(plan.plan_period_start).toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit' })} - ${new Date(plan.plan_period_end).toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit' })}`, - `Creado: ${new Date(plan.created_at).toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit' })}`, - ...(plan.special_requirements ? [`Especiales: ${plan.special_requirements.length > 30 ? plan.special_requirements.substring(0, 30) + '...' : plan.special_requirements}`] : []) - ]} - actions={actions} - /> - - {/* Inline Edit Form for Draft Plans */} - {isEditing && ( - -

- Editando Plan {plan.plan_number} -

-
-
- -