Create the frontend receipes page to use real API
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, Clock, Package, Eye, Edit, CheckCircle, AlertCircle, Timer, Users, DollarSign, Loader } from 'lucide-react';
|
||||
import { Plus, Clock, Package, Eye, Edit, CheckCircle, AlertCircle, Timer, Users, Loader, Euro } from 'lucide-react';
|
||||
import { Button, Input, Card, Badge, StatsGrid, StatusCard, getStatusColor, StatusModal } from '../../../../components/ui';
|
||||
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
@@ -180,13 +180,13 @@ const OrdersPage: React.FC = () => {
|
||||
title: 'Ingresos Hoy',
|
||||
value: formatters.currency(orderStats.revenue_today),
|
||||
variant: 'success' as const,
|
||||
icon: DollarSign,
|
||||
icon: Euro,
|
||||
},
|
||||
{
|
||||
title: 'Valor Promedio',
|
||||
value: formatters.currency(orderStats.average_order_value),
|
||||
variant: 'info' as const,
|
||||
icon: DollarSign,
|
||||
icon: Euro,
|
||||
},
|
||||
];
|
||||
} else {
|
||||
@@ -219,13 +219,13 @@ const OrdersPage: React.FC = () => {
|
||||
title: 'Valor Total',
|
||||
value: formatters.currency(customers.reduce((sum, c) => sum + Number(c.total_spent || 0), 0)),
|
||||
variant: 'success' as const,
|
||||
icon: DollarSign,
|
||||
icon: Euro,
|
||||
},
|
||||
{
|
||||
title: 'Promedio por Cliente',
|
||||
value: formatters.currency(customers.reduce((sum, c) => sum + Number(c.average_order_value || 0), 0) / Math.max(customers.length, 1)),
|
||||
variant: 'info' as const,
|
||||
icon: DollarSign,
|
||||
icon: Euro,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -565,7 +565,7 @@ const OrdersPage: React.FC = () => {
|
||||
},
|
||||
{
|
||||
title: 'Información Financiera',
|
||||
icon: DollarSign,
|
||||
icon: Euro,
|
||||
fields: [
|
||||
{
|
||||
label: 'Subtotal',
|
||||
@@ -717,7 +717,7 @@ const OrdersPage: React.FC = () => {
|
||||
},
|
||||
{
|
||||
title: 'Configuración Comercial',
|
||||
icon: DollarSign,
|
||||
icon: Euro,
|
||||
fields: [
|
||||
{
|
||||
label: 'Código de Cliente',
|
||||
|
||||
@@ -1,107 +1,47 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, Download, Star, Clock, Users, DollarSign, Package, Eye, Edit, ChefHat, Timer } from 'lucide-react';
|
||||
import { Button, Input, Card, Badge, StatsGrid, StatusCard, getStatusColor, StatusModal } from '../../../../components/ui';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Plus, Star, Clock, DollarSign, Package, Eye, Edit, ChefHat, Timer, Euro } from 'lucide-react';
|
||||
import { Button, Input, Card, StatsGrid, StatusCard, getStatusColor, StatusModal } from '../../../../components/ui';
|
||||
import { LoadingSpinner } from '../../../../components/shared';
|
||||
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { useRecipes, useRecipeStatistics, useCreateRecipe, useUpdateRecipe, useDeleteRecipe } from '../../../../api/hooks/recipes';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
import type { RecipeResponse, RecipeCreate } from '../../../../api/types/recipes';
|
||||
import { CreateRecipeModal } from '../../../../components/domain/recipes';
|
||||
|
||||
const RecipesPage: React.FC = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [modalMode, setModalMode] = useState<'view' | 'edit'>('view');
|
||||
const [selectedRecipe, setSelectedRecipe] = useState<typeof mockRecipes[0] | null>(null);
|
||||
const [selectedRecipe, setSelectedRecipe] = useState<RecipeResponse | null>(null);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [editedRecipe, setEditedRecipe] = useState<Partial<RecipeResponse>>({});
|
||||
|
||||
const mockRecipes = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Pan de Molde Integral',
|
||||
category: 'bread',
|
||||
difficulty: 'medium',
|
||||
prepTime: 120,
|
||||
bakingTime: 35,
|
||||
yield: 1,
|
||||
rating: 4.8,
|
||||
cost: 2.50,
|
||||
price: 4.50,
|
||||
profit: 2.00,
|
||||
image: '/api/placeholder/300/200',
|
||||
tags: ['integral', 'saludable', 'artesanal'],
|
||||
description: 'Pan integral artesanal con semillas, perfecto para desayunos saludables.',
|
||||
ingredients: [
|
||||
{ name: 'Harina integral', quantity: 500, unit: 'g' },
|
||||
{ name: 'Agua', quantity: 300, unit: 'ml' },
|
||||
{ name: 'Levadura', quantity: 10, unit: 'g' },
|
||||
{ name: 'Sal', quantity: 8, unit: 'g' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Croissants de Mantequilla',
|
||||
category: 'pastry',
|
||||
difficulty: 'hard',
|
||||
prepTime: 480,
|
||||
bakingTime: 20,
|
||||
yield: 12,
|
||||
rating: 4.9,
|
||||
cost: 8.50,
|
||||
price: 18.00,
|
||||
profit: 9.50,
|
||||
image: '/api/placeholder/300/200',
|
||||
tags: ['francés', 'mantequilla', 'hojaldrado'],
|
||||
description: 'Croissants franceses tradicionales con laminado de mantequilla.',
|
||||
ingredients: [
|
||||
{ name: 'Harina de fuerza', quantity: 500, unit: 'g' },
|
||||
{ name: 'Mantequilla', quantity: 250, unit: 'g' },
|
||||
{ name: 'Leche', quantity: 150, unit: 'ml' },
|
||||
{ name: 'Azúcar', quantity: 50, unit: 'g' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Tarta de Manzana',
|
||||
category: 'cake',
|
||||
difficulty: 'easy',
|
||||
prepTime: 45,
|
||||
bakingTime: 40,
|
||||
yield: 8,
|
||||
rating: 4.6,
|
||||
cost: 4.20,
|
||||
price: 12.00,
|
||||
profit: 7.80,
|
||||
image: '/api/placeholder/300/200',
|
||||
tags: ['frutal', 'casera', 'temporada'],
|
||||
description: 'Tarta casera de manzana con canela y masa quebrada.',
|
||||
ingredients: [
|
||||
{ name: 'Manzanas', quantity: 1000, unit: 'g' },
|
||||
{ name: 'Harina', quantity: 250, unit: 'g' },
|
||||
{ name: 'Mantequilla', quantity: 125, unit: 'g' },
|
||||
{ name: 'Azúcar', quantity: 100, unit: 'g' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Magdalenas de Limón',
|
||||
category: 'pastry',
|
||||
difficulty: 'easy',
|
||||
prepTime: 20,
|
||||
bakingTime: 25,
|
||||
yield: 12,
|
||||
rating: 4.4,
|
||||
cost: 3.80,
|
||||
price: 9.00,
|
||||
profit: 5.20,
|
||||
image: '/api/placeholder/300/200',
|
||||
tags: ['cítrico', 'esponjoso', 'individual'],
|
||||
description: 'Magdalenas suaves y esponjosas con ralladura de limón.',
|
||||
ingredients: [
|
||||
{ name: 'Harina', quantity: 200, unit: 'g' },
|
||||
{ name: 'Huevos', quantity: 3, unit: 'uds' },
|
||||
{ name: 'Azúcar', quantity: 150, unit: 'g' },
|
||||
{ name: 'Limón', quantity: 2, unit: 'uds' },
|
||||
],
|
||||
},
|
||||
];
|
||||
const currentTenant = useCurrentTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
|
||||
const getRecipeStatusConfig = (category: string, difficulty: string, rating: number) => {
|
||||
// Mutations
|
||||
const createRecipeMutation = useCreateRecipe(tenantId);
|
||||
const updateRecipeMutation = useUpdateRecipe(tenantId);
|
||||
const deleteRecipeMutation = useDeleteRecipe(tenantId);
|
||||
|
||||
// API Data
|
||||
const {
|
||||
data: recipes = [],
|
||||
isLoading: recipesLoading,
|
||||
error: recipesError
|
||||
} = useRecipes(tenantId, { search_term: searchTerm || undefined });
|
||||
|
||||
const {
|
||||
data: statisticsData,
|
||||
isLoading: statisticsLoading
|
||||
} = useRecipeStatistics(tenantId);
|
||||
|
||||
|
||||
const getRecipeStatusConfig = (recipe: RecipeResponse) => {
|
||||
const category = recipe.category || 'other';
|
||||
const difficulty = recipe.difficulty_level || 1;
|
||||
const isSignature = recipe.is_signature_item;
|
||||
const categoryConfig = {
|
||||
bread: { text: 'Pan', icon: ChefHat },
|
||||
pastry: { text: 'Bollería', icon: ChefHat },
|
||||
@@ -109,25 +49,24 @@ const RecipesPage: React.FC = () => {
|
||||
cookie: { text: 'Galleta', icon: ChefHat },
|
||||
other: { text: 'Otro', icon: ChefHat },
|
||||
};
|
||||
|
||||
|
||||
const difficultyConfig = {
|
||||
easy: { icon: '●', label: 'Fácil' },
|
||||
medium: { icon: '●●', label: 'Medio' },
|
||||
hard: { icon: '●●●', label: 'Difícil' },
|
||||
1: { icon: '●', label: 'Fácil' },
|
||||
2: { icon: '●●', label: 'Medio' },
|
||||
3: { icon: '●●●', label: 'Difícil' },
|
||||
};
|
||||
|
||||
|
||||
const categoryInfo = categoryConfig[category as keyof typeof categoryConfig] || categoryConfig.other;
|
||||
const difficultyInfo = difficultyConfig[difficulty as keyof typeof difficultyConfig];
|
||||
const isPopular = rating >= 4.7;
|
||||
const difficultyInfo = difficultyConfig[Math.min(difficulty, 3) as keyof typeof difficultyConfig] || difficultyConfig[1];
|
||||
|
||||
return {
|
||||
color: getStatusColor(category),
|
||||
text: categoryInfo.text,
|
||||
icon: categoryInfo.icon,
|
||||
difficultyIcon: difficultyInfo?.icon || '●',
|
||||
difficultyLabel: difficultyInfo?.label || difficulty,
|
||||
difficultyIcon: difficultyInfo.icon,
|
||||
difficultyLabel: difficultyInfo.label,
|
||||
isCritical: false,
|
||||
isHighlight: isPopular
|
||||
isHighlight: isSignature
|
||||
};
|
||||
};
|
||||
|
||||
@@ -137,88 +76,303 @@ const RecipesPage: React.FC = () => {
|
||||
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
|
||||
};
|
||||
|
||||
const filteredRecipes = mockRecipes.filter(recipe => {
|
||||
const matchesSearch = recipe.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
recipe.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
recipe.tags.some(tag => tag.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||
|
||||
return matchesSearch;
|
||||
});
|
||||
const filteredRecipes = useMemo(() => {
|
||||
if (!searchTerm) return recipes;
|
||||
|
||||
const mockRecipeStats = {
|
||||
totalRecipes: mockRecipes.length,
|
||||
popularRecipes: mockRecipes.filter(r => r.rating > 4.7).length,
|
||||
easyRecipes: mockRecipes.filter(r => r.difficulty === 'easy').length,
|
||||
averageCost: mockRecipes.reduce((sum, r) => sum + r.cost, 0) / mockRecipes.length,
|
||||
averageProfit: mockRecipes.reduce((sum, r) => sum + r.profit, 0) / mockRecipes.length,
|
||||
categories: [...new Set(mockRecipes.map(r => r.category))].length,
|
||||
};
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
return recipes.filter(recipe =>
|
||||
recipe.name.toLowerCase().includes(searchLower) ||
|
||||
(recipe.description && recipe.description.toLowerCase().includes(searchLower)) ||
|
||||
(recipe.category && recipe.category.toLowerCase().includes(searchLower))
|
||||
);
|
||||
}, [recipes, searchTerm]);
|
||||
|
||||
const recipeStats = [
|
||||
const recipeStats = useMemo(() => {
|
||||
const stats = {
|
||||
totalRecipes: recipes.length,
|
||||
signatureRecipes: recipes.filter(r => r.is_signature_item).length,
|
||||
easyRecipes: recipes.filter(r => r.difficulty_level === 1).length,
|
||||
averageCost: recipes.length > 0 ? recipes.reduce((sum, r) => sum + (r.estimated_cost_per_unit || 0), 0) / recipes.length : 0,
|
||||
averageProfit: 0, // Will be calculated based on suggested selling price - cost
|
||||
categories: [...new Set(recipes.map(r => r.category).filter(Boolean))].length,
|
||||
};
|
||||
|
||||
// Calculate average profit
|
||||
stats.averageProfit = recipes.length > 0 ?
|
||||
recipes.reduce((sum, r) => {
|
||||
const cost = r.estimated_cost_per_unit || 0;
|
||||
const price = r.suggested_selling_price || 0;
|
||||
return sum + (price - cost);
|
||||
}, 0) / recipes.length : 0;
|
||||
|
||||
return stats;
|
||||
}, [recipes]);
|
||||
|
||||
const stats = [
|
||||
{
|
||||
title: 'Total Recetas',
|
||||
value: mockRecipeStats.totalRecipes,
|
||||
value: recipeStats.totalRecipes,
|
||||
variant: 'default' as const,
|
||||
icon: ChefHat,
|
||||
},
|
||||
{
|
||||
title: 'Populares',
|
||||
value: mockRecipeStats.popularRecipes,
|
||||
title: 'Especiales',
|
||||
value: recipeStats.signatureRecipes,
|
||||
variant: 'warning' as const,
|
||||
icon: Star,
|
||||
},
|
||||
{
|
||||
title: 'Fáciles',
|
||||
value: mockRecipeStats.easyRecipes,
|
||||
value: recipeStats.easyRecipes,
|
||||
variant: 'success' as const,
|
||||
icon: Timer,
|
||||
},
|
||||
{
|
||||
title: 'Costo Promedio',
|
||||
value: formatters.currency(mockRecipeStats.averageCost),
|
||||
value: formatters.currency(recipeStats.averageCost),
|
||||
variant: 'info' as const,
|
||||
icon: DollarSign,
|
||||
icon: Euro,
|
||||
},
|
||||
{
|
||||
title: 'Margen Promedio',
|
||||
value: formatters.currency(mockRecipeStats.averageProfit),
|
||||
value: formatters.currency(recipeStats.averageProfit),
|
||||
variant: 'success' as const,
|
||||
icon: DollarSign,
|
||||
icon: Euro,
|
||||
},
|
||||
{
|
||||
title: 'Categorías',
|
||||
value: mockRecipeStats.categories,
|
||||
value: recipeStats.categories,
|
||||
variant: 'info' as const,
|
||||
icon: Package,
|
||||
},
|
||||
];
|
||||
|
||||
// Handle creating a new recipe
|
||||
const handleCreateRecipe = async (recipeData: RecipeCreate) => {
|
||||
try {
|
||||
await createRecipeMutation.mutateAsync(recipeData);
|
||||
setShowCreateModal(false);
|
||||
console.log('Recipe created successfully');
|
||||
} catch (error) {
|
||||
console.error('Error creating recipe:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Handle field changes in edit mode
|
||||
const handleFieldChange = (sectionIndex: number, fieldIndex: number, value: string | number | boolean) => {
|
||||
if (!selectedRecipe) return;
|
||||
|
||||
const fieldMap: Record<string, string> = {
|
||||
'Categoría': 'category',
|
||||
'Dificultad': 'difficulty_level',
|
||||
'Estado': 'status',
|
||||
'Rendimiento': 'yield_quantity',
|
||||
'Tiempo de preparación': 'prep_time_minutes',
|
||||
'Tiempo de cocción': 'cook_time_minutes',
|
||||
'Costo estimado por unidad': 'estimated_cost_per_unit',
|
||||
'Precio de venta sugerido': 'suggested_selling_price',
|
||||
'Margen objetivo': 'target_margin_percentage'
|
||||
};
|
||||
|
||||
const sections = getModalSections();
|
||||
const field = sections[sectionIndex]?.fields[fieldIndex];
|
||||
const fieldKey = fieldMap[field?.label || ''];
|
||||
|
||||
if (fieldKey) {
|
||||
setEditedRecipe(prev => ({
|
||||
...prev,
|
||||
[fieldKey]: value
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// Handle saving edited recipe
|
||||
const handleSaveRecipe = async () => {
|
||||
if (!selectedRecipe || !Object.keys(editedRecipe).length) return;
|
||||
|
||||
try {
|
||||
const updateData = {
|
||||
...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,
|
||||
};
|
||||
|
||||
await updateRecipeMutation.mutateAsync({
|
||||
id: selectedRecipe.id,
|
||||
data: updateData
|
||||
});
|
||||
|
||||
setModalMode('view');
|
||||
setEditedRecipe({});
|
||||
console.log('Recipe updated successfully');
|
||||
} catch (error) {
|
||||
console.error('Error updating recipe:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Get current value for field (edited value or original)
|
||||
const getFieldValue = (originalValue: any, fieldKey: string) => {
|
||||
return editedRecipe[fieldKey as keyof RecipeResponse] !== undefined
|
||||
? editedRecipe[fieldKey as keyof RecipeResponse]
|
||||
: originalValue;
|
||||
};
|
||||
|
||||
// Get modal sections with editable fields
|
||||
const getModalSections = () => {
|
||||
if (!selectedRecipe) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
title: 'Información Básica',
|
||||
icon: ChefHat,
|
||||
fields: [
|
||||
{
|
||||
label: 'Categoría',
|
||||
value: getFieldValue(selectedRecipe.category || 'Sin categoría', 'category'),
|
||||
type: modalMode === 'edit' ? 'select' : 'status',
|
||||
options: modalMode === 'edit' ? [
|
||||
{ value: 'bread', label: 'Pan' },
|
||||
{ value: 'pastry', label: 'Bollería' },
|
||||
{ value: 'cake', label: 'Tarta' },
|
||||
{ value: 'cookie', label: 'Galleta' },
|
||||
{ value: 'other', label: 'Otro' }
|
||||
] : undefined,
|
||||
editable: true
|
||||
},
|
||||
{
|
||||
label: 'Dificultad',
|
||||
value: modalMode === 'edit'
|
||||
? getFieldValue(selectedRecipe.difficulty_level, 'difficulty_level')
|
||||
: `Nivel ${getFieldValue(selectedRecipe.difficulty_level, 'difficulty_level')}`,
|
||||
type: modalMode === 'edit' ? 'select' : 'text',
|
||||
options: modalMode === 'edit' ? [
|
||||
{ value: 1, label: '1 - Fácil' },
|
||||
{ value: 2, label: '2 - Medio' },
|
||||
{ value: 3, label: '3 - Difícil' },
|
||||
{ value: 4, label: '4 - Muy Difícil' },
|
||||
{ value: 5, label: '5 - Extremo' }
|
||||
] : undefined,
|
||||
editable: true
|
||||
},
|
||||
{
|
||||
label: 'Estado',
|
||||
value: getFieldValue(selectedRecipe.status, 'status'),
|
||||
type: modalMode === 'edit' ? 'select' : 'text',
|
||||
options: modalMode === 'edit' ? [
|
||||
{ value: 'draft', label: 'Borrador' },
|
||||
{ value: 'active', label: 'Activo' },
|
||||
{ value: 'archived', label: 'Archivado' }
|
||||
] : undefined,
|
||||
highlight: selectedRecipe.status === 'active',
|
||||
editable: true
|
||||
},
|
||||
{
|
||||
label: 'Rendimiento',
|
||||
value: modalMode === 'edit'
|
||||
? getFieldValue(selectedRecipe.yield_quantity, 'yield_quantity')
|
||||
: `${getFieldValue(selectedRecipe.yield_quantity, 'yield_quantity')} ${selectedRecipe.yield_unit}`,
|
||||
type: modalMode === 'edit' ? 'number' : 'text',
|
||||
editable: modalMode === 'edit'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Tiempos',
|
||||
icon: Clock,
|
||||
fields: [
|
||||
{
|
||||
label: 'Tiempo de preparación',
|
||||
value: modalMode === 'edit'
|
||||
? getFieldValue(selectedRecipe.prep_time_minutes || 0, 'prep_time_minutes')
|
||||
: formatTime(getFieldValue(selectedRecipe.prep_time_minutes || 0, 'prep_time_minutes')),
|
||||
type: modalMode === 'edit' ? 'number' : 'text',
|
||||
placeholder: modalMode === 'edit' ? 'minutos' : undefined,
|
||||
editable: modalMode === 'edit'
|
||||
},
|
||||
{
|
||||
label: 'Tiempo de cocción',
|
||||
value: modalMode === 'edit'
|
||||
? getFieldValue(selectedRecipe.cook_time_minutes || 0, 'cook_time_minutes')
|
||||
: formatTime(getFieldValue(selectedRecipe.cook_time_minutes || 0, 'cook_time_minutes')),
|
||||
type: modalMode === 'edit' ? 'number' : 'text',
|
||||
placeholder: modalMode === 'edit' ? 'minutos' : undefined,
|
||||
editable: modalMode === 'edit'
|
||||
},
|
||||
{
|
||||
label: 'Tiempo total',
|
||||
value: selectedRecipe.total_time_minutes ? formatTime(selectedRecipe.total_time_minutes) : 'No especificado',
|
||||
type: 'text',
|
||||
highlight: true,
|
||||
readonly: true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Análisis Financiero',
|
||||
icon: DollarSign,
|
||||
fields: [
|
||||
{
|
||||
label: 'Costo estimado por unidad',
|
||||
value: getFieldValue(selectedRecipe.estimated_cost_per_unit || 0, 'estimated_cost_per_unit'),
|
||||
type: modalMode === 'edit' ? 'number' : 'currency',
|
||||
editable: modalMode === 'edit'
|
||||
},
|
||||
{
|
||||
label: 'Precio de venta sugerido',
|
||||
value: getFieldValue(selectedRecipe.suggested_selling_price || 0, 'suggested_selling_price'),
|
||||
type: modalMode === 'edit' ? 'number' : 'currency',
|
||||
editable: modalMode === 'edit'
|
||||
},
|
||||
{
|
||||
label: 'Margen objetivo',
|
||||
value: getFieldValue(selectedRecipe.target_margin_percentage || 0, 'target_margin_percentage'),
|
||||
type: modalMode === 'edit' ? 'number' : 'percentage',
|
||||
highlight: true,
|
||||
editable: modalMode === 'edit'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Ingredientes',
|
||||
icon: Package,
|
||||
fields: [
|
||||
{
|
||||
label: 'Lista de ingredientes',
|
||||
value: selectedRecipe.ingredients?.map(ing => `${ing.quantity} ${ing.unit} - ${ing.ingredient_id}`) || ['No especificados'],
|
||||
type: 'list',
|
||||
span: 2,
|
||||
readonly: true // For now, ingredients editing can be complex, so we'll keep it read-only
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Gestión de Recetas"
|
||||
description="Administra y organiza todas las recetas de tu panadería"
|
||||
actions={[
|
||||
{
|
||||
id: "export",
|
||||
label: "Exportar",
|
||||
variant: "outline" as const,
|
||||
icon: Download,
|
||||
onClick: () => console.log('Export recipes')
|
||||
},
|
||||
{
|
||||
id: "new",
|
||||
label: "Nueva Receta",
|
||||
variant: "primary" as const,
|
||||
icon: Plus,
|
||||
onClick: () => setShowForm(true)
|
||||
onClick: () => setShowCreateModal(true)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<StatsGrid
|
||||
stats={recipeStats}
|
||||
<StatsGrid
|
||||
stats={stats}
|
||||
columns={3}
|
||||
/>
|
||||
|
||||
@@ -227,25 +381,23 @@ const RecipesPage: React.FC = () => {
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
placeholder="Buscar recetas por nombre, ingredientes o etiquetas..."
|
||||
placeholder="Buscar recetas por nombre, descripción o categoría..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => console.log('Export filtered')}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exportar
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Recipes Grid */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredRecipes.map((recipe) => {
|
||||
const statusConfig = getRecipeStatusConfig(recipe.category, recipe.difficulty, recipe.rating);
|
||||
const profitMargin = Math.round((recipe.profit / recipe.price) * 100);
|
||||
const totalTime = formatTime(recipe.prepTime + recipe.bakingTime);
|
||||
const statusConfig = getRecipeStatusConfig(recipe);
|
||||
const cost = recipe.estimated_cost_per_unit || 0;
|
||||
const price = recipe.suggested_selling_price || 0;
|
||||
const profitMargin = price > 0 ? Math.round(((price - cost) / price) * 100) : 0;
|
||||
const totalTime = formatTime((recipe.prep_time_minutes || 0) + (recipe.cook_time_minutes || 0));
|
||||
|
||||
return (
|
||||
<StatusCard
|
||||
@@ -253,12 +405,12 @@ const RecipesPage: React.FC = () => {
|
||||
id={recipe.id}
|
||||
statusIndicator={statusConfig}
|
||||
title={recipe.name}
|
||||
subtitle={`${statusConfig.text} • ${statusConfig.difficultyLabel}${statusConfig.isHighlight ? ' ★' + recipe.rating : ''}`}
|
||||
primaryValue={recipe.ingredients.length}
|
||||
subtitle={`${statusConfig.text} • ${statusConfig.difficultyLabel}${statusConfig.isHighlight ? ' ★' : ''}`}
|
||||
primaryValue={recipe.ingredients?.length || 0}
|
||||
primaryValueLabel="ingredientes"
|
||||
secondaryInfo={{
|
||||
label: 'Margen',
|
||||
value: `€${formatters.compact(recipe.profit)}`
|
||||
value: `€${formatters.compact(price - cost)}`
|
||||
}}
|
||||
progress={{
|
||||
label: 'Margen de beneficio',
|
||||
@@ -267,8 +419,8 @@ const RecipesPage: React.FC = () => {
|
||||
}}
|
||||
metadata={[
|
||||
`Tiempo: ${totalTime}`,
|
||||
`Porciones: ${recipe.yield}`,
|
||||
`${recipe.ingredients.length} ingredientes principales`
|
||||
`Rendimiento: ${recipe.yield_quantity} ${recipe.yield_unit}`,
|
||||
`${recipe.ingredients?.length || 0} ingredientes principales`
|
||||
]}
|
||||
actions={[
|
||||
{
|
||||
@@ -328,110 +480,33 @@ const RecipesPage: React.FC = () => {
|
||||
setShowForm(false);
|
||||
setSelectedRecipe(null);
|
||||
setModalMode('view');
|
||||
setEditedRecipe({});
|
||||
}}
|
||||
mode={modalMode}
|
||||
onModeChange={setModalMode}
|
||||
title={selectedRecipe.name}
|
||||
subtitle={selectedRecipe.description}
|
||||
statusIndicator={getRecipeStatusConfig(selectedRecipe.category, selectedRecipe.difficulty, selectedRecipe.rating)}
|
||||
image={selectedRecipe.image}
|
||||
subtitle={selectedRecipe.description || ''}
|
||||
statusIndicator={getRecipeStatusConfig(selectedRecipe)}
|
||||
size="xl"
|
||||
sections={[
|
||||
sections={getModalSections()}
|
||||
onFieldChange={handleFieldChange}
|
||||
actions={modalMode === 'edit' ? [
|
||||
{
|
||||
title: 'Información Básica',
|
||||
label: 'Guardar',
|
||||
icon: ChefHat,
|
||||
fields: [
|
||||
{
|
||||
label: 'Categoría',
|
||||
value: selectedRecipe.category,
|
||||
type: 'status'
|
||||
},
|
||||
{
|
||||
label: 'Dificultad',
|
||||
value: selectedRecipe.difficulty
|
||||
},
|
||||
{
|
||||
label: 'Valoración',
|
||||
value: `${selectedRecipe.rating} ★`,
|
||||
highlight: selectedRecipe.rating >= 4.7
|
||||
},
|
||||
{
|
||||
label: 'Rendimiento',
|
||||
value: `${selectedRecipe.yield} porciones`
|
||||
}
|
||||
]
|
||||
variant: 'primary',
|
||||
onClick: handleSaveRecipe,
|
||||
disabled: updateRecipeMutation.isPending
|
||||
},
|
||||
{
|
||||
title: 'Tiempos',
|
||||
icon: Clock,
|
||||
fields: [
|
||||
{
|
||||
label: 'Tiempo de preparación',
|
||||
value: formatTime(selectedRecipe.prepTime)
|
||||
},
|
||||
{
|
||||
label: 'Tiempo de horneado',
|
||||
value: formatTime(selectedRecipe.bakingTime)
|
||||
},
|
||||
{
|
||||
label: 'Tiempo total',
|
||||
value: formatTime(selectedRecipe.prepTime + selectedRecipe.bakingTime),
|
||||
highlight: true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Análisis Financiero',
|
||||
icon: DollarSign,
|
||||
fields: [
|
||||
{
|
||||
label: 'Costo de producción',
|
||||
value: selectedRecipe.cost,
|
||||
type: 'currency'
|
||||
},
|
||||
{
|
||||
label: 'Precio de venta',
|
||||
value: selectedRecipe.price,
|
||||
type: 'currency'
|
||||
},
|
||||
{
|
||||
label: 'Margen de beneficio',
|
||||
value: selectedRecipe.profit,
|
||||
type: 'currency',
|
||||
highlight: true
|
||||
},
|
||||
{
|
||||
label: 'Porcentaje de margen',
|
||||
value: Math.round((selectedRecipe.profit / selectedRecipe.price) * 100),
|
||||
type: 'percentage',
|
||||
highlight: true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Ingredientes',
|
||||
icon: Package,
|
||||
fields: [
|
||||
{
|
||||
label: 'Lista de ingredientes',
|
||||
value: selectedRecipe.ingredients.map(ing => `${ing.name}: ${ing.quantity} ${ing.unit}`),
|
||||
type: 'list',
|
||||
span: 2
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Etiquetas',
|
||||
fields: [
|
||||
{
|
||||
label: 'Tags',
|
||||
value: selectedRecipe.tags.join(', '),
|
||||
span: 2
|
||||
}
|
||||
]
|
||||
label: 'Cancelar',
|
||||
variant: 'outline',
|
||||
onClick: () => {
|
||||
setModalMode('view');
|
||||
setEditedRecipe({});
|
||||
}
|
||||
}
|
||||
]}
|
||||
actions={[
|
||||
] : [
|
||||
{
|
||||
label: 'Producir',
|
||||
icon: ChefHat,
|
||||
@@ -444,10 +519,18 @@ const RecipesPage: React.FC = () => {
|
||||
}
|
||||
]}
|
||||
onEdit={() => {
|
||||
console.log('Editing recipe:', selectedRecipe.id);
|
||||
setModalMode('edit');
|
||||
setEditedRecipe({});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Create Recipe Modal */}
|
||||
<CreateRecipeModal
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onCreateRecipe={handleCreateRecipe}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, Building2, Phone, Mail, Eye, Edit, CheckCircle, AlertCircle, Timer, Users, DollarSign, Loader } from 'lucide-react';
|
||||
import { Plus, Building2, Phone, Mail, Eye, Edit, CheckCircle, AlertCircle, Timer, Users, Euro, Loader } from 'lucide-react';
|
||||
import { Button, Input, Card, Badge, StatsGrid, StatusCard, getStatusColor, StatusModal } from '../../../../components/ui';
|
||||
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
@@ -107,7 +107,7 @@ const SuppliersPage: React.FC = () => {
|
||||
title: 'Gasto Total',
|
||||
value: formatters.currency(supplierStats.total_spend),
|
||||
variant: 'info' as const,
|
||||
icon: DollarSign,
|
||||
icon: Euro,
|
||||
},
|
||||
{
|
||||
title: 'Calidad Media',
|
||||
@@ -382,7 +382,7 @@ const SuppliersPage: React.FC = () => {
|
||||
},
|
||||
{
|
||||
title: 'Rendimiento y Estadísticas',
|
||||
icon: DollarSign,
|
||||
icon: Euro,
|
||||
fields: [
|
||||
{
|
||||
label: 'Moneda',
|
||||
|
||||
Reference in New Issue
Block a user