Add frontend imporvements
This commit is contained in:
323
frontend/src/api/hooks/forecasting.ts
Normal file
323
frontend/src/api/hooks/forecasting.ts
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
/**
|
||||||
|
* Forecasting React Query hooks
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
useMutation,
|
||||||
|
useQuery,
|
||||||
|
useQueryClient,
|
||||||
|
UseQueryOptions,
|
||||||
|
UseMutationOptions,
|
||||||
|
useInfiniteQuery,
|
||||||
|
UseInfiniteQueryOptions,
|
||||||
|
} from '@tanstack/react-query';
|
||||||
|
import { forecastingService } from '../services/forecasting';
|
||||||
|
import {
|
||||||
|
ForecastRequest,
|
||||||
|
ForecastResponse,
|
||||||
|
BatchForecastRequest,
|
||||||
|
BatchForecastResponse,
|
||||||
|
ForecastListResponse,
|
||||||
|
ForecastByIdResponse,
|
||||||
|
ForecastStatistics,
|
||||||
|
DeleteForecastResponse,
|
||||||
|
GetForecastsParams,
|
||||||
|
ForecastingHealthResponse,
|
||||||
|
} from '../types/forecasting';
|
||||||
|
import { ApiError } from '../client/apiClient';
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// QUERY KEYS
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
export const forecastingKeys = {
|
||||||
|
all: ['forecasting'] as const,
|
||||||
|
lists: () => [...forecastingKeys.all, 'list'] as const,
|
||||||
|
list: (tenantId: string, filters?: GetForecastsParams) =>
|
||||||
|
[...forecastingKeys.lists(), tenantId, filters] as const,
|
||||||
|
details: () => [...forecastingKeys.all, 'detail'] as const,
|
||||||
|
detail: (tenantId: string, forecastId: string) =>
|
||||||
|
[...forecastingKeys.details(), tenantId, forecastId] as const,
|
||||||
|
statistics: (tenantId: string) =>
|
||||||
|
[...forecastingKeys.all, 'statistics', tenantId] as const,
|
||||||
|
health: () => [...forecastingKeys.all, 'health'] as const,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// QUERIES
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get tenant forecasts with filtering and pagination
|
||||||
|
*/
|
||||||
|
export const useTenantForecasts = (
|
||||||
|
tenantId: string,
|
||||||
|
params?: GetForecastsParams,
|
||||||
|
options?: Omit<UseQueryOptions<ForecastListResponse, ApiError>, 'queryKey' | 'queryFn'>
|
||||||
|
) => {
|
||||||
|
return useQuery<ForecastListResponse, ApiError>({
|
||||||
|
queryKey: forecastingKeys.list(tenantId, params),
|
||||||
|
queryFn: () => forecastingService.getTenantForecasts(tenantId, params),
|
||||||
|
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||||
|
enabled: !!tenantId,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get specific forecast by ID
|
||||||
|
*/
|
||||||
|
export const useForecastById = (
|
||||||
|
tenantId: string,
|
||||||
|
forecastId: string,
|
||||||
|
options?: Omit<UseQueryOptions<ForecastByIdResponse, ApiError>, 'queryKey' | 'queryFn'>
|
||||||
|
) => {
|
||||||
|
return useQuery<ForecastByIdResponse, ApiError>({
|
||||||
|
queryKey: forecastingKeys.detail(tenantId, forecastId),
|
||||||
|
queryFn: () => forecastingService.getForecastById(tenantId, forecastId),
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
enabled: !!tenantId && !!forecastId,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get forecast statistics for tenant
|
||||||
|
*/
|
||||||
|
export const useForecastStatistics = (
|
||||||
|
tenantId: string,
|
||||||
|
options?: Omit<UseQueryOptions<ForecastStatistics, ApiError>, 'queryKey' | 'queryFn'>
|
||||||
|
) => {
|
||||||
|
return useQuery<ForecastStatistics, ApiError>({
|
||||||
|
queryKey: forecastingKeys.statistics(tenantId),
|
||||||
|
queryFn: () => forecastingService.getForecastStatistics(tenantId),
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
enabled: !!tenantId,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Health check for forecasting service
|
||||||
|
*/
|
||||||
|
export const useForecastingHealth = (
|
||||||
|
options?: Omit<UseQueryOptions<ForecastingHealthResponse, ApiError>, 'queryKey' | 'queryFn'>
|
||||||
|
) => {
|
||||||
|
return useQuery<ForecastingHealthResponse, ApiError>({
|
||||||
|
queryKey: forecastingKeys.health(),
|
||||||
|
queryFn: () => forecastingService.getHealthCheck(),
|
||||||
|
staleTime: 30 * 1000, // 30 seconds
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// INFINITE QUERIES
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Infinite query for tenant forecasts (for pagination)
|
||||||
|
*/
|
||||||
|
export const useInfiniteTenantForecasts = (
|
||||||
|
tenantId: string,
|
||||||
|
baseParams?: Omit<GetForecastsParams, 'skip' | 'limit'>,
|
||||||
|
options?: Omit<UseInfiniteQueryOptions<ForecastListResponse, ApiError>, 'queryKey' | 'queryFn' | 'getNextPageParam'>
|
||||||
|
) => {
|
||||||
|
const limit = baseParams?.limit || 20;
|
||||||
|
|
||||||
|
return useInfiniteQuery<ForecastListResponse, ApiError>({
|
||||||
|
queryKey: [...forecastingKeys.list(tenantId, baseParams), 'infinite'],
|
||||||
|
queryFn: ({ pageParam = 0 }) => {
|
||||||
|
const params: GetForecastsParams = {
|
||||||
|
...baseParams,
|
||||||
|
skip: pageParam as number,
|
||||||
|
limit,
|
||||||
|
};
|
||||||
|
return forecastingService.getTenantForecasts(tenantId, params);
|
||||||
|
},
|
||||||
|
getNextPageParam: (lastPage, allPages) => {
|
||||||
|
const totalFetched = allPages.reduce((sum, page) => sum + page.total_returned, 0);
|
||||||
|
return lastPage.total_returned === limit ? totalFetched : undefined;
|
||||||
|
},
|
||||||
|
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||||
|
enabled: !!tenantId,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// MUTATIONS
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create single forecast mutation
|
||||||
|
*/
|
||||||
|
export const useCreateSingleForecast = (
|
||||||
|
options?: UseMutationOptions<
|
||||||
|
ForecastResponse,
|
||||||
|
ApiError,
|
||||||
|
{ tenantId: string; request: ForecastRequest }
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation<
|
||||||
|
ForecastResponse,
|
||||||
|
ApiError,
|
||||||
|
{ tenantId: string; request: ForecastRequest }
|
||||||
|
>({
|
||||||
|
mutationFn: ({ tenantId, request }) =>
|
||||||
|
forecastingService.createSingleForecast(tenantId, request),
|
||||||
|
onSuccess: (data, variables) => {
|
||||||
|
// Invalidate and refetch forecasts list
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: forecastingKeys.lists(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update the specific forecast cache
|
||||||
|
queryClient.setQueryData(
|
||||||
|
forecastingKeys.detail(variables.tenantId, data.id),
|
||||||
|
{
|
||||||
|
...data,
|
||||||
|
enhanced_features: true,
|
||||||
|
repository_integration: true,
|
||||||
|
} as ForecastByIdResponse
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update statistics
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: forecastingKeys.statistics(variables.tenantId),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create batch forecast mutation
|
||||||
|
*/
|
||||||
|
export const useCreateBatchForecast = (
|
||||||
|
options?: UseMutationOptions<
|
||||||
|
BatchForecastResponse,
|
||||||
|
ApiError,
|
||||||
|
{ tenantId: string; request: BatchForecastRequest }
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation<
|
||||||
|
BatchForecastResponse,
|
||||||
|
ApiError,
|
||||||
|
{ tenantId: string; request: BatchForecastRequest }
|
||||||
|
>({
|
||||||
|
mutationFn: ({ tenantId, request }) =>
|
||||||
|
forecastingService.createBatchForecast(tenantId, request),
|
||||||
|
onSuccess: (data, variables) => {
|
||||||
|
// Invalidate forecasts list
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: forecastingKeys.lists(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cache individual forecasts if available
|
||||||
|
if (data.forecasts) {
|
||||||
|
data.forecasts.forEach((forecast) => {
|
||||||
|
queryClient.setQueryData(
|
||||||
|
forecastingKeys.detail(variables.tenantId, forecast.id),
|
||||||
|
{
|
||||||
|
...forecast,
|
||||||
|
enhanced_features: true,
|
||||||
|
repository_integration: true,
|
||||||
|
} as ForecastByIdResponse
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update statistics
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: forecastingKeys.statistics(variables.tenantId),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete forecast mutation
|
||||||
|
*/
|
||||||
|
export const useDeleteForecast = (
|
||||||
|
options?: UseMutationOptions<
|
||||||
|
DeleteForecastResponse,
|
||||||
|
ApiError,
|
||||||
|
{ tenantId: string; forecastId: string }
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation<
|
||||||
|
DeleteForecastResponse,
|
||||||
|
ApiError,
|
||||||
|
{ tenantId: string; forecastId: string }
|
||||||
|
>({
|
||||||
|
mutationFn: ({ tenantId, forecastId }) =>
|
||||||
|
forecastingService.deleteForecast(tenantId, forecastId),
|
||||||
|
onSuccess: (data, variables) => {
|
||||||
|
// Remove from cache
|
||||||
|
queryClient.removeQueries({
|
||||||
|
queryKey: forecastingKeys.detail(variables.tenantId, variables.forecastId),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Invalidate lists to refresh
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: forecastingKeys.lists(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update statistics
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: forecastingKeys.statistics(variables.tenantId),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// UTILITY FUNCTIONS
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefetch forecast by ID
|
||||||
|
*/
|
||||||
|
export const usePrefetchForecast = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return (tenantId: string, forecastId: string) => {
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: forecastingKeys.detail(tenantId, forecastId),
|
||||||
|
queryFn: () => forecastingService.getForecastById(tenantId, forecastId),
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidate all forecasting queries for a tenant
|
||||||
|
*/
|
||||||
|
export const useInvalidateForecasting = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return (tenantId?: string) => {
|
||||||
|
if (tenantId) {
|
||||||
|
// Invalidate specific tenant queries
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: forecastingKeys.list(tenantId),
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: forecastingKeys.statistics(tenantId),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Invalidate all forecasting queries
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: forecastingKeys.all,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -25,6 +25,7 @@ export { trainingService } from './services/training';
|
|||||||
export { alertProcessorService } from './services/alert_processor';
|
export { alertProcessorService } from './services/alert_processor';
|
||||||
export { suppliersService } from './services/suppliers';
|
export { suppliersService } from './services/suppliers';
|
||||||
export { OrdersService } from './services/orders';
|
export { OrdersService } from './services/orders';
|
||||||
|
export { forecastingService } from './services/forecasting';
|
||||||
|
|
||||||
// Types - Auth
|
// Types - Auth
|
||||||
export type {
|
export type {
|
||||||
@@ -295,6 +296,22 @@ export type {
|
|||||||
UpdatePlanStatusParams,
|
UpdatePlanStatusParams,
|
||||||
} from './types/orders';
|
} from './types/orders';
|
||||||
|
|
||||||
|
// Types - Forecasting
|
||||||
|
export type {
|
||||||
|
ForecastRequest,
|
||||||
|
ForecastResponse,
|
||||||
|
BatchForecastRequest,
|
||||||
|
BatchForecastResponse,
|
||||||
|
ForecastStatistics,
|
||||||
|
ForecastListResponse,
|
||||||
|
ForecastByIdResponse,
|
||||||
|
DeleteForecastResponse,
|
||||||
|
GetForecastsParams,
|
||||||
|
ForecastingHealthResponse,
|
||||||
|
} from './types/forecasting';
|
||||||
|
|
||||||
|
export { BusinessType } from './types/forecasting';
|
||||||
|
|
||||||
// Hooks - Auth
|
// Hooks - Auth
|
||||||
export {
|
export {
|
||||||
useAuthProfile,
|
useAuthProfile,
|
||||||
@@ -551,6 +568,21 @@ export {
|
|||||||
ordersKeys,
|
ordersKeys,
|
||||||
} from './hooks/orders';
|
} from './hooks/orders';
|
||||||
|
|
||||||
|
// Hooks - Forecasting
|
||||||
|
export {
|
||||||
|
useTenantForecasts,
|
||||||
|
useForecastById,
|
||||||
|
useForecastStatistics,
|
||||||
|
useForecastingHealth,
|
||||||
|
useInfiniteTenantForecasts,
|
||||||
|
useCreateSingleForecast,
|
||||||
|
useCreateBatchForecast,
|
||||||
|
useDeleteForecast,
|
||||||
|
usePrefetchForecast,
|
||||||
|
useInvalidateForecasting,
|
||||||
|
forecastingKeys,
|
||||||
|
} from './hooks/forecasting';
|
||||||
|
|
||||||
// Query Key Factories (for advanced usage)
|
// Query Key Factories (for advanced usage)
|
||||||
export {
|
export {
|
||||||
authKeys,
|
authKeys,
|
||||||
@@ -567,4 +599,5 @@ export {
|
|||||||
suppliersKeys,
|
suppliersKeys,
|
||||||
ordersKeys,
|
ordersKeys,
|
||||||
dataImportKeys,
|
dataImportKeys,
|
||||||
|
forecastingKeys,
|
||||||
};
|
};
|
||||||
132
frontend/src/api/services/forecasting.ts
Normal file
132
frontend/src/api/services/forecasting.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
/**
|
||||||
|
* Forecasting Service
|
||||||
|
* API calls for forecasting service endpoints
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { apiClient } from '../client/apiClient';
|
||||||
|
import {
|
||||||
|
ForecastRequest,
|
||||||
|
ForecastResponse,
|
||||||
|
BatchForecastRequest,
|
||||||
|
BatchForecastResponse,
|
||||||
|
ForecastListResponse,
|
||||||
|
ForecastByIdResponse,
|
||||||
|
ForecastStatistics,
|
||||||
|
DeleteForecastResponse,
|
||||||
|
GetForecastsParams,
|
||||||
|
ForecastingHealthResponse,
|
||||||
|
} from '../types/forecasting';
|
||||||
|
|
||||||
|
export class ForecastingService {
|
||||||
|
private readonly baseUrl = '/forecasts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a single product forecast
|
||||||
|
* POST /tenants/{tenant_id}/forecasts/single
|
||||||
|
*/
|
||||||
|
async createSingleForecast(
|
||||||
|
tenantId: string,
|
||||||
|
request: ForecastRequest
|
||||||
|
): Promise<ForecastResponse> {
|
||||||
|
return apiClient.post<ForecastResponse, ForecastRequest>(
|
||||||
|
`/tenants/${tenantId}${this.baseUrl}/single`,
|
||||||
|
request
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate batch forecasts for multiple products
|
||||||
|
* POST /tenants/{tenant_id}/forecasts/batch
|
||||||
|
*/
|
||||||
|
async createBatchForecast(
|
||||||
|
tenantId: string,
|
||||||
|
request: BatchForecastRequest
|
||||||
|
): Promise<BatchForecastResponse> {
|
||||||
|
return apiClient.post<BatchForecastResponse, BatchForecastRequest>(
|
||||||
|
`/tenants/${tenantId}${this.baseUrl}/batch`,
|
||||||
|
request
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get tenant forecasts with filtering and pagination
|
||||||
|
* GET /tenants/{tenant_id}/forecasts
|
||||||
|
*/
|
||||||
|
async getTenantForecasts(
|
||||||
|
tenantId: string,
|
||||||
|
params?: GetForecastsParams
|
||||||
|
): Promise<ForecastListResponse> {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
|
||||||
|
if (params?.inventory_product_id) {
|
||||||
|
searchParams.append('inventory_product_id', params.inventory_product_id);
|
||||||
|
}
|
||||||
|
if (params?.start_date) {
|
||||||
|
searchParams.append('start_date', params.start_date);
|
||||||
|
}
|
||||||
|
if (params?.end_date) {
|
||||||
|
searchParams.append('end_date', params.end_date);
|
||||||
|
}
|
||||||
|
if (params?.skip !== undefined) {
|
||||||
|
searchParams.append('skip', params.skip.toString());
|
||||||
|
}
|
||||||
|
if (params?.limit !== undefined) {
|
||||||
|
searchParams.append('limit', params.limit.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryString = searchParams.toString();
|
||||||
|
const url = `/tenants/${tenantId}${this.baseUrl}${queryString ? `?${queryString}` : ''}`;
|
||||||
|
|
||||||
|
return apiClient.get<ForecastListResponse>(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get specific forecast by ID
|
||||||
|
* GET /tenants/{tenant_id}/forecasts/{forecast_id}
|
||||||
|
*/
|
||||||
|
async getForecastById(
|
||||||
|
tenantId: string,
|
||||||
|
forecastId: string
|
||||||
|
): Promise<ForecastByIdResponse> {
|
||||||
|
return apiClient.get<ForecastByIdResponse>(
|
||||||
|
`/tenants/${tenantId}${this.baseUrl}/${forecastId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a forecast
|
||||||
|
* DELETE /tenants/{tenant_id}/forecasts/{forecast_id}
|
||||||
|
*/
|
||||||
|
async deleteForecast(
|
||||||
|
tenantId: string,
|
||||||
|
forecastId: string
|
||||||
|
): Promise<DeleteForecastResponse> {
|
||||||
|
return apiClient.delete<DeleteForecastResponse>(
|
||||||
|
`/tenants/${tenantId}${this.baseUrl}/${forecastId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get comprehensive forecast statistics
|
||||||
|
* GET /tenants/{tenant_id}/forecasts/statistics
|
||||||
|
*/
|
||||||
|
async getForecastStatistics(
|
||||||
|
tenantId: string
|
||||||
|
): Promise<ForecastStatistics> {
|
||||||
|
return apiClient.get<ForecastStatistics>(
|
||||||
|
`/tenants/${tenantId}${this.baseUrl}/statistics`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Health check for forecasting service
|
||||||
|
* GET /health
|
||||||
|
*/
|
||||||
|
async getHealthCheck(): Promise<ForecastingHealthResponse> {
|
||||||
|
return apiClient.get<ForecastingHealthResponse>('/health');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton instance
|
||||||
|
export const forecastingService = new ForecastingService();
|
||||||
|
export default forecastingService;
|
||||||
@@ -83,7 +83,7 @@ export class InventoryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getLowStockIngredients(tenantId: string): Promise<IngredientResponse[]> {
|
async getLowStockIngredients(tenantId: string): Promise<IngredientResponse[]> {
|
||||||
return apiClient.get<IngredientResponse[]>(`${this.baseUrl}/${tenantId}/ingredients/low-stock`);
|
return apiClient.get<IngredientResponse[]>(`${this.baseUrl}/${tenantId}/stock/low-stock`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stock Management
|
// Stock Management
|
||||||
@@ -104,7 +104,7 @@ export class InventoryService {
|
|||||||
queryParams.append('include_unavailable', includeUnavailable.toString());
|
queryParams.append('include_unavailable', includeUnavailable.toString());
|
||||||
|
|
||||||
return apiClient.get<StockResponse[]>(
|
return apiClient.get<StockResponse[]>(
|
||||||
`${this.baseUrl}/${tenantId}/stock/ingredient/${ingredientId}?${queryParams.toString()}`
|
`${this.baseUrl}/${tenantId}/ingredients/${ingredientId}/stock?${queryParams.toString()}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,8 +218,8 @@ export class InventoryService {
|
|||||||
if (endDate) queryParams.append('end_date', endDate);
|
if (endDate) queryParams.append('end_date', endDate);
|
||||||
|
|
||||||
const url = queryParams.toString()
|
const url = queryParams.toString()
|
||||||
? `${this.baseUrl}/${tenantId}/inventory/analytics?${queryParams.toString()}`
|
? `${this.baseUrl}/${tenantId}/dashboard/analytics?${queryParams.toString()}`
|
||||||
: `${this.baseUrl}/${tenantId}/inventory/analytics`;
|
: `${this.baseUrl}/${tenantId}/dashboard/analytics`;
|
||||||
|
|
||||||
return apiClient.get(url);
|
return apiClient.get(url);
|
||||||
}
|
}
|
||||||
|
|||||||
160
frontend/src/api/types/forecasting.ts
Normal file
160
frontend/src/api/types/forecasting.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
/**
|
||||||
|
* Forecasting API Types
|
||||||
|
* Mirror of backend forecasting service schemas
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// ENUMS
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
export enum BusinessType {
|
||||||
|
INDIVIDUAL = "individual",
|
||||||
|
CENTRAL_WORKSHOP = "central_workshop",
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// REQUEST TYPES
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
export interface ForecastRequest {
|
||||||
|
inventory_product_id: string;
|
||||||
|
forecast_date: string; // ISO date string
|
||||||
|
forecast_days?: number; // Default: 1, Min: 1, Max: 30
|
||||||
|
location: string;
|
||||||
|
confidence_level?: number; // Default: 0.8, Min: 0.5, Max: 0.95
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchForecastRequest {
|
||||||
|
tenant_id: string;
|
||||||
|
batch_name: string;
|
||||||
|
inventory_product_ids: string[];
|
||||||
|
forecast_days?: number; // Default: 7, Min: 1, Max: 30
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// RESPONSE TYPES
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
export interface ForecastResponse {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
inventory_product_id: string;
|
||||||
|
location: string;
|
||||||
|
forecast_date: string; // ISO datetime string
|
||||||
|
|
||||||
|
// Predictions
|
||||||
|
predicted_demand: number;
|
||||||
|
confidence_lower: number;
|
||||||
|
confidence_upper: number;
|
||||||
|
confidence_level: number;
|
||||||
|
|
||||||
|
// Model info
|
||||||
|
model_id: string;
|
||||||
|
model_version: string;
|
||||||
|
algorithm: string;
|
||||||
|
|
||||||
|
// Context
|
||||||
|
business_type: string;
|
||||||
|
is_holiday: boolean;
|
||||||
|
is_weekend: boolean;
|
||||||
|
day_of_week: number;
|
||||||
|
|
||||||
|
// External factors
|
||||||
|
weather_temperature?: number;
|
||||||
|
weather_precipitation?: number;
|
||||||
|
weather_description?: string;
|
||||||
|
traffic_volume?: number;
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
created_at: string; // ISO datetime string
|
||||||
|
processing_time_ms?: number;
|
||||||
|
features_used?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchForecastResponse {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
batch_name: string;
|
||||||
|
status: string;
|
||||||
|
total_products: number;
|
||||||
|
completed_products: number;
|
||||||
|
failed_products: number;
|
||||||
|
|
||||||
|
// Timing
|
||||||
|
requested_at: string; // ISO datetime string
|
||||||
|
completed_at?: string; // ISO datetime string
|
||||||
|
processing_time_ms?: number;
|
||||||
|
|
||||||
|
// Results
|
||||||
|
forecasts?: ForecastResponse[];
|
||||||
|
error_message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ForecastStatistics {
|
||||||
|
tenant_id: string;
|
||||||
|
total_forecasts: number;
|
||||||
|
recent_forecasts: number;
|
||||||
|
accuracy_metrics: {
|
||||||
|
average_accuracy: number;
|
||||||
|
accuracy_trend: number;
|
||||||
|
};
|
||||||
|
model_performance: {
|
||||||
|
most_used_algorithm: string;
|
||||||
|
average_processing_time: number;
|
||||||
|
};
|
||||||
|
enhanced_features: boolean;
|
||||||
|
repository_integration: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ForecastListResponse {
|
||||||
|
tenant_id: string;
|
||||||
|
forecasts: ForecastResponse[];
|
||||||
|
total_returned: number;
|
||||||
|
filters: {
|
||||||
|
inventory_product_id?: string;
|
||||||
|
start_date?: string; // ISO date string
|
||||||
|
end_date?: string; // ISO date string
|
||||||
|
};
|
||||||
|
pagination: {
|
||||||
|
skip: number;
|
||||||
|
limit: number;
|
||||||
|
};
|
||||||
|
enhanced_features: boolean;
|
||||||
|
repository_integration: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ForecastByIdResponse extends ForecastResponse {
|
||||||
|
enhanced_features: boolean;
|
||||||
|
repository_integration: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeleteForecastResponse {
|
||||||
|
message: string;
|
||||||
|
forecast_id: string;
|
||||||
|
enhanced_features: boolean;
|
||||||
|
repository_integration: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// QUERY PARAMETERS
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
export interface GetForecastsParams {
|
||||||
|
inventory_product_id?: string;
|
||||||
|
start_date?: string; // ISO date string
|
||||||
|
end_date?: string; // ISO date string
|
||||||
|
skip?: number; // Default: 0
|
||||||
|
limit?: number; // Default: 100
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// HEALTH CHECK
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
export interface ForecastingHealthResponse {
|
||||||
|
status: string;
|
||||||
|
service: string;
|
||||||
|
version: string;
|
||||||
|
features: string[];
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
@@ -6,126 +6,67 @@ import { Select } from '../../ui';
|
|||||||
import { Card } from '../../ui';
|
import { Card } from '../../ui';
|
||||||
import { Badge } from '../../ui';
|
import { Badge } from '../../ui';
|
||||||
import { Modal } from '../../ui';
|
import { Modal } from '../../ui';
|
||||||
import { IngredientFormData, UnitOfMeasure, ProductType, IngredientResponse } from '../../../types/inventory.types';
|
import { IngredientCreate, IngredientResponse } from '../../../api/types/inventory';
|
||||||
import { inventoryService } from '../../../api/services/inventory.service';
|
|
||||||
|
|
||||||
export interface InventoryFormProps {
|
export interface InventoryFormProps {
|
||||||
item?: IngredientResponse;
|
item?: IngredientResponse;
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
onSubmit?: (data: IngredientFormData) => Promise<void>;
|
onSubmit?: (data: IngredientCreate) => Promise<void>;
|
||||||
onClassify?: (name: string, description?: string) => Promise<any>;
|
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spanish bakery categories with subcategories
|
// Spanish bakery categories
|
||||||
const BAKERY_CATEGORIES = {
|
const BAKERY_CATEGORIES = [
|
||||||
harinas: {
|
{ value: 'harinas', label: 'Harinas' },
|
||||||
label: 'Harinas',
|
{ value: 'levaduras', label: 'Levaduras' },
|
||||||
subcategories: ['Harina de trigo', 'Harina integral', 'Harina de fuerza', 'Harina de maíz', 'Harina de centeno', 'Harina sin gluten']
|
{ value: 'azucares', label: 'Azúcares y Endulzantes' },
|
||||||
},
|
{ value: 'chocolates', label: 'Chocolates y Cacao' },
|
||||||
levaduras: {
|
{ value: 'frutas', label: 'Frutas y Frutos Secos' },
|
||||||
label: 'Levaduras',
|
{ value: 'lacteos', label: 'Lácteos' },
|
||||||
subcategories: ['Levadura fresca', 'Levadura seca', 'Levadura química', 'Masa madre', 'Levadura instantánea']
|
{ value: 'huevos', label: 'Huevos' },
|
||||||
},
|
{ value: 'mantequillas', label: 'Mantequillas y Grasas' },
|
||||||
azucares: {
|
{ value: 'especias', label: 'Especias y Aromas' },
|
||||||
label: 'Azúcares y Endulzantes',
|
{ value: 'conservantes', label: 'Conservantes y Aditivos' },
|
||||||
subcategories: ['Azúcar blanco', 'Azúcar moreno', 'Azúcar glass', 'Miel', 'Jarabe de arce', 'Stevia', 'Azúcar invertido']
|
{ value: 'decoracion', label: 'Decoración' },
|
||||||
},
|
{ value: 'envases', label: 'Envases y Embalajes' },
|
||||||
chocolates: {
|
{ value: 'utensilios', label: 'Utensilios y Equipos' },
|
||||||
label: 'Chocolates y Cacao',
|
{ value: 'limpieza', label: 'Limpieza e Higiene' },
|
||||||
subcategories: ['Chocolate negro', 'Chocolate con leche', 'Chocolate blanco', 'Cacao en polvo', 'Pepitas de chocolate', 'Cobertura']
|
];
|
||||||
},
|
|
||||||
frutas: {
|
|
||||||
label: 'Frutas y Frutos Secos',
|
|
||||||
subcategories: ['Almendras', 'Nueces', 'Pasas', 'Fruta confitada', 'Mermeladas', 'Frutas frescas', 'Frutos del bosque']
|
|
||||||
},
|
|
||||||
lacteos: {
|
|
||||||
label: 'Lácteos',
|
|
||||||
subcategories: ['Leche entera', 'Leche desnatada', 'Nata', 'Queso mascarpone', 'Yogur', 'Suero de leche']
|
|
||||||
},
|
|
||||||
huevos: {
|
|
||||||
label: 'Huevos',
|
|
||||||
subcategories: ['Huevos frescos', 'Clara de huevo', 'Yema de huevo', 'Huevo pasteurizado']
|
|
||||||
},
|
|
||||||
mantequillas: {
|
|
||||||
label: 'Mantequillas y Grasas',
|
|
||||||
subcategories: ['Mantequilla', 'Margarina', 'Aceite de girasol', 'Aceite de oliva', 'Manteca']
|
|
||||||
},
|
|
||||||
especias: {
|
|
||||||
label: 'Especias y Aromas',
|
|
||||||
subcategories: ['Vainilla', 'Canela', 'Cardamomo', 'Esencias', 'Colorantes', 'Sal', 'Bicarbonato']
|
|
||||||
},
|
|
||||||
conservantes: {
|
|
||||||
label: 'Conservantes y Aditivos',
|
|
||||||
subcategories: ['Ácido ascórbico', 'Lecitina', 'Emulgentes', 'Estabilizantes', 'Antioxidantes']
|
|
||||||
},
|
|
||||||
decoracion: {
|
|
||||||
label: 'Decoración',
|
|
||||||
subcategories: ['Fondant', 'Pasta de goma', 'Perlas de azúcar', 'Sprinkles', 'Moldes', 'Papel comestible']
|
|
||||||
},
|
|
||||||
envases: {
|
|
||||||
label: 'Envases y Embalajes',
|
|
||||||
subcategories: ['Cajas de cartón', 'Bolsas', 'Papel encerado', 'Film transparente', 'Etiquetas']
|
|
||||||
},
|
|
||||||
utensilios: {
|
|
||||||
label: 'Utensilios y Equipos',
|
|
||||||
subcategories: ['Moldes', 'Boquillas', 'Espátulas', 'Batidores', 'Termómetros']
|
|
||||||
},
|
|
||||||
limpieza: {
|
|
||||||
label: 'Limpieza e Higiene',
|
|
||||||
subcategories: ['Detergentes', 'Desinfectantes', 'Guantes', 'Paños', 'Productos sanitarios']
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const UNITS_OF_MEASURE = [
|
const UNITS_OF_MEASURE = [
|
||||||
{ value: UnitOfMeasure.KILOGRAM, label: 'Kilogramo (kg)' },
|
{ value: 'kg', label: 'Kilogramo (kg)' },
|
||||||
{ value: UnitOfMeasure.GRAM, label: 'Gramo (g)' },
|
{ value: 'g', label: 'Gramo (g)' },
|
||||||
{ value: UnitOfMeasure.LITER, label: 'Litro (l)' },
|
{ value: 'l', label: 'Litro (l)' },
|
||||||
{ value: UnitOfMeasure.MILLILITER, label: 'Mililitro (ml)' },
|
{ value: 'ml', label: 'Mililitro (ml)' },
|
||||||
{ value: UnitOfMeasure.PIECE, label: 'Pieza (pz)' },
|
{ value: 'pz', label: 'Pieza (pz)' },
|
||||||
{ value: UnitOfMeasure.PACKAGE, label: 'Paquete' },
|
{ value: 'pkg', label: 'Paquete' },
|
||||||
{ value: UnitOfMeasure.BAG, label: 'Bolsa' },
|
{ value: 'bag', label: 'Bolsa' },
|
||||||
{ value: UnitOfMeasure.BOX, label: 'Caja' },
|
{ value: 'box', label: 'Caja' },
|
||||||
{ value: UnitOfMeasure.DOZEN, label: 'Docena' },
|
{ value: 'dozen', label: 'Docena' },
|
||||||
{ value: UnitOfMeasure.CUP, label: 'Taza' },
|
{ value: 'cup', label: 'Taza' },
|
||||||
{ value: UnitOfMeasure.TABLESPOON, label: 'Cucharada' },
|
{ value: 'tbsp', label: 'Cucharada' },
|
||||||
{ value: UnitOfMeasure.TEASPOON, label: 'Cucharadita' },
|
{ value: 'tsp', label: 'Cucharadita' },
|
||||||
{ value: UnitOfMeasure.POUND, label: 'Libra (lb)' },
|
{ value: 'lb', label: 'Libra (lb)' },
|
||||||
{ value: UnitOfMeasure.OUNCE, label: 'Onza (oz)' },
|
{ value: 'oz', label: 'Onza (oz)' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const PRODUCT_TYPES = [
|
const initialFormData: IngredientCreate = {
|
||||||
{ value: ProductType.INGREDIENT, label: 'Ingrediente' },
|
|
||||||
{ value: ProductType.FINISHED_PRODUCT, label: 'Producto Terminado' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const initialFormData: IngredientFormData = {
|
|
||||||
name: '',
|
name: '',
|
||||||
product_type: ProductType.INGREDIENT,
|
|
||||||
sku: '',
|
|
||||||
barcode: '',
|
|
||||||
category: '',
|
|
||||||
subcategory: '',
|
|
||||||
description: '',
|
description: '',
|
||||||
brand: '',
|
category: '',
|
||||||
unit_of_measure: UnitOfMeasure.KILOGRAM,
|
unit_of_measure: 'kg',
|
||||||
package_size: undefined,
|
|
||||||
standard_cost: undefined,
|
|
||||||
low_stock_threshold: 10,
|
low_stock_threshold: 10,
|
||||||
|
max_stock_level: 100,
|
||||||
reorder_point: 20,
|
reorder_point: 20,
|
||||||
reorder_quantity: 50,
|
shelf_life_days: undefined,
|
||||||
max_stock_level: undefined,
|
|
||||||
requires_refrigeration: false,
|
requires_refrigeration: false,
|
||||||
requires_freezing: false,
|
requires_freezing: false,
|
||||||
storage_temperature_min: undefined,
|
is_seasonal: false,
|
||||||
storage_temperature_max: undefined,
|
supplier_id: undefined,
|
||||||
storage_humidity_max: undefined,
|
average_cost: undefined,
|
||||||
shelf_life_days: undefined,
|
notes: '',
|
||||||
storage_instructions: '',
|
|
||||||
is_perishable: false,
|
|
||||||
allergen_info: {},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const InventoryForm: React.FC<InventoryFormProps> = ({
|
export const InventoryForm: React.FC<InventoryFormProps> = ({
|
||||||
@@ -133,17 +74,11 @@ export const InventoryForm: React.FC<InventoryFormProps> = ({
|
|||||||
open = false,
|
open = false,
|
||||||
onClose,
|
onClose,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onClassify,
|
|
||||||
loading = false,
|
loading = false,
|
||||||
className,
|
className,
|
||||||
}) => {
|
}) => {
|
||||||
const [formData, setFormData] = useState<IngredientFormData>(initialFormData);
|
const [formData, setFormData] = useState<IngredientCreate>(initialFormData);
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
const [classificationSuggestions, setClassificationSuggestions] = useState<any>(null);
|
|
||||||
const [showClassificationModal, setShowClassificationModal] = useState(false);
|
|
||||||
const [classifying, setClassifying] = useState(false);
|
|
||||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
|
||||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const isEditing = !!item;
|
const isEditing = !!item;
|
||||||
|
|
||||||
@@ -152,39 +87,28 @@ export const InventoryForm: React.FC<InventoryFormProps> = ({
|
|||||||
if (item) {
|
if (item) {
|
||||||
setFormData({
|
setFormData({
|
||||||
name: item.name,
|
name: item.name,
|
||||||
product_type: item.product_type,
|
|
||||||
sku: item.sku || '',
|
|
||||||
barcode: item.barcode || '',
|
|
||||||
category: item.category || '',
|
|
||||||
subcategory: item.subcategory || '',
|
|
||||||
description: item.description || '',
|
description: item.description || '',
|
||||||
brand: item.brand || '',
|
category: item.category,
|
||||||
unit_of_measure: item.unit_of_measure,
|
unit_of_measure: item.unit_of_measure,
|
||||||
package_size: item.package_size,
|
|
||||||
standard_cost: item.standard_cost,
|
|
||||||
low_stock_threshold: item.low_stock_threshold,
|
low_stock_threshold: item.low_stock_threshold,
|
||||||
reorder_point: item.reorder_point,
|
|
||||||
reorder_quantity: item.reorder_quantity,
|
|
||||||
max_stock_level: item.max_stock_level,
|
max_stock_level: item.max_stock_level,
|
||||||
|
reorder_point: item.reorder_point,
|
||||||
|
shelf_life_days: item.shelf_life_days,
|
||||||
requires_refrigeration: item.requires_refrigeration,
|
requires_refrigeration: item.requires_refrigeration,
|
||||||
requires_freezing: item.requires_freezing,
|
requires_freezing: item.requires_freezing,
|
||||||
storage_temperature_min: item.storage_temperature_min,
|
is_seasonal: item.is_seasonal,
|
||||||
storage_temperature_max: item.storage_temperature_max,
|
supplier_id: item.supplier_id,
|
||||||
storage_humidity_max: item.storage_humidity_max,
|
average_cost: item.average_cost,
|
||||||
shelf_life_days: item.shelf_life_days,
|
notes: item.notes || '',
|
||||||
storage_instructions: item.storage_instructions || '',
|
|
||||||
is_perishable: item.is_perishable,
|
|
||||||
allergen_info: item.allergen_info || {},
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setFormData(initialFormData);
|
setFormData(initialFormData);
|
||||||
}
|
}
|
||||||
setErrors({});
|
setErrors({});
|
||||||
setClassificationSuggestions(null);
|
|
||||||
}, [item]);
|
}, [item]);
|
||||||
|
|
||||||
// Handle input changes
|
// Handle input changes
|
||||||
const handleInputChange = useCallback((field: keyof IngredientFormData, value: any) => {
|
const handleInputChange = useCallback((field: keyof IngredientCreate, value: any) => {
|
||||||
setFormData(prev => ({ ...prev, [field]: value }));
|
setFormData(prev => ({ ...prev, [field]: value }));
|
||||||
// Clear error for this field
|
// Clear error for this field
|
||||||
if (errors[field]) {
|
if (errors[field]) {
|
||||||
@@ -192,53 +116,6 @@ export const InventoryForm: React.FC<InventoryFormProps> = ({
|
|||||||
}
|
}
|
||||||
}, [errors]);
|
}, [errors]);
|
||||||
|
|
||||||
// Handle image upload
|
|
||||||
const handleImageUpload = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = event.target.files?.[0];
|
|
||||||
if (file) {
|
|
||||||
setImageFile(file);
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = () => setImagePreview(reader.result as string);
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Auto-classify product
|
|
||||||
const handleClassifyProduct = useCallback(async () => {
|
|
||||||
if (!formData.name.trim()) return;
|
|
||||||
|
|
||||||
setClassifying(true);
|
|
||||||
try {
|
|
||||||
const suggestions = await onClassify?.(formData.name, formData.description);
|
|
||||||
if (suggestions) {
|
|
||||||
setClassificationSuggestions(suggestions);
|
|
||||||
setShowClassificationModal(true);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Classification failed:', error);
|
|
||||||
} finally {
|
|
||||||
setClassifying(false);
|
|
||||||
}
|
|
||||||
}, [formData.name, formData.description, onClassify]);
|
|
||||||
|
|
||||||
// Apply classification suggestions
|
|
||||||
const handleApplyClassification = useCallback(() => {
|
|
||||||
if (!classificationSuggestions) return;
|
|
||||||
|
|
||||||
setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
category: classificationSuggestions.category || prev.category,
|
|
||||||
subcategory: classificationSuggestions.subcategory || prev.subcategory,
|
|
||||||
unit_of_measure: classificationSuggestions.suggested_unit || prev.unit_of_measure,
|
|
||||||
is_perishable: classificationSuggestions.is_perishable ?? prev.is_perishable,
|
|
||||||
requires_refrigeration: classificationSuggestions.storage_requirements?.requires_refrigeration ?? prev.requires_refrigeration,
|
|
||||||
requires_freezing: classificationSuggestions.storage_requirements?.requires_freezing ?? prev.requires_freezing,
|
|
||||||
shelf_life_days: classificationSuggestions.storage_requirements?.estimated_shelf_life_days || prev.shelf_life_days,
|
|
||||||
}));
|
|
||||||
|
|
||||||
setShowClassificationModal(false);
|
|
||||||
}, [classificationSuggestions]);
|
|
||||||
|
|
||||||
// Validate form
|
// Validate form
|
||||||
const validateForm = useCallback((): boolean => {
|
const validateForm = useCallback((): boolean => {
|
||||||
const newErrors: Record<string, string> = {};
|
const newErrors: Record<string, string> = {};
|
||||||
@@ -259,20 +136,12 @@ export const InventoryForm: React.FC<InventoryFormProps> = ({
|
|||||||
newErrors.reorder_point = 'El punto de reorden no puede ser negativo';
|
newErrors.reorder_point = 'El punto de reorden no puede ser negativo';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (formData.reorder_quantity < 0) {
|
|
||||||
newErrors.reorder_quantity = 'La cantidad de reorden no puede ser negativa';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (formData.max_stock_level !== undefined && formData.max_stock_level < formData.low_stock_threshold) {
|
if (formData.max_stock_level !== undefined && formData.max_stock_level < formData.low_stock_threshold) {
|
||||||
newErrors.max_stock_level = 'El stock máximo debe ser mayor que el mínimo';
|
newErrors.max_stock_level = 'El stock máximo debe ser mayor que el mínimo';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (formData.standard_cost !== undefined && formData.standard_cost < 0) {
|
if (formData.average_cost !== undefined && formData.average_cost < 0) {
|
||||||
newErrors.standard_cost = 'El precio no puede ser negativo';
|
newErrors.average_cost = 'El precio no puede ser negativo';
|
||||||
}
|
|
||||||
|
|
||||||
if (formData.package_size !== undefined && formData.package_size <= 0) {
|
|
||||||
newErrors.package_size = 'El tamaño del paquete debe ser mayor que 0';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (formData.shelf_life_days !== undefined && formData.shelf_life_days <= 0) {
|
if (formData.shelf_life_days !== undefined && formData.shelf_life_days <= 0) {
|
||||||
@@ -296,25 +165,12 @@ export const InventoryForm: React.FC<InventoryFormProps> = ({
|
|||||||
}
|
}
|
||||||
}, [formData, validateForm, onSubmit]);
|
}, [formData, validateForm, onSubmit]);
|
||||||
|
|
||||||
// Get subcategories for selected category
|
|
||||||
const subcategoryOptions = formData.category && BAKERY_CATEGORIES[formData.category as keyof typeof BAKERY_CATEGORIES]
|
|
||||||
? BAKERY_CATEGORIES[formData.category as keyof typeof BAKERY_CATEGORIES].subcategories.map(sub => ({
|
|
||||||
value: sub,
|
|
||||||
label: sub,
|
|
||||||
}))
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const categoryOptions = Object.entries(BAKERY_CATEGORIES).map(([key, { label }]) => ({
|
|
||||||
value: key,
|
|
||||||
label,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
open={open}
|
open={open}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title={isEditing ? 'Editar Ingrediente' : 'Nuevo Ingrediente'}
|
title={isEditing ? 'Editar Ingrediente' : 'Nuevo Ingrediente'}
|
||||||
size="xl"
|
size="lg"
|
||||||
className={className}
|
className={className}
|
||||||
>
|
>
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
@@ -322,177 +178,77 @@ export const InventoryForm: React.FC<InventoryFormProps> = ({
|
|||||||
{/* Left Column - Basic Information */}
|
{/* Left Column - Basic Information */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
<h3 className="text-lg font-medium text-text-primary mb-4">Información Básica</h3>
|
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-4">Información Básica</h3>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex gap-2">
|
<Input
|
||||||
<Input
|
label="Nombre del Ingrediente"
|
||||||
label="Nombre del Ingrediente"
|
isRequired
|
||||||
isRequired
|
value={formData.name}
|
||||||
value={formData.name}
|
onChange={(e) => handleInputChange('name', e.target.value)}
|
||||||
onChange={(e) => handleInputChange('name', e.target.value)}
|
error={errors.name}
|
||||||
error={errors.name}
|
placeholder="Ej. Harina de trigo"
|
||||||
placeholder="Ej. Harina de trigo"
|
/>
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleClassifyProduct}
|
|
||||||
disabled={!formData.name.trim() || classifying}
|
|
||||||
className="mt-8"
|
|
||||||
title="Clasificar automáticamente el producto"
|
|
||||||
>
|
|
||||||
{classifying ? (
|
|
||||||
<svg className="w-4 h-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
||||||
</svg>
|
|
||||||
) : (
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<Select
|
||||||
<Select
|
label="Unidad de Medida"
|
||||||
label="Tipo de Producto"
|
isRequired
|
||||||
value={formData.product_type}
|
value={formData.unit_of_measure}
|
||||||
onChange={(value) => handleInputChange('product_type', value)}
|
onChange={(value) => handleInputChange('unit_of_measure', value)}
|
||||||
options={PRODUCT_TYPES}
|
options={UNITS_OF_MEASURE}
|
||||||
error={errors.product_type}
|
error={errors.unit_of_measure}
|
||||||
/>
|
/>
|
||||||
<Select
|
|
||||||
label="Unidad de Medida"
|
|
||||||
isRequired
|
|
||||||
value={formData.unit_of_measure}
|
|
||||||
onChange={(value) => handleInputChange('unit_of_measure', value)}
|
|
||||||
options={UNITS_OF_MEASURE}
|
|
||||||
error={errors.unit_of_measure}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
label="Descripción"
|
label="Descripción"
|
||||||
value={formData.description}
|
value={formData.description || ''}
|
||||||
onChange={(e) => handleInputChange('description', e.target.value)}
|
onChange={(e) => handleInputChange('description', e.target.value)}
|
||||||
placeholder="Descripción detallada del ingrediente"
|
placeholder="Descripción detallada del ingrediente"
|
||||||
helperText="Descripción opcional para ayudar con la clasificación automática"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<Select
|
||||||
<Input
|
label="Categoría"
|
||||||
label="Marca"
|
value={formData.category || ''}
|
||||||
value={formData.brand}
|
onChange={(value) => handleInputChange('category', value)}
|
||||||
onChange={(e) => handleInputChange('brand', e.target.value)}
|
options={[{ value: '', label: 'Seleccionar categoría' }, ...BAKERY_CATEGORIES]}
|
||||||
placeholder="Marca del producto"
|
placeholder="Seleccionar categoría"
|
||||||
/>
|
/>
|
||||||
<Input
|
|
||||||
label="SKU"
|
|
||||||
value={formData.sku}
|
|
||||||
onChange={(e) => handleInputChange('sku', e.target.value)}
|
|
||||||
placeholder="Código SKU interno"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
label="Código de Barras"
|
label="Notas"
|
||||||
value={formData.barcode}
|
value={formData.notes || ''}
|
||||||
onChange={(e) => handleInputChange('barcode', e.target.value)}
|
onChange={(e) => handleInputChange('notes', e.target.value)}
|
||||||
placeholder="Código de barras EAN/UPC"
|
placeholder="Notas adicionales sobre el ingrediente"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Image Upload */}
|
|
||||||
<Card className="p-4">
|
|
||||||
<h3 className="text-lg font-medium text-text-primary mb-4">Imagen del Producto</h3>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
onChange={handleImageUpload}
|
|
||||||
className="block w-full text-sm text-text-secondary file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-bg-secondary file:text-text-primary hover:file:bg-bg-tertiary"
|
|
||||||
/>
|
|
||||||
{imagePreview && (
|
|
||||||
<div className="mt-2">
|
|
||||||
<img
|
|
||||||
src={imagePreview}
|
|
||||||
alt="Preview"
|
|
||||||
className="w-32 h-32 object-cover rounded-md border border-border-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Column - Categories and Specifications */}
|
{/* Right Column - Specifications */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
<h3 className="text-lg font-medium text-text-primary mb-4">Categorización</h3>
|
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-4">Precios y Costos</h3>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Select
|
<Input
|
||||||
label="Categoría"
|
label="Costo Promedio"
|
||||||
value={formData.category}
|
type="number"
|
||||||
onChange={(value) => handleInputChange('category', value)}
|
step="0.01"
|
||||||
options={[{ value: '', label: 'Seleccionar categoría' }, ...categoryOptions]}
|
min="0"
|
||||||
placeholder="Seleccionar categoría"
|
value={formData.average_cost?.toString() || ''}
|
||||||
error={errors.category}
|
onChange={(e) => handleInputChange('average_cost', e.target.value ? parseFloat(e.target.value) : undefined)}
|
||||||
|
error={errors.average_cost}
|
||||||
|
leftAddon="€"
|
||||||
|
placeholder="0.00"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{subcategoryOptions.length > 0 && (
|
|
||||||
<Select
|
|
||||||
label="Subcategoría"
|
|
||||||
value={formData.subcategory}
|
|
||||||
onChange={(value) => handleInputChange('subcategory', value)}
|
|
||||||
options={[{ value: '', label: 'Seleccionar subcategoría' }, ...subcategoryOptions]}
|
|
||||||
placeholder="Seleccionar subcategoría"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
<h3 className="text-lg font-medium text-text-primary mb-4">Precios y Cantidades</h3>
|
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-4">Gestión de Stock</h3>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Input
|
|
||||||
label="Precio Estándar"
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
min="0"
|
|
||||||
value={formData.standard_cost?.toString() || ''}
|
|
||||||
onChange={(e) => handleInputChange('standard_cost', e.target.value ? parseFloat(e.target.value) : undefined)}
|
|
||||||
error={errors.standard_cost}
|
|
||||||
leftAddon="€"
|
|
||||||
placeholder="0.00"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="Tamaño del Paquete"
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
min="0"
|
|
||||||
value={formData.package_size?.toString() || ''}
|
|
||||||
onChange={(e) => handleInputChange('package_size', e.target.value ? parseFloat(e.target.value) : undefined)}
|
|
||||||
error={errors.package_size}
|
|
||||||
rightAddon={formData.unit_of_measure}
|
|
||||||
placeholder="0.00"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-4">
|
|
||||||
<h3 className="text-lg font-medium text-text-primary mb-4">Gestión de Stock</h3>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
<Input
|
<Input
|
||||||
label="Stock Mínimo"
|
label="Stock Mínimo"
|
||||||
isRequired
|
isRequired
|
||||||
@@ -515,17 +271,6 @@ export const InventoryForm: React.FC<InventoryFormProps> = ({
|
|||||||
error={errors.reorder_point}
|
error={errors.reorder_point}
|
||||||
placeholder="20"
|
placeholder="20"
|
||||||
/>
|
/>
|
||||||
<Input
|
|
||||||
label="Cantidad de Reorden"
|
|
||||||
isRequired
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
min="0"
|
|
||||||
value={formData.reorder_quantity?.toString() || ''}
|
|
||||||
onChange={(e) => handleInputChange('reorder_quantity', parseFloat(e.target.value) || 0)}
|
|
||||||
error={errors.reorder_quantity}
|
|
||||||
placeholder="50"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
@@ -546,122 +291,55 @@ export const InventoryForm: React.FC<InventoryFormProps> = ({
|
|||||||
|
|
||||||
{/* Storage and Preservation */}
|
{/* Storage and Preservation */}
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
<h3 className="text-lg font-medium text-text-primary mb-4">Almacenamiento y Conservación</h3>
|
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-4">Almacenamiento y Conservación</h3>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex flex-wrap gap-4">
|
<div className="flex flex-wrap gap-4">
|
||||||
<label className="flex items-center">
|
<label className="flex items-center">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={formData.is_perishable}
|
checked={formData.is_seasonal || false}
|
||||||
onChange={(e) => handleInputChange('is_perishable', e.target.checked)}
|
onChange={(e) => handleInputChange('is_seasonal', e.target.checked)}
|
||||||
className="rounded border-input-border text-color-primary focus:ring-color-primary"
|
className="rounded border-[var(--border-primary)] text-[var(--color-primary)] focus:ring-[var(--color-primary)]"
|
||||||
/>
|
/>
|
||||||
<span className="ml-2 text-sm text-text-primary">Producto Perecedero</span>
|
<span className="ml-2 text-sm text-[var(--text-primary)]">Producto Estacional</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="flex items-center">
|
<label className="flex items-center">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={formData.requires_refrigeration}
|
checked={formData.requires_refrigeration || false}
|
||||||
onChange={(e) => handleInputChange('requires_refrigeration', e.target.checked)}
|
onChange={(e) => handleInputChange('requires_refrigeration', e.target.checked)}
|
||||||
className="rounded border-input-border text-color-primary focus:ring-color-primary"
|
className="rounded border-[var(--border-primary)] text-[var(--color-primary)] focus:ring-[var(--color-primary)]"
|
||||||
/>
|
/>
|
||||||
<span className="ml-2 text-sm text-text-primary">Requiere Refrigeración</span>
|
<span className="ml-2 text-sm text-[var(--text-primary)]">Requiere Refrigeración</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="flex items-center">
|
<label className="flex items-center">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={formData.requires_freezing}
|
checked={formData.requires_freezing || false}
|
||||||
onChange={(e) => handleInputChange('requires_freezing', e.target.checked)}
|
onChange={(e) => handleInputChange('requires_freezing', e.target.checked)}
|
||||||
className="rounded border-input-border text-color-primary focus:ring-color-primary"
|
className="rounded border-[var(--border-primary)] text-[var(--color-primary)] focus:ring-[var(--color-primary)]"
|
||||||
/>
|
/>
|
||||||
<span className="ml-2 text-sm text-text-primary">Requiere Congelación</span>
|
<span className="ml-2 text-sm text-[var(--text-primary)]">Requiere Congelación</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(formData.requires_refrigeration || formData.requires_freezing) && (
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<Input
|
|
||||||
label="Temperatura Mínima (°C)"
|
|
||||||
type="number"
|
|
||||||
value={formData.storage_temperature_min?.toString() || ''}
|
|
||||||
onChange={(e) => handleInputChange('storage_temperature_min', e.target.value ? parseFloat(e.target.value) : undefined)}
|
|
||||||
placeholder="Ej. -18"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="Temperatura Máxima (°C)"
|
|
||||||
type="number"
|
|
||||||
value={formData.storage_temperature_max?.toString() || ''}
|
|
||||||
onChange={(e) => handleInputChange('storage_temperature_max', e.target.value ? parseFloat(e.target.value) : undefined)}
|
|
||||||
placeholder="Ej. 4"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<Input
|
|
||||||
label="Humedad Máxima (%)"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
value={formData.storage_humidity_max?.toString() || ''}
|
|
||||||
onChange={(e) => handleInputChange('storage_humidity_max', e.target.value ? parseFloat(e.target.value) : undefined)}
|
|
||||||
placeholder="Ej. 65"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="Vida Útil (días)"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
value={formData.shelf_life_days?.toString() || ''}
|
|
||||||
onChange={(e) => handleInputChange('shelf_life_days', e.target.value ? parseFloat(e.target.value) : undefined)}
|
|
||||||
error={errors.shelf_life_days}
|
|
||||||
placeholder="Ej. 30"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
label="Instrucciones de Almacenamiento"
|
label="Vida Útil (días)"
|
||||||
value={formData.storage_instructions}
|
type="number"
|
||||||
onChange={(e) => handleInputChange('storage_instructions', e.target.value)}
|
min="1"
|
||||||
placeholder="Ej. Mantener en lugar seco y fresco, alejado de la luz solar"
|
value={formData.shelf_life_days?.toString() || ''}
|
||||||
helperText="Instrucciones específicas para el almacenamiento del producto"
|
onChange={(e) => handleInputChange('shelf_life_days', e.target.value ? parseFloat(e.target.value) : undefined)}
|
||||||
|
error={errors.shelf_life_days}
|
||||||
|
placeholder="Ej. 30"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Allergen Information */}
|
|
||||||
<Card className="p-4">
|
|
||||||
<h3 className="text-lg font-medium text-text-primary mb-4">Información de Alérgenos</h3>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
||||||
{[
|
|
||||||
{ key: 'contains_gluten', label: 'Gluten' },
|
|
||||||
{ key: 'contains_dairy', label: 'Lácteos' },
|
|
||||||
{ key: 'contains_eggs', label: 'Huevos' },
|
|
||||||
{ key: 'contains_nuts', label: 'Frutos Secos' },
|
|
||||||
{ key: 'contains_soy', label: 'Soja' },
|
|
||||||
{ key: 'contains_shellfish', label: 'Mariscos' },
|
|
||||||
].map(({ key, label }) => (
|
|
||||||
<label key={key} className="flex items-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={formData.allergen_info?.[key] || false}
|
|
||||||
onChange={(e) => handleInputChange('allergen_info', {
|
|
||||||
...formData.allergen_info,
|
|
||||||
[key]: e.target.checked,
|
|
||||||
})}
|
|
||||||
className="rounded border-input-border text-color-primary focus:ring-color-primary"
|
|
||||||
/>
|
|
||||||
<span className="ml-2 text-sm text-text-primary">{label}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Form Actions */}
|
{/* Form Actions */}
|
||||||
<div className="flex gap-4 justify-end pt-4 border-t border-border-primary">
|
<div className="flex gap-4 justify-end pt-4 border-t border-[var(--border-primary)]">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -679,76 +357,6 @@ export const InventoryForm: React.FC<InventoryFormProps> = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* Classification Suggestions Modal */}
|
|
||||||
<Modal
|
|
||||||
open={showClassificationModal}
|
|
||||||
onClose={() => setShowClassificationModal(false)}
|
|
||||||
title="Sugerencias de Clasificación"
|
|
||||||
size="md"
|
|
||||||
>
|
|
||||||
{classificationSuggestions && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<p className="text-text-secondary">
|
|
||||||
Se han encontrado las siguientes sugerencias para "{formData.name}":
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="font-medium">Categoría:</span>
|
|
||||||
<Badge variant="primary">
|
|
||||||
{BAKERY_CATEGORIES[classificationSuggestions.category as keyof typeof BAKERY_CATEGORIES]?.label || classificationSuggestions.category}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{classificationSuggestions.subcategory && (
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="font-medium">Subcategoría:</span>
|
|
||||||
<Badge variant="outline">{classificationSuggestions.subcategory}</Badge>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="font-medium">Unidad Sugerida:</span>
|
|
||||||
<Badge variant="secondary">
|
|
||||||
{UNITS_OF_MEASURE.find(u => u.value === classificationSuggestions.suggested_unit)?.label || classificationSuggestions.suggested_unit}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="font-medium">Perecedero:</span>
|
|
||||||
<Badge variant={classificationSuggestions.is_perishable ? "warning" : "success"}>
|
|
||||||
{classificationSuggestions.is_perishable ? 'Sí' : 'No'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="font-medium">Confianza:</span>
|
|
||||||
<Badge variant={
|
|
||||||
classificationSuggestions.confidence > 0.8 ? "success" :
|
|
||||||
classificationSuggestions.confidence > 0.6 ? "warning" : "error"
|
|
||||||
}>
|
|
||||||
{(classificationSuggestions.confidence * 100).toFixed(0)}%
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2 justify-end">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setShowClassificationModal(false)}
|
|
||||||
>
|
|
||||||
Ignorar
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleApplyClassification}
|
|
||||||
>
|
|
||||||
Aplicar Sugerencias
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,537 +1,158 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React from 'react';
|
||||||
import { clsx } from 'clsx';
|
import { AlertTriangle, Package } from 'lucide-react';
|
||||||
import { Card } from '../../ui';
|
import { Card, Button, Badge } from '../../ui';
|
||||||
import { Badge } from '../../ui';
|
import { IngredientResponse } from '../../../api/types/inventory';
|
||||||
import { Button } from '../../ui';
|
|
||||||
import { Modal } from '../../ui';
|
|
||||||
import { Input } from '../../ui';
|
|
||||||
import { EmptyState } from '../../shared';
|
|
||||||
import { LoadingSpinner } from '../../shared';
|
|
||||||
import { StockAlert, IngredientResponse, AlertSeverity } from '../../../types/inventory.types';
|
|
||||||
import { inventoryService } from '../../../api/services/inventory.service';
|
|
||||||
import { StockLevelIndicator } from './StockLevelIndicator';
|
|
||||||
|
|
||||||
export interface LowStockAlertProps {
|
export interface LowStockAlertProps {
|
||||||
alerts?: StockAlert[];
|
items: IngredientResponse[];
|
||||||
autoRefresh?: boolean;
|
|
||||||
refreshInterval?: number;
|
|
||||||
maxItems?: number;
|
|
||||||
showDismissed?: boolean;
|
|
||||||
compact?: boolean;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
onReorder?: (item: IngredientResponse) => void;
|
onReorder?: (item: IngredientResponse) => void;
|
||||||
onAdjustMinimums?: (item: IngredientResponse) => void;
|
onViewDetails?: (item: IngredientResponse) => void;
|
||||||
onDismiss?: (alertId: string) => void;
|
|
||||||
onRefresh?: () => void;
|
|
||||||
onViewAll?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GroupedAlerts {
|
|
||||||
critical: StockAlert[];
|
|
||||||
low: StockAlert[];
|
|
||||||
out: StockAlert[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SupplierSuggestion {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
lastPrice?: number;
|
|
||||||
lastOrderDate?: string;
|
|
||||||
reliability: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LowStockAlert: React.FC<LowStockAlertProps> = ({
|
export const LowStockAlert: React.FC<LowStockAlertProps> = ({
|
||||||
alerts = [],
|
items = [],
|
||||||
autoRefresh = false,
|
|
||||||
refreshInterval = 30000,
|
|
||||||
maxItems = 10,
|
|
||||||
showDismissed = false,
|
|
||||||
compact = false,
|
|
||||||
className,
|
className,
|
||||||
onReorder,
|
onReorder,
|
||||||
onAdjustMinimums,
|
onViewDetails,
|
||||||
onDismiss,
|
|
||||||
onRefresh,
|
|
||||||
onViewAll,
|
|
||||||
}) => {
|
}) => {
|
||||||
const [localAlerts, setLocalAlerts] = useState<StockAlert[]>(alerts);
|
// Filter items that need attention
|
||||||
const [loading, setLoading] = useState(false);
|
const criticalItems = items.filter(item => item.stock_status === 'out_of_stock');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const lowStockItems = items.filter(item => item.stock_status === 'low_stock');
|
||||||
const [dismissedAlerts, setDismissedAlerts] = useState<Set<string>>(new Set());
|
|
||||||
const [showReorderModal, setShowReorderModal] = useState(false);
|
if (items.length === 0) {
|
||||||
const [showAdjustModal, setShowAdjustModal] = useState(false);
|
return null;
|
||||||
const [selectedAlert, setSelectedAlert] = useState<StockAlert | null>(null);
|
}
|
||||||
const [reorderQuantity, setReorderQuantity] = useState<number>(0);
|
|
||||||
const [newMinimumThreshold, setNewMinimumThreshold] = useState<number>(0);
|
|
||||||
const [supplierSuggestions, setSupplierSuggestions] = useState<SupplierSuggestion[]>([]);
|
|
||||||
|
|
||||||
// Update local alerts when prop changes
|
const getSeverityColor = (status: string) => {
|
||||||
useEffect(() => {
|
switch (status) {
|
||||||
setLocalAlerts(alerts);
|
case 'out_of_stock':
|
||||||
}, [alerts]);
|
|
||||||
|
|
||||||
// Auto-refresh functionality
|
|
||||||
useEffect(() => {
|
|
||||||
if (!autoRefresh || refreshInterval <= 0) return;
|
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
onRefresh?.();
|
|
||||||
}, refreshInterval);
|
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [autoRefresh, refreshInterval, onRefresh]);
|
|
||||||
|
|
||||||
// Load supplier suggestions when modal opens
|
|
||||||
useEffect(() => {
|
|
||||||
if (showReorderModal && selectedAlert?.ingredient_id) {
|
|
||||||
loadSupplierSuggestions(selectedAlert.ingredient_id);
|
|
||||||
}
|
|
||||||
}, [showReorderModal, selectedAlert]);
|
|
||||||
|
|
||||||
const loadSupplierSuggestions = async (ingredientId: string) => {
|
|
||||||
try {
|
|
||||||
// This would typically call a suppliers API
|
|
||||||
// For now, we'll simulate some data
|
|
||||||
setSupplierSuggestions([
|
|
||||||
{ id: '1', name: 'Proveedor Principal', lastPrice: 2.50, reliability: 95 },
|
|
||||||
{ id: '2', name: 'Proveedor Alternativo', lastPrice: 2.80, reliability: 87 },
|
|
||||||
]);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading supplier suggestions:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Group alerts by severity
|
|
||||||
const groupedAlerts = React.useMemo((): GroupedAlerts => {
|
|
||||||
const filtered = localAlerts.filter(alert => {
|
|
||||||
if (!alert.is_active) return false;
|
|
||||||
if (!showDismissed && dismissedAlerts.has(alert.id)) return false;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
critical: filtered.filter(alert =>
|
|
||||||
alert.severity === AlertSeverity.CRITICAL ||
|
|
||||||
alert.alert_type === 'out_of_stock'
|
|
||||||
),
|
|
||||||
low: filtered.filter(alert =>
|
|
||||||
alert.severity === AlertSeverity.HIGH &&
|
|
||||||
alert.alert_type === 'low_stock'
|
|
||||||
),
|
|
||||||
out: filtered.filter(alert => alert.alert_type === 'out_of_stock'),
|
|
||||||
};
|
|
||||||
}, [localAlerts, showDismissed, dismissedAlerts]);
|
|
||||||
|
|
||||||
const totalActiveAlerts = groupedAlerts.critical.length + groupedAlerts.low.length;
|
|
||||||
|
|
||||||
// Handle alert dismissal
|
|
||||||
const handleDismiss = useCallback(async (alertId: string, temporary: boolean = true) => {
|
|
||||||
if (temporary) {
|
|
||||||
setDismissedAlerts(prev => new Set(prev).add(alertId));
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
await inventoryService.acknowledgeAlert(alertId);
|
|
||||||
onDismiss?.(alertId);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error dismissing alert:', error);
|
|
||||||
setError('Error al descartar la alerta');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [onDismiss]);
|
|
||||||
|
|
||||||
// Handle reorder action
|
|
||||||
const handleReorder = useCallback((alert: StockAlert) => {
|
|
||||||
setSelectedAlert(alert);
|
|
||||||
setReorderQuantity(alert.ingredient?.reorder_quantity || 0);
|
|
||||||
setShowReorderModal(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Handle adjust minimums action
|
|
||||||
const handleAdjustMinimums = useCallback((alert: StockAlert) => {
|
|
||||||
setSelectedAlert(alert);
|
|
||||||
setNewMinimumThreshold(alert.threshold_value || alert.ingredient?.low_stock_threshold || 0);
|
|
||||||
setShowAdjustModal(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Confirm reorder
|
|
||||||
const handleConfirmReorder = useCallback(() => {
|
|
||||||
if (selectedAlert?.ingredient) {
|
|
||||||
onReorder?.(selectedAlert.ingredient);
|
|
||||||
}
|
|
||||||
setShowReorderModal(false);
|
|
||||||
setSelectedAlert(null);
|
|
||||||
}, [selectedAlert, onReorder]);
|
|
||||||
|
|
||||||
// Confirm adjust minimums
|
|
||||||
const handleConfirmAdjust = useCallback(() => {
|
|
||||||
if (selectedAlert?.ingredient) {
|
|
||||||
onAdjustMinimums?.(selectedAlert.ingredient);
|
|
||||||
}
|
|
||||||
setShowAdjustModal(false);
|
|
||||||
setSelectedAlert(null);
|
|
||||||
}, [selectedAlert, onAdjustMinimums]);
|
|
||||||
|
|
||||||
// Get severity badge variant
|
|
||||||
const getSeverityVariant = (severity: AlertSeverity): any => {
|
|
||||||
switch (severity) {
|
|
||||||
case AlertSeverity.CRITICAL:
|
|
||||||
return 'error';
|
return 'error';
|
||||||
case AlertSeverity.HIGH:
|
case 'low_stock':
|
||||||
return 'warning';
|
return 'warning';
|
||||||
case AlertSeverity.MEDIUM:
|
|
||||||
return 'info';
|
|
||||||
default:
|
default:
|
||||||
return 'secondary';
|
return 'secondary';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Render alert item
|
const getSeverityText = (status: string) => {
|
||||||
const renderAlertItem = (alert: StockAlert, index: number) => {
|
switch (status) {
|
||||||
const ingredient = alert.ingredient;
|
case 'out_of_stock':
|
||||||
if (!ingredient) return null;
|
return 'Sin Stock';
|
||||||
|
case 'low_stock':
|
||||||
const isCompact = compact || index >= maxItems;
|
return 'Stock Bajo';
|
||||||
|
default:
|
||||||
return (
|
return 'Normal';
|
||||||
<div
|
}
|
||||||
key={alert.id}
|
|
||||||
className={clsx(
|
|
||||||
'flex items-center justify-between p-3 border border-border-primary rounded-lg',
|
|
||||||
'hover:bg-bg-secondary transition-colors duration-150',
|
|
||||||
{
|
|
||||||
'bg-color-error/5 border-color-error/20': alert.severity === AlertSeverity.CRITICAL,
|
|
||||||
'bg-color-warning/5 border-color-warning/20': alert.severity === AlertSeverity.HIGH,
|
|
||||||
'py-2': isCompact,
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
|
||||||
{/* Stock indicator */}
|
|
||||||
<StockLevelIndicator
|
|
||||||
current={alert.current_quantity || 0}
|
|
||||||
minimum={ingredient.low_stock_threshold}
|
|
||||||
maximum={ingredient.max_stock_level}
|
|
||||||
reorderPoint={ingredient.reorder_point}
|
|
||||||
unit={ingredient.unit_of_measure}
|
|
||||||
size={isCompact ? 'xs' : 'sm'}
|
|
||||||
variant="minimal"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Alert info */}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<h4 className="font-medium text-text-primary truncate">
|
|
||||||
{ingredient.name}
|
|
||||||
</h4>
|
|
||||||
<Badge
|
|
||||||
variant={getSeverityVariant(alert.severity)}
|
|
||||||
size="xs"
|
|
||||||
>
|
|
||||||
{alert.severity === AlertSeverity.CRITICAL ? 'Crítico' :
|
|
||||||
alert.severity === AlertSeverity.HIGH ? 'Bajo' :
|
|
||||||
'Normal'}
|
|
||||||
</Badge>
|
|
||||||
{ingredient.category && (
|
|
||||||
<Badge variant="outline" size="xs">
|
|
||||||
{ingredient.category}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!isCompact && (
|
|
||||||
<p className="text-sm text-text-secondary">
|
|
||||||
{alert.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center gap-4 mt-1">
|
|
||||||
<span className="text-sm text-text-tertiary">
|
|
||||||
Stock: {alert.current_quantity || 0} {ingredient.unit_of_measure}
|
|
||||||
</span>
|
|
||||||
{alert.threshold_value && (
|
|
||||||
<span className="text-sm text-text-tertiary">
|
|
||||||
Mín: {alert.threshold_value} {ingredient.unit_of_measure}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
|
||||||
{!isCompact && (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => handleReorder(alert)}
|
|
||||||
title="Crear orden de compra"
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
|
||||||
</svg>
|
|
||||||
Reordenar
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => handleAdjustMinimums(alert)}
|
|
||||||
title="Ajustar umbrales mínimos"
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4" />
|
|
||||||
</svg>
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => handleDismiss(alert.id, true)}
|
|
||||||
title="Descartar alerta temporalmente"
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Loading state
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<Card className={clsx('p-6', className)}>
|
|
||||||
<div className="flex items-center justify-center">
|
|
||||||
<LoadingSpinner size="md" />
|
|
||||||
<span className="ml-2 text-text-secondary">Cargando alertas...</span>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error state
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<Card className={clsx('p-6 border-color-error/20 bg-color-error/5', className)}>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<svg className="w-5 h-5 text-color-error flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="font-medium text-color-error">Error al cargar alertas</h3>
|
|
||||||
<p className="text-sm text-text-secondary">{error}</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={onRefresh}
|
|
||||||
>
|
|
||||||
Reintentar
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Empty state
|
|
||||||
if (totalActiveAlerts === 0) {
|
|
||||||
return (
|
|
||||||
<Card className={clsx('p-6', className)}>
|
|
||||||
<EmptyState
|
|
||||||
title="Sin alertas de stock"
|
|
||||||
description="Todos los productos tienen niveles de stock adecuados"
|
|
||||||
icon={
|
|
||||||
<svg className="w-12 h-12 text-color-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const visibleAlerts = [...groupedAlerts.critical, ...groupedAlerts.low].slice(0, maxItems);
|
|
||||||
const hasMoreAlerts = totalActiveAlerts > maxItems;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<Card className={className}>
|
||||||
<Card className="overflow-hidden">
|
<div className="p-4 border-b border-[var(--border-primary)] bg-[var(--bg-secondary)]">
|
||||||
{/* Header */}
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center justify-between p-4 border-b border-border-primary bg-bg-secondary">
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h2 className="text-lg font-semibold text-text-primary">
|
<AlertTriangle className="w-5 h-5 text-[var(--color-warning)]" />
|
||||||
|
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||||
Alertas de Stock
|
Alertas de Stock
|
||||||
</h2>
|
</h3>
|
||||||
<Badge variant="error" count={groupedAlerts.critical.length} />
|
{criticalItems.length > 0 && (
|
||||||
<Badge variant="warning" count={groupedAlerts.low.length} />
|
<Badge variant="error">{criticalItems.length} críticos</Badge>
|
||||||
</div>
|
)}
|
||||||
|
{lowStockItems.length > 0 && (
|
||||||
<div className="flex items-center gap-2">
|
<Badge variant="warning">{lowStockItems.length} bajos</Badge>
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={onRefresh}
|
|
||||||
title="Actualizar alertas"
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
||||||
</svg>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{hasMoreAlerts && onViewAll && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={onViewAll}
|
|
||||||
>
|
|
||||||
Ver Todas ({totalActiveAlerts})
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Alerts list */}
|
<div className="p-4 space-y-3">
|
||||||
<div className="p-4 space-y-3">
|
{items.slice(0, 5).map((item) => (
|
||||||
{visibleAlerts.map((alert, index) => renderAlertItem(alert, index))}
|
<div
|
||||||
</div>
|
key={item.id}
|
||||||
|
className="flex items-center justify-between p-3 border border-[var(--border-primary)] rounded-lg hover:bg-[var(--bg-secondary)] transition-colors"
|
||||||
{/* Footer actions */}
|
>
|
||||||
{hasMoreAlerts && (
|
<div className="flex items-center gap-3 flex-1">
|
||||||
<div className="px-4 py-3 border-t border-border-primary bg-bg-tertiary">
|
<div className={`w-3 h-3 rounded-full ${
|
||||||
<p className="text-sm text-text-secondary text-center">
|
item.stock_status === 'out_of_stock'
|
||||||
Mostrando {visibleAlerts.length} de {totalActiveAlerts} alertas
|
? 'bg-red-500'
|
||||||
</p>
|
: item.stock_status === 'low_stock'
|
||||||
</div>
|
? 'bg-yellow-500'
|
||||||
)}
|
: 'bg-green-500'
|
||||||
</Card>
|
}`} />
|
||||||
|
|
||||||
{/* Reorder Modal */}
|
<div className="flex-1">
|
||||||
<Modal
|
<div className="flex items-center gap-2 mb-1">
|
||||||
open={showReorderModal}
|
<h4 className="font-medium text-[var(--text-primary)]">
|
||||||
onClose={() => setShowReorderModal(false)}
|
{item.name}
|
||||||
title="Crear Orden de Compra"
|
</h4>
|
||||||
size="md"
|
<Badge
|
||||||
>
|
variant={getSeverityColor(item.stock_status) as any}
|
||||||
{selectedAlert && (
|
size="sm"
|
||||||
<div className="space-y-4">
|
>
|
||||||
<div className="p-4 bg-bg-secondary rounded-lg">
|
{getSeverityText(item.stock_status)}
|
||||||
<h3 className="font-medium text-text-primary">
|
</Badge>
|
||||||
{selectedAlert.ingredient?.name}
|
{item.category && (
|
||||||
</h3>
|
<Badge variant="outline" size="sm">
|
||||||
<p className="text-sm text-text-secondary">
|
{item.category}
|
||||||
Stock actual: {selectedAlert.current_quantity || 0} {selectedAlert.ingredient?.unit_of_measure}
|
</Badge>
|
||||||
</p>
|
)}
|
||||||
<p className="text-sm text-text-secondary">
|
</div>
|
||||||
Stock mínimo: {selectedAlert.ingredient?.low_stock_threshold} {selectedAlert.ingredient?.unit_of_measure}
|
|
||||||
</p>
|
<div className="flex items-center gap-4">
|
||||||
</div>
|
<span className="text-sm text-[var(--text-secondary)]">
|
||||||
|
Stock: {item.current_stock_level} {item.unit_of_measure}
|
||||||
<Input
|
</span>
|
||||||
label="Cantidad a Ordenar"
|
<span className="text-sm text-[var(--text-secondary)]">
|
||||||
type="number"
|
Mín: {item.low_stock_threshold} {item.unit_of_measure}
|
||||||
min="1"
|
</span>
|
||||||
step="0.01"
|
|
||||||
value={reorderQuantity.toString()}
|
|
||||||
onChange={(e) => setReorderQuantity(parseFloat(e.target.value) || 0)}
|
|
||||||
rightAddon={selectedAlert.ingredient?.unit_of_measure}
|
|
||||||
helperText="Cantidad sugerida basada en el punto de reorden configurado"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{supplierSuggestions.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium text-text-primary mb-2">
|
|
||||||
Proveedores Sugeridos
|
|
||||||
</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{supplierSuggestions.map(supplier => (
|
|
||||||
<div key={supplier.id} className="p-2 border border-border-primary rounded-lg">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="font-medium">{supplier.name}</span>
|
|
||||||
<Badge variant="success" size="xs">
|
|
||||||
{supplier.reliability}% confiable
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
{supplier.lastPrice && (
|
|
||||||
<p className="text-sm text-text-secondary">
|
|
||||||
Último precio: €{supplier.lastPrice.toFixed(2)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 justify-end">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
{onViewDetails && (
|
||||||
variant="outline"
|
<Button
|
||||||
onClick={() => setShowReorderModal(false)}
|
size="sm"
|
||||||
>
|
variant="outline"
|
||||||
Cancelar
|
onClick={() => onViewDetails(item)}
|
||||||
</Button>
|
>
|
||||||
<Button
|
Ver
|
||||||
onClick={handleConfirmReorder}
|
</Button>
|
||||||
disabled={reorderQuantity <= 0}
|
)}
|
||||||
>
|
{onReorder && (
|
||||||
Crear Orden de Compra
|
<Button
|
||||||
</Button>
|
size="sm"
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => onReorder(item)}
|
||||||
|
>
|
||||||
|
Reordenar
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
))}
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* Adjust Minimums Modal */}
|
{items.length > 5 && (
|
||||||
<Modal
|
<div className="text-center pt-2">
|
||||||
open={showAdjustModal}
|
<span className="text-sm text-[var(--text-secondary)]">
|
||||||
onClose={() => setShowAdjustModal(false)}
|
Y {items.length - 5} elementos más...
|
||||||
title="Ajustar Umbrales Mínimos"
|
</span>
|
||||||
size="md"
|
|
||||||
>
|
|
||||||
{selectedAlert && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="p-4 bg-bg-secondary rounded-lg">
|
|
||||||
<h3 className="font-medium text-text-primary">
|
|
||||||
{selectedAlert.ingredient?.name}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-text-secondary">
|
|
||||||
Stock actual: {selectedAlert.current_quantity || 0} {selectedAlert.ingredient?.unit_of_measure}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
label="Nuevo Umbral Mínimo"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="0.01"
|
|
||||||
value={newMinimumThreshold.toString()}
|
|
||||||
onChange={(e) => setNewMinimumThreshold(parseFloat(e.target.value) || 0)}
|
|
||||||
rightAddon={selectedAlert.ingredient?.unit_of_measure}
|
|
||||||
helperText="Ajusta el nivel mínimo de stock para este producto"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex gap-2 justify-end">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setShowAdjustModal(false)}
|
|
||||||
>
|
|
||||||
Cancelar
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleConfirmAdjust}
|
|
||||||
disabled={newMinimumThreshold < 0}
|
|
||||||
>
|
|
||||||
Actualizar Umbral
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</div>
|
||||||
</div>
|
|
||||||
|
{items.length === 0 && (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
<Package className="w-12 h-12 text-[var(--text-tertiary)] mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
|
||||||
|
Sin alertas de stock
|
||||||
|
</h3>
|
||||||
|
<p className="text-[var(--text-secondary)]">
|
||||||
|
Todos los productos tienen niveles de stock adecuados
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,4 +3,5 @@ export * from './production';
|
|||||||
export * from './recipes';
|
export * from './recipes';
|
||||||
export * from './procurement';
|
export * from './procurement';
|
||||||
export * from './orders';
|
export * from './orders';
|
||||||
|
export * from './suppliers';
|
||||||
export * from './pos';
|
export * from './pos';
|
||||||
@@ -1,190 +1,180 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { Plus, Download, AlertTriangle, Package, Clock, CheckCircle, Eye, Edit, Calendar, DollarSign } from 'lucide-react';
|
import { Plus, Download, AlertTriangle, Package, Clock, CheckCircle, Eye, Edit, Calendar, DollarSign } from 'lucide-react';
|
||||||
import { Button, Input, Card, Badge, StatsGrid, StatusCard, getStatusColor, StatusModal } from '../../../../components/ui';
|
import { Button, Input, Card, Badge, StatsGrid, StatusCard, getStatusColor, StatusModal } from '../../../../components/ui';
|
||||||
|
import { LoadingSpinner } from '../../../../components/shared';
|
||||||
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
||||||
import { PageHeader } from '../../../../components/layout';
|
import { PageHeader } from '../../../../components/layout';
|
||||||
import { InventoryForm, LowStockAlert } from '../../../../components/domain/inventory';
|
import { InventoryForm, LowStockAlert } from '../../../../components/domain/inventory';
|
||||||
|
import { useIngredients, useLowStockIngredients, useStockAnalytics } from '../../../../api/hooks/inventory';
|
||||||
|
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||||
|
import { IngredientResponse } from '../../../../api/types/inventory';
|
||||||
|
|
||||||
const InventoryPage: React.FC = () => {
|
const InventoryPage: React.FC = () => {
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [modalMode, setModalMode] = useState<'view' | 'edit'>('view');
|
const [modalMode, setModalMode] = useState<'view' | 'edit'>('view');
|
||||||
const [selectedItem, setSelectedItem] = useState<typeof mockInventoryItems[0] | null>(null);
|
const [selectedItem, setSelectedItem] = useState<IngredientResponse | null>(null);
|
||||||
|
|
||||||
|
const currentTenant = useCurrentTenant();
|
||||||
|
const tenantId = currentTenant?.id || '';
|
||||||
|
|
||||||
const mockInventoryItems = [
|
// API Data
|
||||||
{
|
const {
|
||||||
id: '1',
|
data: ingredientsData,
|
||||||
name: 'Harina de Trigo',
|
isLoading: ingredientsLoading,
|
||||||
category: 'Harinas',
|
error: ingredientsError
|
||||||
currentStock: 45,
|
} = useIngredients(tenantId, { search: searchTerm || undefined });
|
||||||
minStock: 20,
|
|
||||||
maxStock: 100,
|
const {
|
||||||
unit: 'kg',
|
data: lowStockData,
|
||||||
cost: 1.20,
|
isLoading: lowStockLoading
|
||||||
supplier: 'Molinos del Sur',
|
} = useLowStockIngredients(tenantId);
|
||||||
lastRestocked: '2024-01-20',
|
|
||||||
expirationDate: '2024-06-30',
|
const {
|
||||||
status: 'normal',
|
data: analyticsData,
|
||||||
},
|
isLoading: analyticsLoading
|
||||||
{
|
} = useStockAnalytics(tenantId);
|
||||||
id: '2',
|
|
||||||
name: 'Levadura Fresca',
|
const ingredients = ingredientsData?.items || [];
|
||||||
category: 'Levaduras',
|
const lowStockItems = lowStockData || [];
|
||||||
currentStock: 8,
|
|
||||||
minStock: 10,
|
|
||||||
maxStock: 25,
|
|
||||||
unit: 'kg',
|
|
||||||
cost: 8.50,
|
|
||||||
supplier: 'Levaduras SA',
|
|
||||||
lastRestocked: '2024-01-25',
|
|
||||||
expirationDate: '2024-02-15',
|
|
||||||
status: 'low',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '3',
|
|
||||||
name: 'Mantequilla',
|
|
||||||
category: 'Lácteos',
|
|
||||||
currentStock: 15,
|
|
||||||
minStock: 5,
|
|
||||||
maxStock: 30,
|
|
||||||
unit: 'kg',
|
|
||||||
cost: 5.80,
|
|
||||||
supplier: 'Lácteos Frescos',
|
|
||||||
lastRestocked: '2024-01-24',
|
|
||||||
expirationDate: '2024-02-10',
|
|
||||||
status: 'normal',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '4',
|
|
||||||
name: 'Azúcar Blanco',
|
|
||||||
category: 'Azúcares',
|
|
||||||
currentStock: 0,
|
|
||||||
minStock: 15,
|
|
||||||
maxStock: 50,
|
|
||||||
unit: 'kg',
|
|
||||||
cost: 0.95,
|
|
||||||
supplier: 'Distribuidora Central',
|
|
||||||
lastRestocked: '2024-01-10',
|
|
||||||
expirationDate: '2024-12-31',
|
|
||||||
status: 'out',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '5',
|
|
||||||
name: 'Leche Entera',
|
|
||||||
category: 'Lácteos',
|
|
||||||
currentStock: 3,
|
|
||||||
minStock: 10,
|
|
||||||
maxStock: 40,
|
|
||||||
unit: 'L',
|
|
||||||
cost: 1.45,
|
|
||||||
supplier: 'Lácteos Frescos',
|
|
||||||
lastRestocked: '2024-01-22',
|
|
||||||
expirationDate: '2024-01-28',
|
|
||||||
status: 'expired',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const getInventoryStatusConfig = (item: typeof mockInventoryItems[0]) => {
|
const getInventoryStatusConfig = (ingredient: IngredientResponse) => {
|
||||||
const { currentStock, minStock, status } = item;
|
const { current_stock_level, low_stock_threshold, stock_status } = ingredient;
|
||||||
|
|
||||||
if (status === 'expired') {
|
switch (stock_status) {
|
||||||
return {
|
case 'out_of_stock':
|
||||||
color: getStatusColor('expired'),
|
return {
|
||||||
text: 'Caducado',
|
color: getStatusColor('cancelled'),
|
||||||
icon: AlertTriangle,
|
text: 'Sin Stock',
|
||||||
isCritical: true,
|
icon: AlertTriangle,
|
||||||
isHighlight: false
|
isCritical: true,
|
||||||
};
|
isHighlight: false
|
||||||
|
};
|
||||||
|
case 'low_stock':
|
||||||
|
return {
|
||||||
|
color: getStatusColor('pending'),
|
||||||
|
text: 'Stock Bajo',
|
||||||
|
icon: AlertTriangle,
|
||||||
|
isCritical: false,
|
||||||
|
isHighlight: true
|
||||||
|
};
|
||||||
|
case 'overstock':
|
||||||
|
return {
|
||||||
|
color: getStatusColor('info'),
|
||||||
|
text: 'Sobrestock',
|
||||||
|
icon: Package,
|
||||||
|
isCritical: false,
|
||||||
|
isHighlight: false
|
||||||
|
};
|
||||||
|
case 'in_stock':
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
color: getStatusColor('completed'),
|
||||||
|
text: 'Normal',
|
||||||
|
icon: CheckCircle,
|
||||||
|
isCritical: false,
|
||||||
|
isHighlight: false
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredItems = useMemo(() => {
|
||||||
|
if (!searchTerm) return ingredients;
|
||||||
|
|
||||||
if (currentStock === 0) {
|
return ingredients.filter(ingredient => {
|
||||||
|
const searchLower = searchTerm.toLowerCase();
|
||||||
|
return ingredient.name.toLowerCase().includes(searchLower) ||
|
||||||
|
ingredient.category.toLowerCase().includes(searchLower) ||
|
||||||
|
(ingredient.description && ingredient.description.toLowerCase().includes(searchLower));
|
||||||
|
});
|
||||||
|
}, [ingredients, searchTerm]);
|
||||||
|
|
||||||
|
const inventoryStats = useMemo(() => {
|
||||||
|
if (!analyticsData) {
|
||||||
return {
|
return {
|
||||||
color: getStatusColor('out'),
|
totalItems: ingredients.length,
|
||||||
text: 'Sin Stock',
|
lowStockItems: lowStockItems.length,
|
||||||
icon: AlertTriangle,
|
outOfStock: ingredients.filter(item => item.stock_status === 'out_of_stock').length,
|
||||||
isCritical: true,
|
expiringSoon: 0, // This would come from expired stock API
|
||||||
isHighlight: false
|
totalValue: ingredients.reduce((sum, item) => sum + (item.current_stock_level * (item.average_cost || 0)), 0),
|
||||||
};
|
categories: [...new Set(ingredients.map(item => item.category))].length,
|
||||||
}
|
|
||||||
|
|
||||||
if (currentStock <= minStock) {
|
|
||||||
return {
|
|
||||||
color: getStatusColor('low'),
|
|
||||||
text: 'Stock Bajo',
|
|
||||||
icon: AlertTriangle,
|
|
||||||
isCritical: false,
|
|
||||||
isHighlight: true
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
color: getStatusColor('normal'),
|
totalItems: analyticsData.total_ingredients || 0,
|
||||||
text: 'Normal',
|
lowStockItems: analyticsData.low_stock_count || 0,
|
||||||
icon: CheckCircle,
|
outOfStock: analyticsData.out_of_stock_count || 0,
|
||||||
isCritical: false,
|
expiringSoon: analyticsData.expiring_soon_count || 0,
|
||||||
isHighlight: false
|
totalValue: analyticsData.total_stock_value || 0,
|
||||||
|
categories: [...new Set(ingredients.map(item => item.category))].length,
|
||||||
};
|
};
|
||||||
};
|
}, [analyticsData, ingredients, lowStockItems]);
|
||||||
|
|
||||||
const filteredItems = mockInventoryItems.filter(item => {
|
const stats = [
|
||||||
const matchesSearch = item.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
||||||
item.category.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
||||||
item.supplier.toLowerCase().includes(searchTerm.toLowerCase());
|
|
||||||
|
|
||||||
return matchesSearch;
|
|
||||||
});
|
|
||||||
|
|
||||||
const lowStockItems = mockInventoryItems.filter(item =>
|
|
||||||
item.currentStock <= item.minStock || item.status === 'low' || item.status === 'out' || item.status === 'expired'
|
|
||||||
);
|
|
||||||
|
|
||||||
const mockInventoryStats = {
|
|
||||||
totalItems: mockInventoryItems.length,
|
|
||||||
lowStockItems: lowStockItems.length,
|
|
||||||
outOfStock: mockInventoryItems.filter(item => item.currentStock === 0).length,
|
|
||||||
expiringSoon: mockInventoryItems.filter(item => item.status === 'expired').length,
|
|
||||||
totalValue: mockInventoryItems.reduce((sum, item) => sum + (item.currentStock * item.cost), 0),
|
|
||||||
categories: [...new Set(mockInventoryItems.map(item => item.category))].length,
|
|
||||||
};
|
|
||||||
|
|
||||||
const inventoryStats = [
|
|
||||||
{
|
{
|
||||||
title: 'Total Artículos',
|
title: 'Total Artículos',
|
||||||
value: mockInventoryStats.totalItems,
|
value: inventoryStats.totalItems,
|
||||||
variant: 'default' as const,
|
variant: 'default' as const,
|
||||||
icon: Package,
|
icon: Package,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Stock Bajo',
|
title: 'Stock Bajo',
|
||||||
value: mockInventoryStats.lowStockItems,
|
value: inventoryStats.lowStockItems,
|
||||||
variant: 'warning' as const,
|
variant: 'warning' as const,
|
||||||
icon: AlertTriangle,
|
icon: AlertTriangle,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Sin Stock',
|
title: 'Sin Stock',
|
||||||
value: mockInventoryStats.outOfStock,
|
value: inventoryStats.outOfStock,
|
||||||
variant: 'error' as const,
|
variant: 'error' as const,
|
||||||
icon: AlertTriangle,
|
icon: AlertTriangle,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Por Caducar',
|
title: 'Por Caducar',
|
||||||
value: mockInventoryStats.expiringSoon,
|
value: inventoryStats.expiringSoon,
|
||||||
variant: 'error' as const,
|
variant: 'error' as const,
|
||||||
icon: Clock,
|
icon: Clock,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Valor Total',
|
title: 'Valor Total',
|
||||||
value: formatters.currency(mockInventoryStats.totalValue),
|
value: formatters.currency(inventoryStats.totalValue),
|
||||||
variant: 'success' as const,
|
variant: 'success' as const,
|
||||||
icon: DollarSign,
|
icon: DollarSign,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Categorías',
|
title: 'Categorías',
|
||||||
value: mockInventoryStats.categories,
|
value: inventoryStats.categories,
|
||||||
variant: 'info' as const,
|
variant: 'info' as const,
|
||||||
icon: Package,
|
icon: Package,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Loading and error states
|
||||||
|
if (ingredientsLoading || analyticsLoading || !tenantId) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-64">
|
||||||
|
<LoadingSpinner text="Cargando inventario..." />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ingredientsError) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<AlertTriangle className="mx-auto h-12 w-12 text-red-500 mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
|
||||||
|
Error al cargar el inventario
|
||||||
|
</h3>
|
||||||
|
<p className="text-[var(--text-secondary)] mb-4">
|
||||||
|
{ingredientsError.message || 'Ha ocurrido un error inesperado'}
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => window.location.reload()}>
|
||||||
|
Reintentar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -211,7 +201,7 @@ const InventoryPage: React.FC = () => {
|
|||||||
|
|
||||||
{/* Stats Grid */}
|
{/* Stats Grid */}
|
||||||
<StatsGrid
|
<StatsGrid
|
||||||
stats={inventoryStats}
|
stats={stats}
|
||||||
columns={3}
|
columns={3}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -240,34 +230,39 @@ const InventoryPage: React.FC = () => {
|
|||||||
|
|
||||||
{/* Inventory Items Grid */}
|
{/* Inventory Items Grid */}
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{filteredItems.map((item) => {
|
{filteredItems.map((ingredient) => {
|
||||||
const statusConfig = getInventoryStatusConfig(item);
|
const statusConfig = getInventoryStatusConfig(ingredient);
|
||||||
const stockPercentage = Math.round((item.currentStock / item.maxStock) * 100);
|
const stockPercentage = ingredient.max_stock_level ?
|
||||||
const isExpiringSoon = new Date(item.expirationDate) < new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
Math.round((ingredient.current_stock_level / ingredient.max_stock_level) * 100) : 0;
|
||||||
const isExpired = new Date(item.expirationDate) < new Date();
|
const averageCost = ingredient.average_cost || 0;
|
||||||
|
const totalValue = ingredient.current_stock_level * averageCost;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StatusCard
|
<StatusCard
|
||||||
key={item.id}
|
key={ingredient.id}
|
||||||
id={item.id}
|
id={ingredient.id}
|
||||||
statusIndicator={statusConfig}
|
statusIndicator={statusConfig}
|
||||||
title={item.name}
|
title={ingredient.name}
|
||||||
subtitle={`${item.category} • ${item.supplier}`}
|
subtitle={`${ingredient.category}${ingredient.description ? ` • ${ingredient.description}` : ''}`}
|
||||||
primaryValue={item.currentStock}
|
primaryValue={ingredient.current_stock_level}
|
||||||
primaryValueLabel={item.unit}
|
primaryValueLabel={ingredient.unit_of_measure}
|
||||||
secondaryInfo={{
|
secondaryInfo={{
|
||||||
label: 'Valor total',
|
label: 'Valor total',
|
||||||
value: `${formatters.currency(item.currentStock * item.cost)} (${formatters.currency(item.cost)}/${item.unit})`
|
value: `${formatters.currency(totalValue)}${averageCost > 0 ? ` (${formatters.currency(averageCost)}/${ingredient.unit_of_measure})` : ''}`
|
||||||
}}
|
}}
|
||||||
progress={{
|
progress={ingredient.max_stock_level ? {
|
||||||
label: 'Nivel de stock',
|
label: 'Nivel de stock',
|
||||||
percentage: stockPercentage,
|
percentage: stockPercentage,
|
||||||
color: statusConfig.color
|
color: statusConfig.color
|
||||||
}}
|
} : undefined}
|
||||||
metadata={[
|
metadata={[
|
||||||
`Rango: ${item.minStock} - ${item.maxStock} ${item.unit}`,
|
`Rango: ${ingredient.low_stock_threshold} - ${ingredient.max_stock_level || 'Sin límite'} ${ingredient.unit_of_measure}`,
|
||||||
`Caduca: ${new Date(item.expirationDate).toLocaleDateString('es-ES')}${isExpired ? ' (CADUCADO)' : isExpiringSoon ? ' (PRONTO)' : ''}`,
|
`Stock disponible: ${ingredient.available_stock} ${ingredient.unit_of_measure}`,
|
||||||
`Último restock: ${new Date(item.lastRestocked).toLocaleDateString('es-ES')}`
|
`Stock reservado: ${ingredient.reserved_stock} ${ingredient.unit_of_measure}`,
|
||||||
|
ingredient.last_restocked ? `Último restock: ${new Date(ingredient.last_restocked).toLocaleDateString('es-ES')}` : 'Sin historial de restock',
|
||||||
|
...(ingredient.requires_refrigeration ? ['Requiere refrigeración'] : []),
|
||||||
|
...(ingredient.requires_freezing ? ['Requiere congelación'] : []),
|
||||||
|
...(ingredient.is_seasonal ? ['Producto estacional'] : [])
|
||||||
]}
|
]}
|
||||||
actions={[
|
actions={[
|
||||||
{
|
{
|
||||||
@@ -275,7 +270,7 @@ const InventoryPage: React.FC = () => {
|
|||||||
icon: Eye,
|
icon: Eye,
|
||||||
variant: 'outline',
|
variant: 'outline',
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
setSelectedItem(item);
|
setSelectedItem(ingredient);
|
||||||
setModalMode('view');
|
setModalMode('view');
|
||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
}
|
}
|
||||||
@@ -285,7 +280,7 @@ const InventoryPage: React.FC = () => {
|
|||||||
icon: Edit,
|
icon: Edit,
|
||||||
variant: 'outline',
|
variant: 'outline',
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
setSelectedItem(item);
|
setSelectedItem(ingredient);
|
||||||
setModalMode('edit');
|
setModalMode('edit');
|
||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
}
|
}
|
||||||
@@ -325,7 +320,7 @@ const InventoryPage: React.FC = () => {
|
|||||||
mode={modalMode}
|
mode={modalMode}
|
||||||
onModeChange={setModalMode}
|
onModeChange={setModalMode}
|
||||||
title={selectedItem.name}
|
title={selectedItem.name}
|
||||||
subtitle={`${selectedItem.category} - ${selectedItem.supplier}`}
|
subtitle={`${selectedItem.category}${selectedItem.description ? ` - ${selectedItem.description}` : ''}`}
|
||||||
statusIndicator={getInventoryStatusConfig(selectedItem)}
|
statusIndicator={getInventoryStatusConfig(selectedItem)}
|
||||||
size="lg"
|
size="lg"
|
||||||
sections={[
|
sections={[
|
||||||
@@ -343,12 +338,12 @@ const InventoryPage: React.FC = () => {
|
|||||||
value: selectedItem.category
|
value: selectedItem.category
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Proveedor',
|
label: 'Descripción',
|
||||||
value: selectedItem.supplier
|
value: selectedItem.description || 'Sin descripción'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Unidad de medida',
|
label: 'Unidad de medida',
|
||||||
value: selectedItem.unit
|
value: selectedItem.unit_of_measure
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -358,22 +353,28 @@ const InventoryPage: React.FC = () => {
|
|||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
label: 'Stock actual',
|
label: 'Stock actual',
|
||||||
value: `${selectedItem.currentStock} ${selectedItem.unit}`,
|
value: `${selectedItem.current_stock_level} ${selectedItem.unit_of_measure}`,
|
||||||
highlight: true
|
highlight: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Stock mínimo',
|
label: 'Stock disponible',
|
||||||
value: `${selectedItem.minStock} ${selectedItem.unit}`
|
value: `${selectedItem.available_stock} ${selectedItem.unit_of_measure}`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Stock reservado',
|
||||||
|
value: `${selectedItem.reserved_stock} ${selectedItem.unit_of_measure}`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Umbral mínimo',
|
||||||
|
value: `${selectedItem.low_stock_threshold} ${selectedItem.unit_of_measure}`
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Stock máximo',
|
label: 'Stock máximo',
|
||||||
value: `${selectedItem.maxStock} ${selectedItem.unit}`
|
value: selectedItem.max_stock_level ? `${selectedItem.max_stock_level} ${selectedItem.unit_of_measure}` : 'Sin límite'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Porcentaje de stock',
|
label: 'Punto de reorden',
|
||||||
value: Math.round((selectedItem.currentStock / selectedItem.maxStock) * 100),
|
value: `${selectedItem.reorder_point} ${selectedItem.unit_of_measure}`
|
||||||
type: 'percentage',
|
|
||||||
highlight: selectedItem.currentStock <= selectedItem.minStock
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -382,35 +383,62 @@ const InventoryPage: React.FC = () => {
|
|||||||
icon: DollarSign,
|
icon: DollarSign,
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
label: 'Costo por unidad',
|
label: 'Costo promedio por unidad',
|
||||||
value: selectedItem.cost,
|
value: selectedItem.average_cost || 0,
|
||||||
type: 'currency'
|
type: 'currency'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Valor total en stock',
|
label: 'Valor total en stock',
|
||||||
value: selectedItem.currentStock * selectedItem.cost,
|
value: selectedItem.current_stock_level * (selectedItem.average_cost || 0),
|
||||||
type: 'currency',
|
type: 'currency',
|
||||||
highlight: true
|
highlight: true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Fechas Importantes',
|
title: 'Información Adicional',
|
||||||
icon: Calendar,
|
icon: Calendar,
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
label: 'Último restock',
|
label: 'Último restock',
|
||||||
value: selectedItem.lastRestocked,
|
value: selectedItem.last_restocked || 'Sin historial',
|
||||||
type: 'date'
|
type: selectedItem.last_restocked ? 'datetime' : undefined
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Fecha de caducidad',
|
label: 'Vida útil',
|
||||||
value: selectedItem.expirationDate,
|
value: selectedItem.shelf_life_days ? `${selectedItem.shelf_life_days} días` : 'No especificada'
|
||||||
type: 'date',
|
},
|
||||||
highlight: new Date(selectedItem.expirationDate) < new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
|
{
|
||||||
|
label: 'Requiere refrigeración',
|
||||||
|
value: selectedItem.requires_refrigeration ? 'Sí' : 'No',
|
||||||
|
highlight: selectedItem.requires_refrigeration
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Requiere congelación',
|
||||||
|
value: selectedItem.requires_freezing ? 'Sí' : 'No',
|
||||||
|
highlight: selectedItem.requires_freezing
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Producto estacional',
|
||||||
|
value: selectedItem.is_seasonal ? 'Sí' : 'No'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Creado',
|
||||||
|
value: selectedItem.created_at,
|
||||||
|
type: 'datetime'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
|
...(selectedItem.notes ? [{
|
||||||
|
title: 'Notas',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
label: 'Observaciones',
|
||||||
|
value: selectedItem.notes,
|
||||||
|
span: 2 as const
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}] : [])
|
||||||
]}
|
]}
|
||||||
onEdit={() => {
|
onEdit={() => {
|
||||||
console.log('Editing inventory item:', selectedItem.id);
|
console.log('Editing inventory item:', selectedItem.id);
|
||||||
|
|||||||
435
frontend/src/pages/app/operations/suppliers/SuppliersPage.tsx
Normal file
435
frontend/src/pages/app/operations/suppliers/SuppliersPage.tsx
Normal file
@@ -0,0 +1,435 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Plus, Download, Building2, Phone, Mail, Eye, Edit, CheckCircle, AlertCircle, Timer, Users, DollarSign } from 'lucide-react';
|
||||||
|
import { Button, Input, Card, Badge, StatsGrid, StatusCard, getStatusColor, StatusModal } from '../../../../components/ui';
|
||||||
|
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
||||||
|
import { PageHeader } from '../../../../components/layout';
|
||||||
|
import { SupplierStatus, SupplierType, PaymentTerms } from '../../../../api/types/suppliers';
|
||||||
|
|
||||||
|
const SuppliersPage: React.FC = () => {
|
||||||
|
const [activeTab] = useState('all');
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [modalMode, setModalMode] = useState<'view' | 'edit'>('view');
|
||||||
|
const [selectedSupplier, setSelectedSupplier] = useState<typeof mockSuppliers[0] | null>(null);
|
||||||
|
|
||||||
|
const mockSuppliers = [
|
||||||
|
{
|
||||||
|
id: 'SUP-2024-001',
|
||||||
|
supplier_code: 'HAR001',
|
||||||
|
name: 'Harinas del Norte S.L.',
|
||||||
|
supplier_type: SupplierType.INGREDIENTS,
|
||||||
|
status: SupplierStatus.ACTIVE,
|
||||||
|
contact_person: 'María González',
|
||||||
|
email: 'maria@harinasdelnorte.es',
|
||||||
|
phone: '+34 987 654 321',
|
||||||
|
city: 'León',
|
||||||
|
country: 'España',
|
||||||
|
payment_terms: PaymentTerms.NET_30,
|
||||||
|
credit_limit: 5000,
|
||||||
|
currency: 'EUR',
|
||||||
|
standard_lead_time: 5,
|
||||||
|
minimum_order_amount: 200,
|
||||||
|
total_orders: 45,
|
||||||
|
total_spend: 12750.50,
|
||||||
|
last_order_date: '2024-01-25T14:30:00Z',
|
||||||
|
performance_score: 92,
|
||||||
|
notes: 'Proveedor principal de harinas. Excelente calidad y puntualidad.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'SUP-2024-002',
|
||||||
|
supplier_code: 'EMB002',
|
||||||
|
name: 'Embalajes Biodegradables SA',
|
||||||
|
supplier_type: SupplierType.PACKAGING,
|
||||||
|
status: SupplierStatus.ACTIVE,
|
||||||
|
contact_person: 'Carlos Ruiz',
|
||||||
|
email: 'carlos@embalajes-bio.com',
|
||||||
|
phone: '+34 600 123 456',
|
||||||
|
city: 'Valencia',
|
||||||
|
country: 'España',
|
||||||
|
payment_terms: PaymentTerms.NET_15,
|
||||||
|
credit_limit: 2500,
|
||||||
|
currency: 'EUR',
|
||||||
|
standard_lead_time: 3,
|
||||||
|
minimum_order_amount: 150,
|
||||||
|
total_orders: 28,
|
||||||
|
total_spend: 4280.75,
|
||||||
|
last_order_date: '2024-01-24T10:15:00Z',
|
||||||
|
performance_score: 88,
|
||||||
|
notes: 'Especialista en packaging sostenible.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'SUP-2024-003',
|
||||||
|
supplier_code: 'MAN003',
|
||||||
|
name: 'Maquinaria Industrial López',
|
||||||
|
supplier_type: SupplierType.EQUIPMENT,
|
||||||
|
status: SupplierStatus.PENDING_APPROVAL,
|
||||||
|
contact_person: 'Ana López',
|
||||||
|
email: 'ana@maquinaria-lopez.es',
|
||||||
|
phone: '+34 655 987 654',
|
||||||
|
city: 'Madrid',
|
||||||
|
country: 'España',
|
||||||
|
payment_terms: PaymentTerms.NET_45,
|
||||||
|
credit_limit: 15000,
|
||||||
|
currency: 'EUR',
|
||||||
|
standard_lead_time: 14,
|
||||||
|
minimum_order_amount: 500,
|
||||||
|
total_orders: 0,
|
||||||
|
total_spend: 0,
|
||||||
|
last_order_date: null,
|
||||||
|
performance_score: null,
|
||||||
|
notes: 'Nuevo proveedor de equipamiento industrial. Pendiente de aprobación.'
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const getSupplierStatusConfig = (status: SupplierStatus) => {
|
||||||
|
const statusConfig = {
|
||||||
|
[SupplierStatus.ACTIVE]: { text: 'Activo', icon: CheckCircle },
|
||||||
|
[SupplierStatus.INACTIVE]: { text: 'Inactivo', icon: Timer },
|
||||||
|
[SupplierStatus.PENDING_APPROVAL]: { text: 'Pendiente Aprobación', icon: AlertCircle },
|
||||||
|
[SupplierStatus.SUSPENDED]: { text: 'Suspendido', icon: AlertCircle },
|
||||||
|
[SupplierStatus.BLACKLISTED]: { text: 'Lista Negra', icon: AlertCircle },
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = statusConfig[status];
|
||||||
|
const Icon = config?.icon;
|
||||||
|
|
||||||
|
return {
|
||||||
|
color: getStatusColor(status === SupplierStatus.ACTIVE ? 'completed' :
|
||||||
|
status === SupplierStatus.PENDING_APPROVAL ? 'pending' : 'cancelled'),
|
||||||
|
text: config?.text || status,
|
||||||
|
icon: Icon,
|
||||||
|
isCritical: status === SupplierStatus.BLACKLISTED,
|
||||||
|
isHighlight: status === SupplierStatus.PENDING_APPROVAL
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSupplierTypeText = (type: SupplierType): string => {
|
||||||
|
const typeMap = {
|
||||||
|
[SupplierType.INGREDIENTS]: 'Ingredientes',
|
||||||
|
[SupplierType.PACKAGING]: 'Embalajes',
|
||||||
|
[SupplierType.EQUIPMENT]: 'Equipamiento',
|
||||||
|
[SupplierType.SERVICES]: 'Servicios',
|
||||||
|
[SupplierType.UTILITIES]: 'Servicios Públicos',
|
||||||
|
[SupplierType.MULTI]: 'Múltiple',
|
||||||
|
};
|
||||||
|
return typeMap[type] || type;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPaymentTermsText = (terms: PaymentTerms): string => {
|
||||||
|
const termsMap = {
|
||||||
|
[PaymentTerms.CASH_ON_DELIVERY]: 'Pago Contraentrega',
|
||||||
|
[PaymentTerms.NET_15]: 'Neto 15 días',
|
||||||
|
[PaymentTerms.NET_30]: 'Neto 30 días',
|
||||||
|
[PaymentTerms.NET_45]: 'Neto 45 días',
|
||||||
|
[PaymentTerms.NET_60]: 'Neto 60 días',
|
||||||
|
[PaymentTerms.PREPAID]: 'Prepago',
|
||||||
|
};
|
||||||
|
return termsMap[terms] || terms;
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredSuppliers = mockSuppliers.filter(supplier => {
|
||||||
|
const matchesSearch = supplier.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
supplier.supplier_code.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
supplier.email?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
supplier.contact_person?.toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
|
|
||||||
|
const matchesTab = activeTab === 'all' || supplier.status === activeTab;
|
||||||
|
|
||||||
|
return matchesSearch && matchesTab;
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockSupplierStats = {
|
||||||
|
total: mockSuppliers.length,
|
||||||
|
active: mockSuppliers.filter(s => s.status === SupplierStatus.ACTIVE).length,
|
||||||
|
pendingApproval: mockSuppliers.filter(s => s.status === SupplierStatus.PENDING_APPROVAL).length,
|
||||||
|
suspended: mockSuppliers.filter(s => s.status === SupplierStatus.SUSPENDED).length,
|
||||||
|
totalSpend: mockSuppliers.reduce((sum, supplier) => sum + supplier.total_spend, 0),
|
||||||
|
averageScore: mockSuppliers
|
||||||
|
.filter(s => s.performance_score !== null)
|
||||||
|
.reduce((sum, supplier, _, arr) => sum + (supplier.performance_score || 0) / arr.length, 0),
|
||||||
|
totalOrders: mockSuppliers.reduce((sum, supplier) => sum + supplier.total_orders, 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{
|
||||||
|
title: 'Total Proveedores',
|
||||||
|
value: mockSupplierStats.total,
|
||||||
|
variant: 'default' as const,
|
||||||
|
icon: Building2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Activos',
|
||||||
|
value: mockSupplierStats.active,
|
||||||
|
variant: 'success' as const,
|
||||||
|
icon: CheckCircle,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Pendientes',
|
||||||
|
value: mockSupplierStats.pendingApproval,
|
||||||
|
variant: 'warning' as const,
|
||||||
|
icon: AlertCircle,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Gasto Total',
|
||||||
|
value: formatters.currency(mockSupplierStats.totalSpend),
|
||||||
|
variant: 'info' as const,
|
||||||
|
icon: DollarSign,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Total Pedidos',
|
||||||
|
value: mockSupplierStats.totalOrders,
|
||||||
|
variant: 'default' as const,
|
||||||
|
icon: Building2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Puntuación Media',
|
||||||
|
value: mockSupplierStats.averageScore.toFixed(1),
|
||||||
|
variant: 'success' as const,
|
||||||
|
icon: CheckCircle,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Gestión de Proveedores"
|
||||||
|
description="Administra y supervisa todos los proveedores de la panadería"
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
id: "export",
|
||||||
|
label: "Exportar",
|
||||||
|
variant: "outline" as const,
|
||||||
|
icon: Download,
|
||||||
|
onClick: () => console.log('Export suppliers')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "new",
|
||||||
|
label: "Nuevo Proveedor",
|
||||||
|
variant: "primary" as const,
|
||||||
|
icon: Plus,
|
||||||
|
onClick: () => setShowForm(true)
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Stats Grid */}
|
||||||
|
<StatsGrid
|
||||||
|
stats={stats}
|
||||||
|
columns={3}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Simplified Controls */}
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Input
|
||||||
|
placeholder="Buscar proveedores por nombre, código, email o contacto..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={() => console.log('Export filtered')}>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Exportar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Suppliers Grid */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{filteredSuppliers.map((supplier) => {
|
||||||
|
const statusConfig = getSupplierStatusConfig(supplier.status);
|
||||||
|
const performanceNote = supplier.performance_score
|
||||||
|
? `Puntuación: ${supplier.performance_score}/100`
|
||||||
|
: 'Sin evaluación';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StatusCard
|
||||||
|
key={supplier.id}
|
||||||
|
id={supplier.id}
|
||||||
|
statusIndicator={statusConfig}
|
||||||
|
title={supplier.name}
|
||||||
|
subtitle={supplier.supplier_code}
|
||||||
|
primaryValue={formatters.currency(supplier.total_spend)}
|
||||||
|
primaryValueLabel={`${supplier.total_orders} pedidos`}
|
||||||
|
secondaryInfo={{
|
||||||
|
label: 'Tipo',
|
||||||
|
value: getSupplierTypeText(supplier.supplier_type)
|
||||||
|
}}
|
||||||
|
metadata={[
|
||||||
|
supplier.contact_person || 'Sin contacto',
|
||||||
|
supplier.email || 'Sin email',
|
||||||
|
supplier.phone || 'Sin teléfono',
|
||||||
|
performanceNote
|
||||||
|
]}
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
label: 'Ver',
|
||||||
|
icon: Eye,
|
||||||
|
variant: 'outline',
|
||||||
|
onClick: () => {
|
||||||
|
setSelectedSupplier(supplier);
|
||||||
|
setModalMode('view');
|
||||||
|
setShowForm(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Editar',
|
||||||
|
icon: Edit,
|
||||||
|
variant: 'outline',
|
||||||
|
onClick: () => {
|
||||||
|
setSelectedSupplier(supplier);
|
||||||
|
setModalMode('edit');
|
||||||
|
setShowForm(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{filteredSuppliers.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Building2 className="mx-auto h-12 w-12 text-[var(--text-tertiary)] mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
|
||||||
|
No se encontraron proveedores
|
||||||
|
</h3>
|
||||||
|
<p className="text-[var(--text-secondary)] mb-4">
|
||||||
|
Intenta ajustar la búsqueda o crear un nuevo proveedor
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => setShowForm(true)}>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Nuevo Proveedor
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Supplier Details Modal */}
|
||||||
|
{showForm && selectedSupplier && (
|
||||||
|
<StatusModal
|
||||||
|
isOpen={showForm}
|
||||||
|
onClose={() => {
|
||||||
|
setShowForm(false);
|
||||||
|
setSelectedSupplier(null);
|
||||||
|
setModalMode('view');
|
||||||
|
}}
|
||||||
|
mode={modalMode}
|
||||||
|
onModeChange={setModalMode}
|
||||||
|
title={selectedSupplier.name}
|
||||||
|
subtitle={`Proveedor ${selectedSupplier.supplier_code}`}
|
||||||
|
statusIndicator={getSupplierStatusConfig(selectedSupplier.status)}
|
||||||
|
size="lg"
|
||||||
|
sections={[
|
||||||
|
{
|
||||||
|
title: 'Información de Contacto',
|
||||||
|
icon: Users,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
label: 'Nombre',
|
||||||
|
value: selectedSupplier.name,
|
||||||
|
highlight: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Persona de Contacto',
|
||||||
|
value: selectedSupplier.contact_person || 'No especificado'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Email',
|
||||||
|
value: selectedSupplier.email || 'No especificado'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Teléfono',
|
||||||
|
value: selectedSupplier.phone || 'No especificado'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Ciudad',
|
||||||
|
value: selectedSupplier.city || 'No especificado'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'País',
|
||||||
|
value: selectedSupplier.country || 'No especificado'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Información Comercial',
|
||||||
|
icon: Building2,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
label: 'Código de Proveedor',
|
||||||
|
value: selectedSupplier.supplier_code,
|
||||||
|
highlight: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Tipo de Proveedor',
|
||||||
|
value: getSupplierTypeText(selectedSupplier.supplier_type)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Condiciones de Pago',
|
||||||
|
value: getPaymentTermsText(selectedSupplier.payment_terms)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Tiempo de Entrega',
|
||||||
|
value: `${selectedSupplier.standard_lead_time} días`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Pedido Mínimo',
|
||||||
|
value: selectedSupplier.minimum_order_amount,
|
||||||
|
type: 'currency'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Límite de Crédito',
|
||||||
|
value: selectedSupplier.credit_limit,
|
||||||
|
type: 'currency'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Rendimiento y Estadísticas',
|
||||||
|
icon: DollarSign,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
label: 'Total de Pedidos',
|
||||||
|
value: selectedSupplier.total_orders.toString()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Gasto Total',
|
||||||
|
value: selectedSupplier.total_spend,
|
||||||
|
type: 'currency',
|
||||||
|
highlight: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Puntuación de Rendimiento',
|
||||||
|
value: selectedSupplier.performance_score ? `${selectedSupplier.performance_score}/100` : 'No evaluado'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Último Pedido',
|
||||||
|
value: selectedSupplier.last_order_date || 'Nunca',
|
||||||
|
type: selectedSupplier.last_order_date ? 'datetime' : undefined
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
...(selectedSupplier.notes ? [{
|
||||||
|
title: 'Notas',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
label: 'Observaciones',
|
||||||
|
value: selectedSupplier.notes,
|
||||||
|
span: 2 as const
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}] : [])
|
||||||
|
]}
|
||||||
|
onEdit={() => {
|
||||||
|
console.log('Editing supplier:', selectedSupplier.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SuppliersPage;
|
||||||
1
frontend/src/pages/app/operations/suppliers/index.ts
Normal file
1
frontend/src/pages/app/operations/suppliers/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as SuppliersPage } from './SuppliersPage';
|
||||||
@@ -15,6 +15,7 @@ const InventoryPage = React.lazy(() => import('../pages/app/operations/inventory
|
|||||||
const ProductionPage = React.lazy(() => import('../pages/app/operations/production/ProductionPage'));
|
const ProductionPage = React.lazy(() => import('../pages/app/operations/production/ProductionPage'));
|
||||||
const RecipesPage = React.lazy(() => import('../pages/app/operations/recipes/RecipesPage'));
|
const RecipesPage = React.lazy(() => import('../pages/app/operations/recipes/RecipesPage'));
|
||||||
const ProcurementPage = React.lazy(() => import('../pages/app/operations/procurement/ProcurementPage'));
|
const ProcurementPage = React.lazy(() => import('../pages/app/operations/procurement/ProcurementPage'));
|
||||||
|
const SuppliersPage = React.lazy(() => import('../pages/app/operations/suppliers/SuppliersPage'));
|
||||||
const OrdersPage = React.lazy(() => import('../pages/app/operations/orders/OrdersPage'));
|
const OrdersPage = React.lazy(() => import('../pages/app/operations/orders/OrdersPage'));
|
||||||
const POSPage = React.lazy(() => import('../pages/app/operations/pos/POSPage'));
|
const POSPage = React.lazy(() => import('../pages/app/operations/pos/POSPage'));
|
||||||
|
|
||||||
@@ -112,6 +113,16 @@ export const AppRouter: React.FC = () => {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/app/operations/suppliers"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AppShell>
|
||||||
|
<SuppliersPage />
|
||||||
|
</AppShell>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/app/operations/orders"
|
path="/app/operations/orders"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -267,6 +267,16 @@ export const routesConfig: RouteConfig[] = [
|
|||||||
showInNavigation: true,
|
showInNavigation: true,
|
||||||
showInBreadcrumbs: true,
|
showInBreadcrumbs: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/app/operations/suppliers',
|
||||||
|
name: 'Suppliers',
|
||||||
|
component: 'SuppliersPage',
|
||||||
|
title: 'Proveedores',
|
||||||
|
icon: 'procurement',
|
||||||
|
requiresAuth: true,
|
||||||
|
showInNavigation: true,
|
||||||
|
showInBreadcrumbs: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/app/operations/pos',
|
path: '/app/operations/pos',
|
||||||
name: 'POS',
|
name: 'POS',
|
||||||
|
|||||||
@@ -157,12 +157,19 @@ async def proxy_tenant_inventory(request: Request, tenant_id: str = Path(...), p
|
|||||||
target_path = f"/api/v1/tenants/{tenant_id}/inventory/{path}".rstrip("/")
|
target_path = f"/api/v1/tenants/{tenant_id}/inventory/{path}".rstrip("/")
|
||||||
return await _proxy_to_inventory_service(request, target_path, tenant_id=tenant_id)
|
return await _proxy_to_inventory_service(request, target_path, tenant_id=tenant_id)
|
||||||
|
|
||||||
@router.api_route("/{tenant_id}/ingredients{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
|
@router.api_route("/{tenant_id}/ingredients/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
|
||||||
async def proxy_tenant_ingredients(request: Request, tenant_id: str = Path(...), path: str = ""):
|
async def proxy_tenant_ingredients(request: Request, tenant_id: str = Path(...), path: str = ""):
|
||||||
"""Proxy tenant ingredient requests to inventory service"""
|
"""Proxy tenant ingredient requests to inventory service"""
|
||||||
# The inventory service ingredient endpoints are now tenant-scoped: /api/v1/tenants/{tenant_id}/ingredients/{path}
|
# The inventory service ingredient endpoints are now tenant-scoped: /api/v1/tenants/{tenant_id}/ingredients/{path}
|
||||||
# Keep the full tenant path structure
|
# Keep the full tenant path structure
|
||||||
target_path = f"/api/v1/tenants/{tenant_id}/ingredients{path}".rstrip("/")
|
target_path = f"/api/v1/tenants/{tenant_id}/ingredients/{path}".rstrip("/")
|
||||||
|
return await _proxy_to_inventory_service(request, target_path, tenant_id=tenant_id)
|
||||||
|
|
||||||
|
@router.api_route("/{tenant_id}/stock/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
|
||||||
|
async def proxy_tenant_stock(request: Request, tenant_id: str = Path(...), path: str = ""):
|
||||||
|
"""Proxy tenant stock requests to inventory service"""
|
||||||
|
# The inventory service stock endpoints are now tenant-scoped: /api/v1/tenants/{tenant_id}/stock/{path}
|
||||||
|
target_path = f"/api/v1/tenants/{tenant_id}/stock/{path}".rstrip("/")
|
||||||
return await _proxy_to_inventory_service(request, target_path, tenant_id=tenant_id)
|
return await _proxy_to_inventory_service(request, target_path, tenant_id=tenant_id)
|
||||||
|
|
||||||
# ================================================================
|
# ================================================================
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ API endpoints for stock management
|
|||||||
|
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, Path, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
@@ -19,31 +19,39 @@ from app.schemas.inventory import (
|
|||||||
StockFilter
|
StockFilter
|
||||||
)
|
)
|
||||||
from shared.auth.decorators import get_current_user_dep
|
from shared.auth.decorators import get_current_user_dep
|
||||||
from shared.auth.tenant_access import verify_tenant_access_dep
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/stock", tags=["stock"])
|
router = APIRouter(tags=["stock"])
|
||||||
|
|
||||||
# Helper function to extract user ID from user object
|
# Helper function to extract user ID from user object
|
||||||
def get_current_user_id(current_user: dict = Depends(get_current_user_dep)) -> UUID:
|
def get_current_user_id(current_user: dict = Depends(get_current_user_dep)) -> UUID:
|
||||||
"""Extract user ID from current user context"""
|
"""Extract user ID from current user context"""
|
||||||
user_id = current_user.get('user_id')
|
user_id = current_user.get('user_id')
|
||||||
if not user_id:
|
if not user_id:
|
||||||
|
# Handle service tokens that don't have UUID user_ids
|
||||||
|
if current_user.get('type') == 'service':
|
||||||
|
return None
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="User ID not found in context"
|
detail="User ID not found in context"
|
||||||
)
|
)
|
||||||
return UUID(user_id)
|
try:
|
||||||
|
return UUID(user_id)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@router.post("/", response_model=StockResponse)
|
@router.post("/tenants/{tenant_id}/stock", response_model=StockResponse)
|
||||||
async def add_stock(
|
async def add_stock(
|
||||||
stock_data: StockCreate,
|
stock_data: StockCreate,
|
||||||
tenant_id: UUID = Depends(verify_tenant_access_dep),
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||||
user_id: UUID = Depends(get_current_user_id),
|
current_user: dict = Depends(get_current_user_dep),
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Add new stock entry"""
|
"""Add new stock entry"""
|
||||||
try:
|
try:
|
||||||
|
# Extract user ID - handle service tokens
|
||||||
|
user_id = get_current_user_id(current_user)
|
||||||
|
|
||||||
service = InventoryService()
|
service = InventoryService()
|
||||||
stock = await service.add_stock(stock_data, tenant_id, user_id)
|
stock = await service.add_stock(stock_data, tenant_id, user_id)
|
||||||
return stock
|
return stock
|
||||||
@@ -59,19 +67,22 @@ async def add_stock(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/consume")
|
@router.post("/tenants/{tenant_id}/stock/consume")
|
||||||
async def consume_stock(
|
async def consume_stock(
|
||||||
ingredient_id: UUID,
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||||
|
ingredient_id: UUID = Query(..., description="Ingredient ID to consume"),
|
||||||
quantity: float = Query(..., gt=0, description="Quantity to consume"),
|
quantity: float = Query(..., gt=0, description="Quantity to consume"),
|
||||||
reference_number: Optional[str] = Query(None, description="Reference number (e.g., production order)"),
|
reference_number: Optional[str] = Query(None, description="Reference number (e.g., production order)"),
|
||||||
notes: Optional[str] = Query(None, description="Additional notes"),
|
notes: Optional[str] = Query(None, description="Additional notes"),
|
||||||
fifo: bool = Query(True, description="Use FIFO (First In, First Out) method"),
|
fifo: bool = Query(True, description="Use FIFO (First In, First Out) method"),
|
||||||
tenant_id: UUID = Depends(verify_tenant_access_dep),
|
current_user: dict = Depends(get_current_user_dep),
|
||||||
user_id: UUID = Depends(get_current_user_id),
|
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Consume stock for production"""
|
"""Consume stock for production"""
|
||||||
try:
|
try:
|
||||||
|
# Extract user ID - handle service tokens
|
||||||
|
user_id = get_current_user_id(current_user)
|
||||||
|
|
||||||
service = InventoryService()
|
service = InventoryService()
|
||||||
consumed_items = await service.consume_stock(
|
consumed_items = await service.consume_stock(
|
||||||
ingredient_id, quantity, tenant_id, user_id, reference_number, notes, fifo
|
ingredient_id, quantity, tenant_id, user_id, reference_number, notes, fifo
|
||||||
@@ -94,31 +105,10 @@ async def consume_stock(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/ingredient/{ingredient_id}", response_model=List[StockResponse])
|
@router.get("/tenants/{tenant_id}/stock/expiring", response_model=List[dict])
|
||||||
async def get_ingredient_stock(
|
|
||||||
ingredient_id: UUID,
|
|
||||||
include_unavailable: bool = Query(False, description="Include unavailable stock"),
|
|
||||||
tenant_id: UUID = Depends(verify_tenant_access_dep),
|
|
||||||
db: AsyncSession = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""Get stock entries for an ingredient"""
|
|
||||||
try:
|
|
||||||
service = InventoryService()
|
|
||||||
stock_entries = await service.get_stock_by_ingredient(
|
|
||||||
ingredient_id, tenant_id, include_unavailable
|
|
||||||
)
|
|
||||||
return stock_entries
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail="Failed to get ingredient stock"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/expiring", response_model=List[dict])
|
|
||||||
async def get_expiring_stock(
|
async def get_expiring_stock(
|
||||||
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||||
days_ahead: int = Query(7, ge=1, le=365, description="Days ahead to check for expiring items"),
|
days_ahead: int = Query(7, ge=1, le=365, description="Days ahead to check for expiring items"),
|
||||||
tenant_id: UUID = Depends(verify_tenant_access_dep),
|
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Get stock items expiring within specified days"""
|
"""Get stock items expiring within specified days"""
|
||||||
@@ -133,9 +123,9 @@ async def get_expiring_stock(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/low-stock", response_model=List[dict])
|
@router.get("/tenants/{tenant_id}/stock/low-stock", response_model=List[dict])
|
||||||
async def get_low_stock(
|
async def get_low_stock(
|
||||||
tenant_id: UUID = Depends(verify_tenant_access_dep),
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Get ingredients with low stock levels"""
|
"""Get ingredients with low stock levels"""
|
||||||
@@ -150,9 +140,9 @@ async def get_low_stock(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/summary", response_model=dict)
|
@router.get("/tenants/{tenant_id}/stock/summary", response_model=dict)
|
||||||
async def get_stock_summary(
|
async def get_stock_summary(
|
||||||
tenant_id: UUID = Depends(verify_tenant_access_dep),
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Get stock summary for dashboard"""
|
"""Get stock summary for dashboard"""
|
||||||
@@ -164,4 +154,164 @@ async def get_stock_summary(
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
detail="Failed to get stock summary"
|
detail="Failed to get stock summary"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tenants/{tenant_id}/stock", response_model=List[StockResponse])
|
||||||
|
async def get_stock(
|
||||||
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||||
|
skip: int = Query(0, ge=0, description="Number of records to skip"),
|
||||||
|
limit: int = Query(100, ge=1, le=1000, description="Number of records to return"),
|
||||||
|
ingredient_id: Optional[UUID] = Query(None, description="Filter by ingredient"),
|
||||||
|
available_only: bool = Query(True, description="Show only available stock"),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Get stock entries with filtering"""
|
||||||
|
try:
|
||||||
|
service = InventoryService()
|
||||||
|
stock_entries = await service.get_stock(
|
||||||
|
tenant_id, skip, limit, ingredient_id, available_only
|
||||||
|
)
|
||||||
|
return stock_entries
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to get stock entries"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tenants/{tenant_id}/stock/{stock_id}", response_model=StockResponse)
|
||||||
|
async def get_stock_entry(
|
||||||
|
stock_id: UUID = Path(..., description="Stock entry ID"),
|
||||||
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Get specific stock entry"""
|
||||||
|
try:
|
||||||
|
service = InventoryService()
|
||||||
|
stock = await service.get_stock_entry(stock_id, tenant_id)
|
||||||
|
|
||||||
|
if not stock:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Stock entry not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
return stock
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to get stock entry"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/tenants/{tenant_id}/stock/{stock_id}", response_model=StockResponse)
|
||||||
|
async def update_stock(
|
||||||
|
stock_id: UUID = Path(..., description="Stock entry ID"),
|
||||||
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||||
|
stock_data: StockUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Update stock entry"""
|
||||||
|
try:
|
||||||
|
service = InventoryService()
|
||||||
|
stock = await service.update_stock(stock_id, stock_data, tenant_id)
|
||||||
|
|
||||||
|
if not stock:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Stock entry not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
return stock
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=str(e)
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to update stock entry"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/tenants/{tenant_id}/stock/{stock_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_stock(
|
||||||
|
stock_id: UUID = Path(..., description="Stock entry ID"),
|
||||||
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Delete stock entry (mark as unavailable)"""
|
||||||
|
try:
|
||||||
|
service = InventoryService()
|
||||||
|
deleted = await service.delete_stock(stock_id, tenant_id)
|
||||||
|
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Stock entry not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to delete stock entry"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tenants/{tenant_id}/stock/movements", response_model=StockMovementResponse)
|
||||||
|
async def create_stock_movement(
|
||||||
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||||
|
movement_data: StockMovementCreate,
|
||||||
|
current_user: dict = Depends(get_current_user_dep),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Create stock movement record"""
|
||||||
|
try:
|
||||||
|
# Extract user ID - handle service tokens
|
||||||
|
user_id = get_current_user_id(current_user)
|
||||||
|
|
||||||
|
service = InventoryService()
|
||||||
|
movement = await service.create_stock_movement(movement_data, tenant_id, user_id)
|
||||||
|
return movement
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=str(e)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to create stock movement"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tenants/{tenant_id}/stock/movements", response_model=List[StockMovementResponse])
|
||||||
|
async def get_stock_movements(
|
||||||
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||||
|
skip: int = Query(0, ge=0, description="Number of records to skip"),
|
||||||
|
limit: int = Query(100, ge=1, le=1000, description="Number of records to return"),
|
||||||
|
ingredient_id: Optional[UUID] = Query(None, description="Filter by ingredient"),
|
||||||
|
movement_type: Optional[str] = Query(None, description="Filter by movement type"),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Get stock movements with filtering"""
|
||||||
|
try:
|
||||||
|
service = InventoryService()
|
||||||
|
movements = await service.get_stock_movements(
|
||||||
|
tenant_id, skip, limit, ingredient_id, movement_type
|
||||||
|
)
|
||||||
|
return movements
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to get stock movements"
|
||||||
)
|
)
|
||||||
@@ -6,6 +6,7 @@ Procurement Service - Business logic for procurement planning and scheduling
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import math
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, date, timedelta
|
from datetime import datetime, date, timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
@@ -371,7 +372,14 @@ class ProcurementService:
|
|||||||
safety_stock = predicted_demand * (request.safety_stock_percentage / 100)
|
safety_stock = predicted_demand * (request.safety_stock_percentage / 100)
|
||||||
|
|
||||||
total_needed = predicted_demand + safety_stock
|
total_needed = predicted_demand + safety_stock
|
||||||
net_requirement = max(Decimal('0'), total_needed - current_stock)
|
|
||||||
|
# Round up to whole numbers for finished products (can't order fractional units)
|
||||||
|
# Use ceiling to ensure we never under-order
|
||||||
|
total_needed_rounded = Decimal(str(math.ceil(float(total_needed))))
|
||||||
|
predicted_demand_rounded = Decimal(str(math.ceil(float(predicted_demand))))
|
||||||
|
safety_stock_rounded = total_needed_rounded - predicted_demand_rounded
|
||||||
|
|
||||||
|
net_requirement = max(Decimal('0'), total_needed_rounded - current_stock)
|
||||||
|
|
||||||
if net_requirement > 0: # Only create requirement if needed
|
if net_requirement > 0: # Only create requirement if needed
|
||||||
requirement_number = await self.requirement_repo.generate_requirement_number(plan_id)
|
requirement_number = await self.requirement_repo.generate_requirement_number(plan_id)
|
||||||
@@ -388,15 +396,15 @@ class ProcurementService:
|
|||||||
'product_sku': item.get('sku', ''),
|
'product_sku': item.get('sku', ''),
|
||||||
'product_category': item.get('category', ''),
|
'product_category': item.get('category', ''),
|
||||||
'product_type': 'product',
|
'product_type': 'product',
|
||||||
'required_quantity': predicted_demand,
|
'required_quantity': predicted_demand_rounded,
|
||||||
'unit_of_measure': item.get('unit', 'units'),
|
'unit_of_measure': item.get('unit', 'units'),
|
||||||
'safety_stock_quantity': safety_stock,
|
'safety_stock_quantity': safety_stock_rounded,
|
||||||
'total_quantity_needed': total_needed,
|
'total_quantity_needed': total_needed_rounded,
|
||||||
'current_stock_level': current_stock,
|
'current_stock_level': current_stock,
|
||||||
'available_stock': current_stock,
|
'available_stock': current_stock,
|
||||||
'net_requirement': net_requirement,
|
'net_requirement': net_requirement,
|
||||||
'forecast_demand': predicted_demand,
|
'forecast_demand': predicted_demand_rounded,
|
||||||
'buffer_demand': safety_stock,
|
'buffer_demand': safety_stock_rounded,
|
||||||
'required_by_date': required_by_date,
|
'required_by_date': required_by_date,
|
||||||
'suggested_order_date': suggested_order_date,
|
'suggested_order_date': suggested_order_date,
|
||||||
'latest_order_date': latest_order_date,
|
'latest_order_date': latest_order_date,
|
||||||
|
|||||||
Reference in New Issue
Block a user