Improve the inventory page
This commit is contained in:
@@ -23,6 +23,13 @@ class ApiClient {
|
||||
private baseURL: string;
|
||||
private authToken: string | null = null;
|
||||
private tenantId: string | null = null;
|
||||
private refreshToken: string | null = null;
|
||||
private isRefreshing: boolean = false;
|
||||
private failedQueue: Array<{
|
||||
resolve: (value?: any) => void;
|
||||
reject: (error?: any) => void;
|
||||
config: AxiosRequestConfig;
|
||||
}> = [];
|
||||
|
||||
constructor(baseURL: string = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api/v1') {
|
||||
this.baseURL = baseURL;
|
||||
@@ -57,10 +64,58 @@ class ApiClient {
|
||||
}
|
||||
);
|
||||
|
||||
// Response interceptor for error handling
|
||||
// Response interceptor for error handling and automatic token refresh
|
||||
this.client.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
// Check if error is 401 and we have a refresh token
|
||||
if (error.response?.status === 401 && this.refreshToken && !originalRequest._retry) {
|
||||
if (this.isRefreshing) {
|
||||
// If already refreshing, queue this request
|
||||
return new Promise((resolve, reject) => {
|
||||
this.failedQueue.push({ resolve, reject, config: originalRequest });
|
||||
});
|
||||
}
|
||||
|
||||
originalRequest._retry = true;
|
||||
this.isRefreshing = true;
|
||||
|
||||
try {
|
||||
// Attempt to refresh the token
|
||||
const response = await this.client.post('/auth/refresh', {
|
||||
refresh_token: this.refreshToken
|
||||
});
|
||||
|
||||
const { access_token, refresh_token } = response.data;
|
||||
|
||||
// Update tokens
|
||||
this.setAuthToken(access_token);
|
||||
if (refresh_token) {
|
||||
this.setRefreshToken(refresh_token);
|
||||
}
|
||||
|
||||
// Update auth store if available
|
||||
await this.updateAuthStore(access_token, refresh_token);
|
||||
|
||||
// Process failed queue
|
||||
this.processQueue(null, access_token);
|
||||
|
||||
// Retry original request with new token
|
||||
originalRequest.headers.Authorization = `Bearer ${access_token}`;
|
||||
return this.client(originalRequest);
|
||||
|
||||
} catch (refreshError) {
|
||||
// Refresh failed, clear tokens and redirect to login
|
||||
this.processQueue(refreshError, null);
|
||||
await this.handleAuthFailure();
|
||||
return Promise.reject(this.handleError(refreshError as AxiosError));
|
||||
} finally {
|
||||
this.isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(this.handleError(error));
|
||||
}
|
||||
);
|
||||
@@ -90,11 +145,69 @@ class ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
private processQueue(error: any, token: string | null = null) {
|
||||
this.failedQueue.forEach(({ resolve, reject, config }) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
if (token) {
|
||||
config.headers = config.headers || {};
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
resolve(this.client(config));
|
||||
}
|
||||
});
|
||||
|
||||
this.failedQueue = [];
|
||||
}
|
||||
|
||||
private async updateAuthStore(accessToken: string, refreshToken?: string) {
|
||||
try {
|
||||
// Dynamically import to avoid circular dependency
|
||||
const { useAuthStore } = await import('../../stores/auth.store');
|
||||
const store = useAuthStore.getState();
|
||||
|
||||
// Update the store with new tokens
|
||||
store.token = accessToken;
|
||||
if (refreshToken) {
|
||||
store.refreshToken = refreshToken;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to update auth store:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleAuthFailure() {
|
||||
try {
|
||||
// Clear tokens
|
||||
this.setAuthToken(null);
|
||||
this.setRefreshToken(null);
|
||||
|
||||
// Dynamically import to avoid circular dependency
|
||||
const { useAuthStore } = await import('../../stores/auth.store');
|
||||
const store = useAuthStore.getState();
|
||||
|
||||
// Logout user
|
||||
store.logout();
|
||||
|
||||
// Redirect to login if not already there
|
||||
if (typeof window !== 'undefined' && !window.location.pathname.includes('/login')) {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to handle auth failure:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Configuration methods
|
||||
setAuthToken(token: string | null) {
|
||||
this.authToken = token;
|
||||
}
|
||||
|
||||
setRefreshToken(token: string | null) {
|
||||
this.refreshToken = token;
|
||||
}
|
||||
|
||||
setTenantId(tenantId: string | null) {
|
||||
this.tenantId = tenantId;
|
||||
}
|
||||
@@ -103,6 +216,10 @@ class ApiClient {
|
||||
return this.authToken;
|
||||
}
|
||||
|
||||
getRefreshToken(): string | null {
|
||||
return this.refreshToken;
|
||||
}
|
||||
|
||||
getTenantId(): string | null {
|
||||
return this.tenantId;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
import { useMutation, useQuery, useQueryClient, UseQueryOptions, UseMutationOptions } from '@tanstack/react-query';
|
||||
import { inventoryService } from '../services/inventory';
|
||||
import { transformationService } from '../services/transformations';
|
||||
import {
|
||||
IngredientCreate,
|
||||
IngredientUpdate,
|
||||
@@ -17,6 +18,10 @@ import {
|
||||
StockConsumptionRequest,
|
||||
StockConsumptionResponse,
|
||||
PaginatedResponse,
|
||||
ProductTransformationCreate,
|
||||
ProductTransformationResponse,
|
||||
ProductionStage,
|
||||
DeletionSummary,
|
||||
} from '../types/inventory';
|
||||
import { ApiError } from '../client';
|
||||
|
||||
@@ -53,8 +58,23 @@ export const inventoryKeys = {
|
||||
movements: (tenantId: string, ingredientId?: string) =>
|
||||
[...inventoryKeys.stock.all(), 'movements', tenantId, ingredientId] as const,
|
||||
},
|
||||
analytics: (tenantId: string, startDate?: string, endDate?: string) =>
|
||||
analytics: (tenantId: string, startDate?: string, endDate?: string) =>
|
||||
[...inventoryKeys.all, 'analytics', tenantId, { startDate, endDate }] as const,
|
||||
transformations: {
|
||||
all: () => [...inventoryKeys.all, 'transformations'] as const,
|
||||
lists: () => [...inventoryKeys.transformations.all(), 'list'] as const,
|
||||
list: (tenantId: string, filters?: any) =>
|
||||
[...inventoryKeys.transformations.lists(), tenantId, filters] as const,
|
||||
details: () => [...inventoryKeys.transformations.all(), 'detail'] as const,
|
||||
detail: (tenantId: string, transformationId: string) =>
|
||||
[...inventoryKeys.transformations.details(), tenantId, transformationId] as const,
|
||||
summary: (tenantId: string, daysBack?: number) =>
|
||||
[...inventoryKeys.transformations.all(), 'summary', tenantId, daysBack] as const,
|
||||
byIngredient: (tenantId: string, ingredientId: string) =>
|
||||
[...inventoryKeys.transformations.all(), 'by-ingredient', tenantId, ingredientId] as const,
|
||||
byStage: (tenantId: string, sourceStage?: ProductionStage, targetStage?: ProductionStage) =>
|
||||
[...inventoryKeys.transformations.all(), 'by-stage', tenantId, { sourceStage, targetStage }] as const,
|
||||
},
|
||||
} as const;
|
||||
|
||||
// Ingredient Queries
|
||||
@@ -246,19 +266,41 @@ export const useUpdateIngredient = (
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteIngredient = (
|
||||
options?: UseMutationOptions<{ message: string }, ApiError, { tenantId: string; ingredientId: string }>
|
||||
export const useSoftDeleteIngredient = (
|
||||
options?: UseMutationOptions<void, ApiError, { tenantId: string; ingredientId: string }>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<{ message: string }, ApiError, { tenantId: string; ingredientId: string }>({
|
||||
mutationFn: ({ tenantId, ingredientId }) => inventoryService.deleteIngredient(tenantId, ingredientId),
|
||||
|
||||
return useMutation<void, ApiError, { tenantId: string; ingredientId: string }>({
|
||||
mutationFn: ({ tenantId, ingredientId }) => inventoryService.softDeleteIngredient(tenantId, ingredientId),
|
||||
onSuccess: (data, { tenantId, ingredientId }) => {
|
||||
// Remove from cache
|
||||
queryClient.removeQueries({ queryKey: inventoryKeys.ingredients.detail(tenantId, ingredientId) });
|
||||
// Invalidate lists
|
||||
// Invalidate lists to reflect the soft deletion (item marked as inactive)
|
||||
queryClient.invalidateQueries({ queryKey: inventoryKeys.ingredients.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: inventoryKeys.ingredients.byCategory(tenantId) });
|
||||
queryClient.invalidateQueries({ queryKey: inventoryKeys.stock.lists() });
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useHardDeleteIngredient = (
|
||||
options?: UseMutationOptions<DeletionSummary, ApiError, { tenantId: string; ingredientId: string }>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<DeletionSummary, ApiError, { tenantId: string; ingredientId: string }>({
|
||||
mutationFn: ({ tenantId, ingredientId }) => inventoryService.hardDeleteIngredient(tenantId, ingredientId),
|
||||
onSuccess: (data, { tenantId, ingredientId }) => {
|
||||
// Remove from cache completely
|
||||
queryClient.removeQueries({ queryKey: inventoryKeys.ingredients.detail(tenantId, ingredientId) });
|
||||
// Invalidate all related data since everything was deleted
|
||||
queryClient.invalidateQueries({ queryKey: inventoryKeys.ingredients.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: inventoryKeys.ingredients.byCategory(tenantId) });
|
||||
queryClient.invalidateQueries({ queryKey: inventoryKeys.stock.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: inventoryKeys.stock.movements(tenantId) });
|
||||
queryClient.invalidateQueries({ queryKey: inventoryKeys.analytics.all() });
|
||||
},
|
||||
...options,
|
||||
});
|
||||
@@ -452,4 +494,245 @@ export const useStockOperations = (tenantId: string) => {
|
||||
consumeStock,
|
||||
adjustStock
|
||||
};
|
||||
};
|
||||
|
||||
// ===== TRANSFORMATION HOOKS =====
|
||||
|
||||
export const useTransformations = (
|
||||
tenantId: string,
|
||||
options?: {
|
||||
skip?: number;
|
||||
limit?: number;
|
||||
ingredient_id?: string;
|
||||
source_stage?: ProductionStage;
|
||||
target_stage?: ProductionStage;
|
||||
days_back?: number;
|
||||
},
|
||||
queryOptions?: Omit<UseQueryOptions<ProductTransformationResponse[], ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<ProductTransformationResponse[], ApiError>({
|
||||
queryKey: inventoryKeys.transformations.list(tenantId, options),
|
||||
queryFn: () => transformationService.getTransformations(tenantId, options),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 1 * 60 * 1000, // 1 minute
|
||||
...queryOptions,
|
||||
});
|
||||
};
|
||||
|
||||
export const useTransformation = (
|
||||
tenantId: string,
|
||||
transformationId: string,
|
||||
options?: Omit<UseQueryOptions<ProductTransformationResponse, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<ProductTransformationResponse, ApiError>({
|
||||
queryKey: inventoryKeys.transformations.detail(tenantId, transformationId),
|
||||
queryFn: () => transformationService.getTransformation(tenantId, transformationId),
|
||||
enabled: !!tenantId && !!transformationId,
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useTransformationSummary = (
|
||||
tenantId: string,
|
||||
daysBack: number = 30,
|
||||
options?: Omit<UseQueryOptions<any, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<any, ApiError>({
|
||||
queryKey: inventoryKeys.transformations.summary(tenantId, daysBack),
|
||||
queryFn: () => transformationService.getTransformationSummary(tenantId, daysBack),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useTransformationsByIngredient = (
|
||||
tenantId: string,
|
||||
ingredientId: string,
|
||||
limit: number = 50,
|
||||
options?: Omit<UseQueryOptions<ProductTransformationResponse[], ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<ProductTransformationResponse[], ApiError>({
|
||||
queryKey: inventoryKeys.transformations.byIngredient(tenantId, ingredientId),
|
||||
queryFn: () => transformationService.getTransformationsForIngredient(tenantId, ingredientId, limit),
|
||||
enabled: !!tenantId && !!ingredientId,
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useTransformationsByStage = (
|
||||
tenantId: string,
|
||||
sourceStage?: ProductionStage,
|
||||
targetStage?: ProductionStage,
|
||||
limit: number = 50,
|
||||
options?: Omit<UseQueryOptions<ProductTransformationResponse[], ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<ProductTransformationResponse[], ApiError>({
|
||||
queryKey: inventoryKeys.transformations.byStage(tenantId, sourceStage, targetStage),
|
||||
queryFn: () => transformationService.getTransformationsByStage(tenantId, sourceStage, targetStage, limit),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
// ===== TRANSFORMATION MUTATIONS =====
|
||||
|
||||
export const useCreateTransformation = (
|
||||
options?: UseMutationOptions<
|
||||
ProductTransformationResponse,
|
||||
ApiError,
|
||||
{ tenantId: string; transformationData: ProductTransformationCreate }
|
||||
>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
ProductTransformationResponse,
|
||||
ApiError,
|
||||
{ tenantId: string; transformationData: ProductTransformationCreate }
|
||||
>({
|
||||
mutationFn: ({ tenantId, transformationData }) =>
|
||||
transformationService.createTransformation(tenantId, transformationData),
|
||||
onSuccess: (data, { tenantId, transformationData }) => {
|
||||
// Add to cache
|
||||
queryClient.setQueryData(
|
||||
inventoryKeys.transformations.detail(tenantId, data.id),
|
||||
data
|
||||
);
|
||||
// Invalidate related queries
|
||||
queryClient.invalidateQueries({ queryKey: inventoryKeys.transformations.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: inventoryKeys.stock.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: inventoryKeys.ingredients.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: inventoryKeys.stock.movements(tenantId) });
|
||||
// Invalidate ingredient-specific queries
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: inventoryKeys.transformations.byIngredient(tenantId, transformationData.source_ingredient_id)
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: inventoryKeys.transformations.byIngredient(tenantId, transformationData.target_ingredient_id)
|
||||
});
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useParBakeTransformation = (
|
||||
options?: UseMutationOptions<
|
||||
any,
|
||||
ApiError,
|
||||
{
|
||||
tenantId: string;
|
||||
source_ingredient_id: string;
|
||||
target_ingredient_id: string;
|
||||
quantity: number;
|
||||
target_batch_number?: string;
|
||||
expiration_hours?: number;
|
||||
notes?: string;
|
||||
}
|
||||
>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
any,
|
||||
ApiError,
|
||||
{
|
||||
tenantId: string;
|
||||
source_ingredient_id: string;
|
||||
target_ingredient_id: string;
|
||||
quantity: number;
|
||||
target_batch_number?: string;
|
||||
expiration_hours?: number;
|
||||
notes?: string;
|
||||
}
|
||||
>({
|
||||
mutationFn: ({ tenantId, ...transformationOptions }) =>
|
||||
transformationService.createParBakeToFreshTransformation(tenantId, transformationOptions),
|
||||
onSuccess: (data, { tenantId, source_ingredient_id, target_ingredient_id }) => {
|
||||
// Invalidate related queries
|
||||
queryClient.invalidateQueries({ queryKey: inventoryKeys.transformations.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: inventoryKeys.stock.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: inventoryKeys.ingredients.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: inventoryKeys.stock.movements(tenantId) });
|
||||
// Invalidate ingredient-specific queries
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: inventoryKeys.transformations.byIngredient(tenantId, source_ingredient_id)
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: inventoryKeys.transformations.byIngredient(tenantId, target_ingredient_id)
|
||||
});
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
// Custom hook for common transformation operations
|
||||
export const useTransformationOperations = (tenantId: string) => {
|
||||
const createTransformation = useCreateTransformation();
|
||||
const parBakeTransformation = useParBakeTransformation();
|
||||
|
||||
const bakeParBakedCroissants = useMutation({
|
||||
mutationFn: async ({
|
||||
parBakedIngredientId,
|
||||
freshBakedIngredientId,
|
||||
quantity,
|
||||
expirationHours = 24,
|
||||
notes,
|
||||
}: {
|
||||
parBakedIngredientId: string;
|
||||
freshBakedIngredientId: string;
|
||||
quantity: number;
|
||||
expirationHours?: number;
|
||||
notes?: string;
|
||||
}) => {
|
||||
return transformationService.bakeParBakedCroissants(
|
||||
tenantId,
|
||||
parBakedIngredientId,
|
||||
freshBakedIngredientId,
|
||||
quantity,
|
||||
expirationHours,
|
||||
notes
|
||||
);
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalidate related queries
|
||||
createTransformation.reset();
|
||||
},
|
||||
});
|
||||
|
||||
const transformFrozenToPrepared = useMutation({
|
||||
mutationFn: async ({
|
||||
frozenIngredientId,
|
||||
preparedIngredientId,
|
||||
quantity,
|
||||
notes,
|
||||
}: {
|
||||
frozenIngredientId: string;
|
||||
preparedIngredientId: string;
|
||||
quantity: number;
|
||||
notes?: string;
|
||||
}) => {
|
||||
return transformationService.transformFrozenToPrepared(
|
||||
tenantId,
|
||||
frozenIngredientId,
|
||||
preparedIngredientId,
|
||||
quantity,
|
||||
notes
|
||||
);
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalidate related queries
|
||||
createTransformation.reset();
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
createTransformation,
|
||||
parBakeTransformation,
|
||||
bakeParBakedCroissants,
|
||||
transformFrozenToPrepared,
|
||||
};
|
||||
};
|
||||
@@ -462,7 +462,8 @@ export {
|
||||
useStockAnalytics,
|
||||
useCreateIngredient,
|
||||
useUpdateIngredient,
|
||||
useDeleteIngredient,
|
||||
useSoftDeleteIngredient,
|
||||
useHardDeleteIngredient,
|
||||
useAddStock,
|
||||
useUpdateStock,
|
||||
useConsumeStock,
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
StockConsumptionRequest,
|
||||
StockConsumptionResponse,
|
||||
PaginatedResponse,
|
||||
DeletionSummary,
|
||||
} from '../types/inventory';
|
||||
|
||||
export class InventoryService {
|
||||
@@ -74,8 +75,12 @@ export class InventoryService {
|
||||
);
|
||||
}
|
||||
|
||||
async deleteIngredient(tenantId: string, ingredientId: string): Promise<{ message: string }> {
|
||||
return apiClient.delete<{ message: string }>(`${this.baseUrl}/${tenantId}/ingredients/${ingredientId}`);
|
||||
async softDeleteIngredient(tenantId: string, ingredientId: string): Promise<void> {
|
||||
return apiClient.delete<void>(`${this.baseUrl}/${tenantId}/ingredients/${ingredientId}`);
|
||||
}
|
||||
|
||||
async hardDeleteIngredient(tenantId: string, ingredientId: string): Promise<DeletionSummary> {
|
||||
return apiClient.delete<DeletionSummary>(`${this.baseUrl}/${tenantId}/ingredients/${ingredientId}/hard`);
|
||||
}
|
||||
|
||||
async getIngredientsByCategory(tenantId: string): Promise<Record<string, IngredientResponse[]>> {
|
||||
@@ -103,9 +108,8 @@ export class InventoryService {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append('include_unavailable', includeUnavailable.toString());
|
||||
|
||||
return apiClient.get<StockResponse[]>(
|
||||
`${this.baseUrl}/${tenantId}/ingredients/${ingredientId}/stock?${queryParams.toString()}`
|
||||
);
|
||||
const url = `${this.baseUrl}/${tenantId}/ingredients/${ingredientId}/stock?${queryParams.toString()}`;
|
||||
return apiClient.get<StockResponse[]>(url);
|
||||
}
|
||||
|
||||
async getAllStock(tenantId: string, filter?: StockFilter): Promise<PaginatedResponse<StockResponse>> {
|
||||
|
||||
180
frontend/src/api/services/transformations.ts
Normal file
180
frontend/src/api/services/transformations.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Product Transformation Service - Handle transformation operations
|
||||
*/
|
||||
import { apiClient } from '../client';
|
||||
import {
|
||||
ProductTransformationCreate,
|
||||
ProductTransformationResponse,
|
||||
ProductionStage,
|
||||
} from '../types/inventory';
|
||||
|
||||
export class TransformationService {
|
||||
private readonly baseUrl = '/tenants';
|
||||
|
||||
// Product Transformation Operations
|
||||
async createTransformation(
|
||||
tenantId: string,
|
||||
transformationData: ProductTransformationCreate
|
||||
): Promise<ProductTransformationResponse> {
|
||||
return apiClient.post<ProductTransformationResponse>(
|
||||
`${this.baseUrl}/${tenantId}/transformations`,
|
||||
transformationData
|
||||
);
|
||||
}
|
||||
|
||||
async getTransformation(
|
||||
tenantId: string,
|
||||
transformationId: string
|
||||
): Promise<ProductTransformationResponse> {
|
||||
return apiClient.get<ProductTransformationResponse>(
|
||||
`${this.baseUrl}/${tenantId}/transformations/${transformationId}`
|
||||
);
|
||||
}
|
||||
|
||||
async getTransformations(
|
||||
tenantId: string,
|
||||
options?: {
|
||||
skip?: number;
|
||||
limit?: number;
|
||||
ingredient_id?: string;
|
||||
source_stage?: ProductionStage;
|
||||
target_stage?: ProductionStage;
|
||||
days_back?: number;
|
||||
}
|
||||
): Promise<ProductTransformationResponse[]> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (options?.skip !== undefined) queryParams.append('skip', options.skip.toString());
|
||||
if (options?.limit !== undefined) queryParams.append('limit', options.limit.toString());
|
||||
if (options?.ingredient_id) queryParams.append('ingredient_id', options.ingredient_id);
|
||||
if (options?.source_stage) queryParams.append('source_stage', options.source_stage);
|
||||
if (options?.target_stage) queryParams.append('target_stage', options.target_stage);
|
||||
if (options?.days_back !== undefined) queryParams.append('days_back', options.days_back.toString());
|
||||
|
||||
const url = queryParams.toString()
|
||||
? `${this.baseUrl}/${tenantId}/transformations?${queryParams.toString()}`
|
||||
: `${this.baseUrl}/${tenantId}/transformations`;
|
||||
|
||||
return apiClient.get<ProductTransformationResponse[]>(url);
|
||||
}
|
||||
|
||||
async getTransformationSummary(
|
||||
tenantId: string,
|
||||
daysBack: number = 30
|
||||
): Promise<any> {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append('days_back', daysBack.toString());
|
||||
|
||||
return apiClient.get<any>(
|
||||
`${this.baseUrl}/${tenantId}/transformations/summary?${queryParams.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
// Convenience Methods for Common Transformations
|
||||
|
||||
async createParBakeToFreshTransformation(
|
||||
tenantId: string,
|
||||
options: {
|
||||
source_ingredient_id: string;
|
||||
target_ingredient_id: string;
|
||||
quantity: number;
|
||||
target_batch_number?: string;
|
||||
expiration_hours?: number;
|
||||
notes?: string;
|
||||
}
|
||||
): Promise<{
|
||||
transformation_id: string;
|
||||
transformation_reference: string;
|
||||
source_quantity: number;
|
||||
target_quantity: number;
|
||||
expiration_date: string;
|
||||
message: string;
|
||||
}> {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append('source_ingredient_id', options.source_ingredient_id);
|
||||
queryParams.append('target_ingredient_id', options.target_ingredient_id);
|
||||
queryParams.append('quantity', options.quantity.toString());
|
||||
|
||||
if (options.target_batch_number) {
|
||||
queryParams.append('target_batch_number', options.target_batch_number);
|
||||
}
|
||||
if (options.expiration_hours !== undefined) {
|
||||
queryParams.append('expiration_hours', options.expiration_hours.toString());
|
||||
}
|
||||
if (options.notes) {
|
||||
queryParams.append('notes', options.notes);
|
||||
}
|
||||
|
||||
return apiClient.post<any>(
|
||||
`${this.baseUrl}/${tenantId}/transformations/par-bake-to-fresh?${queryParams.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
async bakeParBakedCroissants(
|
||||
tenantId: string,
|
||||
parBakedIngredientId: string,
|
||||
freshBakedIngredientId: string,
|
||||
quantity: number,
|
||||
expirationHours: number = 24,
|
||||
notes?: string
|
||||
): Promise<ProductTransformationResponse> {
|
||||
return this.createTransformation(tenantId, {
|
||||
source_ingredient_id: parBakedIngredientId,
|
||||
target_ingredient_id: freshBakedIngredientId,
|
||||
source_stage: ProductionStage.PAR_BAKED,
|
||||
target_stage: ProductionStage.FULLY_BAKED,
|
||||
source_quantity: quantity,
|
||||
target_quantity: quantity, // Assume 1:1 ratio for croissants
|
||||
expiration_calculation_method: 'days_from_transformation',
|
||||
expiration_days_offset: Math.max(1, Math.floor(expirationHours / 24)),
|
||||
process_notes: notes || `Baked ${quantity} par-baked croissants to fresh croissants`,
|
||||
});
|
||||
}
|
||||
|
||||
async transformFrozenToPrepared(
|
||||
tenantId: string,
|
||||
frozenIngredientId: string,
|
||||
preparedIngredientId: string,
|
||||
quantity: number,
|
||||
notes?: string
|
||||
): Promise<ProductTransformationResponse> {
|
||||
return this.createTransformation(tenantId, {
|
||||
source_ingredient_id: frozenIngredientId,
|
||||
target_ingredient_id: preparedIngredientId,
|
||||
source_stage: ProductionStage.FROZEN_PRODUCT,
|
||||
target_stage: ProductionStage.PREPARED_DOUGH,
|
||||
source_quantity: quantity,
|
||||
target_quantity: quantity,
|
||||
expiration_calculation_method: 'days_from_transformation',
|
||||
expiration_days_offset: 3, // Prepared dough typically lasts 3 days
|
||||
process_notes: notes || `Thawed and prepared ${quantity} frozen products`,
|
||||
});
|
||||
}
|
||||
|
||||
// Analytics and Reporting
|
||||
async getTransformationsByStage(
|
||||
tenantId: string,
|
||||
sourceStage?: ProductionStage,
|
||||
targetStage?: ProductionStage,
|
||||
limit: number = 50
|
||||
): Promise<ProductTransformationResponse[]> {
|
||||
return this.getTransformations(tenantId, {
|
||||
source_stage: sourceStage,
|
||||
target_stage: targetStage,
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
async getTransformationsForIngredient(
|
||||
tenantId: string,
|
||||
ingredientId: string,
|
||||
limit: number = 50
|
||||
): Promise<ProductTransformationResponse[]> {
|
||||
return this.getTransformations(tenantId, {
|
||||
ingredient_id: ingredientId,
|
||||
limit,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const transformationService = new TransformationService();
|
||||
@@ -8,6 +8,14 @@ export enum ProductType {
|
||||
FINISHED_PRODUCT = 'finished_product'
|
||||
}
|
||||
|
||||
export enum ProductionStage {
|
||||
RAW_INGREDIENT = 'raw_ingredient',
|
||||
PAR_BAKED = 'par_baked',
|
||||
FULLY_BAKED = 'fully_baked',
|
||||
PREPARED_DOUGH = 'prepared_dough',
|
||||
FROZEN_PRODUCT = 'frozen_product'
|
||||
}
|
||||
|
||||
export enum UnitOfMeasure {
|
||||
KILOGRAMS = 'kg',
|
||||
GRAMS = 'g',
|
||||
@@ -141,20 +149,36 @@ export interface IngredientResponse {
|
||||
// Stock Management Types
|
||||
export interface StockCreate {
|
||||
ingredient_id: string;
|
||||
production_stage?: ProductionStage;
|
||||
transformation_reference?: string;
|
||||
quantity: number;
|
||||
unit_price: number;
|
||||
expiration_date?: string;
|
||||
batch_number?: string;
|
||||
supplier_id?: string;
|
||||
purchase_order_reference?: string;
|
||||
|
||||
// Stage-specific expiration fields
|
||||
original_expiration_date?: string;
|
||||
transformation_date?: string;
|
||||
final_expiration_date?: string;
|
||||
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface StockUpdate {
|
||||
production_stage?: ProductionStage;
|
||||
transformation_reference?: string;
|
||||
quantity?: number;
|
||||
unit_price?: number;
|
||||
expiration_date?: string;
|
||||
batch_number?: string;
|
||||
|
||||
// Stage-specific expiration fields
|
||||
original_expiration_date?: string;
|
||||
transformation_date?: string;
|
||||
final_expiration_date?: string;
|
||||
|
||||
notes?: string;
|
||||
is_available?: boolean;
|
||||
}
|
||||
@@ -163,15 +187,34 @@ export interface StockResponse {
|
||||
id: string;
|
||||
ingredient_id: string;
|
||||
tenant_id: string;
|
||||
quantity: number;
|
||||
|
||||
// Production stage tracking
|
||||
production_stage: ProductionStage;
|
||||
transformation_reference?: string;
|
||||
|
||||
// API returns current_quantity, keeping quantity for backward compatibility
|
||||
quantity?: number;
|
||||
current_quantity: number;
|
||||
available_quantity: number;
|
||||
reserved_quantity: number;
|
||||
unit_price: number;
|
||||
total_value: number;
|
||||
|
||||
// API returns unit_cost, keeping unit_price for backward compatibility
|
||||
unit_price?: number;
|
||||
unit_cost: number;
|
||||
|
||||
// API returns total_cost, keeping total_value for backward compatibility
|
||||
total_value?: number;
|
||||
total_cost: number;
|
||||
expiration_date?: string;
|
||||
batch_number?: string;
|
||||
supplier_id?: string;
|
||||
purchase_order_reference?: string;
|
||||
|
||||
// Stage-specific expiration fields
|
||||
original_expiration_date?: string;
|
||||
transformation_date?: string;
|
||||
final_expiration_date?: string;
|
||||
|
||||
notes?: string;
|
||||
is_available: boolean;
|
||||
is_expired: boolean;
|
||||
@@ -184,7 +227,7 @@ export interface StockResponse {
|
||||
export interface StockMovementCreate {
|
||||
ingredient_id: string;
|
||||
stock_id?: string;
|
||||
movement_type: 'purchase' | 'production_use' | 'adjustment' | 'waste' | 'transfer' | 'return' | 'initial_stock';
|
||||
movement_type: 'purchase' | 'production_use' | 'adjustment' | 'waste' | 'transfer' | 'return' | 'initial_stock' | 'transformation';
|
||||
quantity: number;
|
||||
unit_cost?: number;
|
||||
reference_number?: string;
|
||||
@@ -199,7 +242,7 @@ export interface StockMovementResponse {
|
||||
tenant_id: string;
|
||||
ingredient_id: string;
|
||||
stock_id?: string;
|
||||
movement_type: 'purchase' | 'production_use' | 'adjustment' | 'waste' | 'transfer' | 'return' | 'initial_stock';
|
||||
movement_type: 'purchase' | 'production_use' | 'adjustment' | 'waste' | 'transfer' | 'return' | 'initial_stock' | 'transformation';
|
||||
quantity: number;
|
||||
unit_cost?: number;
|
||||
total_cost?: number;
|
||||
@@ -215,6 +258,48 @@ export interface StockMovementResponse {
|
||||
ingredient?: IngredientResponse;
|
||||
}
|
||||
|
||||
// Product Transformation Types
|
||||
export interface ProductTransformationCreate {
|
||||
source_ingredient_id: string;
|
||||
target_ingredient_id: string;
|
||||
source_stage: ProductionStage;
|
||||
target_stage: ProductionStage;
|
||||
source_quantity: number;
|
||||
target_quantity: number;
|
||||
conversion_ratio?: number;
|
||||
expiration_calculation_method?: string;
|
||||
expiration_days_offset?: number;
|
||||
process_notes?: string;
|
||||
target_batch_number?: string;
|
||||
source_stock_ids?: string[];
|
||||
}
|
||||
|
||||
export interface ProductTransformationResponse {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
transformation_reference: string;
|
||||
source_ingredient_id: string;
|
||||
target_ingredient_id: string;
|
||||
source_stage: ProductionStage;
|
||||
target_stage: ProductionStage;
|
||||
source_quantity: number;
|
||||
target_quantity: number;
|
||||
conversion_ratio: number;
|
||||
expiration_calculation_method: string;
|
||||
expiration_days_offset?: number;
|
||||
transformation_date: string;
|
||||
process_notes?: string;
|
||||
performed_by?: string;
|
||||
source_batch_numbers?: string;
|
||||
target_batch_number?: string;
|
||||
is_completed: boolean;
|
||||
is_reversed: boolean;
|
||||
created_at: string;
|
||||
created_by?: string;
|
||||
source_ingredient?: IngredientResponse;
|
||||
target_ingredient?: IngredientResponse;
|
||||
}
|
||||
|
||||
// Filter and Query Types
|
||||
export interface InventoryFilter {
|
||||
category?: string;
|
||||
@@ -233,6 +318,8 @@ export interface InventoryFilter {
|
||||
|
||||
export interface StockFilter {
|
||||
ingredient_id?: string;
|
||||
production_stage?: ProductionStage;
|
||||
transformation_reference?: string;
|
||||
is_available?: boolean;
|
||||
is_expired?: boolean;
|
||||
expiring_within_days?: number;
|
||||
@@ -272,4 +359,14 @@ export interface PaginatedResponse<T> {
|
||||
page: number;
|
||||
per_page: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
// Deletion Summary Response
|
||||
export interface DeletionSummary {
|
||||
ingredient_id: string;
|
||||
ingredient_name: string | null;
|
||||
deleted_stock_entries: number;
|
||||
deleted_stock_movements: number;
|
||||
deleted_stock_alerts: number;
|
||||
success: boolean;
|
||||
}
|
||||
Reference in New Issue
Block a user