diff --git a/frontend/src/api/hooks/forecasting.ts b/frontend/src/api/hooks/forecasting.ts new file mode 100644 index 00000000..5c0ce78a --- /dev/null +++ b/frontend/src/api/hooks/forecasting.ts @@ -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, 'queryKey' | 'queryFn'> +) => { + return useQuery({ + 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, 'queryKey' | 'queryFn'> +) => { + return useQuery({ + 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, 'queryKey' | 'queryFn'> +) => { + return useQuery({ + 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, 'queryKey' | 'queryFn'> +) => { + return useQuery({ + 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, + options?: Omit, 'queryKey' | 'queryFn' | 'getNextPageParam'> +) => { + const limit = baseParams?.limit || 20; + + return useInfiniteQuery({ + 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, + }); + } + }; +}; \ No newline at end of file diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index ad979ca3..4875e7b1 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -25,6 +25,7 @@ export { trainingService } from './services/training'; export { alertProcessorService } from './services/alert_processor'; export { suppliersService } from './services/suppliers'; export { OrdersService } from './services/orders'; +export { forecastingService } from './services/forecasting'; // Types - Auth export type { @@ -295,6 +296,22 @@ export type { UpdatePlanStatusParams, } 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 export { useAuthProfile, @@ -551,6 +568,21 @@ export { ordersKeys, } 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) export { authKeys, @@ -567,4 +599,5 @@ export { suppliersKeys, ordersKeys, dataImportKeys, + forecastingKeys, }; \ No newline at end of file diff --git a/frontend/src/api/services/forecasting.ts b/frontend/src/api/services/forecasting.ts new file mode 100644 index 00000000..052ccde5 --- /dev/null +++ b/frontend/src/api/services/forecasting.ts @@ -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 { + return apiClient.post( + `/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 { + return apiClient.post( + `/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 { + 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(url); + } + + /** + * Get specific forecast by ID + * GET /tenants/{tenant_id}/forecasts/{forecast_id} + */ + async getForecastById( + tenantId: string, + forecastId: string + ): Promise { + return apiClient.get( + `/tenants/${tenantId}${this.baseUrl}/${forecastId}` + ); + } + + /** + * Delete a forecast + * DELETE /tenants/{tenant_id}/forecasts/{forecast_id} + */ + async deleteForecast( + tenantId: string, + forecastId: string + ): Promise { + return apiClient.delete( + `/tenants/${tenantId}${this.baseUrl}/${forecastId}` + ); + } + + /** + * Get comprehensive forecast statistics + * GET /tenants/{tenant_id}/forecasts/statistics + */ + async getForecastStatistics( + tenantId: string + ): Promise { + return apiClient.get( + `/tenants/${tenantId}${this.baseUrl}/statistics` + ); + } + + /** + * Health check for forecasting service + * GET /health + */ + async getHealthCheck(): Promise { + return apiClient.get('/health'); + } +} + +// Export singleton instance +export const forecastingService = new ForecastingService(); +export default forecastingService; \ No newline at end of file diff --git a/frontend/src/api/services/inventory.ts b/frontend/src/api/services/inventory.ts index 4a5c26d4..5266067e 100644 --- a/frontend/src/api/services/inventory.ts +++ b/frontend/src/api/services/inventory.ts @@ -83,7 +83,7 @@ export class InventoryService { } async getLowStockIngredients(tenantId: string): Promise { - return apiClient.get(`${this.baseUrl}/${tenantId}/ingredients/low-stock`); + return apiClient.get(`${this.baseUrl}/${tenantId}/stock/low-stock`); } // Stock Management @@ -104,7 +104,7 @@ export class InventoryService { queryParams.append('include_unavailable', includeUnavailable.toString()); return apiClient.get( - `${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); const url = queryParams.toString() - ? `${this.baseUrl}/${tenantId}/inventory/analytics?${queryParams.toString()}` - : `${this.baseUrl}/${tenantId}/inventory/analytics`; + ? `${this.baseUrl}/${tenantId}/dashboard/analytics?${queryParams.toString()}` + : `${this.baseUrl}/${tenantId}/dashboard/analytics`; return apiClient.get(url); } diff --git a/frontend/src/api/types/forecasting.ts b/frontend/src/api/types/forecasting.ts new file mode 100644 index 00000000..5e2ed60d --- /dev/null +++ b/frontend/src/api/types/forecasting.ts @@ -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; +} + +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; +} \ No newline at end of file diff --git a/frontend/src/components/domain/inventory/InventoryForm.tsx b/frontend/src/components/domain/inventory/InventoryForm.tsx index 91d14f8a..f367b974 100644 --- a/frontend/src/components/domain/inventory/InventoryForm.tsx +++ b/frontend/src/components/domain/inventory/InventoryForm.tsx @@ -6,126 +6,67 @@ import { Select } from '../../ui'; import { Card } from '../../ui'; import { Badge } from '../../ui'; import { Modal } from '../../ui'; -import { IngredientFormData, UnitOfMeasure, ProductType, IngredientResponse } from '../../../types/inventory.types'; -import { inventoryService } from '../../../api/services/inventory.service'; +import { IngredientCreate, IngredientResponse } from '../../../api/types/inventory'; export interface InventoryFormProps { item?: IngredientResponse; open?: boolean; onClose?: () => void; - onSubmit?: (data: IngredientFormData) => Promise; - onClassify?: (name: string, description?: string) => Promise; + onSubmit?: (data: IngredientCreate) => Promise; loading?: boolean; className?: string; } -// Spanish bakery categories with subcategories -const BAKERY_CATEGORIES = { - harinas: { - label: 'Harinas', - subcategories: ['Harina de trigo', 'Harina integral', 'Harina de fuerza', 'Harina de maíz', 'Harina de centeno', 'Harina sin gluten'] - }, - levaduras: { - label: 'Levaduras', - subcategories: ['Levadura fresca', 'Levadura seca', 'Levadura química', 'Masa madre', 'Levadura instantánea'] - }, - azucares: { - label: 'Azúcares y Endulzantes', - subcategories: ['Azúcar blanco', 'Azúcar moreno', 'Azúcar glass', 'Miel', 'Jarabe de arce', 'Stevia', 'Azúcar invertido'] - }, - chocolates: { - label: 'Chocolates y Cacao', - 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'] - }, -}; +// Spanish bakery categories +const BAKERY_CATEGORIES = [ + { value: 'harinas', label: 'Harinas' }, + { value: 'levaduras', label: 'Levaduras' }, + { value: 'azucares', label: 'Azúcares y Endulzantes' }, + { value: 'chocolates', label: 'Chocolates y Cacao' }, + { value: 'frutas', label: 'Frutas y Frutos Secos' }, + { value: 'lacteos', label: 'Lácteos' }, + { value: 'huevos', label: 'Huevos' }, + { value: 'mantequillas', label: 'Mantequillas y Grasas' }, + { value: 'especias', label: 'Especias y Aromas' }, + { value: 'conservantes', label: 'Conservantes y Aditivos' }, + { value: 'decoracion', label: 'Decoración' }, + { value: 'envases', label: 'Envases y Embalajes' }, + { value: 'utensilios', label: 'Utensilios y Equipos' }, + { value: 'limpieza', label: 'Limpieza e Higiene' }, +]; const UNITS_OF_MEASURE = [ - { value: UnitOfMeasure.KILOGRAM, label: 'Kilogramo (kg)' }, - { value: UnitOfMeasure.GRAM, label: 'Gramo (g)' }, - { value: UnitOfMeasure.LITER, label: 'Litro (l)' }, - { value: UnitOfMeasure.MILLILITER, label: 'Mililitro (ml)' }, - { value: UnitOfMeasure.PIECE, label: 'Pieza (pz)' }, - { value: UnitOfMeasure.PACKAGE, label: 'Paquete' }, - { value: UnitOfMeasure.BAG, label: 'Bolsa' }, - { value: UnitOfMeasure.BOX, label: 'Caja' }, - { value: UnitOfMeasure.DOZEN, label: 'Docena' }, - { value: UnitOfMeasure.CUP, label: 'Taza' }, - { value: UnitOfMeasure.TABLESPOON, label: 'Cucharada' }, - { value: UnitOfMeasure.TEASPOON, label: 'Cucharadita' }, - { value: UnitOfMeasure.POUND, label: 'Libra (lb)' }, - { value: UnitOfMeasure.OUNCE, label: 'Onza (oz)' }, + { value: 'kg', label: 'Kilogramo (kg)' }, + { value: 'g', label: 'Gramo (g)' }, + { value: 'l', label: 'Litro (l)' }, + { value: 'ml', label: 'Mililitro (ml)' }, + { value: 'pz', label: 'Pieza (pz)' }, + { value: 'pkg', label: 'Paquete' }, + { value: 'bag', label: 'Bolsa' }, + { value: 'box', label: 'Caja' }, + { value: 'dozen', label: 'Docena' }, + { value: 'cup', label: 'Taza' }, + { value: 'tbsp', label: 'Cucharada' }, + { value: 'tsp', label: 'Cucharadita' }, + { value: 'lb', label: 'Libra (lb)' }, + { value: 'oz', label: 'Onza (oz)' }, ]; -const PRODUCT_TYPES = [ - { value: ProductType.INGREDIENT, label: 'Ingrediente' }, - { value: ProductType.FINISHED_PRODUCT, label: 'Producto Terminado' }, -]; - -const initialFormData: IngredientFormData = { +const initialFormData: IngredientCreate = { name: '', - product_type: ProductType.INGREDIENT, - sku: '', - barcode: '', - category: '', - subcategory: '', description: '', - brand: '', - unit_of_measure: UnitOfMeasure.KILOGRAM, - package_size: undefined, - standard_cost: undefined, + category: '', + unit_of_measure: 'kg', low_stock_threshold: 10, + max_stock_level: 100, reorder_point: 20, - reorder_quantity: 50, - max_stock_level: undefined, + shelf_life_days: undefined, requires_refrigeration: false, requires_freezing: false, - storage_temperature_min: undefined, - storage_temperature_max: undefined, - storage_humidity_max: undefined, - shelf_life_days: undefined, - storage_instructions: '', - is_perishable: false, - allergen_info: {}, + is_seasonal: false, + supplier_id: undefined, + average_cost: undefined, + notes: '', }; export const InventoryForm: React.FC = ({ @@ -133,17 +74,11 @@ export const InventoryForm: React.FC = ({ open = false, onClose, onSubmit, - onClassify, loading = false, className, }) => { - const [formData, setFormData] = useState(initialFormData); + const [formData, setFormData] = useState(initialFormData); const [errors, setErrors] = useState>({}); - const [classificationSuggestions, setClassificationSuggestions] = useState(null); - const [showClassificationModal, setShowClassificationModal] = useState(false); - const [classifying, setClassifying] = useState(false); - const [imageFile, setImageFile] = useState(null); - const [imagePreview, setImagePreview] = useState(null); const isEditing = !!item; @@ -152,39 +87,28 @@ export const InventoryForm: React.FC = ({ if (item) { setFormData({ name: item.name, - product_type: item.product_type, - sku: item.sku || '', - barcode: item.barcode || '', - category: item.category || '', - subcategory: item.subcategory || '', description: item.description || '', - brand: item.brand || '', + category: item.category, unit_of_measure: item.unit_of_measure, - package_size: item.package_size, - standard_cost: item.standard_cost, low_stock_threshold: item.low_stock_threshold, - reorder_point: item.reorder_point, - reorder_quantity: item.reorder_quantity, 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_freezing: item.requires_freezing, - storage_temperature_min: item.storage_temperature_min, - storage_temperature_max: item.storage_temperature_max, - storage_humidity_max: item.storage_humidity_max, - shelf_life_days: item.shelf_life_days, - storage_instructions: item.storage_instructions || '', - is_perishable: item.is_perishable, - allergen_info: item.allergen_info || {}, + is_seasonal: item.is_seasonal, + supplier_id: item.supplier_id, + average_cost: item.average_cost, + notes: item.notes || '', }); } else { setFormData(initialFormData); } setErrors({}); - setClassificationSuggestions(null); }, [item]); // Handle input changes - const handleInputChange = useCallback((field: keyof IngredientFormData, value: any) => { + const handleInputChange = useCallback((field: keyof IngredientCreate, value: any) => { setFormData(prev => ({ ...prev, [field]: value })); // Clear error for this field if (errors[field]) { @@ -192,53 +116,6 @@ export const InventoryForm: React.FC = ({ } }, [errors]); - // Handle image upload - const handleImageUpload = useCallback((event: React.ChangeEvent) => { - 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 const validateForm = useCallback((): boolean => { const newErrors: Record = {}; @@ -259,20 +136,12 @@ export const InventoryForm: React.FC = ({ 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) { newErrors.max_stock_level = 'El stock máximo debe ser mayor que el mínimo'; } - if (formData.standard_cost !== undefined && formData.standard_cost < 0) { - newErrors.standard_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.average_cost !== undefined && formData.average_cost < 0) { + newErrors.average_cost = 'El precio no puede ser negativo'; } if (formData.shelf_life_days !== undefined && formData.shelf_life_days <= 0) { @@ -296,25 +165,12 @@ export const InventoryForm: React.FC = ({ } }, [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 (
@@ -322,177 +178,77 @@ export const InventoryForm: React.FC = ({ {/* Left Column - Basic Information */}
-

Información Básica

+

Información Básica

-
- handleInputChange('name', e.target.value)} - error={errors.name} - placeholder="Ej. Harina de trigo" - className="flex-1" - /> - -
+ handleInputChange('name', e.target.value)} + error={errors.name} + placeholder="Ej. Harina de trigo" + /> -
- handleInputChange('unit_of_measure', value)} - options={UNITS_OF_MEASURE} - error={errors.unit_of_measure} - /> -
+ handleInputChange('description', e.target.value)} placeholder="Descripción detallada del ingrediente" - helperText="Descripción opcional para ayudar con la clasificación automática" /> -
- handleInputChange('brand', e.target.value)} - placeholder="Marca del producto" - /> - handleInputChange('sku', e.target.value)} - placeholder="Código SKU interno" - /> -
+ handleInputChange('barcode', e.target.value)} - placeholder="Código de barras EAN/UPC" + label="Notas" + value={formData.notes || ''} + onChange={(e) => handleInputChange('notes', e.target.value)} + placeholder="Notas adicionales sobre el ingrediente" />
- - {/* Image Upload */} - -

Imagen del Producto

- -
- - {imagePreview && ( -
- Preview -
- )} -
-
- {/* Right Column - Categories and Specifications */} + {/* Right Column - Specifications */}
-

Categorización

+

Precios y Costos

- handleInputChange('average_cost', e.target.value ? parseFloat(e.target.value) : undefined)} + error={errors.average_cost} + leftAddon="€" + placeholder="0.00" /> - - {subcategoryOptions.length > 0 && ( - handleInputChange('standard_cost', e.target.value ? parseFloat(e.target.value) : undefined)} - error={errors.standard_cost} - leftAddon="€" - placeholder="0.00" - /> - handleInputChange('package_size', e.target.value ? parseFloat(e.target.value) : undefined)} - error={errors.package_size} - rightAddon={formData.unit_of_measure} - placeholder="0.00" - /> -
-
- - - -

Gestión de Stock

- -
-
= ({ error={errors.reorder_point} placeholder="20" /> - handleInputChange('reorder_quantity', parseFloat(e.target.value) || 0)} - error={errors.reorder_quantity} - placeholder="50" - />
= ({ {/* Storage and Preservation */} -

Almacenamiento y Conservación

+

Almacenamiento y Conservación

- {(formData.requires_refrigeration || formData.requires_freezing) && ( -
- handleInputChange('storage_temperature_min', e.target.value ? parseFloat(e.target.value) : undefined)} - placeholder="Ej. -18" - /> - handleInputChange('storage_temperature_max', e.target.value ? parseFloat(e.target.value) : undefined)} - placeholder="Ej. 4" - /> -
- )} - -
- handleInputChange('storage_humidity_max', e.target.value ? parseFloat(e.target.value) : undefined)} - placeholder="Ej. 65" - /> - handleInputChange('shelf_life_days', e.target.value ? parseFloat(e.target.value) : undefined)} - error={errors.shelf_life_days} - placeholder="Ej. 30" - /> -
- handleInputChange('storage_instructions', e.target.value)} - placeholder="Ej. Mantener en lugar seco y fresco, alejado de la luz solar" - helperText="Instrucciones específicas para el almacenamiento del producto" + 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" />
- {/* Allergen Information */} - -

Información de Alérgenos

- -
- {[ - { 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 }) => ( - - ))} -
-
- {/* Form Actions */} -
+
- - {/* Classification Suggestions Modal */} - setShowClassificationModal(false)} - title="Sugerencias de Clasificación" - size="md" - > - {classificationSuggestions && ( -
-

- Se han encontrado las siguientes sugerencias para "{formData.name}": -

- -
-
- Categoría: - - {BAKERY_CATEGORIES[classificationSuggestions.category as keyof typeof BAKERY_CATEGORIES]?.label || classificationSuggestions.category} - -
- - {classificationSuggestions.subcategory && ( -
- Subcategoría: - {classificationSuggestions.subcategory} -
- )} - -
- Unidad Sugerida: - - {UNITS_OF_MEASURE.find(u => u.value === classificationSuggestions.suggested_unit)?.label || classificationSuggestions.suggested_unit} - -
- -
- Perecedero: - - {classificationSuggestions.is_perishable ? 'Sí' : 'No'} - -
- -
- Confianza: - 0.8 ? "success" : - classificationSuggestions.confidence > 0.6 ? "warning" : "error" - }> - {(classificationSuggestions.confidence * 100).toFixed(0)}% - -
-
- -
- - -
-
- )} -
); }; diff --git a/frontend/src/components/domain/inventory/LowStockAlert.tsx b/frontend/src/components/domain/inventory/LowStockAlert.tsx index 1860b4f6..adbb66ef 100644 --- a/frontend/src/components/domain/inventory/LowStockAlert.tsx +++ b/frontend/src/components/domain/inventory/LowStockAlert.tsx @@ -1,537 +1,158 @@ -import React, { useState, useEffect, useCallback } from 'react'; -import { clsx } from 'clsx'; -import { Card } from '../../ui'; -import { Badge } from '../../ui'; -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'; +import React from 'react'; +import { AlertTriangle, Package } from 'lucide-react'; +import { Card, Button, Badge } from '../../ui'; +import { IngredientResponse } from '../../../api/types/inventory'; export interface LowStockAlertProps { - alerts?: StockAlert[]; - autoRefresh?: boolean; - refreshInterval?: number; - maxItems?: number; - showDismissed?: boolean; - compact?: boolean; + items: IngredientResponse[]; className?: string; onReorder?: (item: IngredientResponse) => void; - onAdjustMinimums?: (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; + onViewDetails?: (item: IngredientResponse) => void; } export const LowStockAlert: React.FC = ({ - alerts = [], - autoRefresh = false, - refreshInterval = 30000, - maxItems = 10, - showDismissed = false, - compact = false, + items = [], className, onReorder, - onAdjustMinimums, - onDismiss, - onRefresh, - onViewAll, + onViewDetails, }) => { - const [localAlerts, setLocalAlerts] = useState(alerts); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [dismissedAlerts, setDismissedAlerts] = useState>(new Set()); - const [showReorderModal, setShowReorderModal] = useState(false); - const [showAdjustModal, setShowAdjustModal] = useState(false); - const [selectedAlert, setSelectedAlert] = useState(null); - const [reorderQuantity, setReorderQuantity] = useState(0); - const [newMinimumThreshold, setNewMinimumThreshold] = useState(0); - const [supplierSuggestions, setSupplierSuggestions] = useState([]); + // Filter items that need attention + const criticalItems = items.filter(item => item.stock_status === 'out_of_stock'); + const lowStockItems = items.filter(item => item.stock_status === 'low_stock'); + + if (items.length === 0) { + return null; + } - // Update local alerts when prop changes - useEffect(() => { - setLocalAlerts(alerts); - }, [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: + const getSeverityColor = (status: string) => { + switch (status) { + case 'out_of_stock': return 'error'; - case AlertSeverity.HIGH: + case 'low_stock': return 'warning'; - case AlertSeverity.MEDIUM: - return 'info'; default: return 'secondary'; } }; - // Render alert item - const renderAlertItem = (alert: StockAlert, index: number) => { - const ingredient = alert.ingredient; - if (!ingredient) return null; - - const isCompact = compact || index >= maxItems; - - return ( -
-
- {/* Stock indicator */} - - - {/* Alert info */} -
-
-

- {ingredient.name} -

- - {alert.severity === AlertSeverity.CRITICAL ? 'Crítico' : - alert.severity === AlertSeverity.HIGH ? 'Bajo' : - 'Normal'} - - {ingredient.category && ( - - {ingredient.category} - - )} -
- - {!isCompact && ( -

- {alert.message} -

- )} - -
- - Stock: {alert.current_quantity || 0} {ingredient.unit_of_measure} - - {alert.threshold_value && ( - - Mín: {alert.threshold_value} {ingredient.unit_of_measure} - - )} -
-
-
- - {/* Actions */} -
- {!isCompact && ( - <> - - - - - )} - - -
-
- ); + const getSeverityText = (status: string) => { + switch (status) { + case 'out_of_stock': + return 'Sin Stock'; + case 'low_stock': + return 'Stock Bajo'; + default: + return 'Normal'; + } }; - // Loading state - if (loading) { - return ( - -
- - Cargando alertas... -
-
- ); - } - - // Error state - if (error) { - return ( - -
- - - -
-

Error al cargar alertas

-

{error}

-
- -
-
- ); - } - - // Empty state - if (totalActiveAlerts === 0) { - return ( - - - - - } - /> - - ); - } - - const visibleAlerts = [...groupedAlerts.critical, ...groupedAlerts.low].slice(0, maxItems); - const hasMoreAlerts = totalActiveAlerts > maxItems; - return ( -
- - {/* Header */} -
+ +
+
-

+ +

Alertas de Stock -

- - -

- -
- - - {hasMoreAlerts && onViewAll && ( - + + {criticalItems.length > 0 && ( + {criticalItems.length} críticos + )} + {lowStockItems.length > 0 && ( + {lowStockItems.length} bajos )}
+
- {/* Alerts list */} -
- {visibleAlerts.map((alert, index) => renderAlertItem(alert, index))} -
- - {/* Footer actions */} - {hasMoreAlerts && ( -
-

- Mostrando {visibleAlerts.length} de {totalActiveAlerts} alertas -

-
- )} -
- - {/* Reorder Modal */} - setShowReorderModal(false)} - title="Crear Orden de Compra" - size="md" - > - {selectedAlert && ( -
-
-

- {selectedAlert.ingredient?.name} -

-

- Stock actual: {selectedAlert.current_quantity || 0} {selectedAlert.ingredient?.unit_of_measure} -

-

- Stock mínimo: {selectedAlert.ingredient?.low_stock_threshold} {selectedAlert.ingredient?.unit_of_measure} -

-
- - 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 && ( -
-

- Proveedores Sugeridos -

-
- {supplierSuggestions.map(supplier => ( -
-
- {supplier.name} - - {supplier.reliability}% confiable - -
- {supplier.lastPrice && ( -

- Último precio: €{supplier.lastPrice.toFixed(2)} -

- )} -
- ))} +
+ {items.slice(0, 5).map((item) => ( +
+
+
+ +
+
+

+ {item.name} +

+ + {getSeverityText(item.stock_status)} + + {item.category && ( + + {item.category} + + )} +
+ +
+ + Stock: {item.current_stock_level} {item.unit_of_measure} + + + Mín: {item.low_stock_threshold} {item.unit_of_measure} +
- )} +
-
- - +
+ {onViewDetails && ( + + )} + {onReorder && ( + + )}
- )} - + ))} - {/* Adjust Minimums Modal */} - setShowAdjustModal(false)} - title="Ajustar Umbrales Mínimos" - size="md" - > - {selectedAlert && ( -
-
-

- {selectedAlert.ingredient?.name} -

-

- Stock actual: {selectedAlert.current_quantity || 0} {selectedAlert.ingredient?.unit_of_measure} -

-
- - setNewMinimumThreshold(parseFloat(e.target.value) || 0)} - rightAddon={selectedAlert.ingredient?.unit_of_measure} - helperText="Ajusta el nivel mínimo de stock para este producto" - /> - -
- - -
+ {items.length > 5 && ( +
+ + Y {items.length - 5} elementos más... +
)} - -
+
+ + {items.length === 0 && ( +
+ +

+ Sin alertas de stock +

+

+ Todos los productos tienen niveles de stock adecuados +

+
+ )} + ); }; diff --git a/frontend/src/pages/app/operations/index.ts b/frontend/src/pages/app/operations/index.ts index 21c731e6..2eaabf70 100644 --- a/frontend/src/pages/app/operations/index.ts +++ b/frontend/src/pages/app/operations/index.ts @@ -3,4 +3,5 @@ export * from './production'; export * from './recipes'; export * from './procurement'; export * from './orders'; +export * from './suppliers'; export * from './pos'; \ No newline at end of file diff --git a/frontend/src/pages/app/operations/inventory/InventoryPage.tsx b/frontend/src/pages/app/operations/inventory/InventoryPage.tsx index 06b0fe8c..cb90734e 100644 --- a/frontend/src/pages/app/operations/inventory/InventoryPage.tsx +++ b/frontend/src/pages/app/operations/inventory/InventoryPage.tsx @@ -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 { Button, Input, Card, Badge, StatsGrid, StatusCard, getStatusColor, StatusModal } from '../../../../components/ui'; +import { LoadingSpinner } from '../../../../components/shared'; import { formatters } from '../../../../components/ui/Stats/StatsPresets'; import { PageHeader } from '../../../../components/layout'; import { 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 [searchTerm, setSearchTerm] = useState(''); const [showForm, setShowForm] = useState(false); const [modalMode, setModalMode] = useState<'view' | 'edit'>('view'); - const [selectedItem, setSelectedItem] = useState(null); + const [selectedItem, setSelectedItem] = useState(null); + + const currentTenant = useCurrentTenant(); + const tenantId = currentTenant?.id || ''; - const mockInventoryItems = [ - { - id: '1', - name: 'Harina de Trigo', - category: 'Harinas', - currentStock: 45, - minStock: 20, - maxStock: 100, - unit: 'kg', - cost: 1.20, - supplier: 'Molinos del Sur', - lastRestocked: '2024-01-20', - expirationDate: '2024-06-30', - status: 'normal', - }, - { - id: '2', - name: 'Levadura Fresca', - category: 'Levaduras', - 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', - }, - ]; + // API Data + const { + data: ingredientsData, + isLoading: ingredientsLoading, + error: ingredientsError + } = useIngredients(tenantId, { search: searchTerm || undefined }); + + const { + data: lowStockData, + isLoading: lowStockLoading + } = useLowStockIngredients(tenantId); + + const { + data: analyticsData, + isLoading: analyticsLoading + } = useStockAnalytics(tenantId); + + const ingredients = ingredientsData?.items || []; + const lowStockItems = lowStockData || []; - const getInventoryStatusConfig = (item: typeof mockInventoryItems[0]) => { - const { currentStock, minStock, status } = item; + const getInventoryStatusConfig = (ingredient: IngredientResponse) => { + const { current_stock_level, low_stock_threshold, stock_status } = ingredient; - if (status === 'expired') { - return { - color: getStatusColor('expired'), - text: 'Caducado', - icon: AlertTriangle, - isCritical: true, - isHighlight: false - }; + switch (stock_status) { + case 'out_of_stock': + return { + color: getStatusColor('cancelled'), + text: 'Sin Stock', + icon: AlertTriangle, + 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 { - color: getStatusColor('out'), - text: 'Sin Stock', - icon: AlertTriangle, - isCritical: true, - isHighlight: false - }; - } - - if (currentStock <= minStock) { - return { - color: getStatusColor('low'), - text: 'Stock Bajo', - icon: AlertTriangle, - isCritical: false, - isHighlight: true + totalItems: ingredients.length, + lowStockItems: lowStockItems.length, + outOfStock: ingredients.filter(item => item.stock_status === 'out_of_stock').length, + expiringSoon: 0, // This would come from expired stock API + totalValue: ingredients.reduce((sum, item) => sum + (item.current_stock_level * (item.average_cost || 0)), 0), + categories: [...new Set(ingredients.map(item => item.category))].length, }; } return { - color: getStatusColor('normal'), - text: 'Normal', - icon: CheckCircle, - isCritical: false, - isHighlight: false + totalItems: analyticsData.total_ingredients || 0, + lowStockItems: analyticsData.low_stock_count || 0, + outOfStock: analyticsData.out_of_stock_count || 0, + expiringSoon: analyticsData.expiring_soon_count || 0, + totalValue: analyticsData.total_stock_value || 0, + categories: [...new Set(ingredients.map(item => item.category))].length, }; - }; + }, [analyticsData, ingredients, lowStockItems]); - const filteredItems = mockInventoryItems.filter(item => { - 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 = [ + const stats = [ { title: 'Total Artículos', - value: mockInventoryStats.totalItems, + value: inventoryStats.totalItems, variant: 'default' as const, icon: Package, }, { title: 'Stock Bajo', - value: mockInventoryStats.lowStockItems, + value: inventoryStats.lowStockItems, variant: 'warning' as const, icon: AlertTriangle, }, { title: 'Sin Stock', - value: mockInventoryStats.outOfStock, + value: inventoryStats.outOfStock, variant: 'error' as const, icon: AlertTriangle, }, { title: 'Por Caducar', - value: mockInventoryStats.expiringSoon, + value: inventoryStats.expiringSoon, variant: 'error' as const, icon: Clock, }, { title: 'Valor Total', - value: formatters.currency(mockInventoryStats.totalValue), + value: formatters.currency(inventoryStats.totalValue), variant: 'success' as const, icon: DollarSign, }, { title: 'Categorías', - value: mockInventoryStats.categories, + value: inventoryStats.categories, variant: 'info' as const, icon: Package, }, ]; + + // Loading and error states + if (ingredientsLoading || analyticsLoading || !tenantId) { + return ( +
+ +
+ ); + } + + if (ingredientsError) { + return ( +
+ +

+ Error al cargar el inventario +

+

+ {ingredientsError.message || 'Ha ocurrido un error inesperado'} +

+ +
+ ); + } return (
@@ -211,7 +201,7 @@ const InventoryPage: React.FC = () => { {/* Stats Grid */} @@ -240,34 +230,39 @@ const InventoryPage: React.FC = () => { {/* Inventory Items Grid */}
- {filteredItems.map((item) => { - const statusConfig = getInventoryStatusConfig(item); - const stockPercentage = Math.round((item.currentStock / item.maxStock) * 100); - const isExpiringSoon = new Date(item.expirationDate) < new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); - const isExpired = new Date(item.expirationDate) < new Date(); + {filteredItems.map((ingredient) => { + const statusConfig = getInventoryStatusConfig(ingredient); + const stockPercentage = ingredient.max_stock_level ? + Math.round((ingredient.current_stock_level / ingredient.max_stock_level) * 100) : 0; + const averageCost = ingredient.average_cost || 0; + const totalValue = ingredient.current_stock_level * averageCost; return ( 0 ? ` (${formatters.currency(averageCost)}/${ingredient.unit_of_measure})` : ''}` }} - progress={{ + progress={ingredient.max_stock_level ? { label: 'Nivel de stock', percentage: stockPercentage, color: statusConfig.color - }} + } : undefined} metadata={[ - `Rango: ${item.minStock} - ${item.maxStock} ${item.unit}`, - `Caduca: ${new Date(item.expirationDate).toLocaleDateString('es-ES')}${isExpired ? ' (CADUCADO)' : isExpiringSoon ? ' (PRONTO)' : ''}`, - `Último restock: ${new Date(item.lastRestocked).toLocaleDateString('es-ES')}` + `Rango: ${ingredient.low_stock_threshold} - ${ingredient.max_stock_level || 'Sin límite'} ${ingredient.unit_of_measure}`, + `Stock disponible: ${ingredient.available_stock} ${ingredient.unit_of_measure}`, + `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={[ { @@ -275,7 +270,7 @@ const InventoryPage: React.FC = () => { icon: Eye, variant: 'outline', onClick: () => { - setSelectedItem(item); + setSelectedItem(ingredient); setModalMode('view'); setShowForm(true); } @@ -285,7 +280,7 @@ const InventoryPage: React.FC = () => { icon: Edit, variant: 'outline', onClick: () => { - setSelectedItem(item); + setSelectedItem(ingredient); setModalMode('edit'); setShowForm(true); } @@ -325,7 +320,7 @@ const InventoryPage: React.FC = () => { mode={modalMode} onModeChange={setModalMode} title={selectedItem.name} - subtitle={`${selectedItem.category} - ${selectedItem.supplier}`} + subtitle={`${selectedItem.category}${selectedItem.description ? ` - ${selectedItem.description}` : ''}`} statusIndicator={getInventoryStatusConfig(selectedItem)} size="lg" sections={[ @@ -343,12 +338,12 @@ const InventoryPage: React.FC = () => { value: selectedItem.category }, { - label: 'Proveedor', - value: selectedItem.supplier + label: 'Descripción', + value: selectedItem.description || 'Sin descripción' }, { label: 'Unidad de medida', - value: selectedItem.unit + value: selectedItem.unit_of_measure } ] }, @@ -358,22 +353,28 @@ const InventoryPage: React.FC = () => { fields: [ { label: 'Stock actual', - value: `${selectedItem.currentStock} ${selectedItem.unit}`, + value: `${selectedItem.current_stock_level} ${selectedItem.unit_of_measure}`, highlight: true }, { - label: 'Stock mínimo', - value: `${selectedItem.minStock} ${selectedItem.unit}` + label: 'Stock disponible', + 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', - 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', - value: Math.round((selectedItem.currentStock / selectedItem.maxStock) * 100), - type: 'percentage', - highlight: selectedItem.currentStock <= selectedItem.minStock + label: 'Punto de reorden', + value: `${selectedItem.reorder_point} ${selectedItem.unit_of_measure}` } ] }, @@ -382,35 +383,62 @@ const InventoryPage: React.FC = () => { icon: DollarSign, fields: [ { - label: 'Costo por unidad', - value: selectedItem.cost, + label: 'Costo promedio por unidad', + value: selectedItem.average_cost || 0, type: 'currency' }, { label: 'Valor total en stock', - value: selectedItem.currentStock * selectedItem.cost, + value: selectedItem.current_stock_level * (selectedItem.average_cost || 0), type: 'currency', highlight: true } ] }, { - title: 'Fechas Importantes', + title: 'Información Adicional', icon: Calendar, fields: [ { label: 'Último restock', - value: selectedItem.lastRestocked, - type: 'date' + value: selectedItem.last_restocked || 'Sin historial', + type: selectedItem.last_restocked ? 'datetime' : undefined }, { - label: 'Fecha de caducidad', - value: selectedItem.expirationDate, - type: 'date', - highlight: new Date(selectedItem.expirationDate) < new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) + label: 'Vida útil', + value: selectedItem.shelf_life_days ? `${selectedItem.shelf_life_days} días` : 'No especificada' + }, + { + 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={() => { console.log('Editing inventory item:', selectedItem.id); diff --git a/frontend/src/pages/app/operations/suppliers/SuppliersPage.tsx b/frontend/src/pages/app/operations/suppliers/SuppliersPage.tsx new file mode 100644 index 00000000..1d950d79 --- /dev/null +++ b/frontend/src/pages/app/operations/suppliers/SuppliersPage.tsx @@ -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(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 ( +
+ console.log('Export suppliers') + }, + { + id: "new", + label: "Nuevo Proveedor", + variant: "primary" as const, + icon: Plus, + onClick: () => setShowForm(true) + } + ]} + /> + + {/* Stats Grid */} + + + {/* Simplified Controls */} + +
+
+ setSearchTerm(e.target.value)} + className="w-full" + /> +
+ +
+
+ + {/* Suppliers Grid */} +
+ {filteredSuppliers.map((supplier) => { + const statusConfig = getSupplierStatusConfig(supplier.status); + const performanceNote = supplier.performance_score + ? `Puntuación: ${supplier.performance_score}/100` + : 'Sin evaluación'; + + return ( + { + setSelectedSupplier(supplier); + setModalMode('view'); + setShowForm(true); + } + }, + { + label: 'Editar', + icon: Edit, + variant: 'outline', + onClick: () => { + setSelectedSupplier(supplier); + setModalMode('edit'); + setShowForm(true); + } + } + ]} + /> + ); + })} +
+ + {/* Empty State */} + {filteredSuppliers.length === 0 && ( +
+ +

+ No se encontraron proveedores +

+

+ Intenta ajustar la búsqueda o crear un nuevo proveedor +

+ +
+ )} + + {/* Supplier Details Modal */} + {showForm && selectedSupplier && ( + { + 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); + }} + /> + )} +
+ ); +}; + +export default SuppliersPage; \ No newline at end of file diff --git a/frontend/src/pages/app/operations/suppliers/index.ts b/frontend/src/pages/app/operations/suppliers/index.ts new file mode 100644 index 00000000..6a7306b1 --- /dev/null +++ b/frontend/src/pages/app/operations/suppliers/index.ts @@ -0,0 +1 @@ +export { default as SuppliersPage } from './SuppliersPage'; \ No newline at end of file diff --git a/frontend/src/router/AppRouter.tsx b/frontend/src/router/AppRouter.tsx index 5c9f42f0..0cffde00 100644 --- a/frontend/src/router/AppRouter.tsx +++ b/frontend/src/router/AppRouter.tsx @@ -15,6 +15,7 @@ const InventoryPage = React.lazy(() => import('../pages/app/operations/inventory const ProductionPage = React.lazy(() => import('../pages/app/operations/production/ProductionPage')); const RecipesPage = React.lazy(() => import('../pages/app/operations/recipes/RecipesPage')); 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 POSPage = React.lazy(() => import('../pages/app/operations/pos/POSPage')); @@ -112,6 +113,16 @@ export const AppRouter: React.FC = () => { } /> + + + + + + } + /> UUID: """Extract user ID from current user context""" user_id = current_user.get('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( status_code=status.HTTP_401_UNAUTHORIZED, 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( stock_data: StockCreate, - tenant_id: UUID = Depends(verify_tenant_access_dep), - user_id: UUID = Depends(get_current_user_id), + tenant_id: UUID = Path(..., description="Tenant ID"), + current_user: dict = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): """Add new stock entry""" try: + # Extract user ID - handle service tokens + user_id = get_current_user_id(current_user) + service = InventoryService() stock = await service.add_stock(stock_data, tenant_id, user_id) return stock @@ -59,19 +67,22 @@ async def add_stock( ) -@router.post("/consume") +@router.post("/tenants/{tenant_id}/stock/consume") 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"), reference_number: Optional[str] = Query(None, description="Reference number (e.g., production order)"), notes: Optional[str] = Query(None, description="Additional notes"), fifo: bool = Query(True, description="Use FIFO (First In, First Out) method"), - tenant_id: UUID = Depends(verify_tenant_access_dep), - user_id: UUID = Depends(get_current_user_id), + current_user: dict = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): """Consume stock for production""" try: + # Extract user ID - handle service tokens + user_id = get_current_user_id(current_user) + service = InventoryService() consumed_items = await service.consume_stock( 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]) -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]) +@router.get("/tenants/{tenant_id}/stock/expiring", response_model=List[dict]) 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"), - tenant_id: UUID = Depends(verify_tenant_access_dep), db: AsyncSession = Depends(get_db) ): """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( - tenant_id: UUID = Depends(verify_tenant_access_dep), + tenant_id: UUID = Path(..., description="Tenant ID"), db: AsyncSession = Depends(get_db) ): """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( - tenant_id: UUID = Depends(verify_tenant_access_dep), + tenant_id: UUID = Path(..., description="Tenant ID"), db: AsyncSession = Depends(get_db) ): """Get stock summary for dashboard""" @@ -164,4 +154,164 @@ async def get_stock_summary( raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 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" ) \ No newline at end of file diff --git a/services/orders/app/services/procurement_service.py b/services/orders/app/services/procurement_service.py index f17617f3..1c0d1c7d 100644 --- a/services/orders/app/services/procurement_service.py +++ b/services/orders/app/services/procurement_service.py @@ -6,6 +6,7 @@ Procurement Service - Business logic for procurement planning and scheduling """ import asyncio +import math import uuid from datetime import datetime, date, timedelta from decimal import Decimal @@ -371,7 +372,14 @@ class ProcurementService: safety_stock = predicted_demand * (request.safety_stock_percentage / 100) 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 requirement_number = await self.requirement_repo.generate_requirement_number(plan_id) @@ -388,15 +396,15 @@ class ProcurementService: 'product_sku': item.get('sku', ''), 'product_category': item.get('category', ''), 'product_type': 'product', - 'required_quantity': predicted_demand, + 'required_quantity': predicted_demand_rounded, 'unit_of_measure': item.get('unit', 'units'), - 'safety_stock_quantity': safety_stock, - 'total_quantity_needed': total_needed, + 'safety_stock_quantity': safety_stock_rounded, + 'total_quantity_needed': total_needed_rounded, 'current_stock_level': current_stock, 'available_stock': current_stock, 'net_requirement': net_requirement, - 'forecast_demand': predicted_demand, - 'buffer_demand': safety_stock, + 'forecast_demand': predicted_demand_rounded, + 'buffer_demand': safety_stock_rounded, 'required_by_date': required_by_date, 'suggested_order_date': suggested_order_date, 'latest_order_date': latest_order_date,