Improve the production frontend

This commit is contained in:
Urtzi Alfaro
2025-09-21 07:45:19 +02:00
parent 5e941f5f03
commit 13ca3e90b4
21 changed files with 1416 additions and 1357 deletions

View File

@@ -1,88 +1,13 @@
// Dashboard Domain Components - Bakery Management System
// Core dashboard components
export { default as DashboardCard } from './DashboardCard';
export { default as DashboardGrid } from './DashboardGrid';
export { default as QuickActions, BAKERY_QUICK_ACTIONS } from './QuickActions';
export { default as RecentActivity } from './RecentActivity';
export { default as KPIWidget, BAKERY_KPI_CONFIGS } from './KPIWidget';
// Existing dashboard components
export { default as RealTimeAlerts } from './RealTimeAlerts';
export { default as ProcurementPlansToday } from './ProcurementPlansToday';
export { default as ProductionPlansToday } from './ProductionPlansToday';
// Export types for external usage
export type { DashboardCardProps } from './DashboardCard';
export type {
QuickActionsProps,
QuickAction
} from './QuickActions';
export type {
RecentActivityProps,
ActivityItem,
ActivityUser,
ActivityType,
ActivityStatus,
ActivityPriority
} from './RecentActivity';
export type {
KPIWidgetProps,
KPIValue,
KPITrend,
KPIThreshold,
SparklineDataPoint
} from './KPIWidget';
// Re-export enums for convenience
export {
ActivityType,
ActivityStatus,
ActivityPriority
} from './RecentActivity';
/**
* Dashboard Components Usage Examples:
*
* import {
* DashboardCard,
* QuickActions,
* BAKERY_QUICK_ACTIONS,
* RecentActivity,
* KPIWidget,
* BAKERY_KPI_CONFIGS
* } from '@/components/domain/dashboard';
*
* // Basic dashboard card
* <DashboardCard
* title="Ventas Hoy"
* variant="metric"
* isLoading={false}
* >
* Content goes here
* </DashboardCard>
*
* // Quick actions with predefined bakery actions
* <QuickActions
* actions={BAKERY_QUICK_ACTIONS}
* columns={3}
* userRole="baker"
* userPermissions={['orders.create', 'inventory.view']}
* />
*
* // Recent activity feed
* <RecentActivity
* activities={activities}
* showTimestamp={true}
* allowFiltering={true}
* maxItems={10}
* />
*
* // KPI widget with Spanish formatting
* <KPIWidget
* title="Ingresos Diarios"
* value={{
* current: 1250.50,
* previous: 1100.00,
* format: 'currency'
* }}
* color="green"
* variant="detailed"
* showSparkline={true}
* />
*/
// Note: The following components are specified in the design but not yet implemented:
// - DashboardCard
// - DashboardGrid
// - QuickActions
// - RecentActivity
// - KPIWidget

View File

@@ -22,4 +22,7 @@ export * from './forecasting';
export * from './analytics';
// Onboarding components
export * from './onboarding';
export * from './onboarding';
// Team components
export { default as AddTeamMemberModal } from './team/AddTeamMemberModal';

View File

@@ -0,0 +1,383 @@
import React, { useState, useEffect } from 'react';
import { Package, Clock, Users, AlertCircle, ChefHat } from 'lucide-react';
import { StatusModal, StatusModalSection, StatusModalField } from '../../ui/StatusModal/StatusModal';
import { Button, Input, Select } from '../../ui';
import {
ProductionBatchCreate,
ProductionPriorityEnum
} from '../../../api/types/production';
import { useProductionEnums } from '../../../utils/enumHelpers';
import { useRecipes } from '../../../api/hooks/recipes';
import { useIngredients } from '../../../api/hooks/inventory';
import { useCurrentTenant } from '../../../stores/tenant.store';
interface CreateProductionBatchModalProps {
isOpen: boolean;
onClose: () => void;
onCreateBatch?: (batchData: ProductionBatchCreate) => Promise<void>;
}
/**
* CreateProductionBatchModal - Modal for creating a new production batch
* Comprehensive form for adding new production batches
*/
export const CreateProductionBatchModal: React.FC<CreateProductionBatchModalProps> = ({
isOpen,
onClose,
onCreateBatch
}) => {
const productionEnums = useProductionEnums();
const currentTenant = useCurrentTenant();
const tenantId = currentTenant?.id || '';
// API Data
const { data: recipes = [], isLoading: recipesLoading } = useRecipes(tenantId);
const { data: ingredients = [], isLoading: ingredientsLoading } = useIngredients(tenantId);
const [formData, setFormData] = useState<ProductionBatchCreate>({
product_id: '',
product_name: '',
recipe_id: '',
planned_start_time: '',
planned_end_time: '',
planned_quantity: 1,
planned_duration_minutes: 60,
priority: ProductionPriorityEnum.MEDIUM,
is_rush_order: false,
is_special_recipe: false,
production_notes: '',
batch_number: '',
order_id: '',
forecast_id: '',
equipment_used: [],
staff_assigned: [],
station_id: ''
});
const [isSubmitting, setIsSubmitting] = useState(false);
// Reset form when modal opens
useEffect(() => {
if (isOpen) {
setFormData({
product_id: '',
product_name: '',
recipe_id: '',
planned_start_time: '',
planned_end_time: '',
planned_quantity: 1,
planned_duration_minutes: 60,
priority: ProductionPriorityEnum.MEDIUM,
is_rush_order: false,
is_special_recipe: false,
production_notes: '',
batch_number: '',
order_id: '',
forecast_id: '',
equipment_used: [],
staff_assigned: [],
station_id: ''
});
}
}, [isOpen]);
// Filter finished products (ingredients that are finished products)
const finishedProducts = ingredients.filter(ing =>
ing.type === 'finished_product' ||
ing.category === 'finished_products' ||
ing.name.toLowerCase().includes('pan') ||
ing.name.toLowerCase().includes('pastel') ||
ing.name.toLowerCase().includes('torta')
);
const productOptions = finishedProducts.map(product => ({
value: product.id,
label: product.name
}));
const recipeOptions = recipes.map(recipe => ({
value: recipe.id,
label: recipe.name
}));
const handleSubmit = async () => {
// Validate required fields
if (!formData.product_name.trim()) {
alert('El nombre del producto es obligatorio');
return;
}
if (!formData.product_id.trim()) {
alert('El ID del producto es obligatorio');
return;
}
if (!formData.planned_start_time) {
alert('La fecha de inicio planificada es obligatoria');
return;
}
if (!formData.planned_end_time) {
alert('La fecha de fin planificada es obligatoria');
return;
}
if (formData.planned_quantity <= 0) {
alert('La cantidad planificada debe ser mayor a 0');
return;
}
if (formData.planned_duration_minutes <= 0) {
alert('La duración planificada debe ser mayor a 0');
return;
}
// Validate that end time is after start time
const startTime = new Date(formData.planned_start_time);
const endTime = new Date(formData.planned_end_time);
if (endTime <= startTime) {
alert('La fecha de fin debe ser posterior a la fecha de inicio');
return;
}
setIsSubmitting(true);
try {
if (onCreateBatch) {
await onCreateBatch(formData);
}
onClose();
} catch (error) {
console.error('Error creating production batch:', error);
alert('Error al crear el lote de producción. Por favor, inténtalo de nuevo.');
} finally {
setIsSubmitting(false);
}
};
// Define sections for StatusModal
const sections: StatusModalSection[] = [
{
title: 'Información del Producto',
icon: Package,
fields: [
{
label: 'Producto a Producir *',
value: formData.product_id,
type: 'select',
editable: true,
required: true,
options: productOptions,
span: 2
},
{
label: 'Receta a Utilizar',
value: formData.recipe_id || '',
type: 'select',
editable: true,
options: recipeOptions,
span: 2
},
{
label: 'Número de Lote',
value: formData.batch_number || '',
type: 'text',
editable: true,
placeholder: 'Se generará automáticamente si se deja vacío'
}
]
},
{
title: 'Planificación de Producción',
icon: Clock,
fields: [
{
label: 'Inicio Planificado *',
value: formData.planned_start_time,
type: 'datetime-local',
editable: true,
required: true
},
{
label: 'Fin Planificado *',
value: formData.planned_end_time,
type: 'datetime-local',
editable: true,
required: true
},
{
label: 'Cantidad Planificada *',
value: formData.planned_quantity,
type: 'number',
editable: true,
required: true
},
{
label: 'Duración (minutos) *',
value: formData.planned_duration_minutes,
type: 'number',
editable: true,
required: true
}
]
},
{
title: 'Configuración y Prioridad',
icon: AlertCircle,
fields: [
{
label: 'Prioridad *',
value: formData.priority,
type: 'select',
editable: true,
required: true,
options: productionEnums.getProductionPriorityOptions()
},
{
label: 'Orden Urgente',
value: formData.is_rush_order ? 'Sí' : 'No',
type: 'select',
editable: true,
options: [
{ label: 'Sí', value: 'true' },
{ label: 'No', value: 'false' }
]
},
{
label: 'Receta Especial',
value: formData.is_special_recipe ? 'Sí' : 'No',
type: 'select',
editable: true,
options: [
{ label: 'Sí', value: 'true' },
{ label: 'No', value: 'false' }
]
}
]
},
{
title: 'Recursos y Notas',
icon: Users,
fields: [
{
label: 'Personal Asignado',
value: Array.isArray(formData.staff_assigned) ? formData.staff_assigned.join(', ') : '',
type: 'text',
editable: true,
placeholder: 'Separar nombres con comas (opcional)',
span: 2
},
{
label: 'Notas de Producción',
value: formData.production_notes || '',
type: 'text',
editable: true,
placeholder: 'Instrucciones especiales, observaciones, etc.',
span: 2
}
]
}
];
// Handle field changes
const handleFieldChange = (sectionIndex: number, fieldIndex: number, value: string | number) => {
// Map section and field indices to form data properties
switch (sectionIndex) {
case 0: // Product Information
switch (fieldIndex) {
case 0: // Product ID
const selectedProduct = finishedProducts.find(p => p.id === value);
setFormData(prev => ({
...prev,
product_id: String(value),
product_name: selectedProduct?.name || ''
}));
break;
case 1: // Recipe ID
setFormData(prev => ({ ...prev, recipe_id: String(value) }));
break;
case 2: // Batch Number
setFormData(prev => ({ ...prev, batch_number: String(value) }));
break;
}
break;
case 1: // Production Planning
switch (fieldIndex) {
case 0: // Start Time
const startTime = String(value);
setFormData(prev => ({ ...prev, planned_start_time: startTime }));
// Auto-calculate end time
if (startTime && formData.planned_duration_minutes > 0) {
const start = new Date(startTime);
const end = new Date(start.getTime() + formData.planned_duration_minutes * 60000);
const endTimeString = end.toISOString().slice(0, 16);
setFormData(prev => ({ ...prev, planned_end_time: endTimeString }));
}
break;
case 1: // End Time
setFormData(prev => ({ ...prev, planned_end_time: String(value) }));
break;
case 2: // Quantity
setFormData(prev => ({ ...prev, planned_quantity: Number(value) || 1 }));
break;
case 3: // Duration
const duration = Number(value) || 60;
setFormData(prev => ({ ...prev, planned_duration_minutes: duration }));
// Auto-calculate end time
if (formData.planned_start_time && duration > 0) {
const start = new Date(formData.planned_start_time);
const end = new Date(start.getTime() + duration * 60000);
const endTimeString = end.toISOString().slice(0, 16);
setFormData(prev => ({ ...prev, planned_end_time: endTimeString }));
}
break;
}
break;
case 2: // Configuration
switch (fieldIndex) {
case 0: // Priority
setFormData(prev => ({ ...prev, priority: value as ProductionPriorityEnum }));
break;
case 1: // Rush Order
setFormData(prev => ({ ...prev, is_rush_order: String(value) === 'true' }));
break;
case 2: // Special Recipe
setFormData(prev => ({ ...prev, is_special_recipe: String(value) === 'true' }));
break;
}
break;
case 3: // Resources and Notes
switch (fieldIndex) {
case 0: // Staff Assigned
const staff = String(value).split(',').map(s => s.trim()).filter(s => s.length > 0);
setFormData(prev => ({ ...prev, staff_assigned: staff }));
break;
case 1: // Production Notes
setFormData(prev => ({ ...prev, production_notes: String(value) }));
break;
}
break;
}
};
return (
<StatusModal
isOpen={isOpen}
onClose={onClose}
mode="edit"
title="Nueva Orden de Producción"
subtitle="Crear un nuevo lote de producción"
sections={sections}
size="xl"
loading={isSubmitting}
onFieldChange={handleFieldChange}
image={undefined}
onSave={handleSubmit}
onCancel={onClose}
showDefaultActions={true}
/>
);
};
export default CreateProductionBatchModal;

View File

@@ -1,802 +0,0 @@
import React, { useState, useCallback, useMemo } from 'react';
import { Card, Button, Badge, Input, Modal, Table, Select } from '../../ui';
import type { Recipe, RecipeIngredient, RecipeInstruction, NutritionalInfo } from '../../../types/production.types';
// Local enum definition to avoid import issues
enum DifficultyLevel {
BEGINNER = 'beginner',
INTERMEDIATE = 'intermediate',
ADVANCED = 'advanced',
EXPERT = 'expert',
}
interface RecipeDisplayProps {
className?: string;
recipe: Recipe;
editable?: boolean;
showNutrition?: boolean;
showCosting?: boolean;
onScaleChange?: (scaleFactor: number, scaledRecipe: Recipe) => void;
onVersionUpdate?: (newVersion: Recipe) => void;
onCostCalculation?: (totalCost: number, costPerUnit: number) => void;
}
interface ScaledIngredient extends RecipeIngredient {
scaledQuantity: number;
scaledCost?: number;
}
interface ScaledInstruction extends RecipeInstruction {
scaledDuration?: number;
}
interface ScaledRecipe extends Omit<Recipe, 'ingredients' | 'instructions'> {
scaledYieldQuantity: number;
scaledTotalTime: number;
ingredients: ScaledIngredient[];
instructions: ScaledInstruction[];
scaleFactor: number;
estimatedTotalCost?: number;
costPerScaledUnit?: number;
}
interface TimerState {
instructionId: string;
duration: number;
remaining: number;
isActive: boolean;
isComplete: boolean;
}
const DIFFICULTY_COLORS = {
[DifficultyLevel.BEGINNER]: 'bg-[var(--color-success)]/10 text-[var(--color-success)]',
[DifficultyLevel.INTERMEDIATE]: 'bg-yellow-100 text-yellow-800',
[DifficultyLevel.ADVANCED]: 'bg-[var(--color-primary)]/10 text-[var(--color-primary)]',
[DifficultyLevel.EXPERT]: 'bg-[var(--color-error)]/10 text-[var(--color-error)]',
};
const DIFFICULTY_LABELS = {
[DifficultyLevel.BEGINNER]: 'Principiante',
[DifficultyLevel.INTERMEDIATE]: 'Intermedio',
[DifficultyLevel.ADVANCED]: 'Avanzado',
[DifficultyLevel.EXPERT]: 'Experto',
};
const ALLERGEN_ICONS: Record<string, string> = {
gluten: '🌾',
milk: '🥛',
eggs: '🥚',
nuts: '🥜',
soy: '🫘',
sesame: '🌰',
fish: '🐟',
shellfish: '🦐',
};
const EQUIPMENT_ICONS: Record<string, string> = {
oven: '🔥',
mixer: '🥄',
scale: '⚖️',
bowl: '🥣',
whisk: '🔄',
spatula: '🍴',
thermometer: '🌡️',
timer: '⏰',
};
export const RecipeDisplay: React.FC<RecipeDisplayProps> = ({
className = '',
recipe,
editable = false,
showNutrition = true,
showCosting = false,
onScaleChange,
onVersionUpdate,
onCostCalculation,
}) => {
const [scaleFactor, setScaleFactor] = useState<number>(1);
const [activeTimers, setActiveTimers] = useState<Record<string, TimerState>>({});
const [isNutritionModalOpen, setIsNutritionModalOpen] = useState(false);
const [isCostingModalOpen, setIsCostingModalOpen] = useState(false);
const [isEquipmentModalOpen, setIsEquipmentModalOpen] = useState(false);
const [selectedInstruction, setSelectedInstruction] = useState<RecipeInstruction | null>(null);
const [showAllergensDetail, setShowAllergensDetail] = useState(false);
// Mock ingredient costs for demonstration
const ingredientCosts: Record<string, number> = {
flour: 1.2, // €/kg
water: 0.001, // €/l
salt: 0.8, // €/kg
yeast: 8.5, // €/kg
sugar: 1.5, // €/kg
butter: 6.2, // €/kg
milk: 1.3, // €/l
eggs: 0.25, // €/unit
};
const scaledRecipe = useMemo((): ScaledRecipe => {
const scaledIngredients: ScaledIngredient[] = recipe.ingredients.map(ingredient => ({
...ingredient,
scaledQuantity: ingredient.quantity * scaleFactor,
scaledCost: ingredientCosts[ingredient.ingredient_id]
? ingredientCosts[ingredient.ingredient_id] * ingredient.quantity * scaleFactor
: undefined,
}));
const scaledInstructions: ScaledInstruction[] = recipe.instructions.map(instruction => ({
...instruction,
scaledDuration: instruction.duration_minutes ? Math.ceil(instruction.duration_minutes * scaleFactor) : undefined,
}));
const estimatedTotalCost = scaledIngredients.reduce((total, ingredient) =>
total + (ingredient.scaledCost || 0), 0
);
const scaledYieldQuantity = recipe.yield_quantity * scaleFactor;
const costPerScaledUnit = scaledYieldQuantity > 0 ? estimatedTotalCost / scaledYieldQuantity : 0;
return {
...recipe,
scaledYieldQuantity,
scaledTotalTime: Math.ceil(recipe.total_time_minutes * scaleFactor),
ingredients: scaledIngredients,
instructions: scaledInstructions,
scaleFactor,
estimatedTotalCost,
costPerScaledUnit,
};
}, [recipe, scaleFactor, ingredientCosts]);
const handleScaleChange = useCallback((newScaleFactor: number) => {
setScaleFactor(newScaleFactor);
if (onScaleChange) {
onScaleChange(newScaleFactor, scaledRecipe as unknown as Recipe);
}
}, [scaledRecipe, onScaleChange]);
const startTimer = (instruction: RecipeInstruction) => {
if (!instruction.duration_minutes) return;
const timerState: TimerState = {
instructionId: instruction.step_number.toString(),
duration: instruction.duration_minutes * 60, // Convert to seconds
remaining: instruction.duration_minutes * 60,
isActive: true,
isComplete: false,
};
setActiveTimers(prev => ({
...prev,
[instruction.step_number.toString()]: timerState,
}));
// Start countdown
const interval = setInterval(() => {
setActiveTimers(prev => {
const current = prev[instruction.step_number.toString()];
if (!current || current.remaining <= 0) {
clearInterval(interval);
return {
...prev,
[instruction.step_number.toString()]: {
...current,
remaining: 0,
isActive: false,
isComplete: true,
}
};
}
return {
...prev,
[instruction.step_number.toString()]: {
...current,
remaining: current.remaining - 1,
}
};
});
}, 1000);
};
const stopTimer = (instructionId: string) => {
setActiveTimers(prev => ({
...prev,
[instructionId]: {
...prev[instructionId],
isActive: false,
}
}));
};
const formatTime = (seconds: number): string => {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
if (hours > 0) {
return `${hours}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
}
return `${minutes}:${String(secs).padStart(2, '0')}`;
};
const renderScalingControls = () => (
<Card className="p-4">
<h3 className="font-semibold text-lg mb-4">Escalado de receta</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end">
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
Factor de escala
</label>
<Input
type="number"
min="0.1"
max="10"
step="0.1"
value={scaleFactor}
onChange={(e) => handleScaleChange(parseFloat(e.target.value) || 1)}
className="w-full"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
Cantidad original
</label>
<p className="text-lg font-semibold text-[var(--text-primary)]">
{recipe.yield_quantity} {recipe.yield_unit}
</p>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
Cantidad escalada
</label>
<p className="text-lg font-semibold text-[var(--color-info)]">
{scaledRecipe.scaledYieldQuantity} {recipe.yield_unit}
</p>
</div>
</div>
<div className="flex gap-2 mt-4">
<Button
variant="outline"
size="sm"
onClick={() => handleScaleChange(0.5)}
className={scaleFactor === 0.5 ? 'bg-[var(--color-info)]/5 border-blue-300' : ''}
>
1/2
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleScaleChange(1)}
className={scaleFactor === 1 ? 'bg-[var(--color-info)]/5 border-blue-300' : ''}
>
1x
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleScaleChange(2)}
className={scaleFactor === 2 ? 'bg-[var(--color-info)]/5 border-blue-300' : ''}
>
2x
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleScaleChange(3)}
className={scaleFactor === 3 ? 'bg-[var(--color-info)]/5 border-blue-300' : ''}
>
3x
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleScaleChange(5)}
className={scaleFactor === 5 ? 'bg-[var(--color-info)]/5 border-blue-300' : ''}
>
5x
</Button>
</div>
{scaleFactor !== 1 && (
<div className="mt-4 p-3 bg-[var(--color-info)]/5 border border-[var(--color-info)]/20 rounded-lg">
<p className="text-sm text-[var(--color-info)]">
<span className="font-medium">Tiempo total escalado:</span> {Math.ceil(scaledRecipe.scaledTotalTime / 60)}h {scaledRecipe.scaledTotalTime % 60}m
</p>
{showCosting && scaledRecipe.estimatedTotalCost && (
<p className="text-sm text-[var(--color-info)] mt-1">
<span className="font-medium">Costo estimado:</span> {scaledRecipe.estimatedTotalCost.toFixed(2)}
({scaledRecipe.costPerScaledUnit?.toFixed(3)}/{recipe.yield_unit})
</p>
)}
</div>
)}
</Card>
);
const renderRecipeHeader = () => (
<Card className="p-6">
<div className="flex flex-col lg:flex-row gap-6">
<div className="flex-1">
<div className="flex items-start justify-between mb-4">
<div>
<h1 className="text-3xl font-bold text-[var(--text-primary)]">{recipe.name}</h1>
<p className="text-[var(--text-secondary)] mt-1">{recipe.description}</p>
</div>
<div className="flex gap-2">
<Badge className={DIFFICULTY_COLORS[recipe.difficulty_level]}>
{DIFFICULTY_LABELS[recipe.difficulty_level]}
</Badge>
<Badge className="bg-[var(--color-info)]/10 text-[var(--color-info)]">
v{recipe.version}
</Badge>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<p className="text-sm text-[var(--text-secondary)]">Preparación</p>
<p className="font-semibold">{recipe.prep_time_minutes} min</p>
</div>
<div>
<p className="text-sm text-[var(--text-secondary)]">Cocción</p>
<p className="font-semibold">{recipe.cook_time_minutes} min</p>
</div>
<div>
<p className="text-sm text-[var(--text-secondary)]">Total</p>
<p className="font-semibold">{Math.ceil(recipe.total_time_minutes / 60)}h {recipe.total_time_minutes % 60}m</p>
</div>
<div>
<p className="text-sm text-[var(--text-secondary)]">Rendimiento</p>
<p className="font-semibold">{scaledRecipe.scaledYieldQuantity} {recipe.yield_unit}</p>
</div>
</div>
{recipe.allergen_warnings.length > 0 && (
<div className="mt-4">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-[var(--text-secondary)]">Alérgenos:</p>
<Button
variant="ghost"
size="sm"
onClick={() => setShowAllergensDetail(!showAllergensDetail)}
>
{showAllergensDetail ? 'Ocultar' : 'Ver detalles'}
</Button>
</div>
<div className="flex flex-wrap gap-2 mt-2">
{recipe.allergen_warnings.map((allergen) => (
<Badge
key={allergen}
className="bg-[var(--color-error)]/10 text-[var(--color-error)] border border-red-200"
>
{ALLERGEN_ICONS[allergen]} {allergen}
</Badge>
))}
</div>
{showAllergensDetail && (
<div className="mt-3 p-3 bg-red-50 border border-red-200 rounded-lg">
<p className="text-sm text-[var(--color-error)]">
Este producto contiene los siguientes alérgenos. Revisar cuidadosamente
antes del consumo si existe alguna alergia o intolerancia alimentaria.
</p>
</div>
)}
</div>
)}
{recipe.storage_instructions && (
<div className="mt-4 p-3 bg-[var(--bg-secondary)] border border-[var(--border-primary)] rounded-lg">
<p className="text-sm text-[var(--text-secondary)]">
<span className="font-medium">Conservación:</span> {recipe.storage_instructions}
{recipe.shelf_life_hours && (
<span className="ml-2">
Vida útil: {Math.ceil(recipe.shelf_life_hours / 24)} días
</span>
)}
</p>
</div>
)}
</div>
<div className="flex flex-col gap-2 lg:w-48">
{showNutrition && (
<Button
variant="outline"
onClick={() => setIsNutritionModalOpen(true)}
className="w-full"
>
📊 Información nutricional
</Button>
)}
{showCosting && (
<Button
variant="outline"
onClick={() => setIsCostingModalOpen(true)}
className="w-full"
>
💰 Análisis de costos
</Button>
)}
<Button
variant="outline"
onClick={() => setIsEquipmentModalOpen(true)}
className="w-full"
>
🔧 Equipo necesario
</Button>
{editable && onVersionUpdate && (
<Button variant="primary" className="w-full">
Editar receta
</Button>
)}
</div>
</div>
</Card>
);
const renderIngredients = () => (
<Card className="p-6">
<h2 className="text-xl font-semibold mb-4">Ingredientes</h2>
<div className="space-y-3">
{scaledRecipe.ingredients.map((ingredient, index) => (
<div
key={`${ingredient.ingredient_id}-${index}`}
className="flex items-center justify-between py-2 border-b border-[var(--border-primary)] last:border-b-0"
>
<div className="flex-1">
<p className="font-medium text-[var(--text-primary)]">{ingredient.ingredient_name}</p>
{ingredient.preparation_notes && (
<p className="text-sm text-[var(--text-secondary)] italic">{ingredient.preparation_notes}</p>
)}
{ingredient.is_optional && (
<Badge className="bg-[var(--bg-tertiary)] text-[var(--text-secondary)] text-xs mt-1">
Opcional
</Badge>
)}
</div>
<div className="text-right ml-4">
<p className="font-semibold text-lg">
{ingredient.scaledQuantity.toFixed(ingredient.scaledQuantity < 1 ? 2 : 0)} {ingredient.unit}
</p>
{scaleFactor !== 1 && (
<p className="text-sm text-[var(--text-tertiary)]">
(original: {ingredient.quantity} {ingredient.unit})
</p>
)}
{showCosting && ingredient.scaledCost && (
<p className="text-sm text-[var(--color-success)]">
{ingredient.scaledCost.toFixed(2)}
</p>
)}
</div>
</div>
))}
</div>
{showCosting && scaledRecipe.estimatedTotalCost && (
<div className="mt-4 pt-4 border-t border-[var(--border-primary)]">
<div className="flex justify-between items-center">
<span className="font-semibold">Costo total estimado:</span>
<span className="text-xl font-bold text-[var(--color-success)]">
{scaledRecipe.estimatedTotalCost.toFixed(2)}
</span>
</div>
<p className="text-sm text-[var(--text-secondary)] text-right">
{scaledRecipe.costPerScaledUnit?.toFixed(3)} por {recipe.yield_unit}
</p>
</div>
)}
</Card>
);
const renderInstructions = () => (
<Card className="p-6">
<h2 className="text-xl font-semibold mb-4">Instrucciones</h2>
<div className="space-y-4">
{scaledRecipe.instructions.map((instruction, index) => {
const timer = activeTimers[instruction.step_number.toString()];
return (
<div
key={instruction.step_number}
className={`p-4 border rounded-lg ${
instruction.critical_control_point
? 'border-orange-300 bg-orange-50'
: 'border-[var(--border-primary)] bg-[var(--bg-secondary)]'
}`}
>
<div className="flex items-start gap-4">
<div className="flex-shrink-0">
<div className="w-8 h-8 bg-blue-600 text-white rounded-full flex items-center justify-center font-semibold">
{instruction.step_number}
</div>
</div>
<div className="flex-1">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
{instruction.duration_minutes && (
<Badge className="bg-[var(--color-info)]/10 text-[var(--color-info)]">
{instruction.scaledDuration || instruction.duration_minutes} min
</Badge>
)}
{instruction.temperature && (
<Badge className="bg-[var(--color-error)]/10 text-[var(--color-error)]">
🌡 {instruction.temperature}°C
</Badge>
)}
{instruction.critical_control_point && (
<Badge className="bg-[var(--color-primary)]/10 text-[var(--color-primary)]">
🚨 PCC
</Badge>
)}
</div>
{instruction.duration_minutes && (
<div className="flex gap-2">
{!timer?.isActive && !timer?.isComplete && (
<Button
variant="outline"
size="sm"
onClick={() => startTimer(instruction)}
>
Iniciar timer
</Button>
)}
{timer?.isActive && (
<div className="flex items-center gap-2">
<span className="text-lg font-mono font-bold text-[var(--color-info)]">
{formatTime(timer.remaining)}
</span>
<Button
variant="outline"
size="sm"
onClick={() => stopTimer(instruction.step_number.toString())}
>
</Button>
</div>
)}
{timer?.isComplete && (
<Badge className="bg-[var(--color-success)]/10 text-[var(--color-success)] animate-pulse">
Completado
</Badge>
)}
</div>
)}
</div>
<p className="text-[var(--text-primary)] leading-relaxed mb-2">
{instruction.instruction}
</p>
{instruction.equipment && instruction.equipment.length > 0 && (
<div className="mb-2">
<p className="text-sm text-[var(--text-secondary)] mb-1">Equipo necesario:</p>
<div className="flex flex-wrap gap-1">
{instruction.equipment.map((equipment, idx) => (
<Badge key={idx} className="bg-[var(--bg-tertiary)] text-[var(--text-secondary)] text-xs">
{EQUIPMENT_ICONS[equipment.toLowerCase()] || '🔧'} {equipment}
</Badge>
))}
</div>
</div>
)}
{instruction.tips && (
<div className="mt-3 p-2 bg-yellow-50 border border-yellow-200 rounded">
<p className="text-sm text-yellow-800">
💡 <span className="font-medium">Tip:</span> {instruction.tips}
</p>
</div>
)}
</div>
</div>
</div>
);
})}
</div>
</Card>
);
const renderNutritionModal = () => (
<Modal
isOpen={isNutritionModalOpen}
onClose={() => setIsNutritionModalOpen(false)}
title="Información Nutricional"
>
{recipe.nutritional_info ? (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="bg-[var(--bg-secondary)] p-4 rounded-lg">
<p className="text-sm text-[var(--text-secondary)]">Calorías por porción</p>
<p className="text-2xl font-bold text-[var(--text-primary)]">
{recipe.nutritional_info.calories_per_serving || 'N/A'}
</p>
</div>
<div className="bg-[var(--bg-secondary)] p-4 rounded-lg">
<p className="text-sm text-[var(--text-secondary)]">Tamaño de porción</p>
<p className="text-lg font-semibold text-[var(--text-primary)]">
{recipe.nutritional_info.serving_size || 'N/A'}
</p>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<div>
<p className="text-sm text-[var(--text-secondary)]">Proteínas</p>
<p className="text-lg font-semibold">{recipe.nutritional_info.protein_g || 0}g</p>
</div>
<div>
<p className="text-sm text-[var(--text-secondary)]">Carbohidratos</p>
<p className="text-lg font-semibold">{recipe.nutritional_info.carbohydrates_g || 0}g</p>
</div>
<div>
<p className="text-sm text-[var(--text-secondary)]">Grasas</p>
<p className="text-lg font-semibold">{recipe.nutritional_info.fat_g || 0}g</p>
</div>
<div>
<p className="text-sm text-[var(--text-secondary)]">Fibra</p>
<p className="text-lg font-semibold">{recipe.nutritional_info.fiber_g || 0}g</p>
</div>
<div>
<p className="text-sm text-[var(--text-secondary)]">Azúcares</p>
<p className="text-lg font-semibold">{recipe.nutritional_info.sugar_g || 0}g</p>
</div>
<div>
<p className="text-sm text-[var(--text-secondary)]">Sodio</p>
<p className="text-lg font-semibold">{recipe.nutritional_info.sodium_mg || 0}mg</p>
</div>
</div>
<div className="bg-[var(--color-info)]/5 p-4 rounded-lg">
<p className="text-sm text-[var(--color-info)]">
<span className="font-medium">Porciones por lote escalado:</span> {
recipe.nutritional_info.servings_per_batch
? Math.ceil(recipe.nutritional_info.servings_per_batch * scaleFactor)
: 'N/A'
}
</p>
</div>
</div>
) : (
<p className="text-[var(--text-tertiary)] text-center py-8">
No hay información nutricional disponible para esta receta.
</p>
)}
</Modal>
);
const renderCostingModal = () => (
<Modal
isOpen={isCostingModalOpen}
onClose={() => setIsCostingModalOpen(false)}
title="Análisis de Costos"
>
<div className="space-y-4">
<div className="bg-green-50 border border-green-200 p-4 rounded-lg">
<div className="flex justify-between items-center mb-2">
<span className="font-semibold text-green-900">Costo total</span>
<span className="text-2xl font-bold text-[var(--color-success)]">
{scaledRecipe.estimatedTotalCost?.toFixed(2)}
</span>
</div>
<p className="text-sm text-[var(--color-success)]">
{scaledRecipe.costPerScaledUnit?.toFixed(3)} por {recipe.yield_unit}
</p>
</div>
<div>
<h4 className="font-semibold mb-3">Desglose por ingrediente</h4>
<div className="space-y-2">
{scaledRecipe.ingredients.map((ingredient, index) => (
<div key={index} className="flex justify-between items-center py-2 border-b border-[var(--border-primary)]">
<div>
<p className="font-medium">{ingredient.ingredient_name}</p>
<p className="text-sm text-[var(--text-secondary)]">
{ingredient.scaledQuantity.toFixed(2)} {ingredient.unit}
</p>
</div>
<p className="font-semibold text-[var(--color-success)]">
{ingredient.scaledCost?.toFixed(2) || '0.00'}
</p>
</div>
))}
</div>
</div>
<div className="bg-[var(--color-info)]/5 p-4 rounded-lg">
<h4 className="font-semibold text-blue-900 mb-2">Análisis de rentabilidad</h4>
<div className="space-y-1 text-sm text-[var(--color-info)]">
<p> Costo de ingredientes: {scaledRecipe.estimatedTotalCost?.toFixed(2)}</p>
<p> Margen sugerido (300%): {((scaledRecipe.estimatedTotalCost || 0) * 3).toFixed(2)}</p>
<p> Precio de venta sugerido por {recipe.yield_unit}: {((scaledRecipe.costPerScaledUnit || 0) * 4).toFixed(2)}</p>
</div>
</div>
</div>
</Modal>
);
const renderEquipmentModal = () => (
<Modal
isOpen={isEquipmentModalOpen}
onClose={() => setIsEquipmentModalOpen(false)}
title="Equipo Necesario"
>
<div className="space-y-4">
<div>
<h4 className="font-semibold mb-3">Equipo general de la receta</h4>
<div className="grid grid-cols-2 gap-2">
{recipe.equipment_needed.map((equipment, index) => (
<div key={index} className="flex items-center gap-2 p-2 bg-[var(--bg-secondary)] rounded">
<span className="text-xl">
{EQUIPMENT_ICONS[equipment.toLowerCase()] || '🔧'}
</span>
<span className="text-sm">{equipment}</span>
</div>
))}
</div>
</div>
<div>
<h4 className="font-semibold mb-3">Equipo por instrucción</h4>
<div className="space-y-2">
{recipe.instructions
.filter(instruction => instruction.equipment && instruction.equipment.length > 0)
.map((instruction) => (
<div key={instruction.step_number} className="border border-[var(--border-primary)] p-3 rounded">
<p className="font-medium text-sm mb-2">
Paso {instruction.step_number}
</p>
<div className="flex flex-wrap gap-1">
{instruction.equipment?.map((equipment, idx) => (
<Badge key={idx} className="bg-[var(--color-info)]/10 text-[var(--color-info)] text-xs">
{EQUIPMENT_ICONS[equipment.toLowerCase()] || '🔧'} {equipment}
</Badge>
))}
</div>
</div>
))}
</div>
</div>
</div>
</Modal>
);
return (
<div className={`space-y-6 ${className}`}>
{renderRecipeHeader()}
{renderScalingControls()}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{renderIngredients()}
<div className="space-y-6">
{renderInstructions()}
</div>
</div>
{renderNutritionModal()}
{renderCostingModal()}
{renderEquipmentModal()}
</div>
);
};
export default RecipeDisplay;

View File

@@ -2,11 +2,9 @@
export { default as ProductionSchedule } from './ProductionSchedule';
export { default as BatchTracker } from './BatchTracker';
export { default as QualityControl } from './QualityControl';
export { default as RecipeDisplay } from './RecipeDisplay';
export { CreateProductionBatchModal } from './CreateProductionBatchModal';
// Export component props types
export type { ProductionScheduleProps } from './ProductionSchedule';
export type { BatchTrackerProps } from './BatchTracker';
export type { QualityControlProps } from './QualityControl';
export type { RecipeDisplayProps } from './RecipeDisplay';
export type { QualityControlProps } from './QualityControl';

View File

@@ -9,15 +9,38 @@ import {
Modal,
Tooltip
} from '../../ui';
import {
SalesRecord,
SalesChannel,
PaymentMethod,
SalesSortField,
SortOrder
} from '../../../types/sales.types';
import { salesService } from '../../../api/services/sales.service';
import { useSales } from '../../../hooks/api/useSales';
import { SalesDataResponse } from '../../../api/types/sales';
import { salesService } from '../../../api/services/sales';
import { useSalesRecords } from '../../../api/hooks/sales';
// Define missing types for backwards compatibility
type SalesRecord = SalesDataResponse;
enum SalesChannel {
ONLINE = 'online',
IN_STORE = 'in_store',
PHONE = 'phone',
DELIVERY = 'delivery'
}
enum PaymentMethod {
CASH = 'cash',
CARD = 'card',
DIGITAL = 'digital',
TRANSFER = 'transfer'
}
enum SalesSortField {
DATE = 'date',
TOTAL = 'total_revenue',
PRODUCT = 'product_name',
QUANTITY = 'quantity_sold'
}
enum SortOrder {
ASC = 'asc',
DESC = 'desc'
}
// Extended interface for orders
interface Order extends SalesRecord {

View File

@@ -6,14 +6,58 @@ import {
Badge,
Tooltip
} from '../../ui';
import {
SalesAnalytics,
DailyTrend,
ProductPerformance,
PeriodType
} from '../../../types/sales.types';
import { salesService } from '../../../api/services/sales.service';
import { useSales } from '../../../hooks/api/useSales';
import { SalesAnalytics } from '../../../api/types/sales';
import { ProductPerformance } from '../analytics/types';
import { salesService } from '../../../api/services/sales';
import { useSalesAnalytics } from '../../../api/hooks/sales';
// Define missing types
export enum PeriodType {
DAILY = 'daily',
WEEKLY = 'weekly',
MONTHLY = 'monthly',
QUARTERLY = 'quarterly',
YEARLY = 'yearly'
}
export interface DailyTrend {
date: string;
revenue: number;
quantity: number;
orders: number;
average_order_value: number;
new_customers: number;
day_type: 'weekday' | 'weekend' | 'holiday';
}
interface ExtendedSalesAnalytics extends SalesAnalytics {
overview?: {
total_revenue: number;
total_quantity: number;
total_orders: number;
average_order_value: number;
gross_profit: number;
profit_margin: number;
discount_percentage: number;
tax_percentage: number;
best_selling_products: ProductPerformance[];
revenue_by_channel: any[];
};
daily_trends?: DailyTrend[];
hourly_patterns?: Array<{
hour: number;
average_sales: number;
peak_day: string;
orders_count: number;
revenue_percentage: number;
staff_recommendation: number;
}>;
product_performance?: ProductPerformance[];
customer_segments?: any[];
weather_impact?: any[];
seasonal_patterns?: any[];
forecast?: any[];
}
interface SalesChartProps {
tenantId?: string;
@@ -94,7 +138,7 @@ export const SalesChart: React.FC<SalesChartProps> = ({
className = ''
}) => {
// State
const [analytics, setAnalytics] = useState<SalesAnalytics | null>(null);
const [analytics, setAnalytics] = useState<ExtendedSalesAnalytics | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -117,8 +161,8 @@ export const SalesChart: React.FC<SalesChartProps> = ({
// Export options
const [showExportModal, setShowExportModal] = useState(false);
// Sales hook
const { fetchAnalytics } = useSales();
// Sales hook (not used directly, but could be used for caching)
// const salesAnalytics = useSalesAnalytics(tenantId || '', dateRange.start, dateRange.end);
// Load analytics data
useEffect(() => {
@@ -130,55 +174,55 @@ export const SalesChart: React.FC<SalesChartProps> = ({
setError(null);
try {
const response = await salesService.getSalesAnalytics({
start_date: dateRange.start,
end_date: dateRange.end,
granularity: timePeriod === PeriodType.DAILY ? 'daily' :
timePeriod === PeriodType.WEEKLY ? 'weekly' : 'monthly'
});
const response = await salesService.getSalesAnalytics(
tenantId || '',
dateRange.start,
dateRange.end
);
if (response.success && response.data) {
// Transform the API data to our analytics format
const mockAnalytics: SalesAnalytics = {
if (response) {
// Transform the API data to our extended analytics format
const extendedAnalytics: ExtendedSalesAnalytics = {
...response,
overview: {
total_revenue: response.data.daily_sales?.reduce((sum, day) => sum + day.revenue, 0) || 0,
total_quantity: response.data.daily_sales?.reduce((sum, day) => sum + day.quantity, 0) || 0,
total_orders: response.data.daily_sales?.reduce((sum, day) => sum + day.orders, 0) || 0,
average_order_value: 0,
total_revenue: response.total_revenue || 0,
total_quantity: response.total_quantity || 0,
total_orders: response.total_transactions || 0,
average_order_value: response.average_unit_price || 0,
gross_profit: 0,
profit_margin: 0,
discount_percentage: 0,
tax_percentage: 21,
best_selling_products: response.data.product_performance || [],
revenue_by_channel: []
best_selling_products: response.top_products || [],
revenue_by_channel: response.revenue_by_channel || []
},
daily_trends: response.data.daily_sales?.map(day => ({
daily_trends: response.revenue_by_date?.map(day => ({
date: day.date,
revenue: day.revenue,
quantity: day.quantity,
orders: day.orders,
average_order_value: day.revenue / day.orders || 0,
orders: Math.floor(day.quantity / 2) || 1, // Mock orders count
average_order_value: day.revenue / Math.max(Math.floor(day.quantity / 2), 1),
new_customers: Math.floor(Math.random() * 10),
day_type: 'weekday' as const
})) || [],
hourly_patterns: response.data.hourly_patterns?.map((pattern, index) => ({
hourly_patterns: Array.from({ length: 12 }, (_, index) => ({
hour: index + 8, // Start from 8 AM
average_sales: pattern.average_sales,
peak_day: pattern.peak_day as any,
orders_count: Math.floor(pattern.average_sales / 15),
revenue_percentage: (pattern.average_sales / 1000) * 100,
staff_recommendation: Math.ceil(pattern.average_sales / 200)
})) || [],
product_performance: response.data.product_performance || [],
average_sales: Math.random() * 500 + 100,
peak_day: 'Saturday',
orders_count: Math.floor(Math.random() * 20 + 5),
revenue_percentage: Math.random() * 10 + 5,
staff_recommendation: Math.ceil(Math.random() * 3 + 1)
})),
product_performance: response.top_products || [],
customer_segments: [],
weather_impact: response.data.weather_impact || [],
weather_impact: [],
seasonal_patterns: [],
forecast: []
};
setAnalytics(mockAnalytics);
setAnalytics(extendedAnalytics);
} else {
setError(response.error || 'Error al cargar datos de analítica');
setError('Error al cargar datos de analítica');
}
} catch (err) {
setError('Error de conexión al servidor');
@@ -755,7 +799,7 @@ export const SalesChart: React.FC<SalesChartProps> = ({
</h3>
<div className="flex items-center space-x-2">
<span className="text-sm text-[var(--text-secondary)]">Tipo:</span>
<Badge variant="soft" color="blue">
<Badge variant="secondary">
{ChartTypeLabels[chartType]}
</Badge>
</div>

View File

@@ -0,0 +1,176 @@
import React, { useState } from 'react';
import { Plus, Users, Shield, Eye } from 'lucide-react';
import { StatusModal } from '../../ui/StatusModal/StatusModal';
import { TENANT_ROLES } from '../../../types/roles';
import { statusColors } from '../../../styles/colors';
interface AddTeamMemberModalProps {
isOpen: boolean;
onClose: () => void;
onAddMember?: (userData: { userId: string; role: string }) => Promise<void>;
availableUsers: Array<{ id: string; full_name: string; email: string }>;
}
/**
* AddTeamMemberModal - Modal for adding a new member to the team
* Comprehensive form for adding new users to the bakery team
*/
export const AddTeamMemberModal: React.FC<AddTeamMemberModalProps> = ({
isOpen,
onClose,
onAddMember,
availableUsers
}) => {
const [formData, setFormData] = useState({
userId: '',
role: TENANT_ROLES.MEMBER
});
const [loading, setLoading] = useState(false);
const [mode, setMode] = useState<'view' | 'edit'>('edit');
const handleFieldChange = (sectionIndex: number, fieldIndex: number, value: string | number | boolean) => {
// Map field positions to form data fields
const fieldMappings = [
// Basic Information section
['userId', 'role']
];
const fieldName = fieldMappings[sectionIndex]?.[fieldIndex] as keyof typeof formData;
if (fieldName) {
setFormData(prev => ({
...prev,
[fieldName]: value
}));
}
};
const handleSave = async () => {
// Validation
if (!formData.userId) {
alert('Por favor selecciona un usuario');
return;
}
if (!formData.role) {
alert('Por favor selecciona un rol');
return;
}
setLoading(true);
try {
if (onAddMember) {
await onAddMember({
userId: formData.userId,
role: formData.role
});
}
// Reset form
setFormData({
userId: '',
role: TENANT_ROLES.MEMBER
});
onClose();
} catch (error) {
console.error('Error adding team member:', error);
alert('Error al agregar el miembro del equipo. Por favor, intenta de nuevo.');
} finally {
setLoading(false);
}
};
const handleCancel = () => {
// Reset form to initial values
setFormData({
userId: '',
role: TENANT_ROLES.MEMBER
});
onClose();
};
const statusConfig = {
color: statusColors.inProgress.primary,
text: 'Nuevo Miembro',
icon: Plus,
isCritical: false,
isHighlight: true
};
const roleOptions = [
{ label: 'Miembro - Acceso estándar', value: TENANT_ROLES.MEMBER },
{ label: 'Administrador - Gestión de equipo', value: TENANT_ROLES.ADMIN },
{ label: 'Observador - Solo lectura', value: TENANT_ROLES.VIEWER }
];
const userOptions = availableUsers.map(user => ({
label: `${user.full_name} (${user.email})`,
value: user.id
}));
const getRoleDescription = (role: string) => {
switch (role) {
case TENANT_ROLES.ADMIN:
return 'Los administradores pueden gestionar miembros del equipo y configuraciones.';
case TENANT_ROLES.MEMBER:
return 'Los miembros tienen acceso completo para trabajar con datos y funcionalidades.';
case TENANT_ROLES.VIEWER:
return 'Los observadores solo pueden ver datos, sin realizar cambios.';
default:
return '';
}
};
const sections = [
{
title: 'Información del Miembro',
icon: Users,
fields: [
{
label: 'Usuario',
value: formData.userId,
type: 'select' as const,
editable: true,
required: true,
options: userOptions,
placeholder: 'Seleccionar usuario...'
},
{
label: 'Rol',
value: formData.role,
type: 'select' as const,
editable: true,
required: true,
options: roleOptions
},
{
label: 'Descripción del Rol',
value: getRoleDescription(formData.role),
type: 'text' as const,
editable: false
}
]
}
];
return (
<StatusModal
isOpen={isOpen}
onClose={onClose}
mode={mode}
title="Agregar Miembro al Equipo"
subtitle="Agregar un nuevo miembro al equipo de la panadería"
statusIndicator={statusConfig}
sections={sections}
size="lg"
loading={loading}
showDefaultActions={true}
onSave={handleSave}
onCancel={handleCancel}
onFieldChange={handleFieldChange}
/>
);
};
export default AddTeamMemberModal;

View File

@@ -0,0 +1,4 @@
// Team Components - Export all team-related components
export { default as AddTeamMemberModal } from './AddTeamMemberModal';
export type { AddTeamMemberModalProps } from './AddTeamMemberModal';