Add frontend imporvements
This commit is contained in:
323
frontend/src/api/hooks/forecasting.ts
Normal file
323
frontend/src/api/hooks/forecasting.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* Forecasting React Query hooks
|
||||
*/
|
||||
import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
UseQueryOptions,
|
||||
UseMutationOptions,
|
||||
useInfiniteQuery,
|
||||
UseInfiniteQueryOptions,
|
||||
} from '@tanstack/react-query';
|
||||
import { forecastingService } from '../services/forecasting';
|
||||
import {
|
||||
ForecastRequest,
|
||||
ForecastResponse,
|
||||
BatchForecastRequest,
|
||||
BatchForecastResponse,
|
||||
ForecastListResponse,
|
||||
ForecastByIdResponse,
|
||||
ForecastStatistics,
|
||||
DeleteForecastResponse,
|
||||
GetForecastsParams,
|
||||
ForecastingHealthResponse,
|
||||
} from '../types/forecasting';
|
||||
import { ApiError } from '../client/apiClient';
|
||||
|
||||
// ================================================================
|
||||
// QUERY KEYS
|
||||
// ================================================================
|
||||
|
||||
export const forecastingKeys = {
|
||||
all: ['forecasting'] as const,
|
||||
lists: () => [...forecastingKeys.all, 'list'] as const,
|
||||
list: (tenantId: string, filters?: GetForecastsParams) =>
|
||||
[...forecastingKeys.lists(), tenantId, filters] as const,
|
||||
details: () => [...forecastingKeys.all, 'detail'] as const,
|
||||
detail: (tenantId: string, forecastId: string) =>
|
||||
[...forecastingKeys.details(), tenantId, forecastId] as const,
|
||||
statistics: (tenantId: string) =>
|
||||
[...forecastingKeys.all, 'statistics', tenantId] as const,
|
||||
health: () => [...forecastingKeys.all, 'health'] as const,
|
||||
} as const;
|
||||
|
||||
// ================================================================
|
||||
// QUERIES
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* Get tenant forecasts with filtering and pagination
|
||||
*/
|
||||
export const useTenantForecasts = (
|
||||
tenantId: string,
|
||||
params?: GetForecastsParams,
|
||||
options?: Omit<UseQueryOptions<ForecastListResponse, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<ForecastListResponse, ApiError>({
|
||||
queryKey: forecastingKeys.list(tenantId, params),
|
||||
queryFn: () => forecastingService.getTenantForecasts(tenantId, params),
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||
enabled: !!tenantId,
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get specific forecast by ID
|
||||
*/
|
||||
export const useForecastById = (
|
||||
tenantId: string,
|
||||
forecastId: string,
|
||||
options?: Omit<UseQueryOptions<ForecastByIdResponse, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<ForecastByIdResponse, ApiError>({
|
||||
queryKey: forecastingKeys.detail(tenantId, forecastId),
|
||||
queryFn: () => forecastingService.getForecastById(tenantId, forecastId),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
enabled: !!tenantId && !!forecastId,
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get forecast statistics for tenant
|
||||
*/
|
||||
export const useForecastStatistics = (
|
||||
tenantId: string,
|
||||
options?: Omit<UseQueryOptions<ForecastStatistics, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<ForecastStatistics, ApiError>({
|
||||
queryKey: forecastingKeys.statistics(tenantId),
|
||||
queryFn: () => forecastingService.getForecastStatistics(tenantId),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
enabled: !!tenantId,
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Health check for forecasting service
|
||||
*/
|
||||
export const useForecastingHealth = (
|
||||
options?: Omit<UseQueryOptions<ForecastingHealthResponse, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<ForecastingHealthResponse, ApiError>({
|
||||
queryKey: forecastingKeys.health(),
|
||||
queryFn: () => forecastingService.getHealthCheck(),
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
// ================================================================
|
||||
// INFINITE QUERIES
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* Infinite query for tenant forecasts (for pagination)
|
||||
*/
|
||||
export const useInfiniteTenantForecasts = (
|
||||
tenantId: string,
|
||||
baseParams?: Omit<GetForecastsParams, 'skip' | 'limit'>,
|
||||
options?: Omit<UseInfiniteQueryOptions<ForecastListResponse, ApiError>, 'queryKey' | 'queryFn' | 'getNextPageParam'>
|
||||
) => {
|
||||
const limit = baseParams?.limit || 20;
|
||||
|
||||
return useInfiniteQuery<ForecastListResponse, ApiError>({
|
||||
queryKey: [...forecastingKeys.list(tenantId, baseParams), 'infinite'],
|
||||
queryFn: ({ pageParam = 0 }) => {
|
||||
const params: GetForecastsParams = {
|
||||
...baseParams,
|
||||
skip: pageParam as number,
|
||||
limit,
|
||||
};
|
||||
return forecastingService.getTenantForecasts(tenantId, params);
|
||||
},
|
||||
getNextPageParam: (lastPage, allPages) => {
|
||||
const totalFetched = allPages.reduce((sum, page) => sum + page.total_returned, 0);
|
||||
return lastPage.total_returned === limit ? totalFetched : undefined;
|
||||
},
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||
enabled: !!tenantId,
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
// ================================================================
|
||||
// MUTATIONS
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* Create single forecast mutation
|
||||
*/
|
||||
export const useCreateSingleForecast = (
|
||||
options?: UseMutationOptions<
|
||||
ForecastResponse,
|
||||
ApiError,
|
||||
{ tenantId: string; request: ForecastRequest }
|
||||
>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
ForecastResponse,
|
||||
ApiError,
|
||||
{ tenantId: string; request: ForecastRequest }
|
||||
>({
|
||||
mutationFn: ({ tenantId, request }) =>
|
||||
forecastingService.createSingleForecast(tenantId, request),
|
||||
onSuccess: (data, variables) => {
|
||||
// Invalidate and refetch forecasts list
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: forecastingKeys.lists(),
|
||||
});
|
||||
|
||||
// Update the specific forecast cache
|
||||
queryClient.setQueryData(
|
||||
forecastingKeys.detail(variables.tenantId, data.id),
|
||||
{
|
||||
...data,
|
||||
enhanced_features: true,
|
||||
repository_integration: true,
|
||||
} as ForecastByIdResponse
|
||||
);
|
||||
|
||||
// Update statistics
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: forecastingKeys.statistics(variables.tenantId),
|
||||
});
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Create batch forecast mutation
|
||||
*/
|
||||
export const useCreateBatchForecast = (
|
||||
options?: UseMutationOptions<
|
||||
BatchForecastResponse,
|
||||
ApiError,
|
||||
{ tenantId: string; request: BatchForecastRequest }
|
||||
>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
BatchForecastResponse,
|
||||
ApiError,
|
||||
{ tenantId: string; request: BatchForecastRequest }
|
||||
>({
|
||||
mutationFn: ({ tenantId, request }) =>
|
||||
forecastingService.createBatchForecast(tenantId, request),
|
||||
onSuccess: (data, variables) => {
|
||||
// Invalidate forecasts list
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: forecastingKeys.lists(),
|
||||
});
|
||||
|
||||
// Cache individual forecasts if available
|
||||
if (data.forecasts) {
|
||||
data.forecasts.forEach((forecast) => {
|
||||
queryClient.setQueryData(
|
||||
forecastingKeys.detail(variables.tenantId, forecast.id),
|
||||
{
|
||||
...forecast,
|
||||
enhanced_features: true,
|
||||
repository_integration: true,
|
||||
} as ForecastByIdResponse
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Update statistics
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: forecastingKeys.statistics(variables.tenantId),
|
||||
});
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete forecast mutation
|
||||
*/
|
||||
export const useDeleteForecast = (
|
||||
options?: UseMutationOptions<
|
||||
DeleteForecastResponse,
|
||||
ApiError,
|
||||
{ tenantId: string; forecastId: string }
|
||||
>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
DeleteForecastResponse,
|
||||
ApiError,
|
||||
{ tenantId: string; forecastId: string }
|
||||
>({
|
||||
mutationFn: ({ tenantId, forecastId }) =>
|
||||
forecastingService.deleteForecast(tenantId, forecastId),
|
||||
onSuccess: (data, variables) => {
|
||||
// Remove from cache
|
||||
queryClient.removeQueries({
|
||||
queryKey: forecastingKeys.detail(variables.tenantId, variables.forecastId),
|
||||
});
|
||||
|
||||
// Invalidate lists to refresh
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: forecastingKeys.lists(),
|
||||
});
|
||||
|
||||
// Update statistics
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: forecastingKeys.statistics(variables.tenantId),
|
||||
});
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
// ================================================================
|
||||
// UTILITY FUNCTIONS
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* Prefetch forecast by ID
|
||||
*/
|
||||
export const usePrefetchForecast = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return (tenantId: string, forecastId: string) => {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: forecastingKeys.detail(tenantId, forecastId),
|
||||
queryFn: () => forecastingService.getForecastById(tenantId, forecastId),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Invalidate all forecasting queries for a tenant
|
||||
*/
|
||||
export const useInvalidateForecasting = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return (tenantId?: string) => {
|
||||
if (tenantId) {
|
||||
// Invalidate specific tenant queries
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: forecastingKeys.list(tenantId),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: forecastingKeys.statistics(tenantId),
|
||||
});
|
||||
} else {
|
||||
// Invalidate all forecasting queries
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: forecastingKeys.all,
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -25,6 +25,7 @@ export { trainingService } from './services/training';
|
||||
export { alertProcessorService } from './services/alert_processor';
|
||||
export { 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,
|
||||
};
|
||||
132
frontend/src/api/services/forecasting.ts
Normal file
132
frontend/src/api/services/forecasting.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Forecasting Service
|
||||
* API calls for forecasting service endpoints
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client/apiClient';
|
||||
import {
|
||||
ForecastRequest,
|
||||
ForecastResponse,
|
||||
BatchForecastRequest,
|
||||
BatchForecastResponse,
|
||||
ForecastListResponse,
|
||||
ForecastByIdResponse,
|
||||
ForecastStatistics,
|
||||
DeleteForecastResponse,
|
||||
GetForecastsParams,
|
||||
ForecastingHealthResponse,
|
||||
} from '../types/forecasting';
|
||||
|
||||
export class ForecastingService {
|
||||
private readonly baseUrl = '/forecasts';
|
||||
|
||||
/**
|
||||
* Generate a single product forecast
|
||||
* POST /tenants/{tenant_id}/forecasts/single
|
||||
*/
|
||||
async createSingleForecast(
|
||||
tenantId: string,
|
||||
request: ForecastRequest
|
||||
): Promise<ForecastResponse> {
|
||||
return apiClient.post<ForecastResponse, ForecastRequest>(
|
||||
`/tenants/${tenantId}${this.baseUrl}/single`,
|
||||
request
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate batch forecasts for multiple products
|
||||
* POST /tenants/{tenant_id}/forecasts/batch
|
||||
*/
|
||||
async createBatchForecast(
|
||||
tenantId: string,
|
||||
request: BatchForecastRequest
|
||||
): Promise<BatchForecastResponse> {
|
||||
return apiClient.post<BatchForecastResponse, BatchForecastRequest>(
|
||||
`/tenants/${tenantId}${this.baseUrl}/batch`,
|
||||
request
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tenant forecasts with filtering and pagination
|
||||
* GET /tenants/{tenant_id}/forecasts
|
||||
*/
|
||||
async getTenantForecasts(
|
||||
tenantId: string,
|
||||
params?: GetForecastsParams
|
||||
): Promise<ForecastListResponse> {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (params?.inventory_product_id) {
|
||||
searchParams.append('inventory_product_id', params.inventory_product_id);
|
||||
}
|
||||
if (params?.start_date) {
|
||||
searchParams.append('start_date', params.start_date);
|
||||
}
|
||||
if (params?.end_date) {
|
||||
searchParams.append('end_date', params.end_date);
|
||||
}
|
||||
if (params?.skip !== undefined) {
|
||||
searchParams.append('skip', params.skip.toString());
|
||||
}
|
||||
if (params?.limit !== undefined) {
|
||||
searchParams.append('limit', params.limit.toString());
|
||||
}
|
||||
|
||||
const queryString = searchParams.toString();
|
||||
const url = `/tenants/${tenantId}${this.baseUrl}${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
return apiClient.get<ForecastListResponse>(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get specific forecast by ID
|
||||
* GET /tenants/{tenant_id}/forecasts/{forecast_id}
|
||||
*/
|
||||
async getForecastById(
|
||||
tenantId: string,
|
||||
forecastId: string
|
||||
): Promise<ForecastByIdResponse> {
|
||||
return apiClient.get<ForecastByIdResponse>(
|
||||
`/tenants/${tenantId}${this.baseUrl}/${forecastId}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a forecast
|
||||
* DELETE /tenants/{tenant_id}/forecasts/{forecast_id}
|
||||
*/
|
||||
async deleteForecast(
|
||||
tenantId: string,
|
||||
forecastId: string
|
||||
): Promise<DeleteForecastResponse> {
|
||||
return apiClient.delete<DeleteForecastResponse>(
|
||||
`/tenants/${tenantId}${this.baseUrl}/${forecastId}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comprehensive forecast statistics
|
||||
* GET /tenants/{tenant_id}/forecasts/statistics
|
||||
*/
|
||||
async getForecastStatistics(
|
||||
tenantId: string
|
||||
): Promise<ForecastStatistics> {
|
||||
return apiClient.get<ForecastStatistics>(
|
||||
`/tenants/${tenantId}${this.baseUrl}/statistics`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check for forecasting service
|
||||
* GET /health
|
||||
*/
|
||||
async getHealthCheck(): Promise<ForecastingHealthResponse> {
|
||||
return apiClient.get<ForecastingHealthResponse>('/health');
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const forecastingService = new ForecastingService();
|
||||
export default forecastingService;
|
||||
@@ -83,7 +83,7 @@ export class InventoryService {
|
||||
}
|
||||
|
||||
async getLowStockIngredients(tenantId: string): Promise<IngredientResponse[]> {
|
||||
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);
|
||||
}
|
||||
|
||||
160
frontend/src/api/types/forecasting.ts
Normal file
160
frontend/src/api/types/forecasting.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Forecasting API Types
|
||||
* Mirror of backend forecasting service schemas
|
||||
*/
|
||||
|
||||
// ================================================================
|
||||
// ENUMS
|
||||
// ================================================================
|
||||
|
||||
export enum BusinessType {
|
||||
INDIVIDUAL = "individual",
|
||||
CENTRAL_WORKSHOP = "central_workshop",
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// REQUEST TYPES
|
||||
// ================================================================
|
||||
|
||||
export interface ForecastRequest {
|
||||
inventory_product_id: string;
|
||||
forecast_date: string; // ISO date string
|
||||
forecast_days?: number; // Default: 1, Min: 1, Max: 30
|
||||
location: string;
|
||||
confidence_level?: number; // Default: 0.8, Min: 0.5, Max: 0.95
|
||||
}
|
||||
|
||||
export interface BatchForecastRequest {
|
||||
tenant_id: string;
|
||||
batch_name: string;
|
||||
inventory_product_ids: string[];
|
||||
forecast_days?: number; // Default: 7, Min: 1, Max: 30
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// RESPONSE TYPES
|
||||
// ================================================================
|
||||
|
||||
export interface ForecastResponse {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
inventory_product_id: string;
|
||||
location: string;
|
||||
forecast_date: string; // ISO datetime string
|
||||
|
||||
// Predictions
|
||||
predicted_demand: number;
|
||||
confidence_lower: number;
|
||||
confidence_upper: number;
|
||||
confidence_level: number;
|
||||
|
||||
// Model info
|
||||
model_id: string;
|
||||
model_version: string;
|
||||
algorithm: string;
|
||||
|
||||
// Context
|
||||
business_type: string;
|
||||
is_holiday: boolean;
|
||||
is_weekend: boolean;
|
||||
day_of_week: number;
|
||||
|
||||
// External factors
|
||||
weather_temperature?: number;
|
||||
weather_precipitation?: number;
|
||||
weather_description?: string;
|
||||
traffic_volume?: number;
|
||||
|
||||
// Metadata
|
||||
created_at: string; // ISO datetime string
|
||||
processing_time_ms?: number;
|
||||
features_used?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface BatchForecastResponse {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
batch_name: string;
|
||||
status: string;
|
||||
total_products: number;
|
||||
completed_products: number;
|
||||
failed_products: number;
|
||||
|
||||
// Timing
|
||||
requested_at: string; // ISO datetime string
|
||||
completed_at?: string; // ISO datetime string
|
||||
processing_time_ms?: number;
|
||||
|
||||
// Results
|
||||
forecasts?: ForecastResponse[];
|
||||
error_message?: string;
|
||||
}
|
||||
|
||||
export interface ForecastStatistics {
|
||||
tenant_id: string;
|
||||
total_forecasts: number;
|
||||
recent_forecasts: number;
|
||||
accuracy_metrics: {
|
||||
average_accuracy: number;
|
||||
accuracy_trend: number;
|
||||
};
|
||||
model_performance: {
|
||||
most_used_algorithm: string;
|
||||
average_processing_time: number;
|
||||
};
|
||||
enhanced_features: boolean;
|
||||
repository_integration: boolean;
|
||||
}
|
||||
|
||||
export interface ForecastListResponse {
|
||||
tenant_id: string;
|
||||
forecasts: ForecastResponse[];
|
||||
total_returned: number;
|
||||
filters: {
|
||||
inventory_product_id?: string;
|
||||
start_date?: string; // ISO date string
|
||||
end_date?: string; // ISO date string
|
||||
};
|
||||
pagination: {
|
||||
skip: number;
|
||||
limit: number;
|
||||
};
|
||||
enhanced_features: boolean;
|
||||
repository_integration: boolean;
|
||||
}
|
||||
|
||||
export interface ForecastByIdResponse extends ForecastResponse {
|
||||
enhanced_features: boolean;
|
||||
repository_integration: boolean;
|
||||
}
|
||||
|
||||
export interface DeleteForecastResponse {
|
||||
message: string;
|
||||
forecast_id: string;
|
||||
enhanced_features: boolean;
|
||||
repository_integration: boolean;
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// QUERY PARAMETERS
|
||||
// ================================================================
|
||||
|
||||
export interface GetForecastsParams {
|
||||
inventory_product_id?: string;
|
||||
start_date?: string; // ISO date string
|
||||
end_date?: string; // ISO date string
|
||||
skip?: number; // Default: 0
|
||||
limit?: number; // Default: 100
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// HEALTH CHECK
|
||||
// ================================================================
|
||||
|
||||
export interface ForecastingHealthResponse {
|
||||
status: string;
|
||||
service: string;
|
||||
version: string;
|
||||
features: string[];
|
||||
timestamp: string;
|
||||
}
|
||||
@@ -6,126 +6,67 @@ import { Select } from '../../ui';
|
||||
import { Card } from '../../ui';
|
||||
import { 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Update local alerts when prop changes
|
||||
useEffect(() => {
|
||||
setLocalAlerts(alerts);
|
||||
}, [alerts]);
|
||||
|
||||
// Auto-refresh functionality
|
||||
useEffect(() => {
|
||||
if (!autoRefresh || refreshInterval <= 0) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
onRefresh?.();
|
||||
}, refreshInterval);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [autoRefresh, refreshInterval, onRefresh]);
|
||||
|
||||
// Load supplier suggestions when modal opens
|
||||
useEffect(() => {
|
||||
if (showReorderModal && selectedAlert?.ingredient_id) {
|
||||
loadSupplierSuggestions(selectedAlert.ingredient_id);
|
||||
}
|
||||
}, [showReorderModal, selectedAlert]);
|
||||
|
||||
const loadSupplierSuggestions = async (ingredientId: string) => {
|
||||
try {
|
||||
// This would typically call a suppliers API
|
||||
// For now, we'll simulate some data
|
||||
setSupplierSuggestions([
|
||||
{ id: '1', name: 'Proveedor Principal', lastPrice: 2.50, reliability: 95 },
|
||||
{ id: '2', name: 'Proveedor Alternativo', lastPrice: 2.80, reliability: 87 },
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('Error loading supplier suggestions:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Group alerts by severity
|
||||
const groupedAlerts = React.useMemo((): GroupedAlerts => {
|
||||
const filtered = localAlerts.filter(alert => {
|
||||
if (!alert.is_active) return false;
|
||||
if (!showDismissed && dismissedAlerts.has(alert.id)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
return {
|
||||
critical: filtered.filter(alert =>
|
||||
alert.severity === AlertSeverity.CRITICAL ||
|
||||
alert.alert_type === 'out_of_stock'
|
||||
),
|
||||
low: filtered.filter(alert =>
|
||||
alert.severity === AlertSeverity.HIGH &&
|
||||
alert.alert_type === 'low_stock'
|
||||
),
|
||||
out: filtered.filter(alert => alert.alert_type === 'out_of_stock'),
|
||||
};
|
||||
}, [localAlerts, showDismissed, dismissedAlerts]);
|
||||
|
||||
const totalActiveAlerts = groupedAlerts.critical.length + groupedAlerts.low.length;
|
||||
|
||||
// Handle alert dismissal
|
||||
const handleDismiss = useCallback(async (alertId: string, temporary: boolean = true) => {
|
||||
if (temporary) {
|
||||
setDismissedAlerts(prev => new Set(prev).add(alertId));
|
||||
} else {
|
||||
try {
|
||||
await inventoryService.acknowledgeAlert(alertId);
|
||||
onDismiss?.(alertId);
|
||||
} catch (error) {
|
||||
console.error('Error dismissing alert:', error);
|
||||
setError('Error al descartar la alerta');
|
||||
}
|
||||
}
|
||||
}, [onDismiss]);
|
||||
|
||||
// Handle reorder action
|
||||
const handleReorder = useCallback((alert: StockAlert) => {
|
||||
setSelectedAlert(alert);
|
||||
setReorderQuantity(alert.ingredient?.reorder_quantity || 0);
|
||||
setShowReorderModal(true);
|
||||
}, []);
|
||||
|
||||
// Handle adjust minimums action
|
||||
const handleAdjustMinimums = useCallback((alert: StockAlert) => {
|
||||
setSelectedAlert(alert);
|
||||
setNewMinimumThreshold(alert.threshold_value || alert.ingredient?.low_stock_threshold || 0);
|
||||
setShowAdjustModal(true);
|
||||
}, []);
|
||||
|
||||
// Confirm reorder
|
||||
const handleConfirmReorder = useCallback(() => {
|
||||
if (selectedAlert?.ingredient) {
|
||||
onReorder?.(selectedAlert.ingredient);
|
||||
}
|
||||
setShowReorderModal(false);
|
||||
setSelectedAlert(null);
|
||||
}, [selectedAlert, onReorder]);
|
||||
|
||||
// Confirm adjust minimums
|
||||
const handleConfirmAdjust = useCallback(() => {
|
||||
if (selectedAlert?.ingredient) {
|
||||
onAdjustMinimums?.(selectedAlert.ingredient);
|
||||
}
|
||||
setShowAdjustModal(false);
|
||||
setSelectedAlert(null);
|
||||
}, [selectedAlert, onAdjustMinimums]);
|
||||
|
||||
// Get severity badge variant
|
||||
const getSeverityVariant = (severity: AlertSeverity): any => {
|
||||
switch (severity) {
|
||||
case AlertSeverity.CRITICAL:
|
||||
const getSeverityColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'out_of_stock':
|
||||
return 'error';
|
||||
case AlertSeverity.HIGH:
|
||||
case 'low_stock':
|
||||
return 'warning';
|
||||
case AlertSeverity.MEDIUM:
|
||||
return 'info';
|
||||
default:
|
||||
return 'secondary';
|
||||
}
|
||||
};
|
||||
|
||||
// Render alert item
|
||||
const renderAlertItem = (alert: StockAlert, index: number) => {
|
||||
const ingredient = alert.ingredient;
|
||||
if (!ingredient) return null;
|
||||
|
||||
const isCompact = compact || index >= maxItems;
|
||||
|
||||
return (
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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="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'
|
||||
}`} />
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -3,4 +3,5 @@ export * from './production';
|
||||
export * from './recipes';
|
||||
export * from './procurement';
|
||||
export * from './orders';
|
||||
export * from './suppliers';
|
||||
export * from './pos';
|
||||
@@ -1,190 +1,180 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Plus, Download, AlertTriangle, Package, Clock, CheckCircle, Eye, Edit, Calendar, DollarSign } from 'lucide-react';
|
||||
import { 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 currentTenant = useCurrentTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
|
||||
const mockInventoryItems = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Harina de Trigo',
|
||||
category: 'Harinas',
|
||||
currentStock: 45,
|
||||
minStock: 20,
|
||||
maxStock: 100,
|
||||
unit: 'kg',
|
||||
cost: 1.20,
|
||||
supplier: 'Molinos del Sur',
|
||||
lastRestocked: '2024-01-20',
|
||||
expirationDate: '2024-06-30',
|
||||
status: 'normal',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Levadura Fresca',
|
||||
category: 'Levaduras',
|
||||
currentStock: 8,
|
||||
minStock: 10,
|
||||
maxStock: 25,
|
||||
unit: 'kg',
|
||||
cost: 8.50,
|
||||
supplier: 'Levaduras SA',
|
||||
lastRestocked: '2024-01-25',
|
||||
expirationDate: '2024-02-15',
|
||||
status: 'low',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Mantequilla',
|
||||
category: 'Lácteos',
|
||||
currentStock: 15,
|
||||
minStock: 5,
|
||||
maxStock: 30,
|
||||
unit: 'kg',
|
||||
cost: 5.80,
|
||||
supplier: 'Lácteos Frescos',
|
||||
lastRestocked: '2024-01-24',
|
||||
expirationDate: '2024-02-10',
|
||||
status: 'normal',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Azúcar Blanco',
|
||||
category: 'Azúcares',
|
||||
currentStock: 0,
|
||||
minStock: 15,
|
||||
maxStock: 50,
|
||||
unit: 'kg',
|
||||
cost: 0.95,
|
||||
supplier: 'Distribuidora Central',
|
||||
lastRestocked: '2024-01-10',
|
||||
expirationDate: '2024-12-31',
|
||||
status: 'out',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
name: 'Leche Entera',
|
||||
category: 'Lácteos',
|
||||
currentStock: 3,
|
||||
minStock: 10,
|
||||
maxStock: 40,
|
||||
unit: 'L',
|
||||
cost: 1.45,
|
||||
supplier: 'Lácteos Frescos',
|
||||
lastRestocked: '2024-01-22',
|
||||
expirationDate: '2024-01-28',
|
||||
status: 'expired',
|
||||
},
|
||||
];
|
||||
// API Data
|
||||
const {
|
||||
data: ingredientsData,
|
||||
isLoading: ingredientsLoading,
|
||||
error: ingredientsError
|
||||
} = useIngredients(tenantId, { search: searchTerm || undefined });
|
||||
|
||||
const {
|
||||
data: lowStockData,
|
||||
isLoading: lowStockLoading
|
||||
} = useLowStockIngredients(tenantId);
|
||||
|
||||
const {
|
||||
data: analyticsData,
|
||||
isLoading: analyticsLoading
|
||||
} = useStockAnalytics(tenantId);
|
||||
|
||||
const ingredients = ingredientsData?.items || [];
|
||||
const lowStockItems = lowStockData || [];
|
||||
|
||||
const getInventoryStatusConfig = (item: typeof mockInventoryItems[0]) => {
|
||||
const { currentStock, minStock, status } = item;
|
||||
const getInventoryStatusConfig = (ingredient: IngredientResponse) => {
|
||||
const { current_stock_level, low_stock_threshold, stock_status } = ingredient;
|
||||
|
||||
if (status === 'expired') {
|
||||
return {
|
||||
color: getStatusColor('expired'),
|
||||
text: 'Caducado',
|
||||
icon: AlertTriangle,
|
||||
isCritical: true,
|
||||
isHighlight: false
|
||||
};
|
||||
switch (stock_status) {
|
||||
case 'out_of_stock':
|
||||
return {
|
||||
color: getStatusColor('cancelled'),
|
||||
text: 'Sin Stock',
|
||||
icon: AlertTriangle,
|
||||
isCritical: true,
|
||||
isHighlight: false
|
||||
};
|
||||
case 'low_stock':
|
||||
return {
|
||||
color: getStatusColor('pending'),
|
||||
text: 'Stock Bajo',
|
||||
icon: AlertTriangle,
|
||||
isCritical: false,
|
||||
isHighlight: true
|
||||
};
|
||||
case 'overstock':
|
||||
return {
|
||||
color: getStatusColor('info'),
|
||||
text: 'Sobrestock',
|
||||
icon: Package,
|
||||
isCritical: false,
|
||||
isHighlight: false
|
||||
};
|
||||
case 'in_stock':
|
||||
default:
|
||||
return {
|
||||
color: getStatusColor('completed'),
|
||||
text: 'Normal',
|
||||
icon: CheckCircle,
|
||||
isCritical: false,
|
||||
isHighlight: false
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
if (!searchTerm) return ingredients;
|
||||
|
||||
if (currentStock === 0) {
|
||||
return ingredients.filter(ingredient => {
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
return ingredient.name.toLowerCase().includes(searchLower) ||
|
||||
ingredient.category.toLowerCase().includes(searchLower) ||
|
||||
(ingredient.description && ingredient.description.toLowerCase().includes(searchLower));
|
||||
});
|
||||
}, [ingredients, searchTerm]);
|
||||
|
||||
const inventoryStats = useMemo(() => {
|
||||
if (!analyticsData) {
|
||||
return {
|
||||
color: getStatusColor('out'),
|
||||
text: 'Sin Stock',
|
||||
icon: AlertTriangle,
|
||||
isCritical: true,
|
||||
isHighlight: false
|
||||
};
|
||||
}
|
||||
|
||||
if (currentStock <= minStock) {
|
||||
return {
|
||||
color: getStatusColor('low'),
|
||||
text: 'Stock Bajo',
|
||||
icon: AlertTriangle,
|
||||
isCritical: false,
|
||||
isHighlight: true
|
||||
totalItems: ingredients.length,
|
||||
lowStockItems: lowStockItems.length,
|
||||
outOfStock: ingredients.filter(item => item.stock_status === 'out_of_stock').length,
|
||||
expiringSoon: 0, // This would come from expired stock API
|
||||
totalValue: ingredients.reduce((sum, item) => sum + (item.current_stock_level * (item.average_cost || 0)), 0),
|
||||
categories: [...new Set(ingredients.map(item => item.category))].length,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
color: getStatusColor('normal'),
|
||||
text: 'Normal',
|
||||
icon: CheckCircle,
|
||||
isCritical: false,
|
||||
isHighlight: false
|
||||
totalItems: analyticsData.total_ingredients || 0,
|
||||
lowStockItems: analyticsData.low_stock_count || 0,
|
||||
outOfStock: analyticsData.out_of_stock_count || 0,
|
||||
expiringSoon: analyticsData.expiring_soon_count || 0,
|
||||
totalValue: analyticsData.total_stock_value || 0,
|
||||
categories: [...new Set(ingredients.map(item => item.category))].length,
|
||||
};
|
||||
};
|
||||
}, [analyticsData, ingredients, lowStockItems]);
|
||||
|
||||
const filteredItems = mockInventoryItems.filter(item => {
|
||||
const matchesSearch = item.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.category.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.supplier.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
return matchesSearch;
|
||||
});
|
||||
|
||||
const lowStockItems = mockInventoryItems.filter(item =>
|
||||
item.currentStock <= item.minStock || item.status === 'low' || item.status === 'out' || item.status === 'expired'
|
||||
);
|
||||
|
||||
const mockInventoryStats = {
|
||||
totalItems: mockInventoryItems.length,
|
||||
lowStockItems: lowStockItems.length,
|
||||
outOfStock: mockInventoryItems.filter(item => item.currentStock === 0).length,
|
||||
expiringSoon: mockInventoryItems.filter(item => item.status === 'expired').length,
|
||||
totalValue: mockInventoryItems.reduce((sum, item) => sum + (item.currentStock * item.cost), 0),
|
||||
categories: [...new Set(mockInventoryItems.map(item => item.category))].length,
|
||||
};
|
||||
|
||||
const inventoryStats = [
|
||||
const stats = [
|
||||
{
|
||||
title: 'Total Artículos',
|
||||
value: mockInventoryStats.totalItems,
|
||||
value: inventoryStats.totalItems,
|
||||
variant: 'default' as const,
|
||||
icon: Package,
|
||||
},
|
||||
{
|
||||
title: 'Stock Bajo',
|
||||
value: mockInventoryStats.lowStockItems,
|
||||
value: inventoryStats.lowStockItems,
|
||||
variant: 'warning' as const,
|
||||
icon: AlertTriangle,
|
||||
},
|
||||
{
|
||||
title: 'Sin Stock',
|
||||
value: mockInventoryStats.outOfStock,
|
||||
value: inventoryStats.outOfStock,
|
||||
variant: 'error' as const,
|
||||
icon: AlertTriangle,
|
||||
},
|
||||
{
|
||||
title: 'Por Caducar',
|
||||
value: mockInventoryStats.expiringSoon,
|
||||
value: inventoryStats.expiringSoon,
|
||||
variant: 'error' as const,
|
||||
icon: Clock,
|
||||
},
|
||||
{
|
||||
title: 'Valor Total',
|
||||
value: formatters.currency(mockInventoryStats.totalValue),
|
||||
value: formatters.currency(inventoryStats.totalValue),
|
||||
variant: 'success' as const,
|
||||
icon: DollarSign,
|
||||
},
|
||||
{
|
||||
title: 'Categorías',
|
||||
value: mockInventoryStats.categories,
|
||||
value: inventoryStats.categories,
|
||||
variant: 'info' as const,
|
||||
icon: Package,
|
||||
},
|
||||
];
|
||||
|
||||
// Loading and error states
|
||||
if (ingredientsLoading || analyticsLoading || !tenantId) {
|
||||
return (
|
||||
<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">
|
||||
@@ -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);
|
||||
|
||||
435
frontend/src/pages/app/operations/suppliers/SuppliersPage.tsx
Normal file
435
frontend/src/pages/app/operations/suppliers/SuppliersPage.tsx
Normal file
@@ -0,0 +1,435 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, Download, Building2, Phone, Mail, Eye, Edit, CheckCircle, AlertCircle, Timer, Users, DollarSign } from 'lucide-react';
|
||||
import { Button, Input, Card, Badge, StatsGrid, StatusCard, getStatusColor, StatusModal } from '../../../../components/ui';
|
||||
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { SupplierStatus, SupplierType, PaymentTerms } from '../../../../api/types/suppliers';
|
||||
|
||||
const SuppliersPage: React.FC = () => {
|
||||
const [activeTab] = useState('all');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [modalMode, setModalMode] = useState<'view' | 'edit'>('view');
|
||||
const [selectedSupplier, setSelectedSupplier] = useState<typeof mockSuppliers[0] | null>(null);
|
||||
|
||||
const mockSuppliers = [
|
||||
{
|
||||
id: 'SUP-2024-001',
|
||||
supplier_code: 'HAR001',
|
||||
name: 'Harinas del Norte S.L.',
|
||||
supplier_type: SupplierType.INGREDIENTS,
|
||||
status: SupplierStatus.ACTIVE,
|
||||
contact_person: 'María González',
|
||||
email: 'maria@harinasdelnorte.es',
|
||||
phone: '+34 987 654 321',
|
||||
city: 'León',
|
||||
country: 'España',
|
||||
payment_terms: PaymentTerms.NET_30,
|
||||
credit_limit: 5000,
|
||||
currency: 'EUR',
|
||||
standard_lead_time: 5,
|
||||
minimum_order_amount: 200,
|
||||
total_orders: 45,
|
||||
total_spend: 12750.50,
|
||||
last_order_date: '2024-01-25T14:30:00Z',
|
||||
performance_score: 92,
|
||||
notes: 'Proveedor principal de harinas. Excelente calidad y puntualidad.'
|
||||
},
|
||||
{
|
||||
id: 'SUP-2024-002',
|
||||
supplier_code: 'EMB002',
|
||||
name: 'Embalajes Biodegradables SA',
|
||||
supplier_type: SupplierType.PACKAGING,
|
||||
status: SupplierStatus.ACTIVE,
|
||||
contact_person: 'Carlos Ruiz',
|
||||
email: 'carlos@embalajes-bio.com',
|
||||
phone: '+34 600 123 456',
|
||||
city: 'Valencia',
|
||||
country: 'España',
|
||||
payment_terms: PaymentTerms.NET_15,
|
||||
credit_limit: 2500,
|
||||
currency: 'EUR',
|
||||
standard_lead_time: 3,
|
||||
minimum_order_amount: 150,
|
||||
total_orders: 28,
|
||||
total_spend: 4280.75,
|
||||
last_order_date: '2024-01-24T10:15:00Z',
|
||||
performance_score: 88,
|
||||
notes: 'Especialista en packaging sostenible.'
|
||||
},
|
||||
{
|
||||
id: 'SUP-2024-003',
|
||||
supplier_code: 'MAN003',
|
||||
name: 'Maquinaria Industrial López',
|
||||
supplier_type: SupplierType.EQUIPMENT,
|
||||
status: SupplierStatus.PENDING_APPROVAL,
|
||||
contact_person: 'Ana López',
|
||||
email: 'ana@maquinaria-lopez.es',
|
||||
phone: '+34 655 987 654',
|
||||
city: 'Madrid',
|
||||
country: 'España',
|
||||
payment_terms: PaymentTerms.NET_45,
|
||||
credit_limit: 15000,
|
||||
currency: 'EUR',
|
||||
standard_lead_time: 14,
|
||||
minimum_order_amount: 500,
|
||||
total_orders: 0,
|
||||
total_spend: 0,
|
||||
last_order_date: null,
|
||||
performance_score: null,
|
||||
notes: 'Nuevo proveedor de equipamiento industrial. Pendiente de aprobación.'
|
||||
},
|
||||
];
|
||||
|
||||
const getSupplierStatusConfig = (status: SupplierStatus) => {
|
||||
const statusConfig = {
|
||||
[SupplierStatus.ACTIVE]: { text: 'Activo', icon: CheckCircle },
|
||||
[SupplierStatus.INACTIVE]: { text: 'Inactivo', icon: Timer },
|
||||
[SupplierStatus.PENDING_APPROVAL]: { text: 'Pendiente Aprobación', icon: AlertCircle },
|
||||
[SupplierStatus.SUSPENDED]: { text: 'Suspendido', icon: AlertCircle },
|
||||
[SupplierStatus.BLACKLISTED]: { text: 'Lista Negra', icon: AlertCircle },
|
||||
};
|
||||
|
||||
const config = statusConfig[status];
|
||||
const Icon = config?.icon;
|
||||
|
||||
return {
|
||||
color: getStatusColor(status === SupplierStatus.ACTIVE ? 'completed' :
|
||||
status === SupplierStatus.PENDING_APPROVAL ? 'pending' : 'cancelled'),
|
||||
text: config?.text || status,
|
||||
icon: Icon,
|
||||
isCritical: status === SupplierStatus.BLACKLISTED,
|
||||
isHighlight: status === SupplierStatus.PENDING_APPROVAL
|
||||
};
|
||||
};
|
||||
|
||||
const getSupplierTypeText = (type: SupplierType): string => {
|
||||
const typeMap = {
|
||||
[SupplierType.INGREDIENTS]: 'Ingredientes',
|
||||
[SupplierType.PACKAGING]: 'Embalajes',
|
||||
[SupplierType.EQUIPMENT]: 'Equipamiento',
|
||||
[SupplierType.SERVICES]: 'Servicios',
|
||||
[SupplierType.UTILITIES]: 'Servicios Públicos',
|
||||
[SupplierType.MULTI]: 'Múltiple',
|
||||
};
|
||||
return typeMap[type] || type;
|
||||
};
|
||||
|
||||
const getPaymentTermsText = (terms: PaymentTerms): string => {
|
||||
const termsMap = {
|
||||
[PaymentTerms.CASH_ON_DELIVERY]: 'Pago Contraentrega',
|
||||
[PaymentTerms.NET_15]: 'Neto 15 días',
|
||||
[PaymentTerms.NET_30]: 'Neto 30 días',
|
||||
[PaymentTerms.NET_45]: 'Neto 45 días',
|
||||
[PaymentTerms.NET_60]: 'Neto 60 días',
|
||||
[PaymentTerms.PREPAID]: 'Prepago',
|
||||
};
|
||||
return termsMap[terms] || terms;
|
||||
};
|
||||
|
||||
const filteredSuppliers = mockSuppliers.filter(supplier => {
|
||||
const matchesSearch = supplier.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
supplier.supplier_code.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
supplier.email?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
supplier.contact_person?.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
const matchesTab = activeTab === 'all' || supplier.status === activeTab;
|
||||
|
||||
return matchesSearch && matchesTab;
|
||||
});
|
||||
|
||||
const mockSupplierStats = {
|
||||
total: mockSuppliers.length,
|
||||
active: mockSuppliers.filter(s => s.status === SupplierStatus.ACTIVE).length,
|
||||
pendingApproval: mockSuppliers.filter(s => s.status === SupplierStatus.PENDING_APPROVAL).length,
|
||||
suspended: mockSuppliers.filter(s => s.status === SupplierStatus.SUSPENDED).length,
|
||||
totalSpend: mockSuppliers.reduce((sum, supplier) => sum + supplier.total_spend, 0),
|
||||
averageScore: mockSuppliers
|
||||
.filter(s => s.performance_score !== null)
|
||||
.reduce((sum, supplier, _, arr) => sum + (supplier.performance_score || 0) / arr.length, 0),
|
||||
totalOrders: mockSuppliers.reduce((sum, supplier) => sum + supplier.total_orders, 0),
|
||||
};
|
||||
|
||||
const stats = [
|
||||
{
|
||||
title: 'Total Proveedores',
|
||||
value: mockSupplierStats.total,
|
||||
variant: 'default' as const,
|
||||
icon: Building2,
|
||||
},
|
||||
{
|
||||
title: 'Activos',
|
||||
value: mockSupplierStats.active,
|
||||
variant: 'success' as const,
|
||||
icon: CheckCircle,
|
||||
},
|
||||
{
|
||||
title: 'Pendientes',
|
||||
value: mockSupplierStats.pendingApproval,
|
||||
variant: 'warning' as const,
|
||||
icon: AlertCircle,
|
||||
},
|
||||
{
|
||||
title: 'Gasto Total',
|
||||
value: formatters.currency(mockSupplierStats.totalSpend),
|
||||
variant: 'info' as const,
|
||||
icon: DollarSign,
|
||||
},
|
||||
{
|
||||
title: 'Total Pedidos',
|
||||
value: mockSupplierStats.totalOrders,
|
||||
variant: 'default' as const,
|
||||
icon: Building2,
|
||||
},
|
||||
{
|
||||
title: 'Puntuación Media',
|
||||
value: mockSupplierStats.averageScore.toFixed(1),
|
||||
variant: 'success' as const,
|
||||
icon: CheckCircle,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Gestión de Proveedores"
|
||||
description="Administra y supervisa todos los proveedores de la panadería"
|
||||
actions={[
|
||||
{
|
||||
id: "export",
|
||||
label: "Exportar",
|
||||
variant: "outline" as const,
|
||||
icon: Download,
|
||||
onClick: () => console.log('Export suppliers')
|
||||
},
|
||||
{
|
||||
id: "new",
|
||||
label: "Nuevo Proveedor",
|
||||
variant: "primary" as const,
|
||||
icon: Plus,
|
||||
onClick: () => setShowForm(true)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<StatsGrid
|
||||
stats={stats}
|
||||
columns={3}
|
||||
/>
|
||||
|
||||
{/* Simplified Controls */}
|
||||
<Card className="p-4">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
placeholder="Buscar proveedores por nombre, código, email o contacto..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => console.log('Export filtered')}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exportar
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Suppliers Grid */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredSuppliers.map((supplier) => {
|
||||
const statusConfig = getSupplierStatusConfig(supplier.status);
|
||||
const performanceNote = supplier.performance_score
|
||||
? `Puntuación: ${supplier.performance_score}/100`
|
||||
: 'Sin evaluación';
|
||||
|
||||
return (
|
||||
<StatusCard
|
||||
key={supplier.id}
|
||||
id={supplier.id}
|
||||
statusIndicator={statusConfig}
|
||||
title={supplier.name}
|
||||
subtitle={supplier.supplier_code}
|
||||
primaryValue={formatters.currency(supplier.total_spend)}
|
||||
primaryValueLabel={`${supplier.total_orders} pedidos`}
|
||||
secondaryInfo={{
|
||||
label: 'Tipo',
|
||||
value: getSupplierTypeText(supplier.supplier_type)
|
||||
}}
|
||||
metadata={[
|
||||
supplier.contact_person || 'Sin contacto',
|
||||
supplier.email || 'Sin email',
|
||||
supplier.phone || 'Sin teléfono',
|
||||
performanceNote
|
||||
]}
|
||||
actions={[
|
||||
{
|
||||
label: 'Ver',
|
||||
icon: Eye,
|
||||
variant: 'outline',
|
||||
onClick: () => {
|
||||
setSelectedSupplier(supplier);
|
||||
setModalMode('view');
|
||||
setShowForm(true);
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Editar',
|
||||
icon: Edit,
|
||||
variant: 'outline',
|
||||
onClick: () => {
|
||||
setSelectedSupplier(supplier);
|
||||
setModalMode('edit');
|
||||
setShowForm(true);
|
||||
}
|
||||
}
|
||||
]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Empty State */}
|
||||
{filteredSuppliers.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<Building2 className="mx-auto h-12 w-12 text-[var(--text-tertiary)] mb-4" />
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
|
||||
No se encontraron proveedores
|
||||
</h3>
|
||||
<p className="text-[var(--text-secondary)] mb-4">
|
||||
Intenta ajustar la búsqueda o crear un nuevo proveedor
|
||||
</p>
|
||||
<Button onClick={() => setShowForm(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nuevo Proveedor
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Supplier Details Modal */}
|
||||
{showForm && selectedSupplier && (
|
||||
<StatusModal
|
||||
isOpen={showForm}
|
||||
onClose={() => {
|
||||
setShowForm(false);
|
||||
setSelectedSupplier(null);
|
||||
setModalMode('view');
|
||||
}}
|
||||
mode={modalMode}
|
||||
onModeChange={setModalMode}
|
||||
title={selectedSupplier.name}
|
||||
subtitle={`Proveedor ${selectedSupplier.supplier_code}`}
|
||||
statusIndicator={getSupplierStatusConfig(selectedSupplier.status)}
|
||||
size="lg"
|
||||
sections={[
|
||||
{
|
||||
title: 'Información de Contacto',
|
||||
icon: Users,
|
||||
fields: [
|
||||
{
|
||||
label: 'Nombre',
|
||||
value: selectedSupplier.name,
|
||||
highlight: true
|
||||
},
|
||||
{
|
||||
label: 'Persona de Contacto',
|
||||
value: selectedSupplier.contact_person || 'No especificado'
|
||||
},
|
||||
{
|
||||
label: 'Email',
|
||||
value: selectedSupplier.email || 'No especificado'
|
||||
},
|
||||
{
|
||||
label: 'Teléfono',
|
||||
value: selectedSupplier.phone || 'No especificado'
|
||||
},
|
||||
{
|
||||
label: 'Ciudad',
|
||||
value: selectedSupplier.city || 'No especificado'
|
||||
},
|
||||
{
|
||||
label: 'País',
|
||||
value: selectedSupplier.country || 'No especificado'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Información Comercial',
|
||||
icon: Building2,
|
||||
fields: [
|
||||
{
|
||||
label: 'Código de Proveedor',
|
||||
value: selectedSupplier.supplier_code,
|
||||
highlight: true
|
||||
},
|
||||
{
|
||||
label: 'Tipo de Proveedor',
|
||||
value: getSupplierTypeText(selectedSupplier.supplier_type)
|
||||
},
|
||||
{
|
||||
label: 'Condiciones de Pago',
|
||||
value: getPaymentTermsText(selectedSupplier.payment_terms)
|
||||
},
|
||||
{
|
||||
label: 'Tiempo de Entrega',
|
||||
value: `${selectedSupplier.standard_lead_time} días`
|
||||
},
|
||||
{
|
||||
label: 'Pedido Mínimo',
|
||||
value: selectedSupplier.minimum_order_amount,
|
||||
type: 'currency'
|
||||
},
|
||||
{
|
||||
label: 'Límite de Crédito',
|
||||
value: selectedSupplier.credit_limit,
|
||||
type: 'currency'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Rendimiento y Estadísticas',
|
||||
icon: DollarSign,
|
||||
fields: [
|
||||
{
|
||||
label: 'Total de Pedidos',
|
||||
value: selectedSupplier.total_orders.toString()
|
||||
},
|
||||
{
|
||||
label: 'Gasto Total',
|
||||
value: selectedSupplier.total_spend,
|
||||
type: 'currency',
|
||||
highlight: true
|
||||
},
|
||||
{
|
||||
label: 'Puntuación de Rendimiento',
|
||||
value: selectedSupplier.performance_score ? `${selectedSupplier.performance_score}/100` : 'No evaluado'
|
||||
},
|
||||
{
|
||||
label: 'Último Pedido',
|
||||
value: selectedSupplier.last_order_date || 'Nunca',
|
||||
type: selectedSupplier.last_order_date ? 'datetime' : undefined
|
||||
}
|
||||
]
|
||||
},
|
||||
...(selectedSupplier.notes ? [{
|
||||
title: 'Notas',
|
||||
fields: [
|
||||
{
|
||||
label: 'Observaciones',
|
||||
value: selectedSupplier.notes,
|
||||
span: 2 as const
|
||||
}
|
||||
]
|
||||
}] : [])
|
||||
]}
|
||||
onEdit={() => {
|
||||
console.log('Editing supplier:', selectedSupplier.id);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SuppliersPage;
|
||||
1
frontend/src/pages/app/operations/suppliers/index.ts
Normal file
1
frontend/src/pages/app/operations/suppliers/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as SuppliersPage } from './SuppliersPage';
|
||||
@@ -15,6 +15,7 @@ const InventoryPage = React.lazy(() => import('../pages/app/operations/inventory
|
||||
const ProductionPage = React.lazy(() => import('../pages/app/operations/production/ProductionPage'));
|
||||
const 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={
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
# ================================================================
|
||||
|
||||
@@ -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"""
|
||||
@@ -164,4 +154,164 @@ async def get_stock_summary(
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to get stock summary"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tenants/{tenant_id}/stock", response_model=List[StockResponse])
|
||||
async def get_stock(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
skip: int = Query(0, ge=0, description="Number of records to skip"),
|
||||
limit: int = Query(100, ge=1, le=1000, description="Number of records to return"),
|
||||
ingredient_id: Optional[UUID] = Query(None, description="Filter by ingredient"),
|
||||
available_only: bool = Query(True, description="Show only available stock"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get stock entries with filtering"""
|
||||
try:
|
||||
service = InventoryService()
|
||||
stock_entries = await service.get_stock(
|
||||
tenant_id, skip, limit, ingredient_id, available_only
|
||||
)
|
||||
return stock_entries
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to get stock entries"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tenants/{tenant_id}/stock/{stock_id}", response_model=StockResponse)
|
||||
async def get_stock_entry(
|
||||
stock_id: UUID = Path(..., description="Stock entry ID"),
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get specific stock entry"""
|
||||
try:
|
||||
service = InventoryService()
|
||||
stock = await service.get_stock_entry(stock_id, tenant_id)
|
||||
|
||||
if not stock:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Stock entry not found"
|
||||
)
|
||||
|
||||
return stock
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to get stock entry"
|
||||
)
|
||||
|
||||
|
||||
@router.put("/tenants/{tenant_id}/stock/{stock_id}", response_model=StockResponse)
|
||||
async def update_stock(
|
||||
stock_id: UUID = Path(..., description="Stock entry ID"),
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
stock_data: StockUpdate,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update stock entry"""
|
||||
try:
|
||||
service = InventoryService()
|
||||
stock = await service.update_stock(stock_id, stock_data, tenant_id)
|
||||
|
||||
if not stock:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Stock entry not found"
|
||||
)
|
||||
|
||||
return stock
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to update stock entry"
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/tenants/{tenant_id}/stock/{stock_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_stock(
|
||||
stock_id: UUID = Path(..., description="Stock entry ID"),
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Delete stock entry (mark as unavailable)"""
|
||||
try:
|
||||
service = InventoryService()
|
||||
deleted = await service.delete_stock(stock_id, tenant_id)
|
||||
|
||||
if not deleted:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Stock entry not found"
|
||||
)
|
||||
|
||||
return None
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to delete stock entry"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/tenants/{tenant_id}/stock/movements", response_model=StockMovementResponse)
|
||||
async def create_stock_movement(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
movement_data: StockMovementCreate,
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Create stock movement record"""
|
||||
try:
|
||||
# Extract user ID - handle service tokens
|
||||
user_id = get_current_user_id(current_user)
|
||||
|
||||
service = InventoryService()
|
||||
movement = await service.create_stock_movement(movement_data, tenant_id, user_id)
|
||||
return movement
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to create stock movement"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tenants/{tenant_id}/stock/movements", response_model=List[StockMovementResponse])
|
||||
async def get_stock_movements(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
skip: int = Query(0, ge=0, description="Number of records to skip"),
|
||||
limit: int = Query(100, ge=1, le=1000, description="Number of records to return"),
|
||||
ingredient_id: Optional[UUID] = Query(None, description="Filter by ingredient"),
|
||||
movement_type: Optional[str] = Query(None, description="Filter by movement type"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get stock movements with filtering"""
|
||||
try:
|
||||
service = InventoryService()
|
||||
movements = await service.get_stock_movements(
|
||||
tenant_id, skip, limit, ingredient_id, movement_type
|
||||
)
|
||||
return movements
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to get stock movements"
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user