New alert service
This commit is contained in:
@@ -25,12 +25,13 @@ import {
|
||||
useOrchestrationSummary,
|
||||
useUnifiedActionQueue,
|
||||
useProductionTimeline,
|
||||
useInsights,
|
||||
useApprovePurchaseOrder,
|
||||
useStartProductionBatch,
|
||||
usePauseProductionBatch,
|
||||
useExecutionProgress,
|
||||
} from '../../api/hooks/newDashboard';
|
||||
useDashboardRealtime, // PHASE 3: SSE state sync
|
||||
useProgressiveDashboard, // PHASE 4: Progressive loading
|
||||
} from '../../api/hooks/useProfessionalDashboard';
|
||||
import { useRejectPurchaseOrder } from '../../api/hooks/purchase-orders';
|
||||
import { useIngredients } from '../../api/hooks/inventory';
|
||||
import { useSuppliers } from '../../api/hooks/suppliers';
|
||||
@@ -54,7 +55,14 @@ import {
|
||||
useOrchestrationNotifications,
|
||||
} from '../../hooks';
|
||||
|
||||
export function NewDashboardPage() {
|
||||
|
||||
// Import Enterprise Dashboard
|
||||
import EnterpriseDashboardPage from './EnterpriseDashboardPage';
|
||||
import { useSubscription } from '../../api/hooks/subscription';
|
||||
import { SUBSCRIPTION_TIERS } from '../../api/types/subscription';
|
||||
|
||||
// Rename the existing component to BakeryDashboard
|
||||
export function BakeryDashboard() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation(['dashboard', 'common', 'alerts']);
|
||||
const { currentTenant } = useTenant();
|
||||
@@ -75,48 +83,107 @@ export function NewDashboardPage() {
|
||||
const [poModalMode, setPOModalMode] = useState<'view' | 'edit'>('view');
|
||||
|
||||
// Setup Progress Data
|
||||
const { data: ingredients = [], isLoading: loadingIngredients } = useIngredients(tenantId, {}, { enabled: !!tenantId });
|
||||
const { data: suppliers = [], isLoading: loadingSuppliers } = useSuppliers(tenantId, { enabled: !!tenantId });
|
||||
const { data: recipes = [], isLoading: loadingRecipes } = useRecipes(tenantId, { enabled: !!tenantId });
|
||||
const { data: qualityData, isLoading: loadingQuality } = useQualityTemplates(tenantId, { enabled: !!tenantId });
|
||||
// Always fetch setup data to determine true progress, but use localStorage as fallback during loading
|
||||
// PHASE 1 OPTIMIZATION: Only use cached value if we're still waiting for API to respond
|
||||
const setupProgressFromStorage = useMemo(() => {
|
||||
try {
|
||||
const cached = localStorage.getItem(`setup_progress_${tenantId}`);
|
||||
return cached ? parseInt(cached, 10) : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}, [tenantId]);
|
||||
|
||||
// Always fetch the actual data to determine true progress
|
||||
const { data: ingredients = [], isLoading: loadingIngredients } = useIngredients(
|
||||
tenantId,
|
||||
{},
|
||||
{ enabled: !!tenantId }
|
||||
);
|
||||
const { data: suppliers = [], isLoading: loadingSuppliers } = useSuppliers(
|
||||
tenantId,
|
||||
{},
|
||||
{ enabled: !!tenantId }
|
||||
);
|
||||
const { data: recipes = [], isLoading: loadingRecipes } = useRecipes(
|
||||
tenantId,
|
||||
{},
|
||||
{ enabled: !!tenantId }
|
||||
);
|
||||
const { data: qualityData, isLoading: loadingQuality } = useQualityTemplates(
|
||||
tenantId,
|
||||
{},
|
||||
{ enabled: !!tenantId }
|
||||
);
|
||||
const qualityTemplates = Array.isArray(qualityData?.templates) ? qualityData.templates : [];
|
||||
|
||||
// Data fetching
|
||||
// PHASE 4: Progressive data loading for perceived performance boost
|
||||
const {
|
||||
data: healthStatus,
|
||||
isLoading: healthLoading,
|
||||
refetch: refetchHealth,
|
||||
} = useBakeryHealthStatus(tenantId);
|
||||
health: {
|
||||
data: healthStatus,
|
||||
isLoading: healthLoading,
|
||||
refetch: refetchHealth,
|
||||
},
|
||||
actionQueue: {
|
||||
data: actionQueue,
|
||||
isLoading: actionQueueLoading,
|
||||
refetch: refetchActionQueue,
|
||||
},
|
||||
progress: {
|
||||
data: executionProgress,
|
||||
isLoading: executionProgressLoading,
|
||||
refetch: refetchExecutionProgress,
|
||||
},
|
||||
overallLoading,
|
||||
isReady,
|
||||
} = useProgressiveDashboard(tenantId);
|
||||
|
||||
// Additional hooks not part of progressive loading
|
||||
const {
|
||||
data: orchestrationSummary,
|
||||
isLoading: orchestrationLoading,
|
||||
refetch: refetchOrchestration,
|
||||
} = useOrchestrationSummary(tenantId);
|
||||
|
||||
const {
|
||||
data: actionQueue,
|
||||
isLoading: actionQueueLoading,
|
||||
refetch: refetchActionQueue,
|
||||
} = useUnifiedActionQueue(tenantId);
|
||||
|
||||
const {
|
||||
data: executionProgress,
|
||||
isLoading: executionProgressLoading,
|
||||
refetch: refetchExecutionProgress,
|
||||
} = useExecutionProgress(tenantId);
|
||||
|
||||
const {
|
||||
data: productionTimeline,
|
||||
isLoading: timelineLoading,
|
||||
refetch: refetchTimeline,
|
||||
} = useProductionTimeline(tenantId);
|
||||
|
||||
const {
|
||||
data: insights,
|
||||
isLoading: insightsLoading,
|
||||
refetch: refetchInsights,
|
||||
} = useInsights(tenantId);
|
||||
// Insights functionality removed as it's not needed with new architecture
|
||||
const insights = undefined;
|
||||
const insightsLoading = false;
|
||||
const refetchInsights = () => {};
|
||||
|
||||
// PHASE 3: Enable SSE real-time state synchronization
|
||||
useDashboardRealtime(tenantId);
|
||||
|
||||
|
||||
// PHASE 6: Performance monitoring
|
||||
useEffect(() => {
|
||||
const loadTime = performance.now();
|
||||
console.log(`📊 [Performance] Dashboard loaded in ${loadTime.toFixed(0)}ms`);
|
||||
|
||||
// Calculate setup completion status based on stored progress (approximation since actual data may not be loaded yet)
|
||||
const setupComplete = setupProgressFromStorage >= 100;
|
||||
|
||||
if (loadTime > 1000) {
|
||||
console.warn('⚠️ [Performance] Dashboard load time exceeded target (>1000ms):', {
|
||||
loadTime: `${loadTime.toFixed(0)}ms`,
|
||||
target: '1000ms',
|
||||
setupComplete,
|
||||
queriesSkipped: setupComplete ? 4 : 0,
|
||||
});
|
||||
} else {
|
||||
console.log('✅ [Performance] Dashboard load time within target:', {
|
||||
loadTime: `${loadTime.toFixed(0)}ms`,
|
||||
target: '<1000ms',
|
||||
setupComplete,
|
||||
queriesSkipped: setupComplete ? 4 : 0,
|
||||
});
|
||||
}
|
||||
}, [setupProgressFromStorage]); // Include setupProgressFromStorage as dependency
|
||||
|
||||
// Real-time event subscriptions for automatic refetching
|
||||
const { notifications: batchNotifications } = useBatchNotifications();
|
||||
@@ -212,7 +279,7 @@ export function NewDashboardPage() {
|
||||
});
|
||||
|
||||
if (latestBatchNotificationId &&
|
||||
latestBatchNotificationId !== prevBatchNotificationsRef.current) {
|
||||
latestBatchNotificationId !== prevBatchNotificationsRef.current) {
|
||||
console.log('🔥 [Dashboard] NEW batch notification detected, updating ref and refetching');
|
||||
prevBatchNotificationsRef.current = latestBatchNotificationId;
|
||||
const latest = batchNotifications[0];
|
||||
@@ -237,7 +304,7 @@ export function NewDashboardPage() {
|
||||
});
|
||||
|
||||
if (latestDeliveryNotificationId &&
|
||||
latestDeliveryNotificationId !== prevDeliveryNotificationsRef.current) {
|
||||
latestDeliveryNotificationId !== prevDeliveryNotificationsRef.current) {
|
||||
console.log('🔥 [Dashboard] NEW delivery notification detected, updating ref and refetching');
|
||||
prevDeliveryNotificationsRef.current = latestDeliveryNotificationId;
|
||||
const latest = deliveryNotifications[0];
|
||||
@@ -262,7 +329,7 @@ export function NewDashboardPage() {
|
||||
});
|
||||
|
||||
if (latestOrchestrationNotificationId &&
|
||||
latestOrchestrationNotificationId !== prevOrchestrationNotificationsRef.current) {
|
||||
latestOrchestrationNotificationId !== prevOrchestrationNotificationsRef.current) {
|
||||
console.log('🔥 [Dashboard] NEW orchestration notification detected, updating ref and refetching');
|
||||
prevOrchestrationNotificationsRef.current = latestOrchestrationNotificationId;
|
||||
const latest = orchestrationNotifications[0];
|
||||
@@ -401,6 +468,17 @@ export function NewDashboardPage() {
|
||||
|
||||
// Calculate overall progress
|
||||
const { completedSections, totalSections, progressPercentage, criticalMissing, recommendedMissing } = useMemo(() => {
|
||||
// If data is still loading, use stored value as fallback to prevent flickering
|
||||
if (loadingIngredients || loadingSuppliers || loadingRecipes || loadingQuality) {
|
||||
return {
|
||||
completedSections: 0,
|
||||
totalSections: 4, // 4 required sections
|
||||
progressPercentage: setupProgressFromStorage, // Use stored value during loading
|
||||
criticalMissing: [],
|
||||
recommendedMissing: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Guard against undefined or invalid setupSections
|
||||
if (!setupSections || !Array.isArray(setupSections) || setupSections.length === 0) {
|
||||
return {
|
||||
@@ -420,6 +498,13 @@ export function NewDashboardPage() {
|
||||
const critical = setupSections.filter(s => !s.isComplete && s.id !== 'quality');
|
||||
const recommended = setupSections.filter(s => s.count < s.recommended);
|
||||
|
||||
// PHASE 1 OPTIMIZATION: Cache progress to localStorage for next page load
|
||||
try {
|
||||
localStorage.setItem(`setup_progress_${tenantId}`, percentage.toString());
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
|
||||
return {
|
||||
completedSections: completed,
|
||||
totalSections: total,
|
||||
@@ -427,7 +512,7 @@ export function NewDashboardPage() {
|
||||
criticalMissing: critical,
|
||||
recommendedMissing: recommended,
|
||||
};
|
||||
}, [setupSections]);
|
||||
}, [setupSections, tenantId, loadingIngredients, loadingSuppliers, loadingRecipes, loadingQuality, setupProgressFromStorage]);
|
||||
|
||||
const handleRefreshAll = () => {
|
||||
refetchHealth();
|
||||
@@ -547,8 +632,8 @@ export function NewDashboardPage() {
|
||||
</div>
|
||||
|
||||
{/* Setup Flow - Three States */}
|
||||
{loadingIngredients || loadingSuppliers || loadingRecipes || loadingQuality ? (
|
||||
/* Loading state */
|
||||
{loadingIngredients || loadingSuppliers || loadingRecipes || loadingQuality || !isReady ? (
|
||||
/* Loading state - only show spinner until first priority data (health) is ready */
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2" style={{ borderColor: 'var(--color-primary)' }}></div>
|
||||
</div>
|
||||
@@ -570,36 +655,60 @@ export function NewDashboardPage() {
|
||||
|
||||
{/* Main Dashboard Layout */}
|
||||
<div className="space-y-6">
|
||||
{/* SECTION 1: Glanceable Health Hero (Traffic Light) */}
|
||||
{/* SECTION 1: Glanceable Health Hero (Traffic Light) - PRIORITY 1 */}
|
||||
<div data-tour="dashboard-stats">
|
||||
<GlanceableHealthHero
|
||||
healthStatus={healthStatus}
|
||||
loading={healthLoading}
|
||||
urgentActionCount={actionQueue?.urgentCount || 0}
|
||||
/>
|
||||
{healthLoading ? (
|
||||
<div className="bg-[var(--bg-primary)] rounded-lg border border-[var(--border-primary)] p-6 animate-pulse">
|
||||
<div className="h-24 bg-[var(--bg-secondary)] rounded"></div>
|
||||
</div>
|
||||
) : (
|
||||
<GlanceableHealthHero
|
||||
healthStatus={healthStatus!}
|
||||
loading={false}
|
||||
urgentActionCount={actionQueue?.urgentCount || 0}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* SECTION 2: What Needs Your Attention (Unified Action Queue) */}
|
||||
{/* SECTION 2: What Needs Your Attention (Unified Action Queue) - PRIORITY 2 */}
|
||||
<div data-tour="pending-po-approvals">
|
||||
<UnifiedActionQueueCard
|
||||
actionQueue={actionQueue}
|
||||
loading={actionQueueLoading}
|
||||
tenantId={tenantId}
|
||||
/>
|
||||
{actionQueueLoading ? (
|
||||
<div className="bg-[var(--bg-primary)] rounded-lg border border-[var(--border-primary)] p-6 animate-pulse">
|
||||
<div className="space-y-4">
|
||||
<div className="h-8 bg-[var(--bg-secondary)] rounded w-1/3"></div>
|
||||
<div className="h-32 bg-[var(--bg-secondary)] rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<UnifiedActionQueueCard
|
||||
actionQueue={actionQueue!}
|
||||
loading={false}
|
||||
tenantId={tenantId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* SECTION 3: Execution Progress Tracker (Plan vs Actual) */}
|
||||
{/* SECTION 3: Execution Progress Tracker (Plan vs Actual) - PRIORITY 3 */}
|
||||
<div data-tour="execution-progress">
|
||||
<ExecutionProgressTracker
|
||||
progress={executionProgress}
|
||||
loading={executionProgressLoading}
|
||||
/>
|
||||
{executionProgressLoading ? (
|
||||
<div className="bg-[var(--bg-primary)] rounded-lg border border-[var(--border-primary)] p-6 animate-pulse">
|
||||
<div className="space-y-4">
|
||||
<div className="h-8 bg-[var(--bg-secondary)] rounded w-1/2"></div>
|
||||
<div className="h-24 bg-[var(--bg-secondary)] rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ExecutionProgressTracker
|
||||
progress={executionProgress}
|
||||
loading={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* SECTION 4: Intelligent System Summary - Unified AI Impact & Orchestration */}
|
||||
<div data-tour="intelligent-system-summary">
|
||||
<IntelligentSystemSummaryCard
|
||||
orchestrationSummary={orchestrationSummary}
|
||||
orchestrationSummary={orchestrationSummary!}
|
||||
orchestrationLoading={orchestrationLoading}
|
||||
onWorkflowComplete={handleRefreshAll}
|
||||
/>
|
||||
@@ -679,4 +788,30 @@ export function NewDashboardPage() {
|
||||
);
|
||||
}
|
||||
|
||||
export default NewDashboardPage;
|
||||
/**
|
||||
* Main Dashboard Page
|
||||
* Conditionally renders either the Enterprise Dashboard or the Bakery Dashboard
|
||||
* based on the user's subscription tier.
|
||||
*/
|
||||
export function DashboardPage() {
|
||||
const { subscriptionInfo } = useSubscription();
|
||||
const { currentTenant } = useTenant();
|
||||
const { plan, loading } = subscriptionInfo;
|
||||
const tenantId = currentTenant?.id;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2" style={{ borderColor: 'var(--color-primary)' }}></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (plan === SUBSCRIPTION_TIERS.ENTERPRISE) {
|
||||
return <EnterpriseDashboardPage tenantId={tenantId} />;
|
||||
}
|
||||
|
||||
return <BakeryDashboard />;
|
||||
}
|
||||
|
||||
export default DashboardPage;
|
||||
|
||||
@@ -5,57 +5,82 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useQuery, useQueries } from '@tanstack/react-query';
|
||||
import {
|
||||
useNetworkSummary,
|
||||
useChildrenPerformance,
|
||||
useDistributionOverview,
|
||||
useForecastSummary
|
||||
} from '../../api/hooks/enterprise';
|
||||
} from '../../api/hooks/useEnterpriseDashboard';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '../../components/ui/Card';
|
||||
import { Badge } from '../../components/ui/Badge';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import {
|
||||
Users,
|
||||
ShoppingCart,
|
||||
TrendingUp,
|
||||
MapPin,
|
||||
Truck,
|
||||
Package,
|
||||
BarChart3,
|
||||
Network,
|
||||
Store,
|
||||
Activity,
|
||||
Calendar,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
PackageCheck,
|
||||
Building2,
|
||||
DollarSign
|
||||
ArrowLeft,
|
||||
ChevronRight
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LoadingSpinner } from '../../components/ui/LoadingSpinner';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import { apiClient } from '../../api/client/apiClient';
|
||||
import { useEnterprise } from '../../contexts/EnterpriseContext';
|
||||
import { useTenant } from '../../stores/tenant.store';
|
||||
|
||||
// Components for enterprise dashboard
|
||||
const NetworkSummaryCards = React.lazy(() => import('../../components/dashboard/NetworkSummaryCards'));
|
||||
const DistributionMap = React.lazy(() => import('../../components/maps/DistributionMap'));
|
||||
const PerformanceChart = React.lazy(() => import('../../components/charts/PerformanceChart'));
|
||||
|
||||
const EnterpriseDashboardPage = () => {
|
||||
const { tenantId } = useParams();
|
||||
interface EnterpriseDashboardPageProps {
|
||||
tenantId?: string;
|
||||
}
|
||||
|
||||
const EnterpriseDashboardPage: React.FC<EnterpriseDashboardPageProps> = ({ tenantId: propTenantId }) => {
|
||||
const { tenantId: urlTenantId } = useParams<{ tenantId: string }>();
|
||||
const tenantId = propTenantId || urlTenantId;
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('dashboard');
|
||||
const { state: enterpriseState, drillDownToOutlet, returnToNetworkView, enterNetworkView } = useEnterprise();
|
||||
const { switchTenant } = useTenant();
|
||||
|
||||
const [selectedMetric, setSelectedMetric] = useState('sales');
|
||||
const [selectedPeriod, setSelectedPeriod] = useState(30);
|
||||
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
|
||||
|
||||
// Check if tenantId is available at the start
|
||||
useEffect(() => {
|
||||
if (!tenantId) {
|
||||
console.error('No tenant ID available for enterprise dashboard');
|
||||
navigate('/unauthorized');
|
||||
}
|
||||
}, [tenantId, navigate]);
|
||||
|
||||
// Initialize enterprise mode on mount
|
||||
useEffect(() => {
|
||||
if (tenantId && !enterpriseState.parentTenantId) {
|
||||
enterNetworkView(tenantId);
|
||||
}
|
||||
}, [tenantId, enterpriseState.parentTenantId, enterNetworkView]);
|
||||
|
||||
// Check if user has enterprise tier access
|
||||
useEffect(() => {
|
||||
const checkAccess = async () => {
|
||||
if (!tenantId) {
|
||||
console.error('No tenant ID available for enterprise dashboard');
|
||||
navigate('/unauthorized');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.get<{ tenant_type: string }>(`/tenants/${tenantId}`);
|
||||
|
||||
@@ -78,6 +103,7 @@ const EnterpriseDashboardPage = () => {
|
||||
error: networkSummaryError
|
||||
} = useNetworkSummary(tenantId!, {
|
||||
refetchInterval: 60000, // Refetch every minute
|
||||
enabled: !!tenantId, // Only fetch if tenantId is available
|
||||
});
|
||||
|
||||
// Fetch children performance data
|
||||
@@ -85,7 +111,9 @@ const EnterpriseDashboardPage = () => {
|
||||
data: childrenPerformance,
|
||||
isLoading: isChildrenPerformanceLoading,
|
||||
error: childrenPerformanceError
|
||||
} = useChildrenPerformance(tenantId!, selectedMetric, selectedPeriod);
|
||||
} = useChildrenPerformance(tenantId!, selectedMetric, selectedPeriod, {
|
||||
enabled: !!tenantId, // Only fetch if tenantId is available
|
||||
});
|
||||
|
||||
// Fetch distribution overview data
|
||||
const {
|
||||
@@ -94,6 +122,7 @@ const EnterpriseDashboardPage = () => {
|
||||
error: distributionError
|
||||
} = useDistributionOverview(tenantId!, selectedDate, {
|
||||
refetchInterval: 60000, // Refetch every minute
|
||||
enabled: !!tenantId, // Only fetch if tenantId is available
|
||||
});
|
||||
|
||||
// Fetch enterprise forecast summary
|
||||
@@ -101,7 +130,36 @@ const EnterpriseDashboardPage = () => {
|
||||
data: forecastSummary,
|
||||
isLoading: isForecastLoading,
|
||||
error: forecastError
|
||||
} = useForecastSummary(tenantId!);
|
||||
} = useForecastSummary(tenantId!, 7, {
|
||||
enabled: !!tenantId, // Only fetch if tenantId is available
|
||||
});
|
||||
|
||||
// Handle outlet drill-down
|
||||
const handleOutletClick = async (outletId: string, outletName: string) => {
|
||||
// Calculate network metrics if available
|
||||
const networkMetrics = childrenPerformance?.rankings ? {
|
||||
totalSales: childrenPerformance.rankings.reduce((sum, r) => sum + (selectedMetric === 'sales' ? r.metric_value : 0), 0),
|
||||
totalProduction: 0,
|
||||
totalInventoryValue: childrenPerformance.rankings.reduce((sum, r) => sum + (selectedMetric === 'inventory_value' ? r.metric_value : 0), 0),
|
||||
averageSales: childrenPerformance.rankings.reduce((sum, r) => sum + (selectedMetric === 'sales' ? r.metric_value : 0), 0) / childrenPerformance.rankings.length,
|
||||
averageProduction: 0,
|
||||
averageInventoryValue: childrenPerformance.rankings.reduce((sum, r) => sum + (selectedMetric === 'inventory_value' ? r.metric_value : 0), 0) / childrenPerformance.rankings.length,
|
||||
childCount: childrenPerformance.rankings.length
|
||||
} : undefined;
|
||||
|
||||
drillDownToOutlet(outletId, outletName, networkMetrics);
|
||||
await switchTenant(outletId);
|
||||
navigate('/app/dashboard');
|
||||
};
|
||||
|
||||
// Handle return to network view
|
||||
const handleReturnToNetwork = async () => {
|
||||
if (enterpriseState.parentTenantId) {
|
||||
returnToNetworkView();
|
||||
await switchTenant(enterpriseState.parentTenantId);
|
||||
navigate(`/app/enterprise/${enterpriseState.parentTenantId}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Error boundary fallback
|
||||
const ErrorFallback = ({ error, resetErrorBoundary }: { error: Error; resetErrorBoundary: () => void }) => (
|
||||
@@ -142,18 +200,77 @@ const EnterpriseDashboardPage = () => {
|
||||
|
||||
return (
|
||||
<ErrorBoundary FallbackComponent={ErrorFallback}>
|
||||
<div className="p-6 min-h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Network className="w-8 h-8 text-blue-600" />
|
||||
<h1 className="text-3xl font-bold text-gray-900">
|
||||
{t('enterprise.network_dashboard')}
|
||||
</h1>
|
||||
<div className="px-4 sm:px-6 lg:px-8 py-6 min-h-screen" style={{ backgroundColor: 'var(--bg-secondary)' }}>
|
||||
{/* Breadcrumb / Return to Network Banner */}
|
||||
{enterpriseState.selectedOutletId && !enterpriseState.isNetworkView && (
|
||||
<div className="mb-6 rounded-lg p-4" style={{
|
||||
backgroundColor: 'var(--color-info-light, #dbeafe)',
|
||||
borderColor: 'var(--color-info, #3b82f6)',
|
||||
borderWidth: '1px',
|
||||
borderStyle: 'solid'
|
||||
}}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Network className="w-5 h-5" style={{ color: 'var(--color-info)' }} />
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="font-medium" style={{ color: 'var(--color-info)' }}>Network Overview</span>
|
||||
<ChevronRight className="w-4 h-4" style={{ color: 'var(--color-info-light, #93c5fd)' }} />
|
||||
<span className="text-gray-700 font-semibold">{enterpriseState.selectedOutletName}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleReturnToNetwork}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Return to Network View
|
||||
</Button>
|
||||
</div>
|
||||
{enterpriseState.networkMetrics && (
|
||||
<div className="mt-3 pt-3 border-t grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 text-sm"
|
||||
style={{ borderColor: 'var(--color-info-light, #93c5fd)' }}>
|
||||
<div>
|
||||
<span style={{ color: 'var(--color-info)' }}>Network Average Sales:</span>
|
||||
<span className="ml-2 font-semibold">€{enterpriseState.networkMetrics.averageSales.toLocaleString()}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: 'var(--color-info)' }}>Total Outlets:</span>
|
||||
<span className="ml-2 font-semibold">{enterpriseState.networkMetrics.childCount}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: 'var(--color-info)' }}>Network Total:</span>
|
||||
<span className="ml-2 font-semibold">€{enterpriseState.networkMetrics.totalSales.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Enhanced Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Title Section with Gradient Icon */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className="w-16 h-16 rounded-2xl flex items-center justify-center shadow-lg"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, var(--color-info) 0%, var(--color-primary) 100%)',
|
||||
}}
|
||||
>
|
||||
<Network className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold" style={{ color: 'var(--text-primary)' }}>
|
||||
{t('enterprise.network_dashboard')}
|
||||
</h1>
|
||||
<p className="mt-1" style={{ color: 'var(--text-secondary)' }}>
|
||||
{t('enterprise.network_summary_description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-600">
|
||||
{t('enterprise.network_summary_description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Network Summary Cards */}
|
||||
@@ -234,6 +351,7 @@ const EnterpriseDashboardPage = () => {
|
||||
data={childrenPerformance.rankings}
|
||||
metric={selectedMetric}
|
||||
period={selectedPeriod}
|
||||
onOutletClick={handleOutletClick}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-96 flex items-center justify-center text-gray-500">
|
||||
@@ -254,34 +372,78 @@ const EnterpriseDashboardPage = () => {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{forecastSummary && forecastSummary.aggregated_forecasts ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-blue-50 p-4 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Package className="w-4 h-4 text-blue-600" />
|
||||
<h3 className="font-semibold text-blue-800">{t('enterprise.total_demand')}</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Total Demand Card */}
|
||||
<div
|
||||
className="p-6 rounded-xl border-2 transition-all duration-300 hover:shadow-lg"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-info-50)',
|
||||
borderColor: 'var(--color-info-200)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div
|
||||
className="w-10 h-10 rounded-lg flex items-center justify-center shadow-md"
|
||||
style={{ backgroundColor: 'var(--color-info-100)' }}
|
||||
>
|
||||
<Package className="w-5 h-5" style={{ color: 'var(--color-info-600)' }} />
|
||||
</div>
|
||||
<h3 className="font-semibold text-sm" style={{ color: 'var(--color-info-800)' }}>
|
||||
{t('enterprise.total_demand')}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-blue-900">
|
||||
<p className="text-3xl font-bold" style={{ color: 'var(--color-info-900)' }}>
|
||||
{Object.values(forecastSummary.aggregated_forecasts).reduce((total: number, day: any) =>
|
||||
total + Object.values(day).reduce((dayTotal: number, product: any) =>
|
||||
dayTotal + (product.predicted_demand || 0), 0), 0
|
||||
).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-green-50 p-4 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Calendar className="w-4 h-4 text-green-600" />
|
||||
<h3 className="font-semibold text-green-800">{t('enterprise.days_forecast')}</h3>
|
||||
|
||||
{/* Days Forecast Card */}
|
||||
<div
|
||||
className="p-6 rounded-xl border-2 transition-all duration-300 hover:shadow-lg"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-success-50)',
|
||||
borderColor: 'var(--color-success-200)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div
|
||||
className="w-10 h-10 rounded-lg flex items-center justify-center shadow-md"
|
||||
style={{ backgroundColor: 'var(--color-success-100)' }}
|
||||
>
|
||||
<Calendar className="w-5 h-5" style={{ color: 'var(--color-success-600)' }} />
|
||||
</div>
|
||||
<h3 className="font-semibold text-sm" style={{ color: 'var(--color-success-800)' }}>
|
||||
{t('enterprise.days_forecast')}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-green-900">
|
||||
<p className="text-3xl font-bold" style={{ color: 'var(--color-success-900)' }}>
|
||||
{forecastSummary.days_forecast || 7}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-purple-50 p-4 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Activity className="w-4 h-4 text-purple-600" />
|
||||
<h3 className="font-semibold text-purple-800">{t('enterprise.avg_daily_demand')}</h3>
|
||||
|
||||
{/* Average Daily Demand Card */}
|
||||
<div
|
||||
className="p-6 rounded-xl border-2 transition-all duration-300 hover:shadow-lg"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-secondary-50)',
|
||||
borderColor: 'var(--color-secondary-200)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div
|
||||
className="w-10 h-10 rounded-lg flex items-center justify-center shadow-md"
|
||||
style={{ backgroundColor: 'var(--color-secondary-100)' }}
|
||||
>
|
||||
<Activity className="w-5 h-5" style={{ color: 'var(--color-secondary-600)' }} />
|
||||
</div>
|
||||
<h3 className="font-semibold text-sm" style={{ color: 'var(--color-secondary-800)' }}>
|
||||
{t('enterprise.avg_daily_demand')}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-purple-900">
|
||||
<p className="text-3xl font-bold" style={{ color: 'var(--color-secondary-900)' }}>
|
||||
{forecastSummary.aggregated_forecasts
|
||||
? Math.round(Object.values(forecastSummary.aggregated_forecasts).reduce((total: number, day: any) =>
|
||||
total + Object.values(day).reduce((dayTotal: number, product: any) =>
|
||||
@@ -291,12 +453,27 @@ const EnterpriseDashboardPage = () => {
|
||||
: 0}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-yellow-50 p-4 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Clock className="w-4 h-4 text-yellow-600" />
|
||||
<h3 className="font-semibold text-yellow-800">{t('enterprise.last_updated')}</h3>
|
||||
|
||||
{/* Last Updated Card */}
|
||||
<div
|
||||
className="p-6 rounded-xl border-2 transition-all duration-300 hover:shadow-lg"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-warning-50)',
|
||||
borderColor: 'var(--color-warning-200)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div
|
||||
className="w-10 h-10 rounded-lg flex items-center justify-center shadow-md"
|
||||
style={{ backgroundColor: 'var(--color-warning-100)' }}
|
||||
>
|
||||
<Clock className="w-5 h-5" style={{ color: 'var(--color-warning-600)' }} />
|
||||
</div>
|
||||
<h3 className="font-semibold text-sm" style={{ color: 'var(--color-warning-800)' }}>
|
||||
{t('enterprise.last_updated')}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-yellow-900">
|
||||
<p className="text-lg font-semibold" style={{ color: 'var(--color-warning-900)' }}>
|
||||
{forecastSummary.last_updated ?
|
||||
new Date(forecastSummary.last_updated).toLocaleTimeString() :
|
||||
'N/A'}
|
||||
@@ -313,7 +490,7 @@ const EnterpriseDashboardPage = () => {
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
|
||||
@@ -6,14 +6,14 @@ import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
import { useAuthUser } from '../../../../stores/auth.store';
|
||||
import { useAIInsights, useAIInsightStats, useApplyInsight, useDismissInsight } from '../../../../api/hooks/aiInsights';
|
||||
import { AIInsight } from '../../../../api/services/aiInsights';
|
||||
import { useReasoningTranslation } from '../../../../hooks/useReasoningTranslation';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const AIInsightsPage: React.FC = () => {
|
||||
const [selectedCategory, setSelectedCategory] = useState('all');
|
||||
const currentTenant = useCurrentTenant();
|
||||
const user = useAuthUser();
|
||||
const tenantId = currentTenant?.id || user?.tenant_id;
|
||||
const { t } = useReasoningTranslation();
|
||||
const { t } = useTranslation('reasoning');
|
||||
|
||||
// Fetch real insights from API
|
||||
const { data: insightsData, isLoading, refetch } = useAIInsights(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Brain, TrendingUp, AlertCircle, Play, RotateCcw, Eye, Loader, CheckCircle } from 'lucide-react';
|
||||
import { Button, Badge, Modal, Table, Select, StatsGrid, StatusCard, SearchAndFilter, type FilterConfig, Card, EmptyState } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
@@ -40,7 +41,7 @@ interface ModelStatus {
|
||||
}
|
||||
|
||||
const ModelsConfigPage: React.FC = () => {
|
||||
|
||||
const navigate = useNavigate();
|
||||
const currentTenant = useCurrentTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
|
||||
@@ -495,9 +496,9 @@ const ModelsConfigPage: React.FC = () => {
|
||||
model={selectedModel}
|
||||
onRetrain={handleRetrain}
|
||||
onViewPredictions={(modelId) => {
|
||||
// TODO: Navigate to forecast history or predictions view
|
||||
// This should show historical predictions vs actual sales
|
||||
console.log('View predictions for model:', modelId);
|
||||
// Navigate to forecast history page filtered by this model
|
||||
navigate(`/app/operations/forecasting?model_id=${modelId}&view=history`);
|
||||
setShowModelDetailsModal(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Truck,
|
||||
Plus,
|
||||
Package,
|
||||
MapPin,
|
||||
Calendar,
|
||||
ArrowRight,
|
||||
Search,
|
||||
Filter,
|
||||
MoreVertical,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
AlertTriangle
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
StatsGrid,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Badge,
|
||||
Input
|
||||
} from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { useTenant } from '../../../../stores/tenant.store';
|
||||
import { useDistributionOverview } from '../../../../api/hooks/useEnterpriseDashboard';
|
||||
import DistributionMap from '../../../../components/maps/DistributionMap';
|
||||
|
||||
const DistributionPage: React.FC = () => {
|
||||
const { t } = useTranslation(['operations', 'common', 'dashboard']);
|
||||
const { currentTenant: tenant } = useTenant();
|
||||
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'routes' | 'shipments'>('overview');
|
||||
|
||||
// Fetch real distribution data
|
||||
const { data: distributionData, isLoading } = useDistributionOverview(
|
||||
tenant?.id || '',
|
||||
selectedDate,
|
||||
{ enabled: !!tenant?.id }
|
||||
);
|
||||
|
||||
// Derive stats from real data
|
||||
const stats = [
|
||||
{
|
||||
title: t('operations:stats.active_routes', 'Rutas Activas'),
|
||||
value: distributionData?.route_sequences?.filter((r: any) => r.status === 'in_progress').length || 0,
|
||||
variant: 'info' as const,
|
||||
icon: Truck,
|
||||
},
|
||||
{
|
||||
title: t('operations:stats.pending_deliveries', 'Entregas Pendientes'),
|
||||
value: distributionData?.status_counts?.pending || 0,
|
||||
variant: 'warning' as const,
|
||||
icon: Package,
|
||||
},
|
||||
{
|
||||
title: t('operations:stats.completed_deliveries', 'Entregas Completadas'),
|
||||
value: distributionData?.status_counts?.delivered || 0,
|
||||
variant: 'success' as const,
|
||||
icon: CheckCircle,
|
||||
},
|
||||
{
|
||||
title: t('operations:stats.total_routes', 'Total Rutas'),
|
||||
value: distributionData?.route_sequences?.length || 0,
|
||||
variant: 'default' as const,
|
||||
icon: MapPin,
|
||||
},
|
||||
];
|
||||
|
||||
const handleNewRoute = () => {
|
||||
// Navigate to create route page or open modal
|
||||
console.log('New route clicked');
|
||||
};
|
||||
|
||||
if (!tenant) return null;
|
||||
|
||||
// Prepare shipment status data safely
|
||||
const shipmentStatus = {
|
||||
pending: distributionData?.status_counts?.pending || 0,
|
||||
in_transit: distributionData?.status_counts?.in_transit || 0,
|
||||
delivered: distributionData?.status_counts?.delivered || 0,
|
||||
failed: distributionData?.status_counts?.failed || 0,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title={t('operations:distribution.title', 'Distribución y Logística')}
|
||||
description={t('operations:distribution.description', 'Gestión integral de la flota de reparto y seguimiento de entregas en tiempo real')}
|
||||
actions={[
|
||||
{
|
||||
id: "date-select",
|
||||
label: selectedDate,
|
||||
variant: "outline" as const,
|
||||
icon: Calendar,
|
||||
onClick: () => { }, // In a real app this would trigger a date picker
|
||||
size: "md"
|
||||
},
|
||||
{
|
||||
id: "add-new-route",
|
||||
label: t('operations:actions.new_route', 'Nueva Ruta'),
|
||||
variant: "primary" as const,
|
||||
icon: Plus,
|
||||
onClick: handleNewRoute,
|
||||
tooltip: t('operations:tooltips.new_route', 'Crear una nueva ruta de distribución'),
|
||||
size: "md"
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<StatsGrid
|
||||
stats={stats}
|
||||
columns={4}
|
||||
/>
|
||||
|
||||
{/* Main Content Areas */}
|
||||
<div className="flex flex-col gap-6">
|
||||
|
||||
{/* Tabs Navigation */}
|
||||
<div className="flex border-b border-gray-200">
|
||||
<button
|
||||
className={`px-4 py-2 font-medium text-sm transition-colors border-b-2 ${activeTab === 'overview'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
onClick={() => setActiveTab('overview')}
|
||||
>
|
||||
Vista General
|
||||
</button>
|
||||
<button
|
||||
className={`px-4 py-2 font-medium text-sm transition-colors border-b-2 ${activeTab === 'routes'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
onClick={() => setActiveTab('routes')}
|
||||
>
|
||||
Listado de Rutas
|
||||
</button>
|
||||
<button
|
||||
className={`px-4 py-2 font-medium text-sm transition-colors border-b-2 ${activeTab === 'shipments'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
onClick={() => setActiveTab('shipments')}
|
||||
>
|
||||
Listado de Envíos
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content based on Active Tab */}
|
||||
{activeTab === 'overview' && (
|
||||
<div className="space-y-6">
|
||||
{/* Map Section */}
|
||||
<Card className="overflow-hidden border-none shadow-lg">
|
||||
<CardHeader className="bg-white border-b sticky top-0 z-10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-2 bg-blue-100 rounded-lg">
|
||||
<MapPin className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>{t('operations:map.title', 'Mapa de Distribución')}</CardTitle>
|
||||
<p className="text-sm text-gray-500">Visualización en tiempo real de la flota</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
||||
En Vivo
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="p-4 bg-slate-50">
|
||||
<DistributionMap
|
||||
routes={distributionData?.route_sequences || []}
|
||||
shipments={shipmentStatus}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Recent Activity / Quick List */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Rutas en Progreso</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{distributionData?.route_sequences?.filter((r: any) => r.status === 'in_progress').length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{distributionData.route_sequences
|
||||
.filter((r: any) => r.status === 'in_progress')
|
||||
.map((route: any) => (
|
||||
<div key={route.id} className="flex items-center justify-between p-3 bg-white border rounded-lg shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-50 rounded-full">
|
||||
<Truck className="w-4 h-4 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-sm text-gray-900">Ruta {route.route_number}</p>
|
||||
<p className="text-xs text-gray-500">{route.formatted_driver_name || 'Sin conductor asignado'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="info">En Ruta</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
No hay rutas en progreso actualmente.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'routes' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Listado de Rutas</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Buscar rutas..."
|
||||
leftIcon={<Search className="w-4 h-4 text-gray-400" />}
|
||||
className="w-64"
|
||||
/>
|
||||
<Button variant="outline" size="sm" leftIcon={<Filter className="w-4 h-4" />}>Filtros</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{(distributionData?.route_sequences?.length || 0) > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="text-xs text-gray-700 uppercase bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3">Ruta</th>
|
||||
<th className="px-4 py-3">Estado</th>
|
||||
<th className="px-4 py-3">Distancia</th>
|
||||
<th className="px-4 py-3">Duración Est.</th>
|
||||
<th className="px-4 py-3">Paradas</th>
|
||||
<th className="px-4 py-3 text-right">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{distributionData.route_sequences.map((route: any) => (
|
||||
<tr key={route.id} className="border-b hover:bg-gray-50">
|
||||
<td className="px-4 py-3 font-medium">{route.route_number}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant={
|
||||
route.status === 'completed' ? 'success' :
|
||||
route.status === 'in_progress' ? 'info' :
|
||||
route.status === 'pending' ? 'warning' : 'default'
|
||||
}>
|
||||
{route.status}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3">{route.total_distance_km?.toFixed(1) || '-'} km</td>
|
||||
<td className="px-4 py-3">{route.estimated_duration_minutes || '-'} min</td>
|
||||
<td className="px-4 py-3">{route.route_points?.length || 0}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<Button variant="ghost" size="sm" leftIcon={<MoreVertical className="w-4 h-4" />} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 bg-gray-50 rounded-lg border border-dashed">
|
||||
<p className="text-gray-500">No se encontraron rutas para esta fecha.</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Similar structure for Shipments tab, simplified for now */}
|
||||
{activeTab === 'shipments' && (
|
||||
<div className="text-center py-12 bg-gray-50 rounded-lg border border-dashed">
|
||||
<Package className="w-12 h-12 text-gray-300 mx-auto mb-3" />
|
||||
<h3 className="text-lg font-medium text-gray-900">Gestión de Envíos</h3>
|
||||
<p className="text-gray-500">Funcionalidad de listado detallado de envíos próximamente.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DistributionPage;
|
||||
@@ -271,9 +271,34 @@ const TeamPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleSaveMember = async () => {
|
||||
// TODO: Implement member update logic
|
||||
console.log('Saving member:', memberFormData);
|
||||
setShowMemberModal(false);
|
||||
try {
|
||||
// Update user profile
|
||||
if (selectedMember?.user_id) {
|
||||
await userService.updateUser(selectedMember.user_id, {
|
||||
full_name: memberFormData.full_name,
|
||||
email: memberFormData.email,
|
||||
phone: memberFormData.phone,
|
||||
language: memberFormData.language,
|
||||
timezone: memberFormData.timezone
|
||||
});
|
||||
}
|
||||
|
||||
// Update role if changed
|
||||
if (memberFormData.role !== selectedMember?.role) {
|
||||
await updateRoleMutation.mutateAsync({
|
||||
tenantId,
|
||||
memberUserId: selectedMember.user_id,
|
||||
newRole: memberFormData.role
|
||||
});
|
||||
}
|
||||
|
||||
showToast.success(t('settings:team.member_updated_success', 'Miembro actualizado exitosamente'));
|
||||
setShowMemberModal(false);
|
||||
setModalMode('view');
|
||||
} catch (error) {
|
||||
console.error('Error updating member:', error);
|
||||
showToast.error(t('settings:team.member_updated_error', 'Error al actualizar miembro'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleFieldChange = (sectionIndex: number, fieldIndex: number, value: string | number) => {
|
||||
|
||||
@@ -4,105 +4,12 @@ import { useTranslation } from 'react-i18next';
|
||||
import { PublicLayout } from '../../components/layout';
|
||||
import { Calendar, Clock, ArrowRight, Brain } from 'lucide-react';
|
||||
|
||||
interface BlogPost {
|
||||
id: string;
|
||||
slug: string;
|
||||
titleKey: string;
|
||||
excerptKey: string;
|
||||
authorKey: string;
|
||||
date: string;
|
||||
readTime: string;
|
||||
categoryKey: string;
|
||||
tagsKeys: string[];
|
||||
}
|
||||
import { blogPosts } from '../../constants/blog';
|
||||
|
||||
const BlogPage: React.FC = () => {
|
||||
const { t, i18n } = useTranslation(['blog', 'common']);
|
||||
|
||||
// Blog posts metadata - translations come from i18n
|
||||
const blogPosts: BlogPost[] = [
|
||||
{
|
||||
id: '1',
|
||||
slug: 'reducir-desperdicio-alimentario-panaderia',
|
||||
titleKey: 'posts.waste_reduction.title',
|
||||
excerptKey: 'posts.waste_reduction.excerpt',
|
||||
authorKey: 'posts.waste_reduction.author',
|
||||
date: '2025-01-15',
|
||||
readTime: '8',
|
||||
categoryKey: 'categories.management',
|
||||
tagsKeys: [
|
||||
'posts.waste_reduction.tags.food_waste',
|
||||
'posts.waste_reduction.tags.sustainability',
|
||||
'posts.waste_reduction.tags.ai',
|
||||
'posts.waste_reduction.tags.management',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
slug: 'ia-predecir-demanda-panaderia',
|
||||
titleKey: 'posts.ai_prediction.title',
|
||||
excerptKey: 'posts.ai_prediction.excerpt',
|
||||
authorKey: 'posts.ai_prediction.author',
|
||||
date: '2025-01-10',
|
||||
readTime: '10',
|
||||
categoryKey: 'categories.technology',
|
||||
tagsKeys: [
|
||||
'posts.ai_prediction.tags.ai',
|
||||
'posts.ai_prediction.tags.machine_learning',
|
||||
'posts.ai_prediction.tags.prediction',
|
||||
'posts.ai_prediction.tags.technology',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
slug: 'optimizar-produccion-panaderia-artesanal',
|
||||
titleKey: 'posts.production_optimization.title',
|
||||
excerptKey: 'posts.production_optimization.excerpt',
|
||||
authorKey: 'posts.production_optimization.author',
|
||||
date: '2025-01-05',
|
||||
readTime: '12',
|
||||
categoryKey: 'categories.production',
|
||||
tagsKeys: [
|
||||
'posts.production_optimization.tags.optimization',
|
||||
'posts.production_optimization.tags.production',
|
||||
'posts.production_optimization.tags.artisan',
|
||||
'posts.production_optimization.tags.management',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
slug: 'obrador-central-vs-produccion-local',
|
||||
titleKey: 'posts.central_vs_local.title',
|
||||
excerptKey: 'posts.central_vs_local.excerpt',
|
||||
authorKey: 'posts.central_vs_local.author',
|
||||
date: '2025-01-20',
|
||||
readTime: '15',
|
||||
categoryKey: 'categories.strategy',
|
||||
tagsKeys: [
|
||||
'posts.central_vs_local.tags.business_models',
|
||||
'posts.central_vs_local.tags.central_bakery',
|
||||
'posts.central_vs_local.tags.local_production',
|
||||
'posts.central_vs_local.tags.scalability',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
slug: 'gdpr-proteccion-datos-panaderia',
|
||||
titleKey: 'posts.gdpr.title',
|
||||
excerptKey: 'posts.gdpr.excerpt',
|
||||
authorKey: 'posts.gdpr.author',
|
||||
date: '2025-01-01',
|
||||
readTime: '9',
|
||||
categoryKey: 'categories.legal',
|
||||
tagsKeys: [
|
||||
'posts.gdpr.tags.gdpr',
|
||||
'posts.gdpr.tags.rgpd',
|
||||
'posts.gdpr.tags.privacy',
|
||||
'posts.gdpr.tags.legal',
|
||||
'posts.gdpr.tags.security',
|
||||
],
|
||||
},
|
||||
];
|
||||
// Blog posts are now imported from constants/blog.ts
|
||||
|
||||
return (
|
||||
<PublicLayout
|
||||
|
||||
185
frontend/src/pages/public/BlogPostPage.tsx
Normal file
185
frontend/src/pages/public/BlogPostPage.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import React from 'react';
|
||||
import { useParams, Navigate, Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PublicLayout } from '../../components/layout';
|
||||
import { Calendar, Clock, ArrowLeft, User, Tag } from 'lucide-react';
|
||||
import { blogPosts } from '../../constants/blog';
|
||||
|
||||
const BlogPostPage: React.FC = () => {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const { t, i18n } = useTranslation(['blog', 'common']);
|
||||
|
||||
const post = blogPosts.find((p) => p.slug === slug);
|
||||
|
||||
if (!post) {
|
||||
return <Navigate to="/blog" replace />;
|
||||
}
|
||||
|
||||
// Helper to render content sections dynamically
|
||||
const renderContent = () => {
|
||||
// We need to access the structure of the content from the translation file
|
||||
// Since i18next t() function returns a string, we need to know the structure beforehand
|
||||
// or use returnObjects: true, but that returns an unknown type.
|
||||
// For this implementation, we'll assume a standard structure based on the existing blog.json
|
||||
|
||||
// However, since the structure varies per post (e.g. problem_title, solution_1_title),
|
||||
// we might need a more flexible approach or standardized content structure.
|
||||
// Given the current JSON structure, it's quite specific per post.
|
||||
// A robust way is to use `t` with `returnObjects: true` and iterate, but for now,
|
||||
// let's try to render specific known sections if they exist, or just use a generic "content" key if we refactor.
|
||||
|
||||
// Actually, looking at blog.json, the content is nested under `content`.
|
||||
// We can try to render the `intro` and then specific sections if we can infer them.
|
||||
// But since the keys are like `problem_title`, `solution_1_title`, it's hard to iterate without knowing keys.
|
||||
|
||||
// A better approach for this specific codebase without refactoring all JSONs might be
|
||||
// to just render the `intro` and `conclusion` and maybe a "read full guide" if it was a real app,
|
||||
// but here we want to show the content.
|
||||
|
||||
// Let's use `t` to get the whole content object and iterate over keys?
|
||||
// i18next `t` with `returnObjects: true` returns the object.
|
||||
const content = t(`blog:${post.titleKey.replace('.title', '.content')}`, { returnObjects: true });
|
||||
|
||||
if (typeof content !== 'object' || content === null) {
|
||||
return <p>{t('blog:post.content_not_available')}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="prose prose-lg max-w-none text-[var(--text-secondary)]">
|
||||
{Object.entries(content).map(([key, value]) => {
|
||||
if (key === 'intro' || key === 'conclusion') {
|
||||
return <p key={key} className="mb-6">{value as string}</p>;
|
||||
}
|
||||
if (key.endsWith('_title')) {
|
||||
return <h3 key={key} className="text-2xl font-bold text-[var(--text-primary)] mt-8 mb-4">{value as string}</h3>;
|
||||
}
|
||||
if (key.endsWith('_desc')) {
|
||||
// Check if it contains markdown-like bold
|
||||
const text = value as string;
|
||||
const parts = text.split(/(\*\*.*?\*\*)/g);
|
||||
return (
|
||||
<p key={key} className="mb-4">
|
||||
{parts.map((part, i) => {
|
||||
if (part.startsWith('**') && part.endsWith('**')) {
|
||||
return <strong key={i} className="text-[var(--text-primary)]">{part.slice(2, -2)}</strong>;
|
||||
}
|
||||
return part;
|
||||
})}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return (
|
||||
<ul key={key} className="list-disc pl-6 mb-6 space-y-2">
|
||||
{(value as string[]).map((item, index) => {
|
||||
// Handle bold text in list items
|
||||
const parts = item.split(/(\*\*.*?\*\*)/g);
|
||||
return (
|
||||
<li key={index}>
|
||||
{parts.map((part, i) => {
|
||||
if (part.startsWith('**') && part.endsWith('**')) {
|
||||
return <strong key={i} className="text-[var(--text-primary)]">{part.slice(2, -2)}</strong>;
|
||||
}
|
||||
return part;
|
||||
})}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
// Fallback for other string keys that might be paragraphs
|
||||
if (typeof value === 'string' && !key.includes('_title') && !key.includes('_desc')) {
|
||||
return <p key={key} className="mb-4">{value}</p>;
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<PublicLayout
|
||||
variant="default"
|
||||
contentPadding="md"
|
||||
headerProps={{
|
||||
showThemeToggle: true,
|
||||
showAuthButtons: true,
|
||||
showLanguageSelector: true,
|
||||
variant: "default"
|
||||
}}
|
||||
>
|
||||
<article className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
|
||||
{/* Back Link */}
|
||||
<Link
|
||||
to="/blog"
|
||||
className="inline-flex items-center gap-2 text-[var(--text-tertiary)] hover:text-[var(--color-primary)] mb-8 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<span>{t('common:actions.back')}</span>
|
||||
</Link>
|
||||
|
||||
{/* Header */}
|
||||
<header className="mb-12">
|
||||
<div className="flex flex-wrap items-center gap-4 mb-6">
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-[var(--color-primary)]/10 text-[var(--color-primary)]">
|
||||
{t(`blog:${post.categoryKey}`)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 text-[var(--text-tertiary)]">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span>
|
||||
{new Date(post.date).toLocaleDateString(i18n.language, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[var(--text-tertiary)]">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>{t('blog:post.read_time', { time: post.readTime })}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="text-4xl lg:text-5xl font-extrabold text-[var(--text-primary)] mb-6 leading-tight">
|
||||
{t(`blog:${post.titleKey}`)}
|
||||
</h1>
|
||||
|
||||
<div className="flex items-center gap-3 pb-8 border-b border-[var(--border-primary)]">
|
||||
<div className="w-12 h-12 rounded-full bg-[var(--bg-tertiary)] flex items-center justify-center text-[var(--text-secondary)]">
|
||||
<User className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-[var(--text-primary)]">
|
||||
{t(`blog:${post.authorKey}`)}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--text-tertiary)]">
|
||||
{t('blog:post.author_role', { defaultValue: 'Contributor' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
{renderContent()}
|
||||
|
||||
{/* Footer Tags */}
|
||||
<div className="mt-12 pt-8 border-t border-[var(--border-primary)]">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{post.tagsKeys.map((tagKey) => (
|
||||
<span
|
||||
key={tagKey}
|
||||
className="inline-flex items-center gap-1 px-3 py-1 rounded-full bg-[var(--bg-tertiary)] text-[var(--text-secondary)] text-sm"
|
||||
>
|
||||
<Tag className="w-3 h-3" />
|
||||
{t(`blog:${tagKey}`)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</PublicLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlogPostPage;
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
Building,
|
||||
Package,
|
||||
BarChart3,
|
||||
ForkKnife,
|
||||
|
||||
ChefHat,
|
||||
CreditCard,
|
||||
Bell,
|
||||
@@ -295,10 +295,8 @@ const DemoPage = () => {
|
||||
// Full success - navigate immediately
|
||||
clearInterval(progressInterval);
|
||||
setTimeout(() => {
|
||||
const targetUrl = tier === 'enterprise'
|
||||
? `/app/tenants/${sessionData.virtual_tenant_id}/enterprise`
|
||||
: `/app/tenants/${sessionData.virtual_tenant_id}/dashboard`;
|
||||
navigate(targetUrl);
|
||||
// Navigate to the main dashboard which will automatically route to enterprise or bakery dashboard based on subscription tier
|
||||
navigate('/app/dashboard');
|
||||
}, 1000);
|
||||
return;
|
||||
} else if (statusData.status === 'PARTIAL' || statusData.status === 'partial') {
|
||||
@@ -582,9 +580,8 @@ const DemoPage = () => {
|
||||
{demoOptions.map((option) => (
|
||||
<Card
|
||||
key={option.id}
|
||||
className={`cursor-pointer hover:shadow-xl transition-all border-2 ${
|
||||
selectedTier === option.id ? 'border-primary bg-primary/5' : 'border-gray-200 dark:border-gray-700'
|
||||
}`}
|
||||
className={`cursor-pointer hover:shadow-xl transition-all border-2 ${selectedTier === option.id ? 'border-primary bg-primary/5' : 'border-gray-200 dark:border-gray-700'
|
||||
}`}
|
||||
onClick={() => setSelectedTier(option.id)}
|
||||
>
|
||||
<CardHeader>
|
||||
@@ -679,62 +676,69 @@ const DemoPage = () => {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Loading Progress */}
|
||||
{/* Loading Progress Modal */}
|
||||
{creatingTier !== null && (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-center text-[var(--text-primary)]">Configurando Tu Demo</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>Progreso total</span>
|
||||
<span>{cloneProgress.overall}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${cloneProgress.overall}%` }}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
{creatingTier === 'enterprise' && (
|
||||
<div className="space-y-3 mt-4">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="font-medium">Obrador Central</span>
|
||||
<span>{cloneProgress.parent}%</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{cloneProgress.children.map((progress, index) => (
|
||||
<div key={index} className="text-center">
|
||||
<div className="text-xs text-[var(--text-tertiary)] mb-1">Outlet {index + 1}</div>
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
|
||||
<div
|
||||
className="bg-blue-500 h-1.5 rounded-full transition-all duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<div className="text-xs mt-1">{progress}%</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between text-sm mt-2">
|
||||
<span className="font-medium">Distribución</span>
|
||||
<span>{cloneProgress.distribution}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
|
||||
<div
|
||||
className="bg-purple-500 h-1.5 rounded-full transition-all duration-300"
|
||||
style={{ width: `${cloneProgress.distribution}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Modal
|
||||
isOpen={creatingTier !== null}
|
||||
onClose={() => { }}
|
||||
size="md"
|
||||
>
|
||||
<ModalHeader
|
||||
title="Configurando Tu Demo"
|
||||
showCloseButton={false}
|
||||
/>
|
||||
<ModalBody padding="lg">
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>Progreso total</span>
|
||||
<span>{cloneProgress.overall}%</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${cloneProgress.overall}%` }}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div className="text-center text-sm text-[var(--text-secondary)] mt-4">
|
||||
{getLoadingMessage(creatingTier, cloneProgress.overall)}
|
||||
</div>
|
||||
|
||||
{creatingTier === 'enterprise' && (
|
||||
<div className="space-y-3 mt-4">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="font-medium">Obrador Central</span>
|
||||
<span>{cloneProgress.parent}%</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{cloneProgress.children.map((progress, index) => (
|
||||
<div key={index} className="text-center">
|
||||
<div className="text-xs text-[var(--text-tertiary)] mb-1">Outlet {index + 1}</div>
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
|
||||
<div
|
||||
className="bg-blue-500 h-1.5 rounded-full transition-all duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<div className="text-xs mt-1">{progress}%</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between text-sm mt-2">
|
||||
<span className="font-medium">Distribución</span>
|
||||
<span>{cloneProgress.distribution}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
|
||||
<div
|
||||
className="bg-purple-500 h-1.5 rounded-full transition-all duration-300"
|
||||
style={{ width: `${cloneProgress.distribution}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* Error Alert */}
|
||||
@@ -798,11 +802,9 @@ const DemoPage = () => {
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
const tierUrl = partialWarning.tier === 'enterprise'
|
||||
? `/demo/${partialWarning.sessionData.session_id}/enterprise`
|
||||
: `/demo/${partialWarning.sessionData.session_id}`;
|
||||
// Navigate to the main dashboard which will automatically route to enterprise or bakery dashboard based on subscription tier
|
||||
setPartialWarning(null);
|
||||
navigate(tierUrl);
|
||||
navigate('/app/dashboard');
|
||||
}}
|
||||
>
|
||||
Continuar con Demo Parcial
|
||||
@@ -881,11 +883,9 @@ const DemoPage = () => {
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
const tierUrl = timeoutModal.tier === 'enterprise'
|
||||
? `/demo/${timeoutModal.sessionData.session_id}/enterprise`
|
||||
: `/demo/${timeoutModal.sessionData.session_id}`;
|
||||
// Navigate to the main dashboard which will automatically route to enterprise or bakery dashboard based on subscription tier
|
||||
setTimeoutModal(null);
|
||||
navigate(tierUrl);
|
||||
navigate('/app/dashboard');
|
||||
}}
|
||||
>
|
||||
Iniciar con Datos Parciales
|
||||
@@ -905,42 +905,6 @@ const DemoPage = () => {
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* Comparison Section */}
|
||||
<div className="mt-16">
|
||||
<h2 className="text-3xl font-bold text-center mb-8 text-[var(--text-primary)]">Comparación de Funcionalidades</h2>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg overflow-hidden">
|
||||
<div className="grid grid-cols-3 divide-x divide-[var(--border-primary)]">
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-700">
|
||||
<h3 className="font-semibold text-center text-[var(--text-primary)]">Función</h3>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="text-center font-semibold text-blue-600 dark:text-blue-400">Professional</div>
|
||||
<div className="text-center text-sm text-[var(--text-tertiary)]">Individual Bakery</div>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="text-center font-semibold text-purple-600 dark:text-purple-400">Enterprise</div>
|
||||
<div className="text-center text-sm text-[var(--text-tertiary)]">Chain of Bakeries</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{[
|
||||
{ feature: 'Número Máximo de Ubicaciones', professional: '1', enterprise: 'Ilimitado' },
|
||||
{ feature: 'Gestión de Inventario', professional: '✓', enterprise: '✓ Agregado' },
|
||||
{ feature: 'Forecasting con IA', professional: 'Personalizado', enterprise: 'Agregado + Individual' },
|
||||
{ feature: 'Planificación de Producción', professional: '✓', enterprise: '✓ Centralizada' },
|
||||
{ feature: 'Transferencias Internas', professional: '×', enterprise: '✓ Optimizadas' },
|
||||
{ feature: 'Logística y Rutas', professional: '×', enterprise: '✓ Optimización VRP' },
|
||||
{ feature: 'Dashboard Multi-ubicación', professional: '×', enterprise: '✓ Visión de Red' },
|
||||
{ feature: 'Reportes Consolidados', professional: '×', enterprise: '✓ Nivel de Red' }
|
||||
].map((row, index) => (
|
||||
<div key={index} className={`grid grid-cols-3 divide-x divide-[var(--border-primary)] ${index % 2 === 0 ? 'bg-gray-50 dark:bg-gray-700' : 'bg-white dark:bg-gray-800'}`}>
|
||||
<div className="p-3 text-sm text-[var(--text-secondary)]">{row.feature}</div>
|
||||
<div className="p-3 text-center text-sm text-[var(--text-secondary)]">{row.professional}</div>
|
||||
<div className="p-3 text-center text-sm text-[var(--text-secondary)]">{row.enterprise}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</PublicLayout>
|
||||
|
||||
49
frontend/src/pages/public/UnauthorizedPage.tsx
Normal file
49
frontend/src/pages/public/UnauthorizedPage.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import { ROUTES } from '../../router/routes.config';
|
||||
|
||||
const UnauthorizedPage: React.FC = () => (
|
||||
<div className="flex items-center justify-center min-h-screen bg-bg-primary">
|
||||
<div className="text-center max-w-md mx-auto px-6">
|
||||
<div className="mb-6">
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-color-error rounded-full flex items-center justify-center">
|
||||
<svg
|
||||
className="w-8 h-8 text-text-inverse"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.732 19.5c-.77.833.192 2.5 1.732 2.5z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-text-primary mb-2">
|
||||
Acceso no autorizado
|
||||
</h1>
|
||||
<p className="text-text-secondary mb-6">
|
||||
No tienes permisos para acceder a esta página. Contacta con tu administrador si crees que esto es un error.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<button
|
||||
onClick={() => window.history.back()}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
Volver atrás
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.location.href = ROUTES.DASHBOARD}
|
||||
className="btn btn-outline"
|
||||
>
|
||||
Ir al Panel de Control
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default UnauthorizedPage;
|
||||
Reference in New Issue
Block a user