Create the forntend API definitions for recipe service
This commit is contained in:
478
frontend/src/api/hooks/recipes.ts
Normal file
478
frontend/src/api/hooks/recipes.ts
Normal file
@@ -0,0 +1,478 @@
|
||||
/**
|
||||
* Recipes React Query hooks
|
||||
* Data fetching and caching layer for recipe management
|
||||
*/
|
||||
|
||||
import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
UseQueryOptions,
|
||||
UseMutationOptions,
|
||||
useInfiniteQuery,
|
||||
UseInfiniteQueryOptions
|
||||
} from '@tanstack/react-query';
|
||||
import { recipesService } from '../services/recipes';
|
||||
import { ApiError } from '../client/apiClient';
|
||||
import type {
|
||||
RecipeResponse,
|
||||
RecipeCreate,
|
||||
RecipeUpdate,
|
||||
RecipeSearchParams,
|
||||
RecipeDuplicateRequest,
|
||||
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,
|
||||
}
|
||||
} as const;
|
||||
|
||||
// Recipe Queries
|
||||
|
||||
/**
|
||||
* Fetch a single recipe by ID
|
||||
*/
|
||||
export const useRecipe = (
|
||||
recipeId: string,
|
||||
options?: Omit<UseQueryOptions<RecipeResponse, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<RecipeResponse, ApiError>({
|
||||
queryKey: recipesKeys.detail(recipeId),
|
||||
queryFn: () => recipesService.getRecipe(recipeId),
|
||||
enabled: !!recipeId,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Search/list recipes with filters
|
||||
*/
|
||||
export const useRecipes = (
|
||||
filters: RecipeSearchParams = {},
|
||||
options?: Omit<UseQueryOptions<RecipeResponse[], ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<RecipeResponse[], ApiError>({
|
||||
queryKey: recipesKeys.list(filters),
|
||||
queryFn: () => recipesService.searchRecipes(filters),
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Infinite query for recipes (pagination)
|
||||
*/
|
||||
export const useInfiniteRecipes = (
|
||||
filters: Omit<RecipeSearchParams, 'offset'> = {},
|
||||
options?: Omit<UseInfiniteQueryOptions<RecipeResponse[], ApiError>, 'queryKey' | 'queryFn' | 'getNextPageParam'>
|
||||
) => {
|
||||
return useInfiniteQuery<RecipeResponse[], ApiError>({
|
||||
queryKey: recipesKeys.list(filters),
|
||||
queryFn: ({ pageParam = 0 }) =>
|
||||
recipesService.searchRecipes({ ...filters, offset: pageParam }),
|
||||
getNextPageParam: (lastPage, allPages) => {
|
||||
const limit = filters.limit || 100;
|
||||
if (lastPage.length < limit) return undefined;
|
||||
return allPages.length * limit;
|
||||
},
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get recipe statistics
|
||||
*/
|
||||
export const useRecipeStatistics = (
|
||||
options?: Omit<UseQueryOptions<RecipeStatisticsResponse, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<RecipeStatisticsResponse, ApiError>({
|
||||
queryKey: recipesKeys.statistics(),
|
||||
queryFn: () => recipesService.getRecipeStatistics(),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get recipe categories
|
||||
*/
|
||||
export const useRecipeCategories = (
|
||||
options?: Omit<UseQueryOptions<RecipeCategoriesResponse, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<RecipeCategoriesResponse, ApiError>({
|
||||
queryKey: recipesKeys.categories(),
|
||||
queryFn: () => recipesService.getRecipeCategories(),
|
||||
staleTime: 10 * 60 * 1000, // 10 minutes
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Check recipe feasibility
|
||||
*/
|
||||
export const useRecipeFeasibility = (
|
||||
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,
|
||||
staleTime: 1 * 60 * 1000, // 1 minute (fresher data for inventory checks)
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
// Recipe Mutations
|
||||
|
||||
/**
|
||||
* Create a new recipe
|
||||
*/
|
||||
export const useCreateRecipe = (
|
||||
options?: UseMutationOptions<RecipeResponse, ApiError, RecipeCreate>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<RecipeResponse, ApiError, RecipeCreate>({
|
||||
mutationFn: (recipeData: RecipeCreate) => recipesService.createRecipe(recipeData),
|
||||
onSuccess: (data) => {
|
||||
// Add to lists cache
|
||||
queryClient.invalidateQueries({ queryKey: recipesKeys.lists() });
|
||||
// Set individual recipe cache
|
||||
queryClient.setQueryData(recipesKeys.detail(data.id), data);
|
||||
// Invalidate statistics
|
||||
queryClient.invalidateQueries({ queryKey: recipesKeys.statistics() });
|
||||
// Invalidate categories (new category might be added)
|
||||
queryClient.invalidateQueries({ queryKey: recipesKeys.categories() });
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Update an existing recipe
|
||||
*/
|
||||
export const useUpdateRecipe = (
|
||||
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),
|
||||
onSuccess: (data) => {
|
||||
// Update individual recipe cache
|
||||
queryClient.setQueryData(recipesKeys.detail(data.id), data);
|
||||
// Invalidate lists (recipe might move in search results)
|
||||
queryClient.invalidateQueries({ queryKey: recipesKeys.lists() });
|
||||
// Invalidate statistics
|
||||
queryClient.invalidateQueries({ queryKey: recipesKeys.statistics() });
|
||||
// Invalidate categories
|
||||
queryClient.invalidateQueries({ queryKey: recipesKeys.categories() });
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a recipe
|
||||
*/
|
||||
export const useDeleteRecipe = (
|
||||
options?: UseMutationOptions<{ message: string }, ApiError, string>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<{ message: string }, ApiError, string>({
|
||||
mutationFn: (recipeId: string) => recipesService.deleteRecipe(recipeId),
|
||||
onSuccess: (_, recipeId) => {
|
||||
// Remove from individual cache
|
||||
queryClient.removeQueries({ queryKey: recipesKeys.detail(recipeId) });
|
||||
// Invalidate lists
|
||||
queryClient.invalidateQueries({ queryKey: recipesKeys.lists() });
|
||||
// Invalidate statistics
|
||||
queryClient.invalidateQueries({ queryKey: recipesKeys.statistics() });
|
||||
// Invalidate categories
|
||||
queryClient.invalidateQueries({ queryKey: recipesKeys.categories() });
|
||||
// Invalidate production batches for this recipe
|
||||
queryClient.invalidateQueries({ queryKey: recipesKeys.productionBatches.byRecipe(recipeId) });
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Duplicate a recipe
|
||||
*/
|
||||
export const useDuplicateRecipe = (
|
||||
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),
|
||||
onSuccess: (data) => {
|
||||
// Add to lists cache
|
||||
queryClient.invalidateQueries({ queryKey: recipesKeys.lists() });
|
||||
// Set individual recipe cache
|
||||
queryClient.setQueryData(recipesKeys.detail(data.id), data);
|
||||
// Invalidate statistics
|
||||
queryClient.invalidateQueries({ queryKey: recipesKeys.statistics() });
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Activate a recipe
|
||||
*/
|
||||
export const useActivateRecipe = (
|
||||
options?: UseMutationOptions<RecipeResponse, ApiError, string>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<RecipeResponse, ApiError, string>({
|
||||
mutationFn: (recipeId: string) => recipesService.activateRecipe(recipeId),
|
||||
onSuccess: (data) => {
|
||||
// Update individual recipe cache
|
||||
queryClient.setQueryData(recipesKeys.detail(data.id), data);
|
||||
// Invalidate lists
|
||||
queryClient.invalidateQueries({ queryKey: recipesKeys.lists() });
|
||||
// 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) });
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
@@ -28,6 +28,7 @@ export { OrdersService } from './services/orders';
|
||||
export { forecastingService } from './services/forecasting';
|
||||
export { productionService } from './services/production';
|
||||
export { posService } from './services/pos';
|
||||
export { recipesService } from './services/recipes';
|
||||
|
||||
// Types - Auth
|
||||
export type {
|
||||
@@ -374,6 +375,31 @@ export type {
|
||||
POSEnvironment,
|
||||
} from './types/pos';
|
||||
|
||||
// Types - Recipes
|
||||
export type {
|
||||
RecipeStatus,
|
||||
MeasurementUnit,
|
||||
ProductionStatus as RecipeProductionStatus,
|
||||
ProductionPriority as RecipeProductionPriority,
|
||||
RecipeIngredientCreate,
|
||||
RecipeIngredientUpdate,
|
||||
RecipeIngredientResponse,
|
||||
RecipeCreate,
|
||||
RecipeUpdate,
|
||||
RecipeResponse,
|
||||
RecipeSearchRequest,
|
||||
RecipeSearchParams,
|
||||
RecipeDuplicateRequest,
|
||||
RecipeFeasibilityResponse,
|
||||
RecipeStatisticsResponse,
|
||||
RecipeCategoriesResponse,
|
||||
ProductionBatchCreate as RecipeProductionBatchCreate,
|
||||
ProductionBatchUpdate as RecipeProductionBatchUpdate,
|
||||
ProductionBatchResponse as RecipeProductionBatchResponse,
|
||||
RecipeFormData,
|
||||
RecipeUpdateFormData,
|
||||
} from './types/recipes';
|
||||
|
||||
// Hooks - Auth
|
||||
export {
|
||||
useAuthProfile,
|
||||
@@ -689,12 +715,37 @@ export {
|
||||
posKeys,
|
||||
} from './hooks/pos';
|
||||
|
||||
// Hooks - Recipes
|
||||
export {
|
||||
useRecipe,
|
||||
useRecipes,
|
||||
useInfiniteRecipes,
|
||||
useRecipeStatistics,
|
||||
useRecipeCategories,
|
||||
useRecipeFeasibility,
|
||||
useCreateRecipe,
|
||||
useUpdateRecipe,
|
||||
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,
|
||||
export {
|
||||
authKeys,
|
||||
userKeys,
|
||||
onboardingKeys,
|
||||
tenantKeys,
|
||||
salesKeys,
|
||||
inventoryKeys,
|
||||
classificationKeys,
|
||||
|
||||
212
frontend/src/api/services/recipes.ts
Normal file
212
frontend/src/api/services/recipes.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Recipes service - API communication layer
|
||||
* Handles all recipe-related HTTP requests using the API client
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client/apiClient';
|
||||
import type {
|
||||
RecipeResponse,
|
||||
RecipeCreate,
|
||||
RecipeUpdate,
|
||||
RecipeSearchParams,
|
||||
RecipeDuplicateRequest,
|
||||
RecipeFeasibilityResponse,
|
||||
RecipeStatisticsResponse,
|
||||
RecipeCategoriesResponse,
|
||||
ProductionBatchResponse,
|
||||
ProductionBatchCreate,
|
||||
ProductionBatchUpdate,
|
||||
} from '../types/recipes';
|
||||
|
||||
/**
|
||||
* Recipes API service
|
||||
* All methods return promises that resolve to the response data
|
||||
*/
|
||||
export class RecipesService {
|
||||
private readonly baseUrl = '/recipes';
|
||||
|
||||
/**
|
||||
* Create a new recipe
|
||||
*/
|
||||
async createRecipe(recipeData: RecipeCreate): Promise<RecipeResponse> {
|
||||
return apiClient.post<RecipeResponse>(this.baseUrl, recipeData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recipe by ID with ingredients
|
||||
*/
|
||||
async getRecipe(recipeId: string): Promise<RecipeResponse> {
|
||||
return apiClient.get<RecipeResponse>(`${this.baseUrl}/${recipeId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing recipe
|
||||
*/
|
||||
async updateRecipe(recipeId: string, recipeData: RecipeUpdate): Promise<RecipeResponse> {
|
||||
return apiClient.put<RecipeResponse>(`${this.baseUrl}/${recipeId}`, recipeData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a recipe
|
||||
*/
|
||||
async deleteRecipe(recipeId: string): Promise<{ message: string }> {
|
||||
return apiClient.delete<{ message: string }>(`${this.baseUrl}/${recipeId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search recipes with filters
|
||||
*/
|
||||
async searchRecipes(params: RecipeSearchParams = {}): Promise<RecipeResponse[]> {
|
||||
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 ? `${this.baseUrl}?${queryString}` : this.baseUrl;
|
||||
|
||||
return apiClient.get<RecipeResponse[]>(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all recipes (shorthand for search without filters)
|
||||
*/
|
||||
async getRecipes(): Promise<RecipeResponse[]> {
|
||||
return this.searchRecipes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate an existing recipe
|
||||
*/
|
||||
async duplicateRecipe(recipeId: string, duplicateData: RecipeDuplicateRequest): Promise<RecipeResponse> {
|
||||
return apiClient.post<RecipeResponse>(`${this.baseUrl}/${recipeId}/duplicate`, duplicateData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate a recipe for production
|
||||
*/
|
||||
async activateRecipe(recipeId: string): Promise<RecipeResponse> {
|
||||
return apiClient.post<RecipeResponse>(`${this.baseUrl}/${recipeId}/activate`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if recipe can be produced with current inventory
|
||||
*/
|
||||
async checkRecipeFeasibility(recipeId: string, batchMultiplier: number = 1.0): Promise<RecipeFeasibilityResponse> {
|
||||
const params = new URLSearchParams({ batch_multiplier: String(batchMultiplier) });
|
||||
return apiClient.get<RecipeFeasibilityResponse>(`${this.baseUrl}/${recipeId}/feasibility?${params}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recipe statistics for dashboard
|
||||
*/
|
||||
async getRecipeStatistics(): Promise<RecipeStatisticsResponse> {
|
||||
return apiClient.get<RecipeStatisticsResponse>(`${this.baseUrl}/statistics/dashboard`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of recipe categories used by tenant
|
||||
*/
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export singleton instance
|
||||
export const recipesService = new RecipesService();
|
||||
export default recipesService;
|
||||
382
frontend/src/api/types/recipes.ts
Normal file
382
frontend/src/api/types/recipes.ts
Normal file
@@ -0,0 +1,382 @@
|
||||
/**
|
||||
* TypeScript types for Recipes service
|
||||
* Generated based on backend schemas in services/recipes/app/schemas/recipes.py
|
||||
*/
|
||||
|
||||
export enum RecipeStatus {
|
||||
DRAFT = 'draft',
|
||||
ACTIVE = 'active',
|
||||
TESTING = 'testing',
|
||||
ARCHIVED = 'archived',
|
||||
DISCONTINUED = 'discontinued'
|
||||
}
|
||||
|
||||
export enum MeasurementUnit {
|
||||
GRAMS = 'g',
|
||||
KILOGRAMS = 'kg',
|
||||
MILLILITERS = 'ml',
|
||||
LITERS = 'l',
|
||||
CUPS = 'cups',
|
||||
TABLESPOONS = 'tbsp',
|
||||
TEASPOONS = 'tsp',
|
||||
UNITS = 'units',
|
||||
PIECES = 'pieces',
|
||||
PERCENTAGE = '%'
|
||||
}
|
||||
|
||||
export enum ProductionStatus {
|
||||
PLANNED = 'planned',
|
||||
IN_PROGRESS = 'in_progress',
|
||||
COMPLETED = 'completed',
|
||||
FAILED = 'failed',
|
||||
CANCELLED = 'cancelled'
|
||||
}
|
||||
|
||||
export enum ProductionPriority {
|
||||
LOW = 'low',
|
||||
NORMAL = 'normal',
|
||||
HIGH = 'high',
|
||||
URGENT = 'urgent'
|
||||
}
|
||||
|
||||
export interface RecipeIngredientCreate {
|
||||
ingredient_id: string;
|
||||
quantity: number;
|
||||
unit: MeasurementUnit;
|
||||
alternative_quantity?: number | null;
|
||||
alternative_unit?: MeasurementUnit | null;
|
||||
preparation_method?: string | null;
|
||||
ingredient_notes?: string | null;
|
||||
is_optional: boolean;
|
||||
ingredient_order: number;
|
||||
ingredient_group?: string | null;
|
||||
substitution_options?: Record<string, any> | null;
|
||||
substitution_ratio?: number | null;
|
||||
}
|
||||
|
||||
export interface RecipeIngredientUpdate {
|
||||
ingredient_id?: string | null;
|
||||
quantity?: number | null;
|
||||
unit?: MeasurementUnit | null;
|
||||
alternative_quantity?: number | null;
|
||||
alternative_unit?: MeasurementUnit | null;
|
||||
preparation_method?: string | null;
|
||||
ingredient_notes?: string | null;
|
||||
is_optional?: boolean | null;
|
||||
ingredient_order?: number | null;
|
||||
ingredient_group?: string | null;
|
||||
substitution_options?: Record<string, any> | null;
|
||||
substitution_ratio?: number | null;
|
||||
}
|
||||
|
||||
export interface RecipeIngredientResponse {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
recipe_id: string;
|
||||
ingredient_id: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
quantity_in_base_unit?: number | null;
|
||||
alternative_quantity?: number | null;
|
||||
alternative_unit?: string | null;
|
||||
preparation_method?: string | null;
|
||||
ingredient_notes?: string | null;
|
||||
is_optional: boolean;
|
||||
ingredient_order: number;
|
||||
ingredient_group?: string | null;
|
||||
substitution_options?: Record<string, any> | null;
|
||||
substitution_ratio?: number | null;
|
||||
unit_cost?: number | null;
|
||||
total_cost?: number | null;
|
||||
cost_updated_at?: string | null;
|
||||
}
|
||||
|
||||
export interface RecipeCreate {
|
||||
name: string;
|
||||
recipe_code?: string | null;
|
||||
version?: string;
|
||||
finished_product_id: string;
|
||||
description?: string | null;
|
||||
category?: string | null;
|
||||
cuisine_type?: string | null;
|
||||
difficulty_level?: number;
|
||||
yield_quantity: number;
|
||||
yield_unit: MeasurementUnit;
|
||||
prep_time_minutes?: number | null;
|
||||
cook_time_minutes?: number | null;
|
||||
total_time_minutes?: number | null;
|
||||
rest_time_minutes?: number | null;
|
||||
instructions?: Record<string, any> | null;
|
||||
preparation_notes?: string | null;
|
||||
storage_instructions?: string | null;
|
||||
quality_standards?: string | null;
|
||||
serves_count?: number | null;
|
||||
nutritional_info?: Record<string, any> | null;
|
||||
allergen_info?: Record<string, any> | null;
|
||||
dietary_tags?: Record<string, any> | null;
|
||||
batch_size_multiplier?: number;
|
||||
minimum_batch_size?: number | null;
|
||||
maximum_batch_size?: number | null;
|
||||
optimal_production_temperature?: number | null;
|
||||
optimal_humidity?: number | null;
|
||||
quality_check_points?: Record<string, any> | null;
|
||||
common_issues?: Record<string, any> | null;
|
||||
is_seasonal?: boolean;
|
||||
season_start_month?: number | null;
|
||||
season_end_month?: number | null;
|
||||
is_signature_item?: boolean;
|
||||
target_margin_percentage?: number | null;
|
||||
ingredients: RecipeIngredientCreate[];
|
||||
}
|
||||
|
||||
export interface RecipeUpdate {
|
||||
name?: string | null;
|
||||
recipe_code?: string | null;
|
||||
version?: string | null;
|
||||
description?: string | null;
|
||||
category?: string | null;
|
||||
cuisine_type?: string | null;
|
||||
difficulty_level?: number | null;
|
||||
yield_quantity?: number | null;
|
||||
yield_unit?: MeasurementUnit | null;
|
||||
prep_time_minutes?: number | null;
|
||||
cook_time_minutes?: number | null;
|
||||
total_time_minutes?: number | null;
|
||||
rest_time_minutes?: number | null;
|
||||
instructions?: Record<string, any> | null;
|
||||
preparation_notes?: string | null;
|
||||
storage_instructions?: string | null;
|
||||
quality_standards?: string | null;
|
||||
serves_count?: number | null;
|
||||
nutritional_info?: Record<string, any> | null;
|
||||
allergen_info?: Record<string, any> | null;
|
||||
dietary_tags?: Record<string, any> | null;
|
||||
batch_size_multiplier?: number | null;
|
||||
minimum_batch_size?: number | null;
|
||||
maximum_batch_size?: number | null;
|
||||
optimal_production_temperature?: number | null;
|
||||
optimal_humidity?: number | null;
|
||||
quality_check_points?: Record<string, any> | null;
|
||||
common_issues?: Record<string, any> | null;
|
||||
status?: RecipeStatus | null;
|
||||
is_seasonal?: boolean | null;
|
||||
season_start_month?: number | null;
|
||||
season_end_month?: number | null;
|
||||
is_signature_item?: boolean | null;
|
||||
target_margin_percentage?: number | null;
|
||||
ingredients?: RecipeIngredientCreate[] | null;
|
||||
}
|
||||
|
||||
export interface RecipeResponse {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
name: string;
|
||||
recipe_code?: string | null;
|
||||
version: string;
|
||||
finished_product_id: string;
|
||||
description?: string | null;
|
||||
category?: string | null;
|
||||
cuisine_type?: string | null;
|
||||
difficulty_level: number;
|
||||
yield_quantity: number;
|
||||
yield_unit: string;
|
||||
prep_time_minutes?: number | null;
|
||||
cook_time_minutes?: number | null;
|
||||
total_time_minutes?: number | null;
|
||||
rest_time_minutes?: number | null;
|
||||
estimated_cost_per_unit?: number | null;
|
||||
last_calculated_cost?: number | null;
|
||||
cost_calculation_date?: string | null;
|
||||
target_margin_percentage?: number | null;
|
||||
suggested_selling_price?: number | null;
|
||||
instructions?: Record<string, any> | null;
|
||||
preparation_notes?: string | null;
|
||||
storage_instructions?: string | null;
|
||||
quality_standards?: string | null;
|
||||
serves_count?: number | null;
|
||||
nutritional_info?: Record<string, any> | null;
|
||||
allergen_info?: Record<string, any> | null;
|
||||
dietary_tags?: Record<string, any> | null;
|
||||
batch_size_multiplier: number;
|
||||
minimum_batch_size?: number | null;
|
||||
maximum_batch_size?: number | null;
|
||||
optimal_production_temperature?: number | null;
|
||||
optimal_humidity?: number | null;
|
||||
quality_check_points?: Record<string, any> | null;
|
||||
common_issues?: Record<string, any> | null;
|
||||
status: string;
|
||||
is_seasonal: boolean;
|
||||
season_start_month?: number | null;
|
||||
season_end_month?: number | null;
|
||||
is_signature_item: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_by?: string | null;
|
||||
updated_by?: string | null;
|
||||
ingredients?: RecipeIngredientResponse[] | null;
|
||||
}
|
||||
|
||||
export interface RecipeSearchRequest {
|
||||
search_term?: string | null;
|
||||
status?: RecipeStatus | null;
|
||||
category?: string | null;
|
||||
is_seasonal?: boolean | null;
|
||||
is_signature?: boolean | null;
|
||||
difficulty_level?: number | null;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface RecipeSearchParams {
|
||||
search_term?: string;
|
||||
status?: string;
|
||||
category?: string;
|
||||
is_seasonal?: boolean;
|
||||
is_signature?: boolean;
|
||||
difficulty_level?: number;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface RecipeDuplicateRequest {
|
||||
new_name: string;
|
||||
}
|
||||
|
||||
export interface RecipeFeasibilityResponse {
|
||||
recipe_id: string;
|
||||
recipe_name: string;
|
||||
batch_multiplier: number;
|
||||
feasible: boolean;
|
||||
missing_ingredients: Array<Record<string, any>>;
|
||||
insufficient_ingredients: Array<Record<string, any>>;
|
||||
}
|
||||
|
||||
export interface RecipeStatisticsResponse {
|
||||
total_recipes: number;
|
||||
active_recipes: number;
|
||||
signature_recipes: number;
|
||||
seasonal_recipes: number;
|
||||
category_breakdown: Array<Record<string, any>>;
|
||||
}
|
||||
|
||||
export interface RecipeCategoriesResponse {
|
||||
categories: string[];
|
||||
}
|
||||
|
||||
// Production Batch Types
|
||||
export interface ProductionBatchCreate {
|
||||
recipe_id: string;
|
||||
batch_number: string;
|
||||
production_date: string;
|
||||
planned_start_time?: string | null;
|
||||
planned_end_time?: string | null;
|
||||
planned_quantity: number;
|
||||
batch_size_multiplier?: number;
|
||||
priority?: ProductionPriority;
|
||||
assigned_staff?: Array<Record<string, any>> | null;
|
||||
production_notes?: string | null;
|
||||
customer_order_reference?: string | null;
|
||||
pre_order_quantity?: number | null;
|
||||
shelf_quantity?: number | null;
|
||||
}
|
||||
|
||||
export interface ProductionBatchUpdate {
|
||||
batch_number?: string | null;
|
||||
production_date?: string | null;
|
||||
planned_start_time?: string | null;
|
||||
actual_start_time?: string | null;
|
||||
planned_end_time?: string | null;
|
||||
actual_end_time?: string | null;
|
||||
planned_quantity?: number | null;
|
||||
actual_quantity?: number | null;
|
||||
batch_size_multiplier?: number | null;
|
||||
status?: ProductionStatus | null;
|
||||
priority?: ProductionPriority | null;
|
||||
assigned_staff?: Array<Record<string, any>> | null;
|
||||
production_notes?: string | null;
|
||||
quality_score?: number | null;
|
||||
quality_notes?: string | null;
|
||||
defect_rate?: number | null;
|
||||
rework_required?: boolean | null;
|
||||
production_temperature?: number | null;
|
||||
production_humidity?: number | null;
|
||||
oven_temperature?: number | null;
|
||||
baking_time_minutes?: number | null;
|
||||
waste_quantity?: number | null;
|
||||
waste_reason?: string | null;
|
||||
customer_order_reference?: string | null;
|
||||
pre_order_quantity?: number | null;
|
||||
shelf_quantity?: number | null;
|
||||
}
|
||||
|
||||
export interface ProductionBatchResponse {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
recipe_id: string;
|
||||
batch_number: string;
|
||||
production_date: string;
|
||||
planned_start_time?: string | null;
|
||||
actual_start_time?: string | null;
|
||||
planned_end_time?: string | null;
|
||||
actual_end_time?: string | null;
|
||||
planned_quantity: number;
|
||||
actual_quantity?: number | null;
|
||||
yield_percentage?: number | null;
|
||||
batch_size_multiplier: number;
|
||||
status: string;
|
||||
priority: string;
|
||||
assigned_staff?: Array<Record<string, any>> | null;
|
||||
production_notes?: string | null;
|
||||
quality_score?: number | null;
|
||||
quality_notes?: string | null;
|
||||
defect_rate?: number | null;
|
||||
rework_required: boolean;
|
||||
planned_material_cost?: number | null;
|
||||
actual_material_cost?: number | null;
|
||||
labor_cost?: number | null;
|
||||
overhead_cost?: number | null;
|
||||
total_production_cost?: number | null;
|
||||
cost_per_unit?: number | null;
|
||||
production_temperature?: number | null;
|
||||
production_humidity?: number | null;
|
||||
oven_temperature?: number | null;
|
||||
baking_time_minutes?: number | null;
|
||||
waste_quantity: number;
|
||||
waste_reason?: string | null;
|
||||
efficiency_percentage?: number | null;
|
||||
customer_order_reference?: string | null;
|
||||
pre_order_quantity?: number | null;
|
||||
shelf_quantity?: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_by?: string | null;
|
||||
completed_by?: string | null;
|
||||
}
|
||||
|
||||
// Error types
|
||||
export interface ApiErrorDetail {
|
||||
message: string;
|
||||
status?: number;
|
||||
code?: string;
|
||||
details?: any;
|
||||
}
|
||||
|
||||
// Common query parameters for list endpoints
|
||||
export interface PaginationParams {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface DateRangeParams {
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
}
|
||||
|
||||
// Utility types for better type inference
|
||||
export type RecipeFormData = Omit<RecipeCreate, 'ingredients'> & {
|
||||
ingredients: Array<Omit<RecipeIngredientCreate, 'ingredient_order'> & { ingredient_order?: number }>;
|
||||
};
|
||||
|
||||
export type RecipeUpdateFormData = Omit<RecipeUpdate, 'ingredients'> & {
|
||||
ingredients?: Array<Omit<RecipeIngredientCreate, 'ingredient_order'> & { ingredient_order?: number }>;
|
||||
};
|
||||
Reference in New Issue
Block a user