Improve frontend 3
This commit is contained in:
@@ -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<ProductionStatusCardProps> = ({
|
||||
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<ProductionStatusCardProps> = ({
|
||||
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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -175,7 +175,7 @@ export const SubscriptionPricingCards: React.FC<SubscriptionPricingCardsProps> =
|
||||
</div>
|
||||
|
||||
{/* Simplified Plans Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 items-stretch">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 items-start lg:items-stretch">
|
||||
{Object.entries(plans).map(([tier, plan]) => {
|
||||
const price = getPrice(plan);
|
||||
const savings = getSavings(plan);
|
||||
@@ -196,7 +196,7 @@ export const SubscriptionPricingCards: React.FC<SubscriptionPricingCardsProps> =
|
||||
relative rounded-2xl p-8 transition-all duration-300 block no-underline
|
||||
${mode === 'settings' ? 'cursor-pointer' : mode === 'landing' ? 'cursor-pointer' : ''}
|
||||
${isPopular
|
||||
? 'bg-gradient-to-br from-blue-600 to-blue-800 shadow-xl ring-2 ring-blue-400'
|
||||
? 'bg-gradient-to-br from-blue-600 to-blue-800 shadow-xl ring-2 ring-blue-400 lg:scale-105 lg:z-10'
|
||||
: 'bg-[var(--bg-secondary)] border-2 border-[var(--border-primary)] hover:border-[var(--color-primary)] hover:shadow-lg'
|
||||
}
|
||||
`}
|
||||
@@ -256,6 +256,34 @@ export const SubscriptionPricingCards: React.FC<SubscriptionPricingCardsProps> =
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Feature Inheritance Indicator */}
|
||||
{tier === SUBSCRIPTION_TIERS.PROFESSIONAL && (
|
||||
<div className={`mb-5 px-4 py-3 rounded-xl transition-all ${
|
||||
isPopular
|
||||
? 'bg-gradient-to-r from-white/20 to-white/10 border-2 border-white/40 shadow-lg'
|
||||
: 'bg-gradient-to-r from-blue-500/10 to-blue-600/5 border-2 border-blue-400/30 dark:from-blue-400/10 dark:to-blue-500/5 dark:border-blue-400/30'
|
||||
}`}>
|
||||
<p className={`text-xs font-bold uppercase tracking-wide text-center ${
|
||||
isPopular ? 'text-white drop-shadow-sm' : 'text-blue-600 dark:text-blue-300'
|
||||
}`}>
|
||||
✓ {t('ui.feature_inheritance_professional')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{tier === SUBSCRIPTION_TIERS.ENTERPRISE && (
|
||||
<div className={`mb-5 px-4 py-3 rounded-xl transition-all ${
|
||||
isPopular
|
||||
? 'bg-gradient-to-r from-white/20 to-white/10 border-2 border-white/40 shadow-lg'
|
||||
: 'bg-gradient-to-r from-gray-700/20 to-gray-800/10 border-2 border-gray-600/40 dark:from-gray-600/20 dark:to-gray-700/10 dark:border-gray-500/40'
|
||||
}`}>
|
||||
<p className={`text-xs font-bold uppercase tracking-wide text-center ${
|
||||
isPopular ? 'text-white drop-shadow-sm' : 'text-gray-700 dark:text-gray-300'
|
||||
}`}>
|
||||
✓ {t('ui.feature_inheritance_enterprise')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top 3 Benefits + Key Limits */}
|
||||
<div className="space-y-3 mb-6">
|
||||
{/* Business Benefits */}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { formatters } from '../Stats/StatsPresets';
|
||||
export interface EditViewModalField {
|
||||
label: string;
|
||||
value: string | number | React.ReactNode;
|
||||
type?: 'text' | 'currency' | 'date' | 'datetime' | 'percentage' | 'list' | 'status' | 'image' | 'email' | 'tel' | 'number' | 'select' | 'textarea' | 'component';
|
||||
type?: 'text' | 'currency' | 'date' | 'datetime' | 'percentage' | 'list' | 'status' | 'image' | 'email' | 'tel' | 'number' | 'select' | 'textarea' | 'component' | 'custom' | 'button';
|
||||
highlight?: boolean;
|
||||
span?: 1 | 2 | 3; // For grid layout - added 3 for full width on larger screens
|
||||
editable?: boolean; // Whether this field can be edited
|
||||
@@ -22,6 +22,10 @@ export interface EditViewModalField {
|
||||
helpText?: string; // Help text displayed below the field
|
||||
component?: React.ComponentType<any>; // For custom components
|
||||
componentProps?: Record<string, any>; // Props for custom components
|
||||
customRenderer?: (value: any) => React.ReactNode; // For custom rendering in view mode
|
||||
buttonText?: string; // Text for button type fields
|
||||
onButtonClick?: () => void; // Click handler for button type fields
|
||||
readonly?: boolean; // Whether this field is read-only
|
||||
}
|
||||
|
||||
export interface EditViewModalSection {
|
||||
@@ -152,7 +156,12 @@ const renderEditableField = (
|
||||
onChange?: (value: string | number) => void,
|
||||
validationError?: string
|
||||
): React.ReactNode => {
|
||||
// Handle custom components FIRST - they work in both view and edit modes
|
||||
// Handle custom renderer FIRST - for custom view mode rendering
|
||||
if (field.type === 'custom' && field.customRenderer) {
|
||||
return field.customRenderer(field.value);
|
||||
}
|
||||
|
||||
// Handle custom components - they work in both view and edit modes
|
||||
if (field.type === 'component' && field.component) {
|
||||
const Component = field.component;
|
||||
return (
|
||||
@@ -164,6 +173,23 @@ const renderEditableField = (
|
||||
);
|
||||
}
|
||||
|
||||
// Handle button type fields
|
||||
if (field.type === 'button') {
|
||||
return (
|
||||
<button
|
||||
onClick={field.onButtonClick}
|
||||
disabled={!isEditMode}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
isEditMode
|
||||
? 'bg-[var(--color-primary)] text-white hover:bg-[var(--color-primary)]/90'
|
||||
: 'bg-gray-300 text-gray-600 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{field.buttonText || field.value}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Then check if we should render as view or edit
|
||||
if (!isEditMode || !field.editable) {
|
||||
return formatFieldValue(field.value, field.type);
|
||||
|
||||
@@ -125,8 +125,8 @@
|
||||
"intro": "100% of your data belongs to you. Measure your environmental impact automatically and generate sustainability reports that comply with international standards.",
|
||||
"data_ownership_value": "100%",
|
||||
"data_ownership": "Data ownership",
|
||||
"co2_metric": "CO₂",
|
||||
"co2": "Automatic measurement",
|
||||
"co2_metric": "Waste",
|
||||
"co2": "Automatic reduction",
|
||||
"sdg_value": "Green",
|
||||
"sdg": "Sustainability certified",
|
||||
"sustainability_title": "🔒 Private by default, sustainable at its core.",
|
||||
|
||||
@@ -17,12 +17,12 @@
|
||||
"mobile_app_access": "Mobile app access",
|
||||
"email_support": "Email support",
|
||||
"easy_step_by_step_onboarding": "Easy step-by-step onboarding",
|
||||
"basic_forecasting": "Basic forecasting",
|
||||
"basic_forecasting": "Smart daily demand predictions for your products",
|
||||
"demand_prediction": "AI demand prediction",
|
||||
"waste_tracking": "Waste tracking",
|
||||
"waste_tracking": "Reduce waste with real-time tracking and insights",
|
||||
"order_management": "Order management",
|
||||
"customer_management": "Customer management",
|
||||
"supplier_management": "Supplier management",
|
||||
"supplier_management": "Manage suppliers and automate ordering",
|
||||
"batch_tracking": "Track each batch",
|
||||
"expiry_alerts": "Expiry alerts",
|
||||
"advanced_analytics": "Easy-to-understand reports",
|
||||
|
||||
@@ -125,8 +125,8 @@
|
||||
"intro": "100% de tus datos te pertenecen. Mide tu impacto ambiental automáticamente y genera informes de sostenibilidad que cumplen con los estándares internacionales.",
|
||||
"data_ownership_value": "100%",
|
||||
"data_ownership": "Propiedad de datos",
|
||||
"co2_metric": "CO₂",
|
||||
"co2": "Medición automática",
|
||||
"co2_metric": "Hondakinak",
|
||||
"co2": "Murrizketa automatikoa",
|
||||
"sdg_value": "Verde",
|
||||
"sdg": "Certificado de sostenibilidad",
|
||||
"sustainability_title": "🔒 Privados por defecto, sostenibles de serie.",
|
||||
|
||||
@@ -17,12 +17,12 @@
|
||||
"mobile_app_access": "Acceso desde app móvil",
|
||||
"email_support": "Soporte por email",
|
||||
"easy_step_by_step_onboarding": "Onboarding guiado paso a paso",
|
||||
"basic_forecasting": "Pronósticos básicos",
|
||||
"basic_forecasting": "Predicciones inteligentes de demanda diaria",
|
||||
"demand_prediction": "Predicción de demanda IA",
|
||||
"waste_tracking": "Seguimiento de desperdicios",
|
||||
"waste_tracking": "Reduce desperdicios con seguimiento en tiempo real",
|
||||
"order_management": "Gestión de pedidos",
|
||||
"customer_management": "Gestión de clientes",
|
||||
"supplier_management": "Gestión de proveedores",
|
||||
"supplier_management": "Gestiona tus proveedores y pedidos automáticos",
|
||||
"batch_tracking": "Rastrea cada hornada",
|
||||
"expiry_alerts": "Alertas de caducidad",
|
||||
"advanced_analytics": "Informes fáciles de entender",
|
||||
|
||||
@@ -125,8 +125,8 @@
|
||||
"intro": "Zure datuen %100 zureak dira. Neurtu zure ingurumen-inpaktua automatikoki eta sortu nazioarteko estandarrak betetzen dituzten iraunkortasun-txostenak.",
|
||||
"data_ownership_value": "100%",
|
||||
"data_ownership": "Datuen jabetza",
|
||||
"co2_metric": "CO₂",
|
||||
"co2": "Neurketa automatikoa",
|
||||
"co2_metric": "Hondakinak",
|
||||
"co2": "Murrizketa automatikoa",
|
||||
"sdg_value": "Berdea",
|
||||
"sdg": "Iraunkortasun ziurtagiria",
|
||||
"sustainability_title": "🔒 Pribatua berez, jasangarria bere muinean."
|
||||
|
||||
@@ -17,12 +17,12 @@
|
||||
"mobile_app_access": "Aplikazio mugikorretik sarbidea",
|
||||
"email_support": "Posta elektronikoaren laguntza",
|
||||
"easy_step_by_step_onboarding": "Onboarding gidatua pausoz pauso",
|
||||
"basic_forecasting": "Oinarrizko iragarpenak",
|
||||
"basic_forecasting": "Eguneko eskari iragarpen adimentsuak zure produktuentzat",
|
||||
"demand_prediction": "AI eskariaren iragarpena",
|
||||
"waste_tracking": "Hondakinen jarraipena",
|
||||
"waste_tracking": "Murriztu hondakinak denbora errealeko jarraipenarekin",
|
||||
"order_management": "Eskaeren kudeaketa",
|
||||
"customer_management": "Bezeroen kudeaketa",
|
||||
"supplier_management": "Hornitzaileen kudeaketa",
|
||||
"supplier_management": "Kudeatu hornitzaileak eta automatizatu eskaerak",
|
||||
"batch_tracking": "Jarraitu lote bakoitza",
|
||||
"expiry_alerts": "Iraungitze alertak",
|
||||
"advanced_analytics": "Txosten ulerterrazak",
|
||||
|
||||
@@ -3,8 +3,9 @@ import { Plus, ShoppingCart, Euro, Calendar, CheckCircle, AlertCircle, Package,
|
||||
import { Button, Card, StatsGrid, StatusCard, getStatusColor, EditViewModal, SearchAndFilter, Input, type FilterConfig, EmptyState } from '../../../../components/ui';
|
||||
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { CreatePurchaseOrderModal } from '../../../../components/domain/procurement/CreatePurchaseOrderModal';
|
||||
import { UnifiedPurchaseOrderModal } from '../../../../components/domain/procurement/UnifiedPurchaseOrderModal';
|
||||
import { UnifiedAddWizard } from '../../../../components/domain/unified-wizard';
|
||||
import type { ItemType } from '../../../../components/domain/unified-wizard';
|
||||
import {
|
||||
usePurchaseOrders,
|
||||
usePurchaseOrder,
|
||||
@@ -24,7 +25,7 @@ const ProcurementPage: React.FC = () => {
|
||||
const [statusFilter, setStatusFilter] = useState<PurchaseOrderStatus | ''>('');
|
||||
const [priorityFilter, setPriorityFilter] = useState<PurchaseOrderPriority | ''>('');
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [showCreatePOModal, setShowCreatePOModal] = useState(false);
|
||||
const [isWizardOpen, setIsWizardOpen] = useState(false);
|
||||
const [showDetailsModal, setShowDetailsModal] = useState(false);
|
||||
const [selectedPOId, setSelectedPOId] = useState<string | null>(null);
|
||||
const [showApprovalModal, setShowApprovalModal] = useState(false);
|
||||
@@ -395,7 +396,7 @@ const ProcurementPage: React.FC = () => {
|
||||
id: 'create-po',
|
||||
label: 'Nueva Orden',
|
||||
icon: Plus,
|
||||
onClick: () => setShowCreatePOModal(true),
|
||||
onClick: () => setIsWizardOpen(true),
|
||||
variant: 'primary',
|
||||
size: 'md'
|
||||
}
|
||||
@@ -471,7 +472,7 @@ const ProcurementPage: React.FC = () => {
|
||||
description="Comienza creando una nueva orden de compra"
|
||||
actionLabel="Nueva Orden"
|
||||
actionIcon={Plus}
|
||||
onAction={() => setShowCreatePOModal(true)}
|
||||
onAction={() => setIsWizardOpen(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
@@ -514,19 +515,16 @@ const ProcurementPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create PO Modal */}
|
||||
{showCreatePOModal && (
|
||||
<CreatePurchaseOrderModal
|
||||
isOpen={showCreatePOModal}
|
||||
onClose={() => setShowCreatePOModal(false)}
|
||||
requirements={[]}
|
||||
onSuccess={() => {
|
||||
setShowCreatePOModal(false);
|
||||
refetchPOs();
|
||||
showToast.success('Orden de compra creada exitosamente');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Unified Add Wizard for Purchase Orders */}
|
||||
<UnifiedAddWizard
|
||||
isOpen={isWizardOpen}
|
||||
onClose={() => setIsWizardOpen(false)}
|
||||
onComplete={(itemType: ItemType, data?: any) => {
|
||||
console.log('Purchase order created:', data);
|
||||
refetchPOs();
|
||||
}}
|
||||
initialItemType="purchase-order"
|
||||
/>
|
||||
|
||||
{/* PO Details Modal - Using Unified Component */}
|
||||
{showDetailsModal && selectedPOId && (
|
||||
|
||||
@@ -5,7 +5,9 @@ import { statusColors } from '../../../../styles/colors';
|
||||
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
||||
import { LoadingSpinner } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { ProductionSchedule, CreateProductionBatchModal, ProductionStatusCard, QualityCheckModal, ProcessStageTracker } from '../../../../components/domain/production';
|
||||
import { ProductionSchedule, ProductionStatusCard, QualityCheckModal, ProcessStageTracker } from '../../../../components/domain/production';
|
||||
import { UnifiedAddWizard } from '../../../../components/domain/unified-wizard';
|
||||
import type { ItemType } from '../../../../components/domain/unified-wizard';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
import {
|
||||
useProductionDashboard,
|
||||
@@ -34,7 +36,7 @@ const ProductionPage: React.FC = () => {
|
||||
const [priorityFilter, setPriorityFilter] = useState('');
|
||||
const [selectedBatch, setSelectedBatch] = useState<ProductionBatchResponse | null>(null);
|
||||
const [showBatchModal, setShowBatchModal] = useState(false);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [isWizardOpen, setIsWizardOpen] = useState(false);
|
||||
const [showQualityModal, setShowQualityModal] = useState(false);
|
||||
const [modalMode, setModalMode] = useState<'view' | 'edit'>('view');
|
||||
|
||||
@@ -324,7 +326,7 @@ const ProductionPage: React.FC = () => {
|
||||
id: 'create-batch',
|
||||
label: 'Nueva Orden de Producción',
|
||||
icon: PlusCircle,
|
||||
onClick: () => setShowCreateModal(true),
|
||||
onClick: () => setIsWizardOpen(true),
|
||||
variant: 'primary',
|
||||
size: 'md'
|
||||
}
|
||||
@@ -420,11 +422,6 @@ const ProductionPage: React.FC = () => {
|
||||
setModalMode('view');
|
||||
setShowBatchModal(true);
|
||||
}}
|
||||
onEdit={(batch) => {
|
||||
setSelectedBatch(batch);
|
||||
setModalMode('edit');
|
||||
setShowBatchModal(true);
|
||||
}}
|
||||
onStart={async (batch) => {
|
||||
try {
|
||||
await updateBatchStatusMutation.mutateAsync({
|
||||
@@ -469,11 +466,6 @@ const ProductionPage: React.FC = () => {
|
||||
setSelectedBatch(batch);
|
||||
setShowQualityModal(true);
|
||||
}}
|
||||
onViewHistory={(batch) => {
|
||||
setSelectedBatch(batch);
|
||||
setModalMode('view');
|
||||
setShowBatchModal(true);
|
||||
}}
|
||||
showDetailedProgress={true}
|
||||
/>
|
||||
))}
|
||||
@@ -491,14 +483,14 @@ const ProductionPage: React.FC = () => {
|
||||
}
|
||||
actionLabel="Nueva Orden de Producción"
|
||||
actionIcon={Plus}
|
||||
onAction={() => setShowCreateModal(true)}
|
||||
onAction={() => setIsWizardOpen(true)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
|
||||
|
||||
{/* Production Batch Modal */}
|
||||
{/* Production Batch Detail Modal */}
|
||||
{showBatchModal && selectedBatch && (
|
||||
<EditViewModal
|
||||
isOpen={showBatchModal}
|
||||
@@ -516,35 +508,34 @@ const ProductionPage: React.FC = () => {
|
||||
text: t(`production:status.${selectedBatch.status.toLowerCase()}`),
|
||||
icon: Package
|
||||
}}
|
||||
size="lg"
|
||||
size="xl"
|
||||
sections={[
|
||||
{
|
||||
title: 'Información General',
|
||||
icon: Package,
|
||||
fields: [
|
||||
{
|
||||
label: 'Producto',
|
||||
value: selectedBatch.product_name,
|
||||
highlight: true
|
||||
},
|
||||
{
|
||||
label: 'Número de Lote',
|
||||
value: selectedBatch.batch_number
|
||||
},
|
||||
{
|
||||
label: 'Cantidad Planificada',
|
||||
value: `${selectedBatch.planned_quantity} unidades`,
|
||||
highlight: true
|
||||
},
|
||||
{
|
||||
label: 'Cantidad Real',
|
||||
label: 'Cantidad Producida',
|
||||
value: selectedBatch.actual_quantity
|
||||
? `${selectedBatch.actual_quantity} unidades`
|
||||
: 'Pendiente',
|
||||
editable: modalMode === 'edit',
|
||||
type: 'number'
|
||||
},
|
||||
{
|
||||
label: 'Prioridad',
|
||||
value: selectedBatch.priority,
|
||||
type: 'select',
|
||||
editable: modalMode === 'edit',
|
||||
options: Object.values(ProductionPriorityEnum).map(value => ({
|
||||
value,
|
||||
label: t(`production:priority.${value.toLowerCase()}`)
|
||||
}))
|
||||
},
|
||||
{
|
||||
label: 'Estado',
|
||||
value: selectedBatch.status,
|
||||
@@ -555,11 +546,25 @@ const ProductionPage: React.FC = () => {
|
||||
label: t(`production:status.${value.toLowerCase()}`)
|
||||
}))
|
||||
},
|
||||
{
|
||||
label: 'Prioridad',
|
||||
value: selectedBatch.priority,
|
||||
type: 'select',
|
||||
editable: modalMode === 'edit',
|
||||
options: Object.values(ProductionPriorityEnum).map(value => ({
|
||||
value,
|
||||
label: t(`production:priority.${value.toLowerCase()}`)
|
||||
}))
|
||||
},
|
||||
{
|
||||
label: 'Personal Asignado',
|
||||
value: selectedBatch.staff_assigned?.join(', ') || 'No asignado',
|
||||
editable: modalMode === 'edit',
|
||||
type: 'text'
|
||||
},
|
||||
{
|
||||
label: 'Equipos Utilizados',
|
||||
value: selectedBatch.equipment_used?.join(', ') || 'No especificado'
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -577,6 +582,12 @@ const ProductionPage: React.FC = () => {
|
||||
value: selectedBatch.planned_end_time,
|
||||
type: 'datetime'
|
||||
},
|
||||
{
|
||||
label: 'Duración Planificada',
|
||||
value: selectedBatch.planned_duration_minutes
|
||||
? `${selectedBatch.planned_duration_minutes} minutos`
|
||||
: 'No especificada'
|
||||
},
|
||||
{
|
||||
label: 'Inicio Real',
|
||||
value: selectedBatch.actual_start_time || 'Pendiente',
|
||||
@@ -586,11 +597,17 @@ const ProductionPage: React.FC = () => {
|
||||
label: 'Fin Real',
|
||||
value: selectedBatch.actual_end_time || 'Pendiente',
|
||||
type: 'datetime'
|
||||
},
|
||||
{
|
||||
label: 'Duración Real',
|
||||
value: selectedBatch.actual_duration_minutes
|
||||
? `${selectedBatch.actual_duration_minutes} minutos`
|
||||
: 'Pendiente'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Seguimiento de Proceso',
|
||||
title: 'Fases del Proceso de Producción',
|
||||
icon: Timer,
|
||||
fields: [
|
||||
{
|
||||
@@ -657,7 +674,8 @@ const ProductionPage: React.FC = () => {
|
||||
label: 'Puntuación de Calidad',
|
||||
value: selectedBatch.quality_score
|
||||
? `${selectedBatch.quality_score}/10`
|
||||
: 'Pendiente'
|
||||
: 'Pendiente',
|
||||
highlight: selectedBatch.quality_score ? selectedBatch.quality_score >= 8 : false
|
||||
},
|
||||
{
|
||||
label: 'Rendimiento',
|
||||
@@ -674,6 +692,19 @@ const ProductionPage: React.FC = () => {
|
||||
label: 'Costo Real',
|
||||
value: selectedBatch.actual_cost || 0,
|
||||
type: 'currency'
|
||||
},
|
||||
{
|
||||
label: 'Notas de Producción',
|
||||
value: selectedBatch.production_notes || 'Sin notas',
|
||||
type: 'textarea',
|
||||
editable: modalMode === 'edit',
|
||||
span: 2
|
||||
},
|
||||
{
|
||||
label: 'Notas de Calidad',
|
||||
value: selectedBatch.quality_notes || 'Sin notas de calidad',
|
||||
type: 'textarea',
|
||||
span: 2
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -696,39 +727,33 @@ const ProductionPage: React.FC = () => {
|
||||
onFieldChange={(sectionIndex, fieldIndex, value) => {
|
||||
if (!selectedBatch) return;
|
||||
|
||||
const sections = [
|
||||
['planned_quantity', 'actual_quantity', 'priority', 'status', 'staff_assigned'],
|
||||
['planned_start_time', 'planned_end_time', 'actual_start_time', 'actual_end_time'],
|
||||
['quality_score', 'yield_percentage', 'estimated_cost', 'actual_cost']
|
||||
];
|
||||
|
||||
// Get the field names from modal sections
|
||||
const sectionFields = [
|
||||
{ fields: ['planned_quantity', 'actual_quantity', 'priority', 'status', 'staff_assigned'] },
|
||||
{ fields: ['planned_start_time', 'planned_end_time', 'actual_start_time', 'actual_end_time'] },
|
||||
{ fields: ['quality_score', 'yield_percentage', 'estimated_cost', 'actual_cost'] }
|
||||
];
|
||||
|
||||
const fieldMapping: Record<string, string> = {
|
||||
'Cantidad Real': 'actual_quantity',
|
||||
'Prioridad': 'priority',
|
||||
// General Information
|
||||
'Cantidad Producida': 'actual_quantity',
|
||||
'Estado': 'status',
|
||||
'Personal Asignado': 'staff_assigned'
|
||||
'Prioridad': 'priority',
|
||||
'Personal Asignado': 'staff_assigned',
|
||||
// Schedule - most fields are read-only datetime
|
||||
// Quality and Costs
|
||||
'Notas de Producción': 'production_notes',
|
||||
'Notas de Calidad': 'quality_notes'
|
||||
};
|
||||
|
||||
// Get section labels to map back to field names
|
||||
const sectionLabels = [
|
||||
['Cantidad Planificada', 'Cantidad Real', 'Prioridad', 'Estado', 'Personal Asignado'],
|
||||
['Inicio Planificado', 'Fin Planificado', 'Inicio Real', 'Fin Real'],
|
||||
['Puntuación de Calidad', 'Rendimiento', 'Costo Estimado', 'Costo Real']
|
||||
['Producto', 'Número de Lote', 'Cantidad Planificada', 'Cantidad Producida', 'Estado', 'Prioridad', 'Personal Asignado', 'Equipos Utilizados'],
|
||||
['Inicio Planificado', 'Fin Planificado', 'Duración Planificada', 'Inicio Real', 'Fin Real', 'Duración Real'],
|
||||
[], // Process Stage Tracker section - no editable fields
|
||||
['Puntuación de Calidad', 'Rendimiento', 'Costo Estimado', 'Costo Real', 'Notas de Producción', 'Notas de Calidad']
|
||||
];
|
||||
|
||||
const fieldLabel = sectionLabels[sectionIndex]?.[fieldIndex];
|
||||
const propertyName = fieldMapping[fieldLabel] || sectionFields[sectionIndex]?.fields[fieldIndex];
|
||||
const propertyName = fieldMapping[fieldLabel];
|
||||
|
||||
if (propertyName) {
|
||||
let processedValue: any = value;
|
||||
|
||||
// Process specific field types
|
||||
if (propertyName === 'staff_assigned' && typeof value === 'string') {
|
||||
processedValue = value.split(',').map(s => s.trim()).filter(s => s.length > 0);
|
||||
} else if (propertyName === 'actual_quantity') {
|
||||
@@ -744,11 +769,15 @@ const ProductionPage: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Create Production Batch Modal */}
|
||||
<CreateProductionBatchModal
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onCreateBatch={handleCreateBatch}
|
||||
{/* Unified Add Wizard for Production Batches */}
|
||||
<UnifiedAddWizard
|
||||
isOpen={isWizardOpen}
|
||||
onClose={() => setIsWizardOpen(false)}
|
||||
onComplete={(itemType: ItemType, data?: any) => {
|
||||
console.log('Production batch created:', data);
|
||||
refetchBatches();
|
||||
}}
|
||||
initialItemType="production-batch"
|
||||
/>
|
||||
|
||||
{/* Quality Check Modal */}
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Plus, Star, Clock, Euro, Package, Eye, Edit, ChefHat, Timer, CheckCircle, Trash2, Settings, FileText } from 'lucide-react';
|
||||
import { Button, StatsGrid, StatusCard, getStatusColor, EditViewModal, SearchAndFilter, type FilterConfig, EmptyState } from '../../../../components/ui';
|
||||
import { LoadingSpinner } from '../../../../components/ui';
|
||||
import { Plus, Star, Clock, Euro, Package, Eye, ChefHat, Timer, CheckCircle, Trash2, Settings, FileText } from 'lucide-react';
|
||||
import { StatsGrid, StatusCard, getStatusColor, EditViewModal, SearchAndFilter, type FilterConfig, EmptyState } from '../../../../components/ui';
|
||||
import { QualityPromptDialog } from '../../../../components/ui/QualityPromptDialog';
|
||||
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { useRecipes, useRecipeStatistics, useCreateRecipe, useUpdateRecipe, useDeleteRecipe, useArchiveRecipe } from '../../../../api/hooks/recipes';
|
||||
import { useRecipes, useCreateRecipe, useUpdateRecipe, useDeleteRecipe, useArchiveRecipe } from '../../../../api/hooks/recipes';
|
||||
import { recipesService } from '../../../../api/services/recipes';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
import type { RecipeResponse, RecipeCreate } from '../../../../api/types/recipes';
|
||||
import { MeasurementUnit } from '../../../../api/types/recipes';
|
||||
import { useQualityTemplatesForRecipe } from '../../../../api/hooks/qualityTemplates';
|
||||
import { useIngredients } from '../../../../api/hooks/inventory';
|
||||
import { ProcessStage, type RecipeQualityConfiguration } from '../../../../api/types/qualityTemplates';
|
||||
import { CreateRecipeModal, DeleteRecipeModal } from '../../../../components/domain/recipes';
|
||||
@@ -50,6 +48,15 @@ const IngredientsEditComponent: React.FC<{
|
||||
onChange(updated);
|
||||
};
|
||||
|
||||
const [expandedIngredients, setExpandedIngredients] = React.useState<Record<number, boolean>>({});
|
||||
|
||||
const toggleExpanded = (index: number) => {
|
||||
setExpandedIngredients(prev => ({
|
||||
...prev,
|
||||
[index]: !prev[index]
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -60,81 +67,189 @@ const IngredientsEditComponent: React.FC<{
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-[var(--color-primary)] text-white rounded-md hover:bg-[var(--color-primary)]/90 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Agregar
|
||||
Agregar Ingrediente
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||
{ingredientsArray.map((ingredient, index) => (
|
||||
<div key={ingredient.id || index} className="p-3 border border-[var(--border-secondary)] rounded-lg bg-[var(--bg-secondary)]/50 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">Ingrediente #{index + 1}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeIngredient(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 sm:grid-cols-3 gap-3">
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">Ingrediente</label>
|
||||
<select
|
||||
value={ingredient.ingredient_id}
|
||||
onChange={(e) => updateIngredient(index, 'ingredient_id', e.target.value)}
|
||||
className="w-full px-2 py-1.5 border border-[var(--border-secondary)] rounded focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] text-sm"
|
||||
>
|
||||
<option value="">Seleccionar...</option>
|
||||
{availableIngredients.map(ing => (
|
||||
<option key={ing.value} value={ing.value}>{ing.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">Cantidad</label>
|
||||
<input
|
||||
type="number"
|
||||
value={ingredient.quantity}
|
||||
onChange={(e) => updateIngredient(index, 'quantity', parseFloat(e.target.value) || 0)}
|
||||
className="w-full px-2 py-1.5 border border-[var(--border-secondary)] rounded focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] text-sm"
|
||||
min="0"
|
||||
step="0.1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">Unidad</label>
|
||||
<select
|
||||
value={ingredient.unit}
|
||||
onChange={(e) => updateIngredient(index, 'unit', e.target.value as MeasurementUnit)}
|
||||
className="w-full px-2 py-1.5 border border-[var(--border-secondary)] rounded focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] text-sm"
|
||||
>
|
||||
{unitOptions.map(unit => (
|
||||
<option key={unit.value} value={unit.value}>{unit.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1 flex items-center gap-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={ingredient.is_optional}
|
||||
onChange={(e) => updateIngredient(index, 'is_optional', e.target.checked)}
|
||||
className="rounded border-[var(--border-secondary)] text-[var(--color-primary)] focus:ring-[var(--color-primary)]"
|
||||
/>
|
||||
Opcional
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3 max-h-[600px] overflow-y-auto pr-2">
|
||||
{ingredientsArray.length === 0 ? (
|
||||
<div className="text-center py-8 text-[var(--text-secondary)] bg-[var(--bg-secondary)]/30 rounded-lg border-2 border-dashed border-[var(--border-secondary)]">
|
||||
<Package className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">No hay ingredientes. Haz clic en "Agregar Ingrediente" para comenzar.</p>
|
||||
</div>
|
||||
))}
|
||||
) : (
|
||||
ingredientsArray.map((ingredient, index) => {
|
||||
const isExpanded = expandedIngredients[index];
|
||||
|
||||
return (
|
||||
<div key={ingredient.id || index} className="p-4 border-2 border-[var(--border-secondary)] rounded-lg bg-[var(--bg-secondary)]/50 hover:border-[var(--color-primary)]/30 transition-all space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-full bg-[var(--color-primary)]/10 flex items-center justify-center text-sm font-semibold text-[var(--color-primary)]">
|
||||
{index + 1}
|
||||
</div>
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{availableIngredients.find(i => i.value === ingredient.ingredient_id)?.label || 'Ingrediente sin seleccionar'}
|
||||
</span>
|
||||
{ingredient.is_optional && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-amber-500/10 text-amber-700 dark:text-amber-400 border border-amber-500/20">
|
||||
opcional
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeIngredient(index)}
|
||||
className="p-1.5 text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950 rounded transition-colors"
|
||||
title="Eliminar ingrediente"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Main fields */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-4 gap-3">
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">Ingrediente *</label>
|
||||
<select
|
||||
value={ingredient.ingredient_id}
|
||||
onChange={(e) => updateIngredient(index, 'ingredient_id', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] text-sm"
|
||||
>
|
||||
<option value="">Seleccionar ingrediente...</option>
|
||||
{availableIngredients.map(ing => (
|
||||
<option key={ing.value} value={ing.value}>{ing.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">Cantidad *</label>
|
||||
<input
|
||||
type="number"
|
||||
value={ingredient.quantity}
|
||||
onChange={(e) => updateIngredient(index, 'quantity', parseFloat(e.target.value) || 0)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] text-sm"
|
||||
min="0"
|
||||
step="0.1"
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">Unidad *</label>
|
||||
<select
|
||||
value={ingredient.unit}
|
||||
onChange={(e) => updateIngredient(index, 'unit', e.target.value as MeasurementUnit)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] text-sm"
|
||||
>
|
||||
{unitOptions.map(unit => (
|
||||
<option key={unit.value} value={unit.value}>{unit.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Optional checkbox and expand button */}
|
||||
<div className="flex items-center justify-between pt-2 border-t border-[var(--border-secondary)]">
|
||||
<label className="flex items-center gap-2 text-xs font-medium text-[var(--text-secondary)] cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={ingredient.is_optional}
|
||||
onChange={(e) => updateIngredient(index, 'is_optional', e.target.checked)}
|
||||
className="rounded border-[var(--border-secondary)] text-[var(--color-primary)] focus:ring-[var(--color-primary)]"
|
||||
/>
|
||||
Ingrediente opcional
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleExpanded(index)}
|
||||
className="flex items-center gap-1 text-xs text-[var(--color-primary)] hover:text-[var(--color-primary)]/80 transition-colors"
|
||||
>
|
||||
{isExpanded ? 'Ocultar detalles' : 'Agregar detalles'}
|
||||
<svg
|
||||
className={`w-4 h-4 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Expanded details section */}
|
||||
{isExpanded && (
|
||||
<div className="space-y-3 pt-3 border-t border-[var(--border-secondary)] bg-[var(--bg-primary)]/50 -mx-4 -mb-3 px-4 pb-3 rounded-b-lg">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">
|
||||
Método de preparación
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={ingredient.preparation_method || ''}
|
||||
onChange={(e) => updateIngredient(index, 'preparation_method', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] text-sm"
|
||||
placeholder="ej: tamizada, a temperatura ambiente, derretida..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">
|
||||
Notas del ingrediente
|
||||
</label>
|
||||
<textarea
|
||||
value={ingredient.ingredient_notes || ''}
|
||||
onChange={(e) => updateIngredient(index, 'ingredient_notes', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] text-sm"
|
||||
placeholder="Notas adicionales sobre este ingrediente..."
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">
|
||||
Opciones de sustitución
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={typeof ingredient.substitution_options === 'string' ? ingredient.substitution_options : ''}
|
||||
onChange={(e) => updateIngredient(index, 'substitution_options', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] text-sm"
|
||||
placeholder="ej: Aceite de oliva, mantequilla..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">
|
||||
Ratio de sustitución
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={typeof ingredient.substitution_ratio === 'string' ? ingredient.substitution_ratio : (typeof ingredient.substitution_ratio === 'number' ? String(ingredient.substitution_ratio) : '')}
|
||||
onChange={(e) => updateIngredient(index, 'substitution_ratio', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] text-sm"
|
||||
placeholder="ej: 1:1, 1:1.2..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{ingredientsArray.length > 0 && (
|
||||
<div className="pt-3 border-t border-[var(--border-secondary)]">
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
Total: {ingredientsArray.length} ingrediente{ingredientsArray.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -169,20 +284,12 @@ const RecipesPage: React.FC = () => {
|
||||
// API Data
|
||||
const {
|
||||
data: recipes = [],
|
||||
isLoading: recipesLoading,
|
||||
error: recipesError,
|
||||
isRefetching: isRefetchingRecipes
|
||||
} = useRecipes(tenantId, { search_term: searchTerm || undefined });
|
||||
|
||||
const {
|
||||
data: statisticsData,
|
||||
isLoading: statisticsLoading
|
||||
} = useRecipeStatistics(tenantId);
|
||||
|
||||
// Fetch inventory items for ingredient name lookup
|
||||
const {
|
||||
data: inventoryItems = [],
|
||||
isLoading: inventoryLoading
|
||||
data: inventoryItems = []
|
||||
} = useIngredients(tenantId, {});
|
||||
|
||||
// Create ingredient lookup map (UUID -> name)
|
||||
@@ -254,10 +361,23 @@ const RecipesPage: React.FC = () => {
|
||||
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
|
||||
};
|
||||
|
||||
// Helper to translate category values to labels
|
||||
const getCategoryLabel = (category: string | null | undefined): string => {
|
||||
if (!category) return 'Sin categoría';
|
||||
const categoryLabels: Record<string, string> = {
|
||||
'bread': 'Panes',
|
||||
'pastry': 'Bollería',
|
||||
'cake': 'Tartas',
|
||||
'cookie': 'Galletas',
|
||||
'other': 'Otro'
|
||||
};
|
||||
return categoryLabels[category] || category;
|
||||
};
|
||||
|
||||
const getQualityConfigSummary = (config: any) => {
|
||||
if (!config || !config.stages) return 'No configurado';
|
||||
|
||||
const stageLabels = {
|
||||
const stageLabels: Record<string, string> = {
|
||||
[ProcessStage.MIXING]: 'Mezclado',
|
||||
[ProcessStage.PROOFING]: 'Fermentación',
|
||||
[ProcessStage.SHAPING]: 'Formado',
|
||||
@@ -269,7 +389,7 @@ const RecipesPage: React.FC = () => {
|
||||
|
||||
const configuredStages = Object.keys(config.stages)
|
||||
.filter(stage => config.stages[stage]?.template_ids?.length > 0)
|
||||
.map(stage => stageLabels[stage as ProcessStage] || stage);
|
||||
.map(stage => stageLabels[stage] || stage);
|
||||
|
||||
if (configuredStages.length === 0) return 'No configurado';
|
||||
if (configuredStages.length <= 2) return `Configurado para: ${configuredStages.join(', ')}`;
|
||||
@@ -509,7 +629,7 @@ const RecipesPage: React.FC = () => {
|
||||
const handleRecipeSaveComplete = async () => {
|
||||
if (!tenantId) return;
|
||||
// Invalidate recipes query to trigger refetch
|
||||
await queryClient.invalidateQueries(['recipes', tenantId]);
|
||||
await queryClient.invalidateQueries({ queryKey: ['recipes', tenantId] });
|
||||
};
|
||||
|
||||
// Handle saving edited recipe
|
||||
@@ -520,18 +640,24 @@ const RecipesPage: React.FC = () => {
|
||||
const updateData: any = {
|
||||
...editedRecipe,
|
||||
// Convert time fields from formatted strings back to numbers if needed
|
||||
prep_time_minutes: typeof editedRecipe.prep_time_minutes === 'string'
|
||||
? parseInt(editedRecipe.prep_time_minutes.toString())
|
||||
: editedRecipe.prep_time_minutes,
|
||||
cook_time_minutes: typeof editedRecipe.cook_time_minutes === 'string'
|
||||
? parseInt(editedRecipe.cook_time_minutes.toString())
|
||||
: editedRecipe.cook_time_minutes,
|
||||
prep_time_minutes: editedRecipe.prep_time_minutes !== undefined
|
||||
? (typeof editedRecipe.prep_time_minutes === 'string'
|
||||
? parseInt(String(editedRecipe.prep_time_minutes))
|
||||
: editedRecipe.prep_time_minutes)
|
||||
: undefined,
|
||||
cook_time_minutes: editedRecipe.cook_time_minutes !== undefined
|
||||
? (typeof editedRecipe.cook_time_minutes === 'string'
|
||||
? parseInt(String(editedRecipe.cook_time_minutes))
|
||||
: editedRecipe.cook_time_minutes)
|
||||
: undefined,
|
||||
// Ensure yield_unit is properly typed
|
||||
yield_unit: editedRecipe.yield_unit ? editedRecipe.yield_unit as MeasurementUnit : undefined,
|
||||
// Convert difficulty level to number if needed
|
||||
difficulty_level: typeof editedRecipe.difficulty_level === 'string'
|
||||
? parseInt(editedRecipe.difficulty_level.toString())
|
||||
: editedRecipe.difficulty_level,
|
||||
difficulty_level: editedRecipe.difficulty_level !== undefined
|
||||
? (typeof editedRecipe.difficulty_level === 'string'
|
||||
? parseInt(String(editedRecipe.difficulty_level))
|
||||
: editedRecipe.difficulty_level)
|
||||
: undefined,
|
||||
};
|
||||
|
||||
// Include ingredient updates if they were edited
|
||||
@@ -625,13 +751,33 @@ const RecipesPage: React.FC = () => {
|
||||
const formatJsonField = (jsonData: any): string => {
|
||||
if (!jsonData) return 'No especificado';
|
||||
if (typeof jsonData === 'string') return jsonData;
|
||||
|
||||
// Handle arrays
|
||||
if (Array.isArray(jsonData)) {
|
||||
// Check if it's an array of objects with step/description pattern
|
||||
if (jsonData.length > 0 && typeof jsonData[0] === 'object') {
|
||||
return jsonData.map((item, index) => {
|
||||
if (item.step && item.description) {
|
||||
return `${index + 1}. ${item.step}: ${item.description}`;
|
||||
} else if (item.description) {
|
||||
return `${index + 1}. ${item.description}`;
|
||||
} else if (item.step) {
|
||||
return `${index + 1}. ${item.step}`;
|
||||
}
|
||||
return `${index + 1}. ${JSON.stringify(item)}`;
|
||||
}).join('\n');
|
||||
}
|
||||
// Simple array of strings
|
||||
return jsonData.join(', ');
|
||||
}
|
||||
|
||||
if (typeof jsonData === 'object') {
|
||||
// Extract common patterns
|
||||
if (jsonData.steps) return jsonData.steps;
|
||||
if (jsonData.checkpoints) return jsonData.checkpoints;
|
||||
if (jsonData.issues) return jsonData.issues;
|
||||
if (jsonData.allergens) return jsonData.allergens.join(', ');
|
||||
if (jsonData.tags) return jsonData.tags.join(', ');
|
||||
if (jsonData.allergens) return Array.isArray(jsonData.allergens) ? jsonData.allergens.join(', ') : jsonData.allergens;
|
||||
if (jsonData.tags) return Array.isArray(jsonData.tags) ? jsonData.tags.join(', ') : jsonData.tags;
|
||||
if (jsonData.info) return jsonData.info;
|
||||
return JSON.stringify(jsonData, null, 2);
|
||||
}
|
||||
@@ -675,13 +821,15 @@ const RecipesPage: React.FC = () => {
|
||||
},
|
||||
{
|
||||
label: 'Categoría',
|
||||
value: getFieldValue(selectedRecipe.category || 'Sin categoría', 'category'),
|
||||
value: modalMode === 'edit'
|
||||
? getFieldValue(selectedRecipe.category, 'category')
|
||||
: getCategoryLabel(getFieldValue(selectedRecipe.category, 'category') as string),
|
||||
type: modalMode === 'edit' ? 'select' : 'status',
|
||||
options: modalMode === 'edit' ? [
|
||||
{ value: 'bread', label: 'Pan' },
|
||||
{ value: 'bread', label: 'Panes' },
|
||||
{ value: 'pastry', label: 'Bollería' },
|
||||
{ value: 'cake', label: 'Tarta' },
|
||||
{ value: 'cookie', label: 'Galleta' },
|
||||
{ value: 'cake', label: 'Tartas' },
|
||||
{ value: 'cookie', label: 'Galletas' },
|
||||
{ value: 'other', label: 'Otro' }
|
||||
] : undefined,
|
||||
editable: true
|
||||
@@ -956,32 +1104,137 @@ const RecipesPage: React.FC = () => {
|
||||
icon: Package,
|
||||
fields: [
|
||||
{
|
||||
label: 'Lista de ingredientes',
|
||||
label: '',
|
||||
value: modalMode === 'edit'
|
||||
? (() => {
|
||||
const val = editedIngredients.length > 0 ? editedIngredients : selectedRecipe.ingredients || [];
|
||||
console.log('[RecipesPage] Edit mode - Ingredients value:', val, 'editedIngredients.length:', editedIngredients.length);
|
||||
return val;
|
||||
})()
|
||||
? (editedIngredients.length > 0 ? editedIngredients : selectedRecipe.ingredients || [])
|
||||
: (selectedRecipe.ingredients
|
||||
?.sort((a, b) => a.ingredient_order - b.ingredient_order)
|
||||
?.map(ing => {
|
||||
const ingredientName = ingredientLookup[ing.ingredient_id] || ing.ingredient_id;
|
||||
const optional = ing.is_optional ? ' (opcional)' : '';
|
||||
const prep = ing.preparation_method ? ` - ${ing.preparation_method}` : '';
|
||||
const notes = ing.ingredient_notes ? ` [${ing.ingredient_notes}]` : '';
|
||||
return `${ing.quantity} ${ing.unit} de ${ingredientName}${optional}${prep}${notes}`;
|
||||
const ingredientName = ingredientLookup[ing.ingredient_id] || 'Ingrediente desconocido';
|
||||
const parts = [];
|
||||
|
||||
// Main ingredient line with quantity
|
||||
parts.push(`${ing.quantity} ${ing.unit}`);
|
||||
parts.push(ingredientName);
|
||||
|
||||
// Add optional indicator
|
||||
if (ing.is_optional) {
|
||||
parts.push('(opcional)');
|
||||
}
|
||||
|
||||
// Add preparation method on new line if exists
|
||||
if (ing.preparation_method) {
|
||||
parts.push(`\n → ${ing.preparation_method}`);
|
||||
}
|
||||
|
||||
// Add notes on new line if exists
|
||||
if (ing.ingredient_notes) {
|
||||
parts.push(`\n 💡 ${ing.ingredient_notes}`);
|
||||
}
|
||||
|
||||
// Add substitution info if exists
|
||||
if (ing.substitution_options) {
|
||||
parts.push(`\n 🔄 Sustituto: ${ing.substitution_options}`);
|
||||
}
|
||||
|
||||
return parts.join(' ');
|
||||
}) || ['No especificados']),
|
||||
type: modalMode === 'edit' ? 'component' as const : 'list' as const,
|
||||
type: modalMode === 'edit' ? 'component' as const : 'custom' as const,
|
||||
component: modalMode === 'edit' ? IngredientsEditComponent : undefined,
|
||||
componentProps: modalMode === 'edit' ? {
|
||||
availableIngredients,
|
||||
unitOptions,
|
||||
onChange: (newIngredients: RecipeIngredientResponse[]) => {
|
||||
console.log('[RecipesPage] Ingredients onChange called with:', newIngredients);
|
||||
setEditedIngredients(newIngredients);
|
||||
}
|
||||
} : undefined,
|
||||
customRenderer: modalMode === 'view' ? () => {
|
||||
const ingredients = selectedRecipe.ingredients
|
||||
?.sort((a, b) => a.ingredient_order - b.ingredient_order) || [];
|
||||
|
||||
if (ingredients.length === 0) {
|
||||
return (
|
||||
<div className="text-sm text-[var(--text-secondary)] italic p-4 bg-[var(--bg-secondary)]/30 rounded-md">
|
||||
No hay ingredientes especificados
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{ingredients.map((ing, idx) => {
|
||||
const ingredientName = ingredientLookup[ing.ingredient_id] || 'Ingrediente desconocido';
|
||||
const hasDetails = ing.preparation_method || ing.ingredient_notes || ing.substitution_options;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={ing.id || idx}
|
||||
className="p-3 rounded-lg bg-[var(--bg-secondary)]/50 border border-[var(--border-secondary)] hover:border-[var(--color-primary)]/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-[var(--color-primary)]/10 flex items-center justify-center text-sm font-semibold text-[var(--color-primary)]">
|
||||
{idx + 1}
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-baseline gap-2 flex-wrap">
|
||||
<span className="font-bold text-[var(--color-primary)]">
|
||||
{ing.quantity} {ing.unit}
|
||||
</span>
|
||||
<span className="text-[var(--text-primary)] font-medium">
|
||||
{ingredientName}
|
||||
</span>
|
||||
{ing.is_optional && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-amber-500/10 text-amber-700 dark:text-amber-400 border border-amber-500/20">
|
||||
opcional
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasDetails && (
|
||||
<div className="space-y-1.5 text-sm">
|
||||
{ing.preparation_method && (
|
||||
<div className="flex items-start gap-2 text-[var(--text-secondary)]">
|
||||
<span className="text-blue-500 flex-shrink-0">→</span>
|
||||
<span className="italic">{ing.preparation_method}</span>
|
||||
</div>
|
||||
)}
|
||||
{ing.ingredient_notes && (
|
||||
<div className="flex items-start gap-2 text-[var(--text-secondary)]">
|
||||
<span className="flex-shrink-0">💡</span>
|
||||
<span>{ing.ingredient_notes}</span>
|
||||
</div>
|
||||
)}
|
||||
{ing.substitution_options && (
|
||||
<div className="flex items-start gap-2 text-[var(--text-secondary)]">
|
||||
<span className="flex-shrink-0">🔄</span>
|
||||
<span>
|
||||
<strong>Sustituto:</strong> {ing.substitution_options}
|
||||
{ing.substitution_ratio && ` (ratio: ${ing.substitution_ratio})`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Summary footer */}
|
||||
<div className="pt-2 mt-2 border-t border-[var(--border-secondary)]">
|
||||
<div className="text-sm text-[var(--text-secondary)] flex items-center justify-between">
|
||||
<span>Total: {ingredients.length} ingrediente{ingredients.length !== 1 ? 's' : ''}</span>
|
||||
{ingredients.some(ing => ing.is_optional) && (
|
||||
<span className="text-xs text-amber-600 dark:text-amber-400">
|
||||
⚠️ Incluye ingredientes opcionales
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} : undefined,
|
||||
span: 2,
|
||||
editable: modalMode === 'edit'
|
||||
}
|
||||
@@ -996,13 +1249,128 @@ const RecipesPage: React.FC = () => {
|
||||
value: selectedRecipe.quality_check_configuration
|
||||
? getQualityConfigSummary(selectedRecipe.quality_check_configuration)
|
||||
: 'No configurado',
|
||||
type: modalMode === 'edit' ? 'button' : 'text',
|
||||
type: modalMode === 'view' ? 'custom' : 'button',
|
||||
span: 2,
|
||||
buttonText: modalMode === 'edit' ? 'Configurar Controles de Calidad' : undefined,
|
||||
onButtonClick: modalMode === 'edit' ? () => {
|
||||
// Open quality check configuration modal
|
||||
setShowQualityConfigModal(true);
|
||||
} : undefined,
|
||||
customRenderer: modalMode === 'view' ? () => {
|
||||
const config = selectedRecipe.quality_check_configuration as any;
|
||||
|
||||
if (!config || !config.stages || Object.keys(config.stages).length === 0) {
|
||||
return (
|
||||
<div className="text-sm text-[var(--text-secondary)] italic p-4 bg-[var(--bg-secondary)]/30 rounded-md">
|
||||
No hay controles de calidad configurados para esta receta
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const stageLabels = {
|
||||
[ProcessStage.MIXING]: 'Mezclado',
|
||||
[ProcessStage.PROOFING]: 'Fermentación',
|
||||
[ProcessStage.SHAPING]: 'Formado',
|
||||
[ProcessStage.BAKING]: 'Horneado',
|
||||
[ProcessStage.COOLING]: 'Enfriado',
|
||||
[ProcessStage.PACKAGING]: 'Empaquetado',
|
||||
[ProcessStage.FINISHING]: 'Acabado'
|
||||
};
|
||||
|
||||
const configuredStages = Object.entries(config.stages).filter(
|
||||
([_, stageConfig]: [string, any]) => stageConfig?.template_ids?.length > 0
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div className="p-3 rounded-lg bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800">
|
||||
<div className="text-xs text-blue-600 dark:text-blue-400 font-medium mb-1">Etapas Configuradas</div>
|
||||
<div className="text-2xl font-bold text-blue-700 dark:text-blue-300">{configuredStages.length}</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-800">
|
||||
<div className="text-xs text-green-600 dark:text-green-400 font-medium mb-1">Total Controles</div>
|
||||
<div className="text-2xl font-bold text-green-700 dark:text-green-300">
|
||||
{Object.values(config.stages).reduce((sum: number, stage: any) => sum + (stage?.template_ids?.length || 0), 0)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800">
|
||||
<div className="text-xs text-amber-600 dark:text-amber-400 font-medium mb-1">Umbral de Calidad</div>
|
||||
<div className="text-2xl font-bold text-amber-700 dark:text-amber-300">
|
||||
{config.overall_quality_threshold ? `${config.overall_quality_threshold}/10` : 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Configured Stages */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold text-[var(--text-primary)] flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
Etapas Configuradas
|
||||
</h4>
|
||||
{configuredStages.map(([stage, stageConfig]: [string, any]) => (
|
||||
<div
|
||||
key={stage}
|
||||
className="p-4 rounded-lg bg-[var(--bg-secondary)]/50 border border-[var(--border-secondary)] hover:border-[var(--color-primary)]/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-full bg-[var(--color-primary)]/10 flex items-center justify-center">
|
||||
<CheckCircle className="w-4 h-4 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="text-sm font-semibold text-[var(--text-primary)]">
|
||||
{stageLabels[stage as ProcessStage] || stage}
|
||||
</h5>
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
{stageConfig.template_ids.length} control{stageConfig.template_ids.length !== 1 ? 'es' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{stageConfig.is_required && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-red-500/10 text-red-700 dark:text-red-400 border border-red-500/20">
|
||||
Obligatorio
|
||||
</span>
|
||||
)}
|
||||
{stageConfig.blocking && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-amber-500/10 text-amber-700 dark:text-amber-400 border border-amber-500/20">
|
||||
Bloqueante
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Global Settings */}
|
||||
{(config.auto_create_quality_checks || config.quality_manager_approval_required || config.critical_stage_blocking) && (
|
||||
<div className="pt-3 border-t border-[var(--border-secondary)]">
|
||||
<h4 className="text-sm font-semibold text-[var(--text-primary)] mb-2">Configuración Global</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{config.auto_create_quality_checks && (
|
||||
<span className="text-xs px-2 py-1 rounded-md bg-blue-500/10 text-blue-700 dark:text-blue-400 border border-blue-500/20">
|
||||
✓ Creación automática de controles
|
||||
</span>
|
||||
)}
|
||||
{config.quality_manager_approval_required && (
|
||||
<span className="text-xs px-2 py-1 rounded-md bg-purple-500/10 text-purple-700 dark:text-purple-400 border border-purple-500/20">
|
||||
✓ Requiere aprobación del responsable
|
||||
</span>
|
||||
)}
|
||||
{config.critical_stage_blocking && (
|
||||
<span className="text-xs px-2 py-1 rounded-md bg-red-500/10 text-red-700 dark:text-red-400 border border-red-500/20">
|
||||
✓ Etapas críticas bloqueantes
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} : undefined,
|
||||
readonly: modalMode !== 'edit'
|
||||
}
|
||||
]
|
||||
@@ -1124,7 +1492,7 @@ const RecipesPage: React.FC = () => {
|
||||
{
|
||||
label: 'Eliminar',
|
||||
icon: Trash2,
|
||||
variant: 'danger',
|
||||
variant: 'outline',
|
||||
priority: 'secondary',
|
||||
onClick: () => {
|
||||
setRecipeToDelete(recipe);
|
||||
|
||||
Reference in New Issue
Block a user