From d18c64ce6e913e380bc7cd571d6f1539bab53beb Mon Sep 17 00:00:00 2001 From: Urtzi Alfaro Date: Fri, 19 Sep 2025 21:39:04 +0200 Subject: [PATCH] Create the frontend receipes page to use real API --- add_real_recipes.sh | 142 ++++ add_sample_recipes.sh | 145 ++++ .../src/api/README_RECIPES_RESTRUCTURE.md | 244 +++++++ frontend/src/api/hooks/recipes.ts | 328 ++------- frontend/src/api/index.ts | 30 +- frontend/src/api/services/recipes.ts | 170 ++--- .../domain/production/BatchTracker.tsx | 2 +- .../domain/production/ProductionSchedule.tsx | 2 +- .../domain/production/QualityControl.tsx | 2 +- .../domain/recipes/CreateRecipeModal.tsx | 622 ++++++++++++++++++ .../src/components/domain/recipes/index.ts | 1 + frontend/src/examples/RecipesExample.tsx | 542 +++++++++++++++ frontend/src/locales/en/recipes.json | 267 ++++++++ .../app/operations/orders/OrdersPage.tsx | 14 +- .../app/operations/recipes/RecipesPage.tsx | 579 +++++++++------- .../operations/suppliers/SuppliersPage.tsx | 6 +- gateway/app/routes/tenant.py | 20 + insert_real_recipes.sql | 302 +++++++++ services/recipes/add_sample_recipes.py | 183 ++++++ services/recipes/app/api/ingredients.py | 117 ---- services/recipes/app/api/production.py | 427 ------------ services/recipes/app/api/recipes.py | 90 ++- services/recipes/app/core/config.py | 8 +- services/recipes/app/core/database.py | 7 +- services/recipes/app/main.py | 16 +- services/recipes/app/repositories/__init__.py | 6 +- services/recipes/app/repositories/base.py | 96 --- .../app/repositories/production_repository.py | 382 ----------- .../app/repositories/recipe_repository.py | 474 ++++++------- services/recipes/app/schemas/__init__.py | 24 +- services/recipes/app/schemas/production.py | 257 -------- services/recipes/app/services/__init__.py | 6 +- .../recipes/app/services/inventory_client.py | 151 ----- .../app/services/production_service.py | 401 ----------- .../recipes/app/services/recipe_service.py | 462 +++++-------- shared/config/base.py | 2 + 36 files changed, 3356 insertions(+), 3171 deletions(-) create mode 100755 add_real_recipes.sh create mode 100755 add_sample_recipes.sh create mode 100644 frontend/src/api/README_RECIPES_RESTRUCTURE.md create mode 100644 frontend/src/components/domain/recipes/CreateRecipeModal.tsx create mode 100644 frontend/src/components/domain/recipes/index.ts create mode 100644 frontend/src/examples/RecipesExample.tsx create mode 100644 frontend/src/locales/en/recipes.json create mode 100644 insert_real_recipes.sql create mode 100644 services/recipes/add_sample_recipes.py delete mode 100644 services/recipes/app/api/ingredients.py delete mode 100644 services/recipes/app/api/production.py delete mode 100644 services/recipes/app/repositories/base.py delete mode 100644 services/recipes/app/repositories/production_repository.py delete mode 100644 services/recipes/app/schemas/production.py delete mode 100644 services/recipes/app/services/inventory_client.py delete mode 100644 services/recipes/app/services/production_service.py diff --git a/add_real_recipes.sh b/add_real_recipes.sh new file mode 100755 index 00000000..4f7412b5 --- /dev/null +++ b/add_real_recipes.sh @@ -0,0 +1,142 @@ +#!/bin/bash + +# Add real recipes using actual inventory data +TENANT_ID="c464fb3e-7af2-46e6-9e43-85318f34199a" +USER_ID="550e8400-e29b-41d4-a716-446655440000" # User UUID format +API_BASE="http://localhost:8009" + +echo "🧁 Adding real recipes with actual inventory data..." +echo "📍 Tenant ID: $TENANT_ID" +echo "🌐 API Base: $API_BASE" +echo "=" | tr -d '\n' && printf '%.0s=' {1..50} && echo + +# Real finished product IDs from inventory +CROISSANT_ID="c9a049b7-d1ae-4bf5-99cc-ed2c46f0a509" +NAPOLITANA_ID="72c1020f-64be-4b42-8857-e774908204d9" +PALMERA_ID="9e888a05-9dda-488b-a06c-0c60a4479e67" +PAN_TOSTADO_ID="368fba0e-8ec5-4048-a2f1-79b63f9e11cf" + +# Real ingredient IDs from inventory +HARINA_TRIGO_ID="1e1d496f-c041-4f42-82a9-2ae7837c9231" # Harina de Trigo +LEVADURA_ID="de2f0852-75f5-4b18-8f0e-7c707f79a9f9" # Levadura Fresca +MANTEQUILLA_ID="89e6224a-a055-4148-a2f1-86a5091becec" # Mantequilla + +# Recipe 1: Croissant de Mantequilla +echo "Creating Recipe 1: Croissant de Mantequilla..." +curl -X POST "$API_BASE/api/v1/recipes/" \ + -H "Content-Type: application/json" \ + -H "x-tenant-id: $TENANT_ID" \ + -H "x-user-id: $USER_ID" \ + -d '{ + "name": "Croissant de Mantequilla", + "recipe_code": "CRO001", + "finished_product_id": "'$CROISSANT_ID'", + "description": "Croissant clásico francés con mantequilla, hojaldrado perfecto y textura crujiente", + "category": "pastry", + "difficulty_level": 3, + "yield_quantity": 12, + "yield_unit": "units", + "prep_time_minutes": 240, + "cook_time_minutes": 18, + "total_time_minutes": 258, + "is_signature_item": true, + "target_margin_percentage": 65.0, + "ingredients": [ + {"ingredient_id": "'$HARINA_TRIGO_ID'", "quantity": 500, "unit": "g", "is_optional": false, "ingredient_order": 1}, + {"ingredient_id": "'$MANTEQUILLA_ID'", "quantity": 300, "unit": "g", "is_optional": false, "ingredient_order": 2}, + {"ingredient_id": "'$LEVADURA_ID'", "quantity": 12, "unit": "g", "is_optional": false, "ingredient_order": 3} + ] + }' + +echo -e "\n\n" + +# Recipe 2: Napolitana de Chocolate +echo "Creating Recipe 2: Napolitana de Chocolate..." +curl -X POST "$API_BASE/api/v1/recipes/" \ + -H "Content-Type: application/json" \ + -H "x-tenant-id: $TENANT_ID" \ + -H "x-user-id: $USER_ID" \ + -d '{ + "name": "Napolitana de Chocolate", + "recipe_code": "NAP001", + "finished_product_id": "'$NAPOLITANA_ID'", + "description": "Hojaldre relleno de chocolate, dulce y crujiente, perfecto para merienda", + "category": "pastry", + "difficulty_level": 2, + "yield_quantity": 8, + "yield_unit": "units", + "prep_time_minutes": 45, + "cook_time_minutes": 20, + "total_time_minutes": 65, + "is_signature_item": false, + "target_margin_percentage": 70.0, + "ingredients": [ + {"ingredient_id": "'$HARINA_TRIGO_ID'", "quantity": 300, "unit": "g", "is_optional": false, "ingredient_order": 1}, + {"ingredient_id": "'$MANTEQUILLA_ID'", "quantity": 200, "unit": "g", "is_optional": false, "ingredient_order": 2} + ] + }' + +echo -e "\n\n" + +# Recipe 3: Palmera de Azúcar +echo "Creating Recipe 3: Palmera de Azúcar..." +curl -X POST "$API_BASE/api/v1/recipes/" \ + -H "Content-Type: application/json" \ + -H "x-tenant-id: $TENANT_ID" \ + -H "x-user-id: $USER_ID" \ + -d '{ + "name": "Palmera de Azúcar", + "recipe_code": "PAL001", + "finished_product_id": "'$PALMERA_ID'", + "description": "Palmera de hojaldre con azúcar caramelizado, dulce y crujiente", + "category": "pastry", + "difficulty_level": 2, + "yield_quantity": 10, + "yield_unit": "units", + "prep_time_minutes": 30, + "cook_time_minutes": 15, + "total_time_minutes": 45, + "is_signature_item": false, + "target_margin_percentage": 75.0, + "ingredients": [ + {"ingredient_id": "'$HARINA_TRIGO_ID'", "quantity": 400, "unit": "g", "is_optional": false, "ingredient_order": 1}, + {"ingredient_id": "'$MANTEQUILLA_ID'", "quantity": 150, "unit": "g", "is_optional": false, "ingredient_order": 2} + ] + }' + +echo -e "\n\n" + +# Recipe 4: Pan Tostado Artesanal +echo "Creating Recipe 4: Pan Tostado Artesanal..." +curl -X POST "$API_BASE/api/v1/recipes/" \ + -H "Content-Type: application/json" \ + -H "x-tenant-id: $TENANT_ID" \ + -H "x-user-id: $USER_ID" \ + -d '{ + "name": "Pan Tostado Artesanal", + "recipe_code": "PAN001", + "finished_product_id": "'$PAN_TOSTADO_ID'", + "description": "Pan artesanal con corteza crujiente y miga tierna, perfecto para tostadas", + "category": "bread", + "difficulty_level": 2, + "yield_quantity": 2, + "yield_unit": "units", + "prep_time_minutes": 180, + "cook_time_minutes": 35, + "total_time_minutes": 215, + "is_signature_item": true, + "target_margin_percentage": 60.0, + "ingredients": [ + {"ingredient_id": "'$HARINA_TRIGO_ID'", "quantity": 600, "unit": "g", "is_optional": false, "ingredient_order": 1}, + {"ingredient_id": "'$LEVADURA_ID'", "quantity": 15, "unit": "g", "is_optional": false, "ingredient_order": 2} + ] + }' + +echo -e "\n\n🎉 Real recipes creation completed!" +echo "✅ Created recipes for existing products:" +echo " • Croissant de Mantequilla" +echo " • Napolitana de Chocolate" +echo " • Palmera de Azúcar" +echo " • Pan Tostado Artesanal" +echo "" +echo "🔗 All recipes are linked to real products and ingredients from inventory!" \ No newline at end of file diff --git a/add_sample_recipes.sh b/add_sample_recipes.sh new file mode 100755 index 00000000..4e83d64c --- /dev/null +++ b/add_sample_recipes.sh @@ -0,0 +1,145 @@ +#!/bin/bash + +# Add sample recipes using the API +TENANT_ID="c464fb3e-7af2-46e6-9e43-85318f34199a" +API_BASE="http://localhost:8009" + +echo "🧁 Adding sample recipes via API..." +echo "📍 Tenant ID: $TENANT_ID" +echo "🌐 API Base: $API_BASE" +echo "=" | tr -d '\n' && printf '%.0s=' {1..50} && echo + +# Sample finished product IDs (these should exist in your system) +PRODUCT_ID_1="550e8400-e29b-41d4-a716-446655440001" +PRODUCT_ID_2="550e8400-e29b-41d4-a716-446655440002" +PRODUCT_ID_3="550e8400-e29b-41d4-a716-446655440003" +PRODUCT_ID_4="550e8400-e29b-41d4-a716-446655440004" + +# Sample ingredient IDs (these should exist in your system) +ING_ID_1="660e8400-e29b-41d4-a716-446655440001" # Harina integral +ING_ID_2="660e8400-e29b-41d4-a716-446655440002" # Agua +ING_ID_3="660e8400-e29b-41d4-a716-446655440003" # Levadura +ING_ID_4="660e8400-e29b-41d4-a716-446655440004" # Sal +ING_ID_5="660e8400-e29b-41d4-a716-446655440005" # Harina de fuerza +ING_ID_6="660e8400-e29b-41d4-a716-446655440006" # Mantequilla +ING_ID_7="660e8400-e29b-41d4-a716-446655440007" # Leche +ING_ID_8="660e8400-e29b-41d4-a716-446655440008" # Azúcar +ING_ID_9="660e8400-e29b-41d4-a716-446655440009" # Manzanas +ING_ID_10="660e8400-e29b-41d4-a716-446655440010" # Huevos +ING_ID_11="660e8400-e29b-41d4-a716-446655440011" # Limón + +# Recipe 1: Pan de Molde Integral +echo "Creating Recipe 1: Pan de Molde Integral..." +curl -X POST "$API_BASE/api/v1/recipes/" \ + -H "Content-Type: application/json" \ + -H "x-tenant-id: $TENANT_ID" \ + -d '{ + "name": "Pan de Molde Integral", + "recipe_code": "PAN001", + "finished_product_id": "'$PRODUCT_ID_1'", + "description": "Pan integral artesanal con semillas, perfecto para desayunos saludables.", + "category": "bread", + "difficulty_level": 2, + "yield_quantity": 1, + "yield_unit": "units", + "prep_time_minutes": 120, + "cook_time_minutes": 35, + "total_time_minutes": 155, + "is_signature_item": false, + "target_margin_percentage": 40.0, + "ingredients": [ + {"ingredient_id": "'$ING_ID_1'", "quantity": 500, "unit": "g", "is_optional": false, "ingredient_order": 1}, + {"ingredient_id": "'$ING_ID_2'", "quantity": 300, "unit": "ml", "is_optional": false, "ingredient_order": 2}, + {"ingredient_id": "'$ING_ID_3'", "quantity": 10, "unit": "g", "is_optional": false, "ingredient_order": 3}, + {"ingredient_id": "'$ING_ID_4'", "quantity": 8, "unit": "g", "is_optional": false, "ingredient_order": 4} + ] + }' + +echo -e "\n\n" + +# Recipe 2: Croissants de Mantequilla +echo "Creating Recipe 2: Croissants de Mantequilla..." +curl -X POST "$API_BASE/api/v1/recipes/" \ + -H "Content-Type: application/json" \ + -H "x-tenant-id: $TENANT_ID" \ + -d '{ + "name": "Croissants de Mantequilla", + "recipe_code": "CRO001", + "finished_product_id": "'$PRODUCT_ID_2'", + "description": "Croissants franceses tradicionales con laminado de mantequilla.", + "category": "pastry", + "difficulty_level": 3, + "yield_quantity": 12, + "yield_unit": "units", + "prep_time_minutes": 480, + "cook_time_minutes": 20, + "total_time_minutes": 500, + "is_signature_item": true, + "target_margin_percentage": 52.8, + "ingredients": [ + {"ingredient_id": "'$ING_ID_5'", "quantity": 500, "unit": "g", "is_optional": false, "ingredient_order": 1}, + {"ingredient_id": "'$ING_ID_6'", "quantity": 250, "unit": "g", "is_optional": false, "ingredient_order": 2}, + {"ingredient_id": "'$ING_ID_7'", "quantity": 150, "unit": "ml", "is_optional": false, "ingredient_order": 3}, + {"ingredient_id": "'$ING_ID_8'", "quantity": 50, "unit": "g", "is_optional": false, "ingredient_order": 4} + ] + }' + +echo -e "\n\n" + +# Recipe 3: Tarta de Manzana +echo "Creating Recipe 3: Tarta de Manzana..." +curl -X POST "$API_BASE/api/v1/recipes/" \ + -H "Content-Type: application/json" \ + -H "x-tenant-id: $TENANT_ID" \ + -d '{ + "name": "Tarta de Manzana", + "recipe_code": "TAR001", + "finished_product_id": "'$PRODUCT_ID_3'", + "description": "Tarta casera de manzana con canela y masa quebrada.", + "category": "cake", + "difficulty_level": 1, + "yield_quantity": 8, + "yield_unit": "portions", + "prep_time_minutes": 45, + "cook_time_minutes": 40, + "total_time_minutes": 85, + "is_signature_item": false, + "target_margin_percentage": 65.0, + "ingredients": [ + {"ingredient_id": "'$ING_ID_9'", "quantity": 1000, "unit": "g", "is_optional": false, "ingredient_order": 1}, + {"ingredient_id": "'$ING_ID_1'", "quantity": 250, "unit": "g", "is_optional": false, "ingredient_order": 2}, + {"ingredient_id": "'$ING_ID_6'", "quantity": 125, "unit": "g", "is_optional": false, "ingredient_order": 3}, + {"ingredient_id": "'$ING_ID_8'", "quantity": 100, "unit": "g", "is_optional": false, "ingredient_order": 4} + ] + }' + +echo -e "\n\n" + +# Recipe 4: Magdalenas de Limón +echo "Creating Recipe 4: Magdalenas de Limón..." +curl -X POST "$API_BASE/api/v1/recipes/" \ + -H "Content-Type: application/json" \ + -H "x-tenant-id: $TENANT_ID" \ + -d '{ + "name": "Magdalenas de Limón", + "recipe_code": "MAG001", + "finished_product_id": "'$PRODUCT_ID_4'", + "description": "Magdalenas suaves y esponjosas con ralladura de limón.", + "category": "pastry", + "difficulty_level": 1, + "yield_quantity": 12, + "yield_unit": "units", + "prep_time_minutes": 20, + "cook_time_minutes": 25, + "total_time_minutes": 45, + "is_signature_item": false, + "target_margin_percentage": 57.8, + "ingredients": [ + {"ingredient_id": "'$ING_ID_1'", "quantity": 200, "unit": "g", "is_optional": false, "ingredient_order": 1}, + {"ingredient_id": "'$ING_ID_10'", "quantity": 3, "unit": "units", "is_optional": false, "ingredient_order": 2}, + {"ingredient_id": "'$ING_ID_8'", "quantity": 150, "unit": "g", "is_optional": false, "ingredient_order": 3}, + {"ingredient_id": "'$ING_ID_11'", "quantity": 2, "unit": "units", "is_optional": false, "ingredient_order": 4} + ] + }' + +echo -e "\n\n🎉 Sample recipes creation completed!" \ No newline at end of file diff --git a/frontend/src/api/README_RECIPES_RESTRUCTURE.md b/frontend/src/api/README_RECIPES_RESTRUCTURE.md new file mode 100644 index 00000000..ec1dcc55 --- /dev/null +++ b/frontend/src/api/README_RECIPES_RESTRUCTURE.md @@ -0,0 +1,244 @@ +# Recipes API Restructure Summary + +## Overview + +The recipes service API implementation has been completely restructured to handle tenant-dependent routing and properly mirror the backend API endpoints. This ensures consistency with the backend architecture and enables proper multi-tenant functionality. + +## Architecture Changes + +### 1. **Client Layer** (`src/api/client/`) +- ✅ **Already properly implemented**: The existing `apiClient.ts` handles authentication, tenant headers, and error management +- ✅ **Supports tenant-dependent routing**: Client properly forwards tenant ID in headers +- ✅ **React Query integration**: Returns data directly for React Query consumption + +### 2. **Services Layer** (`src/api/services/recipes.ts`) + +#### **Before (Issues)**: +- Missing tenant parameter in all methods +- API calls didn't match backend tenant-dependent routing +- Inconsistent URL patterns + +#### **After (Fixed)**: +```typescript +// All methods now require tenantId parameter +async createRecipe(tenantId: string, recipeData: RecipeCreate): Promise +async getRecipe(tenantId: string, recipeId: string): Promise +async searchRecipes(tenantId: string, params: RecipeSearchParams = {}): Promise + +// URLs properly formatted for tenant-dependent routing +private getBaseUrl(tenantId: string): string { + return `/tenants/${tenantId}/recipes`; +} +``` + +#### **API Endpoints Mirrored**: +- ✅ `POST /tenants/{tenant_id}/recipes` - Create recipe +- ✅ `GET /tenants/{tenant_id}/recipes/{recipe_id}` - Get recipe with ingredients +- ✅ `PUT /tenants/{tenant_id}/recipes/{recipe_id}` - Update recipe +- ✅ `DELETE /tenants/{tenant_id}/recipes/{recipe_id}` - Delete recipe +- ✅ `GET /tenants/{tenant_id}/recipes` - Search recipes with filters +- ✅ `POST /tenants/{tenant_id}/recipes/{recipe_id}/duplicate` - Duplicate recipe +- ✅ `POST /tenants/{tenant_id}/recipes/{recipe_id}/activate` - Activate recipe +- ✅ `GET /tenants/{tenant_id}/recipes/{recipe_id}/feasibility` - Check feasibility +- ✅ `GET /tenants/{tenant_id}/recipes/statistics/dashboard` - Get statistics +- ✅ `GET /tenants/{tenant_id}/recipes/categories/list` - Get categories + +### 3. **Types Layer** (`src/api/types/recipes.ts`) + +#### **Backend Schema Mirroring**: +- ✅ **Enums**: `RecipeStatus`, `MeasurementUnit`, `ProductionStatus`, `ProductionPriority` +- ✅ **Interfaces**: Exactly match backend Pydantic schemas +- ✅ **Request/Response types**: `RecipeCreate`, `RecipeUpdate`, `RecipeResponse`, etc. +- ✅ **Search parameters**: `RecipeSearchParams` with all backend filters +- ✅ **Additional types**: `RecipeFeasibilityResponse`, `RecipeStatisticsResponse`, etc. + +### 4. **Hooks Layer** (`src/api/hooks/recipes.ts`) + +#### **Before (Issues)**: +- Missing tenant parameters in query keys +- Hooks didn't accept tenant ID +- Cache invalidation not tenant-scoped +- Production batch hooks removed (moved to production service) + +#### **After (Fixed)**: +```typescript +// Tenant-scoped query keys +export const recipesKeys = { + all: ['recipes'] as const, + tenant: (tenantId: string) => [...recipesKeys.all, 'tenant', tenantId] as const, + lists: (tenantId: string) => [...recipesKeys.tenant(tenantId), 'list'] as const, + detail: (tenantId: string, id: string) => [...recipesKeys.details(tenantId), id] as const, + // ... other tenant-scoped keys +}; + +// All hooks require tenantId parameter +export const useRecipes = ( + tenantId: string, + filters: RecipeSearchParams = {}, + options?: UseQueryOptions +) => { + return useQuery({ + queryKey: recipesKeys.list(tenantId, filters), + queryFn: () => recipesService.searchRecipes(tenantId, filters), + enabled: !!tenantId, + // ... + }); +}; +``` + +#### **Available Hooks**: +- ✅ **Queries**: `useRecipes`, `useRecipe`, `useRecipeStatistics`, `useRecipeCategories`, `useRecipeFeasibility` +- ✅ **Mutations**: `useCreateRecipe`, `useUpdateRecipe`, `useDeleteRecipe`, `useDuplicateRecipe`, `useActivateRecipe` +- ✅ **Infinite Queries**: `useInfiniteRecipes` for pagination + +### 5. **Internationalization** (`src/locales/`) + +#### **Added Complete i18n Support**: +- ✅ **Spanish (`es/recipes.json`)**: Already existed, comprehensive translations +- ✅ **English (`en/recipes.json`)**: Created new complete translation file +- ✅ **Categories covered**: + - Navigation, actions, fields, ingredients + - Status values, difficulty levels, units + - Categories, dietary tags, allergens + - Production, feasibility, statistics + - Filters, costs, messages, placeholders, tooltips + +## Integration with Existing Stores + +### **Auth Store Integration**: +- ✅ API client automatically includes authentication headers +- ✅ Token refresh handled transparently +- ✅ User context forwarded to backend + +### **Tenant Store Integration**: +- ✅ All hooks require `tenantId` parameter from tenant store +- ✅ Tenant-scoped query cache isolation +- ✅ Automatic tenant context in API calls + +## Usage Examples + +### **Basic Recipe List**: +```typescript +import { useRecipes } from '@/api'; +import { useCurrentTenant } from '@/stores/tenant.store'; + +const RecipesList = () => { + const currentTenant = useCurrentTenant(); + const { data: recipes, isLoading } = useRecipes(currentTenant?.id || '', { + status: 'active', + limit: 20 + }); + + return ( +
+ {recipes?.map(recipe => ( +
{recipe.name}
+ ))} +
+ ); +}; +``` + +### **Recipe Creation**: +```typescript +import { useCreateRecipe, MeasurementUnit } from '@/api'; + +const CreateRecipe = () => { + const currentTenant = useCurrentTenant(); + const createRecipe = useCreateRecipe(currentTenant?.id || ''); + + const handleSubmit = () => { + createRecipe.mutate({ + name: "Sourdough Bread", + finished_product_id: "uuid-here", + yield_quantity: 2, + yield_unit: MeasurementUnit.UNITS, + difficulty_level: 3, + ingredients: [ + { + ingredient_id: "flour-uuid", + quantity: 500, + unit: MeasurementUnit.GRAMS, + is_optional: false, + ingredient_order: 1 + } + ] + }); + }; +}; +``` + +## Benefits + +### **1. Consistency with Backend**: +- ✅ All API calls exactly match backend endpoints +- ✅ Request/response types mirror Pydantic schemas +- ✅ Proper tenant isolation at API level + +### **2. Type Safety**: +- ✅ Full TypeScript coverage +- ✅ Compile-time validation of API calls +- ✅ IDE autocomplete and error detection + +### **3. Caching & Performance**: +- ✅ Tenant-scoped React Query cache +- ✅ Efficient cache invalidation +- ✅ Background refetching and stale-while-revalidate + +### **4. Developer Experience**: +- ✅ Clean, consistent API surface +- ✅ Comprehensive i18n support +- ✅ Example components demonstrating usage +- ✅ Self-documenting code with JSDoc + +### **5. Multi-Tenant Architecture**: +- ✅ Complete tenant isolation +- ✅ Proper tenant context propagation +- ✅ Cache separation between tenants + +## Migration Guide + +### **For Existing Components**: + +1. **Add tenant parameter**: + ```typescript + // Before + const { data } = useRecipes(); + + // After + const currentTenant = useCurrentTenant(); + const { data } = useRecipes(currentTenant?.id || ''); + ``` + +2. **Update mutation calls**: + ```typescript + // Before + const createRecipe = useCreateRecipe(); + + // After + const createRecipe = useCreateRecipe(currentTenant?.id || ''); + ``` + +3. **Use proper types**: + ```typescript + import { RecipeResponse, RecipeCreate, MeasurementUnit } from '@/api'; + ``` + +## Verification + +### **Backend Compatibility**: +- ✅ All endpoints tested with actual backend +- ✅ Request/response format validation +- ✅ Tenant-dependent routing confirmed + +### **Gateway Routing**: +- ✅ Gateway properly proxies `/tenants/{tenant_id}/recipes/*` to recipes service +- ✅ Tenant ID forwarded correctly in headers +- ✅ Authentication and authorization working + +### **Data Flow**: +- ✅ Frontend → Gateway → Recipes Service → Database +- ✅ Proper tenant isolation at all levels +- ✅ Error handling and edge cases covered + +This restructure provides a solid foundation for the recipes feature that properly integrates with the multi-tenant architecture and ensures consistency with the backend API design. \ No newline at end of file diff --git a/frontend/src/api/hooks/recipes.ts b/frontend/src/api/hooks/recipes.ts index bef08c06..644ceb71 100644 --- a/frontend/src/api/hooks/recipes.ts +++ b/frontend/src/api/hooks/recipes.ts @@ -1,6 +1,7 @@ /** * Recipes React Query hooks * Data fetching and caching layer for recipe management + * All hooks properly handle tenant-dependent operations */ import { @@ -23,31 +24,19 @@ import type { RecipeFeasibilityResponse, RecipeStatisticsResponse, RecipeCategoriesResponse, - ProductionBatchResponse, - ProductionBatchCreate, - ProductionBatchUpdate, } from '../types/recipes'; // Query Keys Factory export const recipesKeys = { all: ['recipes'] as const, - lists: () => [...recipesKeys.all, 'list'] as const, - list: (filters: RecipeSearchParams) => [...recipesKeys.lists(), { filters }] as const, - details: () => [...recipesKeys.all, 'detail'] as const, - detail: (id: string) => [...recipesKeys.details(), id] as const, - statistics: () => [...recipesKeys.all, 'statistics'] as const, - categories: () => [...recipesKeys.all, 'categories'] as const, - feasibility: (id: string, batchMultiplier: number) => [...recipesKeys.all, 'feasibility', id, batchMultiplier] as const, - - // Production batch keys - productionBatches: { - all: ['production-batches'] as const, - lists: () => [...recipesKeys.productionBatches.all, 'list'] as const, - list: (filters: any) => [...recipesKeys.productionBatches.lists(), { filters }] as const, - details: () => [...recipesKeys.productionBatches.all, 'detail'] as const, - detail: (id: string) => [...recipesKeys.productionBatches.details(), id] as const, - byRecipe: (recipeId: string) => [...recipesKeys.productionBatches.all, 'recipe', recipeId] as const, - } + tenant: (tenantId: string) => [...recipesKeys.all, 'tenant', tenantId] as const, + lists: (tenantId: string) => [...recipesKeys.tenant(tenantId), 'list'] as const, + list: (tenantId: string, filters: RecipeSearchParams) => [...recipesKeys.lists(tenantId), { filters }] as const, + details: (tenantId: string) => [...recipesKeys.tenant(tenantId), 'detail'] as const, + detail: (tenantId: string, id: string) => [...recipesKeys.details(tenantId), id] as const, + statistics: (tenantId: string) => [...recipesKeys.tenant(tenantId), 'statistics'] as const, + categories: (tenantId: string) => [...recipesKeys.tenant(tenantId), 'categories'] as const, + feasibility: (tenantId: string, id: string, batchMultiplier: number) => [...recipesKeys.tenant(tenantId), 'feasibility', id, batchMultiplier] as const, } as const; // Recipe Queries @@ -56,13 +45,14 @@ export const recipesKeys = { * Fetch a single recipe by ID */ export const useRecipe = ( + tenantId: string, recipeId: string, options?: Omit, 'queryKey' | 'queryFn'> ) => { return useQuery({ - queryKey: recipesKeys.detail(recipeId), - queryFn: () => recipesService.getRecipe(recipeId), - enabled: !!recipeId, + queryKey: recipesKeys.detail(tenantId, recipeId), + queryFn: () => recipesService.getRecipe(tenantId, recipeId), + enabled: !!(tenantId && recipeId), staleTime: 5 * 60 * 1000, // 5 minutes ...options, }); @@ -72,13 +62,15 @@ export const useRecipe = ( * Search/list recipes with filters */ export const useRecipes = ( + tenantId: string, filters: RecipeSearchParams = {}, options?: Omit, 'queryKey' | 'queryFn'> ) => { return useQuery({ - queryKey: recipesKeys.list(filters), - queryFn: () => recipesService.searchRecipes(filters), + queryKey: recipesKeys.list(tenantId, filters), + queryFn: () => recipesService.searchRecipes(tenantId, filters), staleTime: 2 * 60 * 1000, // 2 minutes + enabled: !!tenantId, ...options, }); }; @@ -87,18 +79,20 @@ export const useRecipes = ( * Infinite query for recipes (pagination) */ export const useInfiniteRecipes = ( + tenantId: string, filters: Omit = {}, options?: Omit, 'queryKey' | 'queryFn' | 'getNextPageParam'> ) => { return useInfiniteQuery({ - queryKey: recipesKeys.list(filters), + queryKey: recipesKeys.list(tenantId, filters), queryFn: ({ pageParam = 0 }) => - recipesService.searchRecipes({ ...filters, offset: pageParam }), + recipesService.searchRecipes(tenantId, { ...filters, offset: pageParam }), getNextPageParam: (lastPage, allPages) => { const limit = filters.limit || 100; if (lastPage.length < limit) return undefined; return allPages.length * limit; }, + enabled: !!tenantId, staleTime: 2 * 60 * 1000, // 2 minutes ...options, }); @@ -108,12 +102,14 @@ export const useInfiniteRecipes = ( * Get recipe statistics */ export const useRecipeStatistics = ( + tenantId: string, options?: Omit, 'queryKey' | 'queryFn'> ) => { return useQuery({ - queryKey: recipesKeys.statistics(), - queryFn: () => recipesService.getRecipeStatistics(), + queryKey: recipesKeys.statistics(tenantId), + queryFn: () => recipesService.getRecipeStatistics(tenantId), staleTime: 5 * 60 * 1000, // 5 minutes + enabled: !!tenantId, ...options, }); }; @@ -122,12 +118,14 @@ export const useRecipeStatistics = ( * Get recipe categories */ export const useRecipeCategories = ( + tenantId: string, options?: Omit, 'queryKey' | 'queryFn'> ) => { return useQuery({ - queryKey: recipesKeys.categories(), - queryFn: () => recipesService.getRecipeCategories(), + queryKey: recipesKeys.categories(tenantId), + queryFn: () => recipesService.getRecipeCategories(tenantId), staleTime: 10 * 60 * 1000, // 10 minutes + enabled: !!tenantId, ...options, }); }; @@ -136,14 +134,15 @@ export const useRecipeCategories = ( * Check recipe feasibility */ export const useRecipeFeasibility = ( + tenantId: string, recipeId: string, batchMultiplier: number = 1.0, options?: Omit, 'queryKey' | 'queryFn'> ) => { return useQuery({ - queryKey: recipesKeys.feasibility(recipeId, batchMultiplier), - queryFn: () => recipesService.checkRecipeFeasibility(recipeId, batchMultiplier), - enabled: !!recipeId, + queryKey: recipesKeys.feasibility(tenantId, recipeId, batchMultiplier), + queryFn: () => recipesService.checkRecipeFeasibility(tenantId, recipeId, batchMultiplier), + enabled: !!(tenantId && recipeId), staleTime: 1 * 60 * 1000, // 1 minute (fresher data for inventory checks) ...options, }); @@ -155,21 +154,22 @@ export const useRecipeFeasibility = ( * Create a new recipe */ export const useCreateRecipe = ( + tenantId: string, options?: UseMutationOptions ) => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (recipeData: RecipeCreate) => recipesService.createRecipe(recipeData), + mutationFn: (recipeData: RecipeCreate) => recipesService.createRecipe(tenantId, recipeData), onSuccess: (data) => { // Add to lists cache - queryClient.invalidateQueries({ queryKey: recipesKeys.lists() }); + queryClient.invalidateQueries({ queryKey: recipesKeys.lists(tenantId) }); // Set individual recipe cache - queryClient.setQueryData(recipesKeys.detail(data.id), data); + queryClient.setQueryData(recipesKeys.detail(tenantId, data.id), data); // Invalidate statistics - queryClient.invalidateQueries({ queryKey: recipesKeys.statistics() }); + queryClient.invalidateQueries({ queryKey: recipesKeys.statistics(tenantId) }); // Invalidate categories (new category might be added) - queryClient.invalidateQueries({ queryKey: recipesKeys.categories() }); + queryClient.invalidateQueries({ queryKey: recipesKeys.categories(tenantId) }); }, ...options, }); @@ -179,21 +179,22 @@ export const useCreateRecipe = ( * Update an existing recipe */ export const useUpdateRecipe = ( + tenantId: string, options?: UseMutationOptions ) => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ id, data }) => recipesService.updateRecipe(id, data), + mutationFn: ({ id, data }) => recipesService.updateRecipe(tenantId, id, data), onSuccess: (data) => { // Update individual recipe cache - queryClient.setQueryData(recipesKeys.detail(data.id), data); + queryClient.setQueryData(recipesKeys.detail(tenantId, data.id), data); // Invalidate lists (recipe might move in search results) - queryClient.invalidateQueries({ queryKey: recipesKeys.lists() }); + queryClient.invalidateQueries({ queryKey: recipesKeys.lists(tenantId) }); // Invalidate statistics - queryClient.invalidateQueries({ queryKey: recipesKeys.statistics() }); + queryClient.invalidateQueries({ queryKey: recipesKeys.statistics(tenantId) }); // Invalidate categories - queryClient.invalidateQueries({ queryKey: recipesKeys.categories() }); + queryClient.invalidateQueries({ queryKey: recipesKeys.categories(tenantId) }); }, ...options, }); @@ -203,23 +204,22 @@ export const useUpdateRecipe = ( * Delete a recipe */ export const useDeleteRecipe = ( + tenantId: string, options?: UseMutationOptions<{ message: string }, ApiError, string> ) => { const queryClient = useQueryClient(); return useMutation<{ message: string }, ApiError, string>({ - mutationFn: (recipeId: string) => recipesService.deleteRecipe(recipeId), + mutationFn: (recipeId: string) => recipesService.deleteRecipe(tenantId, recipeId), onSuccess: (_, recipeId) => { // Remove from individual cache - queryClient.removeQueries({ queryKey: recipesKeys.detail(recipeId) }); + queryClient.removeQueries({ queryKey: recipesKeys.detail(tenantId, recipeId) }); // Invalidate lists - queryClient.invalidateQueries({ queryKey: recipesKeys.lists() }); + queryClient.invalidateQueries({ queryKey: recipesKeys.lists(tenantId) }); // Invalidate statistics - queryClient.invalidateQueries({ queryKey: recipesKeys.statistics() }); + queryClient.invalidateQueries({ queryKey: recipesKeys.statistics(tenantId) }); // Invalidate categories - queryClient.invalidateQueries({ queryKey: recipesKeys.categories() }); - // Invalidate production batches for this recipe - queryClient.invalidateQueries({ queryKey: recipesKeys.productionBatches.byRecipe(recipeId) }); + queryClient.invalidateQueries({ queryKey: recipesKeys.categories(tenantId) }); }, ...options, }); @@ -229,19 +229,20 @@ export const useDeleteRecipe = ( * Duplicate a recipe */ export const useDuplicateRecipe = ( + tenantId: string, options?: UseMutationOptions ) => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ id, data }) => recipesService.duplicateRecipe(id, data), + mutationFn: ({ id, data }) => recipesService.duplicateRecipe(tenantId, id, data), onSuccess: (data) => { // Add to lists cache - queryClient.invalidateQueries({ queryKey: recipesKeys.lists() }); + queryClient.invalidateQueries({ queryKey: recipesKeys.lists(tenantId) }); // Set individual recipe cache - queryClient.setQueryData(recipesKeys.detail(data.id), data); + queryClient.setQueryData(recipesKeys.detail(tenantId, data.id), data); // Invalidate statistics - queryClient.invalidateQueries({ queryKey: recipesKeys.statistics() }); + queryClient.invalidateQueries({ queryKey: recipesKeys.statistics(tenantId) }); }, ...options, }); @@ -251,227 +252,20 @@ export const useDuplicateRecipe = ( * Activate a recipe */ export const useActivateRecipe = ( + tenantId: string, options?: UseMutationOptions ) => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (recipeId: string) => recipesService.activateRecipe(recipeId), + mutationFn: (recipeId: string) => recipesService.activateRecipe(tenantId, recipeId), onSuccess: (data) => { // Update individual recipe cache - queryClient.setQueryData(recipesKeys.detail(data.id), data); + queryClient.setQueryData(recipesKeys.detail(tenantId, data.id), data); // Invalidate lists - queryClient.invalidateQueries({ queryKey: recipesKeys.lists() }); + queryClient.invalidateQueries({ queryKey: recipesKeys.lists(tenantId) }); // Invalidate statistics - queryClient.invalidateQueries({ queryKey: recipesKeys.statistics() }); - }, - ...options, - }); -}; - -// Production Batch Queries - -/** - * Get production batch by ID (recipe-specific) - */ -export const useRecipeProductionBatch = ( - batchId: string, - options?: Omit, 'queryKey' | 'queryFn'> -) => { - return useQuery({ - queryKey: recipesKeys.productionBatches.detail(batchId), - queryFn: () => recipesService.getProductionBatch(batchId), - enabled: !!batchId, - staleTime: 2 * 60 * 1000, // 2 minutes - ...options, - }); -}; - -/** - * Get production batches with filters (recipe-specific) - */ -export const useRecipeProductionBatches = ( - filters: { - recipe_id?: string; - status?: string; - start_date?: string; - end_date?: string; - limit?: number; - offset?: number; - } = {}, - options?: Omit, 'queryKey' | 'queryFn'> -) => { - return useQuery({ - queryKey: recipesKeys.productionBatches.list(filters), - queryFn: () => recipesService.getProductionBatches(filters), - staleTime: 1 * 60 * 1000, // 1 minute - ...options, - }); -}; - -/** - * Get production batches for a specific recipe - */ -export const useRecipeProductionBatchesByRecipe = ( - recipeId: string, - options?: Omit, 'queryKey' | 'queryFn'> -) => { - return useQuery({ - queryKey: recipesKeys.productionBatches.byRecipe(recipeId), - queryFn: () => recipesService.getRecipeProductionBatches(recipeId), - enabled: !!recipeId, - staleTime: 1 * 60 * 1000, // 1 minute - ...options, - }); -}; - -// Production Batch Mutations - -/** - * Create a production batch for recipe - */ -export const useCreateRecipeProductionBatch = ( - options?: UseMutationOptions -) => { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: (batchData: ProductionBatchCreate) => recipesService.createProductionBatch(batchData), - onSuccess: (data) => { - // Set individual batch cache - queryClient.setQueryData(recipesKeys.productionBatches.detail(data.id), data); - // Invalidate batch lists - queryClient.invalidateQueries({ queryKey: recipesKeys.productionBatches.lists() }); - // Invalidate recipe-specific batches - queryClient.invalidateQueries({ queryKey: recipesKeys.productionBatches.byRecipe(data.recipe_id) }); - }, - ...options, - }); -}; - -/** - * Update a production batch for recipe - */ -export const useUpdateRecipeProductionBatch = ( - options?: UseMutationOptions -) => { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: ({ id, data }) => recipesService.updateProductionBatch(id, data), - onSuccess: (data) => { - // Update individual batch cache - queryClient.setQueryData(recipesKeys.productionBatches.detail(data.id), data); - // Invalidate batch lists - queryClient.invalidateQueries({ queryKey: recipesKeys.productionBatches.lists() }); - // Invalidate recipe-specific batches - queryClient.invalidateQueries({ queryKey: recipesKeys.productionBatches.byRecipe(data.recipe_id) }); - }, - ...options, - }); -}; - -/** - * Delete a production batch for recipe - */ -export const useDeleteRecipeProductionBatch = ( - options?: UseMutationOptions<{ message: string }, ApiError, string> -) => { - const queryClient = useQueryClient(); - - return useMutation<{ message: string }, ApiError, string>({ - mutationFn: (batchId: string) => recipesService.deleteProductionBatch(batchId), - onSuccess: (_, batchId) => { - // Remove from individual cache - queryClient.removeQueries({ queryKey: recipesKeys.productionBatches.detail(batchId) }); - // Invalidate batch lists - queryClient.invalidateQueries({ queryKey: recipesKeys.productionBatches.lists() }); - }, - ...options, - }); -}; - -/** - * Start a production batch for recipe - */ -export const useStartRecipeProductionBatch = ( - options?: UseMutationOptions -) => { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: (batchId: string) => recipesService.startProductionBatch(batchId), - onSuccess: (data) => { - // Update individual batch cache - queryClient.setQueryData(recipesKeys.productionBatches.detail(data.id), data); - // Invalidate batch lists - queryClient.invalidateQueries({ queryKey: recipesKeys.productionBatches.lists() }); - // Invalidate recipe-specific batches - queryClient.invalidateQueries({ queryKey: recipesKeys.productionBatches.byRecipe(data.recipe_id) }); - }, - ...options, - }); -}; - -/** - * Complete a production batch for recipe - */ -export const useCompleteRecipeProductionBatch = ( - options?: UseMutationOptions -) => { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: ({ id, data }) => recipesService.completeProductionBatch(id, data), - onSuccess: (data) => { - // Update individual batch cache - queryClient.setQueryData(recipesKeys.productionBatches.detail(data.id), data); - // Invalidate batch lists - queryClient.invalidateQueries({ queryKey: recipesKeys.productionBatches.lists() }); - // Invalidate recipe-specific batches - queryClient.invalidateQueries({ queryKey: recipesKeys.productionBatches.byRecipe(data.recipe_id) }); - // Invalidate inventory queries (production affects inventory) - queryClient.invalidateQueries({ queryKey: ['inventory'] }); - }, - ...options, - }); -}; - -/** - * Cancel a production batch for recipe - */ -export const useCancelRecipeProductionBatch = ( - options?: UseMutationOptions -) => { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: ({ id, reason }) => recipesService.cancelProductionBatch(id, reason), - onSuccess: (data) => { - // Update individual batch cache - queryClient.setQueryData(recipesKeys.productionBatches.detail(data.id), data); - // Invalidate batch lists - queryClient.invalidateQueries({ queryKey: recipesKeys.productionBatches.lists() }); - // Invalidate recipe-specific batches - queryClient.invalidateQueries({ queryKey: recipesKeys.productionBatches.byRecipe(data.recipe_id) }); + queryClient.invalidateQueries({ queryKey: recipesKeys.statistics(tenantId) }); }, ...options, }); diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 35b8819a..fd16d746 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -728,35 +728,7 @@ export { useDeleteRecipe, useDuplicateRecipe, useActivateRecipe, - useRecipeProductionBatch, - useRecipeProductionBatches, - useRecipeProductionBatchesByRecipe, - useCreateRecipeProductionBatch, - useUpdateRecipeProductionBatch, - useDeleteRecipeProductionBatch, - useStartRecipeProductionBatch, - useCompleteRecipeProductionBatch, - useCancelRecipeProductionBatch, recipesKeys, } from './hooks/recipes'; -// Query Key Factories (for advanced usage) -export { - authKeys, - userKeys, - onboardingKeys, - tenantKeys, - salesKeys, - inventoryKeys, - classificationKeys, - inventoryDashboardKeys, - foodSafetyKeys, - trainingKeys, - alertProcessorKeys, - suppliersKeys, - ordersKeys, - dataImportKeys, - forecastingKeys, - productionKeys, - posKeys, -}; \ No newline at end of file +// Note: All query key factories are already exported in their respective hook sections above \ No newline at end of file diff --git a/frontend/src/api/services/recipes.ts b/frontend/src/api/services/recipes.ts index ceaf4913..ec414ea3 100644 --- a/frontend/src/api/services/recipes.ts +++ b/frontend/src/api/services/recipes.ts @@ -1,6 +1,7 @@ /** * Recipes service - API communication layer * Handles all recipe-related HTTP requests using the API client + * Mirrors backend endpoints exactly for tenant-dependent operations */ import { apiClient } from '../client/apiClient'; @@ -13,52 +14,66 @@ import type { RecipeFeasibilityResponse, RecipeStatisticsResponse, RecipeCategoriesResponse, - ProductionBatchResponse, - ProductionBatchCreate, - ProductionBatchUpdate, } from '../types/recipes'; /** * Recipes API service * All methods return promises that resolve to the response data + * Follows tenant-dependent routing pattern: /tenants/{tenant_id}/recipes */ export class RecipesService { - private readonly baseUrl = '/recipes'; + /** + * Get tenant-scoped base URL for recipes + */ + private getBaseUrl(tenantId: string): string { + return `/tenants/${tenantId}/recipes`; + } /** * Create a new recipe + * POST /tenants/{tenant_id}/recipes */ - async createRecipe(recipeData: RecipeCreate): Promise { - return apiClient.post(this.baseUrl, recipeData); + async createRecipe(tenantId: string, recipeData: RecipeCreate): Promise { + const baseUrl = this.getBaseUrl(tenantId); + return apiClient.post(baseUrl, recipeData); } /** * Get recipe by ID with ingredients + * GET /tenants/{tenant_id}/recipes/{recipe_id} */ - async getRecipe(recipeId: string): Promise { - return apiClient.get(`${this.baseUrl}/${recipeId}`); + async getRecipe(tenantId: string, recipeId: string): Promise { + const baseUrl = this.getBaseUrl(tenantId); + return apiClient.get(`${baseUrl}/${recipeId}`); } /** * Update an existing recipe + * PUT /tenants/{tenant_id}/recipes/{recipe_id} */ - async updateRecipe(recipeId: string, recipeData: RecipeUpdate): Promise { - return apiClient.put(`${this.baseUrl}/${recipeId}`, recipeData); + async updateRecipe(tenantId: string, recipeId: string, recipeData: RecipeUpdate): Promise { + const baseUrl = this.getBaseUrl(tenantId); + return apiClient.put(`${baseUrl}/${recipeId}`, recipeData); } /** * Delete a recipe + * DELETE /tenants/{tenant_id}/recipes/{recipe_id} */ - async deleteRecipe(recipeId: string): Promise<{ message: string }> { - return apiClient.delete<{ message: string }>(`${this.baseUrl}/${recipeId}`); + async deleteRecipe(tenantId: string, recipeId: string): Promise<{ message: string }> { + const baseUrl = this.getBaseUrl(tenantId); + return apiClient.delete<{ message: string }>(`${baseUrl}/${recipeId}`); } /** * Search recipes with filters + * GET /tenants/{tenant_id}/recipes */ - async searchRecipes(params: RecipeSearchParams = {}): Promise { + async searchRecipes(tenantId: string, params: RecipeSearchParams = {}): Promise { + const baseUrl = this.getBaseUrl(tenantId); const searchParams = new URLSearchParams(); + // Add all non-empty parameters to the query string Object.entries(params).forEach(([key, value]) => { if (value !== undefined && value !== null && value !== '') { searchParams.append(key, String(value)); @@ -66,144 +81,63 @@ export class RecipesService { }); const queryString = searchParams.toString(); - const url = queryString ? `${this.baseUrl}?${queryString}` : this.baseUrl; + const url = queryString ? `${baseUrl}?${queryString}` : baseUrl; return apiClient.get(url); } /** * Get all recipes (shorthand for search without filters) + * GET /tenants/{tenant_id}/recipes */ - async getRecipes(): Promise { - return this.searchRecipes(); + async getRecipes(tenantId: string): Promise { + return this.searchRecipes(tenantId); } /** * Duplicate an existing recipe + * POST /tenants/{tenant_id}/recipes/{recipe_id}/duplicate */ - async duplicateRecipe(recipeId: string, duplicateData: RecipeDuplicateRequest): Promise { - return apiClient.post(`${this.baseUrl}/${recipeId}/duplicate`, duplicateData); + async duplicateRecipe(tenantId: string, recipeId: string, duplicateData: RecipeDuplicateRequest): Promise { + const baseUrl = this.getBaseUrl(tenantId); + return apiClient.post(`${baseUrl}/${recipeId}/duplicate`, duplicateData); } /** * Activate a recipe for production + * POST /tenants/{tenant_id}/recipes/{recipe_id}/activate */ - async activateRecipe(recipeId: string): Promise { - return apiClient.post(`${this.baseUrl}/${recipeId}/activate`); + async activateRecipe(tenantId: string, recipeId: string): Promise { + const baseUrl = this.getBaseUrl(tenantId); + return apiClient.post(`${baseUrl}/${recipeId}/activate`); } /** * Check if recipe can be produced with current inventory + * GET /tenants/{tenant_id}/recipes/{recipe_id}/feasibility */ - async checkRecipeFeasibility(recipeId: string, batchMultiplier: number = 1.0): Promise { + async checkRecipeFeasibility(tenantId: string, recipeId: string, batchMultiplier: number = 1.0): Promise { + const baseUrl = this.getBaseUrl(tenantId); const params = new URLSearchParams({ batch_multiplier: String(batchMultiplier) }); - return apiClient.get(`${this.baseUrl}/${recipeId}/feasibility?${params}`); + return apiClient.get(`${baseUrl}/${recipeId}/feasibility?${params}`); } /** * Get recipe statistics for dashboard + * GET /tenants/{tenant_id}/recipes/statistics/dashboard */ - async getRecipeStatistics(): Promise { - return apiClient.get(`${this.baseUrl}/statistics/dashboard`); + async getRecipeStatistics(tenantId: string): Promise { + const baseUrl = this.getBaseUrl(tenantId); + return apiClient.get(`${baseUrl}/statistics/dashboard`); } /** * Get list of recipe categories used by tenant + * GET /tenants/{tenant_id}/recipes/categories/list */ - async getRecipeCategories(): Promise { - return apiClient.get(`${this.baseUrl}/categories/list`); - } - - // Production Batch Methods - - /** - * Create a production batch for a recipe - */ - async createProductionBatch(batchData: ProductionBatchCreate): Promise { - return apiClient.post('/production/batches', batchData); - } - - /** - * Get production batch by ID - */ - async getProductionBatch(batchId: string): Promise { - return apiClient.get(`/production/batches/${batchId}`); - } - - /** - * Update production batch - */ - async updateProductionBatch(batchId: string, batchData: ProductionBatchUpdate): Promise { - return apiClient.put(`/production/batches/${batchId}`, batchData); - } - - /** - * Delete production batch - */ - async deleteProductionBatch(batchId: string): Promise<{ message: string }> { - return apiClient.delete<{ message: string }>(`/production/batches/${batchId}`); - } - - /** - * Get production batches for a recipe - */ - async getRecipeProductionBatches(recipeId: string): Promise { - return apiClient.get(`/production/batches?recipe_id=${recipeId}`); - } - - /** - * Get all production batches with optional filtering - */ - async getProductionBatches(params: { - recipe_id?: string; - status?: string; - start_date?: string; - end_date?: string; - limit?: number; - offset?: number; - } = {}): Promise { - const searchParams = new URLSearchParams(); - - Object.entries(params).forEach(([key, value]) => { - if (value !== undefined && value !== null && value !== '') { - searchParams.append(key, String(value)); - } - }); - - const queryString = searchParams.toString(); - const url = queryString ? `/production/batches?${queryString}` : '/production/batches'; - - return apiClient.get(url); - } - - /** - * Start production batch - */ - async startProductionBatch(batchId: string): Promise { - return apiClient.post(`/production/batches/${batchId}/start`); - } - - /** - * Complete production batch - */ - async completeProductionBatch( - batchId: string, - completionData: { - actual_quantity?: number; - quality_score?: number; - quality_notes?: string; - waste_quantity?: number; - waste_reason?: string; - } - ): Promise { - return apiClient.post(`/production/batches/${batchId}/complete`, completionData); - } - - /** - * Cancel production batch - */ - async cancelProductionBatch(batchId: string, reason?: string): Promise { - return apiClient.post(`/production/batches/${batchId}/cancel`, { reason }); + async getRecipeCategories(tenantId: string): Promise { + const baseUrl = this.getBaseUrl(tenantId); + return apiClient.get(`${baseUrl}/categories/list`); } } diff --git a/frontend/src/components/domain/production/BatchTracker.tsx b/frontend/src/components/domain/production/BatchTracker.tsx index 60f7556b..bef8be26 100644 --- a/frontend/src/components/domain/production/BatchTracker.tsx +++ b/frontend/src/components/domain/production/BatchTracker.tsx @@ -1,6 +1,6 @@ import React, { useState, useCallback, useEffect } from 'react'; import { Card, Button, Badge, Input, Modal, Table, Select, DatePicker } from '../../ui'; -import { productionService, type ProductionBatchResponse, ProductionBatchStatus, ProductionPriority } from '../../../api/services/production.service'; +import { productionService, type ProductionBatchResponse, ProductionBatchStatus, ProductionPriority } from '../../../api'; import type { ProductionBatch, QualityCheck } from '../../../types/production.types'; interface BatchTrackerProps { diff --git a/frontend/src/components/domain/production/ProductionSchedule.tsx b/frontend/src/components/domain/production/ProductionSchedule.tsx index e768acd5..ca0e17a1 100644 --- a/frontend/src/components/domain/production/ProductionSchedule.tsx +++ b/frontend/src/components/domain/production/ProductionSchedule.tsx @@ -1,6 +1,6 @@ import React, { useState, useCallback, useMemo } from 'react'; import { Card, Button, Badge, Select, DatePicker, Input, Modal, Table } from '../../ui'; -import { productionService, type ProductionScheduleEntry, ProductionBatchStatus } from '../../../api/services/production.service'; +import { productionService, type ProductionScheduleEntry, ProductionBatchStatus } from '../../../api'; import type { ProductionSchedule as ProductionScheduleType, ProductionBatch, EquipmentReservation, StaffAssignment } from '../../../types/production.types'; interface ProductionScheduleProps { diff --git a/frontend/src/components/domain/production/QualityControl.tsx b/frontend/src/components/domain/production/QualityControl.tsx index 1cdbf386..ac908ae4 100644 --- a/frontend/src/components/domain/production/QualityControl.tsx +++ b/frontend/src/components/domain/production/QualityControl.tsx @@ -1,6 +1,6 @@ import React, { useState, useCallback, useRef } from 'react'; import { Card, Button, Badge, Input, Modal, Table, Select, DatePicker } from '../../ui'; -import { productionService, type QualityCheckResponse, QualityCheckStatus } from '../../../api/services/production.service'; +import { productionService, type QualityCheckResponse, QualityCheckStatus } from '../../../api'; import type { QualityCheck, QualityCheckCriteria, ProductionBatch, QualityCheckType } from '../../../types/production.types'; interface QualityControlProps { diff --git a/frontend/src/components/domain/recipes/CreateRecipeModal.tsx b/frontend/src/components/domain/recipes/CreateRecipeModal.tsx new file mode 100644 index 00000000..06858ac3 --- /dev/null +++ b/frontend/src/components/domain/recipes/CreateRecipeModal.tsx @@ -0,0 +1,622 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { ChefHat, Package, Clock, DollarSign, Star } from 'lucide-react'; +import { StatusModal } from '../../ui/StatusModal/StatusModal'; +import { RecipeCreate, RecipeIngredientCreate, MeasurementUnit } from '../../../api/types/recipes'; +import { useIngredients } from '../../../api/hooks/inventory'; +import { useCurrentTenant } from '../../../stores/tenant.store'; + +interface CreateRecipeModalProps { + isOpen: boolean; + onClose: () => void; + onCreateRecipe?: (recipeData: RecipeCreate) => Promise; +} + +/** + * CreateRecipeModal - Modal for creating a new recipe + * Comprehensive form for adding new recipes + */ +export const CreateRecipeModal: React.FC = ({ + isOpen, + onClose, + onCreateRecipe +}) => { + const [formData, setFormData] = useState({ + name: '', + recipe_code: '', + finished_product_id: '', // This should come from a product selector + description: '', + category: '', + cuisine_type: '', + difficulty_level: 1, + yield_quantity: 1, + yield_unit: MeasurementUnit.UNITS, + prep_time_minutes: 0, + cook_time_minutes: 0, + total_time_minutes: 0, + rest_time_minutes: 0, + estimated_cost_per_unit: 0, + target_margin_percentage: 30, + suggested_selling_price: 0, + preparation_notes: '', + storage_instructions: '', + quality_standards: '', + serves_count: 1, + is_seasonal: false, + season_start_month: undefined, + season_end_month: undefined, + is_signature_item: false, + batch_size_multiplier: 1.0, + minimum_batch_size: undefined, + maximum_batch_size: undefined, + optimal_production_temperature: undefined, + optimal_humidity: undefined, + allergen_info: '', + dietary_tags: '', + nutritional_info: '', + ingredients: [] + }); + + const [loading, setLoading] = useState(false); + const [mode, setMode] = useState<'overview' | 'edit'>('edit'); + + // Get tenant and fetch inventory data + const currentTenant = useCurrentTenant(); + const tenantId = currentTenant?.id || ''; + + // Fetch inventory items to populate product and ingredient selectors + const { + data: inventoryItems = [], + isLoading: inventoryLoading + } = useIngredients(tenantId, {}); + + // Separate finished products and ingredients + const finishedProducts = useMemo(() => + inventoryItems.filter(item => item.product_type === 'finished_product') + .map(product => ({ + value: product.id, + label: `${product.name} (${product.category || 'Sin categoría'})` + })), + [inventoryItems] + ); + + const availableIngredients = useMemo(() => + inventoryItems.filter(item => item.product_type === 'ingredient') + .map(ingredient => ({ + value: ingredient.id, + label: `${ingredient.name} (${ingredient.unit_of_measure})`, + unit: ingredient.unit_of_measure + })), + [inventoryItems] + ); + + // Category options + const categoryOptions = [ + { value: 'bread', label: 'Pan' }, + { value: 'pastry', label: 'Bollería' }, + { value: 'cake', label: 'Tarta' }, + { value: 'cookie', label: 'Galleta' }, + { value: 'muffin', label: 'Muffin' }, + { value: 'savory', label: 'Salado' }, + { value: 'desserts', label: 'Postres' }, + { value: 'specialty', label: 'Especialidad' }, + { value: 'other', label: 'Otro' } + ]; + + // Cuisine type options + const cuisineTypeOptions = [ + { value: 'french', label: 'Francés' }, + { value: 'spanish', label: 'Español' }, + { value: 'italian', label: 'Italiano' }, + { value: 'german', label: 'Alemán' }, + { value: 'american', label: 'Americano' }, + { value: 'artisanal', label: 'Artesanal' }, + { value: 'traditional', label: 'Tradicional' }, + { value: 'modern', label: 'Moderno' } + ]; + + // Unit options + const unitOptions = [ + { value: MeasurementUnit.UNITS, label: 'Unidades' }, + { value: MeasurementUnit.PIECES, label: 'Piezas' }, + { value: MeasurementUnit.GRAMS, label: 'Gramos' }, + { value: MeasurementUnit.KILOGRAMS, label: 'Kilogramos' }, + { value: MeasurementUnit.MILLILITERS, label: 'Mililitros' }, + { value: MeasurementUnit.LITERS, label: 'Litros' } + ]; + + // Month options for seasonal recipes + const monthOptions = [ + { value: 1, label: 'Enero' }, + { value: 2, label: 'Febrero' }, + { value: 3, label: 'Marzo' }, + { value: 4, label: 'Abril' }, + { value: 5, label: 'Mayo' }, + { value: 6, label: 'Junio' }, + { value: 7, label: 'Julio' }, + { value: 8, label: 'Agosto' }, + { value: 9, label: 'Septiembre' }, + { value: 10, label: 'Octubre' }, + { value: 11, label: 'Noviembre' }, + { value: 12, label: 'Diciembre' } + ]; + + // Allergen options + const allergenOptions = [ + 'Gluten', 'Lácteos', 'Huevos', 'Frutos secos', 'Soja', 'Sésamo', 'Pescado', 'Mariscos' + ]; + + // Dietary tags + const dietaryTagOptions = [ + 'Vegano', 'Vegetariano', 'Sin gluten', 'Sin lácteos', 'Sin frutos secos', 'Sin azúcar', 'Bajo en carbohidratos', 'Keto', 'Orgánico' + ]; + + const handleFieldChange = (sectionIndex: number, fieldIndex: number, value: string | number | boolean) => { + const sections = getModalSections(); + const field = sections[sectionIndex]?.fields[fieldIndex]; + + if (!field) return; + + setFormData(prev => ({ + ...prev, + [field.key]: value + })); + + // Auto-calculate total time when prep or cook time changes + if (field.key === 'prep_time_minutes' || field.key === 'cook_time_minutes') { + const prepTime = field.key === 'prep_time_minutes' ? Number(value) : formData.prep_time_minutes || 0; + const cookTime = field.key === 'cook_time_minutes' ? Number(value) : formData.cook_time_minutes || 0; + setFormData(prev => ({ + ...prev, + total_time_minutes: prepTime + cookTime + })); + } + }; + + const handleSubmit = async () => { + if (!formData.name.trim()) { + alert('El nombre de la receta es obligatorio'); + return; + } + + if (!formData.category.trim()) { + alert('Debe seleccionar una categoría'); + return; + } + + if (!formData.finished_product_id.trim()) { + alert('Debe seleccionar un producto terminado'); + return; + } + + // Validate seasonal dates if seasonal is enabled + if (formData.is_seasonal) { + if (!formData.season_start_month || !formData.season_end_month) { + alert('Para recetas estacionales, debe especificar los meses de inicio y fin'); + return; + } + } + + // Validate batch sizes + if (formData.minimum_batch_size && formData.maximum_batch_size) { + if (formData.minimum_batch_size > formData.maximum_batch_size) { + alert('El tamaño mínimo de lote no puede ser mayor que el máximo'); + return; + } + } + + try { + setLoading(true); + + // Generate recipe code if not provided + const recipeCode = formData.recipe_code || + formData.name.substring(0, 3).toUpperCase() + + String(Date.now()).slice(-3); + + // Calculate total time including rest time + const totalTime = (formData.prep_time_minutes || 0) + + (formData.cook_time_minutes || 0) + + (formData.rest_time_minutes || 0); + + const recipeData: RecipeCreate = { + ...formData, + recipe_code: recipeCode, + total_time_minutes: totalTime, + // Clean up undefined values for optional fields + season_start_month: formData.is_seasonal ? formData.season_start_month : undefined, + season_end_month: formData.is_seasonal ? formData.season_end_month : undefined, + minimum_batch_size: formData.minimum_batch_size || undefined, + maximum_batch_size: formData.maximum_batch_size || undefined, + optimal_production_temperature: formData.optimal_production_temperature || undefined, + optimal_humidity: formData.optimal_humidity || undefined + }; + + if (onCreateRecipe) { + await onCreateRecipe(recipeData); + } + + onClose(); + + // Reset form + setFormData({ + name: '', + recipe_code: '', + finished_product_id: '', + description: '', + category: '', + cuisine_type: '', + difficulty_level: 1, + yield_quantity: 1, + yield_unit: MeasurementUnit.UNITS, + prep_time_minutes: 0, + cook_time_minutes: 0, + total_time_minutes: 0, + rest_time_minutes: 0, + estimated_cost_per_unit: 0, + target_margin_percentage: 30, + suggested_selling_price: 0, + preparation_notes: '', + storage_instructions: '', + quality_standards: '', + serves_count: 1, + is_seasonal: false, + season_start_month: undefined, + season_end_month: undefined, + is_signature_item: false, + batch_size_multiplier: 1.0, + minimum_batch_size: undefined, + maximum_batch_size: undefined, + optimal_production_temperature: undefined, + optimal_humidity: undefined, + allergen_info: '', + dietary_tags: '', + nutritional_info: '', + ingredients: [] + }); + } catch (error) { + console.error('Error creating recipe:', error); + alert('Error al crear la receta. Por favor, inténtelo de nuevo.'); + } finally { + setLoading(false); + } + }; + + const getModalSections = () => [ + { + title: 'Información Básica', + icon: ChefHat, + fields: [ + { + key: 'name', + label: 'Nombre de la receta', + value: formData.name, + type: 'text', + required: true, + placeholder: 'Ej: Pan de molde integral' + }, + { + key: 'recipe_code', + label: 'Código de receta', + value: formData.recipe_code, + type: 'text', + placeholder: 'Ej: PAN001 (opcional, se genera automáticamente)' + }, + { + key: 'description', + label: 'Descripción', + value: formData.description, + type: 'textarea', + placeholder: 'Descripción de la receta...' + }, + { + key: 'category', + label: 'Categoría', + value: formData.category, + type: 'select', + options: categoryOptions, + required: true + }, + { + key: 'cuisine_type', + label: 'Tipo de cocina', + value: formData.cuisine_type, + type: 'select', + options: cuisineTypeOptions, + placeholder: 'Selecciona el tipo de cocina' + }, + { + key: 'difficulty_level', + label: 'Nivel de dificultad', + value: formData.difficulty_level, + type: 'select', + options: [ + { 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' } + ], + required: true + }, + { + key: 'serves_count', + label: 'Número de porciones', + value: formData.serves_count, + type: 'number', + min: 1, + placeholder: 'Cuántas personas sirve' + } + ] + }, + { + title: 'Rendimiento y Tiempos', + icon: Clock, + fields: [ + { + key: 'yield_quantity', + label: 'Cantidad que produce', + value: formData.yield_quantity, + type: 'number', + min: 1, + required: true + }, + { + key: 'yield_unit', + label: 'Unidad de medida', + value: formData.yield_unit, + type: 'select', + options: unitOptions, + required: true + }, + { + key: 'prep_time_minutes', + label: 'Tiempo de preparación (minutos)', + value: formData.prep_time_minutes, + type: 'number', + min: 0 + }, + { + key: 'cook_time_minutes', + label: 'Tiempo de cocción (minutos)', + value: formData.cook_time_minutes, + type: 'number', + min: 0 + }, + { + key: 'rest_time_minutes', + label: 'Tiempo de reposo (minutos)', + value: formData.rest_time_minutes, + type: 'number', + min: 0, + placeholder: 'Tiempo de fermentación o reposo' + }, + { + key: 'total_time_minutes', + label: 'Tiempo total (calculado automáticamente)', + value: formData.total_time_minutes, + type: 'number', + disabled: true, + readonly: true + } + ] + }, + { + title: 'Configuración Financiera', + icon: DollarSign, + fields: [ + { + key: 'estimated_cost_per_unit', + label: 'Costo estimado por unidad (€)', + value: formData.estimated_cost_per_unit, + type: 'number', + min: 0, + step: 0.01, + placeholder: '0.00' + }, + { + key: 'suggested_selling_price', + label: 'Precio de venta sugerido (€)', + value: formData.suggested_selling_price, + type: 'number', + min: 0, + step: 0.01, + placeholder: '0.00' + }, + { + key: 'target_margin_percentage', + label: 'Margen objetivo (%)', + value: formData.target_margin_percentage, + type: 'number', + min: 0, + max: 100, + placeholder: '30' + } + ] + }, + { + title: 'Configuración de Producción', + icon: Package, + fields: [ + { + key: 'batch_size_multiplier', + label: 'Multiplicador de lote', + value: formData.batch_size_multiplier, + type: 'number', + min: 0.1, + step: 0.1, + placeholder: '1.0' + }, + { + key: 'minimum_batch_size', + label: 'Tamaño mínimo de lote', + value: formData.minimum_batch_size, + type: 'number', + min: 1, + placeholder: 'Cantidad mínima a producir' + }, + { + key: 'maximum_batch_size', + label: 'Tamaño máximo de lote', + value: formData.maximum_batch_size, + type: 'number', + min: 1, + placeholder: 'Cantidad máxima a producir' + }, + { + key: 'optimal_production_temperature', + label: 'Temperatura óptima (°C)', + value: formData.optimal_production_temperature, + type: 'number', + placeholder: 'Temperatura ideal de producción' + }, + { + key: 'optimal_humidity', + label: 'Humedad óptima (%)', + value: formData.optimal_humidity, + type: 'number', + min: 0, + max: 100, + placeholder: 'Humedad ideal' + } + ] + }, + { + title: 'Temporalidad y Especiales', + icon: Star, + fields: [ + { + key: 'is_signature_item', + label: 'Receta especial/estrella', + value: formData.is_signature_item, + type: 'checkbox' + }, + { + key: 'is_seasonal', + label: 'Receta estacional', + value: formData.is_seasonal, + type: 'checkbox' + }, + { + key: 'season_start_month', + label: 'Mes de inicio de temporada', + value: formData.season_start_month, + type: 'select', + options: monthOptions, + placeholder: 'Selecciona mes de inicio', + disabled: !formData.is_seasonal + }, + { + key: 'season_end_month', + label: 'Mes de fin de temporada', + value: formData.season_end_month, + type: 'select', + options: monthOptions, + placeholder: 'Selecciona mes de fin', + disabled: !formData.is_seasonal + } + ] + }, + { + title: 'Información Nutricional y Alérgenos', + icon: Package, + fields: [ + { + key: 'allergen_info', + label: 'Información de alérgenos', + value: formData.allergen_info, + type: 'text', + placeholder: 'Ej: Gluten, Lácteos, Huevos' + }, + { + key: 'dietary_tags', + label: 'Etiquetas dietéticas', + value: formData.dietary_tags, + type: 'text', + placeholder: 'Ej: Vegano, Sin gluten, Orgánico' + }, + { + key: 'nutritional_info', + label: 'Información nutricional', + value: formData.nutritional_info, + type: 'textarea', + placeholder: 'Calorías, proteínas, carbohidratos, etc.' + } + ] + }, + { + title: 'Notas de Preparación', + icon: ChefHat, + fields: [ + { + key: 'preparation_notes', + label: 'Notas de preparación', + value: formData.preparation_notes, + type: 'textarea', + placeholder: 'Instrucciones especiales, consejos, técnicas...' + }, + { + key: 'storage_instructions', + label: 'Instrucciones de almacenamiento', + value: formData.storage_instructions, + type: 'textarea', + placeholder: 'Cómo almacenar el producto terminado...' + }, + { + key: 'quality_standards', + label: 'Estándares de calidad', + value: formData.quality_standards, + type: 'textarea', + placeholder: 'Criterios de calidad, características del producto...' + } + ] + }, + { + title: 'Producto Terminado', + icon: Package, + fields: [ + { + key: 'finished_product_id', + label: 'Producto terminado', + value: formData.finished_product_id, + type: 'select', + options: finishedProducts, + required: true, + placeholder: inventoryLoading ? 'Cargando productos...' : 'Selecciona un producto terminado', + help: 'Selecciona el producto del inventario que produce esta receta', + disabled: inventoryLoading + } + ] + } + ]; + + return ( + + ); +}; + +export default CreateRecipeModal; \ No newline at end of file diff --git a/frontend/src/components/domain/recipes/index.ts b/frontend/src/components/domain/recipes/index.ts new file mode 100644 index 00000000..133bf279 --- /dev/null +++ b/frontend/src/components/domain/recipes/index.ts @@ -0,0 +1 @@ +export { CreateRecipeModal } from './CreateRecipeModal'; \ No newline at end of file diff --git a/frontend/src/examples/RecipesExample.tsx b/frontend/src/examples/RecipesExample.tsx new file mode 100644 index 00000000..6789aa6e --- /dev/null +++ b/frontend/src/examples/RecipesExample.tsx @@ -0,0 +1,542 @@ +/** + * Example usage of the restructured recipes API + * Demonstrates tenant-dependent routing and React Query hooks + */ + +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + useRecipes, + useRecipe, + useCreateRecipe, + useUpdateRecipe, + useDeleteRecipe, + useDuplicateRecipe, + useActivateRecipe, + useRecipeStatistics, + useRecipeCategories, + useRecipeFeasibility, + type RecipeResponse, + type RecipeCreate, + type RecipeSearchParams, + MeasurementUnit, +} from '../api'; +import { useCurrentTenant } from '../stores/tenant.store'; + +/** + * Example: Recipe List Component + * Shows how to use the tenant-dependent useRecipes hook + */ +export const RecipesList: React.FC = () => { + const { t } = useTranslation('recipes'); + const currentTenant = useCurrentTenant(); + const [filters, setFilters] = useState({ + limit: 20, + offset: 0, + }); + + // Use tenant-dependent recipes hook + const { + data: recipes, + isLoading, + error, + refetch, + } = useRecipes(currentTenant?.id || '', filters, { + enabled: !!currentTenant?.id, + }); + + if (!currentTenant) { + return
{t('messages.no_tenant_selected')}
; + } + + if (isLoading) { + return
{t('messages.loading_recipes')}
; + } + + if (error) { + return
Error: {error.message}
; + } + + return ( +
+

{t('title')}

+ + {/* Search and filters */} +
+ setFilters({ ...filters, search_term: e.target.value })} + /> + +
+ + {/* Recipe cards */} +
+ {recipes?.map((recipe) => ( + + ))} +
+ + {(!recipes || recipes.length === 0) && ( +
{t('messages.no_recipes_found')}
+ )} +
+ ); +}; + +/** + * Example: Individual Recipe Card + */ +interface RecipeCardProps { + recipe: RecipeResponse; +} + +const RecipeCard: React.FC = ({ recipe }) => { + const { t } = useTranslation('recipes'); + const currentTenant = useCurrentTenant(); + + // Mutation hooks for recipe actions + const duplicateRecipe = useDuplicateRecipe(currentTenant?.id || ''); + const activateRecipe = useActivateRecipe(currentTenant?.id || ''); + const deleteRecipe = useDeleteRecipe(currentTenant?.id || ''); + + const handleDuplicate = () => { + if (!currentTenant) return; + + duplicateRecipe.mutate({ + id: recipe.id, + data: { new_name: `${recipe.name} (Copy)` } + }, { + onSuccess: () => { + alert(t('messages.recipe_duplicated')); + }, + onError: (error) => { + alert(error.message); + } + }); + }; + + const handleActivate = () => { + if (!currentTenant) return; + + activateRecipe.mutate(recipe.id, { + onSuccess: () => { + alert(t('messages.recipe_activated')); + } + }); + }; + + const handleDelete = () => { + if (!currentTenant) return; + + if (confirm(t('messages.confirm_delete'))) { + deleteRecipe.mutate(recipe.id, { + onSuccess: () => { + alert(t('messages.recipe_deleted')); + } + }); + } + }; + + return ( +
+

{recipe.name}

+

{recipe.description}

+ +
+ + {t(`status.${recipe.status}`)} + + {recipe.category} + + {t(`difficulty.${recipe.difficulty_level}`)} + +
+ +
+ + + {recipe.status === 'draft' && ( + + )} + + +
+
+ ); +}; + +/** + * Example: Recipe Detail View + */ +interface RecipeDetailProps { + recipeId: string; +} + +export const RecipeDetail: React.FC = ({ recipeId }) => { + const { t } = useTranslation('recipes'); + const currentTenant = useCurrentTenant(); + + // Get individual recipe with tenant context + const { + data: recipe, + isLoading, + error, + } = useRecipe(currentTenant?.id || '', recipeId, { + enabled: !!(currentTenant?.id && recipeId), + }); + + // Check feasibility + const { + data: feasibility, + } = useRecipeFeasibility( + currentTenant?.id || '', + recipeId, + 1.0, // batch multiplier + { + enabled: !!(currentTenant?.id && recipeId), + } + ); + + if (!currentTenant) { + return
{t('messages.no_tenant_selected')}
; + } + + if (isLoading) { + return
{t('messages.loading_recipe')}
; + } + + if (error || !recipe) { + return
Recipe not found
; + } + + return ( +
+
+

{recipe.name}

+

{recipe.description}

+ +
+ Yield: {recipe.yield_quantity} {recipe.yield_unit} + Prep: {recipe.prep_time_minutes}min + Cook: {recipe.cook_time_minutes}min + Total: {recipe.total_time_minutes}min +
+
+ + {/* Feasibility check */} + {feasibility && ( +
+

{t('feasibility.title')}

+

+ {feasibility.feasible + ? t('feasibility.feasible') + : t('feasibility.not_feasible') + } +

+ + {feasibility.missing_ingredients.length > 0 && ( +
+

{t('feasibility.missing_ingredients')}

+
    + {feasibility.missing_ingredients.map((ingredient, index) => ( +
  • {JSON.stringify(ingredient)}
  • + ))} +
+
+ )} +
+ )} + + {/* Ingredients */} +
+

{t('ingredients.title')}

+
    + {recipe.ingredients?.map((ingredient) => ( +
  • + {ingredient.quantity} {ingredient.unit} - {ingredient.ingredient_id} + {ingredient.preparation_method && ( + ({ingredient.preparation_method}) + )} + {ingredient.is_optional && ( + ({t('ingredients.is_optional')}) + )} +
  • + ))} +
+
+ + {/* Instructions */} + {recipe.instructions && ( +
+

{t('fields.instructions')}

+
{JSON.stringify(recipe.instructions, null, 2)}
+
+ )} +
+ ); +}; + +/** + * Example: Recipe Creation Form + */ +export const CreateRecipeForm: React.FC = () => { + const { t } = useTranslation('recipes'); + const currentTenant = useCurrentTenant(); + + const [formData, setFormData] = useState({ + name: '', + finished_product_id: '', + yield_quantity: 1, + yield_unit: MeasurementUnit.UNITS, + difficulty_level: 1, + batch_size_multiplier: 1.0, + is_seasonal: false, + is_signature_item: false, + ingredients: [], + }); + + const createRecipe = useCreateRecipe(currentTenant?.id || '', { + onSuccess: (data) => { + alert(t('messages.recipe_created')); + // Reset form or redirect + setFormData({ + name: '', + finished_product_id: '', + yield_quantity: 1, + yield_unit: MeasurementUnit.UNITS, + difficulty_level: 1, + batch_size_multiplier: 1.0, + is_seasonal: false, + is_signature_item: false, + ingredients: [], + }); + }, + onError: (error) => { + alert(error.message); + } + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + if (!currentTenant) { + alert('No tenant selected'); + return; + } + + if (!formData.name.trim()) { + alert(t('messages.recipe_name_required')); + return; + } + + if (formData.ingredients.length === 0) { + alert(t('messages.at_least_one_ingredient')); + return; + } + + createRecipe.mutate(formData); + }; + + if (!currentTenant) { + return
{t('messages.no_tenant_selected')}
; + } + + return ( +
+

{t('actions.create_recipe')}

+ +
+ + setFormData({ ...formData, name: e.target.value })} + placeholder={t('placeholders.recipe_name')} + required + /> +
+ +
+ +