Improve frontend 3

This commit is contained in:
Urtzi Alfaro
2025-11-19 22:12:51 +01:00
parent 938df0866e
commit 29e6ddcea9
17 changed files with 2215 additions and 268 deletions

View File

@@ -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 {

View File

@@ -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<UnifiedAddWizardProps> = ({
initialItemType || null
);
const [wizardData, setWizardData] = useState<AnyWizardData>({});
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<UnifiedAddWizardProps> = ({
setSelectedItemType(initialItemType || null);
setWizardData({});
dataRef.current = {};
setIsSubmitting(false);
onClose();
}, [onClose, initialItemType]);
@@ -66,17 +83,101 @@ export const UnifiedAddWizard: React.FC<UnifiedAddWizardProps> = ({
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<UnifiedAddWizardProps> = ({
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<UnifiedAddWizardProps> = ({
'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';

View File

@@ -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, string> = {
[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<WizardStepProps> = ({ dataRef, onDataChange }) => {
const data = dataRef?.current || {};
const { t } = useTranslation(['wizards', 'production']);
const { currentTenant } = useTenant();
const [selectedRecipe, setSelectedRecipe] = useState<any>(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 (
<div className="space-y-6">
<div className="text-center pb-4 border-b border-[var(--border-primary)]">
<Package className="w-12 h-12 mx-auto mb-3 text-[var(--color-primary)]" />
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">
Seleccionar Producto y Receta
</h3>
<p className="text-sm text-[var(--text-secondary)]">
Elige el producto a producir y opcionalmente una receta
</p>
</div>
{ingredientsLoading || recipesLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-[var(--color-primary)]" />
<span className="ml-3 text-[var(--text-secondary)]">Cargando información...</span>
</div>
) : (
<>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
Producto a Producir *
</label>
<select
value={data.product_id || ''}
onChange={(e) => handleProductChange(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)]"
>
<option value="">Seleccionar producto...</option>
{finishedProducts.map((product: any) => (
<option key={product.id} value={product.id}>
{product.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
Receta a Utilizar (Opcional)
</label>
<select
value={data.recipe_id || ''}
onChange={(e) => handleRecipeChange(e.target.value)}
disabled={loadingRecipe}
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)] disabled:opacity-50"
>
<option value="">Sin receta específica</option>
{recipes.map((recipe: any) => (
<option key={recipe.id} value={recipe.id}>
{recipe.name}
</option>
))}
</select>
{loadingRecipe && (
<p className="text-sm text-[var(--text-secondary)] mt-1 flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
Cargando detalles de la receta...
</p>
)}
</div>
</div>
{/* Quality Requirements Preview */}
{selectedRecipe && (
<Card className="mt-4 p-4 bg-blue-50 border-blue-200">
<h4 className="font-medium text-[var(--text-primary)] mb-3 flex items-center gap-2">
<ClipboardCheck className="w-5 h-5 text-blue-600" />
Controles de Calidad Requeridos
</h4>
{selectedRecipe.quality_check_configuration && selectedRecipe.quality_check_configuration.stages ? (
<div className="space-y-3">
{Object.entries(selectedRecipe.quality_check_configuration.stages).map(
([stage, config]: [string, any]) => {
if (!config.template_ids || config.template_ids.length === 0) return null;
return (
<div key={stage} className="flex items-center gap-2 text-sm">
<Badge variant="info">{STAGE_LABELS[stage as ProcessStage]}</Badge>
<span className="text-[var(--text-secondary)]">
{config.template_ids.length} control{config.template_ids.length > 1 ? 'es' : ''}
</span>
{config.blocking && <Badge variant="warning" size="sm">Bloqueante</Badge>}
{config.is_required && <Badge variant="error" size="sm">Requerido</Badge>}
</div>
);
}
)}
<div className="mt-3 pt-3 border-t border-blue-200">
<p className="text-sm text-[var(--text-secondary)]">
<span className="font-medium">Umbral de calidad mínimo:</span>{' '}
{selectedRecipe.quality_check_configuration.overall_quality_threshold || 7.0}/10
</p>
{selectedRecipe.quality_check_configuration.critical_stage_blocking && (
<p className="text-sm text-[var(--text-secondary)] mt-1">
<span className="font-medium text-orange-600"> Bloqueo crítico activado:</span> El lote no
puede avanzar si fallan checks críticos
</p>
)}
</div>
</div>
) : (
<p className="text-sm text-[var(--text-secondary)]">
Esta receta no tiene controles de calidad configurados.
</p>
)}
</Card>
)}
</>
)}
</div>
);
};
// Step 2: Planning Details
const PlanningDetailsStep: React.FC<WizardStepProps> = ({ 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<string, any>) => {
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 (
<div className="space-y-6">
<div className="text-center pb-4 border-b border-[var(--border-primary)]">
<Clock className="w-12 h-12 mx-auto mb-3 text-[var(--color-primary)]" />
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">Planificación de Producción</h3>
<p className="text-sm text-[var(--text-secondary)]">Define tiempos y cantidades de producción</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Inicio Planificado *</label>
<input
type="datetime-local"
value={getValue('planned_start_time')}
onChange={(e) => 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)]"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
Duración (minutos) *
</label>
<input
type="number"
value={getValue('planned_duration_minutes', 60)}
onChange={(e) => 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)]"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
Fin Planificado (Calculado)
</label>
<input
type="datetime-local"
value={getValue('planned_end_time')}
readOnly
disabled
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg bg-[var(--bg-secondary)] text-[var(--text-tertiary)] cursor-not-allowed"
/>
<p className="text-xs text-[var(--text-tertiary)] mt-1">Se calcula automáticamente</p>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
Cantidad Planificada *
</label>
<input
type="number"
value={getValue('planned_quantity', 1)}
onChange={(e) => 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)]"
/>
</div>
</div>
{getValue('planned_start_time') && getValue('planned_end_time') && (
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-start gap-2">
<Clock className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
<div className="text-sm">
<p className="font-medium text-blue-900">Resumen de Planificación:</p>
<p className="text-blue-700 mt-1">
Inicio: {new Date(getValue('planned_start_time')).toLocaleString('es-ES')}
</p>
<p className="text-blue-700">Fin: {new Date(getValue('planned_end_time')).toLocaleString('es-ES')}</p>
<p className="text-blue-700">
Duración: {getValue('planned_duration_minutes', 0)} minutos (
{(getValue('planned_duration_minutes', 0) / 60).toFixed(1)} horas)
</p>
</div>
</div>
</div>
)}
</div>
);
};
// Step 3: Priority & Resources
const PriorityResourcesStep: React.FC<WizardStepProps> = ({ 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<string, any>) => {
onDataChange?.({ ...data, ...updates });
};
const priorityOptions = Object.values(ProductionPriorityEnum).map((value) => ({
value,
label: value.charAt(0) + value.slice(1).toLowerCase(),
}));
return (
<div className="space-y-6">
<div className="text-center pb-4 border-b border-[var(--border-primary)]">
<AlertCircle className="w-12 h-12 mx-auto mb-3 text-[var(--color-primary)]" />
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">Prioridad y Recursos</h3>
<p className="text-sm text-[var(--text-secondary)]">Configura la prioridad y asigna recursos</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Prioridad *</label>
<select
value={getValue('priority', ProductionPriorityEnum.MEDIUM)}
onChange={(e) => handleFieldChange({ priority: 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)]"
>
{priorityOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Número de Lote</label>
<input
type="text"
value={getValue('batch_number')}
onChange={(e) => 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)]"
/>
</div>
<div className="flex items-center gap-3 p-3 border border-[var(--border-secondary)] rounded-lg">
<input
type="checkbox"
checked={getValue('is_rush_order', false)}
onChange={(e) => handleFieldChange({ is_rush_order: e.target.checked })}
className="rounded border-[var(--border-secondary)] text-[var(--color-primary)] focus:ring-[var(--color-primary)]"
/>
<label className="text-sm font-medium text-[var(--text-primary)]">Orden Urgente</label>
</div>
<div className="flex items-center gap-3 p-3 border border-[var(--border-secondary)] rounded-lg">
<input
type="checkbox"
checked={getValue('is_special_recipe', false)}
onChange={(e) => handleFieldChange({ is_special_recipe: e.target.checked })}
className="rounded border-[var(--border-secondary)] text-[var(--color-primary)] focus:ring-[var(--color-primary)]"
/>
<label className="text-sm font-medium text-[var(--text-primary)]">Receta Especial</label>
</div>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
<Users className="w-4 h-4 inline mr-1.5" />
Personal Asignado (Opcional)
</label>
<input
type="text"
value={getValue('staff_assigned_string', '')}
onChange={(e) => 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)]"
/>
<p className="text-xs text-[var(--text-tertiary)] mt-1">Lista de nombres separados por comas</p>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
Notas de Producción (Opcional)
</label>
<textarea
value={getValue('production_notes')}
onChange={(e) => handleFieldChange({ production_notes: e.target.value })}
placeholder="Instrucciones especiales, observaciones, etc."
rows={4}
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)]"
/>
</div>
<AdvancedOptionsSection title="Opciones Avanzadas" description="Información adicional de producción">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">ID de Estación</label>
<input
type="text"
value={getValue('station_id')}
onChange={(e) => handleFieldChange({ station_id: e.target.value })}
placeholder="Ej: oven-1, mixer-2"
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)]"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">ID de Pedido</label>
<input
type="text"
value={getValue('order_id')}
onChange={(e) => handleFieldChange({ order_id: e.target.value })}
placeholder="ID del pedido asociado"
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)]"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">ID de Pronóstico</label>
<input
type="text"
value={getValue('forecast_id')}
onChange={(e) => handleFieldChange({ forecast_id: e.target.value })}
placeholder="ID del pronóstico asociado"
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)]"
/>
</div>
</div>
</AdvancedOptionsSection>
</div>
);
};
// Step 4: Review
const ReviewStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange }) => {
const data = dataRef?.current || {};
const { t } = useTranslation(['wizards', 'production']);
return (
<div className="space-y-6">
<div className="text-center pb-4 border-b border-[var(--border-primary)]">
<CheckCircle2 className="w-12 h-12 mx-auto mb-3 text-[var(--color-primary)]" />
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">Revisar y Confirmar</h3>
<p className="text-sm text-[var(--text-secondary)]">Verifica los detalles antes de crear el lote</p>
</div>
{/* Product Info */}
<div className="p-4 bg-[var(--bg-secondary)]/50 rounded-lg border border-[var(--border-secondary)]">
<h4 className="font-semibold text-[var(--text-primary)] mb-3 flex items-center gap-2">
<Package className="w-5 h-5" />
Información del Producto
</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-[var(--text-secondary)]">Producto:</span>
<span className="font-medium">{data.product_name || 'N/A'}</span>
</div>
<div className="flex justify-between">
<span className="text-[var(--text-secondary)]">Receta:</span>
<span className="font-medium">{data.selectedRecipe?.name || 'Sin receta específica'}</span>
</div>
{data.batch_number && (
<div className="flex justify-between">
<span className="text-[var(--text-secondary)]">Número de Lote:</span>
<span className="font-medium">{data.batch_number}</span>
</div>
)}
</div>
</div>
{/* Planning Info */}
<div className="p-4 bg-[var(--bg-secondary)]/50 rounded-lg border border-[var(--border-secondary)]">
<h4 className="font-semibold text-[var(--text-primary)] mb-3 flex items-center gap-2">
<Clock className="w-5 h-5" />
Planificación de Producción
</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-[var(--text-secondary)]">Inicio:</span>
<span className="font-medium">
{data.planned_start_time
? new Date(data.planned_start_time).toLocaleString('es-ES')
: 'No especificado'}
</span>
</div>
<div className="flex justify-between">
<span className="text-[var(--text-secondary)]">Fin:</span>
<span className="font-medium">
{data.planned_end_time ? new Date(data.planned_end_time).toLocaleString('es-ES') : 'No especificado'}
</span>
</div>
<div className="flex justify-between">
<span className="text-[var(--text-secondary)]">Duración:</span>
<span className="font-medium">
{data.planned_duration_minutes} minutos ({(data.planned_duration_minutes / 60).toFixed(1)} horas)
</span>
</div>
<div className="flex justify-between">
<span className="text-[var(--text-secondary)]">Cantidad:</span>
<span className="font-medium">{data.planned_quantity} unidades</span>
</div>
</div>
</div>
{/* Priority & Resources */}
<div className="p-4 bg-[var(--bg-secondary)]/50 rounded-lg border border-[var(--border-secondary)]">
<h4 className="font-semibold text-[var(--text-primary)] mb-3 flex items-center gap-2">
<AlertCircle className="w-5 h-5" />
Prioridad y Recursos
</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-[var(--text-secondary)]">Prioridad:</span>
<span className="font-medium capitalize">{data.priority || ProductionPriorityEnum.MEDIUM}</span>
</div>
<div className="flex justify-between">
<span className="text-[var(--text-secondary)]">Orden Urgente:</span>
<span className="font-medium">{data.is_rush_order ? 'Sí' : 'No'}</span>
</div>
<div className="flex justify-between">
<span className="text-[var(--text-secondary)]">Receta Especial:</span>
<span className="font-medium">{data.is_special_recipe ? 'Sí' : 'No'}</span>
</div>
{data.staff_assigned_string && (
<div className="pt-2 border-t border-[var(--border-primary)]">
<span className="text-[var(--text-secondary)] block mb-1">Personal Asignado:</span>
<span className="font-medium">{data.staff_assigned_string}</span>
</div>
)}
{data.production_notes && (
<div className="pt-2 border-t border-[var(--border-primary)]">
<span className="text-[var(--text-secondary)] block mb-1">Notas:</span>
<span className="font-medium">{data.production_notes}</span>
</div>
)}
</div>
</div>
{/* Quality Requirements Preview */}
{data.selectedRecipe?.quality_check_configuration?.stages && (
<Card className="p-4 bg-blue-50 border-blue-200">
<h4 className="font-medium text-[var(--text-primary)] mb-3 flex items-center gap-2">
<ClipboardCheck className="w-5 h-5 text-blue-600" />
Controles de Calidad Requeridos
</h4>
<div className="space-y-2">
{Object.entries(data.selectedRecipe.quality_check_configuration.stages).map(
([stage, config]: [string, any]) => {
if (!config.template_ids || config.template_ids.length === 0) return null;
return (
<div key={stage} className="flex items-center gap-2 text-sm">
<Badge variant="info">{STAGE_LABELS[stage as ProcessStage]}</Badge>
<span className="text-[var(--text-secondary)]">
{config.template_ids.length} control{config.template_ids.length > 1 ? 'es' : ''}
</span>
{config.blocking && <Badge variant="warning" size="sm">Bloqueante</Badge>}
{config.is_required && <Badge variant="error" size="sm">Requerido</Badge>}
</div>
);
}
)}
</div>
</Card>
)}
</div>
);
};
export const ProductionBatchWizardSteps = (
dataRef: React.MutableRefObject<Record<string, any>>,
setData: (data: Record<string, any>) => void
): WizardStep[] => {
return [
{
id: 'product-recipe',
title: 'Producto y Receta',
component: ProductRecipeStep,
validate: () => {
const data = dataRef.current;
if (!data.product_id) {
return 'Debes seleccionar un producto';
}
return true;
},
},
{
id: 'planning-details',
title: 'Planificación',
component: PlanningDetailsStep,
validate: () => {
const data = dataRef.current;
if (!data.planned_start_time) {
return 'Debes especificar la fecha y hora de inicio';
}
if (!data.planned_duration_minutes || data.planned_duration_minutes <= 0) {
return 'La duración debe ser mayor a 0 minutos';
}
if (!data.planned_quantity || data.planned_quantity <= 0) {
return 'La cantidad debe ser mayor a 0';
}
if (data.planned_end_time && new Date(data.planned_end_time) <= new Date(data.planned_start_time)) {
return 'La fecha de fin debe ser posterior a la fecha de inicio';
}
return true;
},
},
{
id: 'priority-resources',
title: 'Prioridad y Recursos',
component: PriorityResourcesStep,
},
{
id: 'review',
title: 'Revisar y Confirmar',
component: ReviewStep,
},
];
};

View File

@@ -0,0 +1,731 @@
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 {
Building2,
Package,
Calendar,
CheckCircle2,
Plus,
Trash2,
Search,
Loader2,
AlertCircle,
TrendingUp,
} from 'lucide-react';
import { useTenant } from '../../../../stores/tenant.store';
import { useSuppliers } from '../../../../api/hooks/suppliers';
import { useIngredients } from '../../../../api/hooks/inventory';
import { suppliersService } from '../../../../api/services/suppliers';
import { useCreatePurchaseOrder } from '../../../../api/hooks/purchase-orders';
// Step 1: Supplier Selection
const SupplierSelectionStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange }) => {
const data = dataRef?.current || {};
const { t } = useTranslation(['wizards', 'procurement']);
const { currentTenant } = useTenant();
const [searchQuery, setSearchQuery] = useState('');
const [selectedSupplier, setSelectedSupplier] = useState(data.supplier || null);
// Fetch suppliers
const { data: suppliersData, isLoading, isError } = useSuppliers(
currentTenant?.id || '',
{ limit: 100 },
{ enabled: !!currentTenant?.id }
);
const suppliers = (suppliersData || []).filter((s: any) => s.status === 'active');
const filteredSuppliers = suppliers.filter((supplier: any) =>
supplier.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
supplier.supplier_code?.toLowerCase().includes(searchQuery.toLowerCase())
);
const handleSelectSupplier = (supplier: any) => {
setSelectedSupplier(supplier);
onDataChange?.({
...data,
supplier,
supplier_id: supplier.id,
});
};
return (
<div className="space-y-6">
<div className="text-center pb-4 border-b border-[var(--border-primary)]">
<Building2 className="w-12 h-12 mx-auto mb-3 text-[var(--color-primary)]" />
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">
Seleccionar Proveedor
</h3>
<p className="text-sm text-[var(--text-secondary)]">
Elige el proveedor para esta orden de compra
</p>
</div>
{isError && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
Error al cargar proveedores
</div>
)}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-[var(--color-primary)]" />
<span className="ml-3 text-[var(--text-secondary)]">Cargando proveedores...</span>
</div>
) : (
<>
{/* Search Bar */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-[var(--text-tertiary)]" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Buscar proveedor por nombre o código..."
className="w-full pl-10 pr-4 py-3 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)]"
/>
</div>
{/* Supplier List */}
<div className="space-y-3 max-h-96 overflow-y-auto pr-2">
{filteredSuppliers.length === 0 ? (
<div className="text-center py-8 border-2 border-dashed border-[var(--border-secondary)] rounded-lg">
<Building2 className="w-12 h-12 mx-auto mb-3 text-[var(--text-tertiary)]" />
<p className="text-[var(--text-secondary)] mb-1">No se encontraron proveedores</p>
<p className="text-sm text-[var(--text-tertiary)]">Intenta con una búsqueda diferente</p>
</div>
) : (
filteredSuppliers.map((supplier: any) => (
<button
key={supplier.id}
onClick={() => handleSelectSupplier(supplier)}
className={`w-full p-4 rounded-xl border-2 transition-all text-left group hover:shadow-md ${
selectedSupplier?.id === supplier.id
? 'border-[var(--color-primary)] bg-gradient-to-r from-[var(--color-primary)]/10 to-[var(--color-primary)]/5 shadow-sm'
: 'border-[var(--border-secondary)] hover:border-[var(--color-primary)]/50 hover:bg-[var(--bg-secondary)]/30'
}`}
>
<div className="flex items-start gap-3">
<div
className={`w-12 h-12 rounded-full flex items-center justify-center flex-shrink-0 transition-colors ${
selectedSupplier?.id === supplier.id
? 'bg-[var(--color-primary)] text-white'
: 'bg-[var(--bg-tertiary)] text-[var(--text-tertiary)] group-hover:bg-[var(--color-primary)]/20 group-hover:text-[var(--color-primary)]'
}`}
>
<Building2 className="w-6 h-6" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h4
className={`font-semibold truncate transition-colors ${
selectedSupplier?.id === supplier.id
? 'text-[var(--color-primary)]'
: 'text-[var(--text-primary)]'
}`}
>
{supplier.name}
</h4>
{selectedSupplier?.id === supplier.id && (
<CheckCircle2 className="w-5 h-5 text-[var(--color-primary)] flex-shrink-0" />
)}
</div>
<div className="flex flex-wrap items-center gap-2 text-sm text-[var(--text-secondary)]">
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
{supplier.supplier_code}
</span>
{supplier.email && <span>📧 {supplier.email}</span>}
{supplier.phone && <span>📱 {supplier.phone}</span>}
</div>
</div>
</div>
</button>
))
)}
</div>
</>
)}
</div>
);
};
// Step 2: Add Items
const AddItemsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange }) => {
const data = dataRef?.current || {};
const { t } = useTranslation(['wizards', 'procurement']);
const { currentTenant } = useTenant();
const [supplierProductIds, setSupplierProductIds] = useState<string[]>([]);
const [isLoadingSupplierProducts, setIsLoadingSupplierProducts] = useState(false);
// Fetch ALL ingredients
const { data: allIngredientsData = [], isLoading: isLoadingIngredients } = useIngredients(
currentTenant?.id || '',
{},
{ enabled: !!currentTenant?.id }
);
// Fetch supplier products when supplier is available
useEffect(() => {
const fetchSupplierProducts = async () => {
if (!data.supplier_id || !currentTenant?.id) {
setSupplierProductIds([]);
return;
}
setIsLoadingSupplierProducts(true);
try {
const products = await suppliersService.getSupplierProducts(currentTenant.id, data.supplier_id);
const productIds = products.map((p: any) => p.inventory_product_id);
setSupplierProductIds(productIds);
} catch (error) {
console.error('Error fetching supplier products:', error);
setSupplierProductIds([]);
} finally {
setIsLoadingSupplierProducts(false);
}
};
fetchSupplierProducts();
}, [data.supplier_id, currentTenant?.id]);
// Filter ingredients based on supplier products
const ingredientsData = useMemo(() => {
if (!data.supplier_id || supplierProductIds.length === 0) {
return [];
}
return allIngredientsData.filter((ing: any) => supplierProductIds.includes(ing.id));
}, [allIngredientsData, supplierProductIds, data.supplier_id]);
const handleAddItem = () => {
onDataChange?.({
...data,
items: [
...(data.items || []),
{
id: Date.now(),
inventory_product_id: '',
product_name: '',
ordered_quantity: 1,
unit_price: 0,
unit_of_measure: 'kg',
subtotal: 0,
},
],
});
};
const handleUpdateItem = (index: number, field: string, value: any) => {
const updated = (data.items || []).map((item: any, i: number) => {
if (i === index) {
const newItem = { ...item, [field]: value };
if (field === 'inventory_product_id') {
const product = ingredientsData.find((p: any) => p.id === value);
if (product) {
newItem.product_name = product.name;
newItem.unit_price = product.last_purchase_price || product.average_cost || 0;
newItem.unit_of_measure = product.unit_of_measure;
}
}
if (field === 'ordered_quantity' || field === 'unit_price' || field === 'inventory_product_id') {
newItem.subtotal = (newItem.ordered_quantity || 0) * (newItem.unit_price || 0);
}
return newItem;
}
return item;
});
onDataChange?.({ ...data, items: updated });
};
const handleRemoveItem = (index: number) => {
onDataChange?.({ ...data, items: (data.items || []).filter((_: any, i: number) => i !== index) });
};
const calculateTotal = () => {
return (data.items || []).reduce((sum: number, item: any) => sum + (item.subtotal || 0), 0);
};
const unitOptions = [
{ value: 'kg', label: 'Kilogramos' },
{ value: 'g', label: 'Gramos' },
{ value: 'l', label: 'Litros' },
{ value: 'ml', label: 'Mililitros' },
{ value: 'units', label: 'Unidades' },
{ value: 'boxes', label: 'Cajas' },
{ value: 'bags', label: 'Bolsas' },
];
return (
<div className="space-y-6">
<div className="text-center pb-4 border-b border-[var(--border-primary)]">
<Package className="w-12 h-12 mx-auto mb-3 text-[var(--color-primary)]" />
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">Productos a Comprar</h3>
<p className="text-sm text-[var(--text-secondary)]">
Proveedor: <span className="font-semibold">{data.supplier?.name || 'N/A'}</span>
</p>
</div>
{isLoadingIngredients || isLoadingSupplierProducts ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-[var(--color-primary)]" />
<span className="ml-3 text-[var(--text-secondary)]">Cargando productos...</span>
</div>
) : (
<>
<div className="space-y-3">
<div className="flex items-center justify-between">
<label className="block text-sm font-medium text-[var(--text-secondary)]">
Productos en la orden
</label>
<button
onClick={handleAddItem}
disabled={ingredientsData.length === 0}
className="px-3 py-1.5 text-sm bg-[var(--color-primary)] text-white rounded-md hover:bg-[var(--color-primary)]/90 transition-colors flex items-center gap-1 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Plus className="w-4 h-4" />
Agregar Producto
</button>
</div>
{ingredientsData.length === 0 && (
<div className="p-4 bg-amber-50 border border-amber-200 rounded-lg text-amber-700 text-sm flex items-center gap-2">
<AlertCircle className="w-5 h-5 flex-shrink-0" />
<span>
Este proveedor no tiene ingredientes asignados. Configura la lista de precios del proveedor primero.
</span>
</div>
)}
{(data.items || []).length === 0 ? (
<div className="text-center py-12 border-2 border-dashed border-[var(--border-secondary)] rounded-lg text-[var(--text-tertiary)]">
<Package className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p className="mb-2">No hay productos en la orden</p>
<p className="text-sm">Haz clic en "Agregar Producto" para comenzar</p>
</div>
) : (
<div className="space-y-3">
{(data.items || []).map((item: any, index: number) => (
<div
key={item.id}
className="p-4 border border-[var(--border-secondary)] rounded-lg bg-[var(--bg-secondary)]/30 space-y-3"
>
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-[var(--text-primary)]">
Producto #{index + 1}
</span>
<button
onClick={() => handleRemoveItem(index)}
className="p-1 text-red-500 hover:text-red-700 transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="md:col-span-2">
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">
Ingrediente *
</label>
<select
value={item.inventory_product_id}
onChange={(e) => handleUpdateItem(index, 'inventory_product_id', e.target.value)}
className="w-full px-3 py-2 text-sm 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)]"
>
<option value="">Seleccionar ingrediente...</option>
{ingredientsData.map((product: any) => (
<option key={product.id} value={product.id}>
{product.name} - {(product.last_purchase_price || product.average_cost || 0).toFixed(2)} /{' '}
{product.unit_of_measure}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">
Cantidad *
</label>
<input
type="number"
value={item.ordered_quantity}
onChange={(e) =>
handleUpdateItem(index, 'ordered_quantity', parseFloat(e.target.value) || 0)
}
className="w-full px-3 py-2 text-sm 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)]"
min="0"
step="0.01"
/>
</div>
<div>
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">Unidad *</label>
<select
value={item.unit_of_measure}
onChange={(e) => handleUpdateItem(index, 'unit_of_measure', e.target.value)}
className="w-full px-3 py-2 text-sm 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)]"
>
{unitOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="md:col-span-2">
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">
Precio Unitario () *
</label>
<input
type="number"
value={item.unit_price}
onChange={(e) => handleUpdateItem(index, 'unit_price', parseFloat(e.target.value) || 0)}
className="w-full px-3 py-2 text-sm 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)]"
min="0"
step="0.01"
/>
</div>
</div>
<div className="pt-2 border-t border-[var(--border-primary)] text-sm">
<span className="font-semibold text-[var(--text-primary)]">
Subtotal: {item.subtotal.toFixed(2)}
</span>
</div>
</div>
))}
</div>
)}
{(data.items || []).length > 0 && (
<div className="p-4 bg-gradient-to-r from-[var(--color-primary)]/5 to-[var(--color-primary)]/10 rounded-lg border-2 border-[var(--color-primary)]/20">
<div className="flex justify-between items-center">
<span className="text-lg font-semibold text-[var(--text-primary)]">Total:</span>
<span className="text-2xl font-bold text-[var(--color-primary)]">{calculateTotal().toFixed(2)}</span>
</div>
</div>
)}
</div>
</>
)}
</div>
);
};
// Step 3: Order Details
const OrderDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange }) => {
const data = dataRef?.current || {};
const { t } = useTranslation(['wizards', 'procurement']);
const getValue = (field: string, defaultValue: any = '') => {
return data[field] ?? defaultValue;
};
const handleFieldChange = (updates: Record<string, any>) => {
onDataChange?.({ ...data, ...updates });
};
const priorityOptions = [
{ value: 'low', label: 'Baja' },
{ value: 'normal', label: 'Normal' },
{ value: 'high', label: 'Alta' },
{ value: 'critical', label: 'Crítica' },
];
return (
<div className="space-y-6">
<div className="text-center pb-4 border-b border-[var(--border-primary)]">
<Calendar className="w-12 h-12 mx-auto mb-3 text-[var(--color-primary)]" />
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">Detalles de la Orden</h3>
<p className="text-sm text-[var(--text-secondary)]">Configura fecha de entrega y prioridad</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
Fecha de Entrega Requerida *
</label>
<input
type="date"
value={getValue('required_delivery_date')}
onChange={(e) => handleFieldChange({ required_delivery_date: e.target.value })}
min={new Date().toISOString().split('T')[0]}
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)]"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Prioridad *</label>
<select
value={getValue('priority', 'normal')}
onChange={(e) => handleFieldChange({ priority: 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)]"
>
{priorityOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
Notas (Opcional)
</label>
<textarea
value={getValue('notes')}
onChange={(e) => handleFieldChange({ notes: e.target.value })}
placeholder="Instrucciones especiales para el proveedor..."
rows={4}
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)]"
/>
</div>
</div>
<AdvancedOptionsSection
title="Opciones Avanzadas"
description="Información financiera adicional"
>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Impuestos ()</label>
<input
type="number"
value={getValue('tax_amount', 0)}
onChange={(e) => handleFieldChange({ tax_amount: parseFloat(e.target.value) || 0 })}
min="0"
step="0.01"
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)]"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Costo de Envío ()</label>
<input
type="number"
value={getValue('shipping_cost', 0)}
onChange={(e) => handleFieldChange({ shipping_cost: parseFloat(e.target.value) || 0 })}
min="0"
step="0.01"
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)]"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Descuento ()</label>
<input
type="number"
value={getValue('discount_amount', 0)}
onChange={(e) => handleFieldChange({ discount_amount: parseFloat(e.target.value) || 0 })}
min="0"
step="0.01"
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)]"
/>
</div>
</div>
</AdvancedOptionsSection>
</div>
);
};
// Step 4: Review & Submit
const ReviewSubmitStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange }) => {
const data = dataRef?.current || {};
const { t } = useTranslation(['wizards', 'procurement']);
const calculateSubtotal = () => {
return (data.items || []).reduce((sum: number, item: any) => sum + (item.subtotal || 0), 0);
};
const calculateTotal = () => {
const subtotal = calculateSubtotal();
const tax = data.tax_amount || 0;
const shipping = data.shipping_cost || 0;
const discount = data.discount_amount || 0;
return subtotal + tax + shipping - discount;
};
return (
<div className="space-y-6">
<div className="text-center pb-4 border-b border-[var(--border-primary)]">
<CheckCircle2 className="w-12 h-12 mx-auto mb-3 text-[var(--color-primary)]" />
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">Revisar y Confirmar</h3>
<p className="text-sm text-[var(--text-secondary)]">Verifica los detalles antes de crear la orden</p>
</div>
{/* Supplier Info */}
<div className="p-4 bg-[var(--bg-secondary)]/50 rounded-lg border border-[var(--border-secondary)]">
<h4 className="font-semibold text-[var(--text-primary)] mb-3 flex items-center gap-2">
<Building2 className="w-5 h-5" />
Información del Proveedor
</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-[var(--text-secondary)]">Proveedor:</span>
<span className="font-medium">{data.supplier?.name || 'N/A'}</span>
</div>
<div className="flex justify-between">
<span className="text-[var(--text-secondary)]">Código:</span>
<span className="font-medium">{data.supplier?.supplier_code || 'N/A'}</span>
</div>
{data.supplier?.email && (
<div className="flex justify-between">
<span className="text-[var(--text-secondary)]">Email:</span>
<span className="font-medium">{data.supplier.email}</span>
</div>
)}
</div>
</div>
{/* Order Details */}
<div className="p-4 bg-[var(--bg-secondary)]/50 rounded-lg border border-[var(--border-secondary)]">
<h4 className="font-semibold text-[var(--text-primary)] mb-3 flex items-center gap-2">
<Calendar className="w-5 h-5" />
Detalles de la Orden
</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-[var(--text-secondary)]">Fecha de Entrega:</span>
<span className="font-medium">
{data.required_delivery_date
? new Date(data.required_delivery_date).toLocaleDateString('es-ES')
: 'No especificada'}
</span>
</div>
<div className="flex justify-between">
<span className="text-[var(--text-secondary)]">Prioridad:</span>
<span className="font-medium capitalize">{data.priority || 'normal'}</span>
</div>
{data.notes && (
<div className="pt-2 border-t border-[var(--border-primary)]">
<span className="text-[var(--text-secondary)] block mb-1">Notas:</span>
<span className="font-medium">{data.notes}</span>
</div>
)}
</div>
</div>
{/* Items */}
<div className="p-4 bg-[var(--bg-secondary)]/50 rounded-lg border border-[var(--border-secondary)]">
<h4 className="font-semibold text-[var(--text-primary)] mb-3 flex items-center gap-2">
<Package className="w-5 h-5" />
Productos ({(data.items || []).length})
</h4>
<div className="space-y-2">
{(data.items || []).map((item: any, index: number) => (
<div
key={item.id}
className="flex justify-between items-start p-3 bg-[var(--bg-primary)] rounded-lg border border-[var(--border-secondary)]"
>
<div className="flex-1">
<p className="font-medium text-[var(--text-primary)]">{item.product_name || 'Producto sin nombre'}</p>
<p className="text-sm text-[var(--text-secondary)]">
{item.ordered_quantity} {item.unit_of_measure} × {item.unit_price.toFixed(2)}
</p>
</div>
<div className="text-right">
<p className="font-semibold text-[var(--text-primary)]">{item.subtotal.toFixed(2)}</p>
</div>
</div>
))}
</div>
</div>
{/* Financial Summary */}
<div className="p-4 bg-gradient-to-r from-[var(--color-primary)]/5 to-[var(--color-primary)]/10 rounded-lg border-2 border-[var(--color-primary)]/20">
<h4 className="font-semibold text-[var(--text-primary)] mb-3 flex items-center gap-2">
<TrendingUp className="w-5 h-5" />
Resumen Financiero
</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-[var(--text-secondary)]">Subtotal:</span>
<span className="font-medium">{calculateSubtotal().toFixed(2)}</span>
</div>
{(data.tax_amount || 0) > 0 && (
<div className="flex justify-between">
<span className="text-[var(--text-secondary)]">Impuestos:</span>
<span className="font-medium">{(data.tax_amount || 0).toFixed(2)}</span>
</div>
)}
{(data.shipping_cost || 0) > 0 && (
<div className="flex justify-between">
<span className="text-[var(--text-secondary)]">Costo de Envío:</span>
<span className="font-medium">{(data.shipping_cost || 0).toFixed(2)}</span>
</div>
)}
{(data.discount_amount || 0) > 0 && (
<div className="flex justify-between">
<span className="text-[var(--text-secondary)]">Descuento:</span>
<span className="font-medium text-green-600">-{(data.discount_amount || 0).toFixed(2)}</span>
</div>
)}
<div className="pt-2 border-t-2 border-[var(--color-primary)]/30 flex justify-between">
<span className="text-lg font-semibold text-[var(--text-primary)]">Total:</span>
<span className="text-2xl font-bold text-[var(--color-primary)]">{calculateTotal().toFixed(2)}</span>
</div>
</div>
</div>
</div>
);
};
export const PurchaseOrderWizardSteps = (
dataRef: React.MutableRefObject<Record<string, any>>,
setData: (data: Record<string, any>) => void
): WizardStep[] => {
return [
{
id: 'supplier-selection',
title: 'Seleccionar Proveedor',
component: SupplierSelectionStep,
validate: () => {
const data = dataRef.current;
if (!data.supplier_id) {
return 'Debes seleccionar un proveedor';
}
return true;
},
},
{
id: 'add-items',
title: 'Agregar Productos',
component: AddItemsStep,
validate: () => {
const data = dataRef.current;
if (!data.items || data.items.length === 0) {
return 'Debes agregar al menos un producto';
}
const invalidItems = data.items.some(
(item: any) => !item.inventory_product_id || item.ordered_quantity <= 0 || item.unit_price <= 0
);
if (invalidItems) {
return 'Todos los productos deben tener ingrediente, cantidad y precio válidos';
}
return true;
},
},
{
id: 'order-details',
title: 'Detalles de la Orden',
component: OrderDetailsStep,
validate: () => {
const data = dataRef.current;
if (!data.required_delivery_date) {
return 'Debes especificar una fecha de entrega';
}
return true;
},
},
{
id: 'review-submit',
title: 'Revisar y Confirmar',
component: ReviewSubmitStep,
},
];
};