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

@@ -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}
/>