REFACTOR ALL APIs fix 1

This commit is contained in:
Urtzi Alfaro
2025-10-07 07:15:07 +02:00
parent 38fb98bc27
commit 7c72f83c51
47 changed files with 1821 additions and 270 deletions

View File

@@ -26,6 +26,10 @@ import {
GetForecastsParams,
ForecastingHealthResponse,
MultiDayForecastResponse,
ScenarioSimulationRequest,
ScenarioSimulationResponse,
ScenarioComparisonRequest,
ScenarioComparisonResponse,
} from '../types/forecasting';
export class ForecastingService {
@@ -258,6 +262,43 @@ export class ForecastingService {
);
}
// ===================================================================
// SCENARIO SIMULATION - PROFESSIONAL/ENTERPRISE ONLY
// Backend: services/forecasting/app/api/scenario_operations.py
// ===================================================================
/**
* Run a "what-if" scenario simulation on forecasts
* POST /tenants/{tenant_id}/forecasting/analytics/scenario-simulation
*
* **PROFESSIONAL/ENTERPRISE ONLY**
*/
async simulateScenario(
tenantId: string,
request: ScenarioSimulationRequest
): Promise<ScenarioSimulationResponse> {
return apiClient.post<ScenarioSimulationResponse, ScenarioSimulationRequest>(
`${this.baseUrl}/${tenantId}/forecasting/analytics/scenario-simulation`,
request
);
}
/**
* Compare multiple scenario simulations
* POST /tenants/{tenant_id}/forecasting/analytics/scenario-comparison
*
* **PROFESSIONAL/ENTERPRISE ONLY**
*/
async compareScenarios(
tenantId: string,
request: ScenarioComparisonRequest
): Promise<ScenarioComparisonResponse> {
return apiClient.post<ScenarioComparisonResponse, ScenarioComparisonRequest>(
`${this.baseUrl}/${tenantId}/forecasting/analytics/scenario-comparison`,
request
);
}
// ===================================================================
// Health Check
// ===================================================================

View File

@@ -215,35 +215,35 @@ export class OrdersService {
/**
* Get current procurement plan for today
* GET /tenants/{tenant_id}/orders/procurement/plans/current
* GET /tenants/{tenant_id}/orders/operations/procurement/plans/current
*/
static async getCurrentProcurementPlan(tenantId: string): Promise<ProcurementPlanResponse | null> {
return apiClient.get<ProcurementPlanResponse | null>(`/tenants/${tenantId}/orders/procurement/plans/current`);
return apiClient.get<ProcurementPlanResponse | null>(`/tenants/${tenantId}/orders/operations/procurement/plans/current`);
}
/**
* Get procurement plan by specific date
* GET /tenants/{tenant_id}/orders/procurement/plans/date/{plan_date}
* GET /tenants/{tenant_id}/orders/operations/procurement/plans/date/{plan_date}
*/
static async getProcurementPlanByDate(tenantId: string, planDate: string): Promise<ProcurementPlanResponse | null> {
return apiClient.get<ProcurementPlanResponse | null>(`/tenants/${tenantId}/orders/procurement/plans/date/${planDate}`);
return apiClient.get<ProcurementPlanResponse | null>(`/tenants/${tenantId}/orders/operations/procurement/plans/date/${planDate}`);
}
/**
* Get procurement plan by ID
* GET /tenants/{tenant_id}/orders/procurement/plans/id/{plan_id}
* GET /tenants/{tenant_id}/orders/operations/procurement/plans/id/{plan_id}
*/
static async getProcurementPlanById(tenantId: string, planId: string): Promise<ProcurementPlanResponse | null> {
return apiClient.get<ProcurementPlanResponse | null>(`/tenants/${tenantId}/orders/procurement/plans/id/${planId}`);
return apiClient.get<ProcurementPlanResponse | null>(`/tenants/${tenantId}/orders/operations/procurement/plans/id/${planId}`);
}
/**
* List procurement plans with filtering
* GET /tenants/{tenant_id}/orders/procurement/plans/
* GET /tenants/{tenant_id}/orders/operations/procurement/plans/
*/
static async getProcurementPlans(params: GetProcurementPlansParams): Promise<PaginatedProcurementPlans> {
const { tenant_id, status, start_date, end_date, limit = 50, offset = 0 } = params;
const queryParams = new URLSearchParams({
limit: limit.toString(),
offset: offset.toString(),
@@ -254,29 +254,29 @@ export class OrdersService {
if (end_date) queryParams.append('end_date', end_date);
return apiClient.get<PaginatedProcurementPlans>(
`/tenants/${tenant_id}/orders/procurement/plans?${queryParams.toString()}`
`/tenants/${tenant_id}/orders/operations/procurement/plans?${queryParams.toString()}`
);
}
/**
* Generate a new procurement plan
* POST /tenants/{tenant_id}/orders/procurement/plans/generate
* POST /tenants/{tenant_id}/orders/operations/procurement/plans/generate
*/
static async generateProcurementPlan(tenantId: string, request: GeneratePlanRequest): Promise<GeneratePlanResponse> {
return apiClient.post<GeneratePlanResponse>(`/tenants/${tenantId}/orders/procurement/plans/generate`, request);
return apiClient.post<GeneratePlanResponse>(`/tenants/${tenantId}/orders/operations/procurement/plans/generate`, request);
}
/**
* Update procurement plan status
* PUT /tenants/{tenant_id}/orders/procurement/plans/{plan_id}/status
* PUT /tenants/{tenant_id}/orders/operations/procurement/plans/{plan_id}/status
*/
static async updateProcurementPlanStatus(params: UpdatePlanStatusParams): Promise<ProcurementPlanResponse> {
const { tenant_id, plan_id, status } = params;
const queryParams = new URLSearchParams({ status });
return apiClient.put<ProcurementPlanResponse>(
`/tenants/${tenant_id}/orders/procurement/plans/${plan_id}/status?${queryParams.toString()}`,
`/tenants/${tenant_id}/orders/operations/procurement/plans/${plan_id}/status?${queryParams.toString()}`,
{}
);
}
@@ -291,45 +291,45 @@ export class OrdersService {
/**
* Get requirements for a specific plan
* GET /tenants/{tenant_id}/orders/procurement/plans/{plan_id}/requirements
* GET /tenants/{tenant_id}/orders/operations/procurement/plans/{plan_id}/requirements
*/
static async getPlanRequirements(params: GetPlanRequirementsParams): Promise<ProcurementRequirementResponse[]> {
const { tenant_id, plan_id, status, priority } = params;
const queryParams = new URLSearchParams();
if (status) queryParams.append('status', status);
if (priority) queryParams.append('priority', priority);
const url = `/tenants/${tenant_id}/orders/procurement/plans/${plan_id}/requirements${queryParams.toString() ? `?${queryParams.toString()}` : ''}`;
const url = `/tenants/${tenant_id}/orders/operations/procurement/plans/${plan_id}/requirements${queryParams.toString() ? `?${queryParams.toString()}` : ''}`;
return apiClient.get<ProcurementRequirementResponse[]>(url);
}
/**
* Get critical requirements across all plans
* GET /tenants/{tenant_id}/orders/procurement/requirements/critical
* GET /tenants/{tenant_id}/orders/operations/procurement/requirements/critical
*/
static async getCriticalRequirements(tenantId: string): Promise<ProcurementRequirementResponse[]> {
return apiClient.get<ProcurementRequirementResponse[]>(`/tenants/${tenantId}/orders/procurement/requirements/critical`);
return apiClient.get<ProcurementRequirementResponse[]>(`/tenants/${tenantId}/orders/operations/procurement/requirements/critical`);
}
/**
* Trigger daily scheduler manually
* POST /tenants/{tenant_id}/orders/procurement/scheduler/trigger
* POST /tenants/{tenant_id}/orders/operations/procurement/scheduler/trigger
*/
static async triggerDailyScheduler(tenantId: string): Promise<{ success: boolean; message: string; tenant_id: string }> {
return apiClient.post<{ success: boolean; message: string; tenant_id: string }>(
`/tenants/${tenantId}/orders/procurement/scheduler/trigger`,
`/tenants/${tenantId}/orders/operations/procurement/scheduler/trigger`,
{}
);
}
/**
* Get procurement service health
* GET /tenants/{tenant_id}/orders/procurement/health
* GET /tenants/{tenant_id}/orders/base/procurement/health
*/
static async getProcurementHealth(tenantId: string): Promise<{ status: string; service: string; procurement_enabled: boolean; timestamp: string }> {
return apiClient.get<{ status: string; service: string; procurement_enabled: boolean; timestamp: string }>(`/tenants/${tenantId}/orders/procurement/health`);
return apiClient.get<{ status: string; service: string; procurement_enabled: boolean; timestamp: string }>(`/tenants/${tenantId}/orders/base/procurement/health`);
}
// ===================================================================
@@ -339,51 +339,51 @@ export class OrdersService {
/**
* Recalculate an existing procurement plan
* POST /tenants/{tenant_id}/orders/procurement/plans/{plan_id}/recalculate
* POST /tenants/{tenant_id}/orders/operations/procurement/plans/{plan_id}/recalculate
*/
static async recalculateProcurementPlan(tenantId: string, planId: string): Promise<GeneratePlanResponse> {
return apiClient.post<GeneratePlanResponse>(
`/tenants/${tenantId}/orders/procurement/plans/${planId}/recalculate`,
`/tenants/${tenantId}/orders/operations/procurement/plans/${planId}/recalculate`,
{}
);
}
/**
* Approve a procurement plan with notes
* POST /tenants/{tenant_id}/orders/procurement/plans/{plan_id}/approve
* POST /tenants/{tenant_id}/orders/operations/procurement/plans/{plan_id}/approve
*/
static async approveProcurementPlan(tenantId: string, planId: string, request?: ApprovalRequest): Promise<ProcurementPlanResponse> {
return apiClient.post<ProcurementPlanResponse>(
`/tenants/${tenantId}/orders/procurement/plans/${planId}/approve`,
`/tenants/${tenantId}/orders/operations/procurement/plans/${planId}/approve`,
request || {}
);
}
/**
* Reject a procurement plan with notes
* POST /tenants/{tenant_id}/orders/procurement/plans/{plan_id}/reject
* POST /tenants/{tenant_id}/orders/operations/procurement/plans/{plan_id}/reject
*/
static async rejectProcurementPlan(tenantId: string, planId: string, request?: RejectionRequest): Promise<ProcurementPlanResponse> {
return apiClient.post<ProcurementPlanResponse>(
`/tenants/${tenantId}/orders/procurement/plans/${planId}/reject`,
`/tenants/${tenantId}/orders/operations/procurement/plans/${planId}/reject`,
request || {}
);
}
/**
* Create purchase orders automatically from procurement plan
* POST /tenants/{tenant_id}/orders/procurement/plans/{plan_id}/create-purchase-orders
* POST /tenants/{tenant_id}/orders/operations/procurement/plans/{plan_id}/create-purchase-orders
*/
static async createPurchaseOrdersFromPlan(tenantId: string, planId: string, autoApprove: boolean = false): Promise<CreatePOsResult> {
return apiClient.post<CreatePOsResult>(
`/tenants/${tenantId}/orders/procurement/plans/${planId}/create-purchase-orders`,
`/tenants/${tenantId}/orders/operations/procurement/plans/${planId}/create-purchase-orders`,
{ auto_approve: autoApprove }
);
}
/**
* Link a procurement requirement to a purchase order
* POST /tenants/{tenant_id}/orders/procurement/requirements/{requirement_id}/link-purchase-order
* POST /tenants/{tenant_id}/orders/operations/procurement/requirements/{requirement_id}/link-purchase-order
*/
static async linkRequirementToPurchaseOrder(
tenantId: string,
@@ -391,14 +391,14 @@ export class OrdersService {
request: LinkRequirementToPORequest
): Promise<{ success: boolean; message: string; requirement_id: string; purchase_order_id: string }> {
return apiClient.post<{ success: boolean; message: string; requirement_id: string; purchase_order_id: string }>(
`/tenants/${tenantId}/orders/procurement/requirements/${requirementId}/link-purchase-order`,
`/tenants/${tenantId}/orders/operations/procurement/requirements/${requirementId}/link-purchase-order`,
request
);
}
/**
* Update delivery status for a requirement
* PUT /tenants/{tenant_id}/orders/procurement/requirements/{requirement_id}/delivery-status
* PUT /tenants/{tenant_id}/orders/operations/procurement/requirements/{requirement_id}/delivery-status
*/
static async updateRequirementDeliveryStatus(
tenantId: string,
@@ -406,7 +406,7 @@ export class OrdersService {
request: UpdateDeliveryStatusRequest
): Promise<{ success: boolean; message: string; requirement_id: string; delivery_status: string }> {
return apiClient.put<{ success: boolean; message: string; requirement_id: string; delivery_status: string }>(
`/tenants/${tenantId}/orders/procurement/requirements/${requirementId}/delivery-status`,
`/tenants/${tenantId}/orders/operations/procurement/requirements/${requirementId}/delivery-status`,
request
);
}

View File

@@ -188,10 +188,10 @@ export class RecipesService {
/**
* Get recipe statistics for dashboard
* GET /tenants/{tenant_id}/recipes/statistics/dashboard
* GET /tenants/{tenant_id}/recipes/dashboard/statistics
*/
async getRecipeStatistics(tenantId: string): Promise<RecipeStatisticsResponse> {
return apiClient.get<RecipeStatisticsResponse>(`${this.baseUrl}/${tenantId}/recipes/statistics/dashboard`);
return apiClient.get<RecipeStatisticsResponse>(`${this.baseUrl}/${tenantId}/recipes/dashboard/statistics`);
}
/**

View File

@@ -100,7 +100,7 @@ export class SalesService {
}
async getProductCategories(tenantId: string): Promise<string[]> {
return apiClient.get<string[]>(`${this.baseUrl}/${tenantId}/sales/sales/categories`);
return apiClient.get<string[]>(`${this.baseUrl}/${tenantId}/sales/categories`);
}
// ===================================================================

View File

@@ -89,27 +89,27 @@ export class SubscriptionService {
}
async validatePlanUpgrade(tenantId: string, planKey: string): Promise<PlanUpgradeValidation> {
return apiClient.get<PlanUpgradeValidation>(`${this.baseUrl}/${tenantId}/validate-upgrade/${planKey}`);
return apiClient.get<PlanUpgradeValidation>(`${this.baseUrl}/subscriptions/${tenantId}/validate-upgrade/${planKey}`);
}
async upgradePlan(tenantId: string, planKey: string): Promise<PlanUpgradeResult> {
return apiClient.post<PlanUpgradeResult>(`${this.baseUrl}/${tenantId}/upgrade?new_plan=${planKey}`, {});
return apiClient.post<PlanUpgradeResult>(`${this.baseUrl}/subscriptions/${tenantId}/upgrade?new_plan=${planKey}`, {});
}
async canAddLocation(tenantId: string): Promise<{ can_add: boolean; reason?: string; current_count?: number; max_allowed?: number }> {
return apiClient.get(`${this.baseUrl}/${tenantId}/can-add-location`);
return apiClient.get(`${this.baseUrl}/subscriptions/${tenantId}/can-add-location`);
}
async canAddProduct(tenantId: string): Promise<{ can_add: boolean; reason?: string; current_count?: number; max_allowed?: number }> {
return apiClient.get(`${this.baseUrl}/${tenantId}/can-add-product`);
return apiClient.get(`${this.baseUrl}/subscriptions/${tenantId}/can-add-product`);
}
async canAddUser(tenantId: string): Promise<{ can_add: boolean; reason?: string; current_count?: number; max_allowed?: number }> {
return apiClient.get(`${this.baseUrl}/${tenantId}/can-add-user`);
return apiClient.get(`${this.baseUrl}/subscriptions/${tenantId}/can-add-user`);
}
async hasFeature(tenantId: string, featureName: string): Promise<{ has_feature: boolean; feature_value?: any; plan?: string; reason?: string }> {
return apiClient.get(`${this.baseUrl}/${tenantId}/features/${featureName}`);
return apiClient.get(`${this.baseUrl}/subscriptions/${tenantId}/features/${featureName}`);
}
formatPrice(amount: number): string {

View File

@@ -173,7 +173,7 @@ class TrainingService {
*/
getTrainingWebSocketUrl(tenantId: string, jobId: string): string {
const baseWsUrl = apiClient.getAxiosInstance().defaults.baseURL?.replace(/^http/, 'ws');
return `${baseWsUrl}/ws/tenants/${tenantId}/training/jobs/${jobId}/live`;
return `${baseWsUrl}/tenants/${tenantId}/training/jobs/${jobId}/live`;
}
/**

View File

@@ -90,7 +90,7 @@ export interface ForecastResponse {
// Metadata
created_at: string; // ISO datetime string
processing_time_ms?: number | null;
features_used?: Record<string, any> | null;
features?: Record<string, any> | null;
}
/**
@@ -260,3 +260,165 @@ export interface PredictionsPerformanceParams {
export interface MessageResponse {
message: string;
}
// ================================================================
// SCENARIO SIMULATION TYPES - PROFESSIONAL/ENTERPRISE ONLY
// ================================================================
/**
* Types of scenarios available for simulation
* Backend: ScenarioType enum in schemas/forecasts.py (lines 114-123)
*/
export enum ScenarioType {
WEATHER = 'weather',
COMPETITION = 'competition',
EVENT = 'event',
PRICING = 'pricing',
PROMOTION = 'promotion',
HOLIDAY = 'holiday',
SUPPLY_DISRUPTION = 'supply_disruption',
CUSTOM = 'custom'
}
/**
* Weather scenario parameters
* Backend: WeatherScenario in schemas/forecasts.py (lines 126-130)
*/
export interface WeatherScenario {
temperature_change?: number | null; // Temperature change in °C (-30 to +30)
precipitation_change?: number | null; // Precipitation change in mm (0-100)
weather_type?: string | null; // Weather type (heatwave, cold_snap, rainy, etc.)
}
/**
* Competition scenario parameters
* Backend: CompetitionScenario in schemas/forecasts.py (lines 133-137)
*/
export interface CompetitionScenario {
new_competitors: number; // Number of new competitors (1-10)
distance_km: number; // Distance from location in km (0.1-10)
estimated_market_share_loss: number; // Estimated market share loss (0-0.5)
}
/**
* Event scenario parameters
* Backend: EventScenario in schemas/forecasts.py (lines 140-145)
*/
export interface EventScenario {
event_type: string; // Type of event (festival, sports, concert, etc.)
expected_attendance: number; // Expected attendance
distance_km: number; // Distance from location in km (0-50)
duration_days: number; // Duration in days (1-30)
}
/**
* Pricing scenario parameters
* Backend: PricingScenario in schemas/forecasts.py (lines 148-151)
*/
export interface PricingScenario {
price_change_percent: number; // Price change percentage (-50 to +100)
affected_products?: string[] | null; // List of affected product IDs
}
/**
* Promotion scenario parameters
* Backend: PromotionScenario in schemas/forecasts.py (lines 154-158)
*/
export interface PromotionScenario {
discount_percent: number; // Discount percentage (0-75)
promotion_type: string; // Type of promotion (bogo, discount, bundle, etc.)
expected_traffic_increase: number; // Expected traffic increase (0-2.0 = 0-200%)
}
/**
* Request schema for scenario simulation
* Backend: ScenarioSimulationRequest in schemas/forecasts.py (lines 161-189)
*/
export interface ScenarioSimulationRequest {
scenario_name: string; // Name for this scenario (3-200 chars)
scenario_type: ScenarioType;
inventory_product_ids: string[]; // Products to simulate (min 1)
start_date: string; // ISO date string
duration_days?: number; // Default: 7, range: 1-30
// Scenario-specific parameters (provide based on scenario_type)
weather_params?: WeatherScenario | null;
competition_params?: CompetitionScenario | null;
event_params?: EventScenario | null;
pricing_params?: PricingScenario | null;
promotion_params?: PromotionScenario | null;
// Custom scenario parameters
custom_multipliers?: Record<string, number> | null;
// Comparison settings
include_baseline?: boolean; // Default: true
}
/**
* Impact of scenario on a specific product
* Backend: ScenarioImpact in schemas/forecasts.py (lines 192-199)
*/
export interface ScenarioImpact {
inventory_product_id: string;
baseline_demand: number;
simulated_demand: number;
demand_change_percent: number;
confidence_range: [number, number];
impact_factors: Record<string, any>;
}
/**
* Response schema for scenario simulation
* Backend: ScenarioSimulationResponse in schemas/forecasts.py (lines 202-256)
*/
export interface ScenarioSimulationResponse {
id: string;
tenant_id: string;
scenario_name: string;
scenario_type: ScenarioType;
// Simulation parameters
start_date: string; // ISO date string
end_date: string; // ISO date string
duration_days: number;
// Results
baseline_forecasts?: ForecastResponse[] | null;
scenario_forecasts: ForecastResponse[];
// Impact summary
total_baseline_demand: number;
total_scenario_demand: number;
overall_impact_percent: number;
product_impacts: ScenarioImpact[];
// Insights and recommendations
insights: string[];
recommendations: string[];
risk_level: string; // low, medium, high
// Metadata
created_at: string; // ISO datetime string
processing_time_ms: number;
}
/**
* Request to compare multiple scenarios
* Backend: ScenarioComparisonRequest in schemas/forecasts.py (lines 259-261)
*/
export interface ScenarioComparisonRequest {
scenario_ids: string[]; // 2-5 scenario IDs to compare
}
/**
* Response comparing multiple scenarios
* Backend: ScenarioComparisonResponse in schemas/forecasts.py (lines 264-270)
*/
export interface ScenarioComparisonResponse {
scenarios: ScenarioSimulationResponse[];
comparison_matrix: Record<string, Record<string, any>>;
best_case_scenario_id: string;
worst_case_scenario_id: string;
recommended_action: string;
}

View File

@@ -401,7 +401,7 @@ export interface ModelMetricsResponse {
rmse: number; // Root Mean Square Error
r2_score: number;
training_samples: number;
features_used: string[];
features?: string[]; // Features used by the model
model_type: string;
created_at?: string | null; // ISO datetime string
last_used_at?: string | null; // ISO datetime string

View File

@@ -247,7 +247,7 @@ const ModelDetailsModal: React.FC<ModelDetailsModalProps> = ({
{
label: "Información que Analiza",
value: (() => {
const features = ((model as any).features_used || model.features_used || []);
const features = ((model as any).features || []);
const featureCount = features.length;
if (featureCount === 0) {
@@ -338,7 +338,7 @@ const ModelDetailsModal: React.FC<ModelDetailsModalProps> = ({
},
{
label: "Patrones descubiertos",
value: ((model as any).features_used || model.features_used || []).some((f: string) => f.toLowerCase().includes('weekend'))
value: ((model as any).features || []).some((f: string) => f.toLowerCase().includes('weekend'))
? "Tu negocio muestra patrones diferentes entre días de semana y fines de semana"
: "Este modelo ha aprendido tus patrones regulares de ventas",
span: 2

View File

@@ -171,6 +171,7 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
'/app/database/inventory': 'navigation.inventory',
'/app/analytics': 'navigation.analytics',
'/app/analytics/forecasting': 'navigation.forecasting',
'/app/analytics/scenario-simulation': 'navigation.scenario_simulation',
'/app/analytics/sales': 'navigation.sales',
'/app/analytics/performance': 'navigation.performance',
'/app/ai': 'navigation.insights',

View File

@@ -11,6 +11,7 @@
"pos": "Point of Sale",
"analytics": "Analytics",
"forecasting": "Forecasting",
"scenario_simulation": "Scenario Simulation",
"sales": "Sales",
"performance": "Performance",
"insights": "AI Insights",

View File

@@ -11,6 +11,7 @@
"pos": "Punto de Venta",
"analytics": "Análisis",
"forecasting": "Predicción",
"scenario_simulation": "Simulación de Escenarios",
"sales": "Ventas",
"performance": "Rendimiento",
"insights": "Insights IA",

View File

@@ -11,6 +11,7 @@
"pos": "Salmenta-puntua",
"analytics": "Analisiak",
"forecasting": "Aurreikuspenak",
"scenario_simulation": "Agertoki-simulazioa",
"sales": "Salmentak",
"performance": "Errendimendua",
"insights": "AA ikuspegiak",

View File

@@ -0,0 +1,556 @@
/**
* Scenario Simulation Page - PROFESSIONAL/ENTERPRISE ONLY
*
* Interactive "what-if" analysis tool for strategic planning
* Allows users to test different scenarios and see potential impacts on demand
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useTenantStore } from '../../../../stores';
import { forecastingService } from '../../../../api/services/forecasting';
import {
ScenarioType,
ScenarioSimulationRequest,
ScenarioSimulationResponse,
WeatherScenario,
CompetitionScenario,
EventScenario,
PricingScenario,
PromotionScenario,
} from '../../../../api/types/forecasting';
import {
Card,
Button,
Badge,
} from '../../../../components/ui';
import {
CloudRain,
Sun,
Users,
Calendar,
Tag,
TrendingUp,
AlertTriangle,
CheckCircle,
Lightbulb,
BarChart3,
ArrowUpRight,
ArrowDownRight,
Play,
Sparkles,
} from 'lucide-react';
import { PageHeader } from '../../../../components/layout';
export const ScenarioSimulationPage: React.FC = () => {
const { t } = useTranslation();
const currentTenant = useTenantStore((state) => state.currentTenant);
const [selectedScenarioType, setSelectedScenarioType] = useState<ScenarioType>(ScenarioType.WEATHER);
const [isSimulating, setIsSimulating] = useState(false);
const [simulationResult, setSimulationResult] = useState<ScenarioSimulationResponse | null>(null);
const [error, setError] = useState<string | null>(null);
// Form state
const [scenarioName, setScenarioName] = useState('');
const [startDate, setStartDate] = useState(new Date().toISOString().split('T')[0]);
const [durationDays, setDurationDays] = useState(7);
const [selectedProducts, setSelectedProducts] = useState<string[]>([]);
// Scenario-specific parameters
const [weatherParams, setWeatherParams] = useState<WeatherScenario>({
temperature_change: 15,
weather_type: 'heatwave',
});
const [competitionParams, setCompetitionParams] = useState<CompetitionScenario>({
new_competitors: 1,
distance_km: 0.5,
estimated_market_share_loss: 0.15,
});
const [eventParams, setEventParams] = useState<EventScenario>({
event_type: 'festival',
expected_attendance: 5000,
distance_km: 1.0,
duration_days: 3,
});
const [pricingParams, setPricingParams] = useState<PricingScenario>({
price_change_percent: 10,
});
const [promotionParams, setPromotionParams] = useState<PromotionScenario>({
discount_percent: 20,
promotion_type: 'discount',
expected_traffic_increase: 0.3,
});
const handleSimulate = async () => {
if (!currentTenant?.id) return;
if (!scenarioName || selectedProducts.length === 0) {
setError('Please provide a scenario name and select at least one product');
return;
}
setIsSimulating(true);
setError(null);
try {
const request: ScenarioSimulationRequest = {
scenario_name: scenarioName,
scenario_type: selectedScenarioType,
inventory_product_ids: selectedProducts,
start_date: startDate,
duration_days: durationDays,
include_baseline: true,
};
// Add scenario-specific parameters
switch (selectedScenarioType) {
case ScenarioType.WEATHER:
request.weather_params = weatherParams;
break;
case ScenarioType.COMPETITION:
request.competition_params = competitionParams;
break;
case ScenarioType.EVENT:
request.event_params = eventParams;
break;
case ScenarioType.PRICING:
request.pricing_params = pricingParams;
break;
case ScenarioType.PROMOTION:
request.promotion_params = promotionParams;
break;
}
const result = await forecastingService.simulateScenario(currentTenant.id, request);
setSimulationResult(result);
} catch (err: any) {
console.error('Simulation error:', err);
if (err.response?.status === 402) {
setError('This feature requires a Professional or Enterprise subscription. Please upgrade your plan to access scenario simulation tools.');
} else {
setError(err.response?.data?.detail || 'Failed to run scenario simulation');
}
} finally {
setIsSimulating(false);
}
};
const scenarioIcons = {
[ScenarioType.WEATHER]: CloudRain,
[ScenarioType.COMPETITION]: Users,
[ScenarioType.EVENT]: Calendar,
[ScenarioType.PRICING]: Tag,
[ScenarioType.PROMOTION]: TrendingUp,
[ScenarioType.HOLIDAY]: Calendar,
[ScenarioType.SUPPLY_DISRUPTION]: AlertTriangle,
[ScenarioType.CUSTOM]: Sparkles,
};
const getRiskLevelColor = (riskLevel: string) => {
switch (riskLevel) {
case 'high':
return 'error';
case 'medium':
return 'warning';
case 'low':
return 'success';
default:
return 'default';
}
};
return (
<div className="space-y-6">
<PageHeader
title={t('analytics.scenario_simulation.title', 'Scenario Simulation')}
subtitle={t('analytics.scenario_simulation.subtitle', 'Test "what-if" scenarios to optimize your planning')}
icon={Sparkles}
status={{
text: t('subscription.professional_enterprise', 'Professional/Enterprise'),
variant: 'primary'
}}
/>
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm text-red-800">{error}</p>
</div>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Left Column: Configuration */}
<div className="space-y-6">
<Card>
<div className="p-6 space-y-6">
<div>
<h3 className="text-lg font-semibold mb-4">
{t('analytics.scenario_simulation.configure', 'Configure Scenario')}
</h3>
{/* Scenario Name */}
<div className="space-y-2">
<label className="text-sm font-medium">
{t('analytics.scenario_simulation.scenario_name', 'Scenario Name')}
</label>
<input
type="text"
value={scenarioName}
onChange={(e) => setScenarioName(e.target.value)}
placeholder={t('analytics.scenario_simulation.scenario_name_placeholder', 'e.g., Summer Heatwave Impact')}
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
{/* Date Range */}
<div className="grid grid-cols-2 gap-4 mt-4">
<div className="space-y-2">
<label className="text-sm font-medium">
{t('analytics.scenario_simulation.start_date', 'Start Date')}
</label>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="w-full px-3 py-2 border rounded-lg"
min={new Date().toISOString().split('T')[0]}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">
{t('analytics.scenario_simulation.duration', 'Duration (days)')}
</label>
<input
type="number"
value={durationDays}
onChange={(e) => setDurationDays(parseInt(e.target.value) || 7)}
min={1}
max={30}
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
</div>
</div>
<div>
<h4 className="text-sm font-medium mb-3">
{t('analytics.scenario_simulation.scenario_type', 'Scenario Type')}
</h4>
<div className="grid grid-cols-2 gap-3">
{Object.values(ScenarioType).map((type) => {
const Icon = scenarioIcons[type];
return (
<button
key={type}
onClick={() => setSelectedScenarioType(type)}
className={`p-3 border rounded-lg flex items-center gap-2 transition-all ${
selectedScenarioType === type
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<Icon className="w-4 h-4" />
<span className="text-sm capitalize">{type.replace('_', ' ')}</span>
</button>
);
})}
</div>
</div>
{/* Scenario-Specific Parameters */}
<div className="pt-4 border-t">
<h4 className="text-sm font-medium mb-3">
{t('analytics.scenario_simulation.parameters', 'Parameters')}
</h4>
{selectedScenarioType === ScenarioType.WEATHER && (
<div className="space-y-3">
<div>
<label className="text-sm">Temperature Change (°C)</label>
<input
type="number"
value={weatherParams.temperature_change || 0}
onChange={(e) => setWeatherParams({ ...weatherParams, temperature_change: parseFloat(e.target.value) })}
className="w-full px-3 py-2 border rounded-lg mt-1"
min={-30}
max={30}
/>
</div>
<div>
<label className="text-sm">Weather Type</label>
<select
value={weatherParams.weather_type || 'heatwave'}
onChange={(e) => setWeatherParams({ ...weatherParams, weather_type: e.target.value })}
className="w-full px-3 py-2 border rounded-lg mt-1"
>
<option value="heatwave">Heatwave</option>
<option value="cold_snap">Cold Snap</option>
<option value="rainy">Rainy</option>
<option value="stormy">Stormy</option>
</select>
</div>
</div>
)}
{selectedScenarioType === ScenarioType.COMPETITION && (
<div className="space-y-3">
<div>
<label className="text-sm">New Competitors</label>
<input
type="number"
value={competitionParams.new_competitors}
onChange={(e) => setCompetitionParams({ ...competitionParams, new_competitors: parseInt(e.target.value) || 1 })}
className="w-full px-3 py-2 border rounded-lg mt-1"
min={1}
max={10}
/>
</div>
<div>
<label className="text-sm">Distance (km)</label>
<input
type="number"
step="0.1"
value={competitionParams.distance_km}
onChange={(e) => setCompetitionParams({ ...competitionParams, distance_km: parseFloat(e.target.value) })}
className="w-full px-3 py-2 border rounded-lg mt-1"
min={0.1}
max={10}
/>
</div>
<div>
<label className="text-sm">Est. Market Share Loss (%)</label>
<input
type="number"
value={competitionParams.estimated_market_share_loss * 100}
onChange={(e) => setCompetitionParams({ ...competitionParams, estimated_market_share_loss: parseFloat(e.target.value) / 100 })}
className="w-full px-3 py-2 border rounded-lg mt-1"
min={0}
max={50}
/>
</div>
</div>
)}
{selectedScenarioType === ScenarioType.PROMOTION && (
<div className="space-y-3">
<div>
<label className="text-sm">Discount (%)</label>
<input
type="number"
value={promotionParams.discount_percent}
onChange={(e) => setPromotionParams({ ...promotionParams, discount_percent: parseFloat(e.target.value) })}
className="w-full px-3 py-2 border rounded-lg mt-1"
min={0}
max={75}
/>
</div>
<div>
<label className="text-sm">Expected Traffic Increase (%)</label>
<input
type="number"
value={promotionParams.expected_traffic_increase * 100}
onChange={(e) => setPromotionParams({ ...promotionParams, expected_traffic_increase: parseFloat(e.target.value) / 100 })}
className="w-full px-3 py-2 border rounded-lg mt-1"
min={0}
max={200}
/>
</div>
</div>
)}
</div>
<Button
onClick={handleSimulate}
disabled={isSimulating || !scenarioName}
className="w-full"
size="lg"
>
<Play className="w-4 h-4 mr-2" />
{isSimulating ? t('common.simulating', 'Simulating...') : t('common.run_simulation', 'Run Simulation')}
</Button>
</div>
</Card>
{/* Quick Examples */}
<Card>
<div className="p-6">
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
<Lightbulb className="w-4 h-4" />
{t('analytics.scenario_simulation.quick_examples', 'Quick Examples')}
</h3>
<div className="space-y-2">
<button
onClick={() => {
setSelectedScenarioType(ScenarioType.WEATHER);
setScenarioName('Summer Heatwave Next Week');
setWeatherParams({ temperature_change: 15, weather_type: 'heatwave' });
}}
className="w-full text-left px-3 py-2 border rounded-lg hover:bg-gray-50 text-sm"
>
<Sun className="w-4 h-4 inline mr-2" />
What if a heatwave hits next week?
</button>
<button
onClick={() => {
setSelectedScenarioType(ScenarioType.COMPETITION);
setScenarioName('New Bakery Opening Nearby');
setCompetitionParams({ new_competitors: 1, distance_km: 0.3, estimated_market_share_loss: 0.2 });
}}
className="w-full text-left px-3 py-2 border rounded-lg hover:bg-gray-50 text-sm"
>
<Users className="w-4 h-4 inline mr-2" />
How would a new competitor affect sales?
</button>
<button
onClick={() => {
setSelectedScenarioType(ScenarioType.PROMOTION);
setScenarioName('Weekend Flash Sale');
setPromotionParams({ discount_percent: 25, promotion_type: 'flash_sale', expected_traffic_increase: 0.5 });
}}
className="w-full text-left px-3 py-2 border rounded-lg hover:bg-gray-50 text-sm"
>
<Tag className="w-4 h-4 inline mr-2" />
Impact of a 25% weekend promotion?
</button>
</div>
</div>
</Card>
</div>
{/* Right Column: Results */}
<div className="space-y-6">
{simulationResult ? (
<>
{/* Impact Summary */}
<Card>
<div className="p-6">
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="text-lg font-semibold">{simulationResult.scenario_name}</h3>
<p className="text-sm text-gray-500 capitalize">{simulationResult.scenario_type.replace('_', ' ')}</p>
</div>
<Badge variant={getRiskLevelColor(simulationResult.risk_level)}>
{simulationResult.risk_level} risk
</Badge>
</div>
<div className="grid grid-cols-2 gap-4 mb-6">
<div className="p-4 bg-gray-50 rounded-lg">
<div className="text-sm text-gray-500 mb-1">Baseline Demand</div>
<div className="text-2xl font-bold">{Math.round(simulationResult.total_baseline_demand)}</div>
</div>
<div className="p-4 bg-blue-50 rounded-lg">
<div className="text-sm text-gray-500 mb-1">Scenario Demand</div>
<div className="text-2xl font-bold">{Math.round(simulationResult.total_scenario_demand)}</div>
</div>
</div>
<div className="p-4 bg-gradient-to-r from-blue-50 to-purple-50 rounded-lg">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Overall Impact</span>
<div className="flex items-center gap-2">
{simulationResult.overall_impact_percent > 0 ? (
<ArrowUpRight className="w-5 h-5 text-green-500" />
) : (
<ArrowDownRight className="w-5 h-5 text-red-500" />
)}
<span className={`text-2xl font-bold ${
simulationResult.overall_impact_percent > 0 ? 'text-green-600' : 'text-red-600'
}`}>
{simulationResult.overall_impact_percent > 0 ? '+' : ''}
{simulationResult.overall_impact_percent.toFixed(1)}%
</span>
</div>
</div>
</div>
</div>
</Card>
{/* Insights */}
<Card>
<div className="p-6">
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
<Lightbulb className="w-4 h-4" />
{t('analytics.scenario_simulation.insights', 'Key Insights')}
</h3>
<div className="space-y-2">
{simulationResult.insights.map((insight, index) => (
<div key={index} className="flex items-start gap-2 text-sm">
<CheckCircle className="w-4 h-4 text-blue-500 mt-0.5 flex-shrink-0" />
<span>{insight}</span>
</div>
))}
</div>
</div>
</Card>
{/* Recommendations */}
<Card>
<div className="p-6">
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
<TrendingUp className="w-4 h-4" />
{t('analytics.scenario_simulation.recommendations', 'Recommendations')}
</h3>
<div className="space-y-2">
{simulationResult.recommendations.map((recommendation, index) => (
<div key={index} className="flex items-start gap-2 text-sm p-3 bg-blue-50 rounded-lg">
<span className="font-medium text-blue-600">{index + 1}.</span>
<span>{recommendation}</span>
</div>
))}
</div>
</div>
</Card>
{/* Product Impacts */}
{simulationResult.product_impacts.length > 0 && (
<Card>
<div className="p-6">
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
<BarChart3 className="w-4 h-4" />
{t('analytics.scenario_simulation.product_impacts', 'Product Impacts')}
</h3>
<div className="space-y-3">
{simulationResult.product_impacts.map((impact, index) => (
<div key={index} className="p-3 border rounded-lg">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">{impact.inventory_product_id}</span>
<span className={`text-sm font-bold ${
impact.demand_change_percent > 0 ? 'text-green-600' : 'text-red-600'
}`}>
{impact.demand_change_percent > 0 ? '+' : ''}
{impact.demand_change_percent.toFixed(1)}%
</span>
</div>
<div className="flex items-center justify-between text-xs text-gray-500">
<span>Baseline: {Math.round(impact.baseline_demand)}</span>
<span></span>
<span>Scenario: {Math.round(impact.simulated_demand)}</span>
</div>
</div>
))}
</div>
</div>
</Card>
)}
</>
) : (
<Card>
<div className="p-12 text-center text-gray-400">
<Sparkles className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p className="text-sm">
{t('analytics.scenario_simulation.no_results', 'Configure and run a scenario to see results')}
</p>
</div>
</Card>
)}
</div>
</div>
</div>
);
};
export default ScenarioSimulationPage;

View File

@@ -17,6 +17,7 @@ import AddStockModal from '../../../../components/domain/inventory/AddStockModal
import { useIngredients, useStockAnalytics, useStockMovements, useStockByIngredient, useCreateIngredient, useSoftDeleteIngredient, useHardDeleteIngredient, useAddStock, useConsumeStock, useUpdateIngredient, useTransformationsByIngredient } from '../../../../api/hooks/inventory';
import { useTenantId } from '../../../../hooks/useTenantId';
import { IngredientResponse, StockCreate, StockMovementCreate, IngredientCreate } from '../../../../api/types/inventory';
import { subscriptionService } from '../../../../api/services/subscription';
const InventoryPage: React.FC = () => {
const [searchTerm, setSearchTerm] = useState('');
@@ -312,7 +313,21 @@ const InventoryPage: React.FC = () => {
// Handle creating a new ingredient
const handleCreateIngredient = async (ingredientData: IngredientCreate) => {
if (!tenantId) {
throw new Error('No tenant ID available');
}
try {
// Check subscription limits before creating
const usageCheck = await subscriptionService.checkUsageLimit(tenantId, 'inventory_items', 1);
if (!usageCheck.allowed) {
throw new Error(
usageCheck.message ||
`Has alcanzado el límite de ${usageCheck.limit} ingredientes para tu plan. Actualiza tu suscripción para agregar más.`
);
}
await createIngredientMutation.mutateAsync({
tenantId,
ingredientData

View File

@@ -10,6 +10,7 @@ import { useAuthUser } from '../../../../stores/auth.store';
import { useCurrentTenant, useCurrentTenantAccess } from '../../../../stores/tenant.store';
import { useToast } from '../../../../hooks/ui/useToast';
import { TENANT_ROLES } from '../../../../types/roles';
import { subscriptionService } from '../../../../api/services/subscription';
const TeamPage: React.FC = () => {
const { t } = useTranslation(['settings']);
@@ -447,20 +448,36 @@ const TeamPage: React.FC = () => {
}}
onAddMember={async (userData) => {
if (!tenantId) return Promise.reject('No tenant ID available');
return addMemberMutation.mutateAsync({
tenantId,
userId: userData.userId,
role: userData.role,
}).then(() => {
try {
// Check subscription limits before adding member
const usageCheck = await subscriptionService.checkUsageLimit(tenantId, 'users', 1);
if (!usageCheck.allowed) {
const errorMessage = usageCheck.message ||
`Has alcanzado el límite de ${usageCheck.limit} usuarios para tu plan. Actualiza tu suscripción para agregar más miembros.`;
addToast(errorMessage, { type: 'error' });
throw new Error(errorMessage);
}
await addMemberMutation.mutateAsync({
tenantId,
userId: userData.userId,
role: userData.role,
});
addToast('Miembro agregado exitosamente', { type: 'success' });
setShowAddForm(false);
setSelectedUserToAdd('');
setSelectedRoleToAdd(TENANT_ROLES.MEMBER);
}).catch((error) => {
} catch (error) {
if ((error as Error).message.includes('límite')) {
// Limit error already toasted above
throw error;
}
addToast('Error al agregar miembro', { type: 'error' });
throw error;
});
}
}}
availableUsers={availableUsers}
/>

View File

@@ -26,6 +26,7 @@ const ProductionAnalyticsPage = React.lazy(() => import('../pages/app/analytics/
const ProcurementAnalyticsPage = React.lazy(() => import('../pages/app/analytics/ProcurementAnalyticsPage'));
const ForecastingPage = React.lazy(() => import('../pages/app/analytics/forecasting/ForecastingPage'));
const SalesAnalyticsPage = React.lazy(() => import('../pages/app/analytics/sales-analytics/SalesAnalyticsPage'));
const ScenarioSimulationPage = React.lazy(() => import('../pages/app/analytics/scenario-simulation/ScenarioSimulationPage'));
const AIInsightsPage = React.lazy(() => import('../pages/app/analytics/ai-insights/AIInsightsPage'));
const PerformanceAnalyticsPage = React.lazy(() => import('../pages/app/analytics/performance/PerformanceAnalyticsPage'));
@@ -258,25 +259,35 @@ export const AppRouter: React.FC = () => {
</ProtectedRoute>
}
/>
<Route
path="/app/analytics/ai-insights"
<Route
path="/app/analytics/scenario-simulation"
element={
<ProtectedRoute>
<AppShell>
<ScenarioSimulationPage />
</AppShell>
</ProtectedRoute>
}
/>
<Route
path="/app/analytics/ai-insights"
element={
<ProtectedRoute>
<AppShell>
<AIInsightsPage />
</AppShell>
</ProtectedRoute>
}
}
/>
<Route
path="/app/analytics/performance"
<Route
path="/app/analytics/performance"
element={
<ProtectedRoute>
<AppShell>
<PerformanceAnalyticsPage />
</AppShell>
</ProtectedRoute>
}
}
/>

View File

@@ -337,6 +337,18 @@ export const routesConfig: RouteConfig[] = [
showInNavigation: true,
showInBreadcrumbs: true,
},
{
path: '/app/analytics/scenario-simulation',
name: 'ScenarioSimulation',
component: 'ScenarioSimulationPage',
title: 'Simulación de Escenarios',
icon: 'forecasting',
requiresAuth: true,
requiredRoles: ROLE_COMBINATIONS.MANAGEMENT_ACCESS,
requiredAnalyticsLevel: 'predictive',
showInNavigation: true,
showInBreadcrumbs: true,
},
{
path: '/app/analytics/ai-insights',
name: 'AIInsights',