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:
249
frontend/src/components/dashboard/ActionQueueCard.tsx
Normal file
249
frontend/src/components/dashboard/ActionQueueCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
149
frontend/src/components/dashboard/HealthStatusCard.tsx
Normal file
149
frontend/src/components/dashboard/HealthStatusCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
frontend/src/components/dashboard/InsightsGrid.tsx
Normal file
112
frontend/src/components/dashboard/InsightsGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
241
frontend/src/components/dashboard/OrchestrationSummaryCard.tsx
Normal file
241
frontend/src/components/dashboard/OrchestrationSummaryCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
223
frontend/src/components/dashboard/ProductionTimelineCard.tsx
Normal file
223
frontend/src/components/dashboard/ProductionTimelineCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
frontend/src/components/dashboard/index.ts
Normal file
13
frontend/src/components/dashboard/index.ts
Normal 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';
|
||||
Reference in New Issue
Block a user