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