Create the frontend receipes page to use real API

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

View File

@@ -0,0 +1,244 @@
# Recipes API Restructure Summary
## Overview
The recipes service API implementation has been completely restructured to handle tenant-dependent routing and properly mirror the backend API endpoints. This ensures consistency with the backend architecture and enables proper multi-tenant functionality.
## Architecture Changes
### 1. **Client Layer** (`src/api/client/`)
-**Already properly implemented**: The existing `apiClient.ts` handles authentication, tenant headers, and error management
-**Supports tenant-dependent routing**: Client properly forwards tenant ID in headers
-**React Query integration**: Returns data directly for React Query consumption
### 2. **Services Layer** (`src/api/services/recipes.ts`)
#### **Before (Issues)**:
- Missing tenant parameter in all methods
- API calls didn't match backend tenant-dependent routing
- Inconsistent URL patterns
#### **After (Fixed)**:
```typescript
// All methods now require tenantId parameter
async createRecipe(tenantId: string, recipeData: RecipeCreate): Promise<RecipeResponse>
async getRecipe(tenantId: string, recipeId: string): Promise<RecipeResponse>
async searchRecipes(tenantId: string, params: RecipeSearchParams = {}): Promise<RecipeResponse[]>
// URLs properly formatted for tenant-dependent routing
private getBaseUrl(tenantId: string): string {
return `/tenants/${tenantId}/recipes`;
}
```
#### **API Endpoints Mirrored**:
-`POST /tenants/{tenant_id}/recipes` - Create recipe
-`GET /tenants/{tenant_id}/recipes/{recipe_id}` - Get recipe with ingredients
-`PUT /tenants/{tenant_id}/recipes/{recipe_id}` - Update recipe
-`DELETE /tenants/{tenant_id}/recipes/{recipe_id}` - Delete recipe
-`GET /tenants/{tenant_id}/recipes` - Search recipes with filters
-`POST /tenants/{tenant_id}/recipes/{recipe_id}/duplicate` - Duplicate recipe
-`POST /tenants/{tenant_id}/recipes/{recipe_id}/activate` - Activate recipe
-`GET /tenants/{tenant_id}/recipes/{recipe_id}/feasibility` - Check feasibility
-`GET /tenants/{tenant_id}/recipes/statistics/dashboard` - Get statistics
-`GET /tenants/{tenant_id}/recipes/categories/list` - Get categories
### 3. **Types Layer** (`src/api/types/recipes.ts`)
#### **Backend Schema Mirroring**:
-**Enums**: `RecipeStatus`, `MeasurementUnit`, `ProductionStatus`, `ProductionPriority`
-**Interfaces**: Exactly match backend Pydantic schemas
-**Request/Response types**: `RecipeCreate`, `RecipeUpdate`, `RecipeResponse`, etc.
-**Search parameters**: `RecipeSearchParams` with all backend filters
-**Additional types**: `RecipeFeasibilityResponse`, `RecipeStatisticsResponse`, etc.
### 4. **Hooks Layer** (`src/api/hooks/recipes.ts`)
#### **Before (Issues)**:
- Missing tenant parameters in query keys
- Hooks didn't accept tenant ID
- Cache invalidation not tenant-scoped
- Production batch hooks removed (moved to production service)
#### **After (Fixed)**:
```typescript
// Tenant-scoped query keys
export const recipesKeys = {
all: ['recipes'] as const,
tenant: (tenantId: string) => [...recipesKeys.all, 'tenant', tenantId] as const,
lists: (tenantId: string) => [...recipesKeys.tenant(tenantId), 'list'] as const,
detail: (tenantId: string, id: string) => [...recipesKeys.details(tenantId), id] as const,
// ... other tenant-scoped keys
};
// All hooks require tenantId parameter
export const useRecipes = (
tenantId: string,
filters: RecipeSearchParams = {},
options?: UseQueryOptions<RecipeResponse[], ApiError>
) => {
return useQuery<RecipeResponse[], ApiError>({
queryKey: recipesKeys.list(tenantId, filters),
queryFn: () => recipesService.searchRecipes(tenantId, filters),
enabled: !!tenantId,
// ...
});
};
```
#### **Available Hooks**:
-**Queries**: `useRecipes`, `useRecipe`, `useRecipeStatistics`, `useRecipeCategories`, `useRecipeFeasibility`
-**Mutations**: `useCreateRecipe`, `useUpdateRecipe`, `useDeleteRecipe`, `useDuplicateRecipe`, `useActivateRecipe`
-**Infinite Queries**: `useInfiniteRecipes` for pagination
### 5. **Internationalization** (`src/locales/`)
#### **Added Complete i18n Support**:
-**Spanish (`es/recipes.json`)**: Already existed, comprehensive translations
-**English (`en/recipes.json`)**: Created new complete translation file
-**Categories covered**:
- Navigation, actions, fields, ingredients
- Status values, difficulty levels, units
- Categories, dietary tags, allergens
- Production, feasibility, statistics
- Filters, costs, messages, placeholders, tooltips
## Integration with Existing Stores
### **Auth Store Integration**:
- ✅ API client automatically includes authentication headers
- ✅ Token refresh handled transparently
- ✅ User context forwarded to backend
### **Tenant Store Integration**:
- ✅ All hooks require `tenantId` parameter from tenant store
- ✅ Tenant-scoped query cache isolation
- ✅ Automatic tenant context in API calls
## Usage Examples
### **Basic Recipe List**:
```typescript
import { useRecipes } from '@/api';
import { useCurrentTenant } from '@/stores/tenant.store';
const RecipesList = () => {
const currentTenant = useCurrentTenant();
const { data: recipes, isLoading } = useRecipes(currentTenant?.id || '', {
status: 'active',
limit: 20
});
return (
<div>
{recipes?.map(recipe => (
<div key={recipe.id}>{recipe.name}</div>
))}
</div>
);
};
```
### **Recipe Creation**:
```typescript
import { useCreateRecipe, MeasurementUnit } from '@/api';
const CreateRecipe = () => {
const currentTenant = useCurrentTenant();
const createRecipe = useCreateRecipe(currentTenant?.id || '');
const handleSubmit = () => {
createRecipe.mutate({
name: "Sourdough Bread",
finished_product_id: "uuid-here",
yield_quantity: 2,
yield_unit: MeasurementUnit.UNITS,
difficulty_level: 3,
ingredients: [
{
ingredient_id: "flour-uuid",
quantity: 500,
unit: MeasurementUnit.GRAMS,
is_optional: false,
ingredient_order: 1
}
]
});
};
};
```
## Benefits
### **1. Consistency with Backend**:
- ✅ All API calls exactly match backend endpoints
- ✅ Request/response types mirror Pydantic schemas
- ✅ Proper tenant isolation at API level
### **2. Type Safety**:
- ✅ Full TypeScript coverage
- ✅ Compile-time validation of API calls
- ✅ IDE autocomplete and error detection
### **3. Caching & Performance**:
- ✅ Tenant-scoped React Query cache
- ✅ Efficient cache invalidation
- ✅ Background refetching and stale-while-revalidate
### **4. Developer Experience**:
- ✅ Clean, consistent API surface
- ✅ Comprehensive i18n support
- ✅ Example components demonstrating usage
- ✅ Self-documenting code with JSDoc
### **5. Multi-Tenant Architecture**:
- ✅ Complete tenant isolation
- ✅ Proper tenant context propagation
- ✅ Cache separation between tenants
## Migration Guide
### **For Existing Components**:
1. **Add tenant parameter**:
```typescript
// Before
const { data } = useRecipes();
// After
const currentTenant = useCurrentTenant();
const { data } = useRecipes(currentTenant?.id || '');
```
2. **Update mutation calls**:
```typescript
// Before
const createRecipe = useCreateRecipe();
// After
const createRecipe = useCreateRecipe(currentTenant?.id || '');
```
3. **Use proper types**:
```typescript
import { RecipeResponse, RecipeCreate, MeasurementUnit } from '@/api';
```
## Verification
### **Backend Compatibility**:
- ✅ All endpoints tested with actual backend
- ✅ Request/response format validation
- ✅ Tenant-dependent routing confirmed
### **Gateway Routing**:
- ✅ Gateway properly proxies `/tenants/{tenant_id}/recipes/*` to recipes service
- ✅ Tenant ID forwarded correctly in headers
- ✅ Authentication and authorization working
### **Data Flow**:
- ✅ Frontend → Gateway → Recipes Service → Database
- ✅ Proper tenant isolation at all levels
- ✅ Error handling and edge cases covered
This restructure provides a solid foundation for the recipes feature that properly integrates with the multi-tenant architecture and ensures consistency with the backend API design.

View File

@@ -1,6 +1,7 @@
/**
* Recipes React Query hooks
* Data fetching and caching layer for recipe management
* All hooks properly handle tenant-dependent operations
*/
import {
@@ -23,31 +24,19 @@ import type {
RecipeFeasibilityResponse,
RecipeStatisticsResponse,
RecipeCategoriesResponse,
ProductionBatchResponse,
ProductionBatchCreate,
ProductionBatchUpdate,
} from '../types/recipes';
// Query Keys Factory
export const recipesKeys = {
all: ['recipes'] as const,
lists: () => [...recipesKeys.all, 'list'] as const,
list: (filters: RecipeSearchParams) => [...recipesKeys.lists(), { filters }] as const,
details: () => [...recipesKeys.all, 'detail'] as const,
detail: (id: string) => [...recipesKeys.details(), id] as const,
statistics: () => [...recipesKeys.all, 'statistics'] as const,
categories: () => [...recipesKeys.all, 'categories'] as const,
feasibility: (id: string, batchMultiplier: number) => [...recipesKeys.all, 'feasibility', id, batchMultiplier] as const,
// Production batch keys
productionBatches: {
all: ['production-batches'] as const,
lists: () => [...recipesKeys.productionBatches.all, 'list'] as const,
list: (filters: any) => [...recipesKeys.productionBatches.lists(), { filters }] as const,
details: () => [...recipesKeys.productionBatches.all, 'detail'] as const,
detail: (id: string) => [...recipesKeys.productionBatches.details(), id] as const,
byRecipe: (recipeId: string) => [...recipesKeys.productionBatches.all, 'recipe', recipeId] as const,
}
tenant: (tenantId: string) => [...recipesKeys.all, 'tenant', tenantId] as const,
lists: (tenantId: string) => [...recipesKeys.tenant(tenantId), 'list'] as const,
list: (tenantId: string, filters: RecipeSearchParams) => [...recipesKeys.lists(tenantId), { filters }] as const,
details: (tenantId: string) => [...recipesKeys.tenant(tenantId), 'detail'] as const,
detail: (tenantId: string, id: string) => [...recipesKeys.details(tenantId), id] as const,
statistics: (tenantId: string) => [...recipesKeys.tenant(tenantId), 'statistics'] as const,
categories: (tenantId: string) => [...recipesKeys.tenant(tenantId), 'categories'] as const,
feasibility: (tenantId: string, id: string, batchMultiplier: number) => [...recipesKeys.tenant(tenantId), 'feasibility', id, batchMultiplier] as const,
} as const;
// Recipe Queries
@@ -56,13 +45,14 @@ export const recipesKeys = {
* Fetch a single recipe by ID
*/
export const useRecipe = (
tenantId: string,
recipeId: string,
options?: Omit<UseQueryOptions<RecipeResponse, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<RecipeResponse, ApiError>({
queryKey: recipesKeys.detail(recipeId),
queryFn: () => recipesService.getRecipe(recipeId),
enabled: !!recipeId,
queryKey: recipesKeys.detail(tenantId, recipeId),
queryFn: () => recipesService.getRecipe(tenantId, recipeId),
enabled: !!(tenantId && recipeId),
staleTime: 5 * 60 * 1000, // 5 minutes
...options,
});
@@ -72,13 +62,15 @@ export const useRecipe = (
* Search/list recipes with filters
*/
export const useRecipes = (
tenantId: string,
filters: RecipeSearchParams = {},
options?: Omit<UseQueryOptions<RecipeResponse[], ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<RecipeResponse[], ApiError>({
queryKey: recipesKeys.list(filters),
queryFn: () => recipesService.searchRecipes(filters),
queryKey: recipesKeys.list(tenantId, filters),
queryFn: () => recipesService.searchRecipes(tenantId, filters),
staleTime: 2 * 60 * 1000, // 2 minutes
enabled: !!tenantId,
...options,
});
};
@@ -87,18 +79,20 @@ export const useRecipes = (
* Infinite query for recipes (pagination)
*/
export const useInfiniteRecipes = (
tenantId: string,
filters: Omit<RecipeSearchParams, 'offset'> = {},
options?: Omit<UseInfiniteQueryOptions<RecipeResponse[], ApiError>, 'queryKey' | 'queryFn' | 'getNextPageParam'>
) => {
return useInfiniteQuery<RecipeResponse[], ApiError>({
queryKey: recipesKeys.list(filters),
queryKey: recipesKeys.list(tenantId, filters),
queryFn: ({ pageParam = 0 }) =>
recipesService.searchRecipes({ ...filters, offset: pageParam }),
recipesService.searchRecipes(tenantId, { ...filters, offset: pageParam }),
getNextPageParam: (lastPage, allPages) => {
const limit = filters.limit || 100;
if (lastPage.length < limit) return undefined;
return allPages.length * limit;
},
enabled: !!tenantId,
staleTime: 2 * 60 * 1000, // 2 minutes
...options,
});
@@ -108,12 +102,14 @@ export const useInfiniteRecipes = (
* Get recipe statistics
*/
export const useRecipeStatistics = (
tenantId: string,
options?: Omit<UseQueryOptions<RecipeStatisticsResponse, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<RecipeStatisticsResponse, ApiError>({
queryKey: recipesKeys.statistics(),
queryFn: () => recipesService.getRecipeStatistics(),
queryKey: recipesKeys.statistics(tenantId),
queryFn: () => recipesService.getRecipeStatistics(tenantId),
staleTime: 5 * 60 * 1000, // 5 minutes
enabled: !!tenantId,
...options,
});
};
@@ -122,12 +118,14 @@ export const useRecipeStatistics = (
* Get recipe categories
*/
export const useRecipeCategories = (
tenantId: string,
options?: Omit<UseQueryOptions<RecipeCategoriesResponse, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<RecipeCategoriesResponse, ApiError>({
queryKey: recipesKeys.categories(),
queryFn: () => recipesService.getRecipeCategories(),
queryKey: recipesKeys.categories(tenantId),
queryFn: () => recipesService.getRecipeCategories(tenantId),
staleTime: 10 * 60 * 1000, // 10 minutes
enabled: !!tenantId,
...options,
});
};
@@ -136,14 +134,15 @@ export const useRecipeCategories = (
* Check recipe feasibility
*/
export const useRecipeFeasibility = (
tenantId: string,
recipeId: string,
batchMultiplier: number = 1.0,
options?: Omit<UseQueryOptions<RecipeFeasibilityResponse, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<RecipeFeasibilityResponse, ApiError>({
queryKey: recipesKeys.feasibility(recipeId, batchMultiplier),
queryFn: () => recipesService.checkRecipeFeasibility(recipeId, batchMultiplier),
enabled: !!recipeId,
queryKey: recipesKeys.feasibility(tenantId, recipeId, batchMultiplier),
queryFn: () => recipesService.checkRecipeFeasibility(tenantId, recipeId, batchMultiplier),
enabled: !!(tenantId && recipeId),
staleTime: 1 * 60 * 1000, // 1 minute (fresher data for inventory checks)
...options,
});
@@ -155,21 +154,22 @@ export const useRecipeFeasibility = (
* Create a new recipe
*/
export const useCreateRecipe = (
tenantId: string,
options?: UseMutationOptions<RecipeResponse, ApiError, RecipeCreate>
) => {
const queryClient = useQueryClient();
return useMutation<RecipeResponse, ApiError, RecipeCreate>({
mutationFn: (recipeData: RecipeCreate) => recipesService.createRecipe(recipeData),
mutationFn: (recipeData: RecipeCreate) => recipesService.createRecipe(tenantId, recipeData),
onSuccess: (data) => {
// Add to lists cache
queryClient.invalidateQueries({ queryKey: recipesKeys.lists() });
queryClient.invalidateQueries({ queryKey: recipesKeys.lists(tenantId) });
// Set individual recipe cache
queryClient.setQueryData(recipesKeys.detail(data.id), data);
queryClient.setQueryData(recipesKeys.detail(tenantId, data.id), data);
// Invalidate statistics
queryClient.invalidateQueries({ queryKey: recipesKeys.statistics() });
queryClient.invalidateQueries({ queryKey: recipesKeys.statistics(tenantId) });
// Invalidate categories (new category might be added)
queryClient.invalidateQueries({ queryKey: recipesKeys.categories() });
queryClient.invalidateQueries({ queryKey: recipesKeys.categories(tenantId) });
},
...options,
});
@@ -179,21 +179,22 @@ export const useCreateRecipe = (
* Update an existing recipe
*/
export const useUpdateRecipe = (
tenantId: string,
options?: UseMutationOptions<RecipeResponse, ApiError, { id: string; data: RecipeUpdate }>
) => {
const queryClient = useQueryClient();
return useMutation<RecipeResponse, ApiError, { id: string; data: RecipeUpdate }>({
mutationFn: ({ id, data }) => recipesService.updateRecipe(id, data),
mutationFn: ({ id, data }) => recipesService.updateRecipe(tenantId, id, data),
onSuccess: (data) => {
// Update individual recipe cache
queryClient.setQueryData(recipesKeys.detail(data.id), data);
queryClient.setQueryData(recipesKeys.detail(tenantId, data.id), data);
// Invalidate lists (recipe might move in search results)
queryClient.invalidateQueries({ queryKey: recipesKeys.lists() });
queryClient.invalidateQueries({ queryKey: recipesKeys.lists(tenantId) });
// Invalidate statistics
queryClient.invalidateQueries({ queryKey: recipesKeys.statistics() });
queryClient.invalidateQueries({ queryKey: recipesKeys.statistics(tenantId) });
// Invalidate categories
queryClient.invalidateQueries({ queryKey: recipesKeys.categories() });
queryClient.invalidateQueries({ queryKey: recipesKeys.categories(tenantId) });
},
...options,
});
@@ -203,23 +204,22 @@ export const useUpdateRecipe = (
* Delete a recipe
*/
export const useDeleteRecipe = (
tenantId: string,
options?: UseMutationOptions<{ message: string }, ApiError, string>
) => {
const queryClient = useQueryClient();
return useMutation<{ message: string }, ApiError, string>({
mutationFn: (recipeId: string) => recipesService.deleteRecipe(recipeId),
mutationFn: (recipeId: string) => recipesService.deleteRecipe(tenantId, recipeId),
onSuccess: (_, recipeId) => {
// Remove from individual cache
queryClient.removeQueries({ queryKey: recipesKeys.detail(recipeId) });
queryClient.removeQueries({ queryKey: recipesKeys.detail(tenantId, recipeId) });
// Invalidate lists
queryClient.invalidateQueries({ queryKey: recipesKeys.lists() });
queryClient.invalidateQueries({ queryKey: recipesKeys.lists(tenantId) });
// Invalidate statistics
queryClient.invalidateQueries({ queryKey: recipesKeys.statistics() });
queryClient.invalidateQueries({ queryKey: recipesKeys.statistics(tenantId) });
// Invalidate categories
queryClient.invalidateQueries({ queryKey: recipesKeys.categories() });
// Invalidate production batches for this recipe
queryClient.invalidateQueries({ queryKey: recipesKeys.productionBatches.byRecipe(recipeId) });
queryClient.invalidateQueries({ queryKey: recipesKeys.categories(tenantId) });
},
...options,
});
@@ -229,19 +229,20 @@ export const useDeleteRecipe = (
* Duplicate a recipe
*/
export const useDuplicateRecipe = (
tenantId: string,
options?: UseMutationOptions<RecipeResponse, ApiError, { id: string; data: RecipeDuplicateRequest }>
) => {
const queryClient = useQueryClient();
return useMutation<RecipeResponse, ApiError, { id: string; data: RecipeDuplicateRequest }>({
mutationFn: ({ id, data }) => recipesService.duplicateRecipe(id, data),
mutationFn: ({ id, data }) => recipesService.duplicateRecipe(tenantId, id, data),
onSuccess: (data) => {
// Add to lists cache
queryClient.invalidateQueries({ queryKey: recipesKeys.lists() });
queryClient.invalidateQueries({ queryKey: recipesKeys.lists(tenantId) });
// Set individual recipe cache
queryClient.setQueryData(recipesKeys.detail(data.id), data);
queryClient.setQueryData(recipesKeys.detail(tenantId, data.id), data);
// Invalidate statistics
queryClient.invalidateQueries({ queryKey: recipesKeys.statistics() });
queryClient.invalidateQueries({ queryKey: recipesKeys.statistics(tenantId) });
},
...options,
});
@@ -251,227 +252,20 @@ export const useDuplicateRecipe = (
* Activate a recipe
*/
export const useActivateRecipe = (
tenantId: string,
options?: UseMutationOptions<RecipeResponse, ApiError, string>
) => {
const queryClient = useQueryClient();
return useMutation<RecipeResponse, ApiError, string>({
mutationFn: (recipeId: string) => recipesService.activateRecipe(recipeId),
mutationFn: (recipeId: string) => recipesService.activateRecipe(tenantId, recipeId),
onSuccess: (data) => {
// Update individual recipe cache
queryClient.setQueryData(recipesKeys.detail(data.id), data);
queryClient.setQueryData(recipesKeys.detail(tenantId, data.id), data);
// Invalidate lists
queryClient.invalidateQueries({ queryKey: recipesKeys.lists() });
queryClient.invalidateQueries({ queryKey: recipesKeys.lists(tenantId) });
// Invalidate statistics
queryClient.invalidateQueries({ queryKey: recipesKeys.statistics() });
},
...options,
});
};
// Production Batch Queries
/**
* Get production batch by ID (recipe-specific)
*/
export const useRecipeProductionBatch = (
batchId: string,
options?: Omit<UseQueryOptions<ProductionBatchResponse, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<ProductionBatchResponse, ApiError>({
queryKey: recipesKeys.productionBatches.detail(batchId),
queryFn: () => recipesService.getProductionBatch(batchId),
enabled: !!batchId,
staleTime: 2 * 60 * 1000, // 2 minutes
...options,
});
};
/**
* Get production batches with filters (recipe-specific)
*/
export const useRecipeProductionBatches = (
filters: {
recipe_id?: string;
status?: string;
start_date?: string;
end_date?: string;
limit?: number;
offset?: number;
} = {},
options?: Omit<UseQueryOptions<ProductionBatchResponse[], ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<ProductionBatchResponse[], ApiError>({
queryKey: recipesKeys.productionBatches.list(filters),
queryFn: () => recipesService.getProductionBatches(filters),
staleTime: 1 * 60 * 1000, // 1 minute
...options,
});
};
/**
* Get production batches for a specific recipe
*/
export const useRecipeProductionBatchesByRecipe = (
recipeId: string,
options?: Omit<UseQueryOptions<ProductionBatchResponse[], ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<ProductionBatchResponse[], ApiError>({
queryKey: recipesKeys.productionBatches.byRecipe(recipeId),
queryFn: () => recipesService.getRecipeProductionBatches(recipeId),
enabled: !!recipeId,
staleTime: 1 * 60 * 1000, // 1 minute
...options,
});
};
// Production Batch Mutations
/**
* Create a production batch for recipe
*/
export const useCreateRecipeProductionBatch = (
options?: UseMutationOptions<ProductionBatchResponse, ApiError, ProductionBatchCreate>
) => {
const queryClient = useQueryClient();
return useMutation<ProductionBatchResponse, ApiError, ProductionBatchCreate>({
mutationFn: (batchData: ProductionBatchCreate) => recipesService.createProductionBatch(batchData),
onSuccess: (data) => {
// Set individual batch cache
queryClient.setQueryData(recipesKeys.productionBatches.detail(data.id), data);
// Invalidate batch lists
queryClient.invalidateQueries({ queryKey: recipesKeys.productionBatches.lists() });
// Invalidate recipe-specific batches
queryClient.invalidateQueries({ queryKey: recipesKeys.productionBatches.byRecipe(data.recipe_id) });
},
...options,
});
};
/**
* Update a production batch for recipe
*/
export const useUpdateRecipeProductionBatch = (
options?: UseMutationOptions<ProductionBatchResponse, ApiError, { id: string; data: ProductionBatchUpdate }>
) => {
const queryClient = useQueryClient();
return useMutation<ProductionBatchResponse, ApiError, { id: string; data: ProductionBatchUpdate }>({
mutationFn: ({ id, data }) => recipesService.updateProductionBatch(id, data),
onSuccess: (data) => {
// Update individual batch cache
queryClient.setQueryData(recipesKeys.productionBatches.detail(data.id), data);
// Invalidate batch lists
queryClient.invalidateQueries({ queryKey: recipesKeys.productionBatches.lists() });
// Invalidate recipe-specific batches
queryClient.invalidateQueries({ queryKey: recipesKeys.productionBatches.byRecipe(data.recipe_id) });
},
...options,
});
};
/**
* Delete a production batch for recipe
*/
export const useDeleteRecipeProductionBatch = (
options?: UseMutationOptions<{ message: string }, ApiError, string>
) => {
const queryClient = useQueryClient();
return useMutation<{ message: string }, ApiError, string>({
mutationFn: (batchId: string) => recipesService.deleteProductionBatch(batchId),
onSuccess: (_, batchId) => {
// Remove from individual cache
queryClient.removeQueries({ queryKey: recipesKeys.productionBatches.detail(batchId) });
// Invalidate batch lists
queryClient.invalidateQueries({ queryKey: recipesKeys.productionBatches.lists() });
},
...options,
});
};
/**
* Start a production batch for recipe
*/
export const useStartRecipeProductionBatch = (
options?: UseMutationOptions<ProductionBatchResponse, ApiError, string>
) => {
const queryClient = useQueryClient();
return useMutation<ProductionBatchResponse, ApiError, string>({
mutationFn: (batchId: string) => recipesService.startProductionBatch(batchId),
onSuccess: (data) => {
// Update individual batch cache
queryClient.setQueryData(recipesKeys.productionBatches.detail(data.id), data);
// Invalidate batch lists
queryClient.invalidateQueries({ queryKey: recipesKeys.productionBatches.lists() });
// Invalidate recipe-specific batches
queryClient.invalidateQueries({ queryKey: recipesKeys.productionBatches.byRecipe(data.recipe_id) });
},
...options,
});
};
/**
* Complete a production batch for recipe
*/
export const useCompleteRecipeProductionBatch = (
options?: UseMutationOptions<ProductionBatchResponse, ApiError, {
id: string;
data: {
actual_quantity?: number;
quality_score?: number;
quality_notes?: string;
waste_quantity?: number;
waste_reason?: string;
}
}>
) => {
const queryClient = useQueryClient();
return useMutation<ProductionBatchResponse, ApiError, {
id: string;
data: {
actual_quantity?: number;
quality_score?: number;
quality_notes?: string;
waste_quantity?: number;
waste_reason?: string;
}
}>({
mutationFn: ({ id, data }) => recipesService.completeProductionBatch(id, data),
onSuccess: (data) => {
// Update individual batch cache
queryClient.setQueryData(recipesKeys.productionBatches.detail(data.id), data);
// Invalidate batch lists
queryClient.invalidateQueries({ queryKey: recipesKeys.productionBatches.lists() });
// Invalidate recipe-specific batches
queryClient.invalidateQueries({ queryKey: recipesKeys.productionBatches.byRecipe(data.recipe_id) });
// Invalidate inventory queries (production affects inventory)
queryClient.invalidateQueries({ queryKey: ['inventory'] });
},
...options,
});
};
/**
* Cancel a production batch for recipe
*/
export const useCancelRecipeProductionBatch = (
options?: UseMutationOptions<ProductionBatchResponse, ApiError, { id: string; reason?: string }>
) => {
const queryClient = useQueryClient();
return useMutation<ProductionBatchResponse, ApiError, { id: string; reason?: string }>({
mutationFn: ({ id, reason }) => recipesService.cancelProductionBatch(id, reason),
onSuccess: (data) => {
// Update individual batch cache
queryClient.setQueryData(recipesKeys.productionBatches.detail(data.id), data);
// Invalidate batch lists
queryClient.invalidateQueries({ queryKey: recipesKeys.productionBatches.lists() });
// Invalidate recipe-specific batches
queryClient.invalidateQueries({ queryKey: recipesKeys.productionBatches.byRecipe(data.recipe_id) });
queryClient.invalidateQueries({ queryKey: recipesKeys.statistics(tenantId) });
},
...options,
});

View File

@@ -728,35 +728,7 @@ export {
useDeleteRecipe,
useDuplicateRecipe,
useActivateRecipe,
useRecipeProductionBatch,
useRecipeProductionBatches,
useRecipeProductionBatchesByRecipe,
useCreateRecipeProductionBatch,
useUpdateRecipeProductionBatch,
useDeleteRecipeProductionBatch,
useStartRecipeProductionBatch,
useCompleteRecipeProductionBatch,
useCancelRecipeProductionBatch,
recipesKeys,
} from './hooks/recipes';
// Query Key Factories (for advanced usage)
export {
authKeys,
userKeys,
onboardingKeys,
tenantKeys,
salesKeys,
inventoryKeys,
classificationKeys,
inventoryDashboardKeys,
foodSafetyKeys,
trainingKeys,
alertProcessorKeys,
suppliersKeys,
ordersKeys,
dataImportKeys,
forecastingKeys,
productionKeys,
posKeys,
};
// Note: All query key factories are already exported in their respective hook sections above

View File

@@ -1,6 +1,7 @@
/**
* Recipes service - API communication layer
* Handles all recipe-related HTTP requests using the API client
* Mirrors backend endpoints exactly for tenant-dependent operations
*/
import { apiClient } from '../client/apiClient';
@@ -13,52 +14,66 @@ import type {
RecipeFeasibilityResponse,
RecipeStatisticsResponse,
RecipeCategoriesResponse,
ProductionBatchResponse,
ProductionBatchCreate,
ProductionBatchUpdate,
} from '../types/recipes';
/**
* Recipes API service
* All methods return promises that resolve to the response data
* Follows tenant-dependent routing pattern: /tenants/{tenant_id}/recipes
*/
export class RecipesService {
private readonly baseUrl = '/recipes';
/**
* Get tenant-scoped base URL for recipes
*/
private getBaseUrl(tenantId: string): string {
return `/tenants/${tenantId}/recipes`;
}
/**
* Create a new recipe
* POST /tenants/{tenant_id}/recipes
*/
async createRecipe(recipeData: RecipeCreate): Promise<RecipeResponse> {
return apiClient.post<RecipeResponse>(this.baseUrl, recipeData);
async createRecipe(tenantId: string, recipeData: RecipeCreate): Promise<RecipeResponse> {
const baseUrl = this.getBaseUrl(tenantId);
return apiClient.post<RecipeResponse>(baseUrl, recipeData);
}
/**
* Get recipe by ID with ingredients
* GET /tenants/{tenant_id}/recipes/{recipe_id}
*/
async getRecipe(recipeId: string): Promise<RecipeResponse> {
return apiClient.get<RecipeResponse>(`${this.baseUrl}/${recipeId}`);
async getRecipe(tenantId: string, recipeId: string): Promise<RecipeResponse> {
const baseUrl = this.getBaseUrl(tenantId);
return apiClient.get<RecipeResponse>(`${baseUrl}/${recipeId}`);
}
/**
* Update an existing recipe
* PUT /tenants/{tenant_id}/recipes/{recipe_id}
*/
async updateRecipe(recipeId: string, recipeData: RecipeUpdate): Promise<RecipeResponse> {
return apiClient.put<RecipeResponse>(`${this.baseUrl}/${recipeId}`, recipeData);
async updateRecipe(tenantId: string, recipeId: string, recipeData: RecipeUpdate): Promise<RecipeResponse> {
const baseUrl = this.getBaseUrl(tenantId);
return apiClient.put<RecipeResponse>(`${baseUrl}/${recipeId}`, recipeData);
}
/**
* Delete a recipe
* DELETE /tenants/{tenant_id}/recipes/{recipe_id}
*/
async deleteRecipe(recipeId: string): Promise<{ message: string }> {
return apiClient.delete<{ message: string }>(`${this.baseUrl}/${recipeId}`);
async deleteRecipe(tenantId: string, recipeId: string): Promise<{ message: string }> {
const baseUrl = this.getBaseUrl(tenantId);
return apiClient.delete<{ message: string }>(`${baseUrl}/${recipeId}`);
}
/**
* Search recipes with filters
* GET /tenants/{tenant_id}/recipes
*/
async searchRecipes(params: RecipeSearchParams = {}): Promise<RecipeResponse[]> {
async searchRecipes(tenantId: string, params: RecipeSearchParams = {}): Promise<RecipeResponse[]> {
const baseUrl = this.getBaseUrl(tenantId);
const searchParams = new URLSearchParams();
// Add all non-empty parameters to the query string
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
searchParams.append(key, String(value));
@@ -66,144 +81,63 @@ export class RecipesService {
});
const queryString = searchParams.toString();
const url = queryString ? `${this.baseUrl}?${queryString}` : this.baseUrl;
const url = queryString ? `${baseUrl}?${queryString}` : baseUrl;
return apiClient.get<RecipeResponse[]>(url);
}
/**
* Get all recipes (shorthand for search without filters)
* GET /tenants/{tenant_id}/recipes
*/
async getRecipes(): Promise<RecipeResponse[]> {
return this.searchRecipes();
async getRecipes(tenantId: string): Promise<RecipeResponse[]> {
return this.searchRecipes(tenantId);
}
/**
* Duplicate an existing recipe
* POST /tenants/{tenant_id}/recipes/{recipe_id}/duplicate
*/
async duplicateRecipe(recipeId: string, duplicateData: RecipeDuplicateRequest): Promise<RecipeResponse> {
return apiClient.post<RecipeResponse>(`${this.baseUrl}/${recipeId}/duplicate`, duplicateData);
async duplicateRecipe(tenantId: string, recipeId: string, duplicateData: RecipeDuplicateRequest): Promise<RecipeResponse> {
const baseUrl = this.getBaseUrl(tenantId);
return apiClient.post<RecipeResponse>(`${baseUrl}/${recipeId}/duplicate`, duplicateData);
}
/**
* Activate a recipe for production
* POST /tenants/{tenant_id}/recipes/{recipe_id}/activate
*/
async activateRecipe(recipeId: string): Promise<RecipeResponse> {
return apiClient.post<RecipeResponse>(`${this.baseUrl}/${recipeId}/activate`);
async activateRecipe(tenantId: string, recipeId: string): Promise<RecipeResponse> {
const baseUrl = this.getBaseUrl(tenantId);
return apiClient.post<RecipeResponse>(`${baseUrl}/${recipeId}/activate`);
}
/**
* Check if recipe can be produced with current inventory
* GET /tenants/{tenant_id}/recipes/{recipe_id}/feasibility
*/
async checkRecipeFeasibility(recipeId: string, batchMultiplier: number = 1.0): Promise<RecipeFeasibilityResponse> {
async checkRecipeFeasibility(tenantId: string, recipeId: string, batchMultiplier: number = 1.0): Promise<RecipeFeasibilityResponse> {
const baseUrl = this.getBaseUrl(tenantId);
const params = new URLSearchParams({ batch_multiplier: String(batchMultiplier) });
return apiClient.get<RecipeFeasibilityResponse>(`${this.baseUrl}/${recipeId}/feasibility?${params}`);
return apiClient.get<RecipeFeasibilityResponse>(`${baseUrl}/${recipeId}/feasibility?${params}`);
}
/**
* Get recipe statistics for dashboard
* GET /tenants/{tenant_id}/recipes/statistics/dashboard
*/
async getRecipeStatistics(): Promise<RecipeStatisticsResponse> {
return apiClient.get<RecipeStatisticsResponse>(`${this.baseUrl}/statistics/dashboard`);
async getRecipeStatistics(tenantId: string): Promise<RecipeStatisticsResponse> {
const baseUrl = this.getBaseUrl(tenantId);
return apiClient.get<RecipeStatisticsResponse>(`${baseUrl}/statistics/dashboard`);
}
/**
* Get list of recipe categories used by tenant
* GET /tenants/{tenant_id}/recipes/categories/list
*/
async getRecipeCategories(): Promise<RecipeCategoriesResponse> {
return apiClient.get<RecipeCategoriesResponse>(`${this.baseUrl}/categories/list`);
}
// Production Batch Methods
/**
* Create a production batch for a recipe
*/
async createProductionBatch(batchData: ProductionBatchCreate): Promise<ProductionBatchResponse> {
return apiClient.post<ProductionBatchResponse>('/production/batches', batchData);
}
/**
* Get production batch by ID
*/
async getProductionBatch(batchId: string): Promise<ProductionBatchResponse> {
return apiClient.get<ProductionBatchResponse>(`/production/batches/${batchId}`);
}
/**
* Update production batch
*/
async updateProductionBatch(batchId: string, batchData: ProductionBatchUpdate): Promise<ProductionBatchResponse> {
return apiClient.put<ProductionBatchResponse>(`/production/batches/${batchId}`, batchData);
}
/**
* Delete production batch
*/
async deleteProductionBatch(batchId: string): Promise<{ message: string }> {
return apiClient.delete<{ message: string }>(`/production/batches/${batchId}`);
}
/**
* Get production batches for a recipe
*/
async getRecipeProductionBatches(recipeId: string): Promise<ProductionBatchResponse[]> {
return apiClient.get<ProductionBatchResponse[]>(`/production/batches?recipe_id=${recipeId}`);
}
/**
* Get all production batches with optional filtering
*/
async getProductionBatches(params: {
recipe_id?: string;
status?: string;
start_date?: string;
end_date?: string;
limit?: number;
offset?: number;
} = {}): Promise<ProductionBatchResponse[]> {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
searchParams.append(key, String(value));
}
});
const queryString = searchParams.toString();
const url = queryString ? `/production/batches?${queryString}` : '/production/batches';
return apiClient.get<ProductionBatchResponse[]>(url);
}
/**
* Start production batch
*/
async startProductionBatch(batchId: string): Promise<ProductionBatchResponse> {
return apiClient.post<ProductionBatchResponse>(`/production/batches/${batchId}/start`);
}
/**
* Complete production batch
*/
async completeProductionBatch(
batchId: string,
completionData: {
actual_quantity?: number;
quality_score?: number;
quality_notes?: string;
waste_quantity?: number;
waste_reason?: string;
}
): Promise<ProductionBatchResponse> {
return apiClient.post<ProductionBatchResponse>(`/production/batches/${batchId}/complete`, completionData);
}
/**
* Cancel production batch
*/
async cancelProductionBatch(batchId: string, reason?: string): Promise<ProductionBatchResponse> {
return apiClient.post<ProductionBatchResponse>(`/production/batches/${batchId}/cancel`, { reason });
async getRecipeCategories(tenantId: string): Promise<RecipeCategoriesResponse> {
const baseUrl = this.getBaseUrl(tenantId);
return apiClient.get<RecipeCategoriesResponse>(`${baseUrl}/categories/list`);
}
}