Improve the frontend 4

This commit is contained in:
Urtzi Alfaro
2025-11-01 21:35:03 +01:00
parent f44d235c6d
commit 0220da1725
59 changed files with 5785 additions and 1870 deletions

View File

@@ -18,22 +18,6 @@ import {
GetCustomersParams,
UpdateOrderStatusParams,
GetDemandRequirementsParams,
// Procurement types
ProcurementPlanResponse,
ProcurementPlanCreate,
ProcurementPlanUpdate,
ProcurementRequirementResponse,
ProcurementRequirementUpdate,
ProcurementDashboardData,
GeneratePlanRequest,
GeneratePlanResponse,
PaginatedProcurementPlans,
GetProcurementPlansParams,
CreatePOsResult,
LinkRequirementToPORequest,
UpdateDeliveryStatusRequest,
GetPlanRequirementsParams,
UpdatePlanStatusParams,
} from '../types/orders';
import { ApiError } from '../client/apiClient';
@@ -58,17 +42,6 @@ export const ordersKeys = {
// Status
status: (tenantId: string) => [...ordersKeys.all, 'status', tenantId] as const,
// Procurement
procurement: () => [...ordersKeys.all, 'procurement'] as const,
procurementPlans: (params: GetProcurementPlansParams) => [...ordersKeys.procurement(), 'plans', params] as const,
procurementPlan: (tenantId: string, planId: string) => [...ordersKeys.procurement(), 'plan', tenantId, planId] as const,
procurementPlanByDate: (tenantId: string, date: string) => [...ordersKeys.procurement(), 'plan-by-date', tenantId, date] as const,
currentProcurementPlan: (tenantId: string) => [...ordersKeys.procurement(), 'current-plan', tenantId] as const,
procurementDashboard: (tenantId: string) => [...ordersKeys.procurement(), 'dashboard', tenantId] as const,
planRequirements: (params: GetPlanRequirementsParams) => [...ordersKeys.procurement(), 'requirements', params] as const,
criticalRequirements: (tenantId: string) => [...ordersKeys.procurement(), 'critical-requirements', tenantId] as const,
procurementHealth: (tenantId: string) => [...ordersKeys.procurement(), 'health', tenantId] as const,
} as const;
// ===== Order Queries =====
@@ -360,378 +333,3 @@ export const useInvalidateOrders = () => {
},
};
};
// ===== Procurement Queries =====
export const useProcurementPlans = (
params: GetProcurementPlansParams,
options?: Omit<UseQueryOptions<PaginatedProcurementPlans, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<PaginatedProcurementPlans, ApiError>({
queryKey: ordersKeys.procurementPlans(params),
queryFn: () => OrdersService.getProcurementPlans(params),
staleTime: 5 * 60 * 1000, // 5 minutes
enabled: !!params.tenant_id,
...options,
});
};
export const useProcurementPlan = (
tenantId: string,
planId: string,
options?: Omit<UseQueryOptions<ProcurementPlanResponse | null, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<ProcurementPlanResponse | null, ApiError>({
queryKey: ordersKeys.procurementPlan(tenantId, planId),
queryFn: () => OrdersService.getProcurementPlanById(tenantId, planId),
staleTime: 2 * 60 * 1000, // 2 minutes
enabled: !!tenantId && !!planId,
...options,
});
};
export const useProcurementPlanByDate = (
tenantId: string,
planDate: string,
options?: Omit<UseQueryOptions<ProcurementPlanResponse | null, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<ProcurementPlanResponse | null, ApiError>({
queryKey: ordersKeys.procurementPlanByDate(tenantId, planDate),
queryFn: () => OrdersService.getProcurementPlanByDate(tenantId, planDate),
staleTime: 2 * 60 * 1000, // 2 minutes
enabled: !!tenantId && !!planDate,
...options,
});
};
export const useCurrentProcurementPlan = (
tenantId: string,
options?: Omit<UseQueryOptions<ProcurementPlanResponse | null, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<ProcurementPlanResponse | null, ApiError>({
queryKey: ordersKeys.currentProcurementPlan(tenantId),
queryFn: () => OrdersService.getCurrentProcurementPlan(tenantId),
staleTime: 1 * 60 * 1000, // 1 minute
enabled: !!tenantId,
...options,
});
};
export const useProcurementDashboard = (
tenantId: string,
options?: Omit<UseQueryOptions<ProcurementDashboardData | null, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<ProcurementDashboardData | null, ApiError>({
queryKey: ordersKeys.procurementDashboard(tenantId),
queryFn: () => OrdersService.getProcurementDashboard(tenantId),
staleTime: 2 * 60 * 1000, // 2 minutes
enabled: !!tenantId,
...options,
});
};
export const usePlanRequirements = (
params: GetPlanRequirementsParams,
options?: Omit<UseQueryOptions<ProcurementRequirementResponse[], ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<ProcurementRequirementResponse[], ApiError>({
queryKey: ordersKeys.planRequirements(params),
queryFn: () => OrdersService.getPlanRequirements(params),
staleTime: 2 * 60 * 1000, // 2 minutes
enabled: !!params.tenant_id && !!params.plan_id,
...options,
});
};
export const useCriticalRequirements = (
tenantId: string,
options?: Omit<UseQueryOptions<ProcurementRequirementResponse[], ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<ProcurementRequirementResponse[], ApiError>({
queryKey: ordersKeys.criticalRequirements(tenantId),
queryFn: () => OrdersService.getCriticalRequirements(tenantId),
staleTime: 1 * 60 * 1000, // 1 minute
enabled: !!tenantId,
...options,
});
};
export const useProcurementHealth = (
tenantId: string,
options?: Omit<UseQueryOptions<{ status: string; service: string; procurement_enabled: boolean; timestamp: string }, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<{ status: string; service: string; procurement_enabled: boolean; timestamp: string }, ApiError>({
queryKey: ordersKeys.procurementHealth(tenantId),
queryFn: () => OrdersService.getProcurementHealth(tenantId),
staleTime: 30 * 1000, // 30 seconds
enabled: !!tenantId,
...options,
});
};
// ===== Procurement Mutations =====
export const useGenerateProcurementPlan = (
options?: UseMutationOptions<GeneratePlanResponse, ApiError, { tenantId: string; request: GeneratePlanRequest }>
) => {
const queryClient = useQueryClient();
return useMutation<GeneratePlanResponse, ApiError, { tenantId: string; request: GeneratePlanRequest }>({
mutationFn: ({ tenantId, request }) => OrdersService.generateProcurementPlan(tenantId, request),
onSuccess: (data, variables) => {
// Invalidate all procurement queries for this tenant
queryClient.invalidateQueries({
queryKey: ordersKeys.procurement(),
predicate: (query) => {
return JSON.stringify(query.queryKey).includes(variables.tenantId);
},
});
// If plan was generated successfully, cache it
if (data.success && data.plan) {
queryClient.setQueryData(
ordersKeys.procurementPlan(variables.tenantId, data.plan.id),
data.plan
);
}
},
...options,
});
};
export const useUpdateProcurementPlanStatus = (
options?: UseMutationOptions<ProcurementPlanResponse, ApiError, UpdatePlanStatusParams>
) => {
const queryClient = useQueryClient();
return useMutation<ProcurementPlanResponse, ApiError, UpdatePlanStatusParams>({
mutationFn: (params) => OrdersService.updateProcurementPlanStatus(params),
onSuccess: (data, variables) => {
// Update the specific plan in cache
queryClient.setQueryData(
ordersKeys.procurementPlan(variables.tenant_id, variables.plan_id),
data
);
// Invalidate plans list
queryClient.invalidateQueries({
queryKey: ordersKeys.procurement(),
predicate: (query) => {
const queryKey = query.queryKey as string[];
return queryKey.includes('plans') &&
JSON.stringify(queryKey).includes(variables.tenant_id);
},
});
// Invalidate dashboard
queryClient.invalidateQueries({
queryKey: ordersKeys.procurementDashboard(variables.tenant_id),
});
},
...options,
});
};
export const useTriggerDailyScheduler = (
options?: UseMutationOptions<{ success: boolean; message: string; tenant_id: string }, ApiError, string>
) => {
const queryClient = useQueryClient();
return useMutation<{ success: boolean; message: string; tenant_id: string }, ApiError, string>({
mutationFn: (tenantId) => OrdersService.triggerDailyScheduler(tenantId),
onSuccess: (data, tenantId) => {
// Invalidate all procurement data for this tenant
queryClient.invalidateQueries({
queryKey: ordersKeys.procurement(),
predicate: (query) => {
return JSON.stringify(query.queryKey).includes(tenantId);
},
});
},
...options,
});
};
// ===== NEW PROCUREMENT FEATURE HOOKS =====
/**
* Hook to recalculate a procurement plan
*/
export const useRecalculateProcurementPlan = (
options?: UseMutationOptions<GeneratePlanResponse, ApiError, { tenantId: string; planId: string }>
) => {
const queryClient = useQueryClient();
return useMutation<GeneratePlanResponse, ApiError, { tenantId: string; planId: string }>({
mutationFn: ({ tenantId, planId }) => OrdersService.recalculateProcurementPlan(tenantId, planId),
onSuccess: (data, variables) => {
if (data.plan) {
// Update the specific plan in cache
queryClient.setQueryData(
ordersKeys.procurementPlan(variables.tenantId, variables.planId),
data.plan
);
}
// Invalidate plans list and dashboard
queryClient.invalidateQueries({
queryKey: ordersKeys.procurement(),
predicate: (query) => {
return JSON.stringify(query.queryKey).includes(variables.tenantId);
},
});
},
...options,
});
};
/**
* Hook to approve a procurement plan
*/
export const useApproveProcurementPlan = (
options?: UseMutationOptions<ProcurementPlanResponse, ApiError, { tenantId: string; planId: string; approval_notes?: string }>
) => {
const queryClient = useQueryClient();
return useMutation<ProcurementPlanResponse, ApiError, { tenantId: string; planId: string; approval_notes?: string }>({
mutationFn: ({ tenantId, planId, approval_notes }) =>
OrdersService.approveProcurementPlan(tenantId, planId, { approval_notes }),
onSuccess: (data, variables) => {
// Update the specific plan in cache
queryClient.setQueryData(
ordersKeys.procurementPlan(variables.tenantId, variables.planId),
data
);
// Invalidate plans list and dashboard
queryClient.invalidateQueries({
queryKey: ordersKeys.procurement(),
predicate: (query) => {
return JSON.stringify(query.queryKey).includes(variables.tenantId);
},
});
},
...options,
});
};
/**
* Hook to reject a procurement plan
*/
export const useRejectProcurementPlan = (
options?: UseMutationOptions<ProcurementPlanResponse, ApiError, { tenantId: string; planId: string; rejection_notes?: string }>
) => {
const queryClient = useQueryClient();
return useMutation<ProcurementPlanResponse, ApiError, { tenantId: string; planId: string; rejection_notes?: string }>({
mutationFn: ({ tenantId, planId, rejection_notes }) =>
OrdersService.rejectProcurementPlan(tenantId, planId, { rejection_notes }),
onSuccess: (data, variables) => {
// Update the specific plan in cache
queryClient.setQueryData(
ordersKeys.procurementPlan(variables.tenantId, variables.planId),
data
);
// Invalidate plans list and dashboard
queryClient.invalidateQueries({
queryKey: ordersKeys.procurement(),
predicate: (query) => {
return JSON.stringify(query.queryKey).includes(variables.tenantId);
},
});
},
...options,
});
};
/**
* Hook to create purchase orders from procurement plan
*/
export const useCreatePurchaseOrdersFromPlan = (
options?: UseMutationOptions<CreatePOsResult, ApiError, { tenantId: string; planId: string; autoApprove?: boolean }>
) => {
const queryClient = useQueryClient();
return useMutation<CreatePOsResult, ApiError, { tenantId: string; planId: string; autoApprove?: boolean }>({
mutationFn: ({ tenantId, planId, autoApprove = false }) =>
OrdersService.createPurchaseOrdersFromPlan(tenantId, planId, autoApprove),
onSuccess: (data, variables) => {
// Invalidate procurement plan to refresh requirements status
queryClient.invalidateQueries({
queryKey: ordersKeys.procurementPlan(variables.tenantId, variables.planId),
});
// Invalidate dashboard
queryClient.invalidateQueries({
queryKey: ordersKeys.procurementDashboard(variables.tenantId),
});
},
...options,
});
};
/**
* Hook to link a requirement to a purchase order
*/
export const useLinkRequirementToPurchaseOrder = (
options?: UseMutationOptions<
{ success: boolean; message: string; requirement_id: string; purchase_order_id: string },
ApiError,
{ tenantId: string; requirementId: string; request: LinkRequirementToPORequest }
>
) => {
const queryClient = useQueryClient();
return useMutation<
{ success: boolean; message: string; requirement_id: string; purchase_order_id: string },
ApiError,
{ tenantId: string; requirementId: string; request: LinkRequirementToPORequest }
>({
mutationFn: ({ tenantId, requirementId, request }) =>
OrdersService.linkRequirementToPurchaseOrder(tenantId, requirementId, request),
onSuccess: (data, variables) => {
// Invalidate procurement data to refresh requirements
queryClient.invalidateQueries({
queryKey: ordersKeys.procurement(),
predicate: (query) => {
return JSON.stringify(query.queryKey).includes(variables.tenantId);
},
});
},
...options,
});
};
/**
* Hook to update delivery status for a requirement
*/
export const useUpdateRequirementDeliveryStatus = (
options?: UseMutationOptions<
{ success: boolean; message: string; requirement_id: string; delivery_status: string },
ApiError,
{ tenantId: string; requirementId: string; request: UpdateDeliveryStatusRequest }
>
) => {
const queryClient = useQueryClient();
return useMutation<
{ success: boolean; message: string; requirement_id: string; delivery_status: string },
ApiError,
{ tenantId: string; requirementId: string; request: UpdateDeliveryStatusRequest }
>({
mutationFn: ({ tenantId, requirementId, request }) =>
OrdersService.updateRequirementDeliveryStatus(tenantId, requirementId, request),
onSuccess: (data, variables) => {
// Invalidate procurement data to refresh requirements
queryClient.invalidateQueries({
queryKey: ordersKeys.procurement(),
predicate: (query) => {
return JSON.stringify(query.queryKey).includes(variables.tenantId);
},
});
},
...options,
});
};

View File

@@ -0,0 +1,926 @@
/**
* Performance Analytics Hooks
* React Query hooks for fetching real-time performance data across all departments
*/
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import {
PerformanceOverview,
DepartmentPerformance,
KPIMetric,
PerformanceAlert,
HourlyProductivity,
ProductionPerformance,
InventoryPerformance,
SalesPerformance,
ProcurementPerformance,
TimePeriod,
} from '../types/performance';
import { useProductionDashboard } from './production';
import { useInventoryDashboard } from './dashboard';
import { useSalesAnalytics } from './sales';
import { useProcurementDashboard } from './procurement';
import { useOrdersDashboard } from './orders';
// ============================================================================
// Helper Functions
// ============================================================================
const getDateRangeForPeriod = (period: TimePeriod): { startDate: string; endDate: string } => {
const endDate = new Date();
const startDate = new Date();
switch (period) {
case 'day':
startDate.setDate(endDate.getDate() - 1);
break;
case 'week':
startDate.setDate(endDate.getDate() - 7);
break;
case 'month':
startDate.setMonth(endDate.getMonth() - 1);
break;
case 'quarter':
startDate.setMonth(endDate.getMonth() - 3);
break;
case 'year':
startDate.setFullYear(endDate.getFullYear() - 1);
break;
}
return {
startDate: startDate.toISOString().split('T')[0],
endDate: endDate.toISOString().split('T')[0],
};
};
const calculateTrend = (current: number, previous: number): 'up' | 'down' | 'stable' => {
const change = ((current - previous) / previous) * 100;
if (Math.abs(change) < 1) return 'stable';
return change > 0 ? 'up' : 'down';
};
const calculateStatus = (current: number, target: number): 'good' | 'warning' | 'critical' => {
const percentage = (current / target) * 100;
if (percentage >= 95) return 'good';
if (percentage >= 85) return 'warning';
return 'critical';
};
// ============================================================================
// Production Performance Hook
// ============================================================================
export const useProductionPerformance = (tenantId: string, period: TimePeriod = 'week') => {
const { data: dashboard, isLoading: dashboardLoading } = useProductionDashboard(tenantId);
// Extract primitive values to prevent unnecessary recalculations
const efficiencyPercentage = dashboard?.efficiency_percentage || 0;
const qualityScore = dashboard?.average_quality_score || 0;
const capacityUtilization = dashboard?.capacity_utilization || 0;
const onTimeCompletionRate = dashboard?.on_time_completion_rate || 0;
const performance: ProductionPerformance | undefined = useMemo(() => {
if (!dashboard) return undefined;
return {
efficiency: efficiencyPercentage,
average_batch_time: 0, // Not available in dashboard
quality_rate: qualityScore,
waste_percentage: 0, // Would need production-trends endpoint
capacity_utilization: capacityUtilization,
equipment_efficiency: capacityUtilization,
on_time_completion_rate: onTimeCompletionRate,
yield_rate: 0, // Would need production-trends endpoint
};
}, [efficiencyPercentage, qualityScore, capacityUtilization, onTimeCompletionRate]);
return {
data: performance,
isLoading: dashboardLoading,
};
};
// ============================================================================
// Inventory Performance Hook
// ============================================================================
export const useInventoryPerformance = (tenantId: string) => {
const { data: dashboard, isLoading: dashboardLoading } = useInventoryDashboard(tenantId);
// Extract primitive values to prevent unnecessary recalculations
const totalItems = dashboard?.total_ingredients || 1;
const lowStockCount = dashboard?.low_stock_items || 0;
const outOfStockCount = dashboard?.out_of_stock_items || 0;
const foodSafetyAlertsActive = dashboard?.food_safety_alerts_active || 0;
const expiringItems = dashboard?.expiring_soon_items || 0;
const stockValue = dashboard?.total_stock_value || 0;
const performance: InventoryPerformance | undefined = useMemo(() => {
if (!dashboard) return undefined;
return {
stock_accuracy: 100 - ((lowStockCount + outOfStockCount) / totalItems * 100),
turnover_rate: 0, // TODO: Not available in dashboard
waste_rate: 0, // TODO: Derive from stock movements if available
low_stock_count: lowStockCount,
compliance_rate: foodSafetyAlertsActive === 0 ? 100 : 90, // Simplified compliance
expiring_items_count: expiringItems,
stock_value: stockValue,
};
}, [totalItems, lowStockCount, outOfStockCount, foodSafetyAlertsActive, expiringItems, stockValue]);
return {
data: performance,
isLoading: dashboardLoading,
};
};
// ============================================================================
// Sales Performance Hook
// ============================================================================
export const useSalesPerformance = (tenantId: string, period: TimePeriod = 'week') => {
const { startDate, endDate } = getDateRangeForPeriod(period);
const { data: salesData, isLoading: salesLoading } = useSalesAnalytics(
tenantId,
startDate,
endDate
);
// Extract primitive values to prevent unnecessary recalculations
const totalRevenue = salesData?.total_revenue || 0;
const totalTransactions = salesData?.total_transactions || 0;
const avgTransactionValue = salesData?.average_transaction_value || 0;
const topProductsString = salesData?.top_products ? JSON.stringify(salesData.top_products) : '[]';
const performance: SalesPerformance | undefined = useMemo(() => {
if (!salesData) return undefined;
const topProducts = JSON.parse(topProductsString);
return {
total_revenue: totalRevenue,
total_transactions: totalTransactions,
average_transaction_value: avgTransactionValue,
growth_rate: 0, // TODO: Calculate from trends
channel_performance: [], // TODO: Parse from sales_by_channel if needed
top_products: Array.isArray(topProducts)
? topProducts.map((product: any) => ({
product_id: product.inventory_product_id || '',
product_name: product.product_name || '',
sales: product.total_quantity || 0,
revenue: product.total_revenue || 0,
}))
: [],
};
}, [totalRevenue, totalTransactions, avgTransactionValue, topProductsString]);
return {
data: performance,
isLoading: salesLoading,
};
};
// ============================================================================
// Procurement Performance Hook
// ============================================================================
export const useProcurementPerformance = (tenantId: string) => {
const { data: dashboard, isLoading } = useProcurementDashboard(tenantId);
// Extract primitive values to prevent unnecessary recalculations
const avgFulfillmentRate = dashboard?.performance_metrics?.average_fulfillment_rate || 0;
const avgOnTimeDelivery = dashboard?.performance_metrics?.average_on_time_delivery || 0;
const costAccuracy = dashboard?.performance_metrics?.cost_accuracy || 0;
const supplierPerformance = dashboard?.performance_metrics?.supplier_performance || 0;
const totalPlans = dashboard?.summary?.total_plans || 0;
const lowStockCount = dashboard?.low_stock_alerts?.length || 0;
const overdueCount = dashboard?.overdue_requirements?.length || 0;
const performance: ProcurementPerformance | undefined = useMemo(() => {
if (!dashboard) return undefined;
return {
fulfillment_rate: avgFulfillmentRate,
on_time_delivery_rate: avgOnTimeDelivery,
cost_accuracy: costAccuracy,
supplier_performance_score: supplierPerformance,
active_plans: totalPlans,
critical_requirements: lowStockCount + overdueCount,
};
}, [avgFulfillmentRate, avgOnTimeDelivery, costAccuracy, supplierPerformance, totalPlans, lowStockCount, overdueCount]);
return {
data: performance,
isLoading,
};
};
// ============================================================================
// Performance Overview Hook (Aggregates All Departments)
// ============================================================================
export const usePerformanceOverview = (tenantId: string, period: TimePeriod = 'week') => {
const { data: production, isLoading: productionLoading } = useProductionPerformance(tenantId, period);
const { data: inventory, isLoading: inventoryLoading } = useInventoryPerformance(tenantId);
const { data: sales, isLoading: salesLoading } = useSalesPerformance(tenantId, period);
const { data: procurement, isLoading: procurementLoading } = useProcurementPerformance(tenantId);
const { data: orders, isLoading: ordersLoading } = useOrdersDashboard(tenantId);
const overview: PerformanceOverview | undefined = useMemo(() => {
if (!production || !inventory || !sales || !procurement || !orders) return undefined;
// Calculate customer satisfaction from order fulfillment and delivery performance
const totalOrders = orders.total_orders_today || 1;
const deliveredOrders = orders.delivered_orders || 0;
const orderFulfillmentRate = (deliveredOrders / totalOrders) * 100;
const customerSatisfaction = (orderFulfillmentRate + procurement.on_time_delivery_rate) / 2;
return {
overall_efficiency: production.efficiency,
average_production_time: production.average_batch_time,
quality_score: production.quality_rate,
employee_productivity: production.capacity_utilization,
customer_satisfaction: customerSatisfaction,
resource_utilization: production.equipment_efficiency || production.capacity_utilization,
};
}, [production, inventory, sales, procurement, orders]);
return {
data: overview,
isLoading: productionLoading || inventoryLoading || salesLoading || procurementLoading || ordersLoading,
};
};
// ============================================================================
// Department Performance Hook
// ============================================================================
export const useDepartmentPerformance = (tenantId: string, period: TimePeriod = 'week') => {
const { data: production, isLoading: productionLoading } = useProductionPerformance(tenantId, period);
const { data: inventory, isLoading: inventoryLoading } = useInventoryPerformance(tenantId);
const { data: sales, isLoading: salesLoading } = useSalesPerformance(tenantId, period);
const { data: procurement, isLoading: procurementLoading } = useProcurementPerformance(tenantId);
// Extract primitive values before useMemo to prevent unnecessary recalculations
const productionEfficiency = production?.efficiency || 0;
const productionAvgBatchTime = production?.average_batch_time || 0;
const productionQualityRate = production?.quality_rate || 0;
const productionWastePercentage = production?.waste_percentage || 0;
const salesTotalRevenue = sales?.total_revenue || 0;
const salesGrowthRate = sales?.growth_rate || 0;
const salesTotalTransactions = sales?.total_transactions || 0;
const salesAvgTransactionValue = sales?.average_transaction_value || 0;
const inventoryStockAccuracy = inventory?.stock_accuracy || 0;
const inventoryLowStockCount = inventory?.low_stock_count || 0;
const inventoryTurnoverRate = inventory?.turnover_rate || 0;
const procurementFulfillmentRate = procurement?.fulfillment_rate || 0;
const procurementOnTimeDeliveryRate = procurement?.on_time_delivery_rate || 0;
const procurementCostAccuracy = procurement?.cost_accuracy || 0;
const departments: DepartmentPerformance[] | undefined = useMemo(() => {
if (!production || !inventory || !sales || !procurement) return undefined;
return [
{
department_id: 'production',
department_name: 'Producción',
efficiency: productionEfficiency,
trend: productionEfficiency >= 85 ? 'up' : productionEfficiency >= 75 ? 'stable' : 'down',
metrics: {
primary_metric: {
label: 'Tiempo promedio de lote',
value: productionAvgBatchTime,
unit: 'h',
},
secondary_metric: {
label: 'Tasa de calidad',
value: productionQualityRate,
unit: '%',
},
tertiary_metric: {
label: 'Desperdicio',
value: productionWastePercentage,
unit: '%',
},
},
},
{
department_id: 'sales',
department_name: 'Ventas',
efficiency: (salesTotalRevenue / 10000) * 100, // Normalize to percentage
trend: salesGrowthRate > 0 ? 'up' : salesGrowthRate < 0 ? 'down' : 'stable',
metrics: {
primary_metric: {
label: 'Ingresos totales',
value: salesTotalRevenue,
unit: '€',
},
secondary_metric: {
label: 'Transacciones',
value: salesTotalTransactions,
unit: '',
},
tertiary_metric: {
label: 'Valor promedio',
value: salesAvgTransactionValue,
unit: '€',
},
},
},
{
department_id: 'inventory',
department_name: 'Inventario',
efficiency: inventoryStockAccuracy,
trend: inventoryLowStockCount < 5 ? 'up' : inventoryLowStockCount < 10 ? 'stable' : 'down',
metrics: {
primary_metric: {
label: 'Rotación de stock',
value: inventoryTurnoverRate,
unit: 'x',
},
secondary_metric: {
label: 'Precisión',
value: inventoryStockAccuracy,
unit: '%',
},
tertiary_metric: {
label: 'Items con bajo stock',
value: inventoryLowStockCount,
unit: '',
},
},
},
{
department_id: 'administration',
department_name: 'Administración',
efficiency: procurementFulfillmentRate,
trend: procurementOnTimeDeliveryRate >= 95 ? 'up' : procurementOnTimeDeliveryRate >= 85 ? 'stable' : 'down',
metrics: {
primary_metric: {
label: 'Tasa de cumplimiento',
value: procurementFulfillmentRate,
unit: '%',
},
secondary_metric: {
label: 'Entrega a tiempo',
value: procurementOnTimeDeliveryRate,
unit: '%',
},
tertiary_metric: {
label: 'Precisión de costos',
value: procurementCostAccuracy,
unit: '%',
},
},
},
];
}, [
productionEfficiency,
productionAvgBatchTime,
productionQualityRate,
productionWastePercentage,
salesTotalRevenue,
salesGrowthRate,
salesTotalTransactions,
salesAvgTransactionValue,
inventoryStockAccuracy,
inventoryLowStockCount,
inventoryTurnoverRate,
procurementFulfillmentRate,
procurementOnTimeDeliveryRate,
procurementCostAccuracy,
]);
return {
data: departments,
isLoading: productionLoading || inventoryLoading || salesLoading || procurementLoading,
};
};
// ============================================================================
// KPI Metrics Hook
// ============================================================================
export const useKPIMetrics = (tenantId: string, period: TimePeriod = 'week') => {
const { data: production, isLoading: productionLoading } = useProductionPerformance(tenantId, period);
const { data: inventory, isLoading: inventoryLoading } = useInventoryPerformance(tenantId);
const { data: procurement, isLoading: procurementLoading } = useProcurementPerformance(tenantId);
const kpis: KPIMetric[] | undefined = useMemo(() => {
if (!production || !inventory || !procurement) return undefined;
// TODO: Get previous period data for accurate trends
const previousProduction = production.efficiency * 0.95; // Mock previous value
const previousInventory = inventory.stock_accuracy * 0.98;
const previousProcurement = procurement.on_time_delivery_rate * 0.97;
const previousQuality = production.quality_rate * 0.96;
return [
{
id: 'overall-efficiency',
name: 'Eficiencia General',
current_value: production.efficiency,
target_value: 90,
previous_value: previousProduction,
unit: '%',
trend: calculateTrend(production.efficiency, previousProduction),
status: calculateStatus(production.efficiency, 90),
},
{
id: 'quality-rate',
name: 'Tasa de Calidad',
current_value: production.quality_rate,
target_value: 95,
previous_value: previousQuality,
unit: '%',
trend: calculateTrend(production.quality_rate, previousQuality),
status: calculateStatus(production.quality_rate, 95),
},
{
id: 'on-time-delivery',
name: 'Entrega a Tiempo',
current_value: procurement.on_time_delivery_rate,
target_value: 95,
previous_value: previousProcurement,
unit: '%',
trend: calculateTrend(procurement.on_time_delivery_rate, previousProcurement),
status: calculateStatus(procurement.on_time_delivery_rate, 95),
},
{
id: 'inventory-accuracy',
name: 'Precisión de Inventario',
current_value: inventory.stock_accuracy,
target_value: 98,
previous_value: previousInventory,
unit: '%',
trend: calculateTrend(inventory.stock_accuracy, previousInventory),
status: calculateStatus(inventory.stock_accuracy, 98),
},
];
}, [production, inventory, procurement]);
return {
data: kpis,
isLoading: productionLoading || inventoryLoading || procurementLoading,
};
};
// ============================================================================
// Performance Alerts Hook
// ============================================================================
export const usePerformanceAlerts = (tenantId: string) => {
const { data: inventory, isLoading: inventoryLoading } = useInventoryDashboard(tenantId);
const { data: procurement, isLoading: procurementLoading } = useProcurementDashboard(tenantId);
// Extract primitive values to prevent unnecessary recalculations
const lowStockCount = inventory?.low_stock_items || 0;
const outOfStockCount = inventory?.out_of_stock_items || 0;
const foodSafetyAlerts = inventory?.food_safety_alerts_active || 0;
const expiringCount = inventory?.expiring_soon_items || 0;
const lowStockAlertsCount = procurement?.low_stock_alerts?.length || 0;
const overdueReqsCount = procurement?.overdue_requirements?.length || 0;
const alerts: PerformanceAlert[] | undefined = useMemo(() => {
if (!inventory || !procurement) return undefined;
const alertsList: PerformanceAlert[] = [];
// Low stock alerts
if (lowStockCount > 0) {
alertsList.push({
id: `low-stock-${Date.now()}`,
type: 'warning',
department: 'Inventario',
message: `${lowStockCount} ingredientes con stock bajo`,
timestamp: new Date().toISOString(),
metric_affected: 'Stock',
current_value: lowStockCount,
});
}
// Out of stock alerts
if (outOfStockCount > 0) {
alertsList.push({
id: `out-of-stock-${Date.now()}`,
type: 'critical',
department: 'Inventario',
message: `${outOfStockCount} ingredientes sin stock`,
timestamp: new Date().toISOString(),
metric_affected: 'Stock',
current_value: outOfStockCount,
});
}
// Food safety alerts
if (foodSafetyAlerts > 0) {
alertsList.push({
id: `food-safety-${Date.now()}`,
type: 'critical',
department: 'Inventario',
message: `${foodSafetyAlerts} alertas de seguridad alimentaria activas`,
timestamp: new Date().toISOString(),
metric_affected: 'Seguridad Alimentaria',
current_value: foodSafetyAlerts,
});
}
// Expiring items alerts
if (expiringCount > 0) {
alertsList.push({
id: `expiring-${Date.now()}`,
type: 'warning',
department: 'Inventario',
message: `${expiringCount} ingredientes próximos a vencer`,
timestamp: new Date().toISOString(),
metric_affected: 'Caducidad',
current_value: expiringCount,
});
}
// Critical procurement requirements
const criticalCount = lowStockAlertsCount + overdueReqsCount;
if (criticalCount > 0) {
alertsList.push({
id: `procurement-critical-${Date.now()}`,
type: 'warning',
department: 'Administración',
message: `${criticalCount} requisitos de compra críticos`,
timestamp: new Date().toISOString(),
metric_affected: 'Aprovisionamiento',
current_value: criticalCount,
});
}
// Sort by severity: critical > warning > info
return alertsList.sort((a, b) => {
const severityOrder = { critical: 0, warning: 1, info: 2 };
return severityOrder[a.type] - severityOrder[b.type];
});
}, [lowStockCount, outOfStockCount, foodSafetyAlerts, expiringCount, lowStockAlertsCount, overdueReqsCount]);
return {
data: alerts || [],
isLoading: inventoryLoading || procurementLoading,
};
};
// ============================================================================
// Hourly Productivity Hook
// ============================================================================
export const useHourlyProductivity = (tenantId: string) => {
// TODO: This requires time-series data aggregation from production batches
// For now, returning empty until backend provides hourly aggregation endpoint
return useQuery<HourlyProductivity[]>({
queryKey: ['performance', 'hourly', tenantId],
queryFn: async () => {
// Placeholder - backend endpoint needed for real hourly data
return [];
},
enabled: false, // Disable until backend endpoint is ready
});
};
// ============================================================================
// Cross-Functional Performance Metrics
// ============================================================================
/**
* Cycle Time: Order-to-Delivery
* Measures the complete time from order creation to delivery
*/
export const useCycleTimeMetrics = (tenantId: string, period: TimePeriod = 'week') => {
const { data: orders, isLoading: ordersLoading } = useOrdersDashboard(tenantId);
const { data: production, isLoading: productionLoading } = useProductionPerformance(tenantId, period);
// Extract primitive values before useMemo to prevent unnecessary recalculations
const totalOrders = orders?.total_orders_today || 1;
const deliveredOrders = orders?.delivered_orders || 0;
const pendingOrders = orders?.pending_orders || 0;
const avgProductionTime = production?.average_batch_time || 2;
const onTimeCompletionRate = production?.on_time_completion_rate || 0;
const cycleTime = useMemo(() => {
if (!orders || !production) return undefined;
// Estimate average cycle time based on fulfillment rate and production efficiency
const fulfillmentRate = (deliveredOrders / totalOrders) * 100;
// Estimated total cycle time includes: order processing + production + delivery
const estimatedCycleTime = avgProductionTime + (pendingOrders > 0 ? 1.5 : 0.5); // Add wait time
return {
average_cycle_time: estimatedCycleTime,
order_to_production_time: 0.5, // Order processing time
production_time: avgProductionTime,
production_to_delivery_time: pendingOrders > 0 ? 1.0 : 0.3,
fulfillment_rate: fulfillmentRate,
on_time_delivery_rate: onTimeCompletionRate,
};
}, [totalOrders, deliveredOrders, pendingOrders, avgProductionTime, onTimeCompletionRate]);
return {
data: cycleTime,
isLoading: ordersLoading || productionLoading,
};
};
/**
* Process Efficiency Score
* Combined efficiency across all departments
*/
export const useProcessEfficiencyScore = (tenantId: string, period: TimePeriod = 'week') => {
const { data: production, isLoading: productionLoading } = useProductionPerformance(tenantId, period);
const { data: inventory, isLoading: inventoryLoading } = useInventoryPerformance(tenantId);
const { data: procurement, isLoading: procurementLoading } = useProcurementPerformance(tenantId);
const { data: orders, isLoading: ordersLoading } = useOrdersDashboard(tenantId);
// Extract primitive values before useMemo to prevent unnecessary recalculations
const productionEfficiency = production?.efficiency || 0;
const inventoryStockAccuracy = inventory?.stock_accuracy || 0;
const procurementFulfillmentRate = procurement?.fulfillment_rate || 0;
const totalOrders = orders?.total_orders_today || 1;
const deliveredOrders = orders?.delivered_orders || 0;
const score = useMemo(() => {
if (!production || !inventory || !procurement || !orders) return undefined;
// Weighted efficiency score across departments
const productionWeight = 0.35;
const inventoryWeight = 0.25;
const procurementWeight = 0.25;
const ordersWeight = 0.15;
const orderEfficiency = (deliveredOrders / totalOrders) * 100;
const overallScore =
(productionEfficiency * productionWeight) +
(inventoryStockAccuracy * inventoryWeight) +
(procurementFulfillmentRate * procurementWeight) +
(orderEfficiency * ordersWeight);
return {
overall_score: overallScore,
production_efficiency: productionEfficiency,
inventory_efficiency: inventoryStockAccuracy,
procurement_efficiency: procurementFulfillmentRate,
order_efficiency: orderEfficiency,
breakdown: {
production: { value: productionEfficiency, weight: productionWeight * 100 },
inventory: { value: inventoryStockAccuracy, weight: inventoryWeight * 100 },
procurement: { value: procurementFulfillmentRate, weight: procurementWeight * 100 },
orders: { value: orderEfficiency, weight: ordersWeight * 100 },
},
};
}, [productionEfficiency, inventoryStockAccuracy, procurementFulfillmentRate, totalOrders, deliveredOrders]);
return {
data: score,
isLoading: productionLoading || inventoryLoading || procurementLoading || ordersLoading,
};
};
/**
* Resource Utilization Rate
* Cross-departmental resource balance and utilization
*/
export const useResourceUtilization = (tenantId: string, period: TimePeriod = 'week') => {
const { data: production, isLoading: productionLoading } = useProductionPerformance(tenantId, period);
const { data: inventory, isLoading: inventoryLoading } = useInventoryPerformance(tenantId);
// Extract primitive values before useMemo to prevent unnecessary recalculations
const equipmentEfficiency = production?.equipment_efficiency || 0;
const turnoverRate = inventory?.turnover_rate || 0;
const stockAccuracy = inventory?.stock_accuracy || 0;
const capacityUtilization = production?.capacity_utilization || 0;
const utilization = useMemo(() => {
if (!production || !inventory) return undefined;
// Equipment utilization from production
const equipmentUtilization = equipmentEfficiency;
// Inventory utilization based on turnover and stock levels
const inventoryUtilization = turnoverRate > 0
? Math.min(turnoverRate * 10, 100) // Normalize turnover to percentage
: stockAccuracy;
// Combined resource utilization
const overallUtilization = (equipmentUtilization + inventoryUtilization) / 2;
return {
overall_utilization: overallUtilization,
equipment_utilization: equipmentUtilization,
inventory_utilization: inventoryUtilization,
capacity_used: capacityUtilization,
resource_balance: Math.abs(equipmentUtilization - inventoryUtilization) < 10 ? 'balanced' : 'imbalanced',
};
}, [equipmentEfficiency, turnoverRate, stockAccuracy, capacityUtilization]);
return {
data: utilization,
isLoading: productionLoading || inventoryLoading,
};
};
/**
* Cost-to-Revenue Ratio
* Overall profitability metric
*/
export const useCostRevenueRatio = (tenantId: string, period: TimePeriod = 'week') => {
const { data: sales, isLoading: salesLoading } = useSalesPerformance(tenantId, period);
const { data: inventory, isLoading: inventoryLoading } = useInventoryPerformance(tenantId);
const { data: production, isLoading: productionLoading } = useProductionPerformance(tenantId, period);
// Extract primitive values before useMemo to prevent unnecessary recalculations
const totalRevenue = sales?.total_revenue || 0;
const stockValue = inventory?.stock_value || 0;
const wastePercentage = production?.waste_percentage || 0;
const ratio = useMemo(() => {
if (!sales || !inventory || !production) return undefined;
// Estimate costs from inventory value and waste
const inventoryCosts = stockValue * 0.1; // Approximate monthly inventory cost
const wasteCosts = (stockValue * wastePercentage) / 100;
const estimatedTotalCosts = inventoryCosts + wasteCosts;
const costRevenueRatio = totalRevenue > 0 ? (estimatedTotalCosts / totalRevenue) * 100 : 0;
const profitMargin = totalRevenue > 0 ? ((totalRevenue - estimatedTotalCosts) / totalRevenue) * 100 : 0;
return {
cost_revenue_ratio: costRevenueRatio,
profit_margin: profitMargin,
total_revenue: totalRevenue,
estimated_costs: estimatedTotalCosts,
inventory_costs: inventoryCosts,
waste_costs: wasteCosts,
};
}, [totalRevenue, stockValue, wastePercentage]);
return {
data: ratio,
isLoading: salesLoading || inventoryLoading || productionLoading,
};
};
/**
* Quality Impact Index
* Quality issues across all departments
*/
export const useQualityImpactIndex = (tenantId: string, period: TimePeriod = 'week') => {
const { data: production, isLoading: productionLoading } = useProductionPerformance(tenantId, period);
const { data: inventory, isLoading: inventoryLoading } = useInventoryPerformance(tenantId);
// Extract primitive values before useMemo to prevent unnecessary recalculations
const productionQuality = production?.quality_rate || 0;
const wasteImpact = production?.waste_percentage || 0;
const expiringItems = inventory?.expiring_items_count || 0;
const lowStockItems = inventory?.low_stock_count || 0;
const qualityIndex = useMemo(() => {
if (!production || !inventory) return undefined;
// Inventory quality
const totalItems = (expiringItems + lowStockItems) || 1;
const inventoryQualityScore = 100 - ((expiringItems + lowStockItems) / totalItems * 10);
// Combined quality index (weighted average)
const overallQuality = (productionQuality * 0.7) + (inventoryQualityScore * 0.3);
return {
overall_quality_index: overallQuality,
production_quality: productionQuality,
inventory_quality: inventoryQualityScore,
waste_impact: wasteImpact,
quality_issues: {
production_defects: 100 - productionQuality,
waste_percentage: wasteImpact,
expiring_items: expiringItems,
low_stock_affecting_quality: lowStockItems,
},
};
}, [productionQuality, wasteImpact, expiringItems, lowStockItems]);
return {
data: qualityIndex,
isLoading: productionLoading || inventoryLoading,
};
};
/**
* Critical Bottlenecks
* Identifies process bottlenecks across operations
*/
export const useCriticalBottlenecks = (tenantId: string, period: TimePeriod = 'week') => {
const { data: production, isLoading: productionLoading } = useProductionPerformance(tenantId, period);
const { data: inventory, isLoading: inventoryLoading } = useInventoryPerformance(tenantId);
const { data: procurement, isLoading: procurementLoading } = useProcurementPerformance(tenantId);
const { data: orders, isLoading: ordersLoading } = useOrdersDashboard(tenantId);
// Extract primitive values before useMemo to prevent unnecessary recalculations
const capacityUtilization = production?.capacity_utilization || 0;
const onTimeCompletionRate = production?.on_time_completion_rate || 0;
const lowStockCount = inventory?.low_stock_count || 0;
const criticalRequirements = procurement?.critical_requirements || 0;
const onTimeDeliveryRate = procurement?.on_time_delivery_rate || 0;
const totalOrders = orders?.total_orders_today || 1;
const pendingOrders = orders?.pending_orders || 0;
const bottlenecks = useMemo(() => {
if (!production || !inventory || !procurement || !orders) return undefined;
const bottlenecksList = [];
// Production bottlenecks
if (capacityUtilization > 90) {
bottlenecksList.push({
area: 'production',
severity: 'high',
description: 'Capacidad de producción al límite',
metric: 'capacity_utilization',
value: capacityUtilization,
});
}
if (onTimeCompletionRate < 85) {
bottlenecksList.push({
area: 'production',
severity: 'medium',
description: 'Retrasos en completitud de producción',
metric: 'on_time_completion',
value: onTimeCompletionRate,
});
}
// Inventory bottlenecks
if (lowStockCount > 10) {
bottlenecksList.push({
area: 'inventory',
severity: 'high',
description: 'Alto número de ingredientes con stock bajo',
metric: 'low_stock_count',
value: lowStockCount,
});
}
// Procurement bottlenecks
if (criticalRequirements > 5) {
bottlenecksList.push({
area: 'procurement',
severity: 'high',
description: 'Requisitos de compra críticos pendientes',
metric: 'critical_requirements',
value: criticalRequirements,
});
}
if (onTimeDeliveryRate < 85) {
bottlenecksList.push({
area: 'procurement',
severity: 'medium',
description: 'Entregas de proveedores retrasadas',
metric: 'on_time_delivery',
value: onTimeDeliveryRate,
});
}
// Orders bottlenecks
const pendingRate = (pendingOrders / totalOrders) * 100;
if (pendingRate > 30) {
bottlenecksList.push({
area: 'orders',
severity: 'medium',
description: 'Alto volumen de pedidos pendientes',
metric: 'pending_orders',
value: pendingOrders,
});
}
return {
total_bottlenecks: bottlenecksList.length,
critical_count: bottlenecksList.filter(b => b.severity === 'high').length,
bottlenecks: bottlenecksList,
most_critical_area: bottlenecksList.length > 0
? bottlenecksList.sort((a, b) => {
const severityOrder = { high: 0, medium: 1, low: 2 };
return severityOrder[a.severity as 'high' | 'medium' | 'low'] - severityOrder[b.severity as 'high' | 'medium' | 'low'];
})[0].area
: null,
};
}, [capacityUtilization, onTimeCompletionRate, lowStockCount, criticalRequirements, onTimeDeliveryRate, totalOrders, pendingOrders]);
return {
data: bottlenecks,
isLoading: productionLoading || inventoryLoading || procurementLoading || ordersLoading,
};
};

View File

@@ -0,0 +1,495 @@
/**
* Procurement React Query hooks
* All hooks use the ProcurementService which connects to the standalone Procurement Service backend
*/
import {
useMutation,
useQuery,
useQueryClient,
UseQueryOptions,
UseMutationOptions,
} from '@tanstack/react-query';
import { ProcurementService } from '../services/procurement-service';
import {
// Response types
ProcurementPlanResponse,
ProcurementRequirementResponse,
ProcurementDashboardData,
ProcurementTrendsData,
PaginatedProcurementPlans,
GeneratePlanResponse,
CreatePOsResult,
// Request types
GeneratePlanRequest,
AutoGenerateProcurementRequest,
AutoGenerateProcurementResponse,
LinkRequirementToPORequest,
UpdateDeliveryStatusRequest,
// Query param types
GetProcurementPlansParams,
GetPlanRequirementsParams,
UpdatePlanStatusParams,
} from '../types/procurement';
import { ApiError } from '../client/apiClient';
// ===================================================================
// QUERY KEYS
// ===================================================================
export const procurementKeys = {
all: ['procurement'] as const,
// Analytics & Dashboard
analytics: (tenantId: string) => [...procurementKeys.all, 'analytics', tenantId] as const,
trends: (tenantId: string, days: number) => [...procurementKeys.all, 'trends', tenantId, days] as const,
// Plans
plans: () => [...procurementKeys.all, 'plans'] as const,
plansList: (params: GetProcurementPlansParams) => [...procurementKeys.plans(), 'list', params] as const,
plan: (tenantId: string, planId: string) => [...procurementKeys.plans(), 'detail', tenantId, planId] as const,
planByDate: (tenantId: string, date: string) => [...procurementKeys.plans(), 'by-date', tenantId, date] as const,
currentPlan: (tenantId: string) => [...procurementKeys.plans(), 'current', tenantId] as const,
// Requirements
requirements: () => [...procurementKeys.all, 'requirements'] as const,
planRequirements: (params: GetPlanRequirementsParams) =>
[...procurementKeys.requirements(), 'plan', params] as const,
criticalRequirements: (tenantId: string) =>
[...procurementKeys.requirements(), 'critical', tenantId] as const,
} as const;
// ===================================================================
// ANALYTICS & DASHBOARD QUERIES
// ===================================================================
/**
* Get procurement analytics dashboard data
*/
export const useProcurementDashboard = (
tenantId: string,
options?: Omit<UseQueryOptions<ProcurementDashboardData, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<ProcurementDashboardData, ApiError>({
queryKey: procurementKeys.analytics(tenantId),
queryFn: () => ProcurementService.getProcurementAnalytics(tenantId),
staleTime: 2 * 60 * 1000, // 2 minutes
enabled: !!tenantId,
...options,
});
};
/**
* Get procurement time-series trends for charts
*/
export const useProcurementTrends = (
tenantId: string,
days: number = 7,
options?: Omit<UseQueryOptions<ProcurementTrendsData, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<ProcurementTrendsData, ApiError>({
queryKey: procurementKeys.trends(tenantId, days),
queryFn: () => ProcurementService.getProcurementTrends(tenantId, days),
staleTime: 5 * 60 * 1000, // 5 minutes
enabled: !!tenantId,
...options,
});
};
// ===================================================================
// PROCUREMENT PLAN QUERIES
// ===================================================================
/**
* Get list of procurement plans with pagination and filtering
*/
export const useProcurementPlans = (
params: GetProcurementPlansParams,
options?: Omit<UseQueryOptions<PaginatedProcurementPlans, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<PaginatedProcurementPlans, ApiError>({
queryKey: procurementKeys.plansList(params),
queryFn: () => ProcurementService.getProcurementPlans(params),
staleTime: 5 * 60 * 1000, // 5 minutes
enabled: !!params.tenant_id,
...options,
});
};
/**
* Get a single procurement plan by ID
*/
export const useProcurementPlan = (
tenantId: string,
planId: string,
options?: Omit<UseQueryOptions<ProcurementPlanResponse | null, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<ProcurementPlanResponse | null, ApiError>({
queryKey: procurementKeys.plan(tenantId, planId),
queryFn: () => ProcurementService.getProcurementPlanById(tenantId, planId),
staleTime: 2 * 60 * 1000, // 2 minutes
enabled: !!tenantId && !!planId,
...options,
});
};
/**
* Get procurement plan for a specific date
*/
export const useProcurementPlanByDate = (
tenantId: string,
planDate: string,
options?: Omit<UseQueryOptions<ProcurementPlanResponse | null, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<ProcurementPlanResponse | null, ApiError>({
queryKey: procurementKeys.planByDate(tenantId, planDate),
queryFn: () => ProcurementService.getProcurementPlanByDate(tenantId, planDate),
staleTime: 2 * 60 * 1000, // 2 minutes
enabled: !!tenantId && !!planDate,
...options,
});
};
/**
* Get the current day's procurement plan
*/
export const useCurrentProcurementPlan = (
tenantId: string,
options?: Omit<UseQueryOptions<ProcurementPlanResponse | null, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<ProcurementPlanResponse | null, ApiError>({
queryKey: procurementKeys.currentPlan(tenantId),
queryFn: () => ProcurementService.getCurrentProcurementPlan(tenantId),
staleTime: 1 * 60 * 1000, // 1 minute
enabled: !!tenantId,
...options,
});
};
// ===================================================================
// PROCUREMENT REQUIREMENTS QUERIES
// ===================================================================
/**
* Get requirements for a specific procurement plan
*/
export const usePlanRequirements = (
params: GetPlanRequirementsParams,
options?: Omit<UseQueryOptions<ProcurementRequirementResponse[], ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<ProcurementRequirementResponse[], ApiError>({
queryKey: procurementKeys.planRequirements(params),
queryFn: () => ProcurementService.getPlanRequirements(params),
staleTime: 2 * 60 * 1000, // 2 minutes
enabled: !!params.tenant_id && !!params.plan_id,
...options,
});
};
/**
* Get critical requirements across all plans
*/
export const useCriticalRequirements = (
tenantId: string,
options?: Omit<UseQueryOptions<ProcurementRequirementResponse[], ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<ProcurementRequirementResponse[], ApiError>({
queryKey: procurementKeys.criticalRequirements(tenantId),
queryFn: () => ProcurementService.getCriticalRequirements(tenantId),
staleTime: 1 * 60 * 1000, // 1 minute
enabled: !!tenantId,
...options,
});
};
// ===================================================================
// PROCUREMENT PLAN MUTATIONS
// ===================================================================
/**
* Generate a new procurement plan (manual/UI-driven)
*/
export const useGenerateProcurementPlan = (
options?: UseMutationOptions<GeneratePlanResponse, ApiError, { tenantId: string; request: GeneratePlanRequest }>
) => {
const queryClient = useQueryClient();
return useMutation<GeneratePlanResponse, ApiError, { tenantId: string; request: GeneratePlanRequest }>({
mutationFn: ({ tenantId, request }) => ProcurementService.generateProcurementPlan(tenantId, request),
onSuccess: (data, variables) => {
// Invalidate all procurement queries for this tenant
queryClient.invalidateQueries({
queryKey: procurementKeys.all,
predicate: (query) => {
return JSON.stringify(query.queryKey).includes(variables.tenantId);
},
});
// If plan was generated successfully, cache it
if (data.success && data.plan) {
queryClient.setQueryData(procurementKeys.plan(variables.tenantId, data.plan.id), data.plan);
}
},
...options,
});
};
/**
* Auto-generate procurement plan from forecast data (Orchestrator integration)
*/
export const useAutoGenerateProcurement = (
options?: UseMutationOptions<
AutoGenerateProcurementResponse,
ApiError,
{ tenantId: string; request: AutoGenerateProcurementRequest }
>
) => {
const queryClient = useQueryClient();
return useMutation<
AutoGenerateProcurementResponse,
ApiError,
{ tenantId: string; request: AutoGenerateProcurementRequest }
>({
mutationFn: ({ tenantId, request }) => ProcurementService.autoGenerateProcurement(tenantId, request),
onSuccess: (data, variables) => {
// Invalidate all procurement queries
queryClient.invalidateQueries({
queryKey: procurementKeys.all,
predicate: (query) => {
return JSON.stringify(query.queryKey).includes(variables.tenantId);
},
});
// If plan was created successfully, cache it
if (data.success && data.plan_id) {
queryClient.invalidateQueries({
queryKey: procurementKeys.currentPlan(variables.tenantId),
});
}
},
...options,
});
};
/**
* Update procurement plan status
*/
export const useUpdateProcurementPlanStatus = (
options?: UseMutationOptions<ProcurementPlanResponse, ApiError, UpdatePlanStatusParams>
) => {
const queryClient = useQueryClient();
return useMutation<ProcurementPlanResponse, ApiError, UpdatePlanStatusParams>({
mutationFn: (params) => ProcurementService.updateProcurementPlanStatus(params),
onSuccess: (data, variables) => {
// Update the specific plan in cache
queryClient.setQueryData(procurementKeys.plan(variables.tenant_id, variables.plan_id), data);
// Invalidate plans list
queryClient.invalidateQueries({
queryKey: procurementKeys.plans(),
predicate: (query) => {
return JSON.stringify(query.queryKey).includes(variables.tenant_id);
},
});
},
...options,
});
};
/**
* Recalculate an existing procurement plan
*/
export const useRecalculateProcurementPlan = (
options?: UseMutationOptions<GeneratePlanResponse, ApiError, { tenantId: string; planId: string }>
) => {
const queryClient = useQueryClient();
return useMutation<GeneratePlanResponse, ApiError, { tenantId: string; planId: string }>({
mutationFn: ({ tenantId, planId }) => ProcurementService.recalculateProcurementPlan(tenantId, planId),
onSuccess: (data, variables) => {
if (data.plan) {
// Update the specific plan in cache
queryClient.setQueryData(procurementKeys.plan(variables.tenantId, variables.planId), data.plan);
}
// Invalidate plans list and dashboard
queryClient.invalidateQueries({
queryKey: procurementKeys.all,
predicate: (query) => {
return JSON.stringify(query.queryKey).includes(variables.tenantId);
},
});
},
...options,
});
};
/**
* Approve a procurement plan
*/
export const useApproveProcurementPlan = (
options?: UseMutationOptions<
ProcurementPlanResponse,
ApiError,
{ tenantId: string; planId: string; approval_notes?: string }
>
) => {
const queryClient = useQueryClient();
return useMutation<
ProcurementPlanResponse,
ApiError,
{ tenantId: string; planId: string; approval_notes?: string }
>({
mutationFn: ({ tenantId, planId, approval_notes }) =>
ProcurementService.approveProcurementPlan(tenantId, planId, { approval_notes }),
onSuccess: (data, variables) => {
// Update the specific plan in cache
queryClient.setQueryData(procurementKeys.plan(variables.tenantId, variables.planId), data);
// Invalidate plans list and dashboard
queryClient.invalidateQueries({
queryKey: procurementKeys.all,
predicate: (query) => {
return JSON.stringify(query.queryKey).includes(variables.tenantId);
},
});
},
...options,
});
};
/**
* Reject a procurement plan
*/
export const useRejectProcurementPlan = (
options?: UseMutationOptions<
ProcurementPlanResponse,
ApiError,
{ tenantId: string; planId: string; rejection_notes?: string }
>
) => {
const queryClient = useQueryClient();
return useMutation<
ProcurementPlanResponse,
ApiError,
{ tenantId: string; planId: string; rejection_notes?: string }
>({
mutationFn: ({ tenantId, planId, rejection_notes }) =>
ProcurementService.rejectProcurementPlan(tenantId, planId, { rejection_notes }),
onSuccess: (data, variables) => {
// Update the specific plan in cache
queryClient.setQueryData(procurementKeys.plan(variables.tenantId, variables.planId), data);
// Invalidate plans list and dashboard
queryClient.invalidateQueries({
queryKey: procurementKeys.all,
predicate: (query) => {
return JSON.stringify(query.queryKey).includes(variables.tenantId);
},
});
},
...options,
});
};
// ===================================================================
// PURCHASE ORDER MUTATIONS
// ===================================================================
/**
* Create purchase orders from procurement plan
*/
export const useCreatePurchaseOrdersFromPlan = (
options?: UseMutationOptions<CreatePOsResult, ApiError, { tenantId: string; planId: string; autoApprove?: boolean }>
) => {
const queryClient = useQueryClient();
return useMutation<CreatePOsResult, ApiError, { tenantId: string; planId: string; autoApprove?: boolean }>({
mutationFn: ({ tenantId, planId, autoApprove = false }) =>
ProcurementService.createPurchaseOrdersFromPlan(tenantId, planId, autoApprove),
onSuccess: (data, variables) => {
// Invalidate procurement plan to refresh requirements status
queryClient.invalidateQueries({
queryKey: procurementKeys.plan(variables.tenantId, variables.planId),
});
// Invalidate plan requirements
queryClient.invalidateQueries({
queryKey: procurementKeys.requirements(),
predicate: (query) => {
return JSON.stringify(query.queryKey).includes(variables.planId);
},
});
},
...options,
});
};
/**
* Link a procurement requirement to a purchase order
*/
export const useLinkRequirementToPurchaseOrder = (
options?: UseMutationOptions<
{ success: boolean; message: string; requirement_id: string; purchase_order_id: string },
ApiError,
{ tenantId: string; requirementId: string; request: LinkRequirementToPORequest }
>
) => {
const queryClient = useQueryClient();
return useMutation<
{ success: boolean; message: string; requirement_id: string; purchase_order_id: string },
ApiError,
{ tenantId: string; requirementId: string; request: LinkRequirementToPORequest }
>({
mutationFn: ({ tenantId, requirementId, request }) =>
ProcurementService.linkRequirementToPurchaseOrder(tenantId, requirementId, request),
onSuccess: (data, variables) => {
// Invalidate procurement data to refresh requirements
queryClient.invalidateQueries({
queryKey: procurementKeys.requirements(),
predicate: (query) => {
return JSON.stringify(query.queryKey).includes(variables.tenantId);
},
});
},
...options,
});
};
/**
* Update delivery status for a requirement
*/
export const useUpdateRequirementDeliveryStatus = (
options?: UseMutationOptions<
{ success: boolean; message: string; requirement_id: string; delivery_status: string },
ApiError,
{ tenantId: string; requirementId: string; request: UpdateDeliveryStatusRequest }
>
) => {
const queryClient = useQueryClient();
return useMutation<
{ success: boolean; message: string; requirement_id: string; delivery_status: string },
ApiError,
{ tenantId: string; requirementId: string; request: UpdateDeliveryStatusRequest }
>({
mutationFn: ({ tenantId, requirementId, request }) =>
ProcurementService.updateRequirementDeliveryStatus(tenantId, requirementId, request),
onSuccess: (data, variables) => {
// Invalidate procurement data to refresh requirements
queryClient.invalidateQueries({
queryKey: procurementKeys.requirements(),
predicate: (query) => {
return JSON.stringify(query.queryKey).includes(variables.tenantId);
},
});
},
...options,
});
};

View File

@@ -72,7 +72,7 @@ export const useSubscription = () => {
error: 'Failed to load subscription data'
}));
}
}, [tenantId, notifySubscriptionChanged]);
}, [tenantId]); // Removed notifySubscriptionChanged - it's now stable from context
useEffect(() => {
loadSubscriptionData();

View File

@@ -319,16 +319,16 @@ export const useUpdateMemberRole = (
export const useRemoveTeamMember = (
options?: UseMutationOptions<
{ success: boolean; message: string },
ApiError,
{ success: boolean; message: string },
ApiError,
{ tenantId: string; memberUserId: string }
>
) => {
const queryClient = useQueryClient();
return useMutation<
{ success: boolean; message: string },
ApiError,
{ success: boolean; message: string },
ApiError,
{ tenantId: string; memberUserId: string }
>({
mutationFn: ({ tenantId, memberUserId }) => tenantService.removeTeamMember(tenantId, memberUserId),
@@ -338,4 +338,36 @@ export const useRemoveTeamMember = (
},
...options,
});
};
/**
* Hook to transfer tenant ownership to another admin
* This is a critical operation that changes the tenant owner
*/
export const useTransferOwnership = (
options?: UseMutationOptions<
TenantResponse,
ApiError,
{ tenantId: string; newOwnerId: string }
>
) => {
const queryClient = useQueryClient();
return useMutation<
TenantResponse,
ApiError,
{ tenantId: string; newOwnerId: string }
>({
mutationFn: ({ tenantId, newOwnerId }) => tenantService.transferOwnership(tenantId, newOwnerId),
onSuccess: (data, { tenantId }) => {
// Invalidate all tenant-related queries since ownership changed
queryClient.invalidateQueries({ queryKey: tenantKeys.detail(tenantId) });
queryClient.invalidateQueries({ queryKey: tenantKeys.members(tenantId) });
queryClient.invalidateQueries({ queryKey: tenantKeys.userTenants('') });
queryClient.invalidateQueries({ queryKey: tenantKeys.userOwnedTenants('') });
// Invalidate access queries for all users since roles changed
queryClient.invalidateQueries({ queryKey: tenantKeys.access(tenantId, '') });
},
...options,
});
};

View File

@@ -27,7 +27,7 @@ export { posService } from './services/pos';
export { recipesService } from './services/recipes';
// NEW: Sprint 2 & 3 Services
export * as procurementService from './services/procurement';
export { ProcurementService } from './services/procurement-service';
export * as orchestratorService from './services/orchestrator';
// Types - Auth
@@ -289,31 +289,55 @@ export type {
GetCustomersParams,
UpdateOrderStatusParams,
GetDemandRequirementsParams,
// Procurement types
} from './types/orders';
// Types - Procurement
export type {
// Enums
ProcurementPlanType,
ProcurementStrategy,
RiskLevel,
RequirementStatus,
PlanStatus,
DeliveryStatus,
DeliveryStatus as ProcurementDeliveryStatus,
PriorityLevel as ProcurementPriorityLevel,
BusinessModel as ProcurementBusinessModel,
// Requirement types
ProcurementRequirementBase,
ProcurementRequirementCreate,
ProcurementRequirementUpdate,
ProcurementRequirementResponse,
// Plan types
ProcurementPlanBase,
ProcurementPlanCreate,
ProcurementPlanUpdate,
ProcurementPlanResponse,
ApprovalWorkflowEntry,
// Dashboard & Analytics
ProcurementSummary,
ProcurementDashboardData,
// Request/Response types
GeneratePlanRequest,
GeneratePlanResponse,
AutoGenerateProcurementRequest,
AutoGenerateProcurementResponse,
CreatePOsResult,
LinkRequirementToPORequest,
UpdateDeliveryStatusRequest,
ApprovalRequest,
RejectionRequest,
PaginatedProcurementPlans,
ForecastRequest,
ForecastRequest as ProcurementForecastRequest,
// Query params
GetProcurementPlansParams,
GetPlanRequirementsParams,
UpdatePlanStatusParams,
} from './types/orders';
} from './types/procurement';
// Types - Forecasting
export type {
@@ -609,26 +633,34 @@ export {
useCreateCustomer,
useUpdateCustomer,
useInvalidateOrders,
// Procurement hooks
ordersKeys,
} from './hooks/orders';
// Hooks - Procurement
export {
// Queries
useProcurementDashboard,
useProcurementPlans,
useProcurementPlan,
useProcurementPlanByDate,
useCurrentProcurementPlan,
useProcurementDashboard,
usePlanRequirements,
useCriticalRequirements,
useProcurementHealth,
// Mutations
useGenerateProcurementPlan,
useAutoGenerateProcurement,
useUpdateProcurementPlanStatus,
useTriggerDailyScheduler,
useRecalculateProcurementPlan,
useApproveProcurementPlan,
useRejectProcurementPlan,
useCreatePurchaseOrdersFromPlan,
useLinkRequirementToPurchaseOrder,
useUpdateRequirementDeliveryStatus,
ordersKeys,
} from './hooks/orders';
// Query keys
procurementKeys,
} from './hooks/procurement';
// Hooks - Forecasting
export {

View File

@@ -28,24 +28,6 @@ import {
GetCustomersParams,
UpdateOrderStatusParams,
GetDemandRequirementsParams,
// Procurement types
ProcurementPlanResponse,
ProcurementPlanCreate,
ProcurementPlanUpdate,
ProcurementRequirementResponse,
ProcurementRequirementUpdate,
ProcurementDashboardData,
GeneratePlanRequest,
GeneratePlanResponse,
PaginatedProcurementPlans,
GetProcurementPlansParams,
GetPlanRequirementsParams,
UpdatePlanStatusParams,
CreatePOsResult,
LinkRequirementToPORequest,
UpdateDeliveryStatusRequest,
ApprovalRequest,
RejectionRequest,
} from '../types/orders';
export class OrdersService {
@@ -209,209 +191,6 @@ export class OrdersService {
return apiClient.get<ServiceStatus>(`/tenants/${tenantId}/orders/operations/status`);
}
// ===================================================================
// OPERATIONS: Procurement Planning
// Backend: services/orders/app/api/procurement_operations.py
// ===================================================================
/**
* Get current procurement plan for today
* 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/operations/procurement/plans/current`);
}
/**
* Get procurement plan by specific 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/operations/procurement/plans/date/${planDate}`);
}
/**
* Get procurement plan by 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/operations/procurement/plans/id/${planId}`);
}
/**
* List procurement plans with filtering
* 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(),
});
if (status) queryParams.append('status', status);
if (start_date) queryParams.append('start_date', start_date);
if (end_date) queryParams.append('end_date', end_date);
return apiClient.get<PaginatedProcurementPlans>(
`/tenants/${tenant_id}/orders/operations/procurement/plans?${queryParams.toString()}`
);
}
/**
* Generate a new procurement plan
* 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/operations/procurement/plans/generate`, request);
}
/**
* Update procurement plan 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/operations/procurement/plans/${plan_id}/status?${queryParams.toString()}`,
{}
);
}
/**
* Get procurement dashboard data
* GET /tenants/{tenant_id}/orders/dashboard/procurement
*/
static async getProcurementDashboard(tenantId: string): Promise<ProcurementDashboardData | null> {
return apiClient.get<ProcurementDashboardData | null>(`/tenants/${tenantId}/orders/dashboard/procurement`);
}
/**
* Get requirements for a specific plan
* 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/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/operations/procurement/requirements/critical
*/
static async getCriticalRequirements(tenantId: string): Promise<ProcurementRequirementResponse[]> {
return apiClient.get<ProcurementRequirementResponse[]>(`/tenants/${tenantId}/orders/operations/procurement/requirements/critical`);
}
/**
* Trigger daily scheduler manually
* 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/operations/procurement/scheduler/trigger`,
{}
);
}
/**
* Get procurement service 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/base/procurement/health`);
}
// ===================================================================
// OPERATIONS: Advanced Procurement Features
// Backend: services/orders/app/api/procurement_operations.py
// ===================================================================
/**
* Recalculate an existing procurement plan
* 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/operations/procurement/plans/${planId}/recalculate`,
{}
);
}
/**
* Approve a procurement plan with notes
* 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/operations/procurement/plans/${planId}/approve`,
request || {}
);
}
/**
* Reject a procurement plan with notes
* 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/operations/procurement/plans/${planId}/reject`,
request || {}
);
}
/**
* Create purchase orders automatically from procurement plan
* 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/operations/procurement/plans/${planId}/create-purchase-orders`,
{ auto_approve: autoApprove }
);
}
/**
* Link a procurement requirement to a purchase order
* POST /tenants/{tenant_id}/orders/operations/procurement/requirements/{requirement_id}/link-purchase-order
*/
static async linkRequirementToPurchaseOrder(
tenantId: string,
requirementId: string,
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/operations/procurement/requirements/${requirementId}/link-purchase-order`,
request
);
}
/**
* Update delivery status for a requirement
* PUT /tenants/{tenant_id}/orders/operations/procurement/requirements/{requirement_id}/delivery-status
*/
static async updateRequirementDeliveryStatus(
tenantId: string,
requirementId: string,
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/operations/procurement/requirements/${requirementId}/delivery-status`,
request
);
}
}
export default OrdersService;

View File

@@ -0,0 +1,335 @@
// ================================================================
// frontend/src/api/services/procurement-service.ts
// ================================================================
/**
* Procurement Service - Fully aligned with backend Procurement Service API
*
* Backend API: services/procurement/app/api/
* - procurement_plans.py: Plan CRUD and generation
* - analytics.py: Analytics and dashboard
* - purchase_orders.py: PO creation from plans
*
* Base URL: /api/v1/tenants/{tenant_id}/procurement/*
*
* Last Updated: 2025-10-31
* Status: ✅ Complete - 100% backend alignment
*/
import { apiClient } from '../client/apiClient';
import {
// Procurement Plan types
ProcurementPlanResponse,
ProcurementPlanCreate,
ProcurementPlanUpdate,
PaginatedProcurementPlans,
// Procurement Requirement types
ProcurementRequirementResponse,
ProcurementRequirementUpdate,
// Dashboard & Analytics types
ProcurementDashboardData,
ProcurementTrendsData,
// Request/Response types
GeneratePlanRequest,
GeneratePlanResponse,
AutoGenerateProcurementRequest,
AutoGenerateProcurementResponse,
CreatePOsResult,
LinkRequirementToPORequest,
UpdateDeliveryStatusRequest,
ApprovalRequest,
RejectionRequest,
// Query parameter types
GetProcurementPlansParams,
GetPlanRequirementsParams,
UpdatePlanStatusParams,
} from '../types/procurement';
/**
* Procurement Service
* All methods use the standalone Procurement Service backend API
*/
export class ProcurementService {
// ===================================================================
// ANALYTICS & DASHBOARD
// Backend: services/procurement/app/api/analytics.py
// ===================================================================
/**
* Get procurement analytics dashboard data
* GET /api/v1/tenants/{tenant_id}/procurement/analytics/procurement
*/
static async getProcurementAnalytics(tenantId: string): Promise<ProcurementDashboardData> {
return apiClient.get<ProcurementDashboardData>(`/tenants/${tenantId}/procurement/analytics/procurement`);
}
/**
* Get procurement time-series trends for charts
* GET /api/v1/tenants/{tenant_id}/procurement/analytics/procurement/trends
*/
static async getProcurementTrends(tenantId: string, days: number = 7): Promise<ProcurementTrendsData> {
return apiClient.get<ProcurementTrendsData>(`/tenants/${tenantId}/procurement/analytics/procurement/trends?days=${days}`);
}
// ===================================================================
// PROCUREMENT PLAN GENERATION
// Backend: services/procurement/app/api/procurement_plans.py
// ===================================================================
/**
* Auto-generate procurement plan from forecast data (Orchestrator integration)
* POST /api/v1/tenants/{tenant_id}/procurement/auto-generate
*
* Called by Orchestrator Service to create procurement plans based on forecast data
*/
static async autoGenerateProcurement(
tenantId: string,
request: AutoGenerateProcurementRequest
): Promise<AutoGenerateProcurementResponse> {
return apiClient.post<AutoGenerateProcurementResponse>(
`/tenants/${tenantId}/procurement/auto-generate`,
request
);
}
/**
* Generate a new procurement plan (manual/UI-driven)
* POST /api/v1/tenants/{tenant_id}/procurement/plans/generate
*/
static async generateProcurementPlan(
tenantId: string,
request: GeneratePlanRequest
): Promise<GeneratePlanResponse> {
return apiClient.post<GeneratePlanResponse>(
`/tenants/${tenantId}/procurement/plans/generate`,
request
);
}
// ===================================================================
// PROCUREMENT PLAN CRUD
// Backend: services/procurement/app/api/procurement_plans.py
// ===================================================================
/**
* Get the current day's procurement plan
* GET /api/v1/tenants/{tenant_id}/procurement/plans/current
*/
static async getCurrentProcurementPlan(tenantId: string): Promise<ProcurementPlanResponse | null> {
return apiClient.get<ProcurementPlanResponse | null>(
`/tenants/${tenantId}/procurement/plans/current`
);
}
/**
* Get procurement plan by ID
* GET /api/v1/tenants/{tenant_id}/procurement/plans/{plan_id}
*/
static async getProcurementPlanById(
tenantId: string,
planId: string
): Promise<ProcurementPlanResponse | null> {
return apiClient.get<ProcurementPlanResponse | null>(
`/tenants/${tenantId}/procurement/plans/${planId}`
);
}
/**
* Get procurement plan for a specific date
* GET /api/v1/tenants/{tenant_id}/procurement/plans/date/{plan_date}
*/
static async getProcurementPlanByDate(
tenantId: string,
planDate: string
): Promise<ProcurementPlanResponse | null> {
return apiClient.get<ProcurementPlanResponse | null>(
`/tenants/${tenantId}/procurement/plans/date/${planDate}`
);
}
/**
* List all procurement plans for tenant with pagination and filtering
* GET /api/v1/tenants/{tenant_id}/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(),
});
if (status) queryParams.append('status', status);
if (start_date) queryParams.append('start_date', start_date);
if (end_date) queryParams.append('end_date', end_date);
return apiClient.get<PaginatedProcurementPlans>(
`/tenants/${tenant_id}/procurement/plans?${queryParams.toString()}`
);
}
/**
* Update procurement plan status
* PATCH /api/v1/tenants/{tenant_id}/procurement/plans/{plan_id}/status
*/
static async updateProcurementPlanStatus(params: UpdatePlanStatusParams): Promise<ProcurementPlanResponse> {
const { tenant_id, plan_id, status, notes } = params;
const queryParams = new URLSearchParams({ status });
if (notes) queryParams.append('notes', notes);
return apiClient.patch<ProcurementPlanResponse>(
`/tenants/${tenant_id}/procurement/plans/${plan_id}/status?${queryParams.toString()}`,
{}
);
}
// ===================================================================
// PROCUREMENT REQUIREMENTS
// Backend: services/procurement/app/api/procurement_plans.py
// ===================================================================
/**
* Get all requirements for a procurement plan
* GET /api/v1/tenants/{tenant_id}/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}/procurement/plans/${plan_id}/requirements${
queryParams.toString() ? `?${queryParams.toString()}` : ''
}`;
return apiClient.get<ProcurementRequirementResponse[]>(url);
}
/**
* Get critical requirements across all plans
* GET /api/v1/tenants/{tenant_id}/procurement/requirements/critical
*/
static async getCriticalRequirements(tenantId: string): Promise<ProcurementRequirementResponse[]> {
return apiClient.get<ProcurementRequirementResponse[]>(
`/tenants/${tenantId}/procurement/requirements/critical`
);
}
/**
* Link a procurement requirement to a purchase order
* POST /api/v1/tenants/{tenant_id}/procurement/requirements/{requirement_id}/link-purchase-order
*/
static async linkRequirementToPurchaseOrder(
tenantId: string,
requirementId: string,
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}/procurement/requirements/${requirementId}/link-purchase-order`,
request
);
}
/**
* Update delivery status for a requirement
* PUT /api/v1/tenants/{tenant_id}/procurement/requirements/{requirement_id}/delivery-status
*/
static async updateRequirementDeliveryStatus(
tenantId: string,
requirementId: string,
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}/procurement/requirements/${requirementId}/delivery-status`,
request
);
}
// ===================================================================
// ADVANCED PROCUREMENT OPERATIONS
// Backend: services/procurement/app/api/procurement_plans.py
// ===================================================================
/**
* Recalculate an existing procurement plan
* POST /api/v1/tenants/{tenant_id}/procurement/plans/{plan_id}/recalculate
*/
static async recalculateProcurementPlan(
tenantId: string,
planId: string
): Promise<GeneratePlanResponse> {
return apiClient.post<GeneratePlanResponse>(
`/tenants/${tenantId}/procurement/plans/${planId}/recalculate`,
{}
);
}
/**
* Approve a procurement plan with optional notes
* POST /api/v1/tenants/{tenant_id}/procurement/plans/{plan_id}/approve
*/
static async approveProcurementPlan(
tenantId: string,
planId: string,
request?: ApprovalRequest
): Promise<ProcurementPlanResponse> {
return apiClient.post<ProcurementPlanResponse>(
`/tenants/${tenantId}/procurement/plans/${planId}/approve`,
request || {}
);
}
/**
* Reject a procurement plan with optional notes
* POST /api/v1/tenants/{tenant_id}/procurement/plans/{plan_id}/reject
*/
static async rejectProcurementPlan(
tenantId: string,
planId: string,
request?: RejectionRequest
): Promise<ProcurementPlanResponse> {
return apiClient.post<ProcurementPlanResponse>(
`/tenants/${tenantId}/procurement/plans/${planId}/reject`,
request || {}
);
}
// ===================================================================
// PURCHASE ORDERS
// Backend: services/procurement/app/api/purchase_orders.py
// ===================================================================
/**
* Create purchase orders from procurement plan requirements
* Groups requirements by supplier and creates POs
* POST /api/v1/tenants/{tenant_id}/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}/procurement/plans/${planId}/create-purchase-orders`,
{ auto_approve: autoApprove }
);
}
}
export default ProcurementService;

View File

@@ -1,317 +0,0 @@
/**
* Procurement Service API Client
* Handles procurement planning and purchase order management
*
* NEW in Sprint 3: Procurement Service now owns all procurement operations
* Previously these were split between Orders Service and Suppliers Service
*/
import { apiClient } from '../client';
// ============================================================================
// PROCUREMENT PLAN TYPES
// ============================================================================
export interface ProcurementRequirement {
id: string;
ingredient_id: string;
ingredient_name?: string;
ingredient_sku?: string;
required_quantity: number;
current_stock: number;
quantity_to_order: number;
unit_of_measure: string;
estimated_cost: string; // Decimal as string
priority: 'urgent' | 'high' | 'normal' | 'low';
reason: string;
supplier_id?: string;
supplier_name?: string;
expected_delivery_date?: string;
// NEW: Local production support
is_locally_produced?: boolean;
recipe_id?: string;
parent_requirement_id?: string;
bom_explosion_level?: number;
}
export interface ProcurementPlanSummary {
id: string;
plan_date: string;
status: 'DRAFT' | 'PENDING_APPROVAL' | 'APPROVED' | 'IN_PROGRESS' | 'COMPLETED' | 'CANCELLED';
total_requirements: number;
total_estimated_cost: string; // Decimal as string
planning_horizon_days: number;
auto_generated: boolean;
// NEW: Orchestrator integration
forecast_id?: string;
production_schedule_id?: string;
created_at: string;
created_by?: string;
}
export interface ProcurementPlanDetail extends ProcurementPlanSummary {
requirements: ProcurementRequirement[];
notes?: string;
approved_by?: string;
approved_at?: string;
updated_at: string;
}
// ============================================================================
// AUTO-GENERATE PROCUREMENT TYPES (Orchestrator Integration)
// ============================================================================
export interface AutoGenerateProcurementRequest {
forecast_data: Record<string, any>; // From Forecasting Service
production_schedule_id?: string;
target_date?: string; // YYYY-MM-DD
planning_horizon_days?: number; // Default: 14
safety_stock_percentage?: number; // Default: 20.00
auto_create_pos?: boolean; // Default: true
auto_approve_pos?: boolean; // Default: false
}
export interface AutoGenerateProcurementResponse {
success: boolean;
plan?: ProcurementPlanDetail;
purchase_orders_created?: number;
purchase_orders_auto_approved?: number;
purchase_orders_pending_approval?: number;
recipe_explosion_applied?: boolean;
recipe_explosion_metadata?: {
total_requirements_before: number;
total_requirements_after: number;
explosion_levels: number;
locally_produced_ingredients: number;
};
warnings?: string[];
errors?: string[];
execution_time_ms?: number;
}
// ============================================================================
// PROCUREMENT PLAN API FUNCTIONS
// ============================================================================
/**
* Get list of procurement plans with optional filters
*/
export async function listProcurementPlans(
tenantId: string,
params?: {
status?: ProcurementPlanSummary['status'];
date_from?: string;
date_to?: string;
limit?: number;
offset?: number;
}
): Promise<ProcurementPlanSummary[]> {
return apiClient.get<ProcurementPlanSummary[]>(
`/tenants/${tenantId}/procurement/plans`,
{ params }
);
}
/**
* Get a single procurement plan by ID with full details
*/
export async function getProcurementPlan(
tenantId: string,
planId: string
): Promise<ProcurementPlanDetail> {
return apiClient.get<ProcurementPlanDetail>(
`/tenants/${tenantId}/procurement/plans/${planId}`
);
}
/**
* Create a new procurement plan (manual)
*/
export async function createProcurementPlan(
tenantId: string,
data: {
plan_date: string;
planning_horizon_days?: number;
include_safety_stock?: boolean;
safety_stock_percentage?: number;
notes?: string;
}
): Promise<ProcurementPlanDetail> {
return apiClient.post<ProcurementPlanDetail>(
`/tenants/${tenantId}/procurement/plans`,
data
);
}
/**
* Update procurement plan
*/
export async function updateProcurementPlan(
tenantId: string,
planId: string,
data: {
status?: ProcurementPlanSummary['status'];
notes?: string;
}
): Promise<ProcurementPlanDetail> {
return apiClient.put<ProcurementPlanDetail>(
`/tenants/${tenantId}/procurement/plans/${planId}`,
data
);
}
/**
* Delete procurement plan
*/
export async function deleteProcurementPlan(
tenantId: string,
planId: string
): Promise<{ message: string }> {
return apiClient.delete<{ message: string }>(
`/tenants/${tenantId}/procurement/plans/${planId}`
);
}
/**
* Approve procurement plan
*/
export async function approveProcurementPlan(
tenantId: string,
planId: string,
notes?: string
): Promise<ProcurementPlanDetail> {
return apiClient.post<ProcurementPlanDetail>(
`/tenants/${tenantId}/procurement/plans/${planId}/approve`,
{ notes }
);
}
// ============================================================================
// AUTO-GENERATE PROCUREMENT (ORCHESTRATOR INTEGRATION)
// ============================================================================
/**
* Auto-generate procurement plan from forecast data
* This is the main entry point for orchestrated procurement planning
*
* NEW in Sprint 3: Called by Orchestrator Service to create procurement plans
* based on forecast data and production schedules
*
* Features:
* - Receives forecast data from Forecasting Service (via Orchestrator)
* - Calculates procurement requirements using smart calculator
* - Applies Recipe Explosion for locally-produced ingredients
* - Optionally creates purchase orders
* - Optionally auto-approves qualifying POs
*/
export async function autoGenerateProcurement(
tenantId: string,
request: AutoGenerateProcurementRequest
): Promise<AutoGenerateProcurementResponse> {
return apiClient.post<AutoGenerateProcurementResponse>(
`/tenants/${tenantId}/procurement/auto-generate`,
request
);
}
/**
* Test auto-generate with sample forecast data (for development/testing)
*/
export async function testAutoGenerateProcurement(
tenantId: string,
targetDate?: string
): Promise<AutoGenerateProcurementResponse> {
return apiClient.post<AutoGenerateProcurementResponse>(
`/tenants/${tenantId}/procurement/auto-generate/test`,
{ target_date: targetDate }
);
}
// ============================================================================
// PROCUREMENT REQUIREMENTS API FUNCTIONS
// ============================================================================
/**
* Add requirement to procurement plan
*/
export async function addProcurementRequirement(
tenantId: string,
planId: string,
requirement: {
ingredient_id: string;
required_quantity: number;
quantity_to_order: number;
priority: ProcurementRequirement['priority'];
reason: string;
supplier_id?: string;
expected_delivery_date?: string;
}
): Promise<ProcurementRequirement> {
return apiClient.post<ProcurementRequirement>(
`/tenants/${tenantId}/procurement/plans/${planId}/requirements`,
requirement
);
}
/**
* Update procurement requirement
*/
export async function updateProcurementRequirement(
tenantId: string,
planId: string,
requirementId: string,
data: {
quantity_to_order?: number;
priority?: ProcurementRequirement['priority'];
supplier_id?: string;
expected_delivery_date?: string;
}
): Promise<ProcurementRequirement> {
return apiClient.put<ProcurementRequirement>(
`/tenants/${tenantId}/procurement/plans/${planId}/requirements/${requirementId}`,
data
);
}
/**
* Delete procurement requirement
*/
export async function deleteProcurementRequirement(
tenantId: string,
planId: string,
requirementId: string
): Promise<{ message: string }> {
return apiClient.delete<{ message: string }>(
`/tenants/${tenantId}/procurement/plans/${planId}/requirements/${requirementId}`
);
}
// ============================================================================
// PURCHASE ORDERS FROM PLAN
// ============================================================================
/**
* Create purchase orders from procurement plan
* Groups requirements by supplier and creates POs
*/
export async function createPurchaseOrdersFromPlan(
tenantId: string,
planId: string,
options?: {
auto_approve?: boolean;
group_by_supplier?: boolean;
delivery_date?: string;
}
): Promise<{
success: boolean;
purchase_orders_created: number;
purchase_orders_auto_approved?: number;
purchase_orders_pending_approval?: number;
purchase_order_ids: string[];
message?: string;
}> {
return apiClient.post(
`/tenants/${tenantId}/procurement/plans/${planId}/create-purchase-orders`,
options
);
}

View File

@@ -168,6 +168,21 @@ export class TenantService {
return apiClient.delete<{ success: boolean; message: string }>(`${this.baseUrl}/${tenantId}/members/${memberUserId}`);
}
/**
* Transfer tenant ownership to another admin
* Backend: services/tenant/app/api/tenant_members.py - transfer_ownership endpoint
*
* @param tenantId - The tenant ID
* @param newOwnerId - The user ID of the new owner (must be an existing admin)
* @returns Updated tenant with new owner
*/
async transferOwnership(tenantId: string, newOwnerId: string): Promise<TenantResponse> {
return apiClient.post<TenantResponse>(
`${this.baseUrl}/${tenantId}/transfer-ownership`,
{ new_owner_id: newOwnerId }
);
}
// ===================================================================
// OPERATIONS: Statistics & Admin
// Backend: services/tenant/app/api/tenant_operations.py

View File

@@ -365,369 +365,3 @@ export interface GetDemandRequirementsParams {
tenant_id: string;
target_date: string;
}
// ================================================================
// PROCUREMENT ENUMS
// ================================================================
/**
* Procurement plan types
* Backend: ProcurementPlanType enum in models/enums.py (lines 104-108)
*/
export enum ProcurementPlanType {
REGULAR = 'regular',
EMERGENCY = 'emergency',
SEASONAL = 'seasonal'
}
/**
* Procurement strategies
* Backend: ProcurementStrategy enum in models/enums.py (lines 111-115)
*/
export enum ProcurementStrategy {
JUST_IN_TIME = 'just_in_time',
BULK = 'bulk',
MIXED = 'mixed'
}
/**
* Risk level classifications
* Backend: RiskLevel enum in models/enums.py (lines 118-123)
*/
export enum RiskLevel {
LOW = 'low',
MEDIUM = 'medium',
HIGH = 'high',
CRITICAL = 'critical'
}
/**
* Procurement requirement status
* Backend: RequirementStatus enum in models/enums.py (lines 126-133)
*/
export enum RequirementStatus {
PENDING = 'pending',
APPROVED = 'approved',
ORDERED = 'ordered',
PARTIALLY_RECEIVED = 'partially_received',
RECEIVED = 'received',
CANCELLED = 'cancelled'
}
/**
* Procurement plan status
* Backend: PlanStatus enum in models/enums.py (lines 136-143)
*/
export enum PlanStatus {
DRAFT = 'draft',
PENDING_APPROVAL = 'pending_approval',
APPROVED = 'approved',
IN_EXECUTION = 'in_execution',
COMPLETED = 'completed',
CANCELLED = 'cancelled'
}
/**
* Delivery status for procurement
* Backend: DeliveryStatus enum in models/enums.py (lines 146-151)
*/
export enum DeliveryStatus {
PENDING = 'pending',
IN_TRANSIT = 'in_transit',
DELIVERED = 'delivered',
DELAYED = 'delayed',
CANCELLED = 'cancelled'
}
// ================================================================
// PROCUREMENT TYPES
// ================================================================
// Procurement Requirement Types
export interface ProcurementRequirementBase {
product_id: string;
product_name: string;
product_sku?: string;
product_category?: string;
product_type: string;
required_quantity: number;
unit_of_measure: string;
safety_stock_quantity: number;
total_quantity_needed: number;
current_stock_level: number;
reserved_stock: number;
available_stock: number;
net_requirement: number;
order_demand: number;
production_demand: number;
forecast_demand: number;
buffer_demand: number;
required_by_date: string;
lead_time_buffer_days: number;
suggested_order_date: string;
latest_order_date: string;
priority: PriorityLevel;
risk_level: RiskLevel;
preferred_supplier_id?: string;
backup_supplier_id?: string;
supplier_name?: string;
supplier_lead_time_days?: number;
minimum_order_quantity?: number;
estimated_unit_cost?: number;
estimated_total_cost?: number;
last_purchase_cost?: number;
}
export interface ProcurementRequirementCreate extends ProcurementRequirementBase {
special_requirements?: string;
storage_requirements?: string;
shelf_life_days?: number;
quality_specifications?: Record<string, any>;
procurement_notes?: string;
}
export interface ProcurementRequirementUpdate {
status?: RequirementStatus;
priority?: PriorityLevel;
approved_quantity?: number;
approved_cost?: number;
purchase_order_id?: string;
purchase_order_number?: string;
ordered_quantity?: number;
expected_delivery_date?: string;
actual_delivery_date?: string;
received_quantity?: number;
delivery_status?: DeliveryStatus;
procurement_notes?: string;
}
export interface ProcurementRequirementResponse extends ProcurementRequirementBase {
id: string;
plan_id: string;
requirement_number: string;
status: RequirementStatus;
created_at: string;
updated_at: string;
purchase_order_id?: string;
purchase_order_number?: string;
ordered_quantity: number;
ordered_at?: string;
expected_delivery_date?: string;
actual_delivery_date?: string;
received_quantity: number;
delivery_status: DeliveryStatus;
fulfillment_rate?: number;
on_time_delivery?: boolean;
quality_rating?: number;
approved_quantity?: number;
approved_cost?: number;
approved_at?: string;
approved_by?: string;
special_requirements?: string;
storage_requirements?: string;
shelf_life_days?: number;
quality_specifications?: Record<string, any>;
procurement_notes?: string;
// Smart procurement calculation metadata
calculation_method?: string;
ai_suggested_quantity?: number;
adjusted_quantity?: number;
adjustment_reason?: string;
price_tier_applied?: Record<string, any>;
supplier_minimum_applied?: boolean;
storage_limit_applied?: boolean;
reorder_rule_applied?: boolean;
}
// Procurement Plan Types
export interface ProcurementPlanBase {
plan_date: string;
plan_period_start: string;
plan_period_end: string;
planning_horizon_days: number;
plan_type: ProcurementPlanType;
priority: PriorityLevel;
business_model?: BusinessModel;
procurement_strategy: ProcurementStrategy;
safety_stock_buffer: number;
supply_risk_level: RiskLevel;
demand_forecast_confidence?: number;
seasonality_adjustment: number;
special_requirements?: string;
}
export interface ProcurementPlanCreate extends ProcurementPlanBase {
tenant_id: string;
requirements?: ProcurementRequirementCreate[];
}
export interface ProcurementPlanUpdate {
status?: PlanStatus;
priority?: PriorityLevel;
approved_at?: string;
approved_by?: string;
execution_started_at?: string;
execution_completed_at?: string;
special_requirements?: string;
seasonal_adjustments?: Record<string, any>;
}
export interface ApprovalWorkflowEntry {
timestamp: string;
from_status: string;
to_status: string;
user_id?: string;
notes?: string;
}
export interface ProcurementPlanResponse extends ProcurementPlanBase {
id: string;
tenant_id: string;
plan_number: string;
status: PlanStatus;
total_requirements: number;
total_estimated_cost: number;
total_approved_cost: number;
cost_variance: number;
total_demand_orders: number;
total_demand_quantity: number;
total_production_requirements: number;
primary_suppliers_count: number;
backup_suppliers_count: number;
supplier_diversification_score?: number;
approved_at?: string;
approved_by?: string;
execution_started_at?: string;
execution_completed_at?: string;
fulfillment_rate?: number;
on_time_delivery_rate?: number;
cost_accuracy?: number;
quality_score?: number;
approval_workflow?: ApprovalWorkflowEntry[];
created_at: string;
updated_at: string;
created_by?: string;
updated_by?: string;
requirements: ProcurementRequirementResponse[];
}
// Summary and Dashboard Types
export interface ProcurementSummary {
total_plans: number;
active_plans: number;
total_requirements: number;
pending_requirements: number;
critical_requirements: number;
total_estimated_cost: number;
total_approved_cost: number;
cost_variance: number;
average_fulfillment_rate?: number;
average_on_time_delivery?: number;
top_suppliers: Record<string, any>[];
critical_items: Record<string, any>[];
}
export interface ProcurementDashboardData {
current_plan?: ProcurementPlanResponse;
summary: ProcurementSummary;
upcoming_deliveries: Record<string, any>[];
overdue_requirements: Record<string, any>[];
low_stock_alerts: Record<string, any>[];
performance_metrics: Record<string, any>;
}
// Request and Response Types
export interface GeneratePlanRequest {
plan_date?: string;
force_regenerate: boolean;
planning_horizon_days: number;
include_safety_stock: boolean;
safety_stock_percentage: number;
}
export interface GeneratePlanResponse {
success: boolean;
message: string;
plan?: ProcurementPlanResponse;
warnings: string[];
errors: string[];
}
// New Feature Types
export interface CreatePOsResult {
success: boolean;
created_pos: {
po_id: string;
po_number: string;
supplier_id: string;
items_count: number;
total_amount: number;
}[];
failed_pos: {
supplier_id: string;
error: string;
}[];
total_created: number;
total_failed: number;
}
export interface LinkRequirementToPORequest {
purchase_order_id: string;
purchase_order_number: string;
ordered_quantity: number;
expected_delivery_date?: string;
}
export interface UpdateDeliveryStatusRequest {
delivery_status: string;
received_quantity?: number;
actual_delivery_date?: string;
quality_rating?: number;
}
export interface ApprovalRequest {
approval_notes?: string;
}
export interface RejectionRequest {
rejection_notes?: string;
}
export interface PaginatedProcurementPlans {
plans: ProcurementPlanResponse[];
total: number;
page: number;
limit: number;
has_more: boolean;
}
export interface ForecastRequest {
target_date: string;
horizon_days: number;
include_confidence_intervals: boolean;
product_ids?: string[];
}
// Query Parameter Types for Procurement
export interface GetProcurementPlansParams {
tenant_id: string;
status?: string;
start_date?: string;
end_date?: string;
limit?: number;
offset?: number;
}
export interface GetPlanRequirementsParams {
tenant_id: string;
plan_id: string;
status?: string;
priority?: string;
}
export interface UpdatePlanStatusParams {
tenant_id: string;
plan_id: string;
status: PlanStatus;
}

View File

@@ -0,0 +1,192 @@
/**
* Performance Analytics Types
* Comprehensive types for performance monitoring across all departments
*/
// ============================================================================
// Overview Metrics
// ============================================================================
export interface PerformanceOverview {
overall_efficiency: number;
average_production_time: number;
quality_score: number;
employee_productivity: number;
customer_satisfaction: number;
resource_utilization: number;
}
// ============================================================================
// Department Performance
// ============================================================================
export interface DepartmentPerformance {
department_id: string;
department_name: string;
efficiency: number;
trend: 'up' | 'down' | 'stable';
metrics: DepartmentMetrics;
}
export interface DepartmentMetrics {
primary_metric: MetricValue;
secondary_metric: MetricValue;
tertiary_metric: MetricValue;
}
export interface MetricValue {
label: string;
value: number;
unit: string;
trend?: number;
}
// Production Department
export interface ProductionPerformance {
efficiency: number;
average_batch_time: number;
quality_rate: number;
waste_percentage: number;
capacity_utilization: number;
equipment_efficiency: number;
on_time_completion_rate: number;
yield_rate: number;
}
// Inventory Department
export interface InventoryPerformance {
stock_accuracy: number;
turnover_rate: number;
waste_rate: number;
low_stock_count: number;
compliance_rate: number;
expiring_items_count: number;
stock_value: number;
}
// Sales Department
export interface SalesPerformance {
total_revenue: number;
total_transactions: number;
average_transaction_value: number;
growth_rate: number;
channel_performance: ChannelPerformance[];
top_products: ProductPerformance[];
}
export interface ChannelPerformance {
channel: string;
revenue: number;
transactions: number;
percentage: number;
}
export interface ProductPerformance {
product_id: string;
product_name: string;
sales: number;
revenue: number;
}
// Procurement/Administration Department
export interface ProcurementPerformance {
fulfillment_rate: number;
on_time_delivery_rate: number;
cost_accuracy: number;
supplier_performance_score: number;
active_plans: number;
critical_requirements: number;
}
// ============================================================================
// KPI Tracking
// ============================================================================
export interface KPIMetric {
id: string;
name: string;
current_value: number;
target_value: number;
previous_value: number;
unit: string;
trend: 'up' | 'down' | 'stable';
status: 'good' | 'warning' | 'critical';
}
// ============================================================================
// Performance Alerts
// ============================================================================
export interface PerformanceAlert {
id: string;
type: 'warning' | 'critical' | 'info';
department: string;
message: string;
timestamp: string;
metric_affected: string;
current_value?: number;
threshold_value?: number;
}
// ============================================================================
// Time-Series Data
// ============================================================================
export interface TimeSeriesData {
timestamp: string;
value: number;
label?: string;
}
export interface HourlyProductivity {
hour: string;
efficiency: number;
production_count: number;
sales_count: number;
}
// ============================================================================
// Aggregated Dashboard Data
// ============================================================================
export interface PerformanceDashboard {
overview: PerformanceOverview;
departments: DepartmentPerformance[];
kpis: KPIMetric[];
alerts: PerformanceAlert[];
hourly_data: HourlyProductivity[];
last_updated: string;
}
// ============================================================================
// Filter and Query Parameters
// ============================================================================
export type TimePeriod = 'day' | 'week' | 'month' | 'quarter' | 'year';
export type MetricType = 'efficiency' | 'productivity' | 'quality' | 'satisfaction';
export interface PerformanceFilters {
period: TimePeriod;
metric_type?: MetricType;
start_date?: string;
end_date?: string;
departments?: string[];
}
// ============================================================================
// Trend Analysis
// ============================================================================
export interface TrendData {
date: string;
value: number;
comparison_value?: number;
}
export interface PerformanceTrend {
metric_name: string;
current_period: TrendData[];
previous_period: TrendData[];
change_percentage: number;
trend_direction: 'up' | 'down' | 'stable';
}

View File

@@ -0,0 +1,634 @@
/**
* TypeScript types for Procurement Service
* Mirrored from backend schemas: services/procurement/app/schemas/procurement_schemas.py
* Backend enums: services/shared/app/models/enums.py
* Backend API: services/procurement/app/api/
*
* Coverage:
* - Procurement Plans (MRP-style procurement planning)
* - Procurement Requirements (demand-driven purchasing)
* - Purchase Orders creation from plans
* - Analytics & Dashboard
* - Auto-generation (Orchestrator integration)
*/
// ================================================================
// ENUMS
// ================================================================
/**
* Procurement plan types
* Backend: ProcurementPlanType enum in models/enums.py
*/
export enum ProcurementPlanType {
REGULAR = 'regular',
EMERGENCY = 'emergency',
SEASONAL = 'seasonal'
}
/**
* Procurement strategies
* Backend: ProcurementStrategy enum in models/enums.py
*/
export enum ProcurementStrategy {
JUST_IN_TIME = 'just_in_time',
BULK = 'bulk',
MIXED = 'mixed'
}
/**
* Risk level classifications
* Backend: RiskLevel enum in models/enums.py
*/
export enum RiskLevel {
LOW = 'low',
MEDIUM = 'medium',
HIGH = 'high',
CRITICAL = 'critical'
}
/**
* Procurement requirement status
* Backend: RequirementStatus enum in models/enums.py
*/
export enum RequirementStatus {
PENDING = 'pending',
APPROVED = 'approved',
ORDERED = 'ordered',
PARTIALLY_RECEIVED = 'partially_received',
RECEIVED = 'received',
CANCELLED = 'cancelled'
}
/**
* Procurement plan status
* Backend: PlanStatus enum in models/enums.py
*/
export enum PlanStatus {
DRAFT = 'draft',
PENDING_APPROVAL = 'pending_approval',
APPROVED = 'approved',
IN_EXECUTION = 'in_execution',
COMPLETED = 'completed',
CANCELLED = 'cancelled'
}
/**
* Delivery status for procurement
* Backend: DeliveryStatus enum in models/enums.py
*/
export enum DeliveryStatus {
PENDING = 'pending',
IN_TRANSIT = 'in_transit',
DELIVERED = 'delivered',
DELAYED = 'delayed',
CANCELLED = 'cancelled'
}
/**
* Priority level (shared enum)
* Backend: PriorityLevel enum in models/enums.py
*/
export enum PriorityLevel {
HIGH = 'high',
NORMAL = 'normal',
LOW = 'low'
}
/**
* Business model (shared enum)
* Backend: BusinessModel enum in models/enums.py
*/
export enum BusinessModel {
INDIVIDUAL_BAKERY = 'individual_bakery',
CENTRAL_BAKERY = 'central_bakery'
}
// ================================================================
// PROCUREMENT REQUIREMENT TYPES
// ================================================================
/**
* Base procurement requirement
* Backend: ProcurementRequirementBase schema
*/
export interface ProcurementRequirementBase {
product_id: string;
product_name: string;
product_sku?: string;
product_category?: string;
product_type: string;
required_quantity: number;
unit_of_measure: string;
safety_stock_quantity: number;
total_quantity_needed: number;
current_stock_level: number;
reserved_stock: number;
available_stock: number;
net_requirement: number;
order_demand: number;
production_demand: number;
forecast_demand: number;
buffer_demand: number;
required_by_date: string;
lead_time_buffer_days: number;
suggested_order_date: string;
latest_order_date: string;
priority: PriorityLevel;
risk_level: RiskLevel;
preferred_supplier_id?: string;
backup_supplier_id?: string;
supplier_name?: string;
supplier_lead_time_days?: number;
minimum_order_quantity?: number;
estimated_unit_cost?: number;
estimated_total_cost?: number;
last_purchase_cost?: number;
}
/**
* Create procurement requirement request
* Backend: ProcurementRequirementCreate schema
*/
export interface ProcurementRequirementCreate extends ProcurementRequirementBase {
special_requirements?: string;
storage_requirements?: string;
shelf_life_days?: number;
quality_specifications?: Record<string, any>;
procurement_notes?: string;
// Smart procurement calculation metadata
calculation_method?: string;
ai_suggested_quantity?: number;
adjusted_quantity?: number;
adjustment_reason?: string;
price_tier_applied?: Record<string, any>;
supplier_minimum_applied?: boolean;
storage_limit_applied?: boolean;
reorder_rule_applied?: boolean;
// Local production support fields
is_locally_produced?: boolean;
recipe_id?: string;
parent_requirement_id?: string;
bom_explosion_level?: number;
}
/**
* Update procurement requirement request
* Backend: ProcurementRequirementUpdate schema
*/
export interface ProcurementRequirementUpdate {
status?: RequirementStatus;
priority?: PriorityLevel;
approved_quantity?: number;
approved_cost?: number;
purchase_order_id?: string;
purchase_order_number?: string;
ordered_quantity?: number;
expected_delivery_date?: string;
actual_delivery_date?: string;
received_quantity?: number;
delivery_status?: DeliveryStatus;
procurement_notes?: string;
}
/**
* Procurement requirement response
* Backend: ProcurementRequirementResponse schema
*/
export interface ProcurementRequirementResponse extends ProcurementRequirementBase {
id: string;
plan_id: string;
requirement_number: string;
status: RequirementStatus;
created_at: string;
updated_at: string;
purchase_order_id?: string;
purchase_order_number?: string;
ordered_quantity: number;
ordered_at?: string;
expected_delivery_date?: string;
actual_delivery_date?: string;
received_quantity: number;
delivery_status: DeliveryStatus;
fulfillment_rate?: number;
on_time_delivery?: boolean;
quality_rating?: number;
approved_quantity?: number;
approved_cost?: number;
approved_at?: string;
approved_by?: string;
special_requirements?: string;
storage_requirements?: string;
shelf_life_days?: number;
quality_specifications?: Record<string, any>;
procurement_notes?: string;
// Smart procurement calculation metadata
calculation_method?: string;
ai_suggested_quantity?: number;
adjusted_quantity?: number;
adjustment_reason?: string;
price_tier_applied?: Record<string, any>;
supplier_minimum_applied?: boolean;
storage_limit_applied?: boolean;
reorder_rule_applied?: boolean;
// Local production support fields
is_locally_produced?: boolean;
recipe_id?: string;
parent_requirement_id?: string;
bom_explosion_level?: number;
}
// ================================================================
// PROCUREMENT PLAN TYPES
// ================================================================
/**
* Base procurement plan
* Backend: ProcurementPlanBase schema
*/
export interface ProcurementPlanBase {
plan_date: string;
plan_period_start: string;
plan_period_end: string;
planning_horizon_days: number;
plan_type: ProcurementPlanType;
priority: PriorityLevel;
business_model?: BusinessModel;
procurement_strategy: ProcurementStrategy;
safety_stock_buffer: number;
supply_risk_level: RiskLevel;
demand_forecast_confidence?: number;
seasonality_adjustment: number;
special_requirements?: string;
}
/**
* Create procurement plan request
* Backend: ProcurementPlanCreate schema
*/
export interface ProcurementPlanCreate extends ProcurementPlanBase {
tenant_id: string;
requirements?: ProcurementRequirementCreate[];
}
/**
* Update procurement plan request
* Backend: ProcurementPlanUpdate schema
*/
export interface ProcurementPlanUpdate {
status?: PlanStatus;
priority?: PriorityLevel;
approved_at?: string;
approved_by?: string;
execution_started_at?: string;
execution_completed_at?: string;
special_requirements?: string;
seasonal_adjustments?: Record<string, any>;
}
/**
* Approval workflow entry
* Backend: ApprovalWorkflowEntry (embedded in plan)
*/
export interface ApprovalWorkflowEntry {
timestamp: string;
from_status: string;
to_status: string;
user_id?: string;
notes?: string;
}
/**
* Procurement plan response
* Backend: ProcurementPlanResponse schema
*/
export interface ProcurementPlanResponse extends ProcurementPlanBase {
id: string;
tenant_id: string;
plan_number: string;
status: PlanStatus;
total_requirements: number;
total_estimated_cost: number;
total_approved_cost: number;
cost_variance: number;
total_demand_orders: number;
total_demand_quantity: number;
total_production_requirements: number;
primary_suppliers_count: number;
backup_suppliers_count: number;
supplier_diversification_score?: number;
approved_at?: string;
approved_by?: string;
execution_started_at?: string;
execution_completed_at?: string;
fulfillment_rate?: number;
on_time_delivery_rate?: number;
cost_accuracy?: number;
quality_score?: number;
approval_workflow?: ApprovalWorkflowEntry[];
created_at: string;
updated_at: string;
created_by?: string;
updated_by?: string;
requirements: ProcurementRequirementResponse[];
}
// ================================================================
// ANALYTICS & DASHBOARD TYPES
// ================================================================
/**
* Procurement summary metrics
* Backend: Returned by analytics endpoints
*/
export interface ProcurementSummary {
total_plans: number;
active_plans: number;
total_requirements: number;
pending_requirements: number;
critical_requirements: number;
total_estimated_cost: number;
total_approved_cost: number;
cost_variance: number;
average_fulfillment_rate?: number;
average_on_time_delivery?: number;
top_suppliers: Record<string, any>[];
critical_items: Record<string, any>[];
}
/**
* Procurement dashboard data
* Backend: GET /api/v1/tenants/{tenant_id}/procurement/analytics/procurement
*/
export interface ProcurementDashboardData {
current_plan?: ProcurementPlanResponse;
summary: {
total_plans: number;
total_estimated_cost: number;
total_approved_cost: number;
cost_variance: number;
};
upcoming_deliveries?: Record<string, any>[];
overdue_requirements?: Record<string, any>[];
low_stock_alerts?: Record<string, any>[];
performance_metrics: {
average_fulfillment_rate: number;
average_on_time_delivery: number;
cost_accuracy: number;
supplier_performance: number;
fulfillment_trend?: number;
on_time_trend?: number;
cost_variance_trend?: number;
};
plan_status_distribution?: Array<{
status: string;
count: number;
}>;
critical_requirements?: {
low_stock: number;
overdue: number;
high_priority: number;
};
recent_plans?: Array<{
id: string;
plan_number: string;
plan_date: string;
status: string;
total_requirements: number;
total_estimated_cost: number;
created_at: string;
}>;
supplier_performance?: Array<{
id: string;
name: string;
total_orders: number;
fulfillment_rate: number;
on_time_rate: number;
quality_score: number;
}>;
cost_by_category?: Array<{
name: string;
amount: number;
}>;
quality_metrics?: {
avg_score: number;
high_quality_count: number;
low_quality_count: number;
};
}
/**
* Procurement trends data for time-series charts
* Backend: GET /api/v1/tenants/{tenant_id}/procurement/analytics/procurement/trends
*/
export interface ProcurementTrendsData {
performance_trend: Array<{
date: string;
fulfillment_rate: number;
on_time_rate: number;
}>;
quality_trend: Array<{
date: string;
quality_score: number;
}>;
period_days: number;
start_date: string;
end_date: string;
}
// ================================================================
// REQUEST & RESPONSE TYPES
// ================================================================
/**
* Generate procurement plan request
* Backend: POST /api/v1/tenants/{tenant_id}/procurement/plans/generate
*/
export interface GeneratePlanRequest {
plan_date?: string;
force_regenerate: boolean;
planning_horizon_days: number;
include_safety_stock: boolean;
safety_stock_percentage: number;
}
/**
* Generate procurement plan response
* Backend: POST /api/v1/tenants/{tenant_id}/procurement/plans/generate
*/
export interface GeneratePlanResponse {
success: boolean;
message: string;
plan?: ProcurementPlanResponse;
warnings: string[];
errors: string[];
}
/**
* Auto-generate procurement request (Orchestrator integration)
* Backend: POST /api/v1/tenants/{tenant_id}/procurement/auto-generate
*/
export interface AutoGenerateProcurementRequest {
forecast_data: Record<string, any>;
production_schedule_id?: string;
target_date?: string;
planning_horizon_days: number;
safety_stock_percentage: number;
auto_create_pos: boolean;
auto_approve_pos: boolean;
// Cached data from Orchestrator
inventory_data?: Record<string, any>;
suppliers_data?: Record<string, any>;
recipes_data?: Record<string, any>;
}
/**
* Auto-generate procurement response
* Backend: POST /api/v1/tenants/{tenant_id}/procurement/auto-generate
*/
export interface AutoGenerateProcurementResponse {
success: boolean;
message: string;
plan_id?: string;
plan_number?: string;
requirements_created: number;
purchase_orders_created: number;
purchase_orders_auto_approved: number;
total_estimated_cost: number;
warnings: string[];
errors: string[];
created_pos: Array<{
po_id: string;
po_number: string;
supplier_id: string;
items_count: number;
total_amount: number;
}>;
}
/**
* Create purchase orders from plan result
* Backend: POST /api/v1/tenants/{tenant_id}/procurement/plans/{plan_id}/create-purchase-orders
*/
export interface CreatePOsResult {
success: boolean;
created_pos: {
po_id: string;
po_number: string;
supplier_id: string;
items_count: number;
total_amount: number;
}[];
failed_pos: {
supplier_id: string;
error: string;
}[];
total_created: number;
total_failed: number;
}
/**
* Link requirement to purchase order request
* Backend: Used in requirement linking operations
*/
export interface LinkRequirementToPORequest {
purchase_order_id: string;
purchase_order_number: string;
ordered_quantity: number;
expected_delivery_date?: string;
}
/**
* Update delivery status request
* Backend: Used in delivery status updates
*/
export interface UpdateDeliveryStatusRequest {
delivery_status: string;
received_quantity?: number;
actual_delivery_date?: string;
quality_rating?: number;
}
/**
* Approval request
* Backend: Used in plan approval operations
*/
export interface ApprovalRequest {
approval_notes?: string;
}
/**
* Rejection request
* Backend: Used in plan rejection operations
*/
export interface RejectionRequest {
rejection_notes?: string;
}
/**
* Paginated procurement plans
* Backend: GET /api/v1/tenants/{tenant_id}/procurement/plans
*/
export interface PaginatedProcurementPlans {
plans: ProcurementPlanResponse[];
total: number;
page: number;
limit: number;
has_more: boolean;
}
/**
* Forecast request
* Backend: Used in forecasting operations
*/
export interface ForecastRequest {
target_date: string;
horizon_days: number;
include_confidence_intervals: boolean;
product_ids?: string[];
}
// ================================================================
// QUERY PARAMETER TYPES
// ================================================================
/**
* Get procurement plans query parameters
* Backend: GET /api/v1/tenants/{tenant_id}/procurement/plans
*/
export interface GetProcurementPlansParams {
tenant_id: string;
status?: string;
start_date?: string;
end_date?: string;
limit?: number;
offset?: number;
}
/**
* Get plan requirements query parameters
* Backend: GET /api/v1/tenants/{tenant_id}/procurement/plans/{plan_id}/requirements
*/
export interface GetPlanRequirementsParams {
tenant_id: string;
plan_id: string;
status?: string;
priority?: string;
}
/**
* Update plan status parameters
* Backend: PATCH /api/v1/tenants/{tenant_id}/procurement/plans/{plan_id}/status
*/
export interface UpdatePlanStatusParams {
tenant_id: string;
plan_id: string;
status: PlanStatus;
notes?: string;
}

View File

@@ -0,0 +1,375 @@
/**
* Transfer Ownership Modal Component
*
* Allows the current tenant owner to transfer ownership to another admin.
* This is a critical operation with multiple confirmation steps.
*/
import React, { useState } from 'react';
import { Crown, AlertTriangle, Shield, ChevronRight } from 'lucide-react';
import { StatusModal } from '../../ui';
import { TENANT_ROLES } from '../../../types/roles';
export interface TeamMember {
id: string;
user_id: string;
role: string;
is_active: boolean;
user_email?: string | null;
user_full_name?: string | null;
joined_at?: string | null;
}
interface TransferOwnershipModalProps {
isOpen: boolean;
onClose: () => void;
onTransfer: (newOwnerId: string) => Promise<void>;
currentOwner: TeamMember | null;
eligibleMembers: TeamMember[]; // Only admins should be eligible
tenantName: string;
}
const TransferOwnershipModal: React.FC<TransferOwnershipModalProps> = ({
isOpen,
onClose,
onTransfer,
currentOwner,
eligibleMembers,
tenantName,
}) => {
const [selectedMemberId, setSelectedMemberId] = useState<string>('');
const [confirmationStep, setConfirmationStep] = useState<1 | 2>(1);
const [confirmationText, setConfirmationText] = useState('');
const [isProcessing, setIsProcessing] = useState(false);
const [error, setError] = useState<string | null>(null);
const selectedMember = eligibleMembers.find(m => m.user_id === selectedMemberId);
const requiredConfirmationText = 'TRANSFERIR PROPIEDAD';
const handleReset = () => {
setSelectedMemberId('');
setConfirmationStep(1);
setConfirmationText('');
setError(null);
setIsProcessing(false);
};
const handleClose = () => {
handleReset();
onClose();
};
const handleNextStep = () => {
if (!selectedMember) {
setError('Por favor selecciona un miembro');
return;
}
setError(null);
setConfirmationStep(2);
};
const handleTransfer = async () => {
if (confirmationText !== requiredConfirmationText) {
setError(`Por favor escribe "${requiredConfirmationText}" para confirmar`);
return;
}
if (!selectedMember) {
setError('No se ha seleccionado ningún miembro');
return;
}
try {
setIsProcessing(true);
setError(null);
await onTransfer(selectedMember.user_id);
handleClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al transferir la propiedad');
setIsProcessing(false);
}
};
return (
<StatusModal
isOpen={isOpen}
onClose={handleClose}
title="Transferir Propiedad"
subtitle={`Organización: ${tenantName}`}
size="lg"
preventClose={isProcessing}
>
<div className="space-y-6">
{/* Warning Banner */}
<div className="bg-color-warning bg-opacity-10 border border-color-warning rounded-lg p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-color-warning flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h4 className="font-semibold text-text-primary mb-1">
Acción Irreversible
</h4>
<p className="text-sm text-text-secondary">
Transferir la propiedad es permanente. El nuevo propietario tendrá control total
de la organización y podrá cambiar todos los permisos, incluyendo los tuyos.
</p>
</div>
</div>
</div>
{/* Step 1: Select New Owner */}
{confirmationStep === 1 && (
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold text-text-primary mb-2">
Paso 1: Selecciona el Nuevo Propietario
</h3>
<p className="text-sm text-text-secondary mb-4">
Solo puedes transferir la propiedad a un administrador actual de la organización.
</p>
</div>
{/* Current Owner Info */}
<div className="bg-bg-tertiary rounded-lg p-4">
<div className="flex items-center gap-3 mb-2">
<Crown className="w-5 h-5 text-color-primary" />
<span className="text-sm font-medium text-text-secondary">
Propietario Actual
</span>
</div>
<div className="ml-8">
<p className="font-medium text-text-primary">
{currentOwner?.user_full_name || currentOwner?.user_email || 'Usuario actual'}
</p>
<p className="text-sm text-text-secondary">
{currentOwner?.user_email}
</p>
</div>
</div>
{/* Eligible Members Selection */}
<div>
<label className="block text-sm font-medium text-text-primary mb-3">
Selecciona el nuevo propietario:
</label>
{eligibleMembers.length === 0 ? (
<div className="bg-bg-tertiary rounded-lg p-4 text-center">
<Shield className="w-12 h-12 text-text-tertiary mx-auto mb-2" />
<p className="text-sm text-text-secondary">
No hay administradores disponibles para la transferencia.
</p>
<p className="text-xs text-text-tertiary mt-1">
Primero promociona a un miembro a administrador.
</p>
</div>
) : (
<div className="space-y-2">
{eligibleMembers.map((member) => (
<button
key={member.user_id}
onClick={() => {
setSelectedMemberId(member.user_id);
setError(null);
}}
className={`
w-full text-left p-4 rounded-lg border-2 transition-all
${selectedMemberId === member.user_id
? 'border-color-primary bg-color-primary bg-opacity-5'
: 'border-border-primary hover:border-border-hover bg-bg-secondary'
}
`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 flex-1">
<div className={`
w-10 h-10 rounded-full flex items-center justify-center
${selectedMemberId === member.user_id
? 'bg-color-primary text-text-inverse'
: 'bg-bg-tertiary text-text-secondary'
}
`}>
<Shield className="w-5 h-5" />
</div>
<div className="flex-1">
<p className="font-medium text-text-primary">
{member.user_full_name || member.user_email}
</p>
<p className="text-sm text-text-secondary">
{member.user_email}
</p>
<p className="text-xs text-text-tertiary mt-1">
Rol actual: Administrador
</p>
</div>
</div>
{selectedMemberId === member.user_id && (
<div className="w-6 h-6 rounded-full bg-color-primary flex items-center justify-center">
<svg className="w-4 h-4 text-text-inverse" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</div>
)}
</div>
</button>
))}
</div>
)}
</div>
{error && (
<div className="bg-color-error bg-opacity-10 border border-color-error rounded-lg p-3">
<p className="text-sm text-color-error">{error}</p>
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-3 pt-4 border-t border-border-primary">
<button
onClick={handleClose}
className="btn btn-outline"
disabled={isProcessing}
>
Cancelar
</button>
<button
onClick={handleNextStep}
disabled={!selectedMember || eligibleMembers.length === 0 || isProcessing}
className="btn btn-primary flex items-center gap-2"
>
Continuar
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
)}
{/* Step 2: Confirmation */}
{confirmationStep === 2 && selectedMember && (
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold text-text-primary mb-2">
Paso 2: Confirma la Transferencia
</h3>
<p className="text-sm text-text-secondary mb-4">
Por favor revisa cuidadosamente antes de confirmar.
</p>
</div>
{/* Transfer Summary */}
<div className="bg-bg-tertiary rounded-lg p-4 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Crown className="w-5 h-5 text-text-secondary" />
<div>
<p className="text-xs text-text-tertiary">De</p>
<p className="font-medium text-text-primary">
{currentOwner?.user_full_name || currentOwner?.user_email}
</p>
</div>
</div>
<ChevronRight className="w-5 h-5 text-text-tertiary" />
<div className="flex items-center gap-3">
<Crown className="w-5 h-5 text-color-primary" />
<div>
<p className="text-xs text-text-tertiary">A</p>
<p className="font-medium text-text-primary">
{selectedMember.user_full_name || selectedMember.user_email}
</p>
</div>
</div>
</div>
</div>
{/* Consequences List */}
<div className="bg-color-warning bg-opacity-5 rounded-lg p-4">
<h4 className="font-semibold text-text-primary mb-3">
Qué sucederá:
</h4>
<ul className="space-y-2 text-sm text-text-secondary">
<li className="flex items-start gap-2">
<span className="text-color-warning mt-0.5"></span>
<span>
<strong>{selectedMember.user_full_name || selectedMember.user_email}</strong> será
el nuevo propietario con control total
</span>
</li>
<li className="flex items-start gap-2">
<span className="text-color-warning mt-0.5"></span>
<span>Tu rol cambiará a <strong>Administrador</strong></span>
</li>
<li className="flex items-start gap-2">
<span className="text-color-warning mt-0.5"></span>
<span>El nuevo propietario podrá modificar tu rol o remover tu acceso</span>
</li>
<li className="flex items-start gap-2">
<span className="text-color-warning mt-0.5"></span>
<span>Esta acción <strong>no se puede deshacer</strong></span>
</li>
</ul>
</div>
{/* Confirmation Input */}
<div>
<label className="block text-sm font-medium text-text-primary mb-2">
Escribe <code className="bg-bg-tertiary px-2 py-1 rounded text-color-error font-mono text-xs">
{requiredConfirmationText}
</code> para confirmar:
</label>
<input
type="text"
value={confirmationText}
onChange={(e) => {
setConfirmationText(e.target.value);
setError(null);
}}
placeholder={requiredConfirmationText}
className="input w-full font-mono"
disabled={isProcessing}
autoComplete="off"
/>
</div>
{error && (
<div className="bg-color-error bg-opacity-10 border border-color-error rounded-lg p-3">
<p className="text-sm text-color-error">{error}</p>
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-3 pt-4 border-t border-border-primary">
<button
onClick={() => {
setConfirmationStep(1);
setConfirmationText('');
setError(null);
}}
className="btn btn-outline"
disabled={isProcessing}
>
Atrás
</button>
<button
onClick={handleTransfer}
disabled={confirmationText !== requiredConfirmationText || isProcessing}
className="btn btn-error flex items-center gap-2"
>
{isProcessing ? (
<>
<div className="spinner spinner-sm"></div>
Transfiriendo...
</>
) : (
<>
<Crown className="w-4 h-4" />
Transferir Propiedad
</>
)}
</button>
</div>
</div>
)}
</div>
</StatusModal>
);
};
export default TransferOwnershipModal;

View File

@@ -20,6 +20,7 @@ export { StatsCard, StatsGrid } from './Stats';
export { StatusCard, getStatusColor } from './StatusCard';
export { EditViewModal } from './EditViewModal';
export { AddModal } from './AddModal';
export { StatusModal } from './StatusModal';
export { DialogModal, showInfoDialog, showWarningDialog, showErrorDialog, showSuccessDialog, showConfirmDialog } from './DialogModal';
export { TenantSwitcher } from './TenantSwitcher';
export { LanguageSelector, CompactLanguageSelector } from './LanguageSelector';
@@ -51,6 +52,7 @@ export type { StatsCardProps, StatsCardVariant, StatsCardSize, StatsGridProps }
export type { StatusCardProps, StatusIndicatorConfig } from './StatusCard';
export type { EditViewModalProps, EditViewModalField, EditViewModalSection, EditViewModalAction } from './EditViewModal';
export type { AddModalProps, AddModalField, AddModalSection } from './AddModal';
export type { StatusModalProps, StatusModalField, StatusModalSection, StatusModalAction } from './StatusModal/StatusModal';
export type { DialogModalProps, DialogModalAction } from './DialogModal';
export type { LoadingSpinnerProps } from './LoadingSpinner';
export type { EmptyStateProps } from './EmptyState';

View File

@@ -10,35 +10,27 @@ const SubscriptionEventsContext = createContext<SubscriptionEventsContextType |
export const SubscriptionEventsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [subscriptionVersion, setSubscriptionVersion] = useState(0);
const [subscribers, setSubscribers] = useState<Set<() => void>>(new Set());
const subscribersRef = React.useRef<Set<() => void>>(new Set());
const notifySubscriptionChanged = useCallback(() => {
setSubscriptionVersion(prev => prev + 1);
// Notify all subscribers
subscribers.forEach(callback => {
subscribersRef.current.forEach(callback => {
try {
callback();
} catch (error) {
console.warn('Error notifying subscription change subscriber:', error);
}
});
}, [subscribers]);
}, []); // Empty dependency array - function is now stable
const subscribeToChanges = useCallback((callback: () => void) => {
setSubscribers(prev => {
const newSubscribers = new Set(prev);
newSubscribers.add(callback);
return newSubscribers;
});
subscribersRef.current.add(callback);
// Return unsubscribe function
return () => {
setSubscribers(prev => {
const newSubscribers = new Set(prev);
newSubscribers.delete(callback);
return newSubscribers;
});
subscribersRef.current.delete(callback);
};
}, []);

View File

@@ -4,7 +4,7 @@ export const getDemoTourSteps = (): DriveStep[] => [
{
element: '[data-tour="demo-banner"]',
popover: {
title: '¡Bienvenido a BakeryIA!',
title: '¡Bienvenido a El Panadero Digital!',
description: 'Descubre cómo gestionar tu panadería en 5 minutos al día. Esta demo de 30 minutos usa datos reales de una panadería española. Te mostramos cómo ahorrar 2-3 horas diarias en planificación y reducir desperdicio un 15-25%. Puedes cerrar el tour con ESC.',
side: 'bottom',
align: 'center',
@@ -105,7 +105,7 @@ export const getMobileTourSteps = (): DriveStep[] => [
{
element: '[data-tour="demo-banner"]',
popover: {
title: '¡Bienvenido a BakeryIA!',
title: '¡Bienvenido a El Panadero Digital !',
description: 'Gestiona tu panadería en 5 min/día. Demo de 30 min con datos reales. Ahorra 2-3h diarias y reduce desperdicio 15-25%.',
side: 'bottom',
align: 'center',

View File

@@ -244,13 +244,13 @@
}
},
"central_workshop": {
"title": "Obrador Central + Puntos de Venta",
"subtitle": "Producción centralizada, distribución múltiple",
"description": "Produces centralmente y distribuyes a múltiples puntos de venta. Necesitas coordinar producción, logística y demanda entre ubicaciones para optimizar cada punto.",
"title": "Panadería Franquiciada",
"subtitle": "Punto de venta con obrador central",
"description": "Operas un punto de venta que recibe productos de un obrador central. Necesitas gestionar pedidos, inventario y ventas para optimizar tu operación retail.",
"features": {
"prediction": "<strong>Predicción agregada y por punto de venta</strong> individual",
"distribution": "<strong>Gestión de distribución</strong> multi-ubicación coordinada",
"visibility": "<strong>Visibilidad centralizada</strong> con control granular"
"prediction": "<strong>Gestión de pedidos</strong> al obrador central",
"distribution": "<strong>Control de inventario</strong> de productos recibidos",
"visibility": "<strong>Previsión de ventas</strong> para tu punto"
}
},
"same_ai": "La misma IA potente, adaptada a tu forma de trabajar"

View File

@@ -12,11 +12,21 @@ import {
Truck,
Calendar
} from 'lucide-react';
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend
} from 'recharts';
import { PageHeader } from '../../../components/layout';
import { Card, StatsGrid, Button, Tabs } from '../../../components/ui';
import { useSubscription } from '../../../api/hooks/subscription';
import { useCurrentTenant } from '../../../stores/tenant.store';
import { useProcurementDashboard } from '../../../api/hooks/orders';
import { useProcurementDashboard, useProcurementTrends } from '../../../api/hooks/procurement';
import { formatters } from '../../../components/ui/Stats/StatsPresets';
const ProcurementAnalyticsPage: React.FC = () => {
@@ -27,6 +37,7 @@ const ProcurementAnalyticsPage: React.FC = () => {
const [activeTab, setActiveTab] = useState('overview');
const { data: dashboard, isLoading: dashboardLoading } = useProcurementDashboard(tenantId);
const { data: trends, isLoading: trendsLoading } = useProcurementTrends(tenantId, 7);
// Check if user has access to advanced analytics (professional/enterprise)
const hasAdvancedAccess = canAccessAnalytics('advanced');
@@ -162,32 +173,32 @@ const ProcurementAnalyticsPage: React.FC = () => {
<>
{/* Overview Tab */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Plan Status Distribution */}
<Card>
<div className="p-6">
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-4">
Distribución de Estados de Planes
</h3>
<div className="space-y-3">
{dashboard?.plan_status_distribution?.map((status: any) => (
<div key={status.status} className="flex items-center justify-between">
<span className="text-[var(--text-secondary)]">{status.status}</span>
<div className="flex items-center gap-2">
<div className="w-32 h-2 bg-[var(--bg-tertiary)] rounded-full overflow-hidden">
<div
className="h-full bg-[var(--color-primary)]"
style={{ width: `${(status.count / (dashboard?.summary?.total_plans || 1)) * 100}%` }}
/>
</div>
<span className="text-sm font-medium text-[var(--text-primary)] w-8 text-right">
{status.count}
</span>
{/* Plan Status Distribution */}
<Card>
<div className="p-6">
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-4">
Distribución de Estados de Planes
</h3>
<div className="space-y-3">
{dashboard?.plan_status_distribution?.map((status: any) => (
<div key={status.status} className="flex items-center justify-between">
<span className="text-[var(--text-secondary)]">{status.status}</span>
<div className="flex items-center gap-2">
<div className="w-32 h-2 bg-[var(--bg-tertiary)] rounded-full overflow-hidden">
<div
className="h-full bg-[var(--color-primary)]"
style={{ width: `${(status.count / (dashboard?.summary?.total_plans || 1)) * 100}%` }}
/>
</div>
<span className="text-sm font-medium text-[var(--text-primary)] w-8 text-right">
{status.count}
</span>
</div>
))}
</div>
</div>
))}
</div>
</Card>
</div>
</Card>
{/* Critical Requirements */}
<Card>
@@ -302,15 +313,63 @@ const ProcurementAnalyticsPage: React.FC = () => {
</Card>
</div>
{/* Performance Trend Chart Placeholder */}
{/* Performance Trend Chart */}
<Card>
<div className="p-6">
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-4">
Tendencias de Rendimiento
Tendencias de Rendimiento (Últimos 7 días)
</h3>
<div className="h-64 flex items-center justify-center text-[var(--text-tertiary)]">
Gráfico de tendencias - Próximamente
</div>
{trendsLoading ? (
<div className="h-64 flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--color-primary)]"></div>
</div>
) : trends && trends.performance_trend && trends.performance_trend.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<LineChart data={trends.performance_trend}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border-primary)" />
<XAxis
dataKey="date"
stroke="var(--text-tertiary)"
tick={{ fill: 'var(--text-secondary)' }}
/>
<YAxis
stroke="var(--text-tertiary)"
tick={{ fill: 'var(--text-secondary)' }}
tickFormatter={(value) => `${(value * 100).toFixed(0)}%`}
/>
<Tooltip
contentStyle={{
backgroundColor: 'var(--bg-primary)',
border: '1px solid var(--border-primary)',
borderRadius: '8px'
}}
formatter={(value: any) => `${(value * 100).toFixed(1)}%`}
labelStyle={{ color: 'var(--text-primary)' }}
/>
<Legend />
<Line
type="monotone"
dataKey="fulfillment_rate"
stroke="var(--color-success)"
strokeWidth={2}
name="Tasa de Cumplimiento"
dot={{ fill: 'var(--color-success)' }}
/>
<Line
type="monotone"
dataKey="on_time_rate"
stroke="var(--color-info)"
strokeWidth={2}
name="Entregas a Tiempo"
dot={{ fill: 'var(--color-info)' }}
/>
</LineChart>
</ResponsiveContainer>
) : (
<div className="h-64 flex items-center justify-center text-[var(--text-tertiary)]">
No hay datos de tendencias disponibles
</div>
)}
</div>
</Card>
</>
@@ -459,11 +518,51 @@ const ProcurementAnalyticsPage: React.FC = () => {
<Card>
<div className="p-6">
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-4">
Tendencia de Calidad
Tendencia de Calidad (Últimos 7 días)
</h3>
<div className="h-48 flex items-center justify-center text-[var(--text-tertiary)]">
Gráfico de tendencia de calidad - Próximamente
</div>
{trendsLoading ? (
<div className="h-48 flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--color-primary)]"></div>
</div>
) : trends && trends.quality_trend && trends.quality_trend.length > 0 ? (
<ResponsiveContainer width="100%" height={200}>
<LineChart data={trends.quality_trend}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border-primary)" />
<XAxis
dataKey="date"
stroke="var(--text-tertiary)"
tick={{ fill: 'var(--text-secondary)' }}
/>
<YAxis
stroke="var(--text-tertiary)"
tick={{ fill: 'var(--text-secondary)' }}
domain={[0, 10]}
ticks={[0, 2, 4, 6, 8, 10]}
/>
<Tooltip
contentStyle={{
backgroundColor: 'var(--bg-primary)',
border: '1px solid var(--border-primary)',
borderRadius: '8px'
}}
formatter={(value: any) => `${value.toFixed(1)} / 10`}
labelStyle={{ color: 'var(--text-primary)' }}
/>
<Line
type="monotone"
dataKey="quality_score"
stroke="var(--color-warning)"
strokeWidth={2}
name="Puntuación de Calidad"
dot={{ fill: 'var(--color-warning)' }}
/>
</LineChart>
</ResponsiveContainer>
) : (
<div className="h-48 flex items-center justify-center text-[var(--text-tertiary)]">
No hay datos de calidad disponibles
</div>
)}
</div>
</Card>
</div>

View File

@@ -1,22 +1,80 @@
import React, { useState } from 'react';
import { Activity, Clock, Users, TrendingUp, Target, AlertCircle, Download, Calendar } from 'lucide-react';
import { Button, Card, Badge } from '../../../../components/ui';
import {
Activity,
Clock,
TrendingUp,
Target,
AlertCircle,
Download,
Calendar,
Lock,
BarChart3,
Zap,
DollarSign,
Package,
AlertOctagon,
} from 'lucide-react';
import {
ResponsiveContainer,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
LineChart,
Line,
RadarChart,
Radar,
PolarGrid,
PolarAngleAxis,
PolarRadiusAxis,
} from 'recharts';
import { Button, Card, Badge, StatsGrid, Tabs } from '../../../../components/ui';
import { PageHeader } from '../../../../components/layout';
import { useSubscription } from '../../../../api/hooks/subscription';
import { useCurrentTenant } from '../../../../stores/tenant.store';
import {
useCycleTimeMetrics,
useProcessEfficiencyScore,
useResourceUtilization,
useCostRevenueRatio,
useQualityImpactIndex,
useCriticalBottlenecks,
useDepartmentPerformance,
usePerformanceAlerts,
} from '../../../../api/hooks/performance';
import { TimePeriod } from '../../../../api/types/performance';
// Formatters for StatsGrid
const formatters = {
number: (value: number) => value.toFixed(0),
percentage: (value: number) => `${value.toFixed(1)}%`,
hours: (value: number) => `${value.toFixed(1)}h`,
currency: (value: number) => `${value.toLocaleString('es-ES', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`,
};
const PerformanceAnalyticsPage: React.FC = () => {
const [selectedTimeframe, setSelectedTimeframe] = useState('month');
const [selectedMetric, setSelectedMetric] = useState('efficiency');
const { canAccessAnalytics, subscriptionInfo } = useSubscription();
const currentTenant = useCurrentTenant();
const tenantId = currentTenant?.id || '';
const performanceMetrics = {
overallEfficiency: 87.5,
productionTime: 4.2,
qualityScore: 92.1,
employeeProductivity: 89.3,
customerSatisfaction: 94.7,
resourceUtilization: 78.9,
};
const [selectedPeriod, setSelectedPeriod] = useState<TimePeriod>('week');
const [activeTab, setActiveTab] = useState('overview');
const timeframes = [
// Fetch all cross-functional performance data
const { data: cycleTime, isLoading: cycleTimeLoading } = useCycleTimeMetrics(tenantId, selectedPeriod);
const { data: processScore, isLoading: processScoreLoading } = useProcessEfficiencyScore(tenantId, selectedPeriod);
const { data: resourceUtil, isLoading: resourceUtilLoading } = useResourceUtilization(tenantId, selectedPeriod);
const { data: costRevenue, isLoading: costRevenueLoading } = useCostRevenueRatio(tenantId, selectedPeriod);
const { data: qualityIndex, isLoading: qualityIndexLoading } = useQualityImpactIndex(tenantId, selectedPeriod);
const { data: bottlenecks, isLoading: bottlenecksLoading } = useCriticalBottlenecks(tenantId, selectedPeriod);
const { data: departments, isLoading: departmentsLoading } = useDepartmentPerformance(tenantId, selectedPeriod);
const { data: alerts, isLoading: alertsLoading } = usePerformanceAlerts(tenantId);
// Period options
const timeframes: { value: TimePeriod; label: string }[] = [
{ value: 'day', label: 'Hoy' },
{ value: 'week', label: 'Esta Semana' },
{ value: 'month', label: 'Este Mes' },
@@ -24,380 +82,568 @@ const PerformanceAnalyticsPage: React.FC = () => {
{ value: 'year', label: 'Año' },
];
const departmentPerformance = [
{
department: 'Producción',
efficiency: 91.2,
trend: 5.3,
issues: 2,
employees: 8,
metrics: {
avgBatchTime: '2.3h',
qualityRate: '94%',
wastePercentage: '3.1%'
}
},
{
department: 'Ventas',
efficiency: 88.7,
trend: -1.2,
issues: 1,
employees: 4,
metrics: {
avgServiceTime: '3.2min',
customerWaitTime: '2.1min',
salesPerHour: '€127'
}
},
{
department: 'Inventario',
efficiency: 82.4,
trend: 2.8,
issues: 3,
employees: 2,
metrics: {
stockAccuracy: '96.7%',
turnoverRate: '12.3',
wastageRate: '4.2%'
}
},
{
department: 'Administración',
efficiency: 94.1,
trend: 8.1,
issues: 0,
employees: 3,
metrics: {
responseTime: '1.2h',
taskCompletion: '98%',
documentAccuracy: '99.1%'
}
}
// Tab configuration
const tabs = [
{ id: 'overview', label: 'Vista General' },
{ id: 'efficiency', label: 'Eficiencia Operativa' },
{ id: 'quality', label: 'Impacto de Calidad' },
{ id: 'optimization', label: 'Optimización' },
];
const kpiTrends = [
{
name: 'Eficiencia General',
current: 87.5,
target: 90.0,
previous: 84.2,
unit: '%',
color: 'blue'
},
{
name: 'Tiempo de Producción',
current: 4.2,
target: 4.0,
previous: 4.5,
unit: 'h',
color: 'green',
inverse: true
},
{
name: 'Satisfacción Cliente',
current: 94.7,
target: 95.0,
previous: 93.1,
unit: '%',
color: 'purple'
},
{
name: 'Utilización de Recursos',
current: 78.9,
target: 85.0,
previous: 76.3,
unit: '%',
color: 'orange'
}
];
// Combined loading state
const isLoading =
cycleTimeLoading ||
processScoreLoading ||
resourceUtilLoading ||
costRevenueLoading ||
qualityIndexLoading ||
bottlenecksLoading ||
departmentsLoading ||
alertsLoading;
const performanceAlerts = [
{
id: '1',
type: 'warning',
title: 'Eficiencia de Inventario Baja',
description: 'El departamento de inventario está por debajo del objetivo del 85%',
value: '82.4%',
target: '85%',
department: 'Inventario'
},
{
id: '2',
type: 'info',
title: 'Tiempo de Producción Mejorado',
description: 'El tiempo promedio de producción ha mejorado este mes',
value: '4.2h',
target: '4.0h',
department: 'Producción'
},
{
id: '3',
type: 'success',
title: 'Administración Supera Objetivos',
description: 'El departamento administrativo está funcionando por encima del objetivo',
value: '94.1%',
target: '90%',
department: 'Administración'
}
];
// Show loading state while subscription data is being fetched
if (subscriptionInfo.loading) {
return (
<div className="p-6 space-y-6">
<PageHeader
title="Análisis de Rendimiento"
description="Monitorea métricas transversales que muestran cómo los departamentos trabajan juntos"
/>
<Card className="p-8 text-center">
<div className="flex flex-col items-center gap-4">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[var(--color-primary)]"></div>
<p className="text-[var(--text-secondary)]">Cargando información de suscripción...</p>
</div>
</Card>
</div>
);
}
const productivityData = [
{ hour: '07:00', efficiency: 75, transactions: 12, employees: 3 },
{ hour: '08:00', efficiency: 82, transactions: 18, employees: 5 },
{ hour: '09:00', efficiency: 89, transactions: 28, employees: 6 },
{ hour: '10:00', efficiency: 91, transactions: 32, employees: 7 },
{ hour: '11:00', efficiency: 94, transactions: 38, employees: 8 },
{ hour: '12:00', efficiency: 96, transactions: 45, employees: 8 },
{ hour: '13:00', efficiency: 95, transactions: 42, employees: 8 },
{ hour: '14:00', efficiency: 88, transactions: 35, employees: 7 },
{ hour: '15:00', efficiency: 85, transactions: 28, employees: 6 },
{ hour: '16:00', efficiency: 83, transactions: 25, employees: 5 },
{ hour: '17:00', efficiency: 87, transactions: 31, employees: 6 },
{ hour: '18:00', efficiency: 90, transactions: 38, employees: 7 },
{ hour: '19:00', efficiency: 86, transactions: 29, employees: 5 },
{ hour: '20:00', efficiency: 78, transactions: 18, employees: 3 },
];
// If user doesn't have access to advanced analytics, show upgrade message
if (!canAccessAnalytics('advanced')) {
return (
<div className="p-6 space-y-6">
<PageHeader
title="Análisis de Rendimiento"
description="Monitorea métricas transversales que muestran cómo los departamentos trabajan juntos"
/>
<Card className="p-8 text-center">
<Lock className="mx-auto h-12 w-12 text-[var(--text-tertiary)] mb-4" />
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
Funcionalidad Exclusiva para Profesionales y Empresas
</h3>
<p className="text-[var(--text-secondary)] mb-4">
El análisis de rendimiento avanzado está disponible solo para planes Professional y Enterprise.
Actualiza tu plan para acceder a métricas transversales de rendimiento, análisis de procesos integrados y optimización operativa.
</p>
<Button
variant="primary"
size="md"
onClick={() => (window.location.hash = '#/app/settings/profile')}
>
Actualizar Plan
</Button>
</Card>
</div>
);
}
const getTrendIcon = (trend: number) => {
if (trend > 0) {
// Helper functions
const getTrendIcon = (trend: 'up' | 'down' | 'stable') => {
if (trend === 'up') {
return <TrendingUp className="w-4 h-4 text-[var(--color-success)]" />;
} else {
} else if (trend === 'down') {
return <TrendingUp className="w-4 h-4 text-[var(--color-error)] transform rotate-180" />;
}
return <Activity className="w-4 h-4 text-gray-500" />;
};
const getTrendColor = (trend: number) => {
return trend >= 0 ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]';
};
const getPerformanceColor = (value: number, target: number, inverse = false) => {
const comparison = inverse ? value < target : value >= target;
return comparison ? 'text-[var(--color-success)]' : value >= target * 0.9 ? 'text-yellow-600' : 'text-[var(--color-error)]';
};
const getAlertIcon = (type: string) => {
const getAlertIcon = (type: 'warning' | 'critical' | 'info') => {
switch (type) {
case 'critical':
return <AlertCircle className="w-5 h-5 text-[var(--color-error)]" />;
case 'warning':
return <AlertCircle className="w-5 h-5 text-yellow-600" />;
case 'success':
return <TrendingUp className="w-5 h-5 text-[var(--color-success)]" />;
default:
return <Activity className="w-5 h-5 text-[var(--color-info)]" />;
}
};
const getAlertColor = (type: string) => {
const getAlertColor = (type: 'warning' | 'critical' | 'info') => {
switch (type) {
case 'critical':
return 'bg-red-50 border-red-200';
case 'warning':
return 'bg-yellow-50 border-yellow-200';
case 'success':
return 'bg-green-50 border-green-200';
default:
return 'bg-[var(--color-info)]/5 border-[var(--color-info)]/20';
}
};
// Prepare StatsGrid data (6 cross-functional metrics) - NO MEMOIZATION to avoid loops
const statsData = [
{
title: 'Tiempo de Ciclo',
value: cycleTime?.average_cycle_time || 0,
icon: Clock,
formatter: formatters.hours,
},
{
title: 'Eficiencia de Procesos',
value: processScore?.overall_score || 0,
icon: Zap,
formatter: formatters.percentage,
},
{
title: 'Utilización de Recursos',
value: resourceUtil?.overall_utilization || 0,
icon: Package,
formatter: formatters.percentage,
},
{
title: 'Ratio Costo-Ingreso',
value: costRevenue?.cost_revenue_ratio || 0,
icon: DollarSign,
formatter: formatters.percentage,
},
{
title: 'Índice de Calidad',
value: qualityIndex?.overall_quality_index || 0,
icon: Target,
formatter: formatters.percentage,
},
{
title: 'Cuellos de Botella Críticos',
value: bottlenecks?.critical_count || 0,
icon: AlertOctagon,
formatter: formatters.number,
},
];
return (
<div className="p-6 space-y-6">
{/* Page Header */}
<PageHeader
title="Análisis de Rendimiento"
description="Monitorea la eficiencia operativa y el rendimiento de todos los departamentos"
action={
<div className="flex space-x-2">
<Button variant="outline">
<Calendar className="w-4 h-4 mr-2" />
Configurar Alertas
</Button>
<Button variant="outline">
<Download className="w-4 h-4 mr-2" />
Exportar Reporte
</Button>
</div>
}
description="Monitorea métricas transversales que muestran cómo los departamentos trabajan juntos"
actions={[
{
id: 'configure-alerts',
label: 'Configurar Alertas',
icon: Calendar,
onClick: () => {},
variant: 'outline',
disabled: true,
},
{
id: 'export-report',
label: 'Exportar Reporte',
icon: Download,
onClick: () => {},
variant: 'outline',
disabled: true,
},
]}
/>
{/* Controls */}
<Card className="p-6">
<div className="flex flex-col sm:flex-row gap-4">
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Período</label>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
Período
</label>
<select
value={selectedTimeframe}
onChange={(e) => setSelectedTimeframe(e.target.value)}
className="px-3 py-2 border border-[var(--border-secondary)] rounded-md"
value={selectedPeriod}
onChange={(e) => setSelectedPeriod(e.target.value as TimePeriod)}
className="px-3 py-2 border border-[var(--border-secondary)] rounded-md bg-white"
>
{timeframes.map(timeframe => (
<option key={timeframe.value} value={timeframe.value}>{timeframe.label}</option>
{timeframes.map((timeframe) => (
<option key={timeframe.value} value={timeframe.value}>
{timeframe.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Métrica Principal</label>
<select
value={selectedMetric}
onChange={(e) => setSelectedMetric(e.target.value)}
className="px-3 py-2 border border-[var(--border-secondary)] rounded-md"
>
<option value="efficiency">Eficiencia</option>
<option value="productivity">Productividad</option>
<option value="quality">Calidad</option>
<option value="satisfaction">Satisfacción</option>
</select>
</div>
</div>
</Card>
{/* KPI Overview */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{kpiTrends.map((kpi) => (
<Card key={kpi.name} className="p-6">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium text-[var(--text-secondary)]">{kpi.name}</h3>
<div className={`w-3 h-3 rounded-full bg-${kpi.color}-500`}></div>
</div>
<div className="flex items-end justify-between">
<div>
<p className={`text-2xl font-bold ${getPerformanceColor(kpi.current, kpi.target, kpi.inverse)}`}>
{kpi.current}{kpi.unit}
</p>
<p className="text-xs text-[var(--text-tertiary)]">
Objetivo: {kpi.target}{kpi.unit}
</p>
</div>
<div className="text-right">
<div className="flex items-center">
{getTrendIcon(kpi.current - kpi.previous)}
<span className={`text-sm ml-1 ${getTrendColor(kpi.current - kpi.previous)}`}>
{Math.abs(kpi.current - kpi.previous).toFixed(1)}{kpi.unit}
</span>
</div>
</div>
</div>
<div className="mt-3 w-full bg-[var(--bg-quaternary)] rounded-full h-2">
<div
className={`bg-${kpi.color}-500 h-2 rounded-full transition-all duration-300`}
style={{ width: `${Math.min((kpi.current / kpi.target) * 100, 100)}%` }}
></div>
</div>
</Card>
))}
</div>
{/* Block 1: StatsGrid with 6 cross-functional metrics */}
<StatsGrid stats={statsData} loading={isLoading} />
{/* Performance Alerts */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Alertas de Rendimiento</h3>
<div className="space-y-3">
{performanceAlerts.map((alert) => (
<div key={alert.id} className={`p-4 rounded-lg border ${getAlertColor(alert.type)}`}>
<div className="flex items-start space-x-3">
{getAlertIcon(alert.type)}
<div className="flex-1">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-[var(--text-primary)]">{alert.title}</h4>
<Badge variant="gray">{alert.department}</Badge>
</div>
<p className="text-sm text-[var(--text-secondary)] mt-1">{alert.description}</p>
<div className="flex items-center space-x-4 mt-2">
<span className="text-sm">
<strong>Actual:</strong> {alert.value}
</span>
<span className="text-sm">
<strong>Objetivo:</strong> {alert.target}
</span>
</div>
</div>
</div>
</div>
))}
</div>
</Card>
{/* Block 2: Tabs */}
<Tabs items={tabs} activeTab={activeTab} onTabChange={setActiveTab} />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Department Performance */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Rendimiento por Departamento</h3>
<div className="space-y-4">
{departmentPerformance.map((dept) => (
<div key={dept.department} className="border rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center space-x-3">
<h4 className="font-medium text-[var(--text-primary)]">{dept.department}</h4>
<Badge variant="gray">{dept.employees} empleados</Badge>
</div>
<div className="flex items-center space-x-2">
<span className="text-lg font-semibold text-[var(--text-primary)]">
{dept.efficiency}%
</span>
<div className="flex items-center">
{getTrendIcon(dept.trend)}
<span className={`text-sm ml-1 ${getTrendColor(dept.trend)}`}>
{Math.abs(dept.trend).toFixed(1)}%
</span>
</div>
</div>
</div>
<div className="grid grid-cols-3 gap-4 text-sm">
{Object.entries(dept.metrics).map(([key, value]) => (
<div key={key}>
<p className="text-[var(--text-tertiary)] text-xs">
{key.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase())}
</p>
<p className="font-medium">{value}</p>
{/* Block 3: Tab Content */}
<div className="space-y-6">
{/* Vista General Tab */}
{activeTab === 'overview' && !isLoading && (
<>
{/* Department Comparison Matrix */}
{departments && departments.length > 0 && (
<Card className="p-6">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">
Comparación de Departamentos
</h3>
<div className="space-y-4">
{departments.map((dept) => (
<div key={dept.department_id} className="border rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<h4 className="font-medium text-[var(--text-primary)]">
{dept.department_name}
</h4>
<div className="flex items-center space-x-2">
<span className="text-lg font-semibold text-[var(--text-primary)]">
{dept.efficiency.toFixed(1)}%
</span>
{getTrendIcon(dept.trend)}
</div>
</div>
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<p className="text-[var(--text-tertiary)] text-xs">
{dept.metrics.primary_metric.label}
</p>
<p className="font-medium">
{dept.metrics.primary_metric.value.toFixed(1)}
{dept.metrics.primary_metric.unit}
</p>
</div>
<div>
<p className="text-[var(--text-tertiary)] text-xs">
{dept.metrics.secondary_metric.label}
</p>
<p className="font-medium">
{dept.metrics.secondary_metric.value.toFixed(1)}
{dept.metrics.secondary_metric.unit}
</p>
</div>
<div>
<p className="text-[var(--text-tertiary)] text-xs">
{dept.metrics.tertiary_metric.label}
</p>
<p className="font-medium">
{dept.metrics.tertiary_metric.value.toFixed(1)}
{dept.metrics.tertiary_metric.unit}
</p>
</div>
</div>
</div>
))}
</div>
</Card>
)}
{dept.issues > 0 && (
<div className="mt-3 flex items-center text-sm text-[var(--color-warning)]">
<AlertCircle className="w-4 h-4 mr-1" />
{dept.issues} problema{dept.issues > 1 ? 's' : ''} detectado{dept.issues > 1 ? 's' : ''}
{/* Process Efficiency Breakdown */}
{processScore && (
<Card className="p-6">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">
Desglose de Eficiencia por Procesos
</h3>
<ResponsiveContainer width="100%" height={300}>
<BarChart
data={[
{ name: 'Producción', value: processScore.production_efficiency, weight: processScore.breakdown.production.weight },
{ name: 'Inventario', value: processScore.inventory_efficiency, weight: processScore.breakdown.inventory.weight },
{ name: 'Compras', value: processScore.procurement_efficiency, weight: processScore.breakdown.procurement.weight },
{ name: 'Pedidos', value: processScore.order_efficiency, weight: processScore.breakdown.orders.weight },
]}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey="value" fill="var(--color-primary)" name="Eficiencia (%)" />
<Bar dataKey="weight" fill="var(--color-secondary)" name="Peso (%)" />
</BarChart>
</ResponsiveContainer>
</Card>
)}
</>
)}
{/* Eficiencia Operativa Tab */}
{activeTab === 'efficiency' && !isLoading && (
<>
{/* Cycle Time Breakdown */}
{cycleTime && (
<Card className="p-6">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">
Análisis de Tiempo de Ciclo
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
<p className="text-sm text-[var(--text-secondary)] mb-2">Pedido Producción</p>
<p className="text-2xl font-bold text-[var(--text-primary)]">
{cycleTime.order_to_production_time.toFixed(1)}h
</p>
</div>
)}
</div>
))}
</div>
</Card>
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
<p className="text-sm text-[var(--text-secondary)] mb-2">Tiempo de Producción</p>
<p className="text-2xl font-bold text-[var(--text-primary)]">
{cycleTime.production_time.toFixed(1)}h
</p>
</div>
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
<p className="text-sm text-[var(--text-secondary)] mb-2">Producción Entrega</p>
<p className="text-2xl font-bold text-[var(--text-primary)]">
{cycleTime.production_to_delivery_time.toFixed(1)}h
</p>
</div>
</div>
<div className="flex items-center justify-between p-4 bg-blue-50 rounded-lg">
<span className="text-sm font-medium">Tiempo Total de Ciclo</span>
<span className="text-xl font-bold text-[var(--color-primary)]">
{cycleTime.average_cycle_time.toFixed(1)}h
</span>
</div>
</Card>
)}
{/* Hourly Productivity */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Eficiencia por Hora</h3>
<div className="h-64 flex items-end space-x-1 justify-between">
{productivityData.map((data, index) => (
<div key={index} className="flex flex-col items-center flex-1">
<div className="text-xs text-[var(--text-secondary)] mb-1">{data.efficiency}%</div>
<div
className="w-full bg-[var(--color-info)]/50 rounded-t"
style={{
height: `${(data.efficiency / 100) * 200}px`,
minHeight: '8px',
backgroundColor: data.efficiency >= 90 ? '#10B981' : data.efficiency >= 80 ? '#F59E0B' : '#EF4444'
}}
></div>
<span className="text-xs text-[var(--text-tertiary)] mt-2 transform -rotate-45 origin-center">
{data.hour}
</span>
</div>
))}
</div>
<div className="mt-4 flex justify-center space-x-6 text-xs">
<div className="flex items-center">
<div className="w-3 h-3 bg-green-500 rounded mr-1"></div>
<span>90% Excelente</span>
</div>
<div className="flex items-center">
<div className="w-3 h-3 bg-yellow-500 rounded mr-1"></div>
<span>80-89% Bueno</span>
</div>
<div className="flex items-center">
<div className="w-3 h-3 bg-red-500 rounded mr-1"></div>
<span>&lt;80% Bajo</span>
</div>
</div>
</Card>
{/* Bottlenecks Analysis */}
{bottlenecks && bottlenecks.bottlenecks.length > 0 && (
<Card className="p-6">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">
Cuellos de Botella Detectados
</h3>
<div className="space-y-3">
{bottlenecks.bottlenecks.map((bottleneck, index) => (
<div
key={index}
className={`p-4 rounded-lg border ${
bottleneck.severity === 'high'
? 'bg-red-50 border-red-200'
: 'bg-yellow-50 border-yellow-200'
}`}
>
<div className="flex items-start space-x-3">
<AlertCircle
className={`w-5 h-5 ${
bottleneck.severity === 'high' ? 'text-red-600' : 'text-yellow-600'
}`}
/>
<div className="flex-1">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-[var(--text-primary)]">
{bottleneck.description}
</h4>
<Badge variant={bottleneck.severity === 'high' ? 'destructive' : 'default'}>
{bottleneck.area}
</Badge>
</div>
<p className="text-sm text-[var(--text-secondary)] mt-1">
{bottleneck.metric}: {bottleneck.value.toFixed(1)}
</p>
</div>
</div>
</div>
))}
</div>
</Card>
)}
{/* Resource Utilization */}
{resourceUtil && (
<Card className="p-6">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">
Utilización de Recursos
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
<p className="text-sm text-[var(--text-secondary)] mb-2">Equipamiento</p>
<p className="text-2xl font-bold text-[var(--text-primary)]">
{resourceUtil.equipment_utilization.toFixed(1)}%
</p>
</div>
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
<p className="text-sm text-[var(--text-secondary)] mb-2">Inventario</p>
<p className="text-2xl font-bold text-[var(--text-primary)]">
{resourceUtil.inventory_utilization.toFixed(1)}%
</p>
</div>
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
<p className="text-sm text-[var(--text-secondary)] mb-2">Balance</p>
<p className="text-lg font-bold text-[var(--text-primary)]">
{resourceUtil.resource_balance === 'balanced' ? 'Equilibrado' : 'Desbalanceado'}
</p>
</div>
</div>
</Card>
)}
</>
)}
{/* Impacto de Calidad Tab */}
{activeTab === 'quality' && !isLoading && (
<>
{/* Quality Index Overview */}
{qualityIndex && (
<Card className="p-6">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">
Índice de Calidad General
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<div className="text-center p-6 bg-[var(--bg-secondary)] rounded-lg">
<p className="text-sm text-[var(--text-secondary)] mb-2">Calidad de Producción</p>
<p className="text-3xl font-bold text-[var(--text-primary)]">
{qualityIndex.production_quality.toFixed(1)}%
</p>
</div>
<div className="text-center p-6 bg-[var(--bg-secondary)] rounded-lg">
<p className="text-sm text-[var(--text-secondary)] mb-2">Calidad de Inventario</p>
<p className="text-3xl font-bold text-[var(--text-primary)]">
{qualityIndex.inventory_quality.toFixed(1)}%
</p>
</div>
</div>
<div className="flex items-center justify-between p-4 bg-green-50 rounded-lg">
<span className="text-sm font-medium">Índice de Calidad Combinado</span>
<span className="text-2xl font-bold text-green-600">
{qualityIndex.overall_quality_index.toFixed(1)}%
</span>
</div>
</Card>
)}
{/* Quality Issues Breakdown */}
{qualityIndex && (
<Card className="p-6">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">
Desglose de Problemas de Calidad
</h3>
<div className="space-y-4">
<div className="flex items-center justify-between p-3 bg-[var(--bg-secondary)] rounded-lg">
<span className="text-sm font-medium">Defectos de Producción</span>
<span className="text-lg font-semibold text-red-600">
{qualityIndex.quality_issues.production_defects.toFixed(1)}%
</span>
</div>
<div className="flex items-center justify-between p-3 bg-[var(--bg-secondary)] rounded-lg">
<span className="text-sm font-medium">Desperdicio</span>
<span className="text-lg font-semibold text-orange-600">
{qualityIndex.quality_issues.waste_percentage.toFixed(1)}%
</span>
</div>
<div className="flex items-center justify-between p-3 bg-[var(--bg-secondary)] rounded-lg">
<span className="text-sm font-medium">Items por Vencer</span>
<span className="text-lg font-semibold text-yellow-600">
{qualityIndex.quality_issues.expiring_items}
</span>
</div>
<div className="flex items-center justify-between p-3 bg-[var(--bg-secondary)] rounded-lg">
<span className="text-sm font-medium">Stock Bajo Afectando Calidad</span>
<span className="text-lg font-semibold text-yellow-600">
{qualityIndex.quality_issues.low_stock_affecting_quality}
</span>
</div>
</div>
</Card>
)}
</>
)}
{/* Optimización Tab */}
{activeTab === 'optimization' && !isLoading && (
<>
{/* Cost-Revenue Analysis */}
{costRevenue && (
<Card className="p-6">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">
Análisis de Rentabilidad
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<div className="text-center p-6 bg-[var(--bg-secondary)] rounded-lg">
<p className="text-sm text-[var(--text-secondary)] mb-2">Ingresos Totales</p>
<p className="text-3xl font-bold text-green-600">
{costRevenue.total_revenue.toLocaleString('es-ES')}
</p>
</div>
<div className="text-center p-6 bg-[var(--bg-secondary)] rounded-lg">
<p className="text-sm text-[var(--text-secondary)] mb-2">Costos Estimados</p>
<p className="text-3xl font-bold text-red-600">
{costRevenue.estimated_costs.toLocaleString('es-ES')}
</p>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between p-4 bg-blue-50 rounded-lg">
<span className="text-sm font-medium">Ratio Costo-Ingreso</span>
<span className="text-xl font-bold text-[var(--color-primary)]">
{costRevenue.cost_revenue_ratio.toFixed(1)}%
</span>
</div>
<div className="flex items-center justify-between p-4 bg-green-50 rounded-lg">
<span className="text-sm font-medium">Margen de Beneficio</span>
<span className="text-xl font-bold text-green-600">
{costRevenue.profit_margin.toFixed(1)}%
</span>
</div>
</div>
</Card>
)}
{/* Improvement Recommendations */}
{bottlenecks && bottlenecks.total_bottlenecks > 0 && (
<Card className="p-6">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">
Recomendaciones de Mejora
</h3>
<div className="space-y-4">
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
<h4 className="font-medium text-[var(--text-primary)] mb-2">
Área más crítica: {bottlenecks.most_critical_area}
</h4>
<p className="text-sm text-[var(--text-secondary)]">
Se han detectado {bottlenecks.critical_count} cuellos de botella críticos.
Prioriza la optimización de esta área para mejorar el flujo general.
</p>
</div>
{qualityIndex && qualityIndex.waste_impact > 5 && (
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<h4 className="font-medium text-[var(--text-primary)] mb-2">
Reducir Desperdicio
</h4>
<p className="text-sm text-[var(--text-secondary)]">
El desperdicio actual es de {qualityIndex.waste_impact.toFixed(1)}%.
Implementa controles de calidad más estrictos para reducir pérdidas.
</p>
</div>
)}
{resourceUtil && resourceUtil.resource_balance === 'imbalanced' && (
<div className="p-4 bg-orange-50 border border-orange-200 rounded-lg">
<h4 className="font-medium text-[var(--text-primary)] mb-2">
Balance de Recursos
</h4>
<p className="text-sm text-[var(--text-secondary)]">
Los recursos están desbalanceados entre departamentos.
Considera redistribuir para optimizar la utilización general.
</p>
</div>
)}
</div>
</Card>
)}
{/* No Recommendations */}
{(!bottlenecks || bottlenecks.total_bottlenecks === 0) && (
<Card className="p-6">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">
Recomendaciones de Mejora
</h3>
<div className="text-center py-8">
<Target className="w-12 h-12 mx-auto text-[var(--color-success)] mb-3" />
<p className="text-[var(--text-secondary)]">
¡Excelente! No se han detectado áreas críticas que requieran optimización inmediata.
</p>
</div>
</Card>
)}
</>
)}
</div>
</div>
);
};
export default PerformanceAnalyticsPage;
export default PerformanceAnalyticsPage;

View File

@@ -5,7 +5,7 @@
* Allows users to test different scenarios and see potential impacts on demand
*/
import React, { useState } from 'react';
import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useTenantStore } from '../../../../stores';
import { forecastingService } from '../../../../api/services/forecasting';
@@ -39,8 +39,11 @@ import {
ArrowDownRight,
Play,
Sparkles,
Package,
} from 'lucide-react';
import { PageHeader } from '../../../../components/layout';
import { useIngredients } from '../../../../api/hooks/inventory';
import { useModels } from '../../../../api/hooks/training';
export const ScenarioSimulationPage: React.FC = () => {
const { t } = useTranslation();
@@ -57,6 +60,43 @@ export const ScenarioSimulationPage: React.FC = () => {
const [durationDays, setDurationDays] = useState(7);
const [selectedProducts, setSelectedProducts] = useState<string[]>([]);
// Fetch real inventory data
const {
data: ingredientsData,
isLoading: ingredientsLoading,
} = useIngredients(currentTenant?.id || '');
// Fetch trained models to filter products
const {
data: modelsData,
isLoading: modelsLoading,
} = useModels(currentTenant?.id || '', { active_only: true });
// Build products list from ingredients that have trained models
const availableProducts = useMemo(() => {
if (!ingredientsData || !modelsData) {
return [];
}
// Handle both array and paginated response formats
const modelsList = Array.isArray(modelsData) ? modelsData : (modelsData.models || modelsData.items || []);
// Get inventory product IDs that have trained models
const modelProductIds = new Set(modelsList.map((model: any) => model.inventory_product_id));
// Filter ingredients to only those with models
const ingredientsWithModels = ingredientsData.filter(ingredient =>
modelProductIds.has(ingredient.id)
);
return ingredientsWithModels.map(ingredient => ({
id: ingredient.id,
name: ingredient.name,
category: ingredient.category || 'Other',
hasModel: true
}));
}, [ingredientsData, modelsData]);
// Scenario-specific parameters
const [weatherParams, setWeatherParams] = useState<WeatherScenario>({
temperature_change: 15,
@@ -81,6 +121,16 @@ export const ScenarioSimulationPage: React.FC = () => {
promotion_type: 'discount',
expected_traffic_increase: 0.3,
});
const [holidayParams, setHolidayParams] = useState({
holiday_name: 'christmas',
expected_impact_multiplier: 1.5,
});
const [supplyDisruptionParams, setSupplyDisruptionParams] = useState({
severity: 'moderate',
affected_percentage: 30,
duration_days: 7,
});
const [customParams, setCustomParams] = useState<Record<string, number>>({});
const handleSimulate = async () => {
if (!currentTenant?.id) return;
@@ -119,6 +169,15 @@ export const ScenarioSimulationPage: React.FC = () => {
case ScenarioType.PROMOTION:
request.promotion_params = promotionParams;
break;
case ScenarioType.HOLIDAY:
request.custom_multipliers = { holiday_multiplier: holidayParams.expected_impact_multiplier };
break;
case ScenarioType.SUPPLY_DISRUPTION:
request.custom_multipliers = { disruption_severity: supplyDisruptionParams.affected_percentage / 100 };
break;
case ScenarioType.CUSTOM:
request.custom_multipliers = customParams;
break;
}
const result = await forecastingService.simulateScenario(currentTenant.id, request);
@@ -201,7 +260,7 @@ export const ScenarioSimulationPage: React.FC = () => {
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"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
@@ -215,7 +274,7 @@ export const ScenarioSimulationPage: React.FC = () => {
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="w-full px-3 py-2 border rounded-lg"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
min={new Date().toISOString().split('T')[0]}
/>
</div>
@@ -229,10 +288,92 @@ export const ScenarioSimulationPage: React.FC = () => {
onChange={(e) => setDurationDays(parseInt(e.target.value) || 7)}
min={1}
max={30}
className="w-full px-3 py-2 border rounded-lg"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
</div>
{/* Product Selection */}
<div className="mt-4 space-y-3">
<div className="flex items-center justify-between">
<label className="text-sm font-medium flex items-center gap-2">
<Package className="w-4 h-4" />
{t('analytics.scenario_simulation.select_products', 'Select Products to Simulate')}
</label>
<span className="text-xs text-gray-500">
{selectedProducts.length} selected
</span>
</div>
{ingredientsLoading || modelsLoading ? (
<div className="p-4 border rounded-lg text-center text-sm text-gray-500">
Loading products...
</div>
) : availableProducts.length === 0 ? (
<div className="p-4 border border-amber-200 bg-amber-50 rounded-lg">
<div className="flex items-start gap-2">
<AlertTriangle className="w-4 h-4 text-amber-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-amber-800">
<p className="font-medium">No products available for simulation</p>
<p className="mt-1">You need to train ML models for your products first. Visit the Training section to get started.</p>
</div>
</div>
</div>
) : (
<>
{/* Quick Select All/None */}
<div className="flex gap-2">
<button
onClick={() => setSelectedProducts(availableProducts.map(p => p.id))}
className="text-xs text-blue-600 hover:text-blue-700 font-medium"
>
Select All
</button>
<span className="text-xs text-gray-300">|</span>
<button
onClick={() => setSelectedProducts([])}
className="text-xs text-gray-600 hover:text-gray-700 font-medium"
>
Clear All
</button>
</div>
{/* Product Grid */}
<div className="max-h-64 overflow-y-auto border border-gray-300 dark:border-gray-600 rounded-lg p-3 space-y-2 bg-gray-50 dark:bg-gray-800">
{availableProducts.map((product) => (
<label
key={product.id}
className={`flex items-center gap-3 p-3 border rounded-lg cursor-pointer transition-all ${
selectedProducts.includes(product.id)
? 'border-blue-500 dark:border-blue-400 bg-blue-50 dark:bg-blue-900/30'
: 'border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-700 hover:border-gray-300 dark:hover:border-gray-500'
}`}
>
<input
type="checkbox"
checked={selectedProducts.includes(product.id)}
onChange={(e) => {
if (e.target.checked) {
setSelectedProducts([...selectedProducts, product.id]);
} else {
setSelectedProducts(selectedProducts.filter(id => id !== product.id));
}
}}
className="w-4 h-4 text-blue-600 border-gray-300 dark:border-gray-500 rounded focus:ring-blue-500 dark:bg-gray-600"
/>
<div className="flex-1">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">{product.name}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{product.category}</div>
</div>
<Badge variant="success" className="text-xs">
ML Ready
</Badge>
</label>
))}
</div>
</>
)}
</div>
</div>
<div>
@@ -274,7 +415,7 @@ export const ScenarioSimulationPage: React.FC = () => {
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"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg mt-1 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
min={-30}
max={30}
/>
@@ -284,7 +425,7 @@ export const ScenarioSimulationPage: React.FC = () => {
<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"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg mt-1 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="heatwave">Heatwave</option>
<option value="cold_snap">Cold Snap</option>
@@ -303,7 +444,7 @@ export const ScenarioSimulationPage: React.FC = () => {
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"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg mt-1 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
min={1}
max={10}
/>
@@ -315,7 +456,7 @@ export const ScenarioSimulationPage: React.FC = () => {
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"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg mt-1 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
min={0.1}
max={10}
/>
@@ -326,7 +467,7 @@ export const ScenarioSimulationPage: React.FC = () => {
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"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg mt-1 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
min={0}
max={50}
/>
@@ -342,10 +483,24 @@ export const ScenarioSimulationPage: React.FC = () => {
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"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg mt-1 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
min={0}
max={75}
/>
<p className="text-xs text-gray-500 mt-1">Typical range: 10-30%</p>
</div>
<div>
<label className="text-sm">Promotion Type</label>
<select
value={promotionParams.promotion_type}
onChange={(e) => setPromotionParams({ ...promotionParams, promotion_type: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg mt-1 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="discount">Discount</option>
<option value="bogo">Buy One Get One</option>
<option value="bundle">Bundle Deal</option>
<option value="flash_sale">Flash Sale</option>
</select>
</div>
<div>
<label className="text-sm">Expected Traffic Increase (%)</label>
@@ -353,10 +508,208 @@ export const ScenarioSimulationPage: React.FC = () => {
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"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg mt-1 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
min={0}
max={200}
/>
<p className="text-xs text-gray-500 mt-1">Most promotions see 20-50% increase</p>
</div>
</div>
)}
{selectedScenarioType === ScenarioType.EVENT && (
<div className="space-y-3">
<div>
<label className="text-sm">Event Type</label>
<select
value={eventParams.event_type}
onChange={(e) => setEventParams({ ...eventParams, event_type: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg mt-1 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="festival">Festival</option>
<option value="sports">Sports Event</option>
<option value="concert">Concert</option>
<option value="conference">Conference</option>
<option value="market">Street Market</option>
</select>
</div>
<div>
<label className="text-sm">Expected Attendance</label>
<input
type="number"
value={eventParams.expected_attendance}
onChange={(e) => setEventParams({ ...eventParams, expected_attendance: parseInt(e.target.value) || 0 })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg mt-1 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
min={100}
step={100}
/>
<p className="text-xs text-gray-500 mt-1">Number of people expected</p>
</div>
<div>
<label className="text-sm">Distance from Location (km)</label>
<input
type="number"
step="0.1"
value={eventParams.distance_km}
onChange={(e) => setEventParams({ ...eventParams, distance_km: parseFloat(e.target.value) })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg mt-1 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
min={0}
max={50}
/>
<p className="text-xs text-gray-500 mt-1">Closer events have bigger impact</p>
</div>
<div>
<label className="text-sm">Event Duration (days)</label>
<input
type="number"
value={eventParams.duration_days}
onChange={(e) => setEventParams({ ...eventParams, duration_days: parseInt(e.target.value) || 1 })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg mt-1 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
min={1}
max={30}
/>
</div>
</div>
)}
{selectedScenarioType === ScenarioType.PRICING && (
<div className="space-y-3">
<div>
<label className="text-sm">Price Change (%)</label>
<input
type="number"
value={pricingParams.price_change_percent}
onChange={(e) => setPricingParams({ ...pricingParams, price_change_percent: parseFloat(e.target.value) })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg mt-1 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
min={-50}
max={100}
/>
<p className="text-xs text-gray-500 mt-1">
Negative for price decrease, positive for increase
</p>
</div>
<div className="p-3 bg-blue-50 border border-blue-200 rounded-lg">
<div className="text-xs text-blue-800">
<p className="font-medium">💡 Pricing Impact Guide:</p>
<ul className="mt-1 space-y-1 ml-4 list-disc">
<li>-10% price: Usually +8-12% demand</li>
<li>+10% price: Usually -8-12% demand</li>
<li>Impact varies by product type and competition</li>
</ul>
</div>
</div>
</div>
)}
{selectedScenarioType === ScenarioType.HOLIDAY && (
<div className="space-y-3">
<div>
<label className="text-sm">Holiday Period</label>
<select
value={holidayParams.holiday_name}
onChange={(e) => setHolidayParams({ ...holidayParams, holiday_name: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg mt-1 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="christmas">Christmas</option>
<option value="new_year">New Year</option>
<option value="easter">Easter</option>
<option value="valentines">Valentine's Day</option>
<option value="mothers_day">Mother's Day</option>
<option value="thanksgiving">Thanksgiving</option>
<option value="halloween">Halloween</option>
</select>
</div>
<div>
<label className="text-sm">Expected Impact Multiplier</label>
<input
type="number"
step="0.1"
value={holidayParams.expected_impact_multiplier}
onChange={(e) => setHolidayParams({ ...holidayParams, expected_impact_multiplier: parseFloat(e.target.value) })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg mt-1 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
min={0.5}
max={3}
/>
<p className="text-xs text-gray-500 mt-1">
1.0 = no change, 1.5 = 50% increase, 0.8 = 20% decrease
</p>
</div>
</div>
)}
{selectedScenarioType === ScenarioType.SUPPLY_DISRUPTION && (
<div className="space-y-3">
<div>
<label className="text-sm">Disruption Severity</label>
<select
value={supplyDisruptionParams.severity}
onChange={(e) => setSupplyDisruptionParams({ ...supplyDisruptionParams, severity: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg mt-1 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="minor">Minor (10-20% affected)</option>
<option value="moderate">Moderate (20-40% affected)</option>
<option value="major">Major (40-60% affected)</option>
<option value="severe">Severe (60%+ affected)</option>
</select>
</div>
<div>
<label className="text-sm">Affected Supply (%)</label>
<input
type="number"
value={supplyDisruptionParams.affected_percentage}
onChange={(e) => setSupplyDisruptionParams({ ...supplyDisruptionParams, affected_percentage: parseInt(e.target.value) || 0 })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg mt-1 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
min={0}
max={100}
/>
<p className="text-xs text-gray-500 mt-1">
Percentage of normal supply that will be unavailable
</p>
</div>
<div>
<label className="text-sm">Expected Duration (days)</label>
<input
type="number"
value={supplyDisruptionParams.duration_days}
onChange={(e) => setSupplyDisruptionParams({ ...supplyDisruptionParams, duration_days: parseInt(e.target.value) || 1 })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg mt-1 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
min={1}
max={30}
/>
</div>
</div>
)}
{selectedScenarioType === ScenarioType.CUSTOM && (
<div className="space-y-3">
<div className="p-3 bg-purple-50 border border-purple-200 rounded-lg">
<p className="text-sm text-purple-800 font-medium">Custom Scenario</p>
<p className="text-xs text-purple-700 mt-1">
Define your own demand multiplier for unique situations
</p>
</div>
<div>
<label className="text-sm">Demand Multiplier</label>
<input
type="number"
step="0.1"
value={customParams.custom_multiplier || 1.0}
onChange={(e) => setCustomParams({ custom_multiplier: parseFloat(e.target.value) })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg mt-1 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
min={0}
max={5}
/>
<p className="text-xs text-gray-500 mt-1">
1.0 = no change, 1.5 = 50% increase, 0.7 = 30% decrease
</p>
</div>
<div>
<label className="text-sm">Scenario Description (optional)</label>
<textarea
placeholder="Describe what you're simulating..."
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg mt-1 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
rows={3}
/>
</div>
</div>
)}
@@ -377,43 +730,138 @@ export const ScenarioSimulationPage: React.FC = () => {
{/* 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 className="text-sm font-semibold mb-4 flex items-center gap-2">
<Lightbulb className="w-5 h-5 text-yellow-500" />
{t('analytics.scenario_simulation.quick_examples', 'Quick Start Templates')}
</h3>
<p className="text-xs text-gray-600 mb-4">
Click any template to pre-fill the scenario parameters
</p>
<div className="space-y-2">
<button
onClick={() => {
setSelectedScenarioType(ScenarioType.WEATHER);
setScenarioName('Summer Heatwave Next Week');
setScenarioName('Summer Heatwave Impact');
setWeatherParams({ temperature_change: 15, weather_type: 'heatwave' });
setDurationDays(7);
}}
className="w-full text-left px-3 py-2 border rounded-lg hover:bg-gray-50 text-sm"
className="w-full text-left px-4 py-3 border-2 border-orange-200 bg-orange-50 rounded-lg hover:bg-orange-100 hover:border-orange-300 transition-all group"
>
<Sun className="w-4 h-4 inline mr-2" />
What if a heatwave hits next week?
<div className="flex items-start gap-3">
<Sun className="w-5 h-5 text-orange-600 mt-0.5" />
<div className="flex-1">
<div className="text-sm font-medium text-gray-900">Summer Heatwave</div>
<div className="text-xs text-gray-600 mt-1">
+15°C temperature spike - See impact on cold drinks & ice cream
</div>
</div>
<Badge variant="warning" className="text-xs">+20-40%</Badge>
</div>
</button>
<button
onClick={() => {
setSelectedScenarioType(ScenarioType.COMPETITION);
setScenarioName('New Bakery Opening Nearby');
setScenarioName('New Competitor Opening');
setCompetitionParams({ new_competitors: 1, distance_km: 0.3, estimated_market_share_loss: 0.2 });
setDurationDays(30);
}}
className="w-full text-left px-3 py-2 border rounded-lg hover:bg-gray-50 text-sm"
className="w-full text-left px-4 py-3 border-2 border-red-200 bg-red-50 rounded-lg hover:bg-red-100 hover:border-red-300 transition-all"
>
<Users className="w-4 h-4 inline mr-2" />
How would a new competitor affect sales?
<div className="flex items-start gap-3">
<Users className="w-5 h-5 text-red-600 mt-0.5" />
<div className="flex-1">
<div className="text-sm font-medium text-gray-900">New Nearby Competitor</div>
<div className="text-xs text-gray-600 mt-1">
Bakery opening 300m away - Est. 20% market share loss
</div>
</div>
<Badge variant="error" className="text-xs">-15-25%</Badge>
</div>
</button>
<button
onClick={() => {
setSelectedScenarioType(ScenarioType.PROMOTION);
setScenarioName('Weekend Flash Sale');
setPromotionParams({ discount_percent: 25, promotion_type: 'flash_sale', expected_traffic_increase: 0.5 });
setDurationDays(3);
}}
className="w-full text-left px-3 py-2 border rounded-lg hover:bg-gray-50 text-sm"
className="w-full text-left px-4 py-3 border-2 border-green-200 bg-green-50 rounded-lg hover:bg-green-100 hover:border-green-300 transition-all"
>
<Tag className="w-4 h-4 inline mr-2" />
Impact of a 25% weekend promotion?
<div className="flex items-start gap-3">
<Tag className="w-5 h-5 text-green-600 mt-0.5" />
<div className="flex-1">
<div className="text-sm font-medium text-gray-900">Weekend Flash Sale</div>
<div className="text-xs text-gray-600 mt-1">
25% discount + 50% traffic boost - Test promotion impact
</div>
</div>
<Badge variant="success" className="text-xs">+40-60%</Badge>
</div>
</button>
<button
onClick={() => {
setSelectedScenarioType(ScenarioType.EVENT);
setScenarioName('Local Festival Impact');
setEventParams({ event_type: 'festival', expected_attendance: 5000, distance_km: 0.5, duration_days: 3 });
setDurationDays(3);
}}
className="w-full text-left px-4 py-3 border-2 border-purple-200 bg-purple-50 rounded-lg hover:bg-purple-100 hover:border-purple-300 transition-all"
>
<div className="flex items-start gap-3">
<Calendar className="w-5 h-5 text-purple-600 mt-0.5" />
<div className="flex-1">
<div className="text-sm font-medium text-gray-900">Local Festival</div>
<div className="text-xs text-gray-600 mt-1">
5,000 attendees 500m away - Capture festival traffic
</div>
</div>
<Badge variant="primary" className="text-xs">+30-50%</Badge>
</div>
</button>
<button
onClick={() => {
setSelectedScenarioType(ScenarioType.HOLIDAY);
setScenarioName('Christmas Holiday Rush');
setHolidayParams({ holiday_name: 'christmas', expected_impact_multiplier: 1.8 });
setDurationDays(14);
}}
className="w-full text-left px-4 py-3 border-2 border-blue-200 bg-blue-50 rounded-lg hover:bg-blue-100 hover:border-blue-300 transition-all"
>
<div className="flex items-start gap-3">
<Sparkles className="w-5 h-5 text-blue-600 mt-0.5" />
<div className="flex-1">
<div className="text-sm font-medium text-gray-900">Christmas Season</div>
<div className="text-xs text-gray-600 mt-1">
Holiday demand spike - Plan for seasonal products
</div>
</div>
<Badge variant="primary" className="text-xs">+60-100%</Badge>
</div>
</button>
<button
onClick={() => {
setSelectedScenarioType(ScenarioType.PRICING);
setScenarioName('Price Adjustment Test');
setPricingParams({ price_change_percent: -10 });
setDurationDays(14);
}}
className="w-full text-left px-4 py-3 border-2 border-indigo-200 bg-indigo-50 rounded-lg hover:bg-indigo-100 hover:border-indigo-300 transition-all"
>
<div className="flex items-start gap-3">
<TrendingUp className="w-5 h-5 text-indigo-600 mt-0.5" />
<div className="flex-1">
<div className="text-sm font-medium text-gray-900">Price Decrease</div>
<div className="text-xs text-gray-600 mt-1">
10% price reduction - Test elasticity and demand response
</div>
</div>
<Badge variant="info" className="text-xs">+8-15%</Badge>
</div>
</button>
</div>
</div>
@@ -438,31 +886,52 @@ export const ScenarioSimulationPage: React.FC = () => {
</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 className="p-4 bg-gray-50 rounded-lg border border-gray-200">
<div className="text-xs text-gray-500 mb-1 uppercase tracking-wide">Baseline Demand</div>
<div className="text-2xl font-bold text-gray-900">{Math.round(simulationResult.total_baseline_demand)}</div>
<div className="text-xs text-gray-500 mt-1">units expected normally</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 className="p-4 bg-blue-50 rounded-lg border border-blue-200">
<div className="text-xs text-blue-600 mb-1 uppercase tracking-wide">Scenario Demand</div>
<div className="text-2xl font-bold text-blue-900">{Math.round(simulationResult.total_scenario_demand)}</div>
<div className="text-xs text-blue-600 mt-1">units with this scenario</div>
</div>
</div>
<div className="p-4 bg-gradient-to-r from-blue-50 to-purple-50 rounded-lg">
<div className={`p-5 rounded-xl border-2 ${
simulationResult.overall_impact_percent > 0
? 'bg-gradient-to-r from-green-50 to-emerald-50 border-green-200'
: 'bg-gradient-to-r from-red-50 to-orange-50 border-red-200'
}`}>
<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 className="text-xs font-medium text-gray-600 mb-1 uppercase tracking-wide">Overall Impact</div>
<div className="flex items-center gap-2">
{simulationResult.overall_impact_percent > 0 ? (
<div className="flex items-center gap-1 text-green-700">
<ArrowUpRight className="w-6 h-6" />
<span className="text-3xl font-bold">
+{simulationResult.overall_impact_percent.toFixed(1)}%
</span>
</div>
) : (
<div className="flex items-center gap-1 text-red-700">
<ArrowDownRight className="w-6 h-6" />
<span className="text-3xl font-bold">
{simulationResult.overall_impact_percent.toFixed(1)}%
</span>
</div>
)}
</div>
</div>
<div className={`px-4 py-2 rounded-lg ${
simulationResult.overall_impact_percent > 0
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}>
<div className="text-xs font-medium">
{simulationResult.overall_impact_percent > 0 ? '📈 Increased Demand' : '📉 Decreased Demand'}
</div>
</div>
</div>
</div>
@@ -472,15 +941,15 @@ export const ScenarioSimulationPage: React.FC = () => {
{/* 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 className="text-sm font-semibold mb-4 flex items-center gap-2">
<Lightbulb className="w-5 h-5 text-yellow-500" />
<span>{t('analytics.scenario_simulation.insights', 'Key Insights')}</span>
</h3>
<div className="space-y-2">
<div className="space-y-3">
{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 key={index} className="flex items-start gap-3 p-3 bg-blue-50 border border-blue-100 rounded-lg">
<CheckCircle className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" />
<span className="text-sm text-gray-800">{insight}</span>
</div>
))}
</div>
@@ -490,15 +959,17 @@ export const ScenarioSimulationPage: React.FC = () => {
{/* 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 className="text-sm font-semibold mb-4 flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-green-600" />
<span>{t('analytics.scenario_simulation.recommendations', 'Action Plan')}</span>
</h3>
<div className="space-y-2">
<div className="space-y-3">
{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 key={index} className="flex items-start gap-3 p-4 bg-gradient-to-r from-green-50 to-emerald-50 border border-green-200 rounded-lg hover:shadow-sm transition-shadow">
<div className="flex items-center justify-center w-6 h-6 rounded-full bg-green-600 text-white font-bold text-xs flex-shrink-0">
{index + 1}
</div>
<span className="text-sm text-gray-800 font-medium">{recommendation}</span>
</div>
))}
</div>
@@ -509,29 +980,70 @@ export const ScenarioSimulationPage: React.FC = () => {
{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 className="text-sm font-semibold mb-4 flex items-center gap-2">
<BarChart3 className="w-5 h-5 text-purple-600" />
<span>{t('analytics.scenario_simulation.product_impacts', 'Product-Level Impact')}</span>
</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>
{simulationResult.product_impacts.map((impact, index) => {
const impactPercent = Math.abs(impact.demand_change_percent);
const isPositive = impact.demand_change_percent > 0;
return (
<div key={index} className={`p-4 border-2 rounded-lg ${
isPositive ? 'border-green-200 bg-green-50' : 'border-red-200 bg-red-50'
}`}>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Package className="w-4 h-4 text-gray-600" />
<span className="text-sm font-semibold text-gray-900">
{availableProducts.find(p => p.id === impact.inventory_product_id)?.name || impact.inventory_product_id}
</span>
</div>
<div className={`flex items-center gap-1 px-3 py-1 rounded-full ${
isPositive ? 'bg-green-100' : 'bg-red-100'
}`}>
{isPositive ? (
<ArrowUpRight className="w-4 h-4 text-green-700" />
) : (
<ArrowDownRight className="w-4 h-4 text-red-700" />
)}
<span className={`text-sm font-bold ${
isPositive ? 'text-green-700' : 'text-red-700'
}`}>
{isPositive ? '+' : ''}{impact.demand_change_percent.toFixed(1)}%
</span>
</div>
</div>
{/* Visual bar showing demand change */}
<div className="mb-2">
<div className="flex items-center justify-between text-xs text-gray-600 mb-1">
<span>Baseline: {Math.round(impact.baseline_demand)} units</span>
<span>Scenario: {Math.round(impact.simulated_demand)} units</span>
</div>
<div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className={`h-full ${isPositive ? 'bg-green-500' : 'bg-red-500'}`}
style={{ width: `${Math.min(impactPercent, 100)}%` }}
/>
</div>
</div>
{/* Traffic light risk indicator */}
<div className="flex items-center gap-2 mt-2">
<div className="flex gap-1">
<div className={`w-2 h-2 rounded-full ${impactPercent > 30 ? 'bg-red-500' : 'bg-gray-300'}`} />
<div className={`w-2 h-2 rounded-full ${impactPercent > 15 && impactPercent <= 30 ? 'bg-yellow-500' : 'bg-gray-300'}`} />
<div className={`w-2 h-2 rounded-full ${impactPercent <= 15 ? 'bg-green-500' : 'bg-gray-300'}`} />
</div>
<span className="text-xs text-gray-600">
{impactPercent > 30 ? '🔴 High impact' : impactPercent > 15 ? '🟡 Medium impact' : '🟢 Low impact'}
</span>
</div>
</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>

View File

@@ -205,13 +205,12 @@ export const DemoPage: React.FC = () => {
</div>
<h1 className="text-4xl tracking-tight font-extrabold text-[var(--text-primary)] sm:text-5xl lg:text-6xl">
<span className="block">Prueba BakeryIA</span>
<span className="block">Prueba El Panadero Digital</span>
<span className="block text-[var(--color-primary)]">sin compromiso</span>
</h1>
<p className="mt-6 max-w-3xl mx-auto text-lg text-[var(--text-secondary)] sm:text-xl">
Explora nuestro sistema con datos reales de panaderías españolas.
Elige el tipo de negocio que mejor se adapte a tu caso.
Elige el tipo de panadería que se ajuste a tu negocio
</p>
<div className="mt-8 flex items-center justify-center space-x-6 text-sm text-[var(--text-tertiary)]">
@@ -225,7 +224,7 @@ export const DemoPage: React.FC = () => {
</div>
<div className="flex items-center">
<Shield className="w-4 h-4 text-green-500 mr-2" />
Datos aislados y seguros
Datos reales en español
</div>
</div>
</div>
@@ -258,10 +257,14 @@ export const DemoPage: React.FC = () => {
</div>
<div className="ml-4">
<h2 className="text-2xl font-bold text-[var(--text-primary)]">
{account.name}
{account.account_type === 'individual_bakery'
? 'Panadería Individual con Producción local'
: 'Panadería Franquiciada con Obrador Central'}
</h2>
<p className="text-sm text-[var(--text-tertiary)] mt-1">
{account.business_model}
{account.account_type === 'individual_bakery'
? account.business_model
: 'Punto de Venta + Obrador Central'}
</p>
</div>
</div>
@@ -275,9 +278,57 @@ export const DemoPage: React.FC = () => {
{account.description}
</p>
{/* Key Characteristics */}
<div className="mb-6 p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-default)]">
<p className="text-xs font-semibold text-[var(--text-tertiary)] uppercase mb-3">
Características del negocio
</p>
<div className="grid grid-cols-2 gap-3 text-sm">
{account.account_type === 'individual_bakery' ? (
<>
<div>
<span className="text-[var(--text-tertiary)]">Empleados:</span>
<span className="ml-2 font-semibold text-[var(--text-primary)]">~8</span>
</div>
<div>
<span className="text-[var(--text-tertiary)]">Turnos:</span>
<span className="ml-2 font-semibold text-[var(--text-primary)]">1/día</span>
</div>
<div>
<span className="text-[var(--text-tertiary)]">Ventas:</span>
<span className="ml-2 font-semibold text-[var(--text-primary)]">Directas</span>
</div>
<div>
<span className="text-[var(--text-tertiary)]">Productos:</span>
<span className="ml-2 font-semibold text-[var(--text-primary)]">Local</span>
</div>
</>
) : (
<>
<div>
<span className="text-[var(--text-tertiary)]">Empleados:</span>
<span className="ml-2 font-semibold text-[var(--text-primary)]">~5-6</span>
</div>
<div>
<span className="text-[var(--text-tertiary)]">Turnos:</span>
<span className="ml-2 font-semibold text-[var(--text-primary)]">2/día</span>
</div>
<div>
<span className="text-[var(--text-tertiary)]">Modelo:</span>
<span className="ml-2 font-semibold text-[var(--text-primary)]">Franquicia</span>
</div>
<div>
<span className="text-[var(--text-tertiary)]">Productos:</span>
<span className="ml-2 font-semibold text-[var(--text-primary)]">De obrador</span>
</div>
</>
)}
</div>
</div>
{/* Features */}
{account.features && account.features.length > 0 && (
<div className="mb-6 space-y-2">
<div className="mb-8 space-y-2">
<p className="text-sm font-semibold text-[var(--text-primary)] mb-3">
Funcionalidades incluidas:
</p>
@@ -290,31 +341,16 @@ export const DemoPage: React.FC = () => {
</div>
)}
{/* Demo Benefits */}
<div className="space-y-2 mb-8 pt-6 border-t border-[var(--border-default)]">
<div className="flex items-center text-sm text-[var(--text-secondary)]">
<Check className="w-4 h-4 mr-2 text-green-500" />
Datos reales en español
</div>
<div className="flex items-center text-sm text-[var(--text-secondary)]">
<Check className="w-4 h-4 mr-2 text-green-500" />
Sesión aislada de 30 minutos
</div>
<div className="flex items-center text-sm text-[var(--text-secondary)]">
<Check className="w-4 h-4 mr-2 text-green-500" />
Sin necesidad de registro
</div>
</div>
{/* CTA Button */}
<Button
onClick={() => handleStartDemo(account.account_type)}
disabled={creatingSession}
size="lg"
className="w-full bg-[var(--color-primary)] hover:bg-[var(--color-primary-dark)] text-white shadow-lg hover:shadow-xl transform hover:scale-105 transition-all duration-200"
className="w-full bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-primary-dark)] hover:from-[var(--color-primary-dark)] hover:to-[var(--color-primary)] text-white shadow-lg hover:shadow-2xl transform hover:scale-[1.02] transition-all duration-200 font-semibold text-base py-4"
>
<Play className="mr-2 w-5 h-5" />
Probar Demo Ahora
Iniciar Demo
<ArrowRight className="ml-2 w-5 h-5" />
</Button>
</div>
</div>

View File

@@ -284,28 +284,28 @@ const LandingPage: React.FC = () => {
<div className="bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-900/20 dark:to-orange-900/20 rounded-2xl p-8 border-2 border-amber-200 dark:border-amber-800">
<div className="flex items-center gap-4 mb-6">
<div className="w-16 h-16 bg-amber-600 rounded-xl flex items-center justify-center">
<Network className="w-8 h-8 text-white" />
<Store className="w-8 h-8 text-white" />
</div>
<div>
<h3 className="text-2xl font-bold text-[var(--text-primary)]">{t('landing:business_models.central_workshop.title', 'Obrador Central + Puntos de Venta')}</h3>
<p className="text-sm text-[var(--text-secondary)]">{t('landing:business_models.central_workshop.subtitle', 'Producción centralizada, distribución múltiple')}</p>
<h3 className="text-2xl font-bold text-[var(--text-primary)]">{t('landing:business_models.central_workshop.title', 'Panadería Franquiciada')}</h3>
<p className="text-sm text-[var(--text-secondary)]">{t('landing:business_models.central_workshop.subtitle', 'Punto de venta con obrador central')}</p>
</div>
</div>
<p className="text-[var(--text-secondary)] mb-6 leading-relaxed">
{t('landing:business_models.central_workshop.description', 'Produces centralmente y distribuyes a múltiples puntos de venta. Necesitas coordinar producción, logística y demanda entre ubicaciones para optimizar cada punto.')}
{t('landing:business_models.central_workshop.description', 'Operas un punto de venta que recibe productos de un obrador central. Necesitas gestionar pedidos, inventario y ventas para optimizar tu operación retail.')}
</p>
<div className="space-y-3">
<div className="flex items-start gap-3">
<Check className="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
<span className="text-sm text-[var(--text-secondary)]" dangerouslySetInnerHTML={{ __html: t('landing:business_models.central_workshop.features.prediction', '<strong>Predicción agregada y por punto de venta</strong> individual') }} />
<span className="text-sm text-[var(--text-secondary)]" dangerouslySetInnerHTML={{ __html: t('landing:business_models.central_workshop.features.prediction', '<strong>Gestión de pedidos</strong> al obrador central') }} />
</div>
<div className="flex items-start gap-3">
<Check className="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
<span className="text-sm text-[var(--text-secondary)]" dangerouslySetInnerHTML={{ __html: t('landing:business_models.central_workshop.features.distribution', '<strong>Gestión de distribución</strong> multi-ubicación coordinada') }} />
<span className="text-sm text-[var(--text-secondary)]" dangerouslySetInnerHTML={{ __html: t('landing:business_models.central_workshop.features.distribution', '<strong>Control de inventario</strong> de productos recibidos') }} />
</div>
<div className="flex items-start gap-3">
<Check className="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
<span className="text-sm text-[var(--text-secondary)]" dangerouslySetInnerHTML={{ __html: t('landing:business_models.central_workshop.features.visibility', '<strong>Visibilidad centralizada</strong> con control granular') }} />
<span className="text-sm text-[var(--text-secondary)]" dangerouslySetInnerHTML={{ __html: t('landing:business_models.central_workshop.features.visibility', '<strong>Previsión de ventas</strong> para tu punto') }} />
</div>
</div>
</div>

View File

@@ -1,5 +1,11 @@
/**
* Protected Route component for handling authentication and authorization
*
* This component integrates with the unified permission system to provide
* comprehensive access control for routes. It checks both global user roles
* and tenant-specific permissions.
*
* For permission checking logic, see utils/permissions.ts
*/
import React from 'react';
@@ -8,6 +14,7 @@ import { useAuthUser, useIsAuthenticated, useAuthLoading } from '../stores';
import { useCurrentTenantAccess, useTenantPermissions } from '../stores/tenant.store';
import { useHasAccess, useIsDemoMode } from '../hooks/useAccessControl';
import { RouteConfig, canAccessRoute, ROUTES } from './routes.config';
import { checkCombinedPermission, type User, type TenantAccess } from '../utils/permissions';
interface ProtectedRouteProps {
children: React.ReactNode;
@@ -311,40 +318,46 @@ export const ConditionalRender: React.FC<ConditionalRenderProps> = ({
};
// Route guard for admin-only routes (global admin or tenant owner/admin)
// Uses unified permission system
export const AdminRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
return (
<ConditionalRender
requiredRoles={['admin', 'super_admin', 'owner']}
requireAll={false}
fallback={<UnauthorizedPage />}
>
{children}
</ConditionalRender>
);
const user = useAuthUser();
const tenantAccess = useCurrentTenantAccess();
// Check using unified permission system
const hasAccess = checkCombinedPermission(user as User | undefined, tenantAccess as TenantAccess | undefined, {
globalRoles: ['admin', 'super_admin'],
tenantRoles: ['owner', 'admin']
});
return hasAccess ? <>{children}</> : <UnauthorizedPage />;
};
// Route guard for manager-level routes (global admin/manager or tenant admin/owner)
// Uses unified permission system
export const ManagerRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
return (
<ConditionalRender
requiredRoles={['admin', 'super_admin', 'manager', 'owner']}
requireAll={false}
fallback={<UnauthorizedPage />}
>
{children}
</ConditionalRender>
);
const user = useAuthUser();
const tenantAccess = useCurrentTenantAccess();
// Check using unified permission system
const hasAccess = checkCombinedPermission(user as User | undefined, tenantAccess as TenantAccess | undefined, {
globalRoles: ['admin', 'super_admin', 'manager'],
tenantRoles: ['owner', 'admin', 'member']
});
return hasAccess ? <>{children}</> : <UnauthorizedPage />;
};
// Route guard for tenant owner-only routes
// Uses unified permission system
export const OwnerRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
return (
<ConditionalRender
requiredRoles={['super_admin', 'owner']}
requireAll={false}
fallback={<UnauthorizedPage />}
>
{children}
</ConditionalRender>
);
const user = useAuthUser();
const tenantAccess = useCurrentTenantAccess();
// Check using unified permission system
const hasAccess = checkCombinedPermission(user as User | undefined, tenantAccess as TenantAccess | undefined, {
globalRoles: ['super_admin'],
tenantRoles: ['owner']
});
return hasAccess ? <>{children}</> : <UnauthorizedPage />;
};

View File

@@ -1,21 +1,47 @@
/**
* Role Types - Must match backend role definitions exactly
*
* This system uses TWO DISTINCT role systems for fine-grained access control:
*
* 1. GLOBAL USER ROLES (Auth Service):
* - System-wide permissions across the platform
* - Managed by the Auth service
* - Stored in the User model
* - Used for cross-tenant operations and platform administration
*
* 2. TENANT-SPECIFIC ROLES (Tenant Service):
* - Organization/tenant-level permissions
* - Managed by the Tenant service
* - Stored in the TenantMember model
* - Used for per-tenant access control and team management
*
* ROLE MAPPING (Tenant → Global):
* When creating users through tenant management, tenant roles are mapped to global roles:
* - tenant 'admin' → global 'admin' (full administrative access)
* - tenant 'member' → global 'manager' (management-level access)
* - tenant 'viewer' → global 'user' (basic user access)
* - tenant 'owner' → No automatic mapping (owner is tenant-specific)
*
* This mapping ensures users have appropriate platform-level permissions
* that align with their organizational role.
*/
// Global User Roles (Auth Service)
// Platform-wide roles for system-level access control
export const GLOBAL_USER_ROLES = {
USER: 'user',
ADMIN: 'admin',
MANAGER: 'manager',
SUPER_ADMIN: 'super_admin',
USER: 'user', // Basic authenticated user
ADMIN: 'admin', // System administrator
MANAGER: 'manager', // Mid-level management access
SUPER_ADMIN: 'super_admin', // Full platform access
} as const;
// Tenant-Specific Roles (Tenant Service)
// Organization-level roles for tenant-scoped operations
export const TENANT_ROLES = {
OWNER: 'owner',
ADMIN: 'admin',
MEMBER: 'member',
VIEWER: 'viewer',
OWNER: 'owner', // Tenant owner (full control, can transfer ownership)
ADMIN: 'admin', // Tenant administrator (team management, most operations)
MEMBER: 'member', // Standard team member (regular operations)
VIEWER: 'viewer', // Read-only observer (view-only access)
} as const;
// Combined role types

View File

@@ -0,0 +1,380 @@
/**
* Unified Permission Checking Utility
*
* This module provides a centralized system for checking permissions across
* both global user roles and tenant-specific roles.
*
* WHEN TO USE WHICH PERMISSION CHECK:
*
* 1. Use checkGlobalPermission() for:
* - Platform-wide features (user management, system settings)
* - Cross-tenant operations
* - Administrative tools
* - Features that aren't tenant-specific
*
* 2. Use checkTenantPermission() for:
* - Tenant-scoped operations (team management, tenant settings)
* - Resource access within a tenant (orders, inventory, recipes)
* - Organization-specific features
* - Most application features
*
* 3. Use checkCombinedPermission() for:
* - Features that require EITHER global OR tenant permissions
* - Mixed access scenarios (e.g., super admin OR tenant owner)
* - Fallback permission checks
*
* EXAMPLES:
*
* // Check if user can manage platform users (global only)
* checkGlobalPermission(user, { requiredRole: 'admin' })
*
* // Check if user can manage tenant team (tenant only)
* checkTenantPermission(tenantAccess, { requiredRole: 'owner' })
*
* // Check if user can access a feature (either global admin or tenant owner)
* checkCombinedPermission(user, tenantAccess, {
* globalRoles: ['admin', 'super_admin'],
* tenantRoles: ['owner']
* })
*/
import {
GLOBAL_USER_ROLES,
TENANT_ROLES,
ROLE_HIERARCHY,
hasGlobalRole,
hasTenantRole,
type GlobalUserRole,
type TenantRole,
type Role
} from '../types/roles';
/**
* User object structure (from Auth service)
*/
export interface User {
id: string;
email: string;
role: GlobalUserRole;
full_name?: string;
is_active: boolean;
}
/**
* Tenant access object structure (from Tenant service)
*/
export interface TenantAccess {
has_access: boolean;
role: TenantRole;
permissions: string[];
}
/**
* Permission check options for global permissions
*/
export interface GlobalPermissionOptions {
requiredRole: GlobalUserRole;
allowHigherRoles?: boolean; // Default: true
}
/**
* Permission check options for tenant permissions
*/
export interface TenantPermissionOptions {
requiredRole?: TenantRole;
requiredPermission?: string;
allowHigherRoles?: boolean; // Default: true
}
/**
* Permission check options for combined (global + tenant) permissions
*/
export interface CombinedPermissionOptions {
globalRoles?: GlobalUserRole[];
tenantRoles?: TenantRole[];
tenantPermissions?: string[];
requireBoth?: boolean; // Default: false (OR logic), true for AND logic
}
/**
* Check if a user has a specific global permission
*
* @param user - User object from auth store
* @param options - Permission requirements
* @returns true if user has the required global permission
*
* @example
* // Check if user is an admin
* checkGlobalPermission(user, { requiredRole: 'admin' })
*
* // Check if user is exactly a manager (no higher roles)
* checkGlobalPermission(user, { requiredRole: 'manager', allowHigherRoles: false })
*/
export function checkGlobalPermission(
user: User | null | undefined,
options: GlobalPermissionOptions
): boolean {
if (!user || !user.is_active) return false;
const { requiredRole, allowHigherRoles = true } = options;
if (allowHigherRoles) {
// Check if user has the required role or higher
return hasGlobalRole(user.role, requiredRole);
} else {
// Check for exact role match
return user.role === requiredRole;
}
}
/**
* Check if a user has a specific tenant permission
*
* @param tenantAccess - Tenant access object from tenant store
* @param options - Permission requirements
* @returns true if user has the required tenant permission
*
* @example
* // Check if user is a tenant owner
* checkTenantPermission(tenantAccess, { requiredRole: 'owner' })
*
* // Check if user has a specific permission
* checkTenantPermission(tenantAccess, { requiredPermission: 'manage_team' })
*
* // Check if user is exactly an admin (no higher roles)
* checkTenantPermission(tenantAccess, { requiredRole: 'admin', allowHigherRoles: false })
*/
export function checkTenantPermission(
tenantAccess: TenantAccess | null | undefined,
options: TenantPermissionOptions
): boolean {
if (!tenantAccess || !tenantAccess.has_access) return false;
const { requiredRole, requiredPermission, allowHigherRoles = true } = options;
// Check role-based permission
if (requiredRole) {
if (allowHigherRoles) {
if (!hasTenantRole(tenantAccess.role, requiredRole)) {
return false;
}
} else {
if (tenantAccess.role !== requiredRole) {
return false;
}
}
}
// Check specific permission
if (requiredPermission) {
if (!tenantAccess.permissions?.includes(requiredPermission)) {
return false;
}
}
return true;
}
/**
* Check combined global and tenant permissions
*
* @param user - User object from auth store
* @param tenantAccess - Tenant access object from tenant store
* @param options - Permission requirements
* @returns true if user meets the permission criteria
*
* @example
* // Check if user is either global admin OR tenant owner (OR logic)
* checkCombinedPermission(user, tenantAccess, {
* globalRoles: ['admin', 'super_admin'],
* tenantRoles: ['owner']
* })
*
* // Check if user is global admin AND tenant member (AND logic)
* checkCombinedPermission(user, tenantAccess, {
* globalRoles: ['admin'],
* tenantRoles: ['member', 'admin', 'owner'],
* requireBoth: true
* })
*/
export function checkCombinedPermission(
user: User | null | undefined,
tenantAccess: TenantAccess | null | undefined,
options: CombinedPermissionOptions
): boolean {
const {
globalRoles = [],
tenantRoles = [],
tenantPermissions = [],
requireBoth = false
} = options;
// Check global roles
const hasGlobalAccess = globalRoles.length === 0 || (
user?.is_active &&
globalRoles.some(role => hasGlobalRole(user.role, role))
);
// Check tenant roles
const hasTenantRoleAccess = tenantRoles.length === 0 || (
tenantAccess?.has_access &&
tenantRoles.some(role => hasTenantRole(tenantAccess.role, role))
);
// Check tenant permissions
const hasTenantPermissionAccess = tenantPermissions.length === 0 || (
tenantAccess?.has_access &&
tenantPermissions.some(perm => tenantAccess.permissions?.includes(perm))
);
// Combine tenant role and permission checks (must pass at least one)
const hasTenantAccess = hasTenantRoleAccess || hasTenantPermissionAccess;
if (requireBoth) {
// AND logic: must have both global and tenant access
return hasGlobalAccess && hasTenantAccess;
} else {
// OR logic: must have either global or tenant access
return hasGlobalAccess || hasTenantAccess;
}
}
/**
* Check if user can manage team members
*
* @param user - User object
* @param tenantAccess - Tenant access object
* @returns true if user can manage team
*
* @example
* canManageTeam(user, tenantAccess)
*/
export function canManageTeam(
user: User | null | undefined,
tenantAccess: TenantAccess | null | undefined
): boolean {
return checkCombinedPermission(user, tenantAccess, {
globalRoles: [GLOBAL_USER_ROLES.ADMIN, GLOBAL_USER_ROLES.SUPER_ADMIN],
tenantRoles: [TENANT_ROLES.OWNER, TENANT_ROLES.ADMIN]
});
}
/**
* Check if user is tenant owner
*
* @param user - User object
* @param tenantAccess - Tenant access object
* @returns true if user is owner
*
* @example
* isTenantOwner(user, tenantAccess)
*/
export function isTenantOwner(
user: User | null | undefined,
tenantAccess: TenantAccess | null | undefined
): boolean {
return checkCombinedPermission(user, tenantAccess, {
globalRoles: [GLOBAL_USER_ROLES.SUPER_ADMIN], // Super admin can act as owner
tenantRoles: [TENANT_ROLES.OWNER]
});
}
/**
* Check if user can perform administrative actions
*
* @param user - User object
* @returns true if user has admin access
*
* @example
* canPerformAdminActions(user)
*/
export function canPerformAdminActions(
user: User | null | undefined
): boolean {
return checkGlobalPermission(user, {
requiredRole: GLOBAL_USER_ROLES.ADMIN
});
}
/**
* Get user's effective permissions for a tenant
*
* @param user - User object
* @param tenantAccess - Tenant access object
* @returns Object with permission flags
*
* @example
* const perms = getEffectivePermissions(user, tenantAccess)
* if (perms.canManageTeam) { ... }
*/
export function getEffectivePermissions(
user: User | null | undefined,
tenantAccess: TenantAccess | null | undefined
) {
return {
// Global permissions
isGlobalAdmin: checkGlobalPermission(user, { requiredRole: GLOBAL_USER_ROLES.ADMIN }),
isSuperAdmin: checkGlobalPermission(user, { requiredRole: GLOBAL_USER_ROLES.SUPER_ADMIN }),
isManager: checkGlobalPermission(user, { requiredRole: GLOBAL_USER_ROLES.MANAGER }),
// Tenant permissions
isTenantOwner: checkTenantPermission(tenantAccess, { requiredRole: TENANT_ROLES.OWNER }),
isTenantAdmin: checkTenantPermission(tenantAccess, { requiredRole: TENANT_ROLES.ADMIN }),
isTenantMember: checkTenantPermission(tenantAccess, { requiredRole: TENANT_ROLES.MEMBER }),
isTenantViewer: checkTenantPermission(tenantAccess, { requiredRole: TENANT_ROLES.VIEWER }),
// Combined permissions
canManageTeam: canManageTeam(user, tenantAccess),
canTransferOwnership: isTenantOwner(user, tenantAccess),
canPerformAdminActions: canPerformAdminActions(user),
// Access flags
hasGlobalAccess: !!user?.is_active,
hasTenantAccess: !!tenantAccess?.has_access,
};
}
/**
* Permission validation error types
*/
export class PermissionError extends Error {
constructor(message: string, public readonly requiredPermissions: string[]) {
super(message);
this.name = 'PermissionError';
}
}
/**
* Assert that user has required permissions, throw error if not
*
* @param user - User object
* @param tenantAccess - Tenant access object
* @param options - Permission requirements
* @throws PermissionError if user lacks required permissions
*
* @example
* assertPermission(user, tenantAccess, {
* tenantRoles: ['owner'],
* errorMessage: 'Only tenant owners can perform this action'
* })
*/
export function assertPermission(
user: User | null | undefined,
tenantAccess: TenantAccess | null | undefined,
options: CombinedPermissionOptions & { errorMessage?: string }
): void {
const hasPermission = checkCombinedPermission(user, tenantAccess, options);
if (!hasPermission) {
const requiredPerms = [
...(options.globalRoles || []),
...(options.tenantRoles || []),
...(options.tenantPermissions || [])
];
throw new PermissionError(
options.errorMessage || 'You do not have permission to perform this action',
requiredPerms
);
}
}