diff --git a/frontend/src/components/domain/production/ProductionStatusCard.tsx b/frontend/src/components/domain/production/ProductionStatusCard.tsx index fc2d12b2..793f7be1 100644 --- a/frontend/src/components/domain/production/ProductionStatusCard.tsx +++ b/frontend/src/components/domain/production/ProductionStatusCard.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Clock, Timer, CheckCircle, AlertCircle, Package, Play, Pause, X, Edit, Eye, History, Settings } from 'lucide-react'; +import { Clock, Timer, CheckCircle, AlertCircle, Package, Play, Pause, X, Eye } from 'lucide-react'; import { StatusCard, StatusIndicatorConfig } from '../../ui/StatusCard/StatusCard'; import { statusColors } from '../../../styles/colors'; import { ProductionBatchResponse, ProductionStatus, ProductionPriority } from '../../../api/types/production'; @@ -153,28 +153,39 @@ export const ProductionStatusCard: React.FC = ({ const getProductionActions = () => { const actions = []; - // Debug logging to see what status we're getting - console.log('ProductionStatusCard - Batch:', batch.batch_number, 'Status:', batch.status, 'Type:', typeof batch.status); - + // Primary action - View batch details (matches inventory "Ver Detalles") if (onView) { actions.push({ - label: 'Ver', + label: 'Ver Detalles', icon: Eye, + variant: 'primary' as const, priority: 'primary' as const, onClick: () => onView(batch) }); } + // Status-specific primary actions if (batch.status === ProductionStatus.PENDING && onStart) { actions.push({ - label: 'Iniciar', + label: 'Iniciar Producción', icon: Play, - priority: 'primary' as const, + priority: 'secondary' as const, + highlighted: true, onClick: () => onStart(batch) }); } if (batch.status === ProductionStatus.IN_PROGRESS) { + if (onComplete) { + actions.push({ + label: 'Completar', + icon: CheckCircle, + priority: 'secondary' as const, + highlighted: true, + onClick: () => onComplete(batch) + }); + } + if (onPause) { actions.push({ label: 'Pausar', @@ -183,75 +194,39 @@ export const ProductionStatusCard: React.FC = ({ onClick: () => onPause(batch) }); } - - if (onComplete) { - actions.push({ - label: 'Completar', - icon: CheckCircle, - priority: 'primary' as const, - onClick: () => onComplete(batch) - }); - } } if (batch.status === ProductionStatus.ON_HOLD && onStart) { actions.push({ label: 'Reanudar', icon: Play, - priority: 'primary' as const, + priority: 'secondary' as const, + highlighted: true, onClick: () => onStart(batch) }); } if (batch.status === ProductionStatus.QUALITY_CHECK && onQualityCheck) { - console.log('ProductionStatusCard - Adding quality check button for batch:', batch.batch_number); actions.push({ - label: 'Calidad', + label: 'Control de Calidad', icon: Package, - priority: 'primary' as const, + priority: 'secondary' as const, + highlighted: true, onClick: () => onQualityCheck(batch) }); - } else { - console.log('ProductionStatusCard - Quality check condition not met:', { - batchNumber: batch.batch_number, - status: batch.status, - expectedStatus: ProductionStatus.QUALITY_CHECK, - hasOnQualityCheck: !!onQualityCheck, - statusMatch: batch.status === ProductionStatus.QUALITY_CHECK - }); - } - - if (onEdit && (batch.status === ProductionStatus.PENDING || batch.status === ProductionStatus.ON_HOLD)) { - actions.push({ - label: 'Editar', - icon: Edit, - priority: 'secondary' as const, - onClick: () => onEdit(batch) - }); } + // Cancel action for non-completed batches if (onCancel && batch.status !== ProductionStatus.COMPLETED && batch.status !== ProductionStatus.CANCELLED) { actions.push({ label: 'Cancelar', icon: X, - priority: 'tertiary' as const, + priority: 'secondary' as const, destructive: true, onClick: () => onCancel(batch) }); } - if (onViewHistory) { - actions.push({ - label: 'Historial', - icon: History, - priority: 'tertiary' as const, - onClick: () => onViewHistory(batch) - }); - } - - // Debug logging to see final actions array - console.log('ProductionStatusCard - Final actions array for batch', batch.batch_number, ':', actions); - return actions; }; diff --git a/frontend/src/components/domain/unified-wizard/ItemTypeSelector.tsx b/frontend/src/components/domain/unified-wizard/ItemTypeSelector.tsx index 34407074..f37d3752 100644 --- a/frontend/src/components/domain/unified-wizard/ItemTypeSelector.tsx +++ b/frontend/src/components/domain/unified-wizard/ItemTypeSelector.tsx @@ -11,6 +11,8 @@ import { UserPlus, Euro as EuroIcon, Sparkles, + FileText, + Factory, } from 'lucide-react'; export type ItemType = @@ -22,7 +24,9 @@ export type ItemType = | 'customer-order' | 'customer' | 'team-member' - | 'sales-entry'; + | 'sales-entry' + | 'purchase-order' + | 'production-batch'; export interface ItemTypeConfig { id: ItemType; @@ -108,6 +112,22 @@ export const ITEM_TYPES: ItemTypeConfig[] = [ badge: 'Configuración', badgeColor: 'bg-blue-100 text-blue-700', }, + { + id: 'purchase-order', + title: 'Orden de Compra', + subtitle: 'Compra a proveedor', + icon: FileText, + badge: 'Diario', + badgeColor: 'bg-amber-100 text-amber-700', + }, + { + id: 'production-batch', + title: 'Lote de Producción', + subtitle: 'Nueva orden de producción', + icon: Factory, + badge: 'Diario', + badgeColor: 'bg-amber-100 text-amber-700', + }, ]; interface ItemTypeSelectorProps { diff --git a/frontend/src/components/domain/unified-wizard/UnifiedAddWizard.tsx b/frontend/src/components/domain/unified-wizard/UnifiedAddWizard.tsx index ccfa8c0d..8fc8ebdf 100644 --- a/frontend/src/components/domain/unified-wizard/UnifiedAddWizard.tsx +++ b/frontend/src/components/domain/unified-wizard/UnifiedAddWizard.tsx @@ -3,6 +3,12 @@ import { Sparkles } from 'lucide-react'; import { WizardModal, WizardStep } from '../../ui/WizardModal/WizardModal'; import { ItemTypeSelector, ItemType } from './ItemTypeSelector'; import { AnyWizardData } from './types'; +import { useTenant } from '../../../stores/tenant.store'; +import { useCreatePurchaseOrder } from '../../../api/hooks/purchase-orders'; +import { useCreateProductionBatch } from '../../../api/hooks/production'; +import { toast } from 'react-hot-toast'; +import type { ProductionBatchCreate } from '../../../api/types/production'; +import { ProductionPriorityEnum } from '../../../api/types/production'; // Import specific wizards import { InventoryWizardSteps, ProductTypeStep, BasicInfoStep, StockConfigStep } from './wizards/InventoryWizard'; @@ -14,6 +20,8 @@ import { CustomerOrderWizardSteps } from './wizards/CustomerOrderWizard'; import { CustomerWizardSteps } from './wizards/CustomerWizard'; import { TeamMemberWizardSteps } from './wizards/TeamMemberWizard'; import { SalesEntryWizardSteps } from './wizards/SalesEntryWizard'; +import { PurchaseOrderWizardSteps } from './wizards/PurchaseOrderWizard'; +import { ProductionBatchWizardSteps } from './wizards/ProductionBatchWizard'; interface UnifiedAddWizardProps { isOpen: boolean; @@ -33,6 +41,14 @@ export const UnifiedAddWizard: React.FC = ({ initialItemType || null ); const [wizardData, setWizardData] = useState({}); + const [isSubmitting, setIsSubmitting] = useState(false); + + // Get current tenant + const { currentTenant } = useTenant(); + + // API hooks + const createPurchaseOrderMutation = useCreatePurchaseOrder(); + const createProductionBatchMutation = useCreateProductionBatch(); // Use a ref to store the current data - this allows step components // to always access the latest data without causing the steps array to be recreated @@ -48,6 +64,7 @@ export const UnifiedAddWizard: React.FC = ({ setSelectedItemType(initialItemType || null); setWizardData({}); dataRef.current = {}; + setIsSubmitting(false); onClose(); }, [onClose, initialItemType]); @@ -66,17 +83,101 @@ export const UnifiedAddWizard: React.FC = ({ setWizardData(newData); }, []); - // Handle wizard completion + // Handle wizard completion with API submission const handleWizardComplete = useCallback( - (data?: any) => { - if (selectedItemType) { - // On completion, sync the ref to state for submission - setWizardData(dataRef.current); - onComplete?.(selectedItemType, dataRef.current); + async () => { + if (!selectedItemType || !currentTenant?.id) { + return; + } + + setIsSubmitting(true); + + try { + const finalData = dataRef.current as any; // Cast to any for flexible data access + + // Handle Purchase Order submission + if (selectedItemType === 'purchase-order') { + const subtotal = (finalData.items || []).reduce( + (sum: number, item: any) => sum + (item.subtotal || 0), + 0 + ); + + await createPurchaseOrderMutation.mutateAsync({ + tenantId: currentTenant.id, + data: { + supplier_id: finalData.supplier_id, + required_delivery_date: finalData.required_delivery_date, + priority: finalData.priority || 'normal', + subtotal: String(subtotal), + tax_amount: String(finalData.tax_amount || 0), + shipping_cost: String(finalData.shipping_cost || 0), + discount_amount: String(finalData.discount_amount || 0), + notes: finalData.notes || undefined, + items: (finalData.items || []).map((item: any) => ({ + inventory_product_id: item.inventory_product_id, + ordered_quantity: item.ordered_quantity, + unit_price: String(item.unit_price), + unit_of_measure: item.unit_of_measure, + })), + }, + }); + toast.success('Orden de compra creada exitosamente'); + } + + // Handle Production Batch submission + if (selectedItemType === 'production-batch') { + // Convert staff_assigned from string to array + const staffArray = finalData.staff_assigned_string + ? finalData.staff_assigned_string.split(',').map((s: string) => s.trim()).filter((s: string) => s.length > 0) + : []; + + const batchData: ProductionBatchCreate = { + product_id: finalData.product_id, + product_name: finalData.product_name, + recipe_id: finalData.recipe_id || undefined, + planned_start_time: finalData.planned_start_time, + planned_end_time: finalData.planned_end_time, + planned_quantity: Number(finalData.planned_quantity), + planned_duration_minutes: Number(finalData.planned_duration_minutes), + priority: (finalData.priority || ProductionPriorityEnum.MEDIUM) as ProductionPriorityEnum, + is_rush_order: finalData.is_rush_order || false, + is_special_recipe: finalData.is_special_recipe || false, + production_notes: finalData.production_notes || undefined, + batch_number: finalData.batch_number || undefined, + order_id: finalData.order_id || undefined, + forecast_id: finalData.forecast_id || undefined, + equipment_used: [], + staff_assigned: staffArray, + station_id: finalData.station_id || undefined, + }; + + await createProductionBatchMutation.mutateAsync({ + tenantId: currentTenant.id, + batchData, + }); + toast.success('Lote de producción creado exitosamente'); + } + + // Call the parent's onComplete callback + onComplete?.(selectedItemType, finalData); + + // Close the modal + handleClose(); + } catch (error: any) { + console.error('Error submitting wizard data:', error); + toast.error(error.message || 'Error al crear el elemento'); + } finally { + setIsSubmitting(false); } - handleClose(); }, - [selectedItemType, onComplete, handleClose] + [ + selectedItemType, + currentTenant, + createPurchaseOrderMutation, + createProductionBatchMutation, + onComplete, + handleClose, + ] ); // Get wizard steps based on selected item type @@ -120,6 +221,10 @@ export const UnifiedAddWizard: React.FC = ({ return TeamMemberWizardSteps(dataRef, setWizardData); case 'sales-entry': return SalesEntryWizardSteps(dataRef, setWizardData); + case 'purchase-order': + return PurchaseOrderWizardSteps(dataRef, setWizardData); + case 'production-batch': + return ProductionBatchWizardSteps(dataRef, setWizardData); default: return []; } @@ -141,6 +246,8 @@ export const UnifiedAddWizard: React.FC = ({ 'customer': 'Agregar Cliente', 'team-member': 'Agregar Miembro del Equipo', 'sales-entry': 'Registrar Ventas', + 'purchase-order': 'Crear Orden de Compra', + 'production-batch': 'Crear Lote de Producción', }; return titleMap[selectedItemType] || 'Agregar Contenido'; diff --git a/frontend/src/components/domain/unified-wizard/wizards/ProductionBatchWizard.tsx b/frontend/src/components/domain/unified-wizard/wizards/ProductionBatchWizard.tsx new file mode 100644 index 00000000..b7d2db67 --- /dev/null +++ b/frontend/src/components/domain/unified-wizard/wizards/ProductionBatchWizard.tsx @@ -0,0 +1,666 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { WizardStep, WizardStepProps } from '../../../ui/WizardModal/WizardModal'; +import { AdvancedOptionsSection } from '../../../ui/AdvancedOptionsSection'; +import { + Package, + ChefHat, + Clock, + AlertCircle, + Users, + CheckCircle2, + Loader2, + ClipboardCheck, + TrendingUp, +} from 'lucide-react'; +import { useTenant } from '../../../../stores/tenant.store'; +import { useRecipes } from '../../../../api/hooks/recipes'; +import { useIngredients } from '../../../../api/hooks/inventory'; +import { recipesService } from '../../../../api/services/recipes'; +import { ProductionPriorityEnum } from '../../../../api/types/production'; +import { ProcessStage } from '../../../../api/types/qualityTemplates'; +import { Badge } from '../../../ui'; +import { Card } from '../../../ui'; + +// Stage labels for display +const STAGE_LABELS: Record = { + [ProcessStage.MIXING]: 'Mezclado', + [ProcessStage.PROOFING]: 'Fermentación', + [ProcessStage.SHAPING]: 'Formado', + [ProcessStage.BAKING]: 'Horneado', + [ProcessStage.COOLING]: 'Enfriado', + [ProcessStage.PACKAGING]: 'Empaquetado', + [ProcessStage.FINISHING]: 'Acabado', +}; + +// Step 1: Product & Recipe Selection +const ProductRecipeStep: React.FC = ({ dataRef, onDataChange }) => { + const data = dataRef?.current || {}; + const { t } = useTranslation(['wizards', 'production']); + const { currentTenant } = useTenant(); + const [selectedRecipe, setSelectedRecipe] = useState(null); + const [loadingRecipe, setLoadingRecipe] = useState(false); + + // Fetch ingredients and recipes + const { data: ingredients = [], isLoading: ingredientsLoading } = useIngredients( + currentTenant?.id || '', + {}, + { enabled: !!currentTenant?.id } + ); + const { data: recipes = [], isLoading: recipesLoading } = useRecipes( + currentTenant?.id || '', + {}, + { enabled: !!currentTenant?.id } + ); + + // Filter finished products + const finishedProducts = useMemo( + () => + ingredients.filter( + (ing: any) => + ing.type === 'finished_product' || + ing.category === 'finished_products' || + ing.name.toLowerCase().includes('pan') || + ing.name.toLowerCase().includes('pastel') || + ing.name.toLowerCase().includes('torta') + ), + [ingredients] + ); + + // Load recipe details when recipe is selected + const handleRecipeChange = async (recipeId: string) => { + if (!recipeId) { + setSelectedRecipe(null); + onDataChange?.({ ...data, recipe_id: '', selectedRecipe: null }); + return; + } + + setLoadingRecipe(true); + try { + const recipe = await recipesService.getRecipe(currentTenant?.id || '', recipeId); + setSelectedRecipe(recipe); + onDataChange?.({ ...data, recipe_id: recipeId, selectedRecipe: recipe }); + } catch (error) { + console.error('Error loading recipe:', error); + setSelectedRecipe(null); + onDataChange?.({ ...data, recipe_id: '', selectedRecipe: null }); + } finally { + setLoadingRecipe(false); + } + }; + + const handleProductChange = (productId: string) => { + const product = finishedProducts.find((p: any) => p.id === productId); + onDataChange?.({ + ...data, + product_id: productId, + product_name: product?.name || '', + }); + }; + + return ( +
+
+ +

+ Seleccionar Producto y Receta +

+

+ Elige el producto a producir y opcionalmente una receta +

+
+ + {ingredientsLoading || recipesLoading ? ( +
+ + Cargando información... +
+ ) : ( + <> +
+
+ + +
+ +
+ + + {loadingRecipe && ( +

+ + Cargando detalles de la receta... +

+ )} +
+
+ + {/* Quality Requirements Preview */} + {selectedRecipe && ( + +

+ + Controles de Calidad Requeridos +

+ {selectedRecipe.quality_check_configuration && selectedRecipe.quality_check_configuration.stages ? ( +
+ {Object.entries(selectedRecipe.quality_check_configuration.stages).map( + ([stage, config]: [string, any]) => { + if (!config.template_ids || config.template_ids.length === 0) return null; + + return ( +
+ {STAGE_LABELS[stage as ProcessStage]} + + {config.template_ids.length} control{config.template_ids.length > 1 ? 'es' : ''} + + {config.blocking && Bloqueante} + {config.is_required && Requerido} +
+ ); + } + )} +
+

+ Umbral de calidad mínimo:{' '} + {selectedRecipe.quality_check_configuration.overall_quality_threshold || 7.0}/10 +

+ {selectedRecipe.quality_check_configuration.critical_stage_blocking && ( +

+ ⚠️ Bloqueo crítico activado: El lote no + puede avanzar si fallan checks críticos +

+ )} +
+
+ ) : ( +

+ Esta receta no tiene controles de calidad configurados. +

+ )} +
+ )} + + )} +
+ ); +}; + +// Step 2: Planning Details +const PlanningDetailsStep: React.FC = ({ dataRef, onDataChange }) => { + const data = dataRef?.current || {}; + const { t } = useTranslation(['wizards', 'production']); + + const getValue = (field: string, defaultValue: any = '') => { + return data[field] ?? defaultValue; + }; + + const handleFieldChange = (updates: Record) => { + onDataChange?.({ ...data, ...updates }); + }; + + // Auto-calculate end time based on start time and duration + const calculateEndTime = (startTime: string, durationMinutes: number): string => { + if (!startTime || !durationMinutes) return ''; + const start = new Date(startTime); + const end = new Date(start.getTime() + durationMinutes * 60000); + return end.toISOString().slice(0, 16); + }; + + // Handle start time or duration change + const handleStartTimeChange = (startTime: string) => { + const duration = getValue('planned_duration_minutes', 60); + const endTime = calculateEndTime(startTime, duration); + handleFieldChange({ + planned_start_time: startTime, + planned_end_time: endTime, + }); + }; + + const handleDurationChange = (duration: number) => { + const startTime = getValue('planned_start_time'); + const endTime = startTime ? calculateEndTime(startTime, duration) : ''; + handleFieldChange({ + planned_duration_minutes: duration, + planned_end_time: endTime, + }); + }; + + return ( +
+
+ +

Planificación de Producción

+

Define tiempos y cantidades de producción

+
+ +
+
+ + handleStartTimeChange(e.target.value)} + className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]" + /> +
+ +
+ + handleDurationChange(parseInt(e.target.value) || 0)} + min="1" + className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]" + /> +
+ +
+ + +

Se calcula automáticamente

+
+ +
+ + handleFieldChange({ planned_quantity: parseFloat(e.target.value) || 1 })} + min="1" + step="1" + className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]" + /> +
+
+ + {getValue('planned_start_time') && getValue('planned_end_time') && ( +
+
+ +
+

Resumen de Planificación:

+

+ Inicio: {new Date(getValue('planned_start_time')).toLocaleString('es-ES')} +

+

Fin: {new Date(getValue('planned_end_time')).toLocaleString('es-ES')}

+

+ Duración: {getValue('planned_duration_minutes', 0)} minutos ( + {(getValue('planned_duration_minutes', 0) / 60).toFixed(1)} horas) +

+
+
+
+ )} +
+ ); +}; + +// Step 3: Priority & Resources +const PriorityResourcesStep: React.FC = ({ dataRef, onDataChange }) => { + const data = dataRef?.current || {}; + const { t } = useTranslation(['wizards', 'production']); + + const getValue = (field: string, defaultValue: any = '') => { + return data[field] ?? defaultValue; + }; + + const handleFieldChange = (updates: Record) => { + onDataChange?.({ ...data, ...updates }); + }; + + const priorityOptions = Object.values(ProductionPriorityEnum).map((value) => ({ + value, + label: value.charAt(0) + value.slice(1).toLowerCase(), + })); + + return ( +
+
+ +

Prioridad y Recursos

+

Configura la prioridad y asigna recursos

+
+ +
+
+ + +
+ +
+ + handleFieldChange({ batch_number: e.target.value })} + placeholder="Se generará automáticamente si se deja vacío" + className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]" + /> +
+ +
+ handleFieldChange({ is_rush_order: e.target.checked })} + className="rounded border-[var(--border-secondary)] text-[var(--color-primary)] focus:ring-[var(--color-primary)]" + /> + +
+ +
+ handleFieldChange({ is_special_recipe: e.target.checked })} + className="rounded border-[var(--border-secondary)] text-[var(--color-primary)] focus:ring-[var(--color-primary)]" + /> + +
+
+ +
+ + handleFieldChange({ staff_assigned_string: e.target.value })} + placeholder="Separar nombres con comas (Ej: Juan Pérez, María García)" + className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]" + /> +

Lista de nombres separados por comas

+
+ +
+ +