REFACTOR ALL APIs fix 1
This commit is contained in:
@@ -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
|
||||
// ===================================================================
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"pos": "Point of Sale",
|
||||
"analytics": "Analytics",
|
||||
"forecasting": "Forecasting",
|
||||
"scenario_simulation": "Scenario Simulation",
|
||||
"sales": "Sales",
|
||||
"performance": "Performance",
|
||||
"insights": "AI Insights",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"pos": "Salmenta-puntua",
|
||||
"analytics": "Analisiak",
|
||||
"forecasting": "Aurreikuspenak",
|
||||
"scenario_simulation": "Agertoki-simulazioa",
|
||||
"sales": "Salmentak",
|
||||
"performance": "Errendimendua",
|
||||
"insights": "AA ikuspegiak",
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user