feat: Complete JTBD-aligned bakery dashboard redesign

Implements comprehensive dashboard redesign based on Jobs To Be Done methodology
focused on answering: "What requires my attention right now?"

## Backend Implementation

### Dashboard Service (NEW)
- Health status calculation (green/yellow/red traffic light)
- Action queue prioritization (critical/important/normal)
- Orchestration summary with narrative format
- Production timeline transformation
- Insights calculation and consequence prediction

### API Endpoints (NEW)
- GET /dashboard/health-status - Overall bakery health indicator
- GET /dashboard/orchestration-summary - What system did automatically
- GET /dashboard/action-queue - Prioritized tasks requiring attention
- GET /dashboard/production-timeline - Today's production schedule
- GET /dashboard/insights - Key metrics (savings, inventory, waste, deliveries)

### Enhanced Models
- PurchaseOrder: Added reasoning, consequence, reasoning_data fields
- ProductionBatch: Added reasoning, reasoning_data fields
- Enables transparency into automation decisions

## Frontend Implementation

### API Hooks (NEW)
- useBakeryHealthStatus() - Real-time health monitoring
- useOrchestrationSummary() - System transparency
- useActionQueue() - Prioritized action management
- useProductionTimeline() - Production tracking
- useInsights() - Glanceable metrics

### Dashboard Components (NEW)
- HealthStatusCard: Traffic light indicator with checklist
- ActionQueueCard: Prioritized actions with reasoning/consequences
- OrchestrationSummaryCard: Narrative of what system did
- ProductionTimelineCard: Chronological production view
- InsightsGrid: 2x2 grid of key metrics

### Main Dashboard Page (REPLACED)
- Complete rewrite with mobile-first design
- All sections integrated with error handling
- Real-time refresh and quick action links
- Old dashboard backed up as DashboardPage.legacy.tsx

## Key Features

### Automation-First
- Shows what orchestrator did overnight
- Builds trust through transparency
- Explains reasoning for all automated decisions

### Action-Oriented
- Prioritizes tasks over information display
- Clear consequences for each action
- Large touch-friendly buttons

### Progressive Disclosure
- Shows 20% of info that matters 80% of time
- Expandable details when needed
- No overwhelming metrics

### Mobile-First
- One-handed operation
- Large touch targets (min 44px)
- Responsive grid layouts

### Trust-Building
- Narrative format ("I planned your day")
- Reasoning inputs transparency
- Clear status indicators

## User Segments Supported

1. Solo Bakery Owner (Primary)
   - Simple health indicator
   - Action checklist (max 3-5 items)
   - Mobile-optimized

2. Multi-Location Owner
   - Multi-tenant support (existing)
   - Comparison capabilities
   - Delegation ready

3. Enterprise/Central Bakery (Future)
   - Network topology support
   - Advanced analytics ready

## JTBD Analysis Delivered

Main Job: "Help me quickly understand bakery status and know what needs my intervention"

Emotional Jobs Addressed:
- Feel in control despite automation
- Reduce daily anxiety
- Feel competent with technology
- Trust system as safety net

Social Jobs Addressed:
- Demonstrate professional management
- Avoid being bottleneck
- Show sustainability

## Technical Stack

Backend: Python, FastAPI, SQLAlchemy, PostgreSQL
Frontend: React, TypeScript, TanStack Query, Tailwind CSS
Architecture: Microservices with circuit breakers

## Breaking Changes

- Complete dashboard page rewrite (old version backed up)
- New API endpoints require orchestrator service deployment
- Database migrations needed for reasoning fields

## Migration Required

Run migrations to add new model fields:
- purchase_orders: reasoning, consequence, reasoning_data
- production_batches: reasoning, reasoning_data

## Documentation

See DASHBOARD_REDESIGN_SUMMARY.md for complete implementation details,
JTBD analysis, success metrics, and deployment guide.

BREAKING CHANGE: Dashboard page completely redesigned with new data structures
This commit is contained in:
Claude
2025-11-07 17:10:17 +00:00
parent 41d3998f53
commit 2ced1ec670
17 changed files with 3545 additions and 565 deletions

View File

@@ -0,0 +1,330 @@
// ================================================================
// frontend/src/api/hooks/newDashboard.ts
// ================================================================
/**
* API Hooks for JTBD-Aligned Dashboard
*
* Provides data fetching for the redesigned bakery dashboard with focus on:
* - Health status
* - Action queue
* - Orchestration summary
* - Production timeline
* - Key insights
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../client';
// ============================================================
// Types
// ============================================================
export interface HealthChecklistItem {
icon: 'check' | 'warning' | 'alert';
text: string;
actionRequired: boolean;
}
export interface BakeryHealthStatus {
status: 'green' | 'yellow' | 'red';
headline: string;
lastOrchestrationRun: string | null;
nextScheduledRun: string;
checklistItems: HealthChecklistItem[];
criticalIssues: number;
pendingActions: number;
}
export interface ReasoningInputs {
customerOrders: number;
historicalDemand: boolean;
inventoryLevels: boolean;
aiInsights: boolean;
}
export interface PurchaseOrderSummary {
supplierName: string;
itemCategories: string[];
totalAmount: number;
}
export interface ProductionBatchSummary {
productName: string;
quantity: number;
readyByTime: string;
}
export interface OrchestrationSummary {
runTimestamp: string | null;
runNumber: number | null;
status: string;
purchaseOrdersCreated: number;
purchaseOrdersSummary: PurchaseOrderSummary[];
productionBatchesCreated: number;
productionBatchesSummary: ProductionBatchSummary[];
reasoningInputs: ReasoningInputs;
userActionsRequired: number;
durationSeconds: number | null;
aiAssisted: boolean;
message?: string;
}
export interface ActionButton {
label: string;
type: 'primary' | 'secondary' | 'tertiary';
action: string;
}
export interface ActionItem {
id: string;
type: string;
urgency: 'critical' | 'important' | 'normal';
title: string;
subtitle: string;
reasoning: string;
consequence: string;
amount?: number;
currency?: string;
actions: ActionButton[];
estimatedTimeMinutes: number;
}
export interface ActionQueue {
actions: ActionItem[];
totalActions: number;
criticalCount: number;
importantCount: number;
}
export interface ProductionTimelineItem {
id: string;
batchNumber: string;
productName: string;
quantity: number;
unit: string;
plannedStartTime: string | null;
plannedEndTime: string | null;
actualStartTime: string | null;
status: string;
statusIcon: string;
statusText: string;
progress: number;
readyBy: string | null;
priority: string;
reasoning: string;
}
export interface ProductionTimeline {
timeline: ProductionTimelineItem[];
totalBatches: number;
completedBatches: number;
inProgressBatches: number;
pendingBatches: number;
}
export interface InsightCard {
label: string;
value: string;
detail: string;
color: 'green' | 'amber' | 'red';
}
export interface Insights {
savings: InsightCard;
inventory: InsightCard;
waste: InsightCard;
deliveries: InsightCard;
}
// ============================================================
// Hooks
// ============================================================
/**
* Get bakery health status
*
* This is the top-level health indicator showing if the bakery is running smoothly.
* Updates every 30 seconds to keep status fresh.
*/
export function useBakeryHealthStatus(tenantId: string) {
return useQuery<BakeryHealthStatus>({
queryKey: ['bakery-health-status', tenantId],
queryFn: async () => {
const response = await apiClient.get(
`/orchestrator/tenants/${tenantId}/dashboard/health-status`
);
return response.data;
},
refetchInterval: 30000, // Refresh every 30 seconds
staleTime: 20000, // Consider stale after 20 seconds
retry: 2,
});
}
/**
* Get orchestration summary
*
* Shows what the automated system did (transparency for trust building).
*/
export function useOrchestrationSummary(tenantId: string, runId?: string) {
return useQuery<OrchestrationSummary>({
queryKey: ['orchestration-summary', tenantId, runId],
queryFn: async () => {
const params = runId ? { run_id: runId } : {};
const response = await apiClient.get(
`/orchestrator/tenants/${tenantId}/dashboard/orchestration-summary`,
{ params }
);
return response.data;
},
staleTime: 60000, // Summary doesn't change often
retry: 2,
});
}
/**
* Get action queue
*
* Prioritized list of what requires user attention right now.
* This is the core JTBD dashboard feature.
*/
export function useActionQueue(tenantId: string) {
return useQuery<ActionQueue>({
queryKey: ['action-queue', tenantId],
queryFn: async () => {
const response = await apiClient.get(
`/orchestrator/tenants/${tenantId}/dashboard/action-queue`
);
return response.data;
},
refetchInterval: 60000, // Refresh every minute
staleTime: 30000,
retry: 2,
});
}
/**
* Get production timeline
*
* Shows today's production schedule in chronological order.
*/
export function useProductionTimeline(tenantId: string) {
return useQuery<ProductionTimeline>({
queryKey: ['production-timeline', tenantId],
queryFn: async () => {
const response = await apiClient.get(
`/orchestrator/tenants/${tenantId}/dashboard/production-timeline`
);
return response.data;
},
refetchInterval: 60000, // Refresh every minute
staleTime: 30000,
retry: 2,
});
}
/**
* Get key insights
*
* Glanceable metrics on savings, inventory, waste, and deliveries.
*/
export function useInsights(tenantId: string) {
return useQuery<Insights>({
queryKey: ['dashboard-insights', tenantId],
queryFn: async () => {
const response = await apiClient.get(
`/orchestrator/tenants/${tenantId}/dashboard/insights`
);
return response.data;
},
refetchInterval: 120000, // Refresh every 2 minutes
staleTime: 60000,
retry: 2,
});
}
// ============================================================
// Action Mutations
// ============================================================
/**
* Approve a purchase order from the action queue
*/
export function useApprovePurchaseOrder() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ tenantId, poId }: { tenantId: string; poId: string }) => {
const response = await apiClient.post(
`/procurement/tenants/${tenantId}/purchase-orders/${poId}/approve`
);
return response.data;
},
onSuccess: (_, variables) => {
// Invalidate relevant queries
queryClient.invalidateQueries({ queryKey: ['action-queue', variables.tenantId] });
queryClient.invalidateQueries({ queryKey: ['bakery-health-status', variables.tenantId] });
queryClient.invalidateQueries({ queryKey: ['orchestration-summary', variables.tenantId] });
},
});
}
/**
* Dismiss an alert from the action queue
*/
export function useDismissAlert() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ tenantId, alertId }: { tenantId: string; alertId: string }) => {
const response = await apiClient.post(
`/alert-processor/tenants/${tenantId}/alerts/${alertId}/dismiss`
);
return response.data;
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['action-queue', variables.tenantId] });
queryClient.invalidateQueries({ queryKey: ['bakery-health-status', variables.tenantId] });
},
});
}
/**
* Start a production batch
*/
export function useStartProductionBatch() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ tenantId, batchId }: { tenantId: string; batchId: string }) => {
const response = await apiClient.post(
`/production/tenants/${tenantId}/production-batches/${batchId}/start`
);
return response.data;
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['production-timeline', variables.tenantId] });
queryClient.invalidateQueries({ queryKey: ['bakery-health-status', variables.tenantId] });
},
});
}
/**
* Pause a production batch
*/
export function usePauseProductionBatch() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ tenantId, batchId }: { tenantId: string; batchId: string }) => {
const response = await apiClient.post(
`/production/tenants/${tenantId}/production-batches/${batchId}/pause`
);
return response.data;
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['production-timeline', variables.tenantId] });
queryClient.invalidateQueries({ queryKey: ['bakery-health-status', variables.tenantId] });
},
});
}

View File

@@ -742,4 +742,29 @@ export {
useRunDailyWorkflow,
} from './hooks/orchestrator';
// Hooks - New Dashboard (JTBD-aligned)
export {
useBakeryHealthStatus,
useOrchestrationSummary,
useActionQueue,
useProductionTimeline,
useInsights,
useApprovePurchaseOrder as useApprovePurchaseOrderDashboard,
useDismissAlert as useDismissAlertDashboard,
useStartProductionBatch,
usePauseProductionBatch,
} from './hooks/newDashboard';
export type {
BakeryHealthStatus,
HealthChecklistItem,
OrchestrationSummary,
ActionQueue,
ActionItem,
ProductionTimeline,
ProductionTimelineItem,
Insights,
InsightCard,
} from './hooks/newDashboard';
// Note: All query key factories are already exported in their respective hook sections above

View File

@@ -0,0 +1,249 @@
// ================================================================
// frontend/src/components/dashboard/ActionQueueCard.tsx
// ================================================================
/**
* Action Queue Card - What needs your attention right now
*
* Prioritized list of actions the user needs to take, with context
* about why each action is needed and what happens if they don't do it.
*/
import React, { useState } from 'react';
import {
FileText,
AlertCircle,
CheckCircle2,
Eye,
Edit,
Clock,
Euro,
ChevronDown,
ChevronUp,
} from 'lucide-react';
import { ActionItem, ActionQueue } from '../../api/hooks/newDashboard';
interface ActionQueueCardProps {
actionQueue: ActionQueue;
loading?: boolean;
onApprove?: (actionId: string) => void;
onViewDetails?: (actionId: string) => void;
onModify?: (actionId: string) => void;
}
const urgencyConfig = {
critical: {
bg: 'bg-red-50',
border: 'border-red-300',
badge: 'bg-red-100 text-red-800',
icon: AlertCircle,
},
important: {
bg: 'bg-amber-50',
border: 'border-amber-300',
badge: 'bg-amber-100 text-amber-800',
icon: AlertCircle,
},
normal: {
bg: 'bg-blue-50',
border: 'border-blue-300',
badge: 'bg-blue-100 text-blue-800',
icon: FileText,
},
};
function ActionItemCard({
action,
onApprove,
onViewDetails,
onModify,
}: {
action: ActionItem;
onApprove?: (id: string) => void;
onViewDetails?: (id: string) => void;
onModify?: (id: string) => void;
}) {
const [expanded, setExpanded] = useState(false);
const config = urgencyConfig[action.urgency];
const UrgencyIcon = config.icon;
return (
<div
className={`${config.bg} ${config.border} border-2 rounded-lg p-4 md:p-5 transition-all duration-200 hover:shadow-md`}
>
{/* Header */}
<div className="flex items-start gap-3 mb-3">
<UrgencyIcon className={`w-6 h-6 flex-shrink-0 ${config.badge.split(' ')[1]}`} />
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2 mb-1">
<h3 className="font-bold text-lg">{action.title}</h3>
<span className={`${config.badge} px-2 py-1 rounded text-xs font-semibold uppercase flex-shrink-0`}>
{action.urgency}
</span>
</div>
<p className="text-sm text-gray-600">{action.subtitle}</p>
</div>
</div>
{/* Amount (for POs) */}
{action.amount && (
<div className="flex items-center gap-2 mb-3 text-lg font-bold">
<Euro className="w-5 h-5" />
<span>
{action.amount.toFixed(2)} {action.currency}
</span>
</div>
)}
{/* Reasoning (always visible) */}
<div className="bg-white rounded-md p-3 mb-3">
<p className="text-sm font-medium text-gray-700 mb-1">Why this is needed:</p>
<p className="text-sm text-gray-600">{action.reasoning}</p>
</div>
{/* Consequence (expandable) */}
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 transition-colors mb-3 w-full"
>
{expanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
<span className="font-medium">What happens if I don't do this?</span>
</button>
{expanded && (
<div className="bg-amber-50 border border-amber-200 rounded-md p-3 mb-3">
<p className="text-sm text-amber-900">{action.consequence}</p>
</div>
)}
{/* Time Estimate */}
<div className="flex items-center gap-2 text-xs text-gray-500 mb-4">
<Clock className="w-4 h-4" />
<span>Estimated time: {action.estimatedTimeMinutes} min</span>
</div>
{/* Action Buttons */}
<div className="flex flex-wrap gap-2">
{action.actions.map((button, index) => {
const buttonStyles = {
primary: 'bg-blue-600 hover:bg-blue-700 text-white',
secondary: 'bg-gray-200 hover:bg-gray-300 text-gray-800',
tertiary: 'bg-white hover:bg-gray-50 text-gray-700 border border-gray-300',
};
const handleClick = () => {
if (button.action === 'approve' && onApprove) {
onApprove(action.id);
} else if (button.action === 'view_details' && onViewDetails) {
onViewDetails(action.id);
} else if (button.action === 'modify' && onModify) {
onModify(action.id);
}
};
return (
<button
key={index}
onClick={handleClick}
className={`${buttonStyles[button.type]} px-4 py-2 rounded-lg font-semibold text-sm transition-colors duration-200 flex items-center gap-2 min-h-[44px]`}
>
{button.action === 'approve' && <CheckCircle2 className="w-4 h-4" />}
{button.action === 'view_details' && <Eye className="w-4 h-4" />}
{button.action === 'modify' && <Edit className="w-4 h-4" />}
{button.label}
</button>
);
})}
</div>
</div>
);
}
export function ActionQueueCard({
actionQueue,
loading,
onApprove,
onViewDetails,
onModify,
}: ActionQueueCardProps) {
const [showAll, setShowAll] = useState(false);
const displayedActions = showAll ? actionQueue.actions : actionQueue.actions.slice(0, 3);
if (loading) {
return (
<div className="bg-white rounded-xl shadow-md p-6">
<div className="animate-pulse space-y-4">
<div className="h-6 bg-gray-200 rounded w-1/2"></div>
<div className="h-32 bg-gray-200 rounded"></div>
<div className="h-32 bg-gray-200 rounded"></div>
</div>
</div>
);
}
if (actionQueue.actions.length === 0) {
return (
<div className="bg-green-50 border-2 border-green-200 rounded-xl p-8 text-center">
<CheckCircle2 className="w-16 h-16 text-green-600 mx-auto mb-4" />
<h3 className="text-xl font-bold text-green-900 mb-2">All caught up!</h3>
<p className="text-green-700">No actions requiring your attention right now.</p>
</div>
);
}
return (
<div className="bg-white rounded-xl shadow-md p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold text-gray-900">What Needs Your Attention</h2>
{actionQueue.totalActions > 3 && (
<span className="bg-red-100 text-red-800 px-3 py-1 rounded-full text-sm font-semibold">
{actionQueue.totalActions} total
</span>
)}
</div>
{/* Summary Badges */}
{(actionQueue.criticalCount > 0 || actionQueue.importantCount > 0) && (
<div className="flex flex-wrap gap-2 mb-6">
{actionQueue.criticalCount > 0 && (
<span className="bg-red-100 text-red-800 px-3 py-1 rounded-full text-sm font-semibold">
{actionQueue.criticalCount} critical
</span>
)}
{actionQueue.importantCount > 0 && (
<span className="bg-amber-100 text-amber-800 px-3 py-1 rounded-full text-sm font-semibold">
{actionQueue.importantCount} important
</span>
)}
</div>
)}
{/* Action Items */}
<div className="space-y-4">
{displayedActions.map((action) => (
<ActionItemCard
key={action.id}
action={action}
onApprove={onApprove}
onViewDetails={onViewDetails}
onModify={onModify}
/>
))}
</div>
{/* Show More/Less */}
{actionQueue.totalActions > 3 && (
<button
onClick={() => setShowAll(!showAll)}
className="w-full mt-4 py-3 bg-gray-100 hover:bg-gray-200 rounded-lg font-semibold text-gray-700 transition-colors duration-200"
>
{showAll
? 'Show Less'
: `Show ${actionQueue.totalActions - 3} More Action${
actionQueue.totalActions - 3 !== 1 ? 's' : ''
}`}
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,149 @@
// ================================================================
// frontend/src/components/dashboard/HealthStatusCard.tsx
// ================================================================
/**
* Health Status Card - Top-level bakery status indicator
*
* Shows if the bakery is running smoothly (green), needs attention (yellow),
* or has critical issues (red). This is the first thing users see.
*/
import React from 'react';
import { CheckCircle, AlertTriangle, AlertCircle, Clock, RefreshCw } from 'lucide-react';
import { BakeryHealthStatus } from '../../api/hooks/newDashboard';
import { formatDistanceToNow } from 'date-fns';
interface HealthStatusCardProps {
healthStatus: BakeryHealthStatus;
loading?: boolean;
}
const statusConfig = {
green: {
bg: 'bg-green-50',
border: 'border-green-200',
text: 'text-green-800',
icon: CheckCircle,
iconColor: 'text-green-600',
},
yellow: {
bg: 'bg-amber-50',
border: 'border-amber-200',
text: 'text-amber-900',
icon: AlertTriangle,
iconColor: 'text-amber-600',
},
red: {
bg: 'bg-red-50',
border: 'border-red-200',
text: 'text-red-900',
icon: AlertCircle,
iconColor: 'text-red-600',
},
};
const iconMap = {
check: CheckCircle,
warning: AlertTriangle,
alert: AlertCircle,
};
export function HealthStatusCard({ healthStatus, loading }: HealthStatusCardProps) {
const config = statusConfig[healthStatus.status];
const StatusIcon = config.icon;
if (loading) {
return (
<div className="animate-pulse bg-white rounded-lg shadow-md p-6">
<div className="h-8 bg-gray-200 rounded w-3/4 mb-4"></div>
<div className="h-4 bg-gray-200 rounded w-1/2"></div>
</div>
);
}
return (
<div
className={`${config.bg} ${config.border} border-2 rounded-xl p-6 shadow-lg transition-all duration-300 hover:shadow-xl`}
>
{/* Header with Status Icon */}
<div className="flex items-start gap-4 mb-4">
<div className={`${config.iconColor} flex-shrink-0`}>
<StatusIcon className="w-10 h-10 md:w-12 md:h-12" strokeWidth={2} />
</div>
<div className="flex-1 min-w-0">
<h2 className={`text-xl md:text-2xl font-bold ${config.text} mb-2`}>
{healthStatus.headline}
</h2>
{/* Last Update */}
<div className="flex items-center gap-2 text-sm text-gray-600">
<Clock className="w-4 h-4" />
<span>
Last updated:{' '}
{healthStatus.lastOrchestrationRun
? formatDistanceToNow(new Date(healthStatus.lastOrchestrationRun), {
addSuffix: true,
})
: 'Never'}
</span>
</div>
{/* Next Check */}
<div className="flex items-center gap-2 text-sm text-gray-600 mt-1">
<RefreshCw className="w-4 h-4" />
<span>
Next check:{' '}
{formatDistanceToNow(new Date(healthStatus.nextScheduledRun), { addSuffix: true })}
</span>
</div>
</div>
</div>
{/* Status Checklist */}
<div className="space-y-3 mt-6">
{healthStatus.checklistItems.map((item, index) => {
const ItemIcon = iconMap[item.icon];
const iconColorClass = item.actionRequired ? 'text-amber-600' : 'text-green-600';
return (
<div
key={index}
className={`flex items-center gap-3 p-3 rounded-lg ${
item.actionRequired ? 'bg-white' : 'bg-white/50'
}`}
>
<ItemIcon className={`w-5 h-5 flex-shrink-0 ${iconColorClass}`} />
<span className={`text-sm md:text-base ${item.actionRequired ? 'font-semibold' : ''}`}>
{item.text}
</span>
</div>
);
})}
</div>
{/* Summary Footer */}
{(healthStatus.criticalIssues > 0 || healthStatus.pendingActions > 0) && (
<div className="mt-6 pt-4 border-t border-gray-200">
<div className="flex flex-wrap gap-4 text-sm">
{healthStatus.criticalIssues > 0 && (
<div className="flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-red-600" />
<span className="font-semibold text-red-800">
{healthStatus.criticalIssues} critical issue{healthStatus.criticalIssues !== 1 ? 's' : ''}
</span>
</div>
)}
{healthStatus.pendingActions > 0 && (
<div className="flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-amber-600" />
<span className="font-semibold text-amber-800">
{healthStatus.pendingActions} action{healthStatus.pendingActions !== 1 ? 's' : ''} needed
</span>
</div>
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,112 @@
// ================================================================
// frontend/src/components/dashboard/InsightsGrid.tsx
// ================================================================
/**
* Insights Grid - Key metrics at a glance
*
* 2x2 grid of important metrics: savings, inventory, waste, deliveries.
* Mobile-first design with large touch targets.
*/
import React from 'react';
import { Insights } from '../../api/hooks/newDashboard';
interface InsightsGridProps {
insights: Insights;
loading?: boolean;
}
const colorConfig = {
green: {
bg: 'bg-green-50',
border: 'border-green-200',
text: 'text-green-800',
detail: 'text-green-600',
},
amber: {
bg: 'bg-amber-50',
border: 'border-amber-200',
text: 'text-amber-900',
detail: 'text-amber-600',
},
red: {
bg: 'bg-red-50',
border: 'border-red-200',
text: 'text-red-900',
detail: 'text-red-600',
},
};
function InsightCard({
label,
value,
detail,
color,
}: {
label: string;
value: string;
detail: string;
color: 'green' | 'amber' | 'red';
}) {
const config = colorConfig[color];
return (
<div
className={`${config.bg} ${config.border} border-2 rounded-xl p-4 md:p-6 transition-all duration-200 hover:shadow-lg cursor-pointer`}
>
{/* Label */}
<div className="text-sm md:text-base font-bold text-gray-700 mb-2">{label}</div>
{/* Value */}
<div className={`text-xl md:text-2xl font-bold ${config.text} mb-1`}>{value}</div>
{/* Detail */}
<div className={`text-sm ${config.detail} font-medium`}>{detail}</div>
</div>
);
}
export function InsightsGrid({ insights, loading }: InsightsGridProps) {
if (loading) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="animate-pulse bg-gray-100 rounded-xl p-6">
<div className="h-4 bg-gray-200 rounded w-1/2 mb-3"></div>
<div className="h-8 bg-gray-200 rounded w-3/4 mb-2"></div>
<div className="h-4 bg-gray-200 rounded w-2/3"></div>
</div>
))}
</div>
);
}
return (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<InsightCard
label={insights.savings.label}
value={insights.savings.value}
detail={insights.savings.detail}
color={insights.savings.color}
/>
<InsightCard
label={insights.inventory.label}
value={insights.inventory.value}
detail={insights.inventory.detail}
color={insights.inventory.color}
/>
<InsightCard
label={insights.waste.label}
value={insights.waste.value}
detail={insights.waste.detail}
color={insights.waste.color}
/>
<InsightCard
label={insights.deliveries.label}
value={insights.deliveries.value}
detail={insights.deliveries.detail}
color={insights.deliveries.color}
/>
</div>
);
}

View File

@@ -0,0 +1,241 @@
// ================================================================
// frontend/src/components/dashboard/OrchestrationSummaryCard.tsx
// ================================================================
/**
* Orchestration Summary Card - What the system did for you
*
* Builds trust by showing transparency into automation decisions.
* Narrative format makes it feel like a helpful assistant.
*/
import React, { useState } from 'react';
import {
Bot,
TrendingUp,
Package,
Clock,
CheckCircle,
FileText,
Users,
Database,
Brain,
ChevronDown,
ChevronUp,
} from 'lucide-react';
import { OrchestrationSummary } from '../../api/hooks/newDashboard';
import { formatDistanceToNow } from 'date-fns';
interface OrchestrationSummaryCardProps {
summary: OrchestrationSummary;
loading?: boolean;
}
export function OrchestrationSummaryCard({ summary, loading }: OrchestrationSummaryCardProps) {
const [expanded, setExpanded] = useState(false);
if (loading) {
return (
<div className="bg-white rounded-xl shadow-md p-6">
<div className="animate-pulse space-y-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gray-200 rounded-full"></div>
<div className="h-6 bg-gray-200 rounded w-1/2"></div>
</div>
<div className="space-y-2">
<div className="h-4 bg-gray-200 rounded"></div>
<div className="h-4 bg-gray-200 rounded w-5/6"></div>
</div>
</div>
</div>
);
}
// Handle case where no orchestration has run yet
if (summary.status === 'no_runs') {
return (
<div className="bg-blue-50 border-2 border-blue-200 rounded-xl p-6">
<div className="flex items-start gap-4">
<Bot className="w-10 h-10 text-blue-600 flex-shrink-0" />
<div>
<h3 className="text-lg font-bold text-blue-900 mb-2">
Ready to Plan Your Bakery Day
</h3>
<p className="text-blue-700 mb-4">{summary.message}</p>
<button className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg font-semibold transition-colors duration-200">
Run Daily Planning
</button>
</div>
</div>
</div>
);
}
const runTime = summary.runTimestamp
? formatDistanceToNow(new Date(summary.runTimestamp), { addSuffix: true })
: 'recently';
return (
<div className="bg-gradient-to-br from-purple-50 to-blue-50 rounded-xl shadow-md p-6 border border-purple-100">
{/* Header */}
<div className="flex items-start gap-4 mb-6">
<div className="bg-purple-100 p-3 rounded-full">
<Bot className="w-8 h-8 text-purple-600" />
</div>
<div className="flex-1">
<h2 className="text-2xl font-bold text-gray-900 mb-1">
Last Night I Planned Your Day
</h2>
<div className="flex items-center gap-2 text-sm text-gray-600">
<Clock className="w-4 h-4" />
<span>Orchestration run #{summary.runNumber} {runTime}</span>
{summary.durationSeconds && (
<span className="text-gray-400"> Took {summary.durationSeconds}s</span>
)}
</div>
</div>
</div>
{/* Purchase Orders Created */}
{summary.purchaseOrdersCreated > 0 && (
<div className="bg-white rounded-lg p-4 mb-4">
<div className="flex items-center gap-3 mb-3">
<CheckCircle className="w-5 h-5 text-green-600" />
<h3 className="font-bold text-gray-900">
Created {summary.purchaseOrdersCreated} purchase order
{summary.purchaseOrdersCreated !== 1 ? 's' : ''}
</h3>
</div>
{summary.purchaseOrdersSummary.length > 0 && (
<ul className="space-y-2 ml-8">
{summary.purchaseOrdersSummary.map((po, index) => (
<li key={index} className="text-sm text-gray-700">
<span className="font-medium">{po.supplierName}</span>
{' • '}
{po.itemCategories.slice(0, 2).join(', ')}
{po.itemCategories.length > 2 && ` +${po.itemCategories.length - 2} more`}
{' • '}
<span className="font-semibold">{po.totalAmount.toFixed(2)}</span>
</li>
))}
</ul>
)}
</div>
)}
{/* Production Batches Created */}
{summary.productionBatchesCreated > 0 && (
<div className="bg-white rounded-lg p-4 mb-4">
<div className="flex items-center gap-3 mb-3">
<CheckCircle className="w-5 h-5 text-green-600" />
<h3 className="font-bold text-gray-900">
Scheduled {summary.productionBatchesCreated} production batch
{summary.productionBatchesCreated !== 1 ? 'es' : ''}
</h3>
</div>
{summary.productionBatchesSummary.length > 0 && (
<ul className="space-y-2 ml-8">
{summary.productionBatchesSummary.slice(0, expanded ? undefined : 3).map((batch, index) => (
<li key={index} className="text-sm text-gray-700">
<span className="font-semibold">{batch.quantity}</span> {batch.productName}
{' • '}
<span className="text-gray-500">
ready by {new Date(batch.readyByTime).toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true,
})}
</span>
</li>
))}
</ul>
)}
{summary.productionBatchesSummary.length > 3 && (
<button
onClick={() => setExpanded(!expanded)}
className="ml-8 mt-2 flex items-center gap-1 text-sm text-purple-600 hover:text-purple-800 font-medium"
>
{expanded ? (
<>
<ChevronUp className="w-4 h-4" />
Show less
</>
) : (
<>
<ChevronDown className="w-4 h-4" />
Show {summary.productionBatchesSummary.length - 3} more
</>
)}
</button>
)}
</div>
)}
{/* No actions created */}
{summary.purchaseOrdersCreated === 0 && summary.productionBatchesCreated === 0 && (
<div className="bg-white rounded-lg p-4 mb-4">
<div className="flex items-center gap-3">
<CheckCircle className="w-5 h-5 text-gray-400" />
<p className="text-gray-600">No new actions needed - everything is on track!</p>
</div>
</div>
)}
{/* Reasoning Inputs (How decisions were made) */}
<div className="bg-white/60 rounded-lg p-4">
<div className="flex items-center gap-2 mb-3">
<Brain className="w-5 h-5 text-purple-600" />
<h3 className="font-bold text-gray-900">Based on:</h3>
</div>
<div className="grid grid-cols-2 gap-3 ml-7">
{summary.reasoningInputs.customerOrders > 0 && (
<div className="flex items-center gap-2 text-sm">
<Users className="w-4 h-4 text-gray-600" />
<span className="text-gray-700">
{summary.reasoningInputs.customerOrders} customer order
{summary.reasoningInputs.customerOrders !== 1 ? 's' : ''}
</span>
</div>
)}
{summary.reasoningInputs.historicalDemand && (
<div className="flex items-center gap-2 text-sm">
<TrendingUp className="w-4 h-4 text-gray-600" />
<span className="text-gray-700">Historical demand</span>
</div>
)}
{summary.reasoningInputs.inventoryLevels && (
<div className="flex items-center gap-2 text-sm">
<Package className="w-4 h-4 text-gray-600" />
<span className="text-gray-700">Inventory levels</span>
</div>
)}
{summary.reasoningInputs.aiInsights && (
<div className="flex items-center gap-2 text-sm">
<Brain className="w-4 h-4 text-purple-600" />
<span className="text-gray-700 font-medium">AI optimization</span>
</div>
)}
</div>
</div>
{/* Actions Required Footer */}
{summary.userActionsRequired > 0 && (
<div className="mt-4 p-4 bg-amber-50 border border-amber-200 rounded-lg">
<div className="flex items-center gap-2">
<FileText className="w-5 h-5 text-amber-600" />
<p className="text-sm font-medium text-amber-900">
{summary.userActionsRequired} item{summary.userActionsRequired !== 1 ? 's' : ''} need
{summary.userActionsRequired === 1 ? 's' : ''} your approval before proceeding
</p>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,223 @@
// ================================================================
// frontend/src/components/dashboard/ProductionTimelineCard.tsx
// ================================================================
/**
* Production Timeline Card - Today's production schedule
*
* Chronological view of what's being made today with real-time progress.
*/
import React from 'react';
import { Factory, Clock, Play, Pause, CheckCircle2 } from 'lucide-react';
import { ProductionTimeline, ProductionTimelineItem } from '../../api/hooks/newDashboard';
interface ProductionTimelineCardProps {
timeline: ProductionTimeline;
loading?: boolean;
onStart?: (batchId: string) => void;
onPause?: (batchId: string) => void;
}
const priorityColors = {
URGENT: 'text-red-600',
HIGH: 'text-orange-600',
MEDIUM: 'text-blue-600',
LOW: 'text-gray-600',
};
function TimelineItemCard({
item,
onStart,
onPause,
}: {
item: ProductionTimelineItem;
onStart?: (id: string) => void;
onPause?: (id: string) => void;
}) {
const priorityColor = priorityColors[item.priority as keyof typeof priorityColors] || 'text-gray-600';
const startTime = item.plannedStartTime
? new Date(item.plannedStartTime).toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true,
})
: 'N/A';
const readyByTime = item.readyBy
? new Date(item.readyBy).toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true,
})
: 'N/A';
return (
<div className="flex gap-4 p-4 bg-white rounded-lg border border-gray-200 hover:shadow-md transition-shadow duration-200">
{/* Timeline icon and connector */}
<div className="flex flex-col items-center flex-shrink-0">
<div className="text-2xl">{item.statusIcon}</div>
<div className="text-xs text-gray-500 font-mono mt-1">{startTime}</div>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
{/* Header */}
<div className="flex items-start justify-between gap-2 mb-2">
<div>
<h3 className="font-bold text-lg text-gray-900">{item.productName}</h3>
<p className="text-sm text-gray-600">
{item.quantity} {item.unit} Batch #{item.batchNumber}
</p>
</div>
<span className={`text-xs font-semibold uppercase ${priorityColor}`}>
{item.priority}
</span>
</div>
{/* Status and Progress */}
<div className="mb-3">
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-gray-700">{item.statusText}</span>
{item.status === 'IN_PROGRESS' && (
<span className="text-sm text-gray-600">{item.progress}%</span>
)}
</div>
{/* Progress Bar */}
{item.status === 'IN_PROGRESS' && (
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-500"
style={{ width: `${item.progress}%` }}
/>
</div>
)}
</div>
{/* Ready By Time */}
{item.status !== 'COMPLETED' && (
<div className="flex items-center gap-2 text-sm text-gray-600 mb-2">
<Clock className="w-4 h-4" />
<span>Ready by: {readyByTime}</span>
</div>
)}
{/* Reasoning */}
{item.reasoning && (
<p className="text-sm text-gray-600 italic mb-3">"{item.reasoning}"</p>
)}
{/* Actions */}
{item.status === 'PENDING' && onStart && (
<button
onClick={() => onStart(item.id)}
className="flex items-center gap-2 px-3 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg text-sm font-semibold transition-colors duration-200"
>
<Play className="w-4 h-4" />
Start Batch
</button>
)}
{item.status === 'IN_PROGRESS' && onPause && (
<button
onClick={() => onPause(item.id)}
className="flex items-center gap-2 px-3 py-2 bg-amber-600 hover:bg-amber-700 text-white rounded-lg text-sm font-semibold transition-colors duration-200"
>
<Pause className="w-4 h-4" />
Pause Batch
</button>
)}
{item.status === 'COMPLETED' && (
<div className="flex items-center gap-2 text-sm text-green-600 font-medium">
<CheckCircle2 className="w-4 h-4" />
Completed
</div>
)}
</div>
</div>
);
}
export function ProductionTimelineCard({
timeline,
loading,
onStart,
onPause,
}: ProductionTimelineCardProps) {
if (loading) {
return (
<div className="bg-white rounded-xl shadow-md p-6">
<div className="animate-pulse space-y-4">
<div className="h-6 bg-gray-200 rounded w-1/2"></div>
<div className="space-y-3">
<div className="h-24 bg-gray-200 rounded"></div>
<div className="h-24 bg-gray-200 rounded"></div>
</div>
</div>
</div>
);
}
if (timeline.timeline.length === 0) {
return (
<div className="bg-white rounded-xl shadow-md p-8 text-center">
<Factory className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h3 className="text-xl font-bold text-gray-700 mb-2">No Production Scheduled</h3>
<p className="text-gray-600">No batches are scheduled for production today.</p>
</div>
);
}
return (
<div className="bg-white rounded-xl shadow-md p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<Factory className="w-8 h-8 text-blue-600" />
<h2 className="text-2xl font-bold text-gray-900">Your Production Plan Today</h2>
</div>
</div>
{/* Summary Stats */}
<div className="grid grid-cols-4 gap-4 mb-6">
<div className="bg-gray-50 rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-gray-900">{timeline.totalBatches}</div>
<div className="text-xs text-gray-600 uppercase">Total</div>
</div>
<div className="bg-green-50 rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-green-600">{timeline.completedBatches}</div>
<div className="text-xs text-green-700 uppercase">Done</div>
</div>
<div className="bg-blue-50 rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-blue-600">{timeline.inProgressBatches}</div>
<div className="text-xs text-blue-700 uppercase">Active</div>
</div>
<div className="bg-gray-50 rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-gray-600">{timeline.pendingBatches}</div>
<div className="text-xs text-gray-600 uppercase">Pending</div>
</div>
</div>
{/* Timeline */}
<div className="space-y-4">
{timeline.timeline.map((item) => (
<TimelineItemCard
key={item.id}
item={item}
onStart={onStart}
onPause={onPause}
/>
))}
</div>
{/* View Full Schedule Link */}
{timeline.totalBatches > 5 && (
<button className="w-full mt-6 py-3 bg-gray-100 hover:bg-gray-200 rounded-lg font-semibold text-gray-700 transition-colors duration-200">
View Full Production Schedule
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,13 @@
// ================================================================
// frontend/src/components/dashboard/index.ts
// ================================================================
/**
* Dashboard Components Export
* Barrel export for all JTBD dashboard components
*/
export { HealthStatusCard } from './HealthStatusCard';
export { ActionQueueCard } from './ActionQueueCard';
export { OrchestrationSummaryCard } from './OrchestrationSummaryCard';
export { ProductionTimelineCard } from './ProductionTimelineCard';
export { InsightsGrid } from './InsightsGrid';

View File

@@ -0,0 +1,611 @@
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { PageHeader } from '../../components/layout';
import StatsGrid from '../../components/ui/Stats/StatsGrid';
import RealTimeAlerts from '../../components/domain/dashboard/RealTimeAlerts';
import { IncompleteIngredientsAlert } from '../../components/domain/dashboard/IncompleteIngredientsAlert';
import { ConfigurationProgressWidget } from '../../components/domain/dashboard/ConfigurationProgressWidget';
import PendingPOApprovals from '../../components/domain/dashboard/PendingPOApprovals';
import TodayProduction from '../../components/domain/dashboard/TodayProduction';
// Sustainability widget removed - now using stats in StatsGrid
import { EditViewModal } from '../../components/ui';
import { useTenant } from '../../stores/tenant.store';
import { useDemoTour, shouldStartTour, clearTourStartPending } from '../../features/demo-onboarding';
import { useDashboardStats } from '../../api/hooks/dashboard';
import { usePurchaseOrder, useApprovePurchaseOrder, useRejectPurchaseOrder } from '../../api/hooks/purchase-orders';
import { useBatchDetails, useUpdateBatchStatus } from '../../api/hooks/production';
import { useRunDailyWorkflow } from '../../api';
import { ProductionStatusEnum } from '../../api';
import {
AlertTriangle,
Clock,
Euro,
Package,
FileText,
Building2,
Calendar,
CheckCircle,
X,
ShoppingCart,
Factory,
Timer,
TrendingDown,
Leaf,
Play
} from 'lucide-react';
import { showToast } from '../../utils/toast';
const DashboardPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { availableTenants, currentTenant } = useTenant();
const { startTour } = useDemoTour();
const isDemoMode = localStorage.getItem('demo_mode') === 'true';
// Modal state management
const [selectedPOId, setSelectedPOId] = useState<string | null>(null);
const [selectedBatchId, setSelectedBatchId] = useState<string | null>(null);
const [showPOModal, setShowPOModal] = useState(false);
const [showBatchModal, setShowBatchModal] = useState(false);
const [approvalNotes, setApprovalNotes] = useState('');
// Fetch real dashboard statistics
const { data: dashboardStats, isLoading: isLoadingStats, error: statsError } = useDashboardStats(
currentTenant?.id || '',
{
enabled: !!currentTenant?.id,
}
);
// Fetch PO details when modal is open
const { data: poDetails, isLoading: isLoadingPO } = usePurchaseOrder(
currentTenant?.id || '',
selectedPOId || '',
{
enabled: !!currentTenant?.id && !!selectedPOId && showPOModal
}
);
// Fetch Production batch details when modal is open
const { data: batchDetails, isLoading: isLoadingBatch } = useBatchDetails(
currentTenant?.id || '',
selectedBatchId || '',
{
enabled: !!currentTenant?.id && !!selectedBatchId && showBatchModal
}
);
// Mutations
const approvePOMutation = useApprovePurchaseOrder();
const rejectPOMutation = useRejectPurchaseOrder();
const updateBatchStatusMutation = useUpdateBatchStatus();
const orchestratorMutation = useRunDailyWorkflow();
const handleRunOrchestrator = async () => {
try {
await orchestratorMutation.mutateAsync(currentTenant?.id || '');
showToast.success('Flujo de planificación ejecutado exitosamente');
} catch (error) {
console.error('Error running orchestrator:', error);
showToast.error('Error al ejecutar flujo de planificación');
}
};
useEffect(() => {
console.log('[Dashboard] Demo mode:', isDemoMode);
console.log('[Dashboard] Should start tour:', shouldStartTour());
console.log('[Dashboard] SessionStorage demo_tour_should_start:', sessionStorage.getItem('demo_tour_should_start'));
console.log('[Dashboard] SessionStorage demo_tour_start_step:', sessionStorage.getItem('demo_tour_start_step'));
// Check if there's a tour intent from redirection (higher priority)
const shouldStartFromRedirect = sessionStorage.getItem('demo_tour_should_start') === 'true';
const redirectStartStep = parseInt(sessionStorage.getItem('demo_tour_start_step') || '0', 10);
if (isDemoMode && (shouldStartTour() || shouldStartFromRedirect)) {
console.log('[Dashboard] Starting tour in 1.5s...');
const timer = setTimeout(() => {
console.log('[Dashboard] Executing startTour()');
if (shouldStartFromRedirect) {
// Start tour from the specific step that was intended
startTour(redirectStartStep);
// Clear the redirect intent
sessionStorage.removeItem('demo_tour_should_start');
sessionStorage.removeItem('demo_tour_start_step');
} else {
// Start tour normally (from beginning or resume)
startTour();
clearTourStartPending();
}
}, 1500);
return () => clearTimeout(timer);
}
}, [isDemoMode, startTour]);
const handleViewAllProcurement = () => {
navigate('/app/operations/procurement');
};
const handleViewAllProduction = () => {
navigate('/app/operations/production');
};
const handleOrderItem = (itemId: string) => {
console.log('Ordering item:', itemId);
navigate('/app/operations/procurement');
};
const handleStartBatch = async (batchId: string) => {
try {
await updateBatchStatusMutation.mutateAsync({
tenantId: currentTenant?.id || '',
batchId,
statusUpdate: { status: ProductionStatusEnum.IN_PROGRESS }
});
showToast.success('Lote iniciado');
} catch (error) {
console.error('Error starting batch:', error);
showToast.error('Error al iniciar lote');
}
};
const handlePauseBatch = async (batchId: string) => {
try {
await updateBatchStatusMutation.mutateAsync({
tenantId: currentTenant?.id || '',
batchId,
statusUpdate: { status: ProductionStatusEnum.ON_HOLD }
});
showToast.success('Lote pausado');
} catch (error) {
console.error('Error pausing batch:', error);
showToast.error('Error al pausar lote');
}
};
const handleViewDetails = (batchId: string) => {
setSelectedBatchId(batchId);
setShowBatchModal(true);
};
const handleApprovePO = async (poId: string) => {
try {
await approvePOMutation.mutateAsync({
tenantId: currentTenant?.id || '',
poId,
notes: 'Aprobado desde el dashboard'
});
showToast.success('Orden aprobada');
} catch (error) {
console.error('Error approving PO:', error);
showToast.error('Error al aprobar orden');
}
};
const handleRejectPO = async (poId: string) => {
try {
await rejectPOMutation.mutateAsync({
tenantId: currentTenant?.id || '',
poId,
reason: 'Rechazado desde el dashboard'
});
showToast.success('Orden rechazada');
} catch (error) {
console.error('Error rejecting PO:', error);
showToast.error('Error al rechazar orden');
}
};
const handleViewPODetails = (poId: string) => {
setSelectedPOId(poId);
setShowPOModal(true);
};
const handleViewAllPOs = () => {
navigate('/app/operations/procurement');
};
// Build stats from real API data (Sales analytics removed - Professional/Enterprise tier only)
const criticalStats = React.useMemo(() => {
if (!dashboardStats) {
// Return loading/empty state
return [];
}
// Determine trend direction
const getTrendDirection = (value: number): 'up' | 'down' | 'neutral' => {
if (value > 0) return 'up';
if (value < 0) return 'down';
return 'neutral';
};
return [
{
title: t('dashboard:stats.pending_orders', 'Pending Orders'),
value: dashboardStats.pendingOrders.toString(),
icon: Clock,
variant: dashboardStats.pendingOrders > 10 ? ('warning' as const) : ('info' as const),
trend: dashboardStats.ordersTrend !== 0 ? {
value: Math.abs(dashboardStats.ordersTrend),
direction: getTrendDirection(dashboardStats.ordersTrend),
label: t('dashboard:trends.vs_yesterday', '% vs yesterday')
} : undefined,
subtitle: dashboardStats.pendingOrders > 0
? t('dashboard:messages.require_attention', 'Require attention')
: t('dashboard:messages.all_caught_up', 'All caught up!')
},
{
title: t('dashboard:stats.stock_alerts', 'Critical Stock'),
value: dashboardStats.criticalStock.toString(),
icon: AlertTriangle,
variant: dashboardStats.criticalStock > 0 ? ('error' as const) : ('success' as const),
trend: undefined, // Stock alerts don't have historical trends
subtitle: dashboardStats.criticalStock > 0
? t('dashboard:messages.action_required', 'Action required')
: t('dashboard:messages.stock_healthy', 'Stock levels healthy')
},
{
title: t('dashboard:stats.waste_reduction', 'Waste Reduction'),
value: dashboardStats.wasteReductionPercentage
? `${Math.abs(dashboardStats.wasteReductionPercentage).toFixed(1)}%`
: '0%',
icon: TrendingDown,
variant: (dashboardStats.wasteReductionPercentage || 0) >= 15 ? ('success' as const) : ('info' as const),
trend: undefined,
subtitle: (dashboardStats.wasteReductionPercentage || 0) >= 15
? t('dashboard:messages.excellent_progress', 'Excellent progress!')
: t('dashboard:messages.keep_improving', 'Keep improving')
},
{
title: t('dashboard:stats.monthly_savings', 'Monthly Savings'),
value: dashboardStats.monthlySavingsEur
? `${dashboardStats.monthlySavingsEur.toFixed(0)}`
: '€0',
icon: Leaf,
variant: 'success' as const,
trend: undefined,
subtitle: t('dashboard:messages.from_sustainability', 'From sustainability')
}
];
}, [dashboardStats, t]);
// Helper function to build PO detail sections (reused from ProcurementPage)
const buildPODetailsSections = (po: any) => {
if (!po) return [];
const getPOStatusConfig = (status: string) => {
const normalizedStatus = status?.toUpperCase().replace(/_/g, '_');
const configs: Record<string, any> = {
PENDING_APPROVAL: { text: 'Pendiente de Aprobación', color: 'var(--color-warning)' },
APPROVED: { text: 'Aprobado', color: 'var(--color-success)' },
SENT_TO_SUPPLIER: { text: 'Enviado al Proveedor', color: 'var(--color-info)' },
CONFIRMED: { text: 'Confirmado', color: 'var(--color-success)' },
RECEIVED: { text: 'Recibido', color: 'var(--color-success)' },
COMPLETED: { text: 'Completado', color: 'var(--color-success)' },
CANCELLED: { text: 'Cancelado', color: 'var(--color-error)' },
};
return configs[normalizedStatus] || { text: status, color: 'var(--color-info)' };
};
const statusConfig = getPOStatusConfig(po.status);
return [
{
title: 'Información General',
icon: FileText,
fields: [
{ label: 'Número de Orden', value: po.po_number, type: 'text' as const },
{ label: 'Estado', value: statusConfig.text, type: 'status' as const },
{ label: 'Prioridad', value: po.priority === 'urgent' ? 'Urgente' : po.priority === 'high' ? 'Alta' : po.priority === 'low' ? 'Baja' : 'Normal', type: 'text' as const },
{ label: 'Fecha de Creación', value: new Date(po.created_at).toLocaleDateString('es-ES', { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' }), type: 'text' as const }
]
},
{
title: 'Información del Proveedor',
icon: Building2,
fields: [
{ label: 'Proveedor', value: po.supplier?.name || po.supplier_name || 'N/A', type: 'text' as const },
{ label: 'Email', value: po.supplier?.contact_email || 'N/A', type: 'text' as const },
{ label: 'Teléfono', value: po.supplier?.contact_phone || 'N/A', type: 'text' as const }
]
},
{
title: 'Resumen Financiero',
icon: Euro,
fields: [
{ label: 'Subtotal', value: `${(typeof po.subtotal === 'string' ? parseFloat(po.subtotal) : po.subtotal || 0).toFixed(2)}`, type: 'text' as const },
{ label: 'Impuestos', value: `${(typeof po.tax_amount === 'string' ? parseFloat(po.tax_amount) : po.tax_amount || 0).toFixed(2)}`, type: 'text' as const },
{ label: 'TOTAL', value: `${(typeof po.total_amount === 'string' ? parseFloat(po.total_amount) : po.total_amount || 0).toFixed(2)}`, type: 'text' as const, highlight: true }
]
},
{
title: 'Entrega',
icon: Calendar,
fields: [
{ label: 'Fecha Requerida', value: po.required_delivery_date ? new Date(po.required_delivery_date).toLocaleDateString('es-ES', { year: 'numeric', month: 'long', day: 'numeric' }) : 'No especificada', type: 'text' as const },
{ label: 'Fecha Esperada', value: po.expected_delivery_date ? new Date(po.expected_delivery_date).toLocaleDateString('es-ES', { year: 'numeric', month: 'long', day: 'numeric' }) : 'No especificada', type: 'text' as const }
]
}
];
};
// Helper function to build Production batch detail sections
const buildBatchDetailsSections = (batch: any) => {
if (!batch) return [];
return [
{
title: 'Información General',
icon: Package,
fields: [
{ label: 'Producto', value: batch.product_name, type: 'text' as const, highlight: true },
{ label: 'Número de Lote', value: batch.batch_number, type: 'text' as const },
{ label: 'Cantidad Planificada', value: `${batch.planned_quantity} unidades`, type: 'text' as const },
{ label: 'Cantidad Real', value: batch.actual_quantity ? `${batch.actual_quantity} unidades` : 'Pendiente', type: 'text' as const },
{ label: 'Estado', value: batch.status, type: 'text' as const },
{ label: 'Prioridad', value: batch.priority, type: 'text' as const }
]
},
{
title: 'Cronograma',
icon: Clock,
fields: [
{ label: 'Inicio Planificado', value: batch.planned_start_time ? new Date(batch.planned_start_time).toLocaleString('es-ES') : 'No especificado', type: 'text' as const },
{ label: 'Fin Planificado', value: batch.planned_end_time ? new Date(batch.planned_end_time).toLocaleString('es-ES') : 'No especificado', type: 'text' as const },
{ label: 'Inicio Real', value: batch.actual_start_time ? new Date(batch.actual_start_time).toLocaleString('es-ES') : 'Pendiente', type: 'text' as const },
{ label: 'Fin Real', value: batch.actual_end_time ? new Date(batch.actual_end_time).toLocaleString('es-ES') : 'Pendiente', type: 'text' as const }
]
},
{
title: 'Producción',
icon: Factory,
fields: [
{ label: 'Personal Asignado', value: batch.staff_assigned?.join(', ') || 'No asignado', type: 'text' as const },
{ label: 'Estación', value: batch.station_id || 'No asignada', type: 'text' as const },
{ label: 'Duración Planificada', value: batch.planned_duration_minutes ? `${batch.planned_duration_minutes} minutos` : 'No especificada', type: 'text' as const }
]
},
{
title: 'Calidad y Costos',
icon: CheckCircle,
fields: [
{ label: 'Puntuación de Calidad', value: batch.quality_score ? `${batch.quality_score}/10` : 'Pendiente', type: 'text' as const },
{ label: 'Rendimiento', value: batch.yield_percentage ? `${batch.yield_percentage}%` : 'Calculando...', type: 'text' as const },
{ label: 'Costo Estimado', value: batch.estimated_cost ? `${batch.estimated_cost}` : '€0.00', type: 'text' as const },
{ label: 'Costo Real', value: batch.actual_cost ? `${batch.actual_cost}` : '€0.00', type: 'text' as const }
]
}
];
};
return (
<div className="space-y-6 p-4 sm:p-6">
<PageHeader
title={t('dashboard:title', 'Dashboard')}
description={t('dashboard:subtitle', 'Overview of your bakery operations')}
actions={[
{
id: 'run-orchestrator',
label: orchestratorMutation.isPending ? 'Ejecutando...' : 'Ejecutar Planificación Diaria',
icon: Play,
onClick: handleRunOrchestrator,
variant: 'primary', // Primary button for visibility
size: 'sm',
disabled: orchestratorMutation.isPending,
loading: orchestratorMutation.isPending
}
]}
/>
{/* Critical Metrics using StatsGrid */}
<div data-tour="dashboard-stats">
{isLoadingStats ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{[1, 2, 3, 4].map((i) => (
<div
key={i}
className="h-32 bg-[var(--bg-secondary)] border border-[var(--border-primary)] rounded-lg animate-pulse"
/>
))}
</div>
) : statsError ? (
<div className="mb-6 p-4 bg-[var(--color-error)]/10 border border-[var(--color-error)]/20 rounded-lg">
<p className="text-[var(--color-error)] text-sm">
{t('dashboard:errors.failed_to_load_stats', 'Failed to load dashboard statistics. Please try again.')}
</p>
</div>
) : (
<StatsGrid
stats={criticalStats}
columns={4}
gap="lg"
className="mb-6"
/>
)}
</div>
{/* Dashboard Content - Main Sections */}
<div className="space-y-6">
{/* 0. Configuration Progress Widget */}
<ConfigurationProgressWidget />
{/* 1. Real-time Alerts */}
<div data-tour="real-time-alerts">
<RealTimeAlerts />
</div>
{/* 1.5. Incomplete Ingredients Alert */}
<IncompleteIngredientsAlert />
{/* 2. Pending PO Approvals - What purchase orders need approval? */}
<div data-tour="pending-po-approvals">
<PendingPOApprovals
onApprovePO={handleApprovePO}
onRejectPO={handleRejectPO}
onViewDetails={handleViewPODetails}
onViewAllPOs={handleViewAllPOs}
maxPOs={5}
/>
</div>
{/* 3. Today's Production - What needs to be produced today? */}
<div data-tour="today-production">
<TodayProduction
onStartBatch={handleStartBatch}
onPauseBatch={handlePauseBatch}
onViewDetails={handleViewDetails}
onViewAllPlans={handleViewAllProduction}
maxBatches={5}
/>
</div>
</div>
{/* Purchase Order Details Modal */}
{showPOModal && poDetails && (
<EditViewModal
isOpen={showPOModal}
onClose={() => {
setShowPOModal(false);
setSelectedPOId(null);
}}
title={`Orden de Compra: ${poDetails.po_number}`}
subtitle={`Proveedor: ${poDetails.supplier?.name || poDetails.supplier_name || 'N/A'}`}
mode="view"
sections={buildPODetailsSections(poDetails)}
loading={isLoadingPO}
statusIndicator={{
color: poDetails.status === 'PENDING_APPROVAL' ? 'var(--color-warning)' :
poDetails.status === 'APPROVED' ? 'var(--color-success)' :
'var(--color-info)',
text: poDetails.status === 'PENDING_APPROVAL' ? 'Pendiente de Aprobación' :
poDetails.status === 'APPROVED' ? 'Aprobado' :
poDetails.status || 'N/A',
icon: ShoppingCart
}}
actions={
poDetails.status === 'PENDING_APPROVAL' ? [
{
label: 'Aprobar',
onClick: async () => {
try {
await approvePOMutation.mutateAsync({
tenantId: currentTenant?.id || '',
poId: poDetails.id,
notes: 'Aprobado desde el dashboard'
});
showToast.success('Orden aprobada');
setShowPOModal(false);
setSelectedPOId(null);
} catch (error) {
console.error('Error approving PO:', error);
showToast.error('Error al aprobar orden');
}
},
variant: 'primary' as const,
icon: CheckCircle
},
{
label: 'Rechazar',
onClick: async () => {
try {
await rejectPOMutation.mutateAsync({
tenantId: currentTenant?.id || '',
poId: poDetails.id,
reason: 'Rechazado desde el dashboard'
});
showToast.success('Orden rechazada');
setShowPOModal(false);
setSelectedPOId(null);
} catch (error) {
console.error('Error rejecting PO:', error);
showToast.error('Error al rechazar orden');
}
},
variant: 'outline' as const,
icon: X
}
] : undefined
}
/>
)}
{/* Production Batch Details Modal */}
{showBatchModal && batchDetails && (
<EditViewModal
isOpen={showBatchModal}
onClose={() => {
setShowBatchModal(false);
setSelectedBatchId(null);
}}
title={batchDetails.product_name}
subtitle={`Lote #${batchDetails.batch_number}`}
mode="view"
sections={buildBatchDetailsSections(batchDetails)}
loading={isLoadingBatch}
statusIndicator={{
color: batchDetails.status === 'PENDING' ? 'var(--color-warning)' :
batchDetails.status === 'IN_PROGRESS' ? 'var(--color-info)' :
batchDetails.status === 'COMPLETED' ? 'var(--color-success)' :
batchDetails.status === 'FAILED' ? 'var(--color-error)' :
'var(--color-info)',
text: batchDetails.status === 'PENDING' ? 'Pendiente' :
batchDetails.status === 'IN_PROGRESS' ? 'En Progreso' :
batchDetails.status === 'COMPLETED' ? 'Completado' :
batchDetails.status === 'FAILED' ? 'Fallido' :
batchDetails.status === 'ON_HOLD' ? 'Pausado' :
batchDetails.status || 'N/A',
icon: Factory
}}
actions={
batchDetails.status === 'PENDING' ? [
{
label: 'Iniciar Lote',
onClick: async () => {
try {
await updateBatchStatusMutation.mutateAsync({
tenantId: currentTenant?.id || '',
batchId: batchDetails.id,
statusUpdate: { status: ProductionStatusEnum.IN_PROGRESS }
});
showToast.success('Lote iniciado');
setShowBatchModal(false);
setSelectedBatchId(null);
} catch (error) {
console.error('Error starting batch:', error);
showToast.error('Error al iniciar lote');
}
},
variant: 'primary' as const,
icon: CheckCircle
}
] : batchDetails.status === 'IN_PROGRESS' ? [
{
label: 'Pausar Lote',
onClick: async () => {
try {
await updateBatchStatusMutation.mutateAsync({
tenantId: currentTenant?.id || '',
batchId: batchDetails.id,
statusUpdate: { status: ProductionStatusEnum.ON_HOLD }
});
showToast.success('Lote pausado');
setShowBatchModal(false);
setSelectedBatchId(null);
} catch (error) {
console.error('Error pausing batch:', error);
showToast.error('Error al pausar lote');
}
},
variant: 'outline' as const,
icon: X
}
] : undefined
}
/>
)}
</div>
);
};
export default DashboardPage;

View File

@@ -1,611 +1,233 @@
import React, { useEffect, useState } from 'react';
// ================================================================
// frontend/src/pages/app/NewDashboardPage.tsx
// ================================================================
/**
* JTBD-Aligned Dashboard Page
*
* Complete redesign based on Jobs To Be Done methodology.
* Focused on answering: "What requires my attention right now?"
*
* Key principles:
* - Automation-first (show what system did)
* - Action-oriented (prioritize tasks)
* - Progressive disclosure (show 20% that matters 80%)
* - Mobile-first (one-handed operation)
* - Trust-building (explain system reasoning)
*/
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { PageHeader } from '../../components/layout';
import StatsGrid from '../../components/ui/Stats/StatsGrid';
import RealTimeAlerts from '../../components/domain/dashboard/RealTimeAlerts';
import { IncompleteIngredientsAlert } from '../../components/domain/dashboard/IncompleteIngredientsAlert';
import { ConfigurationProgressWidget } from '../../components/domain/dashboard/ConfigurationProgressWidget';
import PendingPOApprovals from '../../components/domain/dashboard/PendingPOApprovals';
import TodayProduction from '../../components/domain/dashboard/TodayProduction';
// Sustainability widget removed - now using stats in StatsGrid
import { EditViewModal } from '../../components/ui';
import { useTenant } from '../../stores/tenant.store';
import { useDemoTour, shouldStartTour, clearTourStartPending } from '../../features/demo-onboarding';
import { useDashboardStats } from '../../api/hooks/dashboard';
import { usePurchaseOrder, useApprovePurchaseOrder, useRejectPurchaseOrder } from '../../api/hooks/purchase-orders';
import { useBatchDetails, useUpdateBatchStatus } from '../../api/hooks/production';
import { useRunDailyWorkflow } from '../../api';
import { ProductionStatusEnum } from '../../api';
import { RefreshCw, ExternalLink } from 'lucide-react';
import { useAppContext } from '../../contexts/AppContext';
import {
AlertTriangle,
Clock,
Euro,
Package,
FileText,
Building2,
Calendar,
CheckCircle,
X,
ShoppingCart,
Factory,
Timer,
TrendingDown,
Leaf,
Play
} from 'lucide-react';
import { showToast } from '../../utils/toast';
useBakeryHealthStatus,
useOrchestrationSummary,
useActionQueue,
useProductionTimeline,
useInsights,
useApprovePurchaseOrder,
useStartProductionBatch,
usePauseProductionBatch,
} from '../../api/hooks/newDashboard';
import { HealthStatusCard } from '../../components/dashboard/HealthStatusCard';
import { ActionQueueCard } from '../../components/dashboard/ActionQueueCard';
import { OrchestrationSummaryCard } from '../../components/dashboard/OrchestrationSummaryCard';
import { ProductionTimelineCard } from '../../components/dashboard/ProductionTimelineCard';
import { InsightsGrid } from '../../components/dashboard/InsightsGrid';
const DashboardPage: React.FC = () => {
const { t } = useTranslation();
export function NewDashboardPage() {
const navigate = useNavigate();
const { availableTenants, currentTenant } = useTenant();
const { startTour } = useDemoTour();
const isDemoMode = localStorage.getItem('demo_mode') === 'true';
const { selectedTenant } = useAppContext();
const tenantId = selectedTenant?.id || '';
// Modal state management
const [selectedPOId, setSelectedPOId] = useState<string | null>(null);
const [selectedBatchId, setSelectedBatchId] = useState<string | null>(null);
const [showPOModal, setShowPOModal] = useState(false);
const [showBatchModal, setShowBatchModal] = useState(false);
const [approvalNotes, setApprovalNotes] = useState('');
// Data fetching
const {
data: healthStatus,
isLoading: healthLoading,
refetch: refetchHealth,
} = useBakeryHealthStatus(tenantId);
// Fetch real dashboard statistics
const { data: dashboardStats, isLoading: isLoadingStats, error: statsError } = useDashboardStats(
currentTenant?.id || '',
{
enabled: !!currentTenant?.id,
}
);
const {
data: orchestrationSummary,
isLoading: orchestrationLoading,
refetch: refetchOrchestration,
} = useOrchestrationSummary(tenantId);
// Fetch PO details when modal is open
const { data: poDetails, isLoading: isLoadingPO } = usePurchaseOrder(
currentTenant?.id || '',
selectedPOId || '',
{
enabled: !!currentTenant?.id && !!selectedPOId && showPOModal
}
);
const {
data: actionQueue,
isLoading: actionQueueLoading,
refetch: refetchActionQueue,
} = useActionQueue(tenantId);
// Fetch Production batch details when modal is open
const { data: batchDetails, isLoading: isLoadingBatch } = useBatchDetails(
currentTenant?.id || '',
selectedBatchId || '',
{
enabled: !!currentTenant?.id && !!selectedBatchId && showBatchModal
}
);
const {
data: productionTimeline,
isLoading: timelineLoading,
refetch: refetchTimeline,
} = useProductionTimeline(tenantId);
const {
data: insights,
isLoading: insightsLoading,
refetch: refetchInsights,
} = useInsights(tenantId);
// Mutations
const approvePOMutation = useApprovePurchaseOrder();
const rejectPOMutation = useRejectPurchaseOrder();
const updateBatchStatusMutation = useUpdateBatchStatus();
const orchestratorMutation = useRunDailyWorkflow();
const approvePO = useApprovePurchaseOrder();
const startBatch = useStartProductionBatch();
const pauseBatch = usePauseProductionBatch();
const handleRunOrchestrator = async () => {
// Handlers
const handleApprove = async (actionId: string) => {
try {
await orchestratorMutation.mutateAsync(currentTenant?.id || '');
showToast.success('Flujo de planificación ejecutado exitosamente');
await approvePO.mutateAsync({ tenantId, poId: actionId });
// Refetch to update UI
refetchActionQueue();
refetchHealth();
} catch (error) {
console.error('Error running orchestrator:', error);
showToast.error('Error al ejecutar flujo de planificación');
console.error('Error approving PO:', error);
}
};
useEffect(() => {
console.log('[Dashboard] Demo mode:', isDemoMode);
console.log('[Dashboard] Should start tour:', shouldStartTour());
console.log('[Dashboard] SessionStorage demo_tour_should_start:', sessionStorage.getItem('demo_tour_should_start'));
console.log('[Dashboard] SessionStorage demo_tour_start_step:', sessionStorage.getItem('demo_tour_start_step'));
// Check if there's a tour intent from redirection (higher priority)
const shouldStartFromRedirect = sessionStorage.getItem('demo_tour_should_start') === 'true';
const redirectStartStep = parseInt(sessionStorage.getItem('demo_tour_start_step') || '0', 10);
if (isDemoMode && (shouldStartTour() || shouldStartFromRedirect)) {
console.log('[Dashboard] Starting tour in 1.5s...');
const timer = setTimeout(() => {
console.log('[Dashboard] Executing startTour()');
if (shouldStartFromRedirect) {
// Start tour from the specific step that was intended
startTour(redirectStartStep);
// Clear the redirect intent
sessionStorage.removeItem('demo_tour_should_start');
sessionStorage.removeItem('demo_tour_start_step');
} else {
// Start tour normally (from beginning or resume)
startTour();
clearTourStartPending();
}
}, 1500);
return () => clearTimeout(timer);
}
}, [isDemoMode, startTour]);
const handleViewAllProcurement = () => {
navigate('/app/operations/procurement');
const handleViewDetails = (actionId: string) => {
// Navigate to appropriate detail page based on action type
navigate(`/app/operations/procurement`);
};
const handleViewAllProduction = () => {
navigate('/app/operations/production');
};
const handleOrderItem = (itemId: string) => {
console.log('Ordering item:', itemId);
navigate('/app/operations/procurement');
const handleModify = (actionId: string) => {
navigate(`/app/operations/procurement`);
};
const handleStartBatch = async (batchId: string) => {
try {
await updateBatchStatusMutation.mutateAsync({
tenantId: currentTenant?.id || '',
batchId,
statusUpdate: { status: ProductionStatusEnum.IN_PROGRESS }
});
showToast.success('Lote iniciado');
await startBatch.mutateAsync({ tenantId, batchId });
refetchTimeline();
refetchHealth();
} catch (error) {
console.error('Error starting batch:', error);
showToast.error('Error al iniciar lote');
}
};
const handlePauseBatch = async (batchId: string) => {
try {
await updateBatchStatusMutation.mutateAsync({
tenantId: currentTenant?.id || '',
batchId,
statusUpdate: { status: ProductionStatusEnum.ON_HOLD }
});
showToast.success('Lote pausado');
await pauseBatch.mutateAsync({ tenantId, batchId });
refetchTimeline();
refetchHealth();
} catch (error) {
console.error('Error pausing batch:', error);
showToast.error('Error al pausar lote');
}
};
const handleViewDetails = (batchId: string) => {
setSelectedBatchId(batchId);
setShowBatchModal(true);
};
const handleApprovePO = async (poId: string) => {
try {
await approvePOMutation.mutateAsync({
tenantId: currentTenant?.id || '',
poId,
notes: 'Aprobado desde el dashboard'
});
showToast.success('Orden aprobada');
} catch (error) {
console.error('Error approving PO:', error);
showToast.error('Error al aprobar orden');
}
};
const handleRejectPO = async (poId: string) => {
try {
await rejectPOMutation.mutateAsync({
tenantId: currentTenant?.id || '',
poId,
reason: 'Rechazado desde el dashboard'
});
showToast.success('Orden rechazada');
} catch (error) {
console.error('Error rejecting PO:', error);
showToast.error('Error al rechazar orden');
}
};
const handleViewPODetails = (poId: string) => {
setSelectedPOId(poId);
setShowPOModal(true);
};
const handleViewAllPOs = () => {
navigate('/app/operations/procurement');
};
// Build stats from real API data (Sales analytics removed - Professional/Enterprise tier only)
const criticalStats = React.useMemo(() => {
if (!dashboardStats) {
// Return loading/empty state
return [];
}
// Determine trend direction
const getTrendDirection = (value: number): 'up' | 'down' | 'neutral' => {
if (value > 0) return 'up';
if (value < 0) return 'down';
return 'neutral';
};
return [
{
title: t('dashboard:stats.pending_orders', 'Pending Orders'),
value: dashboardStats.pendingOrders.toString(),
icon: Clock,
variant: dashboardStats.pendingOrders > 10 ? ('warning' as const) : ('info' as const),
trend: dashboardStats.ordersTrend !== 0 ? {
value: Math.abs(dashboardStats.ordersTrend),
direction: getTrendDirection(dashboardStats.ordersTrend),
label: t('dashboard:trends.vs_yesterday', '% vs yesterday')
} : undefined,
subtitle: dashboardStats.pendingOrders > 0
? t('dashboard:messages.require_attention', 'Require attention')
: t('dashboard:messages.all_caught_up', 'All caught up!')
},
{
title: t('dashboard:stats.stock_alerts', 'Critical Stock'),
value: dashboardStats.criticalStock.toString(),
icon: AlertTriangle,
variant: dashboardStats.criticalStock > 0 ? ('error' as const) : ('success' as const),
trend: undefined, // Stock alerts don't have historical trends
subtitle: dashboardStats.criticalStock > 0
? t('dashboard:messages.action_required', 'Action required')
: t('dashboard:messages.stock_healthy', 'Stock levels healthy')
},
{
title: t('dashboard:stats.waste_reduction', 'Waste Reduction'),
value: dashboardStats.wasteReductionPercentage
? `${Math.abs(dashboardStats.wasteReductionPercentage).toFixed(1)}%`
: '0%',
icon: TrendingDown,
variant: (dashboardStats.wasteReductionPercentage || 0) >= 15 ? ('success' as const) : ('info' as const),
trend: undefined,
subtitle: (dashboardStats.wasteReductionPercentage || 0) >= 15
? t('dashboard:messages.excellent_progress', 'Excellent progress!')
: t('dashboard:messages.keep_improving', 'Keep improving')
},
{
title: t('dashboard:stats.monthly_savings', 'Monthly Savings'),
value: dashboardStats.monthlySavingsEur
? `${dashboardStats.monthlySavingsEur.toFixed(0)}`
: '€0',
icon: Leaf,
variant: 'success' as const,
trend: undefined,
subtitle: t('dashboard:messages.from_sustainability', 'From sustainability')
}
];
}, [dashboardStats, t]);
// Helper function to build PO detail sections (reused from ProcurementPage)
const buildPODetailsSections = (po: any) => {
if (!po) return [];
const getPOStatusConfig = (status: string) => {
const normalizedStatus = status?.toUpperCase().replace(/_/g, '_');
const configs: Record<string, any> = {
PENDING_APPROVAL: { text: 'Pendiente de Aprobación', color: 'var(--color-warning)' },
APPROVED: { text: 'Aprobado', color: 'var(--color-success)' },
SENT_TO_SUPPLIER: { text: 'Enviado al Proveedor', color: 'var(--color-info)' },
CONFIRMED: { text: 'Confirmado', color: 'var(--color-success)' },
RECEIVED: { text: 'Recibido', color: 'var(--color-success)' },
COMPLETED: { text: 'Completado', color: 'var(--color-success)' },
CANCELLED: { text: 'Cancelado', color: 'var(--color-error)' },
};
return configs[normalizedStatus] || { text: status, color: 'var(--color-info)' };
};
const statusConfig = getPOStatusConfig(po.status);
return [
{
title: 'Información General',
icon: FileText,
fields: [
{ label: 'Número de Orden', value: po.po_number, type: 'text' as const },
{ label: 'Estado', value: statusConfig.text, type: 'status' as const },
{ label: 'Prioridad', value: po.priority === 'urgent' ? 'Urgente' : po.priority === 'high' ? 'Alta' : po.priority === 'low' ? 'Baja' : 'Normal', type: 'text' as const },
{ label: 'Fecha de Creación', value: new Date(po.created_at).toLocaleDateString('es-ES', { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' }), type: 'text' as const }
]
},
{
title: 'Información del Proveedor',
icon: Building2,
fields: [
{ label: 'Proveedor', value: po.supplier?.name || po.supplier_name || 'N/A', type: 'text' as const },
{ label: 'Email', value: po.supplier?.contact_email || 'N/A', type: 'text' as const },
{ label: 'Teléfono', value: po.supplier?.contact_phone || 'N/A', type: 'text' as const }
]
},
{
title: 'Resumen Financiero',
icon: Euro,
fields: [
{ label: 'Subtotal', value: `${(typeof po.subtotal === 'string' ? parseFloat(po.subtotal) : po.subtotal || 0).toFixed(2)}`, type: 'text' as const },
{ label: 'Impuestos', value: `${(typeof po.tax_amount === 'string' ? parseFloat(po.tax_amount) : po.tax_amount || 0).toFixed(2)}`, type: 'text' as const },
{ label: 'TOTAL', value: `${(typeof po.total_amount === 'string' ? parseFloat(po.total_amount) : po.total_amount || 0).toFixed(2)}`, type: 'text' as const, highlight: true }
]
},
{
title: 'Entrega',
icon: Calendar,
fields: [
{ label: 'Fecha Requerida', value: po.required_delivery_date ? new Date(po.required_delivery_date).toLocaleDateString('es-ES', { year: 'numeric', month: 'long', day: 'numeric' }) : 'No especificada', type: 'text' as const },
{ label: 'Fecha Esperada', value: po.expected_delivery_date ? new Date(po.expected_delivery_date).toLocaleDateString('es-ES', { year: 'numeric', month: 'long', day: 'numeric' }) : 'No especificada', type: 'text' as const }
]
}
];
};
// Helper function to build Production batch detail sections
const buildBatchDetailsSections = (batch: any) => {
if (!batch) return [];
return [
{
title: 'Información General',
icon: Package,
fields: [
{ label: 'Producto', value: batch.product_name, type: 'text' as const, highlight: true },
{ label: 'Número de Lote', value: batch.batch_number, type: 'text' as const },
{ label: 'Cantidad Planificada', value: `${batch.planned_quantity} unidades`, type: 'text' as const },
{ label: 'Cantidad Real', value: batch.actual_quantity ? `${batch.actual_quantity} unidades` : 'Pendiente', type: 'text' as const },
{ label: 'Estado', value: batch.status, type: 'text' as const },
{ label: 'Prioridad', value: batch.priority, type: 'text' as const }
]
},
{
title: 'Cronograma',
icon: Clock,
fields: [
{ label: 'Inicio Planificado', value: batch.planned_start_time ? new Date(batch.planned_start_time).toLocaleString('es-ES') : 'No especificado', type: 'text' as const },
{ label: 'Fin Planificado', value: batch.planned_end_time ? new Date(batch.planned_end_time).toLocaleString('es-ES') : 'No especificado', type: 'text' as const },
{ label: 'Inicio Real', value: batch.actual_start_time ? new Date(batch.actual_start_time).toLocaleString('es-ES') : 'Pendiente', type: 'text' as const },
{ label: 'Fin Real', value: batch.actual_end_time ? new Date(batch.actual_end_time).toLocaleString('es-ES') : 'Pendiente', type: 'text' as const }
]
},
{
title: 'Producción',
icon: Factory,
fields: [
{ label: 'Personal Asignado', value: batch.staff_assigned?.join(', ') || 'No asignado', type: 'text' as const },
{ label: 'Estación', value: batch.station_id || 'No asignada', type: 'text' as const },
{ label: 'Duración Planificada', value: batch.planned_duration_minutes ? `${batch.planned_duration_minutes} minutos` : 'No especificada', type: 'text' as const }
]
},
{
title: 'Calidad y Costos',
icon: CheckCircle,
fields: [
{ label: 'Puntuación de Calidad', value: batch.quality_score ? `${batch.quality_score}/10` : 'Pendiente', type: 'text' as const },
{ label: 'Rendimiento', value: batch.yield_percentage ? `${batch.yield_percentage}%` : 'Calculando...', type: 'text' as const },
{ label: 'Costo Estimado', value: batch.estimated_cost ? `${batch.estimated_cost}` : '€0.00', type: 'text' as const },
{ label: 'Costo Real', value: batch.actual_cost ? `${batch.actual_cost}` : '€0.00', type: 'text' as const }
]
}
];
const handleRefreshAll = () => {
refetchHealth();
refetchOrchestration();
refetchActionQueue();
refetchTimeline();
refetchInsights();
};
return (
<div className="space-y-6 p-4 sm:p-6">
<PageHeader
title={t('dashboard:title', 'Dashboard')}
description={t('dashboard:subtitle', 'Overview of your bakery operations')}
actions={[
{
id: 'run-orchestrator',
label: orchestratorMutation.isPending ? 'Ejecutando...' : 'Ejecutar Planificación Diaria',
icon: Play,
onClick: handleRunOrchestrator,
variant: 'primary', // Primary button for visibility
size: 'sm',
disabled: orchestratorMutation.isPending,
loading: orchestratorMutation.isPending
}
]}
/>
{/* Critical Metrics using StatsGrid */}
<div data-tour="dashboard-stats">
{isLoadingStats ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{[1, 2, 3, 4].map((i) => (
<div
key={i}
className="h-32 bg-[var(--bg-secondary)] border border-[var(--border-primary)] rounded-lg animate-pulse"
/>
))}
<div className="min-h-screen bg-gray-50 pb-20 md:pb-8">
{/* Mobile-optimized container */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-3xl md:text-4xl font-bold text-gray-900">Panel de Control</h1>
<p className="text-gray-600 mt-1">Your bakery at a glance</p>
</div>
) : statsError ? (
<div className="mb-6 p-4 bg-[var(--color-error)]/10 border border-[var(--color-error)]/20 rounded-lg">
<p className="text-[var(--color-error)] text-sm">
{t('dashboard:errors.failed_to_load_stats', 'Failed to load dashboard statistics. Please try again.')}
</p>
</div>
) : (
<StatsGrid
stats={criticalStats}
columns={4}
gap="lg"
className="mb-6"
/>
)}
</div>
{/* Dashboard Content - Main Sections */}
<div className="space-y-6">
{/* 0. Configuration Progress Widget */}
<ConfigurationProgressWidget />
{/* 1. Real-time Alerts */}
<div data-tour="real-time-alerts">
<RealTimeAlerts />
<button
onClick={handleRefreshAll}
className="flex items-center gap-2 px-4 py-2 bg-white hover:bg-gray-50 border border-gray-300 rounded-lg font-semibold text-gray-700 transition-colors duration-200"
>
<RefreshCw className="w-5 h-5" />
<span className="hidden sm:inline">Refresh</span>
</button>
</div>
{/* 1.5. Incomplete Ingredients Alert */}
<IncompleteIngredientsAlert />
{/* Main Dashboard Layout */}
<div className="space-y-6">
{/* SECTION 1: Bakery Health Status */}
{healthStatus && (
<HealthStatusCard healthStatus={healthStatus} loading={healthLoading} />
)}
{/* 2. Pending PO Approvals - What purchase orders need approval? */}
<div data-tour="pending-po-approvals">
<PendingPOApprovals
onApprovePO={handleApprovePO}
onRejectPO={handleRejectPO}
onViewDetails={handleViewPODetails}
onViewAllPOs={handleViewAllPOs}
maxPOs={5}
/>
</div>
{/* SECTION 2: What Needs Your Attention (Action Queue) */}
{actionQueue && (
<ActionQueueCard
actionQueue={actionQueue}
loading={actionQueueLoading}
onApprove={handleApprove}
onViewDetails={handleViewDetails}
onModify={handleModify}
/>
)}
{/* 3. Today's Production - What needs to be produced today? */}
<div data-tour="today-production">
<TodayProduction
onStartBatch={handleStartBatch}
onPauseBatch={handlePauseBatch}
onViewDetails={handleViewDetails}
onViewAllPlans={handleViewAllProduction}
maxBatches={5}
/>
{/* SECTION 3: What the System Did for You (Orchestration Summary) */}
{orchestrationSummary && (
<OrchestrationSummaryCard
summary={orchestrationSummary}
loading={orchestrationLoading}
/>
)}
{/* SECTION 4: Today's Production Timeline */}
{productionTimeline && (
<ProductionTimelineCard
timeline={productionTimeline}
loading={timelineLoading}
onStart={handleStartBatch}
onPause={handlePauseBatch}
/>
)}
{/* SECTION 5: Quick Insights Grid */}
<div>
<h2 className="text-2xl font-bold text-gray-900 mb-4">Key Metrics</h2>
{insights && <InsightsGrid insights={insights} loading={insightsLoading} />}
</div>
{/* SECTION 6: Quick Action Links */}
<div className="bg-white rounded-xl shadow-md p-6">
<h2 className="text-xl font-bold text-gray-900 mb-4">Quick Actions</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<button
onClick={() => navigate('/app/operations/procurement')}
className="flex items-center justify-between p-4 bg-blue-50 hover:bg-blue-100 rounded-lg transition-colors duration-200 group"
>
<span className="font-semibold text-blue-900">View Orders</span>
<ExternalLink className="w-5 h-5 text-blue-600 group-hover:translate-x-1 transition-transform duration-200" />
</button>
<button
onClick={() => navigate('/app/operations/production')}
className="flex items-center justify-between p-4 bg-green-50 hover:bg-green-100 rounded-lg transition-colors duration-200 group"
>
<span className="font-semibold text-green-900">Production</span>
<ExternalLink className="w-5 h-5 text-green-600 group-hover:translate-x-1 transition-transform duration-200" />
</button>
<button
onClick={() => navigate('/app/database/inventory')}
className="flex items-center justify-between p-4 bg-purple-50 hover:bg-purple-100 rounded-lg transition-colors duration-200 group"
>
<span className="font-semibold text-purple-900">Inventory</span>
<ExternalLink className="w-5 h-5 text-purple-600 group-hover:translate-x-1 transition-transform duration-200" />
</button>
<button
onClick={() => navigate('/app/database/suppliers')}
className="flex items-center justify-between p-4 bg-amber-50 hover:bg-amber-100 rounded-lg transition-colors duration-200 group"
>
<span className="font-semibold text-amber-900">Suppliers</span>
<ExternalLink className="w-5 h-5 text-amber-600 group-hover:translate-x-1 transition-transform duration-200" />
</button>
</div>
</div>
</div>
</div>
{/* Purchase Order Details Modal */}
{showPOModal && poDetails && (
<EditViewModal
isOpen={showPOModal}
onClose={() => {
setShowPOModal(false);
setSelectedPOId(null);
}}
title={`Orden de Compra: ${poDetails.po_number}`}
subtitle={`Proveedor: ${poDetails.supplier?.name || poDetails.supplier_name || 'N/A'}`}
mode="view"
sections={buildPODetailsSections(poDetails)}
loading={isLoadingPO}
statusIndicator={{
color: poDetails.status === 'PENDING_APPROVAL' ? 'var(--color-warning)' :
poDetails.status === 'APPROVED' ? 'var(--color-success)' :
'var(--color-info)',
text: poDetails.status === 'PENDING_APPROVAL' ? 'Pendiente de Aprobación' :
poDetails.status === 'APPROVED' ? 'Aprobado' :
poDetails.status || 'N/A',
icon: ShoppingCart
}}
actions={
poDetails.status === 'PENDING_APPROVAL' ? [
{
label: 'Aprobar',
onClick: async () => {
try {
await approvePOMutation.mutateAsync({
tenantId: currentTenant?.id || '',
poId: poDetails.id,
notes: 'Aprobado desde el dashboard'
});
showToast.success('Orden aprobada');
setShowPOModal(false);
setSelectedPOId(null);
} catch (error) {
console.error('Error approving PO:', error);
showToast.error('Error al aprobar orden');
}
},
variant: 'primary' as const,
icon: CheckCircle
},
{
label: 'Rechazar',
onClick: async () => {
try {
await rejectPOMutation.mutateAsync({
tenantId: currentTenant?.id || '',
poId: poDetails.id,
reason: 'Rechazado desde el dashboard'
});
showToast.success('Orden rechazada');
setShowPOModal(false);
setSelectedPOId(null);
} catch (error) {
console.error('Error rejecting PO:', error);
showToast.error('Error al rechazar orden');
}
},
variant: 'outline' as const,
icon: X
}
] : undefined
}
/>
)}
{/* Production Batch Details Modal */}
{showBatchModal && batchDetails && (
<EditViewModal
isOpen={showBatchModal}
onClose={() => {
setShowBatchModal(false);
setSelectedBatchId(null);
}}
title={batchDetails.product_name}
subtitle={`Lote #${batchDetails.batch_number}`}
mode="view"
sections={buildBatchDetailsSections(batchDetails)}
loading={isLoadingBatch}
statusIndicator={{
color: batchDetails.status === 'PENDING' ? 'var(--color-warning)' :
batchDetails.status === 'IN_PROGRESS' ? 'var(--color-info)' :
batchDetails.status === 'COMPLETED' ? 'var(--color-success)' :
batchDetails.status === 'FAILED' ? 'var(--color-error)' :
'var(--color-info)',
text: batchDetails.status === 'PENDING' ? 'Pendiente' :
batchDetails.status === 'IN_PROGRESS' ? 'En Progreso' :
batchDetails.status === 'COMPLETED' ? 'Completado' :
batchDetails.status === 'FAILED' ? 'Fallido' :
batchDetails.status === 'ON_HOLD' ? 'Pausado' :
batchDetails.status || 'N/A',
icon: Factory
}}
actions={
batchDetails.status === 'PENDING' ? [
{
label: 'Iniciar Lote',
onClick: async () => {
try {
await updateBatchStatusMutation.mutateAsync({
tenantId: currentTenant?.id || '',
batchId: batchDetails.id,
statusUpdate: { status: ProductionStatusEnum.IN_PROGRESS }
});
showToast.success('Lote iniciado');
setShowBatchModal(false);
setSelectedBatchId(null);
} catch (error) {
console.error('Error starting batch:', error);
showToast.error('Error al iniciar lote');
}
},
variant: 'primary' as const,
icon: CheckCircle
}
] : batchDetails.status === 'IN_PROGRESS' ? [
{
label: 'Pausar Lote',
onClick: async () => {
try {
await updateBatchStatusMutation.mutateAsync({
tenantId: currentTenant?.id || '',
batchId: batchDetails.id,
statusUpdate: { status: ProductionStatusEnum.ON_HOLD }
});
showToast.success('Lote pausado');
setShowBatchModal(false);
setSelectedBatchId(null);
} catch (error) {
console.error('Error pausing batch:', error);
showToast.error('Error al pausar lote');
}
},
variant: 'outline' as const,
icon: X
}
] : undefined
}
/>
)}
{/* Mobile-friendly bottom padding */}
<div className="h-20 md:hidden"></div>
</div>
);
};
export default DashboardPage;
}