Create the frontend receipes page to use real API

This commit is contained in:
Urtzi Alfaro
2025-09-19 21:39:04 +02:00
parent 8002d89d2b
commit d18c64ce6e
36 changed files with 3356 additions and 3171 deletions

View File

@@ -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<RecipeResponse>
async getRecipe(tenantId: string, recipeId: string): Promise<RecipeResponse>
async searchRecipes(tenantId: string, params: RecipeSearchParams = {}): Promise<RecipeResponse[]>
// 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<RecipeResponse[], ApiError>
) => {
return useQuery<RecipeResponse[], ApiError>({
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 (
<div>
{recipes?.map(recipe => (
<div key={recipe.id}>{recipe.name}</div>
))}
</div>
);
};
```
### **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.

View File

@@ -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<UseQueryOptions<RecipeResponse, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<RecipeResponse, ApiError>({
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<UseQueryOptions<RecipeResponse[], ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<RecipeResponse[], ApiError>({
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<RecipeSearchParams, 'offset'> = {},
options?: Omit<UseInfiniteQueryOptions<RecipeResponse[], ApiError>, 'queryKey' | 'queryFn' | 'getNextPageParam'>
) => {
return useInfiniteQuery<RecipeResponse[], ApiError>({
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<UseQueryOptions<RecipeStatisticsResponse, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<RecipeStatisticsResponse, ApiError>({
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<UseQueryOptions<RecipeCategoriesResponse, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<RecipeCategoriesResponse, ApiError>({
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<UseQueryOptions<RecipeFeasibilityResponse, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<RecipeFeasibilityResponse, ApiError>({
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<RecipeResponse, ApiError, RecipeCreate>
) => {
const queryClient = useQueryClient();
return useMutation<RecipeResponse, ApiError, RecipeCreate>({
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<RecipeResponse, ApiError, { id: string; data: RecipeUpdate }>
) => {
const queryClient = useQueryClient();
return useMutation<RecipeResponse, ApiError, { id: string; data: RecipeUpdate }>({
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<RecipeResponse, ApiError, { id: string; data: RecipeDuplicateRequest }>
) => {
const queryClient = useQueryClient();
return useMutation<RecipeResponse, ApiError, { id: string; data: RecipeDuplicateRequest }>({
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<RecipeResponse, ApiError, string>
) => {
const queryClient = useQueryClient();
return useMutation<RecipeResponse, ApiError, string>({
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<UseQueryOptions<ProductionBatchResponse, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<ProductionBatchResponse, ApiError>({
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<UseQueryOptions<ProductionBatchResponse[], ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<ProductionBatchResponse[], ApiError>({
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<UseQueryOptions<ProductionBatchResponse[], ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<ProductionBatchResponse[], ApiError>({
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<ProductionBatchResponse, ApiError, ProductionBatchCreate>
) => {
const queryClient = useQueryClient();
return useMutation<ProductionBatchResponse, ApiError, ProductionBatchCreate>({
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<ProductionBatchResponse, ApiError, { id: string; data: ProductionBatchUpdate }>
) => {
const queryClient = useQueryClient();
return useMutation<ProductionBatchResponse, ApiError, { id: string; data: ProductionBatchUpdate }>({
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<ProductionBatchResponse, ApiError, string>
) => {
const queryClient = useQueryClient();
return useMutation<ProductionBatchResponse, ApiError, string>({
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<ProductionBatchResponse, ApiError, {
id: string;
data: {
actual_quantity?: number;
quality_score?: number;
quality_notes?: string;
waste_quantity?: number;
waste_reason?: string;
}
}>
) => {
const queryClient = useQueryClient();
return useMutation<ProductionBatchResponse, ApiError, {
id: string;
data: {
actual_quantity?: number;
quality_score?: number;
quality_notes?: string;
waste_quantity?: number;
waste_reason?: string;
}
}>({
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<ProductionBatchResponse, ApiError, { id: string; reason?: string }>
) => {
const queryClient = useQueryClient();
return useMutation<ProductionBatchResponse, ApiError, { id: string; reason?: string }>({
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,
});

View File

@@ -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,
};
// Note: All query key factories are already exported in their respective hook sections above

View File

@@ -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<RecipeResponse> {
return apiClient.post<RecipeResponse>(this.baseUrl, recipeData);
async createRecipe(tenantId: string, recipeData: RecipeCreate): Promise<RecipeResponse> {
const baseUrl = this.getBaseUrl(tenantId);
return apiClient.post<RecipeResponse>(baseUrl, recipeData);
}
/**
* Get recipe by ID with ingredients
* GET /tenants/{tenant_id}/recipes/{recipe_id}
*/
async getRecipe(recipeId: string): Promise<RecipeResponse> {
return apiClient.get<RecipeResponse>(`${this.baseUrl}/${recipeId}`);
async getRecipe(tenantId: string, recipeId: string): Promise<RecipeResponse> {
const baseUrl = this.getBaseUrl(tenantId);
return apiClient.get<RecipeResponse>(`${baseUrl}/${recipeId}`);
}
/**
* Update an existing recipe
* PUT /tenants/{tenant_id}/recipes/{recipe_id}
*/
async updateRecipe(recipeId: string, recipeData: RecipeUpdate): Promise<RecipeResponse> {
return apiClient.put<RecipeResponse>(`${this.baseUrl}/${recipeId}`, recipeData);
async updateRecipe(tenantId: string, recipeId: string, recipeData: RecipeUpdate): Promise<RecipeResponse> {
const baseUrl = this.getBaseUrl(tenantId);
return apiClient.put<RecipeResponse>(`${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<RecipeResponse[]> {
async searchRecipes(tenantId: string, params: RecipeSearchParams = {}): Promise<RecipeResponse[]> {
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<RecipeResponse[]>(url);
}
/**
* Get all recipes (shorthand for search without filters)
* GET /tenants/{tenant_id}/recipes
*/
async getRecipes(): Promise<RecipeResponse[]> {
return this.searchRecipes();
async getRecipes(tenantId: string): Promise<RecipeResponse[]> {
return this.searchRecipes(tenantId);
}
/**
* Duplicate an existing recipe
* POST /tenants/{tenant_id}/recipes/{recipe_id}/duplicate
*/
async duplicateRecipe(recipeId: string, duplicateData: RecipeDuplicateRequest): Promise<RecipeResponse> {
return apiClient.post<RecipeResponse>(`${this.baseUrl}/${recipeId}/duplicate`, duplicateData);
async duplicateRecipe(tenantId: string, recipeId: string, duplicateData: RecipeDuplicateRequest): Promise<RecipeResponse> {
const baseUrl = this.getBaseUrl(tenantId);
return apiClient.post<RecipeResponse>(`${baseUrl}/${recipeId}/duplicate`, duplicateData);
}
/**
* Activate a recipe for production
* POST /tenants/{tenant_id}/recipes/{recipe_id}/activate
*/
async activateRecipe(recipeId: string): Promise<RecipeResponse> {
return apiClient.post<RecipeResponse>(`${this.baseUrl}/${recipeId}/activate`);
async activateRecipe(tenantId: string, recipeId: string): Promise<RecipeResponse> {
const baseUrl = this.getBaseUrl(tenantId);
return apiClient.post<RecipeResponse>(`${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<RecipeFeasibilityResponse> {
async checkRecipeFeasibility(tenantId: string, recipeId: string, batchMultiplier: number = 1.0): Promise<RecipeFeasibilityResponse> {
const baseUrl = this.getBaseUrl(tenantId);
const params = new URLSearchParams({ batch_multiplier: String(batchMultiplier) });
return apiClient.get<RecipeFeasibilityResponse>(`${this.baseUrl}/${recipeId}/feasibility?${params}`);
return apiClient.get<RecipeFeasibilityResponse>(`${baseUrl}/${recipeId}/feasibility?${params}`);
}
/**
* Get recipe statistics for dashboard
* GET /tenants/{tenant_id}/recipes/statistics/dashboard
*/
async getRecipeStatistics(): Promise<RecipeStatisticsResponse> {
return apiClient.get<RecipeStatisticsResponse>(`${this.baseUrl}/statistics/dashboard`);
async getRecipeStatistics(tenantId: string): Promise<RecipeStatisticsResponse> {
const baseUrl = this.getBaseUrl(tenantId);
return apiClient.get<RecipeStatisticsResponse>(`${baseUrl}/statistics/dashboard`);
}
/**
* Get list of recipe categories used by tenant
* GET /tenants/{tenant_id}/recipes/categories/list
*/
async getRecipeCategories(): Promise<RecipeCategoriesResponse> {
return apiClient.get<RecipeCategoriesResponse>(`${this.baseUrl}/categories/list`);
}
// Production Batch Methods
/**
* Create a production batch for a recipe
*/
async createProductionBatch(batchData: ProductionBatchCreate): Promise<ProductionBatchResponse> {
return apiClient.post<ProductionBatchResponse>('/production/batches', batchData);
}
/**
* Get production batch by ID
*/
async getProductionBatch(batchId: string): Promise<ProductionBatchResponse> {
return apiClient.get<ProductionBatchResponse>(`/production/batches/${batchId}`);
}
/**
* Update production batch
*/
async updateProductionBatch(batchId: string, batchData: ProductionBatchUpdate): Promise<ProductionBatchResponse> {
return apiClient.put<ProductionBatchResponse>(`/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<ProductionBatchResponse[]> {
return apiClient.get<ProductionBatchResponse[]>(`/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<ProductionBatchResponse[]> {
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<ProductionBatchResponse[]>(url);
}
/**
* Start production batch
*/
async startProductionBatch(batchId: string): Promise<ProductionBatchResponse> {
return apiClient.post<ProductionBatchResponse>(`/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<ProductionBatchResponse> {
return apiClient.post<ProductionBatchResponse>(`/production/batches/${batchId}/complete`, completionData);
}
/**
* Cancel production batch
*/
async cancelProductionBatch(batchId: string, reason?: string): Promise<ProductionBatchResponse> {
return apiClient.post<ProductionBatchResponse>(`/production/batches/${batchId}/cancel`, { reason });
async getRecipeCategories(tenantId: string): Promise<RecipeCategoriesResponse> {
const baseUrl = this.getBaseUrl(tenantId);
return apiClient.get<RecipeCategoriesResponse>(`${baseUrl}/categories/list`);
}
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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<void>;
}
/**
* CreateRecipeModal - Modal for creating a new recipe
* Comprehensive form for adding new recipes
*/
export const CreateRecipeModal: React.FC<CreateRecipeModalProps> = ({
isOpen,
onClose,
onCreateRecipe
}) => {
const [formData, setFormData] = useState<RecipeCreate>({
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 (
<StatusModal
isOpen={isOpen}
onClose={onClose}
mode={mode}
onModeChange={setMode}
title="Nueva Receta"
subtitle="Crear una nueva receta para la panadería"
statusIndicator={{
color: '#3b82f6',
text: 'Nueva',
icon: ChefHat,
isCritical: false,
isHighlight: true
}}
size="xl"
sections={getModalSections()}
onFieldChange={handleFieldChange}
actions={[
{
label: loading ? 'Creando...' : 'Crear Receta',
icon: ChefHat,
variant: 'primary',
onClick: handleSubmit,
disabled: loading || !formData.name.trim() || !formData.finished_product_id.trim()
}
]}
/>
);
};
export default CreateRecipeModal;

View File

@@ -0,0 +1 @@
export { CreateRecipeModal } from './CreateRecipeModal';

View File

@@ -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<RecipeSearchParams>({
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 <div>{t('messages.no_tenant_selected')}</div>;
}
if (isLoading) {
return <div>{t('messages.loading_recipes')}</div>;
}
if (error) {
return <div>Error: {error.message}</div>;
}
return (
<div className="recipes-list">
<h2>{t('title')}</h2>
{/* Search and filters */}
<div className="filters">
<input
type="text"
placeholder={t('filters.search_placeholder')}
value={filters.search_term || ''}
onChange={(e) => setFilters({ ...filters, search_term: e.target.value })}
/>
<select
value={filters.status || ''}
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
>
<option value="">{t('filters.all')}</option>
<option value="active">{t('status.active')}</option>
<option value="draft">{t('status.draft')}</option>
</select>
</div>
{/* Recipe cards */}
<div className="recipe-grid">
{recipes?.map((recipe) => (
<RecipeCard key={recipe.id} recipe={recipe} />
))}
</div>
{(!recipes || recipes.length === 0) && (
<div className="no-results">{t('messages.no_recipes_found')}</div>
)}
</div>
);
};
/**
* Example: Individual Recipe Card
*/
interface RecipeCardProps {
recipe: RecipeResponse;
}
const RecipeCard: React.FC<RecipeCardProps> = ({ 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 (
<div className="recipe-card">
<h3>{recipe.name}</h3>
<p>{recipe.description}</p>
<div className="recipe-meta">
<span className={`status status-${recipe.status}`}>
{t(`status.${recipe.status}`)}
</span>
<span className="category">{recipe.category}</span>
<span className="difficulty">
{t(`difficulty.${recipe.difficulty_level}`)}
</span>
</div>
<div className="recipe-actions">
<button onClick={handleDuplicate} disabled={duplicateRecipe.isPending}>
{t('actions.duplicate_recipe')}
</button>
{recipe.status === 'draft' && (
<button onClick={handleActivate} disabled={activateRecipe.isPending}>
{t('actions.activate_recipe')}
</button>
)}
<button onClick={handleDelete} disabled={deleteRecipe.isPending}>
{t('actions.delete_recipe')}
</button>
</div>
</div>
);
};
/**
* Example: Recipe Detail View
*/
interface RecipeDetailProps {
recipeId: string;
}
export const RecipeDetail: React.FC<RecipeDetailProps> = ({ 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 <div>{t('messages.no_tenant_selected')}</div>;
}
if (isLoading) {
return <div>{t('messages.loading_recipe')}</div>;
}
if (error || !recipe) {
return <div>Recipe not found</div>;
}
return (
<div className="recipe-detail">
<header className="recipe-header">
<h1>{recipe.name}</h1>
<p>{recipe.description}</p>
<div className="recipe-info">
<span>Yield: {recipe.yield_quantity} {recipe.yield_unit}</span>
<span>Prep: {recipe.prep_time_minutes}min</span>
<span>Cook: {recipe.cook_time_minutes}min</span>
<span>Total: {recipe.total_time_minutes}min</span>
</div>
</header>
{/* Feasibility check */}
{feasibility && (
<div className={`feasibility ${feasibility.feasible ? 'feasible' : 'not-feasible'}`}>
<h3>{t('feasibility.title')}</h3>
<p>
{feasibility.feasible
? t('feasibility.feasible')
: t('feasibility.not_feasible')
}
</p>
{feasibility.missing_ingredients.length > 0 && (
<div>
<h4>{t('feasibility.missing_ingredients')}</h4>
<ul>
{feasibility.missing_ingredients.map((ingredient, index) => (
<li key={index}>{JSON.stringify(ingredient)}</li>
))}
</ul>
</div>
)}
</div>
)}
{/* Ingredients */}
<section className="ingredients">
<h2>{t('ingredients.title')}</h2>
<ul>
{recipe.ingredients?.map((ingredient) => (
<li key={ingredient.id}>
{ingredient.quantity} {ingredient.unit} - {ingredient.ingredient_id}
{ingredient.preparation_method && (
<span className="prep-method"> ({ingredient.preparation_method})</span>
)}
{ingredient.is_optional && (
<span className="optional"> ({t('ingredients.is_optional')})</span>
)}
</li>
))}
</ul>
</section>
{/* Instructions */}
{recipe.instructions && (
<section className="instructions">
<h2>{t('fields.instructions')}</h2>
<div>{JSON.stringify(recipe.instructions, null, 2)}</div>
</section>
)}
</div>
);
};
/**
* Example: Recipe Creation Form
*/
export const CreateRecipeForm: React.FC = () => {
const { t } = useTranslation('recipes');
const currentTenant = useCurrentTenant();
const [formData, setFormData] = useState<RecipeCreate>({
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 <div>{t('messages.no_tenant_selected')}</div>;
}
return (
<form onSubmit={handleSubmit} className="create-recipe-form">
<h2>{t('actions.create_recipe')}</h2>
<div className="form-group">
<label htmlFor="name">{t('fields.name')}</label>
<input
id="name"
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder={t('placeholders.recipe_name')}
required
/>
</div>
<div className="form-group">
<label htmlFor="description">{t('fields.description')}</label>
<textarea
id="description"
value={formData.description || ''}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder={t('placeholders.description')}
/>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="yield_quantity">{t('fields.yield_quantity')}</label>
<input
id="yield_quantity"
type="number"
min="0.1"
step="0.1"
value={formData.yield_quantity}
onChange={(e) => setFormData({ ...formData, yield_quantity: parseFloat(e.target.value) })}
required
/>
</div>
<div className="form-group">
<label htmlFor="yield_unit">{t('fields.yield_unit')}</label>
<select
id="yield_unit"
value={formData.yield_unit}
onChange={(e) => setFormData({ ...formData, yield_unit: e.target.value as MeasurementUnit })}
required
>
{Object.values(MeasurementUnit).map((unit) => (
<option key={unit} value={unit}>
{t(`units.${unit}`)}
</option>
))}
</select>
</div>
</div>
<div className="form-group">
<label htmlFor="difficulty_level">{t('fields.difficulty_level')}</label>
<select
id="difficulty_level"
value={formData.difficulty_level}
onChange={(e) => setFormData({ ...formData, difficulty_level: parseInt(e.target.value) })}
>
{[1, 2, 3, 4, 5].map((level) => (
<option key={level} value={level}>
{t(`difficulty.${level}`)}
</option>
))}
</select>
</div>
<div className="form-checkboxes">
<label>
<input
type="checkbox"
checked={formData.is_signature_item}
onChange={(e) => setFormData({ ...formData, is_signature_item: e.target.checked })}
/>
{t('fields.is_signature')}
</label>
<label>
<input
type="checkbox"
checked={formData.is_seasonal}
onChange={(e) => setFormData({ ...formData, is_seasonal: e.target.checked })}
/>
{t('fields.is_seasonal')}
</label>
</div>
{/* Ingredients section would go here */}
<div className="ingredients-section">
<h3>{t('ingredients.title')}</h3>
<p>{t('messages.no_ingredients')}</p>
{/* Add ingredient form components here */}
</div>
<div className="form-actions">
<button
type="submit"
disabled={createRecipe.isPending}
className="btn-primary"
>
{createRecipe.isPending ? 'Creating...' : t('actions.create_recipe')}
</button>
</div>
</form>
);
};
/**
* Example: Recipe Statistics Dashboard
*/
export const RecipeStatistics: React.FC = () => {
const { t } = useTranslation('recipes');
const currentTenant = useCurrentTenant();
const { data: stats, isLoading } = useRecipeStatistics(currentTenant?.id || '', {
enabled: !!currentTenant?.id,
});
const { data: categories } = useRecipeCategories(currentTenant?.id || '', {
enabled: !!currentTenant?.id,
});
if (!currentTenant) {
return <div>{t('messages.no_tenant_selected')}</div>;
}
if (isLoading) {
return <div>Loading statistics...</div>;
}
return (
<div className="recipe-statistics">
<h2>{t('statistics.title')}</h2>
{stats && (
<div className="stats-grid">
<div className="stat-card">
<h3>{t('statistics.total_recipes')}</h3>
<span className="stat-number">{stats.total_recipes}</span>
</div>
<div className="stat-card">
<h3>{t('statistics.active_recipes')}</h3>
<span className="stat-number">{stats.active_recipes}</span>
</div>
<div className="stat-card">
<h3>{t('statistics.signature_recipes')}</h3>
<span className="stat-number">{stats.signature_recipes}</span>
</div>
<div className="stat-card">
<h3>{t('statistics.seasonal_recipes')}</h3>
<span className="stat-number">{stats.seasonal_recipes}</span>
</div>
</div>
)}
{categories && (
<div className="categories-list">
<h3>Categories</h3>
<ul>
{categories.categories.map((category) => (
<li key={category}>{category}</li>
))}
</ul>
</div>
)}
</div>
);
};
export default {
RecipesList,
RecipeDetail,
CreateRecipeForm,
RecipeStatistics,
};

View File

@@ -0,0 +1,267 @@
{
"title": "Recipe Management",
"subtitle": "Manage your bakery's recipes",
"navigation": {
"all_recipes": "All Recipes",
"active_recipes": "Active Recipes",
"draft_recipes": "Drafts",
"signature_recipes": "Signature Recipes",
"seasonal_recipes": "Seasonal Recipes",
"production_batches": "Production Batches"
},
"actions": {
"create_recipe": "Create Recipe",
"edit_recipe": "Edit Recipe",
"duplicate_recipe": "Duplicate Recipe",
"activate_recipe": "Activate Recipe",
"archive_recipe": "Archive Recipe",
"delete_recipe": "Delete Recipe",
"view_recipe": "View Recipe",
"check_feasibility": "Check Feasibility",
"create_batch": "Create Batch",
"start_production": "Start Production",
"complete_batch": "Complete Batch",
"cancel_batch": "Cancel Batch",
"export_recipe": "Export Recipe",
"print_recipe": "Print Recipe"
},
"fields": {
"name": "Recipe Name",
"recipe_code": "Recipe Code",
"version": "Version",
"description": "Description",
"category": "Category",
"cuisine_type": "Cuisine Type",
"difficulty_level": "Difficulty Level",
"yield_quantity": "Yield Quantity",
"yield_unit": "Yield Unit",
"prep_time": "Preparation Time",
"cook_time": "Cooking Time",
"total_time": "Total Time",
"rest_time": "Rest Time",
"instructions": "Instructions",
"preparation_notes": "Preparation Notes",
"storage_instructions": "Storage Instructions",
"quality_standards": "Quality Standards",
"serves_count": "Serving Count",
"is_seasonal": "Is Seasonal",
"season_start": "Season Start",
"season_end": "Season End",
"is_signature": "Is Signature Recipe",
"target_margin": "Target Margin",
"batch_multiplier": "Batch Multiplier",
"min_batch_size": "Minimum Batch Size",
"max_batch_size": "Maximum Batch Size",
"optimal_temperature": "Optimal Temperature",
"optimal_humidity": "Optimal Humidity",
"allergens": "Allergens",
"dietary_tags": "Dietary Tags",
"nutritional_info": "Nutritional Information"
},
"ingredients": {
"title": "Ingredients",
"add_ingredient": "Add Ingredient",
"remove_ingredient": "Remove Ingredient",
"ingredient_name": "Ingredient Name",
"quantity": "Quantity",
"unit": "Unit",
"alternative_quantity": "Alternative Quantity",
"alternative_unit": "Alternative Unit",
"preparation_method": "Preparation Method",
"notes": "Ingredient Notes",
"is_optional": "Is Optional",
"ingredient_order": "Order",
"ingredient_group": "Group",
"substitutions": "Substitutions",
"substitution_ratio": "Substitution Ratio",
"cost_per_unit": "Cost per Unit",
"total_cost": "Total Cost",
"groups": {
"wet_ingredients": "Wet Ingredients",
"dry_ingredients": "Dry Ingredients",
"spices": "Spices & Seasonings",
"toppings": "Toppings",
"fillings": "Fillings",
"decorations": "Decorations"
}
},
"status": {
"draft": "Draft",
"active": "Active",
"testing": "Testing",
"archived": "Archived",
"discontinued": "Discontinued"
},
"difficulty": {
"1": "Very Easy",
"2": "Easy",
"3": "Intermediate",
"4": "Hard",
"5": "Very Hard"
},
"units": {
"g": "grams",
"kg": "kilograms",
"ml": "milliliters",
"l": "liters",
"cups": "cups",
"tbsp": "tablespoons",
"tsp": "teaspoons",
"units": "units",
"pieces": "pieces",
"%": "percentage"
},
"categories": {
"bread": "Breads",
"pastry": "Pastries",
"cake": "Cakes & Tarts",
"cookies": "Cookies",
"savory": "Savory",
"desserts": "Desserts",
"seasonal": "Seasonal",
"specialty": "Specialties"
},
"dietary_tags": {
"vegan": "Vegan",
"vegetarian": "Vegetarian",
"gluten_free": "Gluten Free",
"dairy_free": "Dairy Free",
"nut_free": "Nut Free",
"sugar_free": "Sugar Free",
"low_carb": "Low Carb",
"keto": "Keto",
"organic": "Organic"
},
"allergens": {
"gluten": "Gluten",
"dairy": "Dairy",
"eggs": "Eggs",
"nuts": "Tree Nuts",
"soy": "Soy",
"sesame": "Sesame",
"fish": "Fish",
"shellfish": "Shellfish"
},
"production": {
"title": "Production",
"batch_number": "Batch Number",
"production_date": "Production Date",
"planned_quantity": "Planned Quantity",
"actual_quantity": "Actual Quantity",
"yield_percentage": "Yield Percentage",
"priority": "Priority",
"assigned_staff": "Assigned Staff",
"production_notes": "Production Notes",
"quality_score": "Quality Score",
"quality_notes": "Quality Notes",
"defect_rate": "Defect Rate",
"rework_required": "Rework Required",
"waste_quantity": "Waste Quantity",
"waste_reason": "Waste Reason",
"efficiency": "Efficiency",
"material_cost": "Material Cost",
"labor_cost": "Labor Cost",
"overhead_cost": "Overhead Cost",
"total_cost": "Total Cost",
"cost_per_unit": "Cost per Unit",
"status": {
"planned": "Planned",
"in_progress": "In Progress",
"completed": "Completed",
"failed": "Failed",
"cancelled": "Cancelled"
},
"priority": {
"low": "Low",
"normal": "Normal",
"high": "High",
"urgent": "Urgent"
}
},
"feasibility": {
"title": "Feasibility Check",
"feasible": "Feasible",
"not_feasible": "Not Feasible",
"missing_ingredients": "Missing Ingredients",
"insufficient_ingredients": "Insufficient Ingredients",
"batch_multiplier": "Batch Multiplier",
"required_quantity": "Required Quantity",
"available_quantity": "Available Quantity",
"shortage": "Shortage"
},
"statistics": {
"title": "Recipe Statistics",
"total_recipes": "Total Recipes",
"active_recipes": "Active Recipes",
"signature_recipes": "Signature Recipes",
"seasonal_recipes": "Seasonal Recipes",
"category_breakdown": "Category Breakdown",
"most_popular": "Most Popular",
"most_profitable": "Most Profitable",
"production_volume": "Production Volume"
},
"filters": {
"all": "All",
"search_placeholder": "Search recipes...",
"status_filter": "Filter by Status",
"category_filter": "Filter by Category",
"difficulty_filter": "Filter by Difficulty",
"seasonal_filter": "Seasonal Recipes Only",
"signature_filter": "Signature Recipes Only",
"clear_filters": "Clear Filters"
},
"costs": {
"estimated_cost": "Estimated Cost",
"last_calculated": "Last Calculated",
"suggested_price": "Suggested Price",
"margin_percentage": "Margin Percentage",
"cost_breakdown": "Cost Breakdown",
"ingredient_costs": "Ingredient Costs",
"labor_costs": "Labor Costs",
"overhead_costs": "Overhead Costs"
},
"messages": {
"recipe_created": "Recipe created successfully",
"recipe_updated": "Recipe updated successfully",
"recipe_deleted": "Recipe deleted successfully",
"recipe_duplicated": "Recipe duplicated successfully",
"recipe_activated": "Recipe activated successfully",
"batch_created": "Production batch created successfully",
"batch_started": "Production started successfully",
"batch_completed": "Batch completed successfully",
"batch_cancelled": "Batch cancelled successfully",
"feasibility_checked": "Feasibility checked",
"loading_recipes": "Loading recipes...",
"loading_recipe": "Loading recipe...",
"no_recipes_found": "No recipes found",
"no_ingredients": "No ingredients added",
"confirm_delete": "Are you sure you want to delete this recipe?",
"confirm_cancel_batch": "Are you sure you want to cancel this batch?",
"recipe_name_required": "Recipe name is required",
"at_least_one_ingredient": "Must add at least one ingredient",
"invalid_quantity": "Quantity must be greater than 0",
"ingredient_required": "Must select an ingredient"
},
"placeholders": {
"recipe_name": "e.g. Classic Sourdough Bread",
"recipe_code": "e.g. BRD-001",
"description": "Describe the unique aspects of this recipe...",
"preparation_notes": "Special notes for preparation...",
"storage_instructions": "How to store the finished product...",
"quality_standards": "Quality criteria for the final product...",
"batch_number": "e.g. BATCH-20231201-001",
"production_notes": "Specific notes for this batch...",
"quality_notes": "Quality observations...",
"waste_reason": "Reason for waste..."
},
"tooltips": {
"difficulty_level": "Level from 1 (very easy) to 5 (very hard)",
"yield_quantity": "Amount this recipe produces",
"batch_multiplier": "Factor to scale the recipe",
"target_margin": "Target profit margin percentage",
"optimal_temperature": "Ideal temperature for production",
"optimal_humidity": "Ideal humidity for production",
"is_seasonal": "Check if this is a seasonal recipe",
"is_signature": "Check if this is a bakery signature recipe"
}
}

View File

@@ -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',

View File

@@ -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>
);
};

View File

@@ -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',