323 lines
11 KiB
TypeScript
323 lines
11 KiB
TypeScript
// frontend/src/components/recipes/IngredientList.tsx
|
||
import React from 'react';
|
||
import {
|
||
Plus,
|
||
Minus,
|
||
Edit2,
|
||
Trash2,
|
||
GripVertical,
|
||
Info,
|
||
AlertCircle,
|
||
Package,
|
||
Droplets,
|
||
Scale,
|
||
Euro
|
||
} from 'lucide-react';
|
||
|
||
import { RecipeIngredient } from '../../api/services/recipes.service';
|
||
|
||
interface IngredientListProps {
|
||
ingredients: RecipeIngredient[];
|
||
editable?: boolean;
|
||
showCosts?: boolean;
|
||
showGroups?: boolean;
|
||
batchMultiplier?: number;
|
||
onAddIngredient?: () => void;
|
||
onEditIngredient?: (ingredient: RecipeIngredient) => void;
|
||
onRemoveIngredient?: (ingredientId: string) => void;
|
||
onReorderIngredients?: (ingredients: RecipeIngredient[]) => void;
|
||
className?: string;
|
||
}
|
||
|
||
const IngredientList: React.FC<IngredientListProps> = ({
|
||
ingredients,
|
||
editable = false,
|
||
showCosts = false,
|
||
showGroups = true,
|
||
batchMultiplier = 1,
|
||
onAddIngredient,
|
||
onEditIngredient,
|
||
onRemoveIngredient,
|
||
onReorderIngredients,
|
||
className = ''
|
||
}) => {
|
||
// Group ingredients by ingredient_group
|
||
const groupedIngredients = React.useMemo(() => {
|
||
if (!showGroups) {
|
||
return { 'All Ingredients': ingredients };
|
||
}
|
||
|
||
const groups: Record<string, RecipeIngredient[]> = {};
|
||
|
||
ingredients.forEach(ingredient => {
|
||
const group = ingredient.ingredient_group || 'Other';
|
||
if (!groups[group]) {
|
||
groups[group] = [];
|
||
}
|
||
groups[group].push(ingredient);
|
||
});
|
||
|
||
// Sort ingredients within each group by order
|
||
Object.keys(groups).forEach(group => {
|
||
groups[group].sort((a, b) => a.ingredient_order - b.ingredient_order);
|
||
});
|
||
|
||
return groups;
|
||
}, [ingredients, showGroups]);
|
||
|
||
// Get unit icon
|
||
const getUnitIcon = (unit: string) => {
|
||
switch (unit.toLowerCase()) {
|
||
case 'g':
|
||
case 'kg':
|
||
return <Scale className="w-4 h-4" />;
|
||
case 'ml':
|
||
case 'l':
|
||
return <Droplets className="w-4 h-4" />;
|
||
case 'units':
|
||
case 'pieces':
|
||
case 'pcs':
|
||
return <Package className="w-4 h-4" />;
|
||
default:
|
||
return <Scale className="w-4 h-4" />;
|
||
}
|
||
};
|
||
|
||
// Format quantity with multiplier
|
||
const formatQuantity = (quantity: number, unit: string) => {
|
||
const adjustedQuantity = quantity * batchMultiplier;
|
||
return `${adjustedQuantity} ${unit}`;
|
||
};
|
||
|
||
// Calculate total cost
|
||
const getTotalCost = () => {
|
||
return ingredients.reduce((total, ingredient) => {
|
||
const cost = ingredient.total_cost || 0;
|
||
return total + (cost * batchMultiplier);
|
||
}, 0);
|
||
};
|
||
|
||
return (
|
||
<div className={`bg-white rounded-lg border ${className}`}>
|
||
{/* Header */}
|
||
<div className="p-4 border-b bg-gray-50 rounded-t-lg">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h3 className="text-lg font-semibold text-gray-900">Ingredients</h3>
|
||
<p className="text-sm text-gray-600">
|
||
{ingredients.length} ingredient{ingredients.length !== 1 ? 's' : ''}
|
||
{batchMultiplier !== 1 && (
|
||
<span className="ml-2 px-2 py-1 bg-blue-100 text-blue-800 rounded text-xs">
|
||
×{batchMultiplier} batch
|
||
</span>
|
||
)}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex items-center space-x-2">
|
||
{showCosts && (
|
||
<div className="text-right">
|
||
<div className="text-sm text-gray-600">Total Cost</div>
|
||
<div className="text-lg font-semibold text-gray-900 flex items-center">
|
||
<Euro className="w-4 h-4 mr-1" />
|
||
{getTotalCost().toFixed(2)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{editable && onAddIngredient && (
|
||
<button
|
||
onClick={onAddIngredient}
|
||
className="px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center space-x-2"
|
||
>
|
||
<Plus className="w-4 h-4" />
|
||
<span>Add Ingredient</span>
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Ingredients List */}
|
||
<div className="divide-y">
|
||
{Object.entries(groupedIngredients).map(([groupName, groupIngredients]) => (
|
||
<div key={groupName}>
|
||
{/* Group Header */}
|
||
{showGroups && Object.keys(groupedIngredients).length > 1 && (
|
||
<div className="px-4 py-2 bg-gray-25 border-b">
|
||
<h4 className="text-sm font-medium text-gray-700 uppercase tracking-wide">
|
||
{groupName}
|
||
</h4>
|
||
</div>
|
||
)}
|
||
|
||
{/* Group Ingredients */}
|
||
{groupIngredients.map((ingredient, index) => (
|
||
<div
|
||
key={ingredient.id}
|
||
className="p-4 hover:bg-gray-50 transition-colors"
|
||
>
|
||
<div className="flex items-center space-x-4">
|
||
{/* Drag Handle */}
|
||
{editable && onReorderIngredients && (
|
||
<div className="cursor-move text-gray-400 hover:text-gray-600">
|
||
<GripVertical className="w-4 h-4" />
|
||
</div>
|
||
)}
|
||
|
||
{/* Order Number */}
|
||
<div className="w-6 h-6 bg-gray-200 rounded-full flex items-center justify-center text-xs font-medium text-gray-600">
|
||
{ingredient.ingredient_order}
|
||
</div>
|
||
|
||
{/* Ingredient Info */}
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center space-x-2 mb-1">
|
||
<h4 className="font-medium text-gray-900">
|
||
{ingredient.ingredient_id} {/* This would be ingredient name from inventory */}
|
||
</h4>
|
||
|
||
{ingredient.is_optional && (
|
||
<span className="px-2 py-1 bg-yellow-100 text-yellow-800 rounded text-xs">
|
||
Optional
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* Quantity */}
|
||
<div className="flex items-center space-x-2 text-sm text-gray-600">
|
||
{getUnitIcon(ingredient.unit)}
|
||
<span className="font-medium">
|
||
{formatQuantity(ingredient.quantity, ingredient.unit)}
|
||
</span>
|
||
|
||
{ingredient.alternative_quantity && ingredient.alternative_unit && (
|
||
<span className="text-gray-500">
|
||
(≈ {formatQuantity(ingredient.alternative_quantity, ingredient.alternative_unit)})
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* Preparation Method */}
|
||
{ingredient.preparation_method && (
|
||
<div className="text-sm text-gray-600 mt-1">
|
||
<span className="font-medium">Prep:</span> {ingredient.preparation_method}
|
||
</div>
|
||
)}
|
||
|
||
{/* Notes */}
|
||
{ingredient.ingredient_notes && (
|
||
<div className="text-sm text-gray-600 mt-1 flex items-start">
|
||
<Info className="w-3 h-3 mr-1 mt-0.5 flex-shrink-0" />
|
||
<span>{ingredient.ingredient_notes}</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Substitutions */}
|
||
{ingredient.substitution_options && (
|
||
<div className="text-sm text-blue-600 mt-1">
|
||
<span className="font-medium">Substitutions available</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Cost */}
|
||
{showCosts && (
|
||
<div className="text-right">
|
||
<div className="text-sm font-medium text-gray-900">
|
||
€{((ingredient.total_cost || 0) * batchMultiplier).toFixed(2)}
|
||
</div>
|
||
{ingredient.unit_cost && (
|
||
<div className="text-xs text-gray-600">
|
||
€{ingredient.unit_cost.toFixed(2)}/{ingredient.unit}
|
||
</div>
|
||
)}
|
||
{ingredient.cost_updated_at && (
|
||
<div className="text-xs text-gray-500">
|
||
{new Date(ingredient.cost_updated_at).toLocaleDateString()}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Actions */}
|
||
{editable && (
|
||
<div className="flex items-center space-x-2">
|
||
<button
|
||
onClick={() => onEditIngredient?.(ingredient)}
|
||
className="p-1 text-gray-400 hover:text-blue-600 transition-colors"
|
||
title="Edit ingredient"
|
||
>
|
||
<Edit2 className="w-4 h-4" />
|
||
</button>
|
||
|
||
<button
|
||
onClick={() => onRemoveIngredient?.(ingredient.id)}
|
||
className="p-1 text-gray-400 hover:text-red-600 transition-colors"
|
||
title="Remove ingredient"
|
||
>
|
||
<Trash2 className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
))}
|
||
|
||
{/* Empty State */}
|
||
{ingredients.length === 0 && (
|
||
<div className="p-8 text-center">
|
||
<Package className="w-12 h-12 text-gray-400 mx-auto mb-4" />
|
||
<h3 className="text-lg font-medium text-gray-900 mb-2">No ingredients yet</h3>
|
||
<p className="text-gray-600 mb-4">
|
||
Add ingredients to start building your recipe
|
||
</p>
|
||
{editable && onAddIngredient && (
|
||
<button
|
||
onClick={onAddIngredient}
|
||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||
>
|
||
Add First Ingredient
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Summary */}
|
||
{ingredients.length > 0 && (
|
||
<div className="p-4 bg-gray-50 border-t rounded-b-lg">
|
||
<div className="flex items-center justify-between text-sm">
|
||
<div className="flex items-center space-x-4">
|
||
<span className="text-gray-600">
|
||
{ingredients.length} total ingredients
|
||
</span>
|
||
|
||
{ingredients.filter(i => i.is_optional).length > 0 && (
|
||
<span className="text-yellow-600">
|
||
{ingredients.filter(i => i.is_optional).length} optional
|
||
</span>
|
||
)}
|
||
|
||
{ingredients.some(i => i.substitution_options) && (
|
||
<span className="text-blue-600">
|
||
{ingredients.filter(i => i.substitution_options).length} with substitutions
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{showCosts && (
|
||
<div className="font-medium text-gray-900">
|
||
Total: €{getTotalCost().toFixed(2)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default IngredientList; |