Add frontend imporvements

This commit is contained in:
Urtzi Alfaro
2025-09-09 21:39:12 +02:00
parent 23e088dcb4
commit 2a05048912
16 changed files with 1761 additions and 1233 deletions

View 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,
});
}
};
};

View File

@@ -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,
};

View 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;

View File

@@ -83,7 +83,7 @@ export class InventoryService {
}
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
@@ -104,7 +104,7 @@ export class InventoryService {
queryParams.append('include_unavailable', includeUnavailable.toString());
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);
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);
}

View 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;
}

View File

@@ -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<void>;
onClassify?: (name: string, description?: string) => Promise<any>;
onSubmit?: (data: IngredientCreate) => Promise<void>;
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<InventoryFormProps> = ({
@@ -133,17 +74,11 @@ export const InventoryForm: React.FC<InventoryFormProps> = ({
open = false,
onClose,
onSubmit,
onClassify,
loading = false,
className,
}) => {
const [formData, setFormData] = useState<IngredientFormData>(initialFormData);
const [formData, setFormData] = useState<IngredientCreate>(initialFormData);
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;
@@ -152,39 +87,28 @@ export const InventoryForm: React.FC<InventoryFormProps> = ({
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<InventoryFormProps> = ({
}
}, [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
const validateForm = useCallback((): boolean => {
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';
}
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<InventoryFormProps> = ({
}
}, [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 (
<Modal
open={open}
onClose={onClose}
title={isEditing ? 'Editar Ingrediente' : 'Nuevo Ingrediente'}
size="xl"
size="lg"
className={className}
>
<form onSubmit={handleSubmit} className="space-y-6">
@@ -322,177 +178,77 @@ export const InventoryForm: React.FC<InventoryFormProps> = ({
{/* Left Column - Basic Information */}
<div className="space-y-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="flex gap-2">
<Input
label="Nombre del Ingrediente"
isRequired
value={formData.name}
onChange={(e) => handleInputChange('name', e.target.value)}
error={errors.name}
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>
<Input
label="Nombre del Ingrediente"
isRequired
value={formData.name}
onChange={(e) => handleInputChange('name', e.target.value)}
error={errors.name}
placeholder="Ej. Harina de trigo"
/>
<div className="grid grid-cols-2 gap-4">
<Select
label="Tipo de Producto"
value={formData.product_type}
onChange={(value) => handleInputChange('product_type', value)}
options={PRODUCT_TYPES}
error={errors.product_type}
/>
<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>
<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}
/>
<Input
label="Descripción"
value={formData.description}
value={formData.description || ''}
onChange={(e) => handleInputChange('description', e.target.value)}
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">
<Input
label="Marca"
value={formData.brand}
onChange={(e) => handleInputChange('brand', e.target.value)}
placeholder="Marca del producto"
/>
<Input
label="SKU"
value={formData.sku}
onChange={(e) => handleInputChange('sku', e.target.value)}
placeholder="Código SKU interno"
/>
</div>
<Select
label="Categoría"
value={formData.category || ''}
onChange={(value) => handleInputChange('category', value)}
options={[{ value: '', label: 'Seleccionar categoría' }, ...BAKERY_CATEGORIES]}
placeholder="Seleccionar categoría"
/>
<Input
label="Código de Barras"
value={formData.barcode}
onChange={(e) => 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"
/>
</div>
</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>
{/* Right Column - Categories and Specifications */}
{/* Right Column - Specifications */}
<div className="space-y-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">
<Select
label="Categoría"
value={formData.category}
onChange={(value) => handleInputChange('category', value)}
options={[{ value: '', label: 'Seleccionar categoría' }, ...categoryOptions]}
placeholder="Seleccionar categoría"
error={errors.category}
<Input
label="Costo Promedio"
type="number"
step="0.01"
min="0"
value={formData.average_cost?.toString() || ''}
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>
</Card>
<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="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
label="Stock Mínimo"
isRequired
@@ -515,17 +271,6 @@ export const InventoryForm: React.FC<InventoryFormProps> = ({
error={errors.reorder_point}
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>
<Input
@@ -546,122 +291,55 @@ export const InventoryForm: React.FC<InventoryFormProps> = ({
{/* Storage and Preservation */}
<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="flex flex-wrap gap-4">
<label className="flex items-center">
<input
type="checkbox"
checked={formData.is_perishable}
onChange={(e) => handleInputChange('is_perishable', e.target.checked)}
className="rounded border-input-border text-color-primary focus:ring-color-primary"
checked={formData.is_seasonal || false}
onChange={(e) => handleInputChange('is_seasonal', e.target.checked)}
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 className="flex items-center">
<input
type="checkbox"
checked={formData.requires_refrigeration}
checked={formData.requires_refrigeration || false}
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 className="flex items-center">
<input
type="checkbox"
checked={formData.requires_freezing}
checked={formData.requires_freezing || false}
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>
</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
label="Instrucciones de Almacenamiento"
value={formData.storage_instructions}
onChange={(e) => 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"
/>
</div>
</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 */}
<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
type="button"
variant="outline"
@@ -679,76 +357,6 @@ export const InventoryForm: React.FC<InventoryFormProps> = ({
</Button>
</div>
</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>
);
};

View File

@@ -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<LowStockAlertProps> = ({
alerts = [],
autoRefresh = false,
refreshInterval = 30000,
maxItems = 10,
showDismissed = false,
compact = false,
items = [],
className,
onReorder,
onAdjustMinimums,
onDismiss,
onRefresh,
onViewAll,
onViewDetails,
}) => {
const [localAlerts, setLocalAlerts] = useState<StockAlert[]>(alerts);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [dismissedAlerts, setDismissedAlerts] = useState<Set<string>>(new Set());
const [showReorderModal, setShowReorderModal] = useState(false);
const [showAdjustModal, setShowAdjustModal] = useState(false);
const [selectedAlert, setSelectedAlert] = useState<StockAlert | null>(null);
const [reorderQuantity, setReorderQuantity] = useState<number>(0);
const [newMinimumThreshold, setNewMinimumThreshold] = useState<number>(0);
const [supplierSuggestions, setSupplierSuggestions] = useState<SupplierSuggestion[]>([]);
// 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');
// Update local alerts when prop changes
useEffect(() => {
setLocalAlerts(alerts);
}, [alerts]);
if (items.length === 0) {
return null;
}
// 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 (
<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>
);
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 (
<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 (
<div className={className}>
<Card className="overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border-primary bg-bg-secondary">
<Card className={className}>
<div className="p-4 border-b border-[var(--border-primary)] bg-[var(--bg-secondary)]">
<div className="flex items-center justify-between">
<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
</h2>
<Badge variant="error" count={groupedAlerts.critical.length} />
<Badge variant="warning" count={groupedAlerts.low.length} />
</div>
<div className="flex items-center gap-2">
<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>
</h3>
{criticalItems.length > 0 && (
<Badge variant="error">{criticalItems.length} críticos</Badge>
)}
{lowStockItems.length > 0 && (
<Badge variant="warning">{lowStockItems.length} bajos</Badge>
)}
</div>
</div>
</div>
{/* Alerts list */}
<div className="p-4 space-y-3">
{visibleAlerts.map((alert, index) => renderAlertItem(alert, index))}
</div>
<div className="p-4 space-y-3">
{items.slice(0, 5).map((item) => (
<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"
>
<div className="flex items-center gap-3 flex-1">
<div className={`w-3 h-3 rounded-full ${
item.stock_status === 'out_of_stock'
? 'bg-red-500'
: item.stock_status === 'low_stock'
? 'bg-yellow-500'
: 'bg-green-500'
}`} />
{/* Footer actions */}
{hasMoreAlerts && (
<div className="px-4 py-3 border-t border-border-primary bg-bg-tertiary">
<p className="text-sm text-text-secondary text-center">
Mostrando {visibleAlerts.length} de {totalActiveAlerts} alertas
</p>
</div>
)}
</Card>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h4 className="font-medium text-[var(--text-primary)]">
{item.name}
</h4>
<Badge
variant={getSeverityColor(item.stock_status) as any}
size="sm"
>
{getSeverityText(item.stock_status)}
</Badge>
{item.category && (
<Badge variant="outline" size="sm">
{item.category}
</Badge>
)}
</div>
{/* Reorder Modal */}
<Modal
open={showReorderModal}
onClose={() => setShowReorderModal(false)}
title="Crear Orden de Compra"
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>
<p className="text-sm text-text-secondary">
Stock mínimo: {selectedAlert.ingredient?.low_stock_threshold} {selectedAlert.ingredient?.unit_of_measure}
</p>
</div>
<Input
label="Cantidad a Ordenar"
type="number"
min="1"
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 className="flex items-center gap-4">
<span className="text-sm text-[var(--text-secondary)]">
Stock: {item.current_stock_level} {item.unit_of_measure}
</span>
<span className="text-sm text-[var(--text-secondary)]">
Mín: {item.low_stock_threshold} {item.unit_of_measure}
</span>
</div>
</div>
)}
</div>
<div className="flex gap-2 justify-end">
<Button
variant="outline"
onClick={() => setShowReorderModal(false)}
>
Cancelar
</Button>
<Button
onClick={handleConfirmReorder}
disabled={reorderQuantity <= 0}
>
Crear Orden de Compra
</Button>
<div className="flex items-center gap-2">
{onViewDetails && (
<Button
size="sm"
variant="outline"
onClick={() => onViewDetails(item)}
>
Ver
</Button>
)}
{onReorder && (
<Button
size="sm"
variant="primary"
onClick={() => onReorder(item)}
>
Reordenar
</Button>
)}
</div>
</div>
)}
</Modal>
))}
{/* Adjust Minimums Modal */}
<Modal
open={showAdjustModal}
onClose={() => setShowAdjustModal(false)}
title="Ajustar Umbrales Mínimos"
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>
{items.length > 5 && (
<div className="text-center pt-2">
<span className="text-sm text-[var(--text-secondary)]">
Y {items.length - 5} elementos más...
</span>
</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>
);
};

View File

@@ -3,4 +3,5 @@ export * from './production';
export * from './recipes';
export * from './procurement';
export * from './orders';
export * from './suppliers';
export * from './pos';

View File

@@ -1,191 +1,181 @@
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<typeof mockInventoryItems[0] | null>(null);
const [selectedItem, setSelectedItem] = useState<IngredientResponse | null>(null);
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',
},
];
const currentTenant = useCurrentTenant();
const tenantId = currentTenant?.id || '';
const getInventoryStatusConfig = (item: typeof mockInventoryItems[0]) => {
const { currentStock, minStock, status } = item;
// API Data
const {
data: ingredientsData,
isLoading: ingredientsLoading,
error: ingredientsError
} = useIngredients(tenantId, { search: searchTerm || undefined });
if (status === 'expired') {
return {
color: getStatusColor('expired'),
text: 'Caducado',
icon: AlertTriangle,
isCritical: true,
isHighlight: false
};
const {
data: lowStockData,
isLoading: lowStockLoading
} = useLowStockIngredients(tenantId);
const {
data: analyticsData,
isLoading: analyticsLoading
} = useStockAnalytics(tenantId);
const ingredients = ingredientsData?.items || [];
const lowStockItems = lowStockData || [];
const getInventoryStatusConfig = (ingredient: IngredientResponse) => {
const { current_stock_level, low_stock_threshold, stock_status } = ingredient;
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
};
}
};
if (currentStock === 0) {
return {
color: getStatusColor('out'),
text: 'Sin Stock',
icon: AlertTriangle,
isCritical: true,
isHighlight: false
};
}
const filteredItems = useMemo(() => {
if (!searchTerm) return ingredients;
if (currentStock <= minStock) {
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('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 (
<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 (
<div className="space-y-6">
<PageHeader
@@ -211,7 +201,7 @@ const InventoryPage: React.FC = () => {
{/* Stats Grid */}
<StatsGrid
stats={inventoryStats}
stats={stats}
columns={3}
/>
@@ -240,34 +230,39 @@ const InventoryPage: React.FC = () => {
{/* Inventory Items Grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{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 (
<StatusCard
key={item.id}
id={item.id}
key={ingredient.id}
id={ingredient.id}
statusIndicator={statusConfig}
title={item.name}
subtitle={`${item.category}${item.supplier}`}
primaryValue={item.currentStock}
primaryValueLabel={item.unit}
title={ingredient.name}
subtitle={`${ingredient.category}${ingredient.description ? `${ingredient.description}` : ''}`}
primaryValue={ingredient.current_stock_level}
primaryValueLabel={ingredient.unit_of_measure}
secondaryInfo={{
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',
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);

View 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;

View File

@@ -0,0 +1 @@
export { default as SuppliersPage } from './SuppliersPage';

View File

@@ -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 = () => {
</ProtectedRoute>
}
/>
<Route
path="/app/operations/suppliers"
element={
<ProtectedRoute>
<AppShell>
<SuppliersPage />
</AppShell>
</ProtectedRoute>
}
/>
<Route
path="/app/operations/orders"
element={

View File

@@ -267,6 +267,16 @@ export const routesConfig: RouteConfig[] = [
showInNavigation: 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',
name: 'POS',

View File

@@ -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("/")
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 = ""):
"""Proxy tenant ingredient requests to inventory service"""
# The inventory service ingredient endpoints are now tenant-scoped: /api/v1/tenants/{tenant_id}/ingredients/{path}
# 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)
# ================================================================

View File

@@ -5,7 +5,7 @@ API endpoints for stock management
from typing import List, Optional
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 app.core.database import get_db
@@ -19,31 +19,39 @@ from app.schemas.inventory import (
StockFilter
)
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
def get_current_user_id(current_user: dict = Depends(get_current_user_dep)) -> 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"""
@@ -165,3 +155,163 @@ async def get_stock_summary(
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"
)

View File

@@ -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,