REFACTOR ALL APIs fix 1
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user