Fix and UI imporvements 3
This commit is contained in:
238
SUPPLIER_ID_MISMATCH_ROOT_CAUSE.md
Normal file
238
SUPPLIER_ID_MISMATCH_ROOT_CAUSE.md
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
# Root Cause Analysis: Supplier ID Mismatch in Demo Sessions
|
||||||
|
|
||||||
|
## Problem Summary
|
||||||
|
|
||||||
|
In demo sessions, the supplier names are showing as "Unknown" in the Pending Purchases block, even though:
|
||||||
|
1. The Supplier API returns valid suppliers with real names (e.g., "Lácteos del Valle S.A.")
|
||||||
|
2. The alerts contain reasoning data with supplier names
|
||||||
|
3. The PO data has supplier IDs
|
||||||
|
|
||||||
|
## Root Cause
|
||||||
|
|
||||||
|
**The supplier IDs in the alert's reasoning data DO NOT match the cloned supplier IDs.**
|
||||||
|
|
||||||
|
### Why This Happens
|
||||||
|
|
||||||
|
The system uses an XOR-based strategy to generate tenant-specific UUIDs:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Formula used in all seed scripts:
|
||||||
|
supplier_id = uuid.UUID(int=tenant_int ^ base_supplier_int)
|
||||||
|
```
|
||||||
|
|
||||||
|
However, **the alert seeding script uses hardcoded placeholder IDs that don't follow this pattern:**
|
||||||
|
|
||||||
|
#### In `seed_enriched_alert_demo.py` (Line 45):
|
||||||
|
```python
|
||||||
|
YEAST_SUPPLIER_ID = "supplier-levadura-fresh" # ❌ String ID, not UUID
|
||||||
|
FLOUR_PO_ID = "po-flour-demo-001" # ❌ String ID, not UUID
|
||||||
|
```
|
||||||
|
|
||||||
|
#### In `seed_demo_purchase_orders.py` (Lines 62-67):
|
||||||
|
```python
|
||||||
|
# Hardcoded base supplier IDs (correct pattern)
|
||||||
|
BASE_SUPPLIER_IDS = [
|
||||||
|
uuid.UUID("40000000-0000-0000-0000-000000000001"), # Molinos San José S.L.
|
||||||
|
uuid.UUID("40000000-0000-0000-0000-000000000002"), # Lácteos del Valle S.A.
|
||||||
|
uuid.UUID("40000000-0000-0000-0000-000000000005"), # Lesaffre Ibérica
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
These base IDs are then XORed with the tenant ID to create unique supplier IDs for each tenant:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Line 136 of seed_demo_purchase_orders.py
|
||||||
|
tenant_int = int(tenant_id.hex, 16)
|
||||||
|
base_int = int(base_id.hex, 16)
|
||||||
|
supplier_id = uuid.UUID(int=tenant_int ^ base_int) # ✅ Correct cloning pattern
|
||||||
|
```
|
||||||
|
|
||||||
|
## The Data Flow Mismatch
|
||||||
|
|
||||||
|
### 1. Supplier Seeding (Template Tenants)
|
||||||
|
File: `services/suppliers/scripts/demo/seed_demo_suppliers.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Line 155-158: Creates suppliers with XOR-based IDs
|
||||||
|
base_supplier_id = uuid.UUID(supplier_data["id"]) # From proveedores_es.json
|
||||||
|
tenant_int = int(tenant_id.hex, 16)
|
||||||
|
supplier_id = uuid.UUID(int=tenant_int ^ int(base_supplier_id.hex, 16))
|
||||||
|
```
|
||||||
|
|
||||||
|
**Result:** Suppliers are created with tenant-specific UUIDs like:
|
||||||
|
- `uuid.UUID("6e1f9009-e640-48c7-95c5-17d6e7c1da55")` (example from API response)
|
||||||
|
|
||||||
|
### 2. Purchase Order Seeding (Template Tenants)
|
||||||
|
File: `services/procurement/scripts/demo/seed_demo_purchase_orders.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Lines 111-144: Uses same XOR pattern
|
||||||
|
def get_demo_supplier_ids(tenant_id: uuid.UUID):
|
||||||
|
tenant_int = int(tenant_id.hex, 16)
|
||||||
|
|
||||||
|
for i, base_id in enumerate(BASE_SUPPLIER_IDS):
|
||||||
|
base_int = int(base_id.hex, 16)
|
||||||
|
supplier_id = uuid.UUID(int=tenant_int ^ base_int) # ✅ Matches supplier seeding
|
||||||
|
```
|
||||||
|
|
||||||
|
**PO reasoning_data contains:**
|
||||||
|
```python
|
||||||
|
reasoning_data = create_po_reasoning_low_stock(
|
||||||
|
supplier_name=supplier.name, # ✅ CORRECT: Real supplier name like "Lácteos del Valle S.A."
|
||||||
|
product_names=product_names,
|
||||||
|
# ... other parameters
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Result:**
|
||||||
|
- POs are created with correct supplier IDs matching the suppliers
|
||||||
|
- `reasoning_data.parameters.supplier_name` contains the real supplier name (e.g., "Lácteos del Valle S.A.")
|
||||||
|
|
||||||
|
### 3. Alert Seeding (Demo Sessions)
|
||||||
|
File: `services/demo_session/scripts/seed_enriched_alert_demo.py`
|
||||||
|
|
||||||
|
**Problem:** Uses hardcoded string IDs instead of XOR-generated UUIDs:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Lines 40-46 ❌ WRONG: String IDs instead of proper UUIDs
|
||||||
|
FLOUR_INGREDIENT_ID = "flour-tipo-55"
|
||||||
|
YEAST_INGREDIENT_ID = "yeast-fresh"
|
||||||
|
CROISSANT_PRODUCT_ID = "croissant-mantequilla"
|
||||||
|
CROISSANT_BATCH_ID = "batch-croissants-001"
|
||||||
|
YEAST_SUPPLIER_ID = "supplier-levadura-fresh" # ❌ This doesn't match anything!
|
||||||
|
FLOUR_PO_ID = "po-flour-demo-001"
|
||||||
|
```
|
||||||
|
|
||||||
|
These IDs are then embedded in the alert metadata, but they don't match the actual cloned supplier IDs.
|
||||||
|
|
||||||
|
### 4. Session Cloning Process
|
||||||
|
File: `services/demo_session/app/services/clone_orchestrator.py`
|
||||||
|
|
||||||
|
When a user creates a demo session:
|
||||||
|
1. **Base template tenant** (e.g., `a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6`) is cloned
|
||||||
|
2. **Virtual tenant** is created (e.g., `f8e7d6c5-b4a3-2918-1726-354443526178`)
|
||||||
|
3. **Suppliers are cloned** using XOR pattern:
|
||||||
|
```python
|
||||||
|
# In services/suppliers/app/api/internal_demo.py
|
||||||
|
new_supplier_id = uuid.UUID(int=virtual_tenant_int ^ base_supplier_int)
|
||||||
|
```
|
||||||
|
4. **Purchase orders are cloned** with matching supplier IDs
|
||||||
|
5. **Alerts are generated** but use placeholder string IDs ❌
|
||||||
|
|
||||||
|
## Why the Frontend Shows "Unknown"
|
||||||
|
|
||||||
|
In `useDashboardData.ts` (line 142-144), the code tries to look up supplier names:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const supplierName = reasoningInfo?.supplier_name_from_alert || // ✅ This works!
|
||||||
|
supplierMap.get(po.supplier_id) || // ❌ This fails (ID mismatch)
|
||||||
|
po.supplier_name; // ❌ Fallback also fails
|
||||||
|
```
|
||||||
|
|
||||||
|
**However, our fix IS working!** The first line:
|
||||||
|
```typescript
|
||||||
|
reasoningInfo?.supplier_name_from_alert
|
||||||
|
```
|
||||||
|
|
||||||
|
This extracts the supplier name from the alert's reasoning data, which was correctly set during PO creation in `seed_demo_purchase_orders.py` (line 336):
|
||||||
|
|
||||||
|
```python
|
||||||
|
reasoning_data = create_po_reasoning_low_stock(
|
||||||
|
supplier_name=supplier.name, # ✅ Real name like "Lácteos del Valle S.A."
|
||||||
|
# ...
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## The Fix We Applied
|
||||||
|
|
||||||
|
In `useDashboardData.ts` (lines 127, 133-134, 142-144):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Extract supplier name from reasoning data
|
||||||
|
const supplierNameFromReasoning = reasoningData?.parameters?.supplier_name;
|
||||||
|
|
||||||
|
poReasoningMap.set(poId, {
|
||||||
|
reasoning_data: reasoningData,
|
||||||
|
ai_reasoning_summary: alert.ai_reasoning_summary || alert.description || alert.i18n?.message_key,
|
||||||
|
supplier_name_from_alert: supplierNameFromReasoning, // ✅ Real supplier name from PO creation
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prioritize supplier name from alert reasoning (has actual name in demo data)
|
||||||
|
const supplierName = reasoningInfo?.supplier_name_from_alert || // ✅ NOW WORKS!
|
||||||
|
supplierMap.get(po.supplier_id) ||
|
||||||
|
po.supplier_name;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Why This Fix Works
|
||||||
|
|
||||||
|
The **PO reasoning data is created during PO seeding**, not during alert seeding. When POs are created in `seed_demo_purchase_orders.py`, the code has access to the real supplier objects:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Line 490: Get suppliers using XOR pattern
|
||||||
|
suppliers = get_demo_supplier_ids(tenant_id)
|
||||||
|
|
||||||
|
# Line 498: Use supplier with correct ID and name
|
||||||
|
supplier_high_trust = high_trust_suppliers[0] if high_trust_suppliers else suppliers[0]
|
||||||
|
|
||||||
|
# Lines 533-545: Create PO with supplier reference
|
||||||
|
po3 = await create_purchase_order(
|
||||||
|
db, tenant_id, supplier_high_trust, # ✅ Has correct ID and name
|
||||||
|
PurchaseOrderStatus.pending_approval,
|
||||||
|
Decimal("450.00"),
|
||||||
|
# ...
|
||||||
|
)
|
||||||
|
|
||||||
|
# Line 336: Reasoning data includes real supplier name
|
||||||
|
reasoning_data = create_po_reasoning_low_stock(
|
||||||
|
supplier_name=supplier.name, # ✅ "Lácteos del Valle S.A."
|
||||||
|
# ...
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Why the Alert Seeder Doesn't Matter (For This Issue)
|
||||||
|
|
||||||
|
The alert seeder (`seed_enriched_alert_demo.py`) creates generic demo alerts with placeholder IDs, but these are NOT used for the PO approval alerts we see in the dashboard.
|
||||||
|
|
||||||
|
The **actual PO approval alerts are created automatically** by the procurement service when POs are created, and those alerts include the correct reasoning data with real supplier names.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Component | Supplier ID Source | Status |
|
||||||
|
|-----------|-------------------|--------|
|
||||||
|
| **Supplier Seed** | XOR(tenant_id, base_supplier_id) | ✅ Correct UUID |
|
||||||
|
| **PO Seed** | XOR(tenant_id, base_supplier_id) | ✅ Correct UUID |
|
||||||
|
| **PO Reasoning Data** | `supplier.name` (real name) | ✅ "Lácteos del Valle S.A." |
|
||||||
|
| **Alert Seed** | Hardcoded string "supplier-levadura-fresh" | ❌ Wrong format (but not used for PO alerts) |
|
||||||
|
| **Session Clone** | XOR(virtual_tenant_id, base_supplier_id) | ✅ Correct UUID |
|
||||||
|
| **Frontend Lookup** | `supplierMap.get(po.supplier_id)` | ❌ Fails (ID mismatch in demo) |
|
||||||
|
| **Frontend Fix** | `reasoningInfo?.supplier_name_from_alert` | ✅ WORKS! Gets name from PO reasoning |
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
The fix should now work because:
|
||||||
|
1. ✅ POs are created with `reasoning_data` containing `supplier_name` parameter
|
||||||
|
2. ✅ Frontend extracts `supplier_name` from `reasoning_data.parameters.supplier_name`
|
||||||
|
3. ✅ Frontend prioritizes this value over ID lookup
|
||||||
|
4. ✅ User should now see "Lácteos del Valle S.A." instead of "Unknown"
|
||||||
|
|
||||||
|
## Long-term Fix (Optional)
|
||||||
|
|
||||||
|
To fully resolve the underlying issue, the alert seeder should be updated to use proper XOR-based UUID generation instead of hardcoded string IDs:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# In seed_enriched_alert_demo.py, replace lines 40-46 with:
|
||||||
|
|
||||||
|
# Demo tenant ID (should match existing demo tenant)
|
||||||
|
DEMO_TENANT_ID = uuid.UUID("a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6")
|
||||||
|
|
||||||
|
# Base IDs matching suppliers seed
|
||||||
|
BASE_SUPPLIER_MOLINOS = uuid.UUID("40000000-0000-0000-0000-000000000001")
|
||||||
|
BASE_SUPPLIER_LACTEOS = uuid.UUID("40000000-0000-0000-0000-000000000002")
|
||||||
|
|
||||||
|
# Generate tenant-specific IDs using XOR
|
||||||
|
tenant_int = int(DEMO_TENANT_ID.hex, 16)
|
||||||
|
MOLINOS_SUPPLIER_ID = uuid.UUID(int=tenant_int ^ int(BASE_SUPPLIER_MOLINOS.hex, 16))
|
||||||
|
LACTEOS_SUPPLIER_ID = uuid.UUID(int=tenant_int ^ int(BASE_SUPPLIER_LACTEOS.hex, 16))
|
||||||
|
```
|
||||||
|
|
||||||
|
However, this is not necessary for fixing the current dashboard issue, as PO alerts use the correct reasoning data from PO creation.
|
||||||
317
frontend/src/api/hooks/useDashboardData.ts
Normal file
317
frontend/src/api/hooks/useDashboardData.ts
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
/**
|
||||||
|
* Unified Dashboard Data Hook
|
||||||
|
*
|
||||||
|
* Single data fetch for all 4 dashboard blocks.
|
||||||
|
* Fetches data once and computes derived values for efficiency.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { alertService } from '../services/alertService';
|
||||||
|
import { getPendingApprovalPurchaseOrders } from '../services/purchase_orders';
|
||||||
|
import { productionService } from '../services/production';
|
||||||
|
import { ProcurementService } from '../services/procurement-service';
|
||||||
|
import * as orchestratorService from '../services/orchestrator';
|
||||||
|
import { suppliersService } from '../services/suppliers';
|
||||||
|
import { useBatchNotifications, useDeliveryNotifications, useOrchestrationNotifications } from '../../hooks/useEventNotifications';
|
||||||
|
import { useSSEEvents } from '../../hooks/useSSE';
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export interface DashboardData {
|
||||||
|
// Raw data from APIs
|
||||||
|
alerts: any[];
|
||||||
|
pendingPOs: any[];
|
||||||
|
productionBatches: any[];
|
||||||
|
deliveries: any[];
|
||||||
|
orchestrationSummary: OrchestrationSummary | null;
|
||||||
|
|
||||||
|
// Computed/derived data
|
||||||
|
preventedIssues: any[];
|
||||||
|
issuesRequiringAction: number;
|
||||||
|
issuesPreventedByAI: number;
|
||||||
|
|
||||||
|
// Filtered data for blocks
|
||||||
|
overdueDeliveries: any[];
|
||||||
|
pendingDeliveries: any[];
|
||||||
|
lateToStartBatches: any[];
|
||||||
|
runningBatches: any[];
|
||||||
|
pendingBatches: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrchestrationSummary {
|
||||||
|
runTimestamp: string | null;
|
||||||
|
runNumber?: number;
|
||||||
|
status: string;
|
||||||
|
purchaseOrdersCreated: number;
|
||||||
|
productionBatchesCreated: number;
|
||||||
|
userActionsRequired: number;
|
||||||
|
aiHandlingRate?: number;
|
||||||
|
estimatedSavingsEur?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Main Hook
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified dashboard data hook.
|
||||||
|
* Fetches ALL data needed by the 4 dashboard blocks in a single parallel request.
|
||||||
|
*
|
||||||
|
* @param tenantId - Tenant identifier
|
||||||
|
* @returns Dashboard data for all blocks
|
||||||
|
*/
|
||||||
|
export function useDashboardData(tenantId: string) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const query = useQuery<DashboardData>({
|
||||||
|
queryKey: ['dashboard-data', tenantId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// Parallel fetch ALL data needed by all 4 blocks (including suppliers for PO enrichment)
|
||||||
|
const [alertsResponse, pendingPOs, productionResponse, deliveriesResponse, orchestration, suppliers] = await Promise.all([
|
||||||
|
alertService.getEvents(tenantId, { status: 'active', limit: 100 }).catch(() => []),
|
||||||
|
getPendingApprovalPurchaseOrders(tenantId, 100).catch(() => []),
|
||||||
|
productionService.getBatches(tenantId, { start_date: today, page_size: 100 }).catch(() => ({ batches: [] })),
|
||||||
|
ProcurementService.getExpectedDeliveries(tenantId, { days_ahead: 1, include_overdue: true }).catch(() => ({ deliveries: [] })),
|
||||||
|
orchestratorService.getLastOrchestrationRun(tenantId).catch(() => null),
|
||||||
|
suppliersService.getSuppliers(tenantId).catch(() => []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Normalize alerts (API returns array directly or {items: []})
|
||||||
|
const alerts = Array.isArray(alertsResponse) ? alertsResponse : (alertsResponse?.items || []);
|
||||||
|
const productionBatches = productionResponse?.batches || [];
|
||||||
|
const deliveries = deliveriesResponse?.deliveries || [];
|
||||||
|
|
||||||
|
// Create supplier ID -> supplier name map for quick lookup
|
||||||
|
const supplierMap = new Map<string, string>();
|
||||||
|
(suppliers || []).forEach((supplier: any) => {
|
||||||
|
supplierMap.set(supplier.id, supplier.name || supplier.supplier_name);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Compute derived data - prevented issues and action-needed counts
|
||||||
|
const preventedIssues = alerts.filter((a: any) => a.type_class === 'prevented_issue');
|
||||||
|
const actionNeededAlerts = alerts.filter((a: any) =>
|
||||||
|
a.type_class === 'action_needed' &&
|
||||||
|
!a.hidden_from_ui &&
|
||||||
|
a.status === 'active'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find PO approval alerts to get reasoning data
|
||||||
|
const poApprovalAlerts = alerts.filter((a: any) =>
|
||||||
|
a.event_type === 'po_approval_needed' ||
|
||||||
|
a.event_type === 'purchase_order_created'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create a map of PO ID -> reasoning data from alerts
|
||||||
|
const poReasoningMap = new Map<string, any>();
|
||||||
|
poApprovalAlerts.forEach((alert: any) => {
|
||||||
|
// Get PO ID from multiple possible locations
|
||||||
|
const poId = alert.event_metadata?.po_id ||
|
||||||
|
alert.entity_links?.purchase_order ||
|
||||||
|
alert.entity_id ||
|
||||||
|
alert.metadata?.purchase_order_id ||
|
||||||
|
alert.reference_id;
|
||||||
|
|
||||||
|
// Get reasoning data from multiple possible locations
|
||||||
|
const reasoningData = alert.event_metadata?.reasoning_data ||
|
||||||
|
alert.ai_reasoning_details ||
|
||||||
|
alert.reasoning_data ||
|
||||||
|
alert.ai_reasoning ||
|
||||||
|
alert.metadata?.reasoning;
|
||||||
|
|
||||||
|
// Get supplier name from reasoning data (which has the actual name, not the placeholder)
|
||||||
|
const supplierNameFromReasoning = reasoningData?.parameters?.supplier_name;
|
||||||
|
|
||||||
|
if (poId && reasoningData) {
|
||||||
|
poReasoningMap.set(poId, {
|
||||||
|
reasoning_data: reasoningData,
|
||||||
|
ai_reasoning_summary: alert.ai_reasoning_summary || alert.description || alert.i18n?.message_key,
|
||||||
|
supplier_name_from_alert: supplierNameFromReasoning, // Real supplier name from alert reasoning
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enrich POs with reasoning data from alerts AND supplier names
|
||||||
|
const enrichedPendingPOs = (pendingPOs || []).map((po: any) => {
|
||||||
|
const reasoningInfo = poReasoningMap.get(po.id);
|
||||||
|
// Prioritize supplier name from alert reasoning (has actual name in demo data)
|
||||||
|
const supplierName = reasoningInfo?.supplier_name_from_alert ||
|
||||||
|
supplierMap.get(po.supplier_id) ||
|
||||||
|
po.supplier_name;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...po,
|
||||||
|
supplier_name: supplierName, // Enrich with actual supplier name
|
||||||
|
reasoning_data: reasoningInfo?.reasoning_data,
|
||||||
|
ai_reasoning_summary: reasoningInfo?.ai_reasoning_summary,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter deliveries by status
|
||||||
|
const isPending = (status: string) =>
|
||||||
|
status === 'PENDING' || status === 'sent_to_supplier' || status === 'confirmed';
|
||||||
|
|
||||||
|
const overdueDeliveries = deliveries.filter((d: any) => {
|
||||||
|
if (!isPending(d.status)) return false;
|
||||||
|
const expectedDate = new Date(d.expected_delivery_date);
|
||||||
|
return expectedDate < now;
|
||||||
|
}).map((d: any) => ({
|
||||||
|
...d,
|
||||||
|
hoursOverdue: Math.ceil((now.getTime() - new Date(d.expected_delivery_date).getTime()) / (1000 * 60 * 60)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const pendingDeliveriesFiltered = deliveries.filter((d: any) => {
|
||||||
|
if (!isPending(d.status)) return false;
|
||||||
|
const expectedDate = new Date(d.expected_delivery_date);
|
||||||
|
return expectedDate >= now;
|
||||||
|
}).map((d: any) => ({
|
||||||
|
...d,
|
||||||
|
hoursUntil: Math.ceil((new Date(d.expected_delivery_date).getTime() - now.getTime()) / (1000 * 60 * 60)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Filter production batches by status
|
||||||
|
const lateToStartBatches = productionBatches.filter((b: any) => {
|
||||||
|
const status = b.status?.toUpperCase();
|
||||||
|
if (status !== 'PENDING' && status !== 'SCHEDULED') return false;
|
||||||
|
const plannedStart = b.planned_start_time;
|
||||||
|
if (!plannedStart) return false;
|
||||||
|
return new Date(plannedStart) < now;
|
||||||
|
}).map((b: any) => ({
|
||||||
|
...b,
|
||||||
|
hoursLate: Math.ceil((now.getTime() - new Date(b.planned_start_time).getTime()) / (1000 * 60 * 60)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const runningBatches = productionBatches.filter((b: any) =>
|
||||||
|
b.status?.toUpperCase() === 'IN_PROGRESS'
|
||||||
|
);
|
||||||
|
|
||||||
|
const pendingBatchesFiltered = productionBatches.filter((b: any) => {
|
||||||
|
const status = b.status?.toUpperCase();
|
||||||
|
if (status !== 'PENDING' && status !== 'SCHEDULED') return false;
|
||||||
|
const plannedStart = b.planned_start_time;
|
||||||
|
if (!plannedStart) return true; // No planned start, count as pending
|
||||||
|
return new Date(plannedStart) >= now;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build orchestration summary
|
||||||
|
// Note: The API only returns timestamp and runNumber, other stats are computed/estimated
|
||||||
|
let orchestrationSummary: OrchestrationSummary | null = null;
|
||||||
|
if (orchestration && orchestration.timestamp) {
|
||||||
|
orchestrationSummary = {
|
||||||
|
runTimestamp: orchestration.timestamp,
|
||||||
|
runNumber: orchestration.runNumber ?? undefined,
|
||||||
|
status: 'completed',
|
||||||
|
purchaseOrdersCreated: enrichedPendingPOs.length, // Estimate from pending POs
|
||||||
|
productionBatchesCreated: productionBatches.length,
|
||||||
|
userActionsRequired: actionNeededAlerts.length,
|
||||||
|
aiHandlingRate: preventedIssues.length > 0
|
||||||
|
? Math.round((preventedIssues.length / (preventedIssues.length + actionNeededAlerts.length)) * 100)
|
||||||
|
: undefined,
|
||||||
|
estimatedSavingsEur: preventedIssues.length * 50, // Rough estimate: €50 per prevented issue
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Raw data
|
||||||
|
alerts,
|
||||||
|
pendingPOs: enrichedPendingPOs,
|
||||||
|
productionBatches,
|
||||||
|
deliveries,
|
||||||
|
orchestrationSummary,
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
preventedIssues,
|
||||||
|
issuesRequiringAction: actionNeededAlerts.length,
|
||||||
|
issuesPreventedByAI: preventedIssues.length,
|
||||||
|
|
||||||
|
// Filtered for blocks
|
||||||
|
overdueDeliveries,
|
||||||
|
pendingDeliveries: pendingDeliveriesFiltered,
|
||||||
|
lateToStartBatches,
|
||||||
|
runningBatches,
|
||||||
|
pendingBatches: pendingBatchesFiltered,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
enabled: !!tenantId,
|
||||||
|
staleTime: 20000, // 20 seconds
|
||||||
|
refetchOnMount: 'always',
|
||||||
|
retry: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Real-time SSE Hook
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Real-time dashboard synchronization via SSE.
|
||||||
|
* Invalidates the dashboard-data query when relevant events occur.
|
||||||
|
*
|
||||||
|
* @param tenantId - Tenant identifier
|
||||||
|
*/
|
||||||
|
export function useDashboardRealtimeSync(tenantId: string) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Subscribe to SSE notifications
|
||||||
|
const { notifications: batchNotifications } = useBatchNotifications();
|
||||||
|
const { notifications: deliveryNotifications } = useDeliveryNotifications();
|
||||||
|
const { recentNotifications: orchestrationNotifications } = useOrchestrationNotifications();
|
||||||
|
const { events: alertEvents } = useSSEEvents({ channels: ['*.alerts'] });
|
||||||
|
|
||||||
|
// Invalidate dashboard data on batch events
|
||||||
|
useEffect(() => {
|
||||||
|
if (batchNotifications.length === 0 || !tenantId) return;
|
||||||
|
|
||||||
|
const latest = batchNotifications[0];
|
||||||
|
if (['batch_completed', 'batch_started', 'batch_state_changed'].includes(latest.event_type)) {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['dashboard-data', tenantId],
|
||||||
|
refetchType: 'active',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [batchNotifications, tenantId, queryClient]);
|
||||||
|
|
||||||
|
// Invalidate dashboard data on delivery events
|
||||||
|
useEffect(() => {
|
||||||
|
if (deliveryNotifications.length === 0 || !tenantId) return;
|
||||||
|
|
||||||
|
const latest = deliveryNotifications[0];
|
||||||
|
if (['delivery_received', 'delivery_overdue'].includes(latest.event_type)) {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['dashboard-data', tenantId],
|
||||||
|
refetchType: 'active',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [deliveryNotifications, tenantId, queryClient]);
|
||||||
|
|
||||||
|
// Invalidate dashboard data on orchestration events
|
||||||
|
useEffect(() => {
|
||||||
|
if (orchestrationNotifications.length === 0 || !tenantId) return;
|
||||||
|
|
||||||
|
const latest = orchestrationNotifications[0];
|
||||||
|
if (latest.event_type === 'orchestration_run_completed') {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['dashboard-data', tenantId],
|
||||||
|
refetchType: 'active',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [orchestrationNotifications, tenantId, queryClient]);
|
||||||
|
|
||||||
|
// Invalidate dashboard data on alert events
|
||||||
|
useEffect(() => {
|
||||||
|
if (!alertEvents || alertEvents.length === 0 || !tenantId) return;
|
||||||
|
|
||||||
|
// Any new alert should trigger a refresh
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['dashboard-data', tenantId],
|
||||||
|
refetchType: 'active',
|
||||||
|
});
|
||||||
|
}, [alertEvents, tenantId, queryClient]);
|
||||||
|
}
|
||||||
@@ -54,6 +54,13 @@ export interface SharedDashboardData {
|
|||||||
pendingPOs: any[];
|
pendingPOs: any[];
|
||||||
delayedBatches: any[];
|
delayedBatches: any[];
|
||||||
inventoryData: any;
|
inventoryData: any;
|
||||||
|
// Execution progress data for health component
|
||||||
|
executionProgress?: {
|
||||||
|
overdueDeliveries: number;
|
||||||
|
lateToStartBatches: number;
|
||||||
|
allProductionBatches: any[];
|
||||||
|
overdueDeliveryDetails?: any[];
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -64,20 +71,39 @@ function buildChecklistItems(
|
|||||||
productionDelays: number,
|
productionDelays: number,
|
||||||
outOfStock: number,
|
outOfStock: number,
|
||||||
pendingApprovals: number,
|
pendingApprovals: number,
|
||||||
alerts: any[]
|
alerts: any[],
|
||||||
|
lateToStartBatches: number,
|
||||||
|
overdueDeliveries: number
|
||||||
): HealthChecklistItem[] {
|
): HealthChecklistItem[] {
|
||||||
const items: HealthChecklistItem[] = [];
|
const items: HealthChecklistItem[] = [];
|
||||||
|
|
||||||
// Production status (tri-state)
|
// Production status (tri-state) - includes ON_HOLD batches + late-to-start batches
|
||||||
const productionPrevented = alerts.filter(
|
const productionPrevented = alerts.filter(
|
||||||
a => a.type_class === 'prevented_issue' && a.alert_type?.includes('production')
|
a => a.type_class === 'prevented_issue' && a.alert_type?.includes('production')
|
||||||
);
|
);
|
||||||
|
|
||||||
if (productionDelays > 0) {
|
const totalProductionIssues = productionDelays + lateToStartBatches;
|
||||||
|
|
||||||
|
if (totalProductionIssues > 0) {
|
||||||
|
// Build detailed message based on what types of issues exist
|
||||||
|
let textKey = 'dashboard.health.production_issues';
|
||||||
|
let textParams: any = { total: totalProductionIssues };
|
||||||
|
|
||||||
|
if (productionDelays > 0 && lateToStartBatches > 0) {
|
||||||
|
textKey = 'dashboard.health.production_delayed_and_late';
|
||||||
|
textParams = { delayed: productionDelays, late: lateToStartBatches };
|
||||||
|
} else if (productionDelays > 0) {
|
||||||
|
textKey = 'dashboard.health.production_delayed';
|
||||||
|
textParams = { count: productionDelays };
|
||||||
|
} else if (lateToStartBatches > 0) {
|
||||||
|
textKey = 'dashboard.health.production_late_to_start';
|
||||||
|
textParams = { count: lateToStartBatches };
|
||||||
|
}
|
||||||
|
|
||||||
items.push({
|
items.push({
|
||||||
icon: 'alert',
|
icon: 'alert',
|
||||||
textKey: 'dashboard.health.production_delayed',
|
textKey,
|
||||||
textParams: { count: productionDelays },
|
textParams,
|
||||||
actionRequired: true,
|
actionRequired: true,
|
||||||
status: 'needs_you',
|
status: 'needs_you',
|
||||||
actionPath: '/dashboard'
|
actionPath: '/dashboard'
|
||||||
@@ -162,20 +188,28 @@ function buildChecklistItems(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delivery status (tri-state)
|
// Delivery status (tri-state) - use actual overdue count from execution progress
|
||||||
const deliveryAlerts = alerts.filter(
|
const deliveryPrevented = alerts.filter(
|
||||||
a => a.alert_type?.includes('delivery')
|
a => a.type_class === 'prevented_issue' && a.alert_type?.includes('delivery')
|
||||||
);
|
);
|
||||||
|
|
||||||
if (deliveryAlerts.length > 0) {
|
if (overdueDeliveries > 0) {
|
||||||
items.push({
|
items.push({
|
||||||
icon: 'warning',
|
icon: 'alert',
|
||||||
textKey: 'dashboard.health.deliveries_pending',
|
textKey: 'dashboard.health.deliveries_overdue',
|
||||||
textParams: { count: deliveryAlerts.length },
|
textParams: { count: overdueDeliveries },
|
||||||
actionRequired: true,
|
actionRequired: true,
|
||||||
status: 'needs_you',
|
status: 'needs_you',
|
||||||
actionPath: '/dashboard'
|
actionPath: '/dashboard'
|
||||||
});
|
});
|
||||||
|
} else if (deliveryPrevented.length > 0) {
|
||||||
|
items.push({
|
||||||
|
icon: 'ai_handled',
|
||||||
|
textKey: 'dashboard.health.deliveries_ai_prevented',
|
||||||
|
textParams: { count: deliveryPrevented.length },
|
||||||
|
actionRequired: false,
|
||||||
|
status: 'ai_handled'
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
items.push({
|
items.push({
|
||||||
icon: 'check',
|
icon: 'check',
|
||||||
@@ -267,8 +301,8 @@ export function useSharedDashboardData(tenantId: string) {
|
|||||||
return useQuery<SharedDashboardData>({
|
return useQuery<SharedDashboardData>({
|
||||||
queryKey: ['shared-dashboard-data', tenantId],
|
queryKey: ['shared-dashboard-data', tenantId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
// Fetch data from 4 services in parallel - ONCE per dashboard load
|
// Fetch data from services in parallel - ONCE per dashboard load
|
||||||
const [alertsResponse, pendingPOs, delayedBatchesResp, inventoryData] = await Promise.all([
|
const [alertsResponse, pendingPOs, delayedBatchesResp, inventoryData, executionProgressResp] = await Promise.all([
|
||||||
// CHANGED: Add status=active filter and limit to 100 (backend max)
|
// CHANGED: Add status=active filter and limit to 100 (backend max)
|
||||||
alertService.getEvents(tenantId, {
|
alertService.getEvents(tenantId, {
|
||||||
status: 'active',
|
status: 'active',
|
||||||
@@ -277,6 +311,61 @@ export function useSharedDashboardData(tenantId: string) {
|
|||||||
getPendingApprovalPurchaseOrders(tenantId, 100),
|
getPendingApprovalPurchaseOrders(tenantId, 100),
|
||||||
productionService.getBatches(tenantId, { status: ProductionStatus.ON_HOLD, page_size: 100 }),
|
productionService.getBatches(tenantId, { status: ProductionStatus.ON_HOLD, page_size: 100 }),
|
||||||
inventoryService.getDashboardSummary(tenantId),
|
inventoryService.getDashboardSummary(tenantId),
|
||||||
|
// NEW: Fetch execution progress for timing data
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
// Fetch production batches and deliveries for timing calculations
|
||||||
|
const [prodBatches, deliveries] = await Promise.all([
|
||||||
|
productionService.getBatches(tenantId, {
|
||||||
|
start_date: new Date().toISOString().split('T')[0],
|
||||||
|
page_size: 100
|
||||||
|
}),
|
||||||
|
ProcurementService.getExpectedDeliveries(tenantId, {
|
||||||
|
days_ahead: 1,
|
||||||
|
include_overdue: true
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Calculate late-to-start batches (batches that should have started but haven't)
|
||||||
|
const now = new Date();
|
||||||
|
const allBatches = prodBatches?.batches || [];
|
||||||
|
const lateToStart = allBatches.filter((b: any) => {
|
||||||
|
// Only check PENDING or SCHEDULED batches (not started yet)
|
||||||
|
if (b.status !== 'PENDING' && b.status !== 'SCHEDULED') return false;
|
||||||
|
|
||||||
|
// Check if batch has a planned start time
|
||||||
|
const plannedStart = b.planned_start_time;
|
||||||
|
if (!plannedStart) return false;
|
||||||
|
|
||||||
|
// Check if planned start time is in the past (late to start)
|
||||||
|
return new Date(plannedStart) < now;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate overdue deliveries (pending deliveries with past due date)
|
||||||
|
const allDelivs = deliveries?.deliveries || [];
|
||||||
|
const isPending = (s: string) =>
|
||||||
|
s === 'PENDING' || s === 'sent_to_supplier' || s === 'confirmed';
|
||||||
|
const overdueDelivs = allDelivs.filter((d: any) =>
|
||||||
|
isPending(d.status) && new Date(d.expected_delivery_date) < now
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
overdueDeliveries: overdueDelivs.length,
|
||||||
|
lateToStartBatches: lateToStart.length,
|
||||||
|
allProductionBatches: allBatches,
|
||||||
|
overdueDeliveryDetails: overdueDelivs,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
// Fail gracefully - health will still work without execution progress
|
||||||
|
console.error('Failed to fetch execution progress for health:', err);
|
||||||
|
return {
|
||||||
|
overdueDeliveries: 0,
|
||||||
|
lateToStartBatches: 0,
|
||||||
|
allProductionBatches: [],
|
||||||
|
overdueDeliveryDetails: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// FIX: Alert API returns array directly, not {items: []}
|
// FIX: Alert API returns array directly, not {items: []}
|
||||||
@@ -287,6 +376,7 @@ export function useSharedDashboardData(tenantId: string) {
|
|||||||
pendingPOs: pendingPOs || [],
|
pendingPOs: pendingPOs || [],
|
||||||
delayedBatches: delayedBatchesResp?.batches || [],
|
delayedBatches: delayedBatchesResp?.batches || [],
|
||||||
inventoryData: inventoryData || {},
|
inventoryData: inventoryData || {},
|
||||||
|
executionProgress: executionProgressResp,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
enabled: !!tenantId,
|
enabled: !!tenantId,
|
||||||
@@ -335,23 +425,47 @@ export function useBakeryHealthStatus(tenantId: string, sharedData?: SharedDashb
|
|||||||
const criticalAlerts = alerts.filter((a: any) => a.priority_level === 'CRITICAL').length;
|
const criticalAlerts = alerts.filter((a: any) => a.priority_level === 'CRITICAL').length;
|
||||||
const aiPreventedCount = alerts.filter((a: any) => a.type_class === 'prevented_issue').length;
|
const aiPreventedCount = alerts.filter((a: any) => a.type_class === 'prevented_issue').length;
|
||||||
const pendingApprovals = pendingPOs.length;
|
const pendingApprovals = pendingPOs.length;
|
||||||
const productionDelays = delayedBatches.length;
|
const productionDelays = delayedBatches.length; // ON_HOLD batches
|
||||||
const outOfStock = inventoryData?.out_of_stock_items || 0;
|
const outOfStock = inventoryData?.out_of_stock_items || 0;
|
||||||
|
|
||||||
// Calculate health status (same logic as Python backend lines 245-268)
|
// Extract execution progress data (operational delays)
|
||||||
|
const executionProgress = sharedData?.executionProgress || {
|
||||||
|
overdueDeliveries: 0,
|
||||||
|
lateToStartBatches: 0,
|
||||||
|
allProductionBatches: [],
|
||||||
|
overdueDeliveryDetails: []
|
||||||
|
};
|
||||||
|
const overdueDeliveries = executionProgress.overdueDeliveries;
|
||||||
|
const lateToStartBatches = executionProgress.lateToStartBatches;
|
||||||
|
|
||||||
|
// Calculate health status - UPDATED to include operational delays
|
||||||
let status: 'green' | 'yellow' | 'red' = 'green';
|
let status: 'green' | 'yellow' | 'red' = 'green';
|
||||||
if (criticalAlerts >= 3 || outOfStock > 0 || productionDelays > 2) {
|
|
||||||
|
// Red conditions: Include operational delays (overdue deliveries, late batches)
|
||||||
|
if (
|
||||||
|
criticalAlerts >= 3 ||
|
||||||
|
outOfStock > 0 ||
|
||||||
|
productionDelays > 2 ||
|
||||||
|
overdueDeliveries > 0 || // NEW: Any overdue delivery = red
|
||||||
|
lateToStartBatches > 0 // NEW: Any late batch = red
|
||||||
|
) {
|
||||||
status = 'red';
|
status = 'red';
|
||||||
} else if (criticalAlerts > 0 || pendingApprovals > 0 || productionDelays > 0) {
|
} else if (
|
||||||
|
criticalAlerts > 0 ||
|
||||||
|
pendingApprovals > 0 ||
|
||||||
|
productionDelays > 0
|
||||||
|
) {
|
||||||
status = 'yellow';
|
status = 'yellow';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate tri-state checklist (same logic as Python backend lines 93-223)
|
// Generate tri-state checklist with operational delays
|
||||||
const checklistItems = buildChecklistItems(
|
const checklistItems = buildChecklistItems(
|
||||||
productionDelays,
|
productionDelays,
|
||||||
outOfStock,
|
outOfStock,
|
||||||
pendingApprovals,
|
pendingApprovals,
|
||||||
alerts
|
alerts,
|
||||||
|
lateToStartBatches, // NEW
|
||||||
|
overdueDeliveries // NEW
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get last orchestration run timestamp from orchestrator DB
|
// Get last orchestration run timestamp from orchestrator DB
|
||||||
@@ -372,7 +486,9 @@ export function useBakeryHealthStatus(tenantId: string, sharedData?: SharedDashb
|
|||||||
nextScheduledRun: nextRun.toISOString(),
|
nextScheduledRun: nextRun.toISOString(),
|
||||||
checklistItems,
|
checklistItems,
|
||||||
criticalIssues: criticalAlerts,
|
criticalIssues: criticalAlerts,
|
||||||
pendingActions: pendingApprovals + productionDelays + outOfStock,
|
// UPDATED: Include all operational delays (approvals, delays, stock, deliveries, late batches)
|
||||||
|
pendingActions: pendingApprovals + productionDelays + outOfStock +
|
||||||
|
overdueDeliveries + lateToStartBatches,
|
||||||
aiPreventedIssues: aiPreventedCount,
|
aiPreventedIssues: aiPreventedCount,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,548 +0,0 @@
|
|||||||
// ================================================================
|
|
||||||
// frontend/src/components/dashboard/ExecutionProgressTracker.tsx
|
|
||||||
// ================================================================
|
|
||||||
/**
|
|
||||||
* Execution Progress Tracker - Plan vs Actual
|
|
||||||
*
|
|
||||||
* Shows how today's execution is progressing vs the plan.
|
|
||||||
* Helps identify bottlenecks early (e.g., deliveries running late).
|
|
||||||
*
|
|
||||||
* Features:
|
|
||||||
* - Production progress (plan vs actual batches)
|
|
||||||
* - Delivery status (received, pending, overdue)
|
|
||||||
* - Approval tracking
|
|
||||||
* - "What's next" preview
|
|
||||||
* - Status indicators (on_track, at_risk, completed)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import {
|
|
||||||
Package,
|
|
||||||
Truck,
|
|
||||||
CheckCircle,
|
|
||||||
Clock,
|
|
||||||
AlertCircle,
|
|
||||||
TrendingUp,
|
|
||||||
Calendar,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { formatTime as formatTimeUtil } from '../../utils/date';
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Types
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
export interface ProductionProgress {
|
|
||||||
status: 'no_plan' | 'completed' | 'on_track' | 'at_risk';
|
|
||||||
total: number;
|
|
||||||
completed: number;
|
|
||||||
inProgress: number;
|
|
||||||
pending: number;
|
|
||||||
inProgressBatches?: Array<{
|
|
||||||
id: string;
|
|
||||||
batchNumber: string;
|
|
||||||
productName: string;
|
|
||||||
quantity: number;
|
|
||||||
actualStartTime: string;
|
|
||||||
estimatedCompletion: string;
|
|
||||||
}>;
|
|
||||||
nextBatch?: {
|
|
||||||
productName: string;
|
|
||||||
plannedStart: string; // ISO datetime
|
|
||||||
batchNumber: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeliveryInfo {
|
|
||||||
poId: string;
|
|
||||||
poNumber: string;
|
|
||||||
supplierName: string;
|
|
||||||
supplierPhone?: string;
|
|
||||||
expectedDeliveryDate: string;
|
|
||||||
status: string;
|
|
||||||
lineItems: Array<{
|
|
||||||
product_name: string;
|
|
||||||
quantity: number;
|
|
||||||
unit: string;
|
|
||||||
}>;
|
|
||||||
totalAmount: number;
|
|
||||||
currency: string;
|
|
||||||
itemCount: number;
|
|
||||||
hoursOverdue?: number;
|
|
||||||
hoursUntil?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeliveryProgress {
|
|
||||||
status: 'no_deliveries' | 'completed' | 'on_track' | 'at_risk';
|
|
||||||
total: number;
|
|
||||||
received: number;
|
|
||||||
pending: number;
|
|
||||||
overdue: number;
|
|
||||||
overdueDeliveries?: DeliveryInfo[];
|
|
||||||
pendingDeliveries?: DeliveryInfo[];
|
|
||||||
receivedDeliveries?: DeliveryInfo[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ApprovalProgress {
|
|
||||||
status: 'completed' | 'on_track' | 'at_risk';
|
|
||||||
pending: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExecutionProgress {
|
|
||||||
production: ProductionProgress;
|
|
||||||
deliveries: DeliveryProgress;
|
|
||||||
approvals: ApprovalProgress;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ExecutionProgressTrackerProps {
|
|
||||||
progress: ExecutionProgress | null | undefined;
|
|
||||||
loading?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Helper Functions
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
function getStatusColor(status: string): {
|
|
||||||
bg: string;
|
|
||||||
border: string;
|
|
||||||
text: string;
|
|
||||||
icon: string;
|
|
||||||
} {
|
|
||||||
switch (status) {
|
|
||||||
case 'completed':
|
|
||||||
return {
|
|
||||||
bg: 'var(--color-success-50)',
|
|
||||||
border: 'var(--color-success-300)',
|
|
||||||
text: 'var(--color-success-900)',
|
|
||||||
icon: 'var(--color-success-600)',
|
|
||||||
};
|
|
||||||
case 'on_track':
|
|
||||||
return {
|
|
||||||
bg: 'var(--color-info-50)',
|
|
||||||
border: 'var(--color-info-300)',
|
|
||||||
text: 'var(--color-info-900)',
|
|
||||||
icon: 'var(--color-info-600)',
|
|
||||||
};
|
|
||||||
case 'at_risk':
|
|
||||||
return {
|
|
||||||
bg: 'var(--color-error-50)',
|
|
||||||
border: 'var(--color-error-300)',
|
|
||||||
text: 'var(--color-error-900)',
|
|
||||||
icon: 'var(--color-error-600)',
|
|
||||||
};
|
|
||||||
case 'no_plan':
|
|
||||||
case 'no_deliveries':
|
|
||||||
return {
|
|
||||||
bg: 'var(--bg-secondary)',
|
|
||||||
border: 'var(--border-secondary)',
|
|
||||||
text: 'var(--text-secondary)',
|
|
||||||
icon: 'var(--text-tertiary)',
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
bg: 'var(--bg-secondary)',
|
|
||||||
border: 'var(--border-secondary)',
|
|
||||||
text: 'var(--text-primary)',
|
|
||||||
icon: 'var(--text-secondary)',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTime(isoDate: string): string {
|
|
||||||
return formatTimeUtil(isoDate, 'HH:mm');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Sub-Components
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
interface SectionProps {
|
|
||||||
title: string;
|
|
||||||
icon: React.ElementType;
|
|
||||||
status: string;
|
|
||||||
statusLabel: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Section({ title, icon: Icon, status, statusLabel, children }: SectionProps) {
|
|
||||||
const colors = getStatusColor(status);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="rounded-lg border-2 p-4"
|
|
||||||
style={{
|
|
||||||
backgroundColor: colors.bg,
|
|
||||||
borderColor: colors.border,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Section Header */}
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Icon className="w-5 h-5" style={{ color: colors.icon }} />
|
|
||||||
<h3 className="font-bold" style={{ color: colors.text }}>
|
|
||||||
{title}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className="px-3 py-1 rounded-full text-xs font-semibold"
|
|
||||||
style={{
|
|
||||||
backgroundColor: colors.border,
|
|
||||||
color: 'white',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{statusLabel}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Section Content */}
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Main Component
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
export function ExecutionProgressTracker({
|
|
||||||
progress,
|
|
||||||
loading,
|
|
||||||
}: ExecutionProgressTrackerProps) {
|
|
||||||
const { t } = useTranslation(['dashboard', 'common']);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="rounded-xl shadow-lg p-6 border"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--bg-primary)',
|
|
||||||
borderColor: 'var(--border-primary)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="animate-pulse space-y-4">
|
|
||||||
<div className="h-6 rounded w-1/3" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
|
|
||||||
<div className="h-32 rounded" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
|
|
||||||
<div className="h-32 rounded" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
|
|
||||||
<div className="h-32 rounded" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!progress) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="rounded-xl shadow-xl p-6 border-2 transition-all duration-300 hover:shadow-2xl"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--bg-primary)',
|
|
||||||
borderColor: 'var(--border-primary)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Header with Hero Icon */}
|
|
||||||
<div className="flex items-center gap-4 mb-6">
|
|
||||||
{/* Hero Icon */}
|
|
||||||
<div
|
|
||||||
className="flex-shrink-0 w-16 h-16 md:w-20 md:h-20 rounded-full flex items-center justify-center shadow-md"
|
|
||||||
style={{ backgroundColor: 'var(--color-primary-100)' }}
|
|
||||||
>
|
|
||||||
<TrendingUp className="w-8 h-8 md:w-10 md:h-10" strokeWidth={2.5} style={{ color: 'var(--color-primary-600)' }} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Title */}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h2 className="text-2xl md:text-3xl font-bold mb-2" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
{t('dashboard:execution_progress.title')}
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
{t('dashboard:execution_progress.subtitle')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Production Section */}
|
|
||||||
<Section
|
|
||||||
title={t('dashboard:execution_progress.production')}
|
|
||||||
icon={Package}
|
|
||||||
status={progress.production.status}
|
|
||||||
statusLabel={t(`dashboard:execution_progress.status.${progress.production.status}`)}
|
|
||||||
>
|
|
||||||
{progress.production.status === 'no_plan' ? (
|
|
||||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
{t('dashboard:execution_progress.no_production_plan')}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* Progress Bar */}
|
|
||||||
<div className="mb-3">
|
|
||||||
<div className="flex items-center justify-between text-sm mb-2">
|
|
||||||
<span style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
{progress.production.completed} / {progress.production.total} {t('dashboard:execution_progress.batches_complete')}
|
|
||||||
</span>
|
|
||||||
<span className="font-semibold" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
{Math.round((progress.production.completed / progress.production.total) * 100)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="h-3 rounded-full overflow-hidden"
|
|
||||||
style={{ backgroundColor: 'var(--bg-tertiary)' }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="h-full transition-all duration-500"
|
|
||||||
style={{
|
|
||||||
width: `${(progress.production.completed / progress.production.total) * 100}%`,
|
|
||||||
backgroundColor:
|
|
||||||
progress.production.status === 'at_risk'
|
|
||||||
? 'var(--color-error-500)'
|
|
||||||
: progress.production.status === 'completed'
|
|
||||||
? 'var(--color-success-500)'
|
|
||||||
: 'var(--color-info-500)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status Breakdown */}
|
|
||||||
<div className="grid grid-cols-3 gap-2 text-sm">
|
|
||||||
<div>
|
|
||||||
<span style={{ color: 'var(--text-secondary)' }}>{t('dashboard:execution_progress.completed')}:</span>
|
|
||||||
<span className="ml-2 font-semibold" style={{ color: 'var(--color-success-700)' }}>
|
|
||||||
{progress.production.completed}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span style={{ color: 'var(--text-secondary)' }}>{t('dashboard:execution_progress.in_progress')}:</span>
|
|
||||||
<span className="ml-2 font-semibold" style={{ color: 'var(--color-info-700)' }}>
|
|
||||||
{progress.production.inProgress}
|
|
||||||
</span>
|
|
||||||
{progress.production.inProgressBatches && progress.production.inProgressBatches.length > 0 && (
|
|
||||||
<div className="ml-6 mt-1 text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
|
||||||
{progress.production.inProgressBatches.map((batch) => (
|
|
||||||
<div key={batch.id} className="flex items-center gap-2">
|
|
||||||
<span>• {batch.productName}</span>
|
|
||||||
<span className="opacity-60">({batch.batchNumber})</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span style={{ color: 'var(--text-secondary)' }}>{t('dashboard:execution_progress.pending')}:</span>
|
|
||||||
<span className="ml-2 font-semibold" style={{ color: 'var(--text-tertiary)' }}>
|
|
||||||
{progress.production.pending}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Next Batch */}
|
|
||||||
{progress.production.nextBatch && (
|
|
||||||
<div
|
|
||||||
className="mt-3 p-3 rounded-lg border"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--bg-primary)',
|
|
||||||
borderColor: 'var(--border-secondary)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<Clock className="w-4 h-4" style={{ color: 'var(--color-info)' }} />
|
|
||||||
<span className="text-xs font-semibold" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
{t('dashboard:execution_progress.whats_next')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
{progress.production.nextBatch.productName}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
{progress.production.nextBatch.batchNumber} · {t('dashboard:execution_progress.starts_at')}{' '}
|
|
||||||
{formatTime(progress.production.nextBatch.plannedStart)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
{/* Deliveries Section */}
|
|
||||||
<Section
|
|
||||||
title={t('dashboard:execution_progress.deliveries')}
|
|
||||||
icon={Truck}
|
|
||||||
status={progress.deliveries.status}
|
|
||||||
statusLabel={t(`dashboard:execution_progress.status.${progress.deliveries.status}`)}
|
|
||||||
>
|
|
||||||
{progress.deliveries.status === 'no_deliveries' ? (
|
|
||||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
{t('dashboard:execution_progress.no_deliveries_today')}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* Summary Grid */}
|
|
||||||
<div className="grid grid-cols-3 gap-3 mb-4">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="flex items-center justify-center mb-1">
|
|
||||||
<CheckCircle className="w-4 h-4" style={{ color: 'var(--color-success-600)' }} />
|
|
||||||
</div>
|
|
||||||
<div className="text-2xl font-bold" style={{ color: 'var(--color-success-700)' }}>
|
|
||||||
{progress.deliveries.received}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
{t('dashboard:execution_progress.received')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="flex items-center justify-center mb-1">
|
|
||||||
<Clock className="w-4 h-4" style={{ color: 'var(--color-info-600)' }} />
|
|
||||||
</div>
|
|
||||||
<div className="text-2xl font-bold" style={{ color: 'var(--color-info-700)' }}>
|
|
||||||
{progress.deliveries.pending}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
{t('dashboard:execution_progress.pending')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="flex items-center justify-center mb-1">
|
|
||||||
<AlertCircle className="w-4 h-4" style={{ color: 'var(--color-error-600)' }} />
|
|
||||||
</div>
|
|
||||||
<div className="text-2xl font-bold" style={{ color: 'var(--color-error-700)' }}>
|
|
||||||
{progress.deliveries.overdue}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
{t('dashboard:execution_progress.overdue')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Overdue Deliveries List */}
|
|
||||||
{progress.deliveries.overdueDeliveries && progress.deliveries.overdueDeliveries.length > 0 && (
|
|
||||||
<div className="mb-3">
|
|
||||||
<div className="text-xs font-semibold mb-2 flex items-center gap-1" style={{ color: 'var(--color-error-700)' }}>
|
|
||||||
<AlertCircle className="w-3 h-3" />
|
|
||||||
{t('dashboard:execution_progress.overdue_deliveries')}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{progress.deliveries.overdueDeliveries.map((delivery) => (
|
|
||||||
<div
|
|
||||||
key={delivery.poId}
|
|
||||||
className="p-3 rounded-lg border"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--color-error-50)',
|
|
||||||
borderColor: 'var(--color-error-200)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between mb-1">
|
|
||||||
<div>
|
|
||||||
<div className="font-semibold text-sm" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
{delivery.supplierName}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
{delivery.poNumber} · {delivery.hoursOverdue}h {t('dashboard:execution_progress.overdue_label')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs font-semibold" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
{delivery.totalAmount.toFixed(2)} {delivery.currency}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs mt-2" style={{ color: 'var(--text-tertiary)' }}>
|
|
||||||
{delivery.lineItems.slice(0, 2).map((item, idx) => (
|
|
||||||
<div key={idx}>• {item.product_name} ({item.quantity} {item.unit})</div>
|
|
||||||
))}
|
|
||||||
{delivery.itemCount > 2 && (
|
|
||||||
<div>+ {delivery.itemCount - 2} {t('dashboard:execution_progress.more_items')}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Pending Deliveries List */}
|
|
||||||
{progress.deliveries.pendingDeliveries && progress.deliveries.pendingDeliveries.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<div className="text-xs font-semibold mb-2 flex items-center gap-1" style={{ color: 'var(--color-info-700)' }}>
|
|
||||||
<Clock className="w-3 h-3" />
|
|
||||||
{t('dashboard:execution_progress.pending_deliveries')}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{progress.deliveries.pendingDeliveries.slice(0, 3).map((delivery) => (
|
|
||||||
<div
|
|
||||||
key={delivery.poId}
|
|
||||||
className="p-3 rounded-lg border"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--bg-primary)',
|
|
||||||
borderColor: 'var(--border-secondary)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between mb-1">
|
|
||||||
<div>
|
|
||||||
<div className="font-semibold text-sm" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
{delivery.supplierName}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
{delivery.poNumber} · {delivery.hoursUntil !== undefined && delivery.hoursUntil >= 0
|
|
||||||
? `${t('dashboard:execution_progress.arriving_in')} ${delivery.hoursUntil}h`
|
|
||||||
: formatTime(delivery.expectedDeliveryDate)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs font-semibold" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
{delivery.totalAmount.toFixed(2)} {delivery.currency}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs mt-2" style={{ color: 'var(--text-tertiary)' }}>
|
|
||||||
{delivery.lineItems.slice(0, 2).map((item, idx) => (
|
|
||||||
<div key={idx}>• {item.product_name} ({item.quantity} {item.unit})</div>
|
|
||||||
))}
|
|
||||||
{delivery.itemCount > 2 && (
|
|
||||||
<div>+ {delivery.itemCount - 2} {t('dashboard:execution_progress.more_items')}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{progress.deliveries.pendingDeliveries.length > 3 && (
|
|
||||||
<div className="text-xs text-center py-1" style={{ color: 'var(--text-tertiary)' }}>
|
|
||||||
+ {progress.deliveries.pendingDeliveries.length - 3} {t('dashboard:execution_progress.more_deliveries')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
{/* Approvals Section */}
|
|
||||||
<Section
|
|
||||||
title={t('dashboard:execution_progress.approvals')}
|
|
||||||
icon={Calendar}
|
|
||||||
status={progress.approvals.status}
|
|
||||||
statusLabel={t(`dashboard:execution_progress.status.${progress.approvals.status}`)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
{t('dashboard:execution_progress.pending_approvals')}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className="text-3xl font-bold"
|
|
||||||
style={{
|
|
||||||
color:
|
|
||||||
progress.approvals.status === 'at_risk'
|
|
||||||
? 'var(--color-error-700)'
|
|
||||||
: progress.approvals.status === 'completed'
|
|
||||||
? 'var(--color-success-700)'
|
|
||||||
: 'var(--color-info-700)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{progress.approvals.pending}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,382 +0,0 @@
|
|||||||
// ================================================================
|
|
||||||
// frontend/src/components/dashboard/GlanceableHealthHero.tsx
|
|
||||||
// ================================================================
|
|
||||||
/**
|
|
||||||
* Glanceable Health Hero - Simplified Dashboard Status
|
|
||||||
*
|
|
||||||
* JTBD-Aligned Design:
|
|
||||||
* - Core Job: "Quickly understand if anything requires my immediate attention"
|
|
||||||
* - Emotional Job: "Feel confident to proceed or know to stop and fix"
|
|
||||||
* - Design Principle: Progressive disclosure (traffic light → details)
|
|
||||||
*
|
|
||||||
* States:
|
|
||||||
* - 🟢 Green: "Everything looks good - proceed with your day"
|
|
||||||
* - 🟡 Yellow: "Some items need attention - but not urgent"
|
|
||||||
* - 🔴 Red: "Critical issues - stop and fix these first"
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState, useMemo } from 'react';
|
|
||||||
import { CheckCircle, AlertTriangle, AlertCircle, Clock, RefreshCw, Zap, ChevronDown, ChevronUp, ChevronRight } from 'lucide-react';
|
|
||||||
import { BakeryHealthStatus } from '../../api/hooks/useProfessionalDashboard';
|
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
|
||||||
import { es, eu, enUS } from 'date-fns/locale';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { useEventNotifications } from '../../hooks/useEventNotifications';
|
|
||||||
|
|
||||||
interface GlanceableHealthHeroProps {
|
|
||||||
healthStatus: BakeryHealthStatus;
|
|
||||||
loading?: boolean;
|
|
||||||
urgentActionCount?: number; // New: show count of urgent actions
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusConfig = {
|
|
||||||
green: {
|
|
||||||
bgColor: 'var(--color-success-50)',
|
|
||||||
borderColor: 'var(--color-success-200)',
|
|
||||||
textColor: 'var(--color-success-900)',
|
|
||||||
icon: CheckCircle,
|
|
||||||
iconColor: 'var(--color-success-600)',
|
|
||||||
iconBg: 'var(--color-success-100)',
|
|
||||||
},
|
|
||||||
yellow: {
|
|
||||||
bgColor: 'var(--color-warning-50)',
|
|
||||||
borderColor: 'var(--color-warning-300)',
|
|
||||||
textColor: 'var(--color-warning-900)',
|
|
||||||
icon: AlertTriangle,
|
|
||||||
iconColor: 'var(--color-warning-600)',
|
|
||||||
iconBg: 'var(--color-warning-100)',
|
|
||||||
},
|
|
||||||
red: {
|
|
||||||
bgColor: 'var(--color-error-50)',
|
|
||||||
borderColor: 'var(--color-error-300)',
|
|
||||||
textColor: 'var(--color-error-900)',
|
|
||||||
icon: AlertCircle,
|
|
||||||
iconColor: 'var(--color-error-600)',
|
|
||||||
iconBg: 'var(--color-error-100)',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const iconMap = {
|
|
||||||
check: CheckCircle,
|
|
||||||
warning: AlertTriangle,
|
|
||||||
alert: AlertCircle,
|
|
||||||
ai_handled: Zap,
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper function to translate keys with proper namespace handling
|
|
||||||
*/
|
|
||||||
function translateKey(
|
|
||||||
key: string,
|
|
||||||
params: Record<string, any>,
|
|
||||||
t: any
|
|
||||||
): string {
|
|
||||||
const namespaceMap: Record<string, string> = {
|
|
||||||
'health.': 'dashboard',
|
|
||||||
'dashboard.health.': 'dashboard',
|
|
||||||
'dashboard.': 'dashboard',
|
|
||||||
'reasoning.': 'reasoning',
|
|
||||||
'production.': 'production',
|
|
||||||
'jtbd.': 'reasoning',
|
|
||||||
};
|
|
||||||
|
|
||||||
let namespace = 'common';
|
|
||||||
let translationKey = key;
|
|
||||||
|
|
||||||
for (const [prefix, ns] of Object.entries(namespaceMap)) {
|
|
||||||
if (key.startsWith(prefix)) {
|
|
||||||
namespace = ns;
|
|
||||||
if (prefix === 'reasoning.') {
|
|
||||||
translationKey = key.substring(prefix.length);
|
|
||||||
} else if (prefix === 'dashboard.health.') {
|
|
||||||
translationKey = key.substring('dashboard.'.length);
|
|
||||||
} else if (prefix === 'dashboard.' && !key.startsWith('dashboard.health.')) {
|
|
||||||
translationKey = key.substring('dashboard.'.length);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return t(translationKey, { ...params, ns: namespace, defaultValue: key });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GlanceableHealthHero({ healthStatus, loading, urgentActionCount = 0 }: GlanceableHealthHeroProps) {
|
|
||||||
const { t, i18n } = useTranslation(['dashboard', 'reasoning', 'production']);
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { notifications } = useEventNotifications();
|
|
||||||
const [detailsExpanded, setDetailsExpanded] = useState(false);
|
|
||||||
|
|
||||||
// Get date-fns locale
|
|
||||||
const dateLocale = i18n.language === 'es' ? es : i18n.language === 'eu' ? eu : enUS;
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// ALL HOOKS MUST BE CALLED BEFORE ANY EARLY RETURNS
|
|
||||||
// This ensures hooks are called in the same order on every render
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
// Optimize notifications filtering - cache the filtered array itself
|
|
||||||
const criticalAlerts = useMemo(() => {
|
|
||||||
if (!notifications || notifications.length === 0) return [];
|
|
||||||
return notifications.filter(
|
|
||||||
n => n.priority_level === 'critical' && !n.read && n.type_class !== 'prevented_issue'
|
|
||||||
);
|
|
||||||
}, [notifications]);
|
|
||||||
|
|
||||||
const criticalAlertsCount = criticalAlerts.length;
|
|
||||||
|
|
||||||
// Filter prevented issues from last 7 days to match IntelligentSystemSummaryCard
|
|
||||||
const preventedIssuesCount = useMemo(() => {
|
|
||||||
if (!notifications || notifications.length === 0) return 0;
|
|
||||||
const sevenDaysAgo = new Date();
|
|
||||||
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
|
||||||
|
|
||||||
return notifications.filter(
|
|
||||||
n => n.type_class === 'prevented_issue' && new Date(n.timestamp) >= sevenDaysAgo
|
|
||||||
).length;
|
|
||||||
}, [notifications]);
|
|
||||||
|
|
||||||
// Create stable key for checklist items to prevent infinite re-renders
|
|
||||||
const checklistItemsKey = useMemo(() => {
|
|
||||||
if (!healthStatus?.checklistItems || healthStatus.checklistItems.length === 0) return 'empty';
|
|
||||||
return healthStatus.checklistItems.map(item => item.textKey).join(',');
|
|
||||||
}, [healthStatus?.checklistItems]);
|
|
||||||
|
|
||||||
// Update checklist items with real-time data
|
|
||||||
const updatedChecklistItems = useMemo(() => {
|
|
||||||
if (!healthStatus?.checklistItems) return [];
|
|
||||||
|
|
||||||
return healthStatus.checklistItems.map(item => {
|
|
||||||
if (item.textKey === 'dashboard.health.critical_issues' && criticalAlertsCount > 0) {
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
textParams: { ...item.textParams, count: criticalAlertsCount },
|
|
||||||
status: 'needs_you' as const,
|
|
||||||
actionRequired: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return item;
|
|
||||||
});
|
|
||||||
}, [checklistItemsKey, criticalAlertsCount]);
|
|
||||||
|
|
||||||
// Status and config (use safe defaults for loading state)
|
|
||||||
const status = healthStatus?.status || 'green';
|
|
||||||
const config = statusConfig[status];
|
|
||||||
const StatusIcon = config?.icon || (() => <div>🟢</div>);
|
|
||||||
|
|
||||||
// Determine simplified headline for glanceable view (safe for loading state)
|
|
||||||
const simpleHeadline = useMemo(() => {
|
|
||||||
if (status === 'green') {
|
|
||||||
return t('jtbd.health_status.green_simple', { ns: 'reasoning', defaultValue: '✅ Todo listo para hoy' });
|
|
||||||
} else if (status === 'yellow') {
|
|
||||||
if (urgentActionCount > 0) {
|
|
||||||
return t('jtbd.health_status.yellow_simple_with_count', { count: urgentActionCount, ns: 'reasoning', defaultValue: `⚠️ ${urgentActionCount} acción${urgentActionCount > 1 ? 'es' : ''} necesaria${urgentActionCount > 1 ? 's' : ''}` });
|
|
||||||
}
|
|
||||||
return t('jtbd.health_status.yellow_simple', { ns: 'reasoning', defaultValue: '⚠️ Algunas cosas necesitan atención' });
|
|
||||||
} else {
|
|
||||||
return t('jtbd.health_status.red_simple', { ns: 'reasoning', defaultValue: '🔴 Problemas críticos requieren acción' });
|
|
||||||
}
|
|
||||||
}, [status, urgentActionCount, t]);
|
|
||||||
|
|
||||||
const displayCriticalIssues = criticalAlertsCount > 0 ? criticalAlertsCount : (healthStatus?.criticalIssues || 0);
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// NOW it's safe to early return - all hooks have been called
|
|
||||||
// ============================================================================
|
|
||||||
if (loading || !healthStatus) {
|
|
||||||
return (
|
|
||||||
<div className="animate-pulse rounded-xl shadow-lg p-6 border-2 border-[var(--border-primary)] bg-[var(--bg-primary)]">
|
|
||||||
<div className="h-16 rounded w-2/3 mb-4 bg-[var(--bg-tertiary)]"></div>
|
|
||||||
<div className="h-6 rounded w-1/2 bg-[var(--bg-tertiary)]"></div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="border-2 rounded-xl shadow-xl transition-all duration-300 hover:shadow-2xl"
|
|
||||||
style={{
|
|
||||||
backgroundColor: config.bgColor,
|
|
||||||
borderColor: config.borderColor,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Glanceable Hero View (Always Visible) */}
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
{/* Status Icon */}
|
|
||||||
<div
|
|
||||||
className="flex-shrink-0 w-16 h-16 md:w-20 md:h-20 rounded-full flex items-center justify-center shadow-md"
|
|
||||||
style={{ backgroundColor: config.iconBg }}
|
|
||||||
>
|
|
||||||
<StatusIcon className="w-8 h-8 md:w-10 md:h-10" strokeWidth={2.5} style={{ color: config.iconColor }} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Headline + Quick Stats */}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h2 className="text-2xl md:text-3xl font-bold mb-2" style={{ color: config.textColor }}>
|
|
||||||
{simpleHeadline}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{/* Quick Stats Row */}
|
|
||||||
<div className="flex flex-wrap items-center gap-3 text-sm">
|
|
||||||
{/* Last Update */}
|
|
||||||
<div className="flex items-center gap-1.5" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
<Clock className="w-4 h-4" />
|
|
||||||
<span>
|
|
||||||
{healthStatus.lastOrchestrationRun
|
|
||||||
? formatDistanceToNow(new Date(healthStatus.lastOrchestrationRun), {
|
|
||||||
addSuffix: true,
|
|
||||||
locale: dateLocale,
|
|
||||||
})
|
|
||||||
: t('jtbd.health_status.never', { ns: 'reasoning' })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Critical Issues Badge */}
|
|
||||||
{displayCriticalIssues > 0 && (
|
|
||||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded-md" style={{ backgroundColor: 'var(--color-error-100)', color: 'var(--color-error-800)' }}>
|
|
||||||
<AlertCircle className={`w-4 h-4 ${criticalAlertsCount > 0 ? 'animate-pulse' : ''}`} />
|
|
||||||
<span className="font-semibold">{displayCriticalIssues} crítico{displayCriticalIssues > 1 ? 's' : ''}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Pending Actions Badge */}
|
|
||||||
{healthStatus.pendingActions > 0 && (
|
|
||||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded-md" style={{ backgroundColor: 'var(--color-warning-100)', color: 'var(--color-warning-800)' }}>
|
|
||||||
<AlertTriangle className="w-4 h-4" />
|
|
||||||
<span className="font-semibold">{healthStatus.pendingActions} pendiente{healthStatus.pendingActions > 1 ? 's' : ''}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* AI Prevented Badge - Show last 7 days to match detail section */}
|
|
||||||
{preventedIssuesCount > 0 && (
|
|
||||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded-md" style={{ backgroundColor: 'var(--color-info-100)', color: 'var(--color-info-800)' }}>
|
|
||||||
<Zap className="w-4 h-4" />
|
|
||||||
<span className="font-semibold">{preventedIssuesCount} evitado{preventedIssuesCount > 1 ? 's' : ''}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Expand/Collapse Button */}
|
|
||||||
<button
|
|
||||||
onClick={() => setDetailsExpanded(!detailsExpanded)}
|
|
||||||
className="flex-shrink-0 p-2 rounded-lg transition-colors hover:bg-[var(--bg-tertiary)]"
|
|
||||||
title={detailsExpanded ? 'Ocultar detalles' : 'Ver detalles'}
|
|
||||||
>
|
|
||||||
{detailsExpanded ? (
|
|
||||||
<ChevronUp className="w-6 h-6" style={{ color: config.textColor }} />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="w-6 h-6" style={{ color: config.textColor }} />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Detailed Checklist (Collapsible) */}
|
|
||||||
{detailsExpanded && (
|
|
||||||
<div
|
|
||||||
className="px-6 pb-6 pt-2 border-t border-[var(--border-primary)]"
|
|
||||||
style={{ borderColor: config.borderColor }}
|
|
||||||
>
|
|
||||||
{/* Full Headline */}
|
|
||||||
<p className="text-base mb-4 text-[var(--text-secondary)]">
|
|
||||||
{typeof healthStatus.headline === 'object' && healthStatus.headline?.key
|
|
||||||
? translateKey(healthStatus.headline.key, healthStatus.headline.params || {}, t)
|
|
||||||
: healthStatus.headline}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Checklist */}
|
|
||||||
{updatedChecklistItems && updatedChecklistItems.length > 0 && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{updatedChecklistItems.map((item, index) => {
|
|
||||||
// Safely get the icon with proper validation
|
|
||||||
const SafeIconComponent = iconMap[item.icon];
|
|
||||||
const ItemIcon = SafeIconComponent || AlertCircle;
|
|
||||||
|
|
||||||
const getStatusStyles = () => {
|
|
||||||
switch (item.status) {
|
|
||||||
case 'good':
|
|
||||||
return {
|
|
||||||
iconColor: 'var(--color-success-600)',
|
|
||||||
bgColor: 'var(--color-success-50)',
|
|
||||||
borderColor: 'transparent',
|
|
||||||
};
|
|
||||||
case 'ai_handled':
|
|
||||||
return {
|
|
||||||
iconColor: 'var(--color-info-600)',
|
|
||||||
bgColor: 'var(--color-info-50)',
|
|
||||||
borderColor: 'var(--color-info-300)',
|
|
||||||
};
|
|
||||||
case 'needs_you':
|
|
||||||
return {
|
|
||||||
iconColor: 'var(--color-warning-600)',
|
|
||||||
bgColor: 'var(--color-warning-50)',
|
|
||||||
borderColor: 'var(--color-warning-300)',
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
iconColor: item.actionRequired ? 'var(--color-warning-600)' : 'var(--color-success-600)',
|
|
||||||
bgColor: item.actionRequired ? 'var(--bg-primary)' : 'var(--bg-secondary)',
|
|
||||||
borderColor: 'transparent',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = getStatusStyles();
|
|
||||||
const displayText = item.textKey
|
|
||||||
? translateKey(item.textKey, item.textParams || {}, t)
|
|
||||||
: item.text || '';
|
|
||||||
|
|
||||||
const handleClick = () => {
|
|
||||||
if (item.actionPath && (item.status === 'needs_you' || item.actionRequired)) {
|
|
||||||
navigate(item.actionPath);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const isClickable = Boolean(item.actionPath && (item.status === 'needs_you' || item.actionRequired));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className={`flex items-center gap-3 p-3 rounded-lg border transition-all ${
|
|
||||||
isClickable ? 'cursor-pointer hover:shadow-md hover:scale-[1.01] bg-[var(--bg-tertiary)]' : ''
|
|
||||||
}`}
|
|
||||||
style={{
|
|
||||||
backgroundColor: styles.bgColor,
|
|
||||||
borderColor: styles.borderColor,
|
|
||||||
}}
|
|
||||||
onClick={handleClick}
|
|
||||||
>
|
|
||||||
<ItemIcon className="w-5 h-5 flex-shrink-0" style={{ color: styles.iconColor }} />
|
|
||||||
<span
|
|
||||||
className={`flex-1 text-sm ${
|
|
||||||
item.status === 'needs_you' || item.actionRequired ? 'font-semibold' : ''
|
|
||||||
} text-[var(--text-primary)]`}
|
|
||||||
>
|
|
||||||
{displayText}
|
|
||||||
</span>
|
|
||||||
{isClickable && (
|
|
||||||
<ChevronRight className="w-4 h-4 flex-shrink-0 text-[var(--text-tertiary)]" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Next Check */}
|
|
||||||
{healthStatus.nextScheduledRun && (
|
|
||||||
<div className="flex items-center gap-2 text-sm mt-4 p-3 rounded-lg bg-[var(--bg-secondary)] text-[var(--text-secondary)]">
|
|
||||||
<RefreshCw className="w-4 h-4" />
|
|
||||||
<span>
|
|
||||||
{t('jtbd.health_status.next_check', { ns: 'reasoning' })}:{' '}
|
|
||||||
{formatDistanceToNow(new Date(healthStatus.nextScheduledRun), { addSuffix: true, locale: dateLocale })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,529 +0,0 @@
|
|||||||
// ================================================================
|
|
||||||
// frontend/src/components/dashboard/IntelligentSystemSummaryCard.tsx
|
|
||||||
// ================================================================
|
|
||||||
/**
|
|
||||||
* Intelligent System Summary Card - Unified AI Impact Component
|
|
||||||
*
|
|
||||||
* Simplified design matching GlanceableHealthHero pattern:
|
|
||||||
* - Clean, scannable header with inline metrics badges
|
|
||||||
* - Minimal orchestration summary (details shown elsewhere)
|
|
||||||
* - Progressive disclosure for prevented issues details
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState, useEffect, useMemo } from 'react';
|
|
||||||
import {
|
|
||||||
Bot,
|
|
||||||
TrendingUp,
|
|
||||||
TrendingDown,
|
|
||||||
Clock,
|
|
||||||
CheckCircle,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronUp,
|
|
||||||
Zap,
|
|
||||||
ShieldCheck,
|
|
||||||
Euro,
|
|
||||||
Package,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { OrchestrationSummary } from '../../api/hooks/useProfessionalDashboard';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { formatTime, formatRelativeTime } from '../../utils/date';
|
|
||||||
import { useTenant } from '../../stores/tenant.store';
|
|
||||||
import { useEventNotifications } from '../../hooks/useEventNotifications';
|
|
||||||
import { Alert } from '../../api/types/events';
|
|
||||||
import { renderEventTitle, renderEventMessage } from '../../utils/i18n/alertRendering';
|
|
||||||
import { Badge } from '../ui/Badge';
|
|
||||||
|
|
||||||
interface PeriodComparison {
|
|
||||||
current_period: {
|
|
||||||
days: number;
|
|
||||||
total_alerts: number;
|
|
||||||
prevented_issues: number;
|
|
||||||
handling_rate_percentage: number;
|
|
||||||
};
|
|
||||||
previous_period: {
|
|
||||||
days: number;
|
|
||||||
total_alerts: number;
|
|
||||||
prevented_issues: number;
|
|
||||||
handling_rate_percentage: number;
|
|
||||||
};
|
|
||||||
changes: {
|
|
||||||
handling_rate_change_percentage: number;
|
|
||||||
alert_count_change_percentage: number;
|
|
||||||
trend_direction: 'up' | 'down' | 'stable';
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DashboardAnalytics {
|
|
||||||
period_days: number;
|
|
||||||
total_alerts: number;
|
|
||||||
active_alerts: number;
|
|
||||||
ai_handling_rate: number;
|
|
||||||
prevented_issues_count: number;
|
|
||||||
estimated_savings_eur: number;
|
|
||||||
total_financial_impact_at_risk_eur: number;
|
|
||||||
period_comparison?: PeriodComparison;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface IntelligentSystemSummaryCardProps {
|
|
||||||
orchestrationSummary: OrchestrationSummary;
|
|
||||||
orchestrationLoading?: boolean;
|
|
||||||
onWorkflowComplete?: () => void;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function IntelligentSystemSummaryCard({
|
|
||||||
orchestrationSummary,
|
|
||||||
orchestrationLoading,
|
|
||||||
onWorkflowComplete,
|
|
||||||
className = '',
|
|
||||||
}: IntelligentSystemSummaryCardProps) {
|
|
||||||
const { t } = useTranslation(['dashboard', 'reasoning']);
|
|
||||||
const { currentTenant } = useTenant();
|
|
||||||
const { notifications } = useEventNotifications();
|
|
||||||
|
|
||||||
const [analytics, setAnalytics] = useState<DashboardAnalytics | null>(null);
|
|
||||||
const [preventedAlerts, setPreventedAlerts] = useState<Alert[]>([]);
|
|
||||||
const [analyticsLoading, setAnalyticsLoading] = useState(true);
|
|
||||||
const [preventedIssuesExpanded, setPreventedIssuesExpanded] = useState(false);
|
|
||||||
const [orchestrationExpanded, setOrchestrationExpanded] = useState(false);
|
|
||||||
|
|
||||||
// Fetch analytics data
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchAnalytics = async () => {
|
|
||||||
if (!currentTenant?.id) {
|
|
||||||
setAnalyticsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setAnalyticsLoading(true);
|
|
||||||
const { apiClient } = await import('../../api/client/apiClient');
|
|
||||||
|
|
||||||
const [analyticsData, alertsData] = await Promise.all([
|
|
||||||
apiClient.get<DashboardAnalytics>(
|
|
||||||
`/tenants/${currentTenant.id}/alerts/analytics/dashboard`,
|
|
||||||
{ params: { days: 30 } }
|
|
||||||
),
|
|
||||||
apiClient.get<{ alerts: Alert[] }>(
|
|
||||||
`/tenants/${currentTenant.id}/alerts`,
|
|
||||||
{ params: { limit: 100 } }
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
setAnalytics(analyticsData);
|
|
||||||
|
|
||||||
const sevenDaysAgo = new Date();
|
|
||||||
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
|
||||||
|
|
||||||
const filteredAlerts = (alertsData.alerts || [])
|
|
||||||
.filter(
|
|
||||||
(alert) =>
|
|
||||||
alert.type_class === 'prevented_issue' &&
|
|
||||||
new Date(alert.created_at) >= sevenDaysAgo
|
|
||||||
)
|
|
||||||
.slice(0, 20);
|
|
||||||
|
|
||||||
setPreventedAlerts(filteredAlerts);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error fetching intelligent system data:', err);
|
|
||||||
} finally {
|
|
||||||
setAnalyticsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchAnalytics();
|
|
||||||
}, [currentTenant?.id]);
|
|
||||||
|
|
||||||
// Real-time prevented issues from SSE - merge with API data
|
|
||||||
const allPreventedAlerts = useMemo(() => {
|
|
||||||
if (!notifications || notifications.length === 0) return preventedAlerts;
|
|
||||||
|
|
||||||
// Filter SSE notifications for prevented issues from last 7 days
|
|
||||||
const sevenDaysAgo = new Date();
|
|
||||||
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
|
||||||
|
|
||||||
const ssePreventedIssues = notifications.filter(
|
|
||||||
(n) => n.type_class === 'prevented_issue' && new Date(n.created_at) >= sevenDaysAgo
|
|
||||||
);
|
|
||||||
|
|
||||||
// Deduplicate: combine SSE + API data, removing duplicates by ID
|
|
||||||
const existingIds = new Set(preventedAlerts.map((a) => a.id));
|
|
||||||
const newSSEAlerts = ssePreventedIssues.filter((n) => !existingIds.has(n.id));
|
|
||||||
|
|
||||||
// Merge and sort by created_at (newest first)
|
|
||||||
const merged = [...preventedAlerts, ...newSSEAlerts].sort(
|
|
||||||
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
|
||||||
);
|
|
||||||
|
|
||||||
return merged.slice(0, 20); // Keep only top 20
|
|
||||||
}, [preventedAlerts, notifications]);
|
|
||||||
|
|
||||||
// Calculate metrics
|
|
||||||
const totalSavings = analytics?.estimated_savings_eur || 0;
|
|
||||||
const trendPercentage = analytics?.period_comparison?.changes?.handling_rate_change_percentage || 0;
|
|
||||||
const hasPositiveTrend = trendPercentage > 0;
|
|
||||||
|
|
||||||
// Loading state
|
|
||||||
if (analyticsLoading || orchestrationLoading) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`rounded-xl shadow-xl p-6 border-2 ${className}`}
|
|
||||||
style={{ backgroundColor: 'var(--bg-primary)', borderColor: 'var(--border-primary)' }}
|
|
||||||
>
|
|
||||||
<div className="animate-pulse space-y-4">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="w-16 h-16 rounded-full" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="h-6 rounded w-1/2 mb-2" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
|
|
||||||
<div className="h-4 rounded w-3/4" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`rounded-xl shadow-xl border-2 transition-all duration-300 hover:shadow-2xl ${className}`}
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--color-success-50)',
|
|
||||||
borderColor: 'var(--color-success-200)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Always Visible Header - GlanceableHealthHero Style */}
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
{/* Icon */}
|
|
||||||
<div
|
|
||||||
className="w-16 h-16 md:w-20 md:h-20 rounded-full flex items-center justify-center flex-shrink-0 shadow-md"
|
|
||||||
style={{ backgroundColor: 'var(--color-success-100)' }}
|
|
||||||
>
|
|
||||||
<Bot className="w-8 h-8 md:w-10 md:h-10" style={{ color: 'var(--color-success-600)' }} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Title + Metrics Badges */}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h2 className="text-2xl md:text-3xl font-bold mb-2" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
{t('dashboard:intelligent_system.title', 'Intelligent System Summary')}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{/* Inline Metrics Badges */}
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
{/* AI Handling Rate Badge */}
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-2 px-2 py-1 rounded-md"
|
|
||||||
style={{ backgroundColor: 'var(--color-success-100)', border: '1px solid var(--color-success-300)' }}
|
|
||||||
>
|
|
||||||
<span className="text-sm font-semibold" style={{ color: 'var(--color-success-700)' }}>
|
|
||||||
{analytics?.ai_handling_rate.toFixed(1)}%
|
|
||||||
</span>
|
|
||||||
{hasPositiveTrend ? (
|
|
||||||
<TrendingUp className="w-3 h-3" style={{ color: 'var(--color-success-600)' }} />
|
|
||||||
) : (
|
|
||||||
<TrendingDown className="w-3 h-3" style={{ color: 'var(--color-error)' }} />
|
|
||||||
)}
|
|
||||||
{trendPercentage !== 0 && (
|
|
||||||
<span className="text-xs" style={{ color: 'var(--color-success-600)' }}>
|
|
||||||
{trendPercentage > 0 ? '+' : ''}{trendPercentage}%
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Prevented Issues Badge - Show actual count from last 7 days to match detail section */}
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-1 px-2 py-1 rounded-md"
|
|
||||||
style={{ backgroundColor: 'var(--color-primary-100)', border: '1px solid var(--color-primary-300)' }}
|
|
||||||
>
|
|
||||||
<ShieldCheck className="w-4 h-4" style={{ color: 'var(--color-primary-600)' }} />
|
|
||||||
<span className="text-sm font-semibold" style={{ color: 'var(--color-primary-700)' }}>
|
|
||||||
{allPreventedAlerts.length}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs" style={{ color: 'var(--color-primary-600)' }}>
|
|
||||||
{t('dashboard:intelligent_system.prevented_issues', 'issues')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Savings Badge */}
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-1 px-2 py-1 rounded-md"
|
|
||||||
style={{ backgroundColor: 'var(--color-success-100)', border: '1px solid var(--color-success-300)' }}
|
|
||||||
>
|
|
||||||
<Euro className="w-4 h-4" style={{ color: 'var(--color-success-600)' }} />
|
|
||||||
<span className="text-sm font-semibold" style={{ color: 'var(--color-success-700)' }}>
|
|
||||||
€{totalSavings.toFixed(0)}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs" style={{ color: 'var(--color-success-600)' }}>
|
|
||||||
saved
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Expand Button */}
|
|
||||||
<button
|
|
||||||
onClick={() => setPreventedIssuesExpanded(!preventedIssuesExpanded)}
|
|
||||||
className="flex-shrink-0 p-2 rounded-lg hover:bg-black/5 transition-colors"
|
|
||||||
>
|
|
||||||
{preventedIssuesExpanded ? (
|
|
||||||
<ChevronUp className="w-6 h-6" style={{ color: 'var(--text-secondary)' }} />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="w-6 h-6" style={{ color: 'var(--text-secondary)' }} />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Collapsible Section: Prevented Issues Details */}
|
|
||||||
{preventedIssuesExpanded && (
|
|
||||||
<div className="px-6 pb-6 pt-2 border-t" style={{ borderColor: 'var(--color-success-200)' }}>
|
|
||||||
{allPreventedAlerts.length === 0 ? (
|
|
||||||
<div className="text-center py-8">
|
|
||||||
<p className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
{t('dashboard:intelligent_system.no_prevented_issues', 'No issues prevented this week - all systems running smoothly!')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* Celebration Message */}
|
|
||||||
<div
|
|
||||||
className="rounded-lg p-3 mb-4"
|
|
||||||
style={{ backgroundColor: 'var(--color-success-100)', border: '1px solid var(--color-success-300)' }}
|
|
||||||
>
|
|
||||||
<p className="text-sm font-semibold" style={{ color: 'var(--color-success-700)' }}>
|
|
||||||
{t('dashboard:intelligent_system.celebration', 'Great news! AI prevented {count} issue(s) before they became problems.', {
|
|
||||||
count: allPreventedAlerts.length,
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Prevented Issues List */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
{allPreventedAlerts.map((alert) => {
|
|
||||||
const savings = alert.orchestrator_context?.estimated_savings_eur || 0;
|
|
||||||
const actionTaken = alert.orchestrator_context?.action_taken || 'AI intervention';
|
|
||||||
const timeAgo = formatRelativeTime(alert.created_at) || 'Fecha desconocida';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={alert.id}
|
|
||||||
className="rounded-lg p-3 border"
|
|
||||||
style={{ backgroundColor: 'var(--bg-primary)', borderColor: 'var(--border-primary)' }}
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between mb-2">
|
|
||||||
<div className="flex items-start gap-2 flex-1">
|
|
||||||
<CheckCircle className="w-4 h-4 mt-0.5 flex-shrink-0" style={{ color: 'var(--color-success-600)' }} />
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h4 className="font-semibold text-sm mb-1" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
{renderEventTitle(alert, t)}
|
|
||||||
</h4>
|
|
||||||
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
{renderEventMessage(alert, t)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{savings > 0 && (
|
|
||||||
<Badge variant="success" className="ml-2 flex-shrink-0">
|
|
||||||
<Euro className="w-3 h-3 mr-1" />
|
|
||||||
€{savings.toFixed(0)}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Zap className="w-3 h-3" />
|
|
||||||
<span>{actionTaken}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Clock className="w-3 h-3" />
|
|
||||||
<span>{timeAgo}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Collapsible Section: Latest Orchestration Run (Ultra Minimal) */}
|
|
||||||
<div className="border-t" style={{ borderColor: 'var(--color-success-200)' }}>
|
|
||||||
<button
|
|
||||||
onClick={() => setOrchestrationExpanded(!orchestrationExpanded)}
|
|
||||||
className="w-full flex items-center justify-between px-6 py-3 hover:bg-black/5 transition-colors"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Bot className="w-5 h-5" style={{ color: 'var(--color-primary)' }} />
|
|
||||||
<h3 className="text-base font-bold" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
{t('dashboard:intelligent_system.orchestration_title', 'Latest Orchestration Run')}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
{orchestrationExpanded ? (
|
|
||||||
<ChevronUp className="w-5 h-5" style={{ color: 'var(--text-secondary)' }} />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="w-5 h-5" style={{ color: 'var(--text-secondary)' }} />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{orchestrationExpanded && (
|
|
||||||
<div className="px-6 pb-4">
|
|
||||||
{orchestrationSummary && orchestrationSummary.status !== 'no_runs' ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{/* Run Info Line */}
|
|
||||||
<div className="flex items-center gap-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
<Clock className="w-4 h-4" />
|
|
||||||
<span>
|
|
||||||
{t('reasoning:jtbd.orchestration_summary.run_info', 'Run #{runNumber}', {
|
|
||||||
runNumber: orchestrationSummary.runNumber || 0,
|
|
||||||
})}{' '}
|
|
||||||
•{' '}
|
|
||||||
{orchestrationSummary.runTimestamp
|
|
||||||
? formatRelativeTime(orchestrationSummary.runTimestamp) || 'recently'
|
|
||||||
: 'recently'}
|
|
||||||
{orchestrationSummary.durationSeconds && ` • ${orchestrationSummary.durationSeconds}s`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Summary Line */}
|
|
||||||
<div className="text-sm" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
{orchestrationSummary.purchaseOrdersCreated > 0 && (
|
|
||||||
<span>
|
|
||||||
<span className="font-semibold">{orchestrationSummary.purchaseOrdersCreated}</span>{' '}
|
|
||||||
{orchestrationSummary.purchaseOrdersCreated === 1 ? 'purchase order' : 'purchase orders'}
|
|
||||||
{orchestrationSummary.purchaseOrdersSummary && orchestrationSummary.purchaseOrdersSummary.length > 0 && (
|
|
||||||
<span>
|
|
||||||
{' '}(€
|
|
||||||
{orchestrationSummary.purchaseOrdersSummary
|
|
||||||
.reduce((sum, po) => sum + (po.totalAmount || 0), 0)
|
|
||||||
.toFixed(0)}
|
|
||||||
)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{orchestrationSummary.purchaseOrdersCreated > 0 && orchestrationSummary.productionBatchesCreated > 0 && ' • '}
|
|
||||||
{orchestrationSummary.productionBatchesCreated > 0 && (
|
|
||||||
<span>
|
|
||||||
<span className="font-semibold">{orchestrationSummary.productionBatchesCreated}</span>{' '}
|
|
||||||
{orchestrationSummary.productionBatchesCreated === 1 ? 'production batch' : 'production batches'}
|
|
||||||
{orchestrationSummary.productionBatchesSummary && orchestrationSummary.productionBatchesSummary.length > 0 && (
|
|
||||||
<span>
|
|
||||||
{' '}(
|
|
||||||
{orchestrationSummary.productionBatchesSummary[0].readyByTime
|
|
||||||
? formatTime(orchestrationSummary.productionBatchesSummary[0].readyByTime, 'HH:mm')
|
|
||||||
: 'TBD'}
|
|
||||||
{orchestrationSummary.productionBatchesSummary.length > 1 &&
|
|
||||||
orchestrationSummary.productionBatchesSummary[orchestrationSummary.productionBatchesSummary.length - 1]
|
|
||||||
.readyByTime &&
|
|
||||||
` - ${formatTime(
|
|
||||||
orchestrationSummary.productionBatchesSummary[orchestrationSummary.productionBatchesSummary.length - 1]
|
|
||||||
.readyByTime,
|
|
||||||
'HH:mm'
|
|
||||||
)}`}
|
|
||||||
)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{orchestrationSummary.purchaseOrdersCreated === 0 && orchestrationSummary.productionBatchesCreated === 0 && (
|
|
||||||
<span style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
{t('reasoning:jtbd.orchestration_summary.no_actions', 'No actions created')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* AI Reasoning Section */}
|
|
||||||
{orchestrationSummary.reasoning && orchestrationSummary.reasoning.reasoning_i18n && (
|
|
||||||
<div className="mt-4 space-y-3">
|
|
||||||
{/* Reasoning Text Block */}
|
|
||||||
<div
|
|
||||||
className="rounded-lg p-4 border-l-4"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--bg-secondary)',
|
|
||||||
borderColor: 'var(--color-info-600)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-2 mb-2">
|
|
||||||
<Bot className="w-5 h-5 mt-0.5" style={{ color: 'var(--color-info-600)' }} />
|
|
||||||
<h4 className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
{t('alerts:orchestration.reasoning_title', '🤖 Razonamiento del Orquestador Diario')}
|
|
||||||
</h4>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm leading-relaxed" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
{t(
|
|
||||||
orchestrationSummary.reasoning.reasoning_i18n.key,
|
|
||||||
orchestrationSummary.reasoning.reasoning_i18n.params || {}
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Business Impact Metrics */}
|
|
||||||
{(orchestrationSummary.reasoning.business_impact?.financial_impact_eur ||
|
|
||||||
orchestrationSummary.reasoning.business_impact?.affected_orders) && (
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{orchestrationSummary.reasoning.business_impact.financial_impact_eur > 0 && (
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-2 px-3 py-2 rounded-md"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--color-success-100)',
|
|
||||||
border: '1px solid var(--color-success-300)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TrendingUp className="w-4 h-4" style={{ color: 'var(--color-success-600)' }} />
|
|
||||||
<span className="text-sm font-semibold" style={{ color: 'var(--color-success-700)' }}>
|
|
||||||
€{orchestrationSummary.reasoning.business_impact.financial_impact_eur.toFixed(0)}{' '}
|
|
||||||
{t('dashboard:intelligent_system.estimated_savings', 'impacto financiero')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{orchestrationSummary.reasoning.business_impact.affected_orders > 0 && (
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-2 px-3 py-2 rounded-md"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--color-info-100)',
|
|
||||||
border: '1px solid var(--color-info-300)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Package className="w-4 h-4" style={{ color: 'var(--color-info-600)' }} />
|
|
||||||
<span className="text-sm font-semibold" style={{ color: 'var(--color-info-700)' }}>
|
|
||||||
{orchestrationSummary.reasoning.business_impact.affected_orders}{' '}
|
|
||||||
{t('common:orders', 'pedidos')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Urgency Context */}
|
|
||||||
{orchestrationSummary.reasoning.urgency_context?.time_until_consequence_hours > 0 && (
|
|
||||||
<div
|
|
||||||
className="rounded-lg p-3 flex items-center gap-2"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--color-warning-50)',
|
|
||||||
borderLeft: '4px solid var(--color-warning-600)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Clock className="w-4 h-4" style={{ color: 'var(--color-warning-600)' }} />
|
|
||||||
<span className="text-sm font-semibold" style={{ color: 'var(--color-warning-800)' }}>
|
|
||||||
{Math.round(orchestrationSummary.reasoning.urgency_context.time_until_consequence_hours)}h{' '}
|
|
||||||
{t('common:remaining', 'restantes')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-sm text-center py-4" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
{t('dashboard:orchestration.no_runs_message', 'No orchestration has been run yet.')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,286 @@
|
|||||||
|
/**
|
||||||
|
* PendingDeliveriesBlock - Block 3: "Entregas Pendientes"
|
||||||
|
*
|
||||||
|
* Displays today's delivery status:
|
||||||
|
* - Overdue deliveries with alert styling
|
||||||
|
* - Pending deliveries expected today
|
||||||
|
* - Actions: Call Supplier, Mark Received
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
|
Package,
|
||||||
|
Phone,
|
||||||
|
Truck,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface PendingDeliveriesBlockProps {
|
||||||
|
overdueDeliveries?: any[];
|
||||||
|
pendingDeliveries?: any[];
|
||||||
|
onCallSupplier?: (delivery: any) => void;
|
||||||
|
onMarkReceived?: (poId: string) => void;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PendingDeliveriesBlock({
|
||||||
|
overdueDeliveries = [],
|
||||||
|
pendingDeliveries = [],
|
||||||
|
onCallSupplier,
|
||||||
|
onMarkReceived,
|
||||||
|
loading,
|
||||||
|
}: PendingDeliveriesBlockProps) {
|
||||||
|
const { t } = useTranslation(['dashboard', 'common']);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl shadow-lg p-6 border border-[var(--border-primary)] bg-[var(--bg-primary)] animate-pulse">
|
||||||
|
<div className="flex items-center gap-4 mb-4">
|
||||||
|
<div className="w-12 h-12 bg-[var(--bg-secondary)] rounded-full"></div>
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div className="h-5 bg-[var(--bg-secondary)] rounded w-1/3"></div>
|
||||||
|
<div className="h-4 bg-[var(--bg-secondary)] rounded w-1/4"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="h-16 bg-[var(--bg-secondary)] rounded"></div>
|
||||||
|
<div className="h-16 bg-[var(--bg-secondary)] rounded"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasOverdue = overdueDeliveries.length > 0;
|
||||||
|
const hasPending = pendingDeliveries.length > 0;
|
||||||
|
const hasAnyDeliveries = hasOverdue || hasPending;
|
||||||
|
const totalCount = overdueDeliveries.length + pendingDeliveries.length;
|
||||||
|
|
||||||
|
// Determine header status
|
||||||
|
const status = hasOverdue ? 'error' : hasPending ? 'warning' : 'success';
|
||||||
|
|
||||||
|
const statusStyles = {
|
||||||
|
success: {
|
||||||
|
iconBg: 'bg-[var(--color-success-100)]',
|
||||||
|
iconColor: 'text-[var(--color-success-600)]',
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
iconBg: 'bg-[var(--color-warning-100)]',
|
||||||
|
iconColor: 'text-[var(--color-warning-600)]',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
iconBg: 'bg-[var(--color-error-100)]',
|
||||||
|
iconColor: 'text-[var(--color-error-600)]',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = statusStyles[status];
|
||||||
|
|
||||||
|
// Format hours display
|
||||||
|
const formatHours = (hours: number) => {
|
||||||
|
if (hours < 1) return t('common:time.less_than_hour', '< 1h');
|
||||||
|
if (hours === 1) return t('common:time.one_hour', '1h');
|
||||||
|
return `${hours}h`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl shadow-lg border border-[var(--border-primary)] bg-[var(--bg-primary)] overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-6 pb-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{/* Icon */}
|
||||||
|
<div className={`w-12 h-12 rounded-full flex items-center justify-center flex-shrink-0 ${styles.iconBg}`}>
|
||||||
|
{hasOverdue ? (
|
||||||
|
<AlertTriangle className={`w-6 h-6 ${styles.iconColor}`} />
|
||||||
|
) : hasAnyDeliveries ? (
|
||||||
|
<Truck className={`w-6 h-6 ${styles.iconColor}`} />
|
||||||
|
) : (
|
||||||
|
<CheckCircle2 className={`w-6 h-6 ${styles.iconColor}`} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title & Count */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<h2 className="text-xl font-bold text-[var(--text-primary)]">
|
||||||
|
{t('dashboard:new_dashboard.pending_deliveries.title')}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">
|
||||||
|
{hasAnyDeliveries
|
||||||
|
? t('dashboard:new_dashboard.pending_deliveries.count', { count: totalCount })
|
||||||
|
: t('dashboard:new_dashboard.pending_deliveries.no_deliveries')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Count Badges */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{hasOverdue && (
|
||||||
|
<div className="px-3 py-1 rounded-full bg-[var(--color-error-100)] text-[var(--color-error-700)] font-semibold text-sm">
|
||||||
|
{overdueDeliveries.length} {t('dashboard:new_dashboard.pending_deliveries.overdue_badge')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasPending && (
|
||||||
|
<div className="px-3 py-1 rounded-full bg-[var(--color-warning-100)] text-[var(--color-warning-700)] font-semibold text-sm">
|
||||||
|
{pendingDeliveries.length}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{hasAnyDeliveries ? (
|
||||||
|
<div className="border-t border-[var(--border-primary)]">
|
||||||
|
{/* Overdue Section */}
|
||||||
|
{hasOverdue && (
|
||||||
|
<div className="bg-[var(--color-error-50)]">
|
||||||
|
<div className="px-6 py-3 border-b border-[var(--color-error-100)]">
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--color-error-700)] flex items-center gap-2">
|
||||||
|
<AlertTriangle className="w-4 h-4" />
|
||||||
|
{t('dashboard:new_dashboard.pending_deliveries.overdue_section')}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{overdueDeliveries.map((delivery, index) => (
|
||||||
|
<div
|
||||||
|
key={delivery.po_id || index}
|
||||||
|
className={`p-4 ${
|
||||||
|
index < overdueDeliveries.length - 1 ? 'border-b border-[var(--color-error-100)]' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
{/* Delivery Info */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Package className="w-4 h-4 text-[var(--color-error-600)]" />
|
||||||
|
<span className="font-semibold text-[var(--text-primary)]">
|
||||||
|
{delivery.supplier_name || 'Unknown Supplier'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-[var(--text-secondary)] mb-1">
|
||||||
|
{t('dashboard:new_dashboard.pending_deliveries.po_ref', {
|
||||||
|
number: delivery.po_number || delivery.po_id?.slice(0, 8),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Overdue Badge */}
|
||||||
|
<div className="inline-flex items-center gap-1 px-2 py-1 rounded bg-[var(--color-error-100)] text-[var(--color-error-700)] text-xs font-medium">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
{t('dashboard:new_dashboard.pending_deliveries.overdue_by', {
|
||||||
|
hours: formatHours(delivery.hoursOverdue || 0),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
{delivery.supplier_phone && onCallSupplier && (
|
||||||
|
<button
|
||||||
|
onClick={() => onCallSupplier(delivery)}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-[var(--border-primary)] text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] transition-colors"
|
||||||
|
>
|
||||||
|
<Phone className="w-4 h-4" />
|
||||||
|
<span className="text-sm font-medium hidden sm:inline">
|
||||||
|
{t('dashboard:new_dashboard.pending_deliveries.call_supplier')}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{onMarkReceived && (
|
||||||
|
<button
|
||||||
|
onClick={() => onMarkReceived(delivery.po_id)}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-[var(--color-success-600)] text-white hover:bg-[var(--color-success-700)] transition-colors"
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="w-4 h-4" />
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{t('dashboard:new_dashboard.pending_deliveries.mark_received')}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pending Today Section */}
|
||||||
|
{hasPending && (
|
||||||
|
<div>
|
||||||
|
<div className="px-6 py-3 border-b border-[var(--border-primary)] bg-[var(--bg-secondary)]">
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--text-secondary)] flex items-center gap-2">
|
||||||
|
<Truck className="w-4 h-4" />
|
||||||
|
{t('dashboard:new_dashboard.pending_deliveries.today_section')}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pendingDeliveries.map((delivery, index) => (
|
||||||
|
<div
|
||||||
|
key={delivery.po_id || index}
|
||||||
|
className={`p-4 ${
|
||||||
|
index < pendingDeliveries.length - 1 ? 'border-b border-[var(--border-primary)]' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
{/* Delivery Info */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Package className="w-4 h-4 text-[var(--text-tertiary)]" />
|
||||||
|
<span className="font-semibold text-[var(--text-primary)]">
|
||||||
|
{delivery.supplier_name || 'Unknown Supplier'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-[var(--text-secondary)] mb-1">
|
||||||
|
{t('dashboard:new_dashboard.pending_deliveries.po_ref', {
|
||||||
|
number: delivery.po_number || delivery.po_id?.slice(0, 8),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Arriving Badge */}
|
||||||
|
<div className="inline-flex items-center gap-1 px-2 py-1 rounded bg-[var(--color-info-50)] text-[var(--color-info-700)] text-xs font-medium">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
{t('dashboard:new_dashboard.pending_deliveries.arriving_in', {
|
||||||
|
hours: formatHours(delivery.hoursUntil || 0),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
{onMarkReceived && (
|
||||||
|
<button
|
||||||
|
onClick={() => onMarkReceived(delivery.po_id)}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-[var(--color-success-600)] text-white hover:bg-[var(--color-success-700)] transition-colors"
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="w-4 h-4" />
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{t('dashboard:new_dashboard.pending_deliveries.mark_received')}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Empty State */
|
||||||
|
<div className="px-6 pb-6">
|
||||||
|
<div className="flex items-center gap-3 p-4 rounded-lg bg-[var(--color-success-50)] border border-[var(--color-success-100)]">
|
||||||
|
<CheckCircle2 className="w-6 h-6 text-[var(--color-success-600)]" />
|
||||||
|
<p className="text-sm text-[var(--color-success-700)]">
|
||||||
|
{t('dashboard:new_dashboard.pending_deliveries.all_clear')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PendingDeliveriesBlock;
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
/**
|
||||||
|
* PendingPurchasesBlock - Block 2: "Compras Pendientes"
|
||||||
|
*
|
||||||
|
* Displays pending purchase orders awaiting approval:
|
||||||
|
* - PO number, supplier, amount
|
||||||
|
* - AI reasoning for why the PO was created
|
||||||
|
* - Inline actions: Approve, Reject, View Details
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
Brain,
|
||||||
|
Check,
|
||||||
|
CheckCircle2,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
Eye,
|
||||||
|
ShoppingCart,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface PendingPurchasesBlockProps {
|
||||||
|
pendingPOs: any[];
|
||||||
|
onApprove?: (poId: string) => Promise<void>;
|
||||||
|
onReject?: (poId: string, reason: string) => Promise<void>;
|
||||||
|
onViewDetails?: (poId: string) => void;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PendingPurchasesBlock({
|
||||||
|
pendingPOs = [],
|
||||||
|
onApprove,
|
||||||
|
onReject,
|
||||||
|
onViewDetails,
|
||||||
|
loading,
|
||||||
|
}: PendingPurchasesBlockProps) {
|
||||||
|
const { t } = useTranslation(['dashboard', 'common']);
|
||||||
|
const [expandedReasoningId, setExpandedReasoningId] = useState<string | null>(null);
|
||||||
|
const [processingId, setProcessingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl shadow-lg p-6 border border-[var(--border-primary)] bg-[var(--bg-primary)] animate-pulse">
|
||||||
|
<div className="flex items-center gap-4 mb-4">
|
||||||
|
<div className="w-12 h-12 bg-[var(--bg-secondary)] rounded-full"></div>
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div className="h-5 bg-[var(--bg-secondary)] rounded w-1/3"></div>
|
||||||
|
<div className="h-4 bg-[var(--bg-secondary)] rounded w-1/4"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="h-20 bg-[var(--bg-secondary)] rounded"></div>
|
||||||
|
<div className="h-20 bg-[var(--bg-secondary)] rounded"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPendingPOs = pendingPOs.length > 0;
|
||||||
|
|
||||||
|
// Handle approve action
|
||||||
|
const handleApprove = async (poId: string) => {
|
||||||
|
if (!onApprove || processingId) return;
|
||||||
|
setProcessingId(poId);
|
||||||
|
try {
|
||||||
|
await onApprove(poId);
|
||||||
|
} finally {
|
||||||
|
setProcessingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle reject action
|
||||||
|
const handleReject = async (poId: string) => {
|
||||||
|
if (!onReject || processingId) return;
|
||||||
|
setProcessingId(poId);
|
||||||
|
try {
|
||||||
|
await onReject(poId, 'Rejected from dashboard');
|
||||||
|
} finally {
|
||||||
|
setProcessingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Toggle reasoning expansion
|
||||||
|
const toggleReasoning = (poId: string) => {
|
||||||
|
setExpandedReasoningId(expandedReasoningId === poId ? null : poId);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format AI reasoning from reasoning_data
|
||||||
|
const formatReasoning = (po: any): string | null => {
|
||||||
|
const reasoningData = po.reasoning_data || po.ai_reasoning;
|
||||||
|
|
||||||
|
// If no structured reasoning data, try the summary field
|
||||||
|
if (!reasoningData) {
|
||||||
|
return po.ai_reasoning_summary || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof reasoningData === 'string') return reasoningData;
|
||||||
|
|
||||||
|
// Handle structured reasoning data
|
||||||
|
if (reasoningData.type === 'low_stock_forecast' || reasoningData.type === 'low_stock_detection') {
|
||||||
|
const params = reasoningData.parameters || {};
|
||||||
|
const productNames = params.product_names || params.critical_products || [];
|
||||||
|
const productDetails = params.product_details || [];
|
||||||
|
const criticalCount = params.critical_product_count || productNames.length;
|
||||||
|
const minDepletionDays = Math.ceil(params.min_depletion_days || 0);
|
||||||
|
const affectedBatchesCount = params.affected_batches_count || 0;
|
||||||
|
const potentialLoss = params.potential_loss_eur || 0;
|
||||||
|
|
||||||
|
// If we have detailed data (multiple products), show comprehensive message
|
||||||
|
if (criticalCount > 1 && productNames.length > 0) {
|
||||||
|
const productsStr = productNames.slice(0, 3).join(', ') + (productNames.length > 3 ? '...' : '');
|
||||||
|
|
||||||
|
return t('dashboard:new_dashboard.pending_purchases.reasoning.low_stock_detailed', {
|
||||||
|
count: criticalCount,
|
||||||
|
products: productsStr,
|
||||||
|
days: minDepletionDays,
|
||||||
|
batches: affectedBatchesCount,
|
||||||
|
loss: potentialLoss.toFixed(2),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple version for single product
|
||||||
|
const firstProduct = productDetails[0] || {};
|
||||||
|
const ingredient = firstProduct.product_name || productNames[0] || 'ingredient';
|
||||||
|
const days = Math.ceil(firstProduct.days_until_depletion || minDepletionDays);
|
||||||
|
|
||||||
|
return t('dashboard:new_dashboard.pending_purchases.reasoning.low_stock', {
|
||||||
|
ingredient,
|
||||||
|
days,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reasoningData.type === 'demand_forecast') {
|
||||||
|
return t('dashboard:new_dashboard.pending_purchases.reasoning.demand_forecast', {
|
||||||
|
product: reasoningData.parameters?.product_name || 'product',
|
||||||
|
increase: reasoningData.parameters?.demand_increase_percent || 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reasoningData.summary) return reasoningData.summary;
|
||||||
|
|
||||||
|
// Fallback to ai_reasoning_summary if structured data doesn't have a matching type
|
||||||
|
return po.ai_reasoning_summary || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl shadow-lg border border-[var(--border-primary)] bg-[var(--bg-primary)] overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-6 pb-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{/* Icon */}
|
||||||
|
<div
|
||||||
|
className={`w-12 h-12 rounded-full flex items-center justify-center flex-shrink-0 ${
|
||||||
|
hasPendingPOs
|
||||||
|
? 'bg-[var(--color-warning-100)]'
|
||||||
|
: 'bg-[var(--color-success-100)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{hasPendingPOs ? (
|
||||||
|
<ShoppingCart className="w-6 h-6 text-[var(--color-warning-600)]" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle2 className="w-6 h-6 text-[var(--color-success-600)]" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title & Count */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<h2 className="text-xl font-bold text-[var(--text-primary)]">
|
||||||
|
{t('dashboard:new_dashboard.pending_purchases.title')}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">
|
||||||
|
{hasPendingPOs
|
||||||
|
? t('dashboard:new_dashboard.pending_purchases.count', {
|
||||||
|
count: pendingPOs.length,
|
||||||
|
})
|
||||||
|
: t('dashboard:new_dashboard.pending_purchases.no_pending')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Count Badge */}
|
||||||
|
{hasPendingPOs && (
|
||||||
|
<div className="px-3 py-1 rounded-full bg-[var(--color-warning-100)] text-[var(--color-warning-700)] font-semibold text-sm">
|
||||||
|
{pendingPOs.length}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PO List */}
|
||||||
|
{hasPendingPOs ? (
|
||||||
|
<div className="border-t border-[var(--border-primary)]">
|
||||||
|
{pendingPOs.map((po, index) => {
|
||||||
|
const poId = po.id || po.po_id;
|
||||||
|
const isProcessing = processingId === poId;
|
||||||
|
const isExpanded = expandedReasoningId === poId;
|
||||||
|
const reasoning = formatReasoning(po);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={poId || index}
|
||||||
|
className={`p-4 ${
|
||||||
|
index < pendingPOs.length - 1 ? 'border-b border-[var(--border-primary)]' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* PO Main Info */}
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
{/* PO Details */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="font-semibold text-[var(--text-primary)]">
|
||||||
|
{t('dashboard:new_dashboard.pending_purchases.po_number', {
|
||||||
|
number: po.po_number || po.id?.slice(0, 8),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
{reasoning && (
|
||||||
|
<button
|
||||||
|
onClick={() => toggleReasoning(poId)}
|
||||||
|
className="flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-[var(--color-primary-50)] text-[var(--color-primary-700)] hover:bg-[var(--color-primary-100)] transition-colors"
|
||||||
|
>
|
||||||
|
<Brain className="w-3 h-3" />
|
||||||
|
AI
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronUp className="w-3 h-3" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-3 h-3" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-[var(--text-secondary)] mb-1">
|
||||||
|
{t('dashboard:new_dashboard.pending_purchases.supplier', {
|
||||||
|
name: po.supplier_name || po.supplier?.name || 'Unknown',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="text-lg font-bold text-[var(--text-primary)]">
|
||||||
|
€{(po.total_amount || po.total || 0).toLocaleString(undefined, {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
{/* View Details */}
|
||||||
|
{onViewDetails && (
|
||||||
|
<button
|
||||||
|
onClick={() => onViewDetails(poId)}
|
||||||
|
disabled={isProcessing}
|
||||||
|
className="p-2 rounded-lg border border-[var(--border-primary)] text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)] transition-colors disabled:opacity-50"
|
||||||
|
title={t('dashboard:new_dashboard.pending_purchases.view_details')}
|
||||||
|
>
|
||||||
|
<Eye className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reject */}
|
||||||
|
{onReject && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleReject(poId)}
|
||||||
|
disabled={isProcessing}
|
||||||
|
className="p-2 rounded-lg border border-[var(--color-error-200)] text-[var(--color-error-600)] hover:bg-[var(--color-error-50)] transition-colors disabled:opacity-50"
|
||||||
|
title={t('dashboard:new_dashboard.pending_purchases.reject')}
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Approve */}
|
||||||
|
{onApprove && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleApprove(poId)}
|
||||||
|
disabled={isProcessing}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--color-success-600)] text-white hover:bg-[var(--color-success-700)] transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Check className="w-5 h-5" />
|
||||||
|
<span className="font-medium">
|
||||||
|
{t('dashboard:new_dashboard.pending_purchases.approve')}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AI Reasoning (Expanded) */}
|
||||||
|
{isExpanded && reasoning && (
|
||||||
|
<div className="mt-3 p-3 rounded-lg bg-[var(--color-primary-50)] border border-[var(--color-primary-100)]">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Brain className="w-4 h-4 text-[var(--color-primary-600)] mt-0.5 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-[var(--color-primary-700)] mb-1">
|
||||||
|
{t('dashboard:new_dashboard.pending_purchases.ai_reasoning')}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-[var(--color-primary-600)]">{reasoning}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Empty State */
|
||||||
|
<div className="px-6 pb-6">
|
||||||
|
<div className="flex items-center gap-3 p-4 rounded-lg bg-[var(--color-success-50)] border border-[var(--color-success-100)]">
|
||||||
|
<CheckCircle2 className="w-6 h-6 text-[var(--color-success-600)]" />
|
||||||
|
<p className="text-sm text-[var(--color-success-700)]">
|
||||||
|
{t('dashboard:new_dashboard.pending_purchases.all_clear')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PendingPurchasesBlock;
|
||||||
@@ -0,0 +1,417 @@
|
|||||||
|
/**
|
||||||
|
* ProductionStatusBlock - Block 4: "Estado de Produccion"
|
||||||
|
*
|
||||||
|
* Displays today's production overview:
|
||||||
|
* - Late to start batches (should have started but haven't)
|
||||||
|
* - Currently running batches (IN_PROGRESS)
|
||||||
|
* - Pending batches for today
|
||||||
|
* - AI reasoning for batch scheduling
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
Brain,
|
||||||
|
CheckCircle2,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
Clock,
|
||||||
|
Factory,
|
||||||
|
Play,
|
||||||
|
Timer,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface ProductionStatusBlockProps {
|
||||||
|
lateToStartBatches?: any[];
|
||||||
|
runningBatches?: any[];
|
||||||
|
pendingBatches?: any[];
|
||||||
|
onStartBatch?: (batchId: string) => Promise<void>;
|
||||||
|
onViewBatch?: (batchId: string) => void;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProductionStatusBlock({
|
||||||
|
lateToStartBatches = [],
|
||||||
|
runningBatches = [],
|
||||||
|
pendingBatches = [],
|
||||||
|
onStartBatch,
|
||||||
|
onViewBatch,
|
||||||
|
loading,
|
||||||
|
}: ProductionStatusBlockProps) {
|
||||||
|
const { t } = useTranslation(['dashboard', 'common', 'production']);
|
||||||
|
const [expandedReasoningId, setExpandedReasoningId] = useState<string | null>(null);
|
||||||
|
const [processingId, setProcessingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl shadow-lg p-6 border border-[var(--border-primary)] bg-[var(--bg-primary)] animate-pulse">
|
||||||
|
<div className="flex items-center gap-4 mb-4">
|
||||||
|
<div className="w-12 h-12 bg-[var(--bg-secondary)] rounded-full"></div>
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div className="h-5 bg-[var(--bg-secondary)] rounded w-1/3"></div>
|
||||||
|
<div className="h-4 bg-[var(--bg-secondary)] rounded w-1/4"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="h-20 bg-[var(--bg-secondary)] rounded"></div>
|
||||||
|
<div className="h-20 bg-[var(--bg-secondary)] rounded"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasLate = lateToStartBatches.length > 0;
|
||||||
|
const hasRunning = runningBatches.length > 0;
|
||||||
|
const hasPending = pendingBatches.length > 0;
|
||||||
|
const hasAnyProduction = hasLate || hasRunning || hasPending;
|
||||||
|
const totalCount = lateToStartBatches.length + runningBatches.length + pendingBatches.length;
|
||||||
|
|
||||||
|
// Determine header status
|
||||||
|
const status = hasLate ? 'error' : hasRunning ? 'info' : hasPending ? 'warning' : 'success';
|
||||||
|
|
||||||
|
const statusStyles = {
|
||||||
|
success: {
|
||||||
|
iconBg: 'bg-[var(--color-success-100)]',
|
||||||
|
iconColor: 'text-[var(--color-success-600)]',
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
iconBg: 'bg-[var(--color-warning-100)]',
|
||||||
|
iconColor: 'text-[var(--color-warning-600)]',
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
iconBg: 'bg-[var(--color-info-100)]',
|
||||||
|
iconColor: 'text-[var(--color-info-600)]',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
iconBg: 'bg-[var(--color-error-100)]',
|
||||||
|
iconColor: 'text-[var(--color-error-600)]',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = statusStyles[status];
|
||||||
|
|
||||||
|
// Handle start batch
|
||||||
|
const handleStartBatch = async (batchId: string) => {
|
||||||
|
if (!onStartBatch || processingId) return;
|
||||||
|
setProcessingId(batchId);
|
||||||
|
try {
|
||||||
|
await onStartBatch(batchId);
|
||||||
|
} finally {
|
||||||
|
setProcessingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Toggle reasoning expansion
|
||||||
|
const toggleReasoning = (batchId: string) => {
|
||||||
|
setExpandedReasoningId(expandedReasoningId === batchId ? null : batchId);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format AI reasoning
|
||||||
|
const formatReasoning = (batch: any): string | null => {
|
||||||
|
const reasoningData = batch.reasoning_data;
|
||||||
|
if (!reasoningData) return null;
|
||||||
|
|
||||||
|
if (typeof reasoningData === 'string') return reasoningData;
|
||||||
|
|
||||||
|
if (reasoningData.type === 'forecast_demand') {
|
||||||
|
return t('dashboard:new_dashboard.production_status.reasoning.forecast_demand', {
|
||||||
|
product: reasoningData.parameters?.product_name || batch.product_name,
|
||||||
|
demand: reasoningData.parameters?.predicted_demand || batch.planned_quantity,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reasoningData.type === 'customer_order') {
|
||||||
|
return t('dashboard:new_dashboard.production_status.reasoning.customer_order', {
|
||||||
|
customer: reasoningData.parameters?.customer_name || 'customer',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reasoningData.summary) return reasoningData.summary;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format time
|
||||||
|
const formatTime = (isoString: string | null | undefined) => {
|
||||||
|
if (!isoString) return '--:--';
|
||||||
|
const date = new Date(isoString);
|
||||||
|
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate progress percentage for running batches
|
||||||
|
const calculateProgress = (batch: any): number => {
|
||||||
|
if (!batch.actual_start_time || !batch.planned_end_time) return 0;
|
||||||
|
|
||||||
|
const start = new Date(batch.actual_start_time).getTime();
|
||||||
|
const end = new Date(batch.planned_end_time).getTime();
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (now >= end) return 100;
|
||||||
|
if (now <= start) return 0;
|
||||||
|
|
||||||
|
return Math.round(((now - start) / (end - start)) * 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render a batch item
|
||||||
|
const renderBatchItem = (batch: any, type: 'late' | 'running' | 'pending', index: number, total: number) => {
|
||||||
|
const batchId = batch.id || batch.batch_id;
|
||||||
|
const isProcessing = processingId === batchId;
|
||||||
|
const isExpanded = expandedReasoningId === batchId;
|
||||||
|
const reasoning = formatReasoning(batch);
|
||||||
|
const progress = type === 'running' ? calculateProgress(batch) : 0;
|
||||||
|
|
||||||
|
const typeStyles = {
|
||||||
|
late: {
|
||||||
|
timeBg: 'bg-[var(--color-error-100)]',
|
||||||
|
timeColor: 'text-[var(--color-error-700)]',
|
||||||
|
icon: <AlertTriangle className="w-4 h-4 text-[var(--color-error-600)]" />,
|
||||||
|
},
|
||||||
|
running: {
|
||||||
|
timeBg: 'bg-[var(--color-info-100)]',
|
||||||
|
timeColor: 'text-[var(--color-info-700)]',
|
||||||
|
icon: <Timer className="w-4 h-4 text-[var(--color-info-600)]" />,
|
||||||
|
},
|
||||||
|
pending: {
|
||||||
|
timeBg: 'bg-[var(--color-warning-100)]',
|
||||||
|
timeColor: 'text-[var(--color-warning-700)]',
|
||||||
|
icon: <Clock className="w-4 h-4 text-[var(--text-tertiary)]" />,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const batchStyles = typeStyles[type];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={batchId || index}
|
||||||
|
className={`p-4 ${index < total - 1 ? 'border-b border-[var(--border-primary)]' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
{/* Batch Info */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
{batchStyles.icon}
|
||||||
|
<span className="font-semibold text-[var(--text-primary)]">
|
||||||
|
{batch.product_name || 'Unknown Product'}
|
||||||
|
</span>
|
||||||
|
{reasoning && (
|
||||||
|
<button
|
||||||
|
onClick={() => toggleReasoning(batchId)}
|
||||||
|
className="flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-[var(--color-primary-50)] text-[var(--color-primary-700)] hover:bg-[var(--color-primary-100)] transition-colors"
|
||||||
|
>
|
||||||
|
<Brain className="w-3 h-3" />
|
||||||
|
AI
|
||||||
|
{isExpanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-[var(--text-secondary)] mb-2">
|
||||||
|
{t('dashboard:new_dashboard.production_status.batch_info', {
|
||||||
|
number: batch.batch_number || batchId?.slice(0, 8),
|
||||||
|
quantity: batch.planned_quantity || 0,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Time/Status Badge */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{type === 'late' && (
|
||||||
|
<div className={`inline-flex items-center gap-1 px-2 py-1 rounded ${batchStyles.timeBg} ${batchStyles.timeColor} text-xs font-medium`}>
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
{t('dashboard:new_dashboard.production_status.should_have_started', {
|
||||||
|
time: formatTime(batch.planned_start_time),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{type === 'running' && (
|
||||||
|
<>
|
||||||
|
<div className={`inline-flex items-center gap-1 px-2 py-1 rounded ${batchStyles.timeBg} ${batchStyles.timeColor} text-xs font-medium`}>
|
||||||
|
<Timer className="w-3 h-3" />
|
||||||
|
{t('dashboard:new_dashboard.production_status.started_at', {
|
||||||
|
time: formatTime(batch.actual_start_time),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{/* Progress Bar */}
|
||||||
|
<div className="flex-1 max-w-[120px]">
|
||||||
|
<div className="h-2 bg-[var(--bg-tertiary)] rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-[var(--color-info-500)] transition-all"
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-[var(--text-tertiary)]">{progress}%</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{type === 'pending' && batch.planned_start_time && (
|
||||||
|
<div className={`inline-flex items-center gap-1 px-2 py-1 rounded ${batchStyles.timeBg} ${batchStyles.timeColor} text-xs font-medium`}>
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
{t('dashboard:new_dashboard.production_status.starts_at', {
|
||||||
|
time: formatTime(batch.planned_start_time),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
{(type === 'late' || type === 'pending') && onStartBatch && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleStartBatch(batchId)}
|
||||||
|
disabled={isProcessing}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-[var(--color-success-600)] text-white hover:bg-[var(--color-success-700)] transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Play className="w-4 h-4" />
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{t('dashboard:new_dashboard.production_status.start_batch')}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{type === 'running' && onViewBatch && (
|
||||||
|
<button
|
||||||
|
onClick={() => onViewBatch(batchId)}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-[var(--border-primary)] text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] transition-colors"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{t('dashboard:new_dashboard.production_status.view_details')}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AI Reasoning (Expanded) */}
|
||||||
|
{isExpanded && reasoning && (
|
||||||
|
<div className="mt-3 p-3 rounded-lg bg-[var(--color-primary-50)] border border-[var(--color-primary-100)]">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Brain className="w-4 h-4 text-[var(--color-primary-600)] mt-0.5 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-[var(--color-primary-700)] mb-1">
|
||||||
|
{t('dashboard:new_dashboard.production_status.ai_reasoning')}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-[var(--color-primary-600)]">{reasoning}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl shadow-lg border border-[var(--border-primary)] bg-[var(--bg-primary)] overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-6 pb-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{/* Icon */}
|
||||||
|
<div className={`w-12 h-12 rounded-full flex items-center justify-center flex-shrink-0 ${styles.iconBg}`}>
|
||||||
|
{hasLate ? (
|
||||||
|
<AlertTriangle className={`w-6 h-6 ${styles.iconColor}`} />
|
||||||
|
) : hasAnyProduction ? (
|
||||||
|
<Factory className={`w-6 h-6 ${styles.iconColor}`} />
|
||||||
|
) : (
|
||||||
|
<CheckCircle2 className={`w-6 h-6 ${styles.iconColor}`} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title & Count */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<h2 className="text-xl font-bold text-[var(--text-primary)]">
|
||||||
|
{t('dashboard:new_dashboard.production_status.title')}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">
|
||||||
|
{hasAnyProduction
|
||||||
|
? t('dashboard:new_dashboard.production_status.count', { count: totalCount })
|
||||||
|
: t('dashboard:new_dashboard.production_status.no_production')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Count Badges */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{hasLate && (
|
||||||
|
<div className="px-3 py-1 rounded-full bg-[var(--color-error-100)] text-[var(--color-error-700)] font-semibold text-sm">
|
||||||
|
{lateToStartBatches.length} {t('dashboard:new_dashboard.production_status.late_badge')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasRunning && (
|
||||||
|
<div className="px-3 py-1 rounded-full bg-[var(--color-info-100)] text-[var(--color-info-700)] font-semibold text-sm">
|
||||||
|
{runningBatches.length} {t('dashboard:new_dashboard.production_status.running_badge')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasPending && (
|
||||||
|
<div className="px-3 py-1 rounded-full bg-[var(--color-warning-100)] text-[var(--color-warning-700)] font-semibold text-sm">
|
||||||
|
{pendingBatches.length}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{hasAnyProduction ? (
|
||||||
|
<div className="border-t border-[var(--border-primary)]">
|
||||||
|
{/* Late to Start Section */}
|
||||||
|
{hasLate && (
|
||||||
|
<div className="bg-[var(--color-error-50)]">
|
||||||
|
<div className="px-6 py-3 border-b border-[var(--color-error-100)]">
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--color-error-700)] flex items-center gap-2">
|
||||||
|
<AlertTriangle className="w-4 h-4" />
|
||||||
|
{t('dashboard:new_dashboard.production_status.late_section')}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
{lateToStartBatches.map((batch, index) =>
|
||||||
|
renderBatchItem(batch, 'late', index, lateToStartBatches.length)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Running Section */}
|
||||||
|
{hasRunning && (
|
||||||
|
<div className="bg-[var(--color-info-50)]">
|
||||||
|
<div className="px-6 py-3 border-b border-[var(--color-info-100)]">
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--color-info-700)] flex items-center gap-2">
|
||||||
|
<Timer className="w-4 h-4" />
|
||||||
|
{t('dashboard:new_dashboard.production_status.running_section')}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
{runningBatches.map((batch, index) =>
|
||||||
|
renderBatchItem(batch, 'running', index, runningBatches.length)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pending Section */}
|
||||||
|
{hasPending && (
|
||||||
|
<div>
|
||||||
|
<div className="px-6 py-3 border-b border-[var(--border-primary)] bg-[var(--bg-secondary)]">
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--text-secondary)] flex items-center gap-2">
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
{t('dashboard:new_dashboard.production_status.pending_section')}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
{pendingBatches.map((batch, index) =>
|
||||||
|
renderBatchItem(batch, 'pending', index, pendingBatches.length)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Empty State */
|
||||||
|
<div className="px-6 pb-6">
|
||||||
|
<div className="flex items-center gap-3 p-4 rounded-lg bg-[var(--color-success-50)] border border-[var(--color-success-100)]">
|
||||||
|
<CheckCircle2 className="w-6 h-6 text-[var(--color-success-600)]" />
|
||||||
|
<p className="text-sm text-[var(--color-success-700)]">
|
||||||
|
{t('dashboard:new_dashboard.production_status.all_clear')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProductionStatusBlock;
|
||||||
265
frontend/src/components/dashboard/blocks/SystemStatusBlock.tsx
Normal file
265
frontend/src/components/dashboard/blocks/SystemStatusBlock.tsx
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
/**
|
||||||
|
* SystemStatusBlock - Block 1: "Estado del Sistema"
|
||||||
|
*
|
||||||
|
* Displays system status including:
|
||||||
|
* - Issues requiring user action
|
||||||
|
* - Issues prevented by AI
|
||||||
|
* - Last intelligent system run timestamp
|
||||||
|
* - AI handling rate and savings
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
AlertTriangle,
|
||||||
|
Bot,
|
||||||
|
CheckCircle2,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
Clock,
|
||||||
|
Sparkles,
|
||||||
|
TrendingUp,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import type { DashboardData, OrchestrationSummary } from '../../../api/hooks/useDashboardData';
|
||||||
|
|
||||||
|
interface SystemStatusBlockProps {
|
||||||
|
data: DashboardData | undefined;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SystemStatusBlock({ data, loading }: SystemStatusBlockProps) {
|
||||||
|
const { t } = useTranslation(['dashboard', 'common']);
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl shadow-lg p-6 border border-[var(--border-primary)] bg-[var(--bg-primary)] animate-pulse">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-16 h-16 bg-[var(--bg-secondary)] rounded-full"></div>
|
||||||
|
<div className="flex-1 space-y-3">
|
||||||
|
<div className="h-6 bg-[var(--bg-secondary)] rounded w-1/3"></div>
|
||||||
|
<div className="h-4 bg-[var(--bg-secondary)] rounded w-1/2"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const issuesRequiringAction = data?.issuesRequiringAction || 0;
|
||||||
|
const issuesPreventedByAI = data?.issuesPreventedByAI || 0;
|
||||||
|
const orchestrationSummary = data?.orchestrationSummary;
|
||||||
|
const preventedIssues = data?.preventedIssues || [];
|
||||||
|
|
||||||
|
// Determine status: green if no issues, yellow/red if issues exist
|
||||||
|
const hasIssues = issuesRequiringAction > 0;
|
||||||
|
const status = hasIssues ? 'warning' : 'success';
|
||||||
|
|
||||||
|
// Format last run time
|
||||||
|
const formatLastRun = (timestamp: string | null | undefined) => {
|
||||||
|
if (!timestamp) return t('dashboard:new_dashboard.system_status.never_run');
|
||||||
|
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - date.getTime();
|
||||||
|
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||||
|
const diffMinutes = Math.floor(diffMs / (1000 * 60));
|
||||||
|
|
||||||
|
if (diffMinutes < 1) return t('common:time.just_now', 'Just now');
|
||||||
|
if (diffMinutes < 60) return t('common:time.minutes_ago', '{{count}} min ago', { count: diffMinutes });
|
||||||
|
if (diffHours < 24) return t('common:time.hours_ago', '{{count}}h ago', { count: diffHours });
|
||||||
|
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Status styling
|
||||||
|
const statusStyles = {
|
||||||
|
success: {
|
||||||
|
bg: 'bg-[var(--color-success-50)]',
|
||||||
|
border: 'border-[var(--color-success-200)]',
|
||||||
|
iconBg: 'bg-[var(--color-success-100)]',
|
||||||
|
iconColor: 'text-[var(--color-success-600)]',
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
bg: 'bg-[var(--color-warning-50)]',
|
||||||
|
border: 'border-[var(--color-warning-200)]',
|
||||||
|
iconBg: 'bg-[var(--color-warning-100)]',
|
||||||
|
iconColor: 'text-[var(--color-warning-600)]',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = statusStyles[status];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`rounded-xl shadow-lg border ${styles.border} ${styles.bg} overflow-hidden`}>
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
{/* Status Icon */}
|
||||||
|
<div className={`w-16 h-16 rounded-full ${styles.iconBg} flex items-center justify-center flex-shrink-0`}>
|
||||||
|
{hasIssues ? (
|
||||||
|
<AlertTriangle className={`w-8 h-8 ${styles.iconColor}`} />
|
||||||
|
) : (
|
||||||
|
<CheckCircle2 className={`w-8 h-8 ${styles.iconColor}`} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{/* Title */}
|
||||||
|
<h2 className="text-xl font-bold text-[var(--text-primary)] mb-1">
|
||||||
|
{t('dashboard:new_dashboard.system_status.title')}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Status Message */}
|
||||||
|
<p className="text-[var(--text-secondary)] mb-4">
|
||||||
|
{hasIssues
|
||||||
|
? t('dashboard:new_dashboard.system_status.issues_requiring_action', {
|
||||||
|
count: issuesRequiringAction,
|
||||||
|
})
|
||||||
|
: t('dashboard:new_dashboard.system_status.all_clear')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Stats Row */}
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
{/* Issues Requiring Action */}
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-[var(--bg-primary)] border border-[var(--border-primary)]">
|
||||||
|
<AlertTriangle
|
||||||
|
className={`w-5 h-5 ${
|
||||||
|
issuesRequiringAction > 0 ? 'text-[var(--color-warning-500)]' : 'text-[var(--text-tertiary)]'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium text-[var(--text-primary)]">
|
||||||
|
{issuesRequiringAction}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-[var(--text-secondary)]">
|
||||||
|
{t('dashboard:new_dashboard.system_status.action_needed_label')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Issues Prevented by AI */}
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-[var(--bg-primary)] border border-[var(--border-primary)]">
|
||||||
|
<Bot className="w-5 h-5 text-[var(--color-primary)]" />
|
||||||
|
<span className="text-sm font-medium text-[var(--text-primary)]">
|
||||||
|
{issuesPreventedByAI}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-[var(--text-secondary)]">
|
||||||
|
{t('dashboard:new_dashboard.system_status.ai_prevented_label')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Last Run */}
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-[var(--bg-primary)] border border-[var(--border-primary)]">
|
||||||
|
<Clock className="w-5 h-5 text-[var(--text-tertiary)]" />
|
||||||
|
<span className="text-sm text-[var(--text-secondary)]">
|
||||||
|
{t('dashboard:new_dashboard.system_status.last_run_label')}:
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium text-[var(--text-primary)]">
|
||||||
|
{formatLastRun(orchestrationSummary?.runTimestamp)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expand Button (if there are prevented issues to show) */}
|
||||||
|
{issuesPreventedByAI > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
className="p-2 rounded-lg hover:bg-[var(--bg-secondary)] transition-colors"
|
||||||
|
aria-label={isExpanded ? 'Collapse' : 'Expand'}
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronUp className="w-5 h-5 text-[var(--text-tertiary)]" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-5 h-5 text-[var(--text-tertiary)]" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded AI Details Section */}
|
||||||
|
{isExpanded && issuesPreventedByAI > 0 && (
|
||||||
|
<div className="border-t border-[var(--border-primary)] bg-[var(--bg-primary)] p-6">
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--text-primary)] mb-4 flex items-center gap-2">
|
||||||
|
<Sparkles className="w-4 h-4 text-[var(--color-primary)]" />
|
||||||
|
{t('dashboard:new_dashboard.system_status.ai_prevented_details')}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* AI Stats */}
|
||||||
|
{orchestrationSummary && (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4">
|
||||||
|
{orchestrationSummary.aiHandlingRate !== undefined && (
|
||||||
|
<div className="p-3 rounded-lg bg-[var(--bg-secondary)]">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-[var(--text-secondary)] mb-1">
|
||||||
|
<Activity className="w-4 h-4" />
|
||||||
|
{t('dashboard:new_dashboard.system_status.ai_handling_rate')}
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-bold text-[var(--color-primary)]">
|
||||||
|
{Math.round(orchestrationSummary.aiHandlingRate)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{orchestrationSummary.estimatedSavingsEur !== undefined && orchestrationSummary.estimatedSavingsEur > 0 && (
|
||||||
|
<div className="p-3 rounded-lg bg-[var(--bg-secondary)]">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-[var(--text-secondary)] mb-1">
|
||||||
|
<TrendingUp className="w-4 h-4" />
|
||||||
|
{t('dashboard:new_dashboard.system_status.estimated_savings')}
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-bold text-[var(--color-success-600)]">
|
||||||
|
€{orchestrationSummary.estimatedSavingsEur.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="p-3 rounded-lg bg-[var(--bg-secondary)]">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-[var(--text-secondary)] mb-1">
|
||||||
|
<Bot className="w-4 h-4" />
|
||||||
|
{t('dashboard:new_dashboard.system_status.issues_prevented')}
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-bold text-[var(--color-primary)]">
|
||||||
|
{issuesPreventedByAI}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Prevented Issues List */}
|
||||||
|
{preventedIssues.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{preventedIssues.slice(0, 5).map((issue: any, index: number) => (
|
||||||
|
<div
|
||||||
|
key={issue.id || index}
|
||||||
|
className="flex items-center gap-3 p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-primary)]"
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="w-5 h-5 text-[var(--color-success-500)] flex-shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-[var(--text-primary)] truncate">
|
||||||
|
{issue.title || issue.message || t('dashboard:new_dashboard.system_status.issue_prevented')}
|
||||||
|
</p>
|
||||||
|
{issue.business_impact?.financial_impact_eur && (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">
|
||||||
|
{t('dashboard:new_dashboard.system_status.saved')}: €{issue.business_impact.financial_impact_eur.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{preventedIssues.length > 5 && (
|
||||||
|
<p className="text-sm text-[var(--text-secondary)] text-center py-2">
|
||||||
|
{t('dashboard:new_dashboard.system_status.and_more', {
|
||||||
|
count: preventedIssues.length - 5,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SystemStatusBlock;
|
||||||
10
frontend/src/components/dashboard/blocks/index.ts
Normal file
10
frontend/src/components/dashboard/blocks/index.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* Dashboard Blocks - Barrel Export
|
||||||
|
*
|
||||||
|
* Export all dashboard block components for the new Panel de Control design.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { SystemStatusBlock } from './SystemStatusBlock';
|
||||||
|
export { PendingPurchasesBlock } from './PendingPurchasesBlock';
|
||||||
|
export { PendingDeliveriesBlock } from './PendingDeliveriesBlock';
|
||||||
|
export { ProductionStatusBlock } from './ProductionStatusBlock';
|
||||||
@@ -3,14 +3,16 @@
|
|||||||
// ================================================================
|
// ================================================================
|
||||||
/**
|
/**
|
||||||
* Dashboard Components Export
|
* Dashboard Components Export
|
||||||
* Barrel export for all JTBD dashboard components
|
* Barrel export for all dashboard components
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Core Dashboard Components (JTBD-Aligned)
|
// New Dashboard Blocks (4 focused blocks for Panel de Control)
|
||||||
export { GlanceableHealthHero } from './GlanceableHealthHero';
|
export {
|
||||||
export { UnifiedActionQueueCard } from './UnifiedActionQueueCard';
|
SystemStatusBlock,
|
||||||
export { ExecutionProgressTracker } from './ExecutionProgressTracker';
|
PendingPurchasesBlock,
|
||||||
export { IntelligentSystemSummaryCard } from './IntelligentSystemSummaryCard';
|
PendingDeliveriesBlock,
|
||||||
|
ProductionStatusBlock,
|
||||||
|
} from './blocks';
|
||||||
|
|
||||||
// Setup Flow Components
|
// Setup Flow Components
|
||||||
export { SetupWizardBlocker } from './SetupWizardBlocker';
|
export { SetupWizardBlocker } from './SetupWizardBlocker';
|
||||||
|
|||||||
@@ -223,23 +223,26 @@ export const ModifyPurchaseOrderModal: React.FC<ModifyPurchaseOrderModalProps> =
|
|||||||
label: 'Unidad',
|
label: 'Unidad',
|
||||||
type: 'select',
|
type: 'select',
|
||||||
required: true,
|
required: true,
|
||||||
options: unitOptions
|
options: unitOptions,
|
||||||
|
disabled: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'unit_price',
|
name: 'unit_price',
|
||||||
label: 'Precio Unitario (€)',
|
label: 'Precio Unitario (€)',
|
||||||
type: 'currency',
|
type: 'currency',
|
||||||
required: true,
|
required: true,
|
||||||
placeholder: '0.00'
|
placeholder: '0.00',
|
||||||
|
disabled: true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
addButtonLabel: 'Agregar Producto',
|
addButtonLabel: 'Agregar Producto',
|
||||||
emptyStateText: 'No hay productos en esta orden',
|
emptyStateText: 'No hay productos en esta orden',
|
||||||
showSubtotals: true,
|
showSubtotals: true,
|
||||||
subtotalFields: { quantity: 'ordered_quantity', price: 'unit_price' },
|
subtotalFields: { quantity: 'ordered_quantity', price: 'unit_price' },
|
||||||
disabled: false
|
disabled: true,
|
||||||
|
disableRemove: true
|
||||||
},
|
},
|
||||||
helpText: 'Modifica las cantidades, unidades y precios según sea necesario'
|
helpText: 'Solo puedes modificar las cantidades. Los precios unitarios están fijados por el proveedor seleccionado.'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ const ListFieldRenderer: React.FC<ListFieldRendererProps> = ({ field, value, onC
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isDisabled = listConfig.disabled ?? false;
|
const isDisabled = listConfig.disabled ?? false;
|
||||||
|
const disableRemove = listConfig.disableRemove ?? false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -148,13 +149,15 @@ const ListFieldRenderer: React.FC<ListFieldRendererProps> = ({ field, value, onC
|
|||||||
<div key={item.id || itemIndex} className="p-4 border border-[var(--border-secondary)] rounded-lg bg-[var(--bg-secondary)]/50 space-y-3">
|
<div key={item.id || itemIndex} className="p-4 border border-[var(--border-secondary)] rounded-lg bg-[var(--bg-secondary)]/50 space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm font-medium text-[var(--text-primary)]">Elemento #{itemIndex + 1}</span>
|
<span className="text-sm font-medium text-[var(--text-primary)]">Elemento #{itemIndex + 1}</span>
|
||||||
<button
|
{!disableRemove && (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => removeItem(itemIndex)}
|
type="button"
|
||||||
className="p-1 text-red-500 hover:text-red-700 transition-colors"
|
onClick={() => removeItem(itemIndex)}
|
||||||
>
|
className="p-1 text-red-500 hover:text-red-700 transition-colors"
|
||||||
<Trash2 className="w-4 h-4" />
|
>
|
||||||
</button>
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
@@ -223,6 +226,7 @@ export interface AddModalField {
|
|||||||
showSubtotals?: boolean; // For calculating item totals
|
showSubtotals?: boolean; // For calculating item totals
|
||||||
subtotalFields?: { quantity: string; price: string }; // Field names for calculation
|
subtotalFields?: { quantity: string; price: string }; // Field names for calculation
|
||||||
disabled?: boolean; // Disable adding new items
|
disabled?: boolean; // Disable adding new items
|
||||||
|
disableRemove?: boolean; // Disable removing existing items
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -171,14 +171,19 @@
|
|||||||
"health": {
|
"health": {
|
||||||
"production_on_schedule": "Production on schedule",
|
"production_on_schedule": "Production on schedule",
|
||||||
"production_delayed": "{count} production batch{count, plural, one {} other {es}} delayed",
|
"production_delayed": "{count} production batch{count, plural, one {} other {es}} delayed",
|
||||||
|
"production_late_to_start": "{count} batch{count, plural, one {} other {es}} not started on time",
|
||||||
|
"production_delayed_and_late": "{delayed} batch{delayed, plural, one {} other {es}} delayed and {late} not started on time",
|
||||||
|
"production_issues": "{total} production issue{total, plural, one {} other {s}}",
|
||||||
"production_ai_prevented": "AI prevented {count} production delay{count, plural, one {} other {s}}",
|
"production_ai_prevented": "AI prevented {count} production delay{count, plural, one {} other {s}}",
|
||||||
"all_ingredients_in_stock": "All ingredients in stock",
|
"all_ingredients_in_stock": "All ingredients in stock",
|
||||||
"ingredients_out_of_stock": "{count} ingredient{count, plural, one {} other {s}} out of stock",
|
"ingredients_out_of_stock": "{count} ingredient{count, plural, one {} other {s}} out of stock",
|
||||||
"inventory_ai_prevented": "AI prevented {count} inventory issue{count, plural, one {} other {s}}",
|
"inventory_ai_prevented": "AI prevented {count} inventory issue{count, plural, one {} other {s}}",
|
||||||
"no_pending_approvals": "No pending approvals",
|
"no_pending_approvals": "No pending approvals",
|
||||||
"approvals_awaiting": "{count} purchase order{count, plural, one {} other {s}} awaiting approval",
|
"approvals_awaiting": "{count} purchase order{count, plural, one {} other {s}} awaiting approval",
|
||||||
"procurement_ai_created": "AI created {count} purchase order{count, plural, one {} other {s}} automatically",
|
"procurement_ai_prevented": "AI created {count} purchase order{count, plural, one {} other {s}} automatically",
|
||||||
"deliveries_on_track": "All deliveries on track",
|
"deliveries_on_track": "All deliveries on track",
|
||||||
|
"deliveries_overdue": "{count} deliver{count, plural, one {y} other {ies}} overdue",
|
||||||
|
"deliveries_ai_prevented": "AI prevented {count} delivery issue{count, plural, one {} other {s}}",
|
||||||
"deliveries_pending": "{count} pending deliver{count, plural, one {y} other {ies}}",
|
"deliveries_pending": "{count} pending deliver{count, plural, one {y} other {ies}}",
|
||||||
"all_systems_operational": "All systems operational",
|
"all_systems_operational": "All systems operational",
|
||||||
"critical_issues": "{count} critical issue{count, plural, one {} other {s}}",
|
"critical_issues": "{count} critical issue{count, plural, one {} other {s}}",
|
||||||
@@ -403,5 +408,76 @@
|
|||||||
"delivered": "Delivered",
|
"delivered": "Delivered",
|
||||||
"failed": "Failed",
|
"failed": "Failed",
|
||||||
"distribution_routes": "Distribution Routes"
|
"distribution_routes": "Distribution Routes"
|
||||||
|
},
|
||||||
|
"new_dashboard": {
|
||||||
|
"system_status": {
|
||||||
|
"title": "System Status",
|
||||||
|
"issues_requiring_action": "{count, plural, one {# issue} other {# issues}} requiring your action",
|
||||||
|
"all_clear": "All systems running smoothly",
|
||||||
|
"never_run": "Never run",
|
||||||
|
"action_needed_label": "action needed",
|
||||||
|
"ai_prevented_label": "prevented by AI",
|
||||||
|
"last_run_label": "Last run",
|
||||||
|
"ai_prevented_details": "Issues Prevented by AI",
|
||||||
|
"ai_handling_rate": "AI Handling Rate",
|
||||||
|
"estimated_savings": "Estimated Savings",
|
||||||
|
"issues_prevented": "Issues Prevented",
|
||||||
|
"issue_prevented": "Issue prevented",
|
||||||
|
"saved": "Saved",
|
||||||
|
"and_more": "+{count} more"
|
||||||
|
},
|
||||||
|
"pending_purchases": {
|
||||||
|
"title": "Pending Purchases",
|
||||||
|
"count": "{count, plural, one {# order} other {# orders}} awaiting approval",
|
||||||
|
"no_pending": "No pending purchase orders",
|
||||||
|
"all_clear": "No purchase orders pending approval",
|
||||||
|
"po_number": "PO #{number}",
|
||||||
|
"supplier": "Supplier: {name}",
|
||||||
|
"approve": "Approve",
|
||||||
|
"reject": "Reject",
|
||||||
|
"view_details": "View Details",
|
||||||
|
"ai_reasoning": "AI created this PO because:",
|
||||||
|
"reasoning": {
|
||||||
|
"low_stock": "{ingredient} will run out in {days, plural, =0 {less than a day} one {# day} other {# days}}",
|
||||||
|
"low_stock_detailed": "{count, plural, one {# critical ingredient} other {# critical ingredients}} at risk: {products}. Earliest depletion in {days, plural, =0 {<1 day} one {1 day} other {# days}}, affecting {batches, plural, one {# batch} other {# batches}}. Potential loss: €{loss}",
|
||||||
|
"demand_forecast": "Demand for {product} is expected to increase by {increase}%"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pending_deliveries": {
|
||||||
|
"title": "Pending Deliveries",
|
||||||
|
"count": "{count, plural, one {# delivery} other {# deliveries}} expected today",
|
||||||
|
"no_deliveries": "No deliveries expected today",
|
||||||
|
"all_clear": "No pending deliveries today",
|
||||||
|
"overdue_section": "Overdue Deliveries",
|
||||||
|
"today_section": "Expected Today",
|
||||||
|
"overdue_badge": "overdue",
|
||||||
|
"po_ref": "PO #{number}",
|
||||||
|
"overdue_by": "Overdue by {hours}",
|
||||||
|
"arriving_in": "Arriving in {hours}",
|
||||||
|
"call_supplier": "Call",
|
||||||
|
"mark_received": "Received"
|
||||||
|
},
|
||||||
|
"production_status": {
|
||||||
|
"title": "Production Status",
|
||||||
|
"count": "{count, plural, one {# batch} other {# batches}} today",
|
||||||
|
"no_production": "No production scheduled for today",
|
||||||
|
"all_clear": "No production scheduled for today",
|
||||||
|
"late_section": "Late to Start",
|
||||||
|
"running_section": "Currently Running",
|
||||||
|
"pending_section": "Pending Today",
|
||||||
|
"late_badge": "late",
|
||||||
|
"running_badge": "running",
|
||||||
|
"batch_info": "Batch #{number} - {quantity} units",
|
||||||
|
"should_have_started": "Should have started at {time}",
|
||||||
|
"started_at": "Started at {time}",
|
||||||
|
"starts_at": "Starts at {time}",
|
||||||
|
"start_batch": "Start",
|
||||||
|
"view_details": "View",
|
||||||
|
"ai_reasoning": "AI scheduled this batch because:",
|
||||||
|
"reasoning": {
|
||||||
|
"forecast_demand": "Predicted demand of {demand} units for {product}",
|
||||||
|
"customer_order": "Customer order from {customer}"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -198,14 +198,19 @@
|
|||||||
"health": {
|
"health": {
|
||||||
"production_on_schedule": "Producción a tiempo",
|
"production_on_schedule": "Producción a tiempo",
|
||||||
"production_delayed": "{count} lote{count, plural, one {} other {s}} de producción retrasado{count, plural, one {} other {s}}",
|
"production_delayed": "{count} lote{count, plural, one {} other {s}} de producción retrasado{count, plural, one {} other {s}}",
|
||||||
|
"production_late_to_start": "{count} lote{count, plural, one {} other {s}} no iniciado{count, plural, one {} other {s}} a tiempo",
|
||||||
|
"production_delayed_and_late": "{delayed} lote{delayed, plural, one {} other {s}} retrasado{delayed, plural, one {} other {s}} y {late} no iniciado{late, plural, one {} other {s}}",
|
||||||
|
"production_issues": "{total} problema{total, plural, one {} other {s}} de producción",
|
||||||
"production_ai_prevented": "IA evitó {count} retraso{count, plural, one {} other {s}} de producción",
|
"production_ai_prevented": "IA evitó {count} retraso{count, plural, one {} other {s}} de producción",
|
||||||
"all_ingredients_in_stock": "Todos los ingredientes en stock",
|
"all_ingredients_in_stock": "Todos los ingredientes en stock",
|
||||||
"ingredients_out_of_stock": "{count} ingrediente{count, plural, one {} other {s}} sin stock",
|
"ingredients_out_of_stock": "{count} ingrediente{count, plural, one {} other {s}} sin stock",
|
||||||
"inventory_ai_prevented": "IA evitó {count} problema{count, plural, one {} other {s}} de inventario",
|
"inventory_ai_prevented": "IA evitó {count} problema{count, plural, one {} other {s}} de inventario",
|
||||||
"no_pending_approvals": "Sin aprobaciones pendientes",
|
"no_pending_approvals": "Sin aprobaciones pendientes",
|
||||||
"approvals_awaiting": "{count} orden{count, plural, one {} other {es}} de compra esperando aprobación",
|
"approvals_awaiting": "{count} orden{count, plural, one {} other {es}} de compra esperando aprobación",
|
||||||
"procurement_ai_created": "IA creó {count} orden{count, plural, one {} other {es}} de compra automáticamente",
|
"procurement_ai_prevented": "IA creó {count} orden{count, plural, one {} other {es}} de compra automáticamente",
|
||||||
"deliveries_on_track": "Todas las entregas a tiempo",
|
"deliveries_on_track": "Todas las entregas a tiempo",
|
||||||
|
"deliveries_overdue": "{count} entrega{count, plural, one {} other {s}} atrasada{count, plural, one {} other {s}}",
|
||||||
|
"deliveries_ai_prevented": "IA evitó {count} problema{count, plural, one {} other {s}} de entrega",
|
||||||
"deliveries_pending": "{count} entrega{count, plural, one {} other {s}} pendiente{count, plural, one {} other {s}}",
|
"deliveries_pending": "{count} entrega{count, plural, one {} other {s}} pendiente{count, plural, one {} other {s}}",
|
||||||
"all_systems_operational": "Todos los sistemas operativos",
|
"all_systems_operational": "Todos los sistemas operativos",
|
||||||
"critical_issues": "{count} problema{count, plural, one {} other {s}} crítico{count, plural, one {} other {s}}",
|
"critical_issues": "{count} problema{count, plural, one {} other {s}} crítico{count, plural, one {} other {s}}",
|
||||||
@@ -452,5 +457,76 @@
|
|||||||
"delivered": "Entregada",
|
"delivered": "Entregada",
|
||||||
"failed": "Fallida",
|
"failed": "Fallida",
|
||||||
"distribution_routes": "Rutas de Distribución"
|
"distribution_routes": "Rutas de Distribución"
|
||||||
|
},
|
||||||
|
"new_dashboard": {
|
||||||
|
"system_status": {
|
||||||
|
"title": "Estado del Sistema",
|
||||||
|
"issues_requiring_action": "{count, plural, one {# problema requiere} other {# problemas requieren}} tu acción",
|
||||||
|
"all_clear": "Todos los sistemas funcionan correctamente",
|
||||||
|
"never_run": "Nunca ejecutado",
|
||||||
|
"action_needed_label": "acción requerida",
|
||||||
|
"ai_prevented_label": "evitados por IA",
|
||||||
|
"last_run_label": "Última ejecución",
|
||||||
|
"ai_prevented_details": "Problemas Evitados por IA",
|
||||||
|
"ai_handling_rate": "Tasa de Gestión IA",
|
||||||
|
"estimated_savings": "Ahorros Estimados",
|
||||||
|
"issues_prevented": "Problemas Evitados",
|
||||||
|
"issue_prevented": "Problema evitado",
|
||||||
|
"saved": "Ahorrado",
|
||||||
|
"and_more": "+{count} más"
|
||||||
|
},
|
||||||
|
"pending_purchases": {
|
||||||
|
"title": "Compras Pendientes",
|
||||||
|
"count": "{count, plural, one {# orden} other {# órdenes}} esperando aprobación",
|
||||||
|
"no_pending": "Sin órdenes de compra pendientes",
|
||||||
|
"all_clear": "Sin órdenes de compra pendientes de aprobación",
|
||||||
|
"po_number": "OC #{number}",
|
||||||
|
"supplier": "Proveedor: {name}",
|
||||||
|
"approve": "Aprobar",
|
||||||
|
"reject": "Rechazar",
|
||||||
|
"view_details": "Ver Detalles",
|
||||||
|
"ai_reasoning": "IA creó esta OC porque:",
|
||||||
|
"reasoning": {
|
||||||
|
"low_stock": "{ingredient} se agotará en {days, plural, =0 {menos de un día} one {# día} other {# días}}",
|
||||||
|
"low_stock_detailed": "{count, plural, one {# ingrediente crítico} other {# ingredientes críticos}} en riesgo: {products}. Agotamiento más temprano en {days, plural, =0 {<1 día} one {1 día} other {# días}}, afectando {batches, plural, one {# lote} other {# lotes}}. Pérdida potencial: €{loss}",
|
||||||
|
"demand_forecast": "Se espera que la demanda de {product} aumente un {increase}%"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pending_deliveries": {
|
||||||
|
"title": "Entregas Pendientes",
|
||||||
|
"count": "{count, plural, one {# entrega} other {# entregas}} esperadas hoy",
|
||||||
|
"no_deliveries": "Sin entregas esperadas hoy",
|
||||||
|
"all_clear": "Sin entregas pendientes hoy",
|
||||||
|
"overdue_section": "Entregas Atrasadas",
|
||||||
|
"today_section": "Esperadas Hoy",
|
||||||
|
"overdue_badge": "atrasada",
|
||||||
|
"po_ref": "OC #{number}",
|
||||||
|
"overdue_by": "Atrasada {hours}",
|
||||||
|
"arriving_in": "Llega en {hours}",
|
||||||
|
"call_supplier": "Llamar",
|
||||||
|
"mark_received": "Recibido"
|
||||||
|
},
|
||||||
|
"production_status": {
|
||||||
|
"title": "Estado de Producción",
|
||||||
|
"count": "{count, plural, one {# lote} other {# lotes}} hoy",
|
||||||
|
"no_production": "Sin producción programada para hoy",
|
||||||
|
"all_clear": "Sin producción programada para hoy",
|
||||||
|
"late_section": "Atrasados para Empezar",
|
||||||
|
"running_section": "En Ejecución",
|
||||||
|
"pending_section": "Pendientes Hoy",
|
||||||
|
"late_badge": "atrasado",
|
||||||
|
"running_badge": "en curso",
|
||||||
|
"batch_info": "Lote #{number} - {quantity} unidades",
|
||||||
|
"should_have_started": "Debía empezar a las {time}",
|
||||||
|
"started_at": "Empezó a las {time}",
|
||||||
|
"starts_at": "Empieza a las {time}",
|
||||||
|
"start_batch": "Iniciar",
|
||||||
|
"view_details": "Ver",
|
||||||
|
"ai_reasoning": "IA programó este lote porque:",
|
||||||
|
"reasoning": {
|
||||||
|
"forecast_demand": "Demanda prevista de {demand} unidades para {product}",
|
||||||
|
"customer_order": "Pedido del cliente {customer}"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -395,5 +395,76 @@
|
|||||||
"delivered": "Entregatua",
|
"delivered": "Entregatua",
|
||||||
"failed": "Huts egin du",
|
"failed": "Huts egin du",
|
||||||
"distribution_routes": "Banaketa Ibilbideak"
|
"distribution_routes": "Banaketa Ibilbideak"
|
||||||
|
},
|
||||||
|
"new_dashboard": {
|
||||||
|
"system_status": {
|
||||||
|
"title": "Sistema Egoera",
|
||||||
|
"issues_requiring_action": "{count, plural, one {# arazok} other {# arazok}} zure ekintza behar {count, plural, one {du} other {dute}}",
|
||||||
|
"all_clear": "Sistema guztiak ondo dabiltza",
|
||||||
|
"never_run": "Inoiz exekutatu gabe",
|
||||||
|
"action_needed_label": "ekintza behar",
|
||||||
|
"ai_prevented_label": "IAk saihestua",
|
||||||
|
"last_run_label": "Azken exekuzioa",
|
||||||
|
"ai_prevented_details": "IAk Saihestutako Arazoak",
|
||||||
|
"ai_handling_rate": "IA Kudeaketa Tasa",
|
||||||
|
"estimated_savings": "Aurrezki Estimatuak",
|
||||||
|
"issues_prevented": "Saihestutako Arazoak",
|
||||||
|
"issue_prevented": "Arazo saihestua",
|
||||||
|
"saved": "Aurreztua",
|
||||||
|
"and_more": "+{count} gehiago"
|
||||||
|
},
|
||||||
|
"pending_purchases": {
|
||||||
|
"title": "Erosketa Zain",
|
||||||
|
"count": "{count, plural, one {# agindu} other {# agindu}} onarpenaren zai",
|
||||||
|
"no_pending": "Ez dago erosketa-agindu zain",
|
||||||
|
"all_clear": "Ez dago erosketa-agindu onartzeko zain",
|
||||||
|
"po_number": "EA #{number}",
|
||||||
|
"supplier": "Hornitzailea: {name}",
|
||||||
|
"approve": "Onartu",
|
||||||
|
"reject": "Baztertu",
|
||||||
|
"view_details": "Xehetasunak Ikusi",
|
||||||
|
"ai_reasoning": "IAk EA hau sortu zuen zeren:",
|
||||||
|
"reasoning": {
|
||||||
|
"low_stock": "{ingredient} {days, plural, =0 {egun bat baino gutxiago} one {# egunean} other {# egunetan}} agortuko da",
|
||||||
|
"low_stock_detailed": "{count, plural, one {# osagai kritiko} other {# osagai kritiko}} arriskuan: {products}. Lehen agortze {days, plural, =0 {<1 egun} one {1 egun} other {# egun}}, {batches, plural, one {# lote} other {# lote}} ukituz. Galera potentziala: €{loss}",
|
||||||
|
"demand_forecast": "{product} produktuaren eskaria %{increase} igotzea espero da"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pending_deliveries": {
|
||||||
|
"title": "Entrega Zain",
|
||||||
|
"count": "{count, plural, one {# entrega} other {# entrega}} gaur espero",
|
||||||
|
"no_deliveries": "Ez dago entregarik gaur esperatzen",
|
||||||
|
"all_clear": "Ez dago entregarik zain gaur",
|
||||||
|
"overdue_section": "Atzeratutako Entregak",
|
||||||
|
"today_section": "Gaur Esperatzen",
|
||||||
|
"overdue_badge": "atzeratua",
|
||||||
|
"po_ref": "EA #{number}",
|
||||||
|
"overdue_by": "{hours} atzeratuta",
|
||||||
|
"arriving_in": "{hours} barru iristen",
|
||||||
|
"call_supplier": "Deitu",
|
||||||
|
"mark_received": "Jasota"
|
||||||
|
},
|
||||||
|
"production_status": {
|
||||||
|
"title": "Ekoizpen Egoera",
|
||||||
|
"count": "{count, plural, one {# lote} other {# lote}} gaur",
|
||||||
|
"no_production": "Ez dago ekoizpenik programatuta gaur",
|
||||||
|
"all_clear": "Ez dago ekoizpenik programatuta gaur",
|
||||||
|
"late_section": "Hasteko Atzeratua",
|
||||||
|
"running_section": "Martxan",
|
||||||
|
"pending_section": "Gaur Zain",
|
||||||
|
"late_badge": "atzeratua",
|
||||||
|
"running_badge": "martxan",
|
||||||
|
"batch_info": "Lote #{number} - {quantity} unitate",
|
||||||
|
"should_have_started": "{time}-an hasi behar zen",
|
||||||
|
"started_at": "{time}-an hasi zen",
|
||||||
|
"starts_at": "{time}-an hasiko da",
|
||||||
|
"start_batch": "Hasi",
|
||||||
|
"view_details": "Ikusi",
|
||||||
|
"ai_reasoning": "IAk lote hau programatu zuen zeren:",
|
||||||
|
"reasoning": {
|
||||||
|
"forecast_demand": "{product}-rentzat {demand} unitateko eskaria aurreikusita",
|
||||||
|
"customer_order": "{customer} bezeroaren eskaera"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -15,45 +15,33 @@
|
|||||||
* - Trust-building (explain system reasoning)
|
* - Trust-building (explain system reasoning)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { Plus, Sparkles } from 'lucide-react';
|
||||||
import { RefreshCw, ExternalLink, Plus, Sparkles, Wifi, WifiOff } from 'lucide-react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useTenant } from '../../stores/tenant.store';
|
import { useTenant } from '../../stores/tenant.store';
|
||||||
import {
|
import {
|
||||||
useBakeryHealthStatus,
|
|
||||||
useOrchestrationSummary,
|
|
||||||
useUnifiedActionQueue,
|
|
||||||
useProductionTimeline,
|
|
||||||
useApprovePurchaseOrder,
|
useApprovePurchaseOrder,
|
||||||
useStartProductionBatch,
|
useStartProductionBatch,
|
||||||
usePauseProductionBatch,
|
|
||||||
useExecutionProgress,
|
|
||||||
useDashboardRealtime, // PHASE 3: SSE state sync
|
|
||||||
useProgressiveDashboard, // PHASE 4: Progressive loading
|
|
||||||
} from '../../api/hooks/useProfessionalDashboard';
|
} from '../../api/hooks/useProfessionalDashboard';
|
||||||
|
import { useDashboardData, useDashboardRealtimeSync } from '../../api/hooks/useDashboardData';
|
||||||
import { useRejectPurchaseOrder } from '../../api/hooks/purchase-orders';
|
import { useRejectPurchaseOrder } from '../../api/hooks/purchase-orders';
|
||||||
import { useIngredients } from '../../api/hooks/inventory';
|
import { useIngredients } from '../../api/hooks/inventory';
|
||||||
import { useSuppliers } from '../../api/hooks/suppliers';
|
import { useSuppliers } from '../../api/hooks/suppliers';
|
||||||
import { useRecipes } from '../../api/hooks/recipes';
|
import { useRecipes } from '../../api/hooks/recipes';
|
||||||
import { useQualityTemplates } from '../../api/hooks/qualityTemplates';
|
import { useQualityTemplates } from '../../api/hooks/qualityTemplates';
|
||||||
import { GlanceableHealthHero } from '../../components/dashboard/GlanceableHealthHero';
|
|
||||||
import { SetupWizardBlocker } from '../../components/dashboard/SetupWizardBlocker';
|
import { SetupWizardBlocker } from '../../components/dashboard/SetupWizardBlocker';
|
||||||
import { CollapsibleSetupBanner } from '../../components/dashboard/CollapsibleSetupBanner';
|
import { CollapsibleSetupBanner } from '../../components/dashboard/CollapsibleSetupBanner';
|
||||||
import { UnifiedActionQueueCard } from '../../components/dashboard/UnifiedActionQueueCard';
|
import {
|
||||||
import { ExecutionProgressTracker } from '../../components/dashboard/ExecutionProgressTracker';
|
SystemStatusBlock,
|
||||||
import { IntelligentSystemSummaryCard } from '../../components/dashboard/IntelligentSystemSummaryCard';
|
PendingPurchasesBlock,
|
||||||
import { useAuthUser } from '../../stores';
|
PendingDeliveriesBlock,
|
||||||
|
ProductionStatusBlock,
|
||||||
|
} from '../../components/dashboard/blocks';
|
||||||
import { UnifiedPurchaseOrderModal } from '../../components/domain/procurement/UnifiedPurchaseOrderModal';
|
import { UnifiedPurchaseOrderModal } from '../../components/domain/procurement/UnifiedPurchaseOrderModal';
|
||||||
import { UnifiedAddWizard } from '../../components/domain/unified-wizard';
|
import { UnifiedAddWizard } from '../../components/domain/unified-wizard';
|
||||||
import type { ItemType } from '../../components/domain/unified-wizard';
|
import type { ItemType } from '../../components/domain/unified-wizard';
|
||||||
import { useDemoTour, shouldStartTour, clearTourStartPending } from '../../features/demo-onboarding';
|
import { useDemoTour, shouldStartTour, clearTourStartPending } from '../../features/demo-onboarding';
|
||||||
import { Package, Users, BookOpen, Shield } from 'lucide-react';
|
import { Package, Users, BookOpen, Shield } from 'lucide-react';
|
||||||
import {
|
|
||||||
useBatchNotifications,
|
|
||||||
useDeliveryNotifications,
|
|
||||||
useOrchestrationNotifications,
|
|
||||||
} from '../../hooks';
|
|
||||||
|
|
||||||
|
|
||||||
// Import Enterprise Dashboard
|
// Import Enterprise Dashboard
|
||||||
@@ -63,28 +51,21 @@ import { SUBSCRIPTION_TIERS } from '../../api/types/subscription';
|
|||||||
|
|
||||||
// Rename the existing component to BakeryDashboard
|
// Rename the existing component to BakeryDashboard
|
||||||
export function BakeryDashboard() {
|
export function BakeryDashboard() {
|
||||||
const navigate = useNavigate();
|
|
||||||
const { t } = useTranslation(['dashboard', 'common', 'alerts']);
|
const { t } = useTranslation(['dashboard', 'common', 'alerts']);
|
||||||
const { currentTenant } = useTenant();
|
const { currentTenant } = useTenant();
|
||||||
const tenantId = currentTenant?.id || '';
|
const tenantId = currentTenant?.id || '';
|
||||||
const { startTour } = useDemoTour();
|
const { startTour } = useDemoTour();
|
||||||
const isDemoMode = localStorage.getItem('demo_mode') === 'true';
|
const isDemoMode = localStorage.getItem('demo_mode') === 'true';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Unified Add Wizard state
|
// Unified Add Wizard state
|
||||||
const [isAddWizardOpen, setIsAddWizardOpen] = useState(false);
|
const [isAddWizardOpen, setIsAddWizardOpen] = useState(false);
|
||||||
const [addWizardError, setAddWizardError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// PO Details Modal state
|
// PO Details Modal state
|
||||||
const [selectedPOId, setSelectedPOId] = useState<string | null>(null);
|
const [selectedPOId, setSelectedPOId] = useState<string | null>(null);
|
||||||
const [isPOModalOpen, setIsPOModalOpen] = useState(false);
|
const [isPOModalOpen, setIsPOModalOpen] = useState(false);
|
||||||
const [poModalMode, setPOModalMode] = useState<'view' | 'edit'>('view');
|
const [poModalMode, setPOModalMode] = useState<'view' | 'edit'>('view');
|
||||||
|
|
||||||
// Setup Progress Data
|
// Setup Progress Data - use localStorage as fallback during loading
|
||||||
// Always fetch setup data to determine true progress, but use localStorage as fallback during loading
|
|
||||||
// PHASE 1 OPTIMIZATION: Only use cached value if we're still waiting for API to respond
|
|
||||||
const setupProgressFromStorage = useMemo(() => {
|
const setupProgressFromStorage = useMemo(() => {
|
||||||
try {
|
try {
|
||||||
const cached = localStorage.getItem(`setup_progress_${tenantId}`);
|
const cached = localStorage.getItem(`setup_progress_${tenantId}`);
|
||||||
@@ -94,7 +75,7 @@ export function BakeryDashboard() {
|
|||||||
}
|
}
|
||||||
}, [tenantId]);
|
}, [tenantId]);
|
||||||
|
|
||||||
// Always fetch the actual data to determine true progress
|
// Fetch setup data to determine true progress
|
||||||
const { data: ingredients = [], isLoading: loadingIngredients } = useIngredients(
|
const { data: ingredients = [], isLoading: loadingIngredients } = useIngredients(
|
||||||
tenantId,
|
tenantId,
|
||||||
{},
|
{},
|
||||||
@@ -117,296 +98,57 @@ export function BakeryDashboard() {
|
|||||||
);
|
);
|
||||||
const qualityTemplates = Array.isArray(qualityData?.templates) ? qualityData.templates : [];
|
const qualityTemplates = Array.isArray(qualityData?.templates) ? qualityData.templates : [];
|
||||||
|
|
||||||
// PHASE 4: Progressive data loading for perceived performance boost
|
// NEW: Single unified data fetch for all 4 dashboard blocks
|
||||||
const {
|
const {
|
||||||
health: {
|
data: dashboardData,
|
||||||
data: healthStatus,
|
isLoading: dashboardLoading,
|
||||||
isLoading: healthLoading,
|
refetch: refetchDashboard,
|
||||||
refetch: refetchHealth,
|
} = useDashboardData(tenantId);
|
||||||
},
|
|
||||||
actionQueue: {
|
|
||||||
data: actionQueue,
|
|
||||||
isLoading: actionQueueLoading,
|
|
||||||
refetch: refetchActionQueue,
|
|
||||||
},
|
|
||||||
progress: {
|
|
||||||
data: executionProgress,
|
|
||||||
isLoading: executionProgressLoading,
|
|
||||||
refetch: refetchExecutionProgress,
|
|
||||||
},
|
|
||||||
overallLoading,
|
|
||||||
isReady,
|
|
||||||
} = useProgressiveDashboard(tenantId);
|
|
||||||
|
|
||||||
// Additional hooks not part of progressive loading
|
// Enable SSE real-time state synchronization
|
||||||
const {
|
useDashboardRealtimeSync(tenantId);
|
||||||
data: orchestrationSummary,
|
|
||||||
isLoading: orchestrationLoading,
|
|
||||||
refetch: refetchOrchestration,
|
|
||||||
} = useOrchestrationSummary(tenantId);
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: productionTimeline,
|
|
||||||
isLoading: timelineLoading,
|
|
||||||
refetch: refetchTimeline,
|
|
||||||
} = useProductionTimeline(tenantId);
|
|
||||||
|
|
||||||
// Insights functionality removed as it's not needed with new architecture
|
|
||||||
const insights = undefined;
|
|
||||||
const insightsLoading = false;
|
|
||||||
const refetchInsights = () => {};
|
|
||||||
|
|
||||||
// PHASE 3: Enable SSE real-time state synchronization
|
|
||||||
useDashboardRealtime(tenantId);
|
|
||||||
|
|
||||||
|
|
||||||
// PHASE 6: Performance monitoring
|
|
||||||
useEffect(() => {
|
|
||||||
const loadTime = performance.now();
|
|
||||||
console.log(`📊 [Performance] Dashboard loaded in ${loadTime.toFixed(0)}ms`);
|
|
||||||
|
|
||||||
// Calculate setup completion status based on stored progress (approximation since actual data may not be loaded yet)
|
|
||||||
const setupComplete = setupProgressFromStorage >= 100;
|
|
||||||
|
|
||||||
if (loadTime > 1000) {
|
|
||||||
console.warn('⚠️ [Performance] Dashboard load time exceeded target (>1000ms):', {
|
|
||||||
loadTime: `${loadTime.toFixed(0)}ms`,
|
|
||||||
target: '1000ms',
|
|
||||||
setupComplete,
|
|
||||||
queriesSkipped: setupComplete ? 4 : 0,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
console.log('✅ [Performance] Dashboard load time within target:', {
|
|
||||||
loadTime: `${loadTime.toFixed(0)}ms`,
|
|
||||||
target: '<1000ms',
|
|
||||||
setupComplete,
|
|
||||||
queriesSkipped: setupComplete ? 4 : 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [setupProgressFromStorage]); // Include setupProgressFromStorage as dependency
|
|
||||||
|
|
||||||
// Real-time event subscriptions for automatic refetching
|
|
||||||
const { notifications: batchNotifications } = useBatchNotifications();
|
|
||||||
const { notifications: deliveryNotifications } = useDeliveryNotifications();
|
|
||||||
const { recentNotifications: orchestrationNotifications } = useOrchestrationNotifications();
|
|
||||||
|
|
||||||
console.log('🔄 [Dashboard] Component render - notification counts:', {
|
|
||||||
batch: batchNotifications.length,
|
|
||||||
delivery: deliveryNotifications.length,
|
|
||||||
orchestration: orchestrationNotifications.length,
|
|
||||||
batchIds: batchNotifications.map(n => n.id).join(','),
|
|
||||||
deliveryIds: deliveryNotifications.map(n => n.id).join(','),
|
|
||||||
orchestrationIds: orchestrationNotifications.map(n => n.id).join(','),
|
|
||||||
});
|
|
||||||
|
|
||||||
// SSE connection status
|
|
||||||
const sseConnected = true; // Simplified - based on other notification hooks
|
|
||||||
|
|
||||||
// Store refetch callbacks in a ref to prevent infinite loop from dependency changes
|
|
||||||
// React Query refetch functions are recreated on every query state change, which would
|
|
||||||
// trigger useEffect again if they were in the dependency array
|
|
||||||
const refetchCallbacksRef = useRef({
|
|
||||||
refetchActionQueue,
|
|
||||||
refetchHealth,
|
|
||||||
refetchExecutionProgress,
|
|
||||||
refetchOrchestration,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Store previous notification IDs to prevent infinite refetch loops
|
|
||||||
const prevBatchNotificationsRef = useRef('');
|
|
||||||
const prevDeliveryNotificationsRef = useRef('');
|
|
||||||
const prevOrchestrationNotificationsRef = useRef('');
|
|
||||||
|
|
||||||
// Update ref with latest callbacks on every render
|
|
||||||
useEffect(() => {
|
|
||||||
refetchCallbacksRef.current = {
|
|
||||||
refetchActionQueue,
|
|
||||||
refetchHealth,
|
|
||||||
refetchExecutionProgress,
|
|
||||||
refetchOrchestration,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Track the latest notification ID to prevent re-running on same notification
|
|
||||||
// Use stringified ID array to create stable dependency that only changes when IDs actually change
|
|
||||||
const batchIdsString = JSON.stringify(batchNotifications.map(n => n.id));
|
|
||||||
const deliveryIdsString = JSON.stringify(deliveryNotifications.map(n => n.id));
|
|
||||||
const orchestrationIdsString = JSON.stringify(orchestrationNotifications.map(n => n.id));
|
|
||||||
|
|
||||||
console.log('📝 [Dashboard] Stringified ID arrays:', {
|
|
||||||
batchIdsString,
|
|
||||||
deliveryIdsString,
|
|
||||||
orchestrationIdsString,
|
|
||||||
});
|
|
||||||
|
|
||||||
const latestBatchNotificationId = useMemo(() => {
|
|
||||||
const result = batchNotifications.length === 0 ? '' : (batchNotifications[0]?.id || '');
|
|
||||||
console.log('🧮 [Dashboard] latestBatchNotificationId useMemo recalculated:', {
|
|
||||||
result,
|
|
||||||
dependency: batchIdsString,
|
|
||||||
notificationCount: batchNotifications.length,
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}, [batchIdsString]);
|
|
||||||
|
|
||||||
const latestDeliveryNotificationId = useMemo(() => {
|
|
||||||
const result = deliveryNotifications.length === 0 ? '' : (deliveryNotifications[0]?.id || '');
|
|
||||||
console.log('🧮 [Dashboard] latestDeliveryNotificationId useMemo recalculated:', {
|
|
||||||
result,
|
|
||||||
dependency: deliveryIdsString,
|
|
||||||
notificationCount: deliveryNotifications.length,
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}, [deliveryIdsString]);
|
|
||||||
|
|
||||||
const latestOrchestrationNotificationId = useMemo(() => {
|
|
||||||
const result = orchestrationNotifications.length === 0 ? '' : (orchestrationNotifications[0]?.id || '');
|
|
||||||
console.log('🧮 [Dashboard] latestOrchestrationNotificationId useMemo recalculated:', {
|
|
||||||
result,
|
|
||||||
dependency: orchestrationIdsString,
|
|
||||||
notificationCount: orchestrationNotifications.length,
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}, [orchestrationIdsString]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
console.log('⚡ [Dashboard] batchNotifications useEffect triggered', {
|
|
||||||
latestBatchNotificationId,
|
|
||||||
prevValue: prevBatchNotificationsRef.current,
|
|
||||||
hasChanged: latestBatchNotificationId !== prevBatchNotificationsRef.current,
|
|
||||||
notificationCount: batchNotifications.length,
|
|
||||||
firstNotification: batchNotifications[0],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (latestBatchNotificationId &&
|
|
||||||
latestBatchNotificationId !== prevBatchNotificationsRef.current) {
|
|
||||||
console.log('🔥 [Dashboard] NEW batch notification detected, updating ref and refetching');
|
|
||||||
prevBatchNotificationsRef.current = latestBatchNotificationId;
|
|
||||||
const latest = batchNotifications[0];
|
|
||||||
|
|
||||||
if (['batch_completed', 'batch_started'].includes(latest.event_type)) {
|
|
||||||
console.log('🚀 [Dashboard] Triggering refetch for batch event:', latest.event_type);
|
|
||||||
refetchCallbacksRef.current.refetchExecutionProgress();
|
|
||||||
refetchCallbacksRef.current.refetchHealth();
|
|
||||||
} else {
|
|
||||||
console.log('⏭️ [Dashboard] Skipping refetch - event type not relevant:', latest.event_type);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [latestBatchNotificationId]); // Only run when a NEW notification arrives
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
console.log('⚡ [Dashboard] deliveryNotifications useEffect triggered', {
|
|
||||||
latestDeliveryNotificationId,
|
|
||||||
prevValue: prevDeliveryNotificationsRef.current,
|
|
||||||
hasChanged: latestDeliveryNotificationId !== prevDeliveryNotificationsRef.current,
|
|
||||||
notificationCount: deliveryNotifications.length,
|
|
||||||
firstNotification: deliveryNotifications[0],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (latestDeliveryNotificationId &&
|
|
||||||
latestDeliveryNotificationId !== prevDeliveryNotificationsRef.current) {
|
|
||||||
console.log('🔥 [Dashboard] NEW delivery notification detected, updating ref and refetching');
|
|
||||||
prevDeliveryNotificationsRef.current = latestDeliveryNotificationId;
|
|
||||||
const latest = deliveryNotifications[0];
|
|
||||||
|
|
||||||
if (['delivery_received', 'delivery_overdue'].includes(latest.event_type)) {
|
|
||||||
console.log('🚀 [Dashboard] Triggering refetch for delivery event:', latest.event_type);
|
|
||||||
refetchCallbacksRef.current.refetchExecutionProgress();
|
|
||||||
refetchCallbacksRef.current.refetchHealth();
|
|
||||||
} else {
|
|
||||||
console.log('⏭️ [Dashboard] Skipping refetch - event type not relevant:', latest.event_type);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [latestDeliveryNotificationId]); // Only run when a NEW notification arrives
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
console.log('⚡ [Dashboard] orchestrationNotifications useEffect triggered', {
|
|
||||||
latestOrchestrationNotificationId,
|
|
||||||
prevValue: prevOrchestrationNotificationsRef.current,
|
|
||||||
hasChanged: latestOrchestrationNotificationId !== prevOrchestrationNotificationsRef.current,
|
|
||||||
notificationCount: orchestrationNotifications.length,
|
|
||||||
firstNotification: orchestrationNotifications[0],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (latestOrchestrationNotificationId &&
|
|
||||||
latestOrchestrationNotificationId !== prevOrchestrationNotificationsRef.current) {
|
|
||||||
console.log('🔥 [Dashboard] NEW orchestration notification detected, updating ref and refetching');
|
|
||||||
prevOrchestrationNotificationsRef.current = latestOrchestrationNotificationId;
|
|
||||||
const latest = orchestrationNotifications[0];
|
|
||||||
|
|
||||||
if (latest.event_type === 'orchestration_run_completed') {
|
|
||||||
console.log('🚀 [Dashboard] Triggering refetch for orchestration event:', latest.event_type);
|
|
||||||
refetchCallbacksRef.current.refetchOrchestration();
|
|
||||||
refetchCallbacksRef.current.refetchActionQueue();
|
|
||||||
} else {
|
|
||||||
console.log('⏭️ [Dashboard] Skipping refetch - event type not relevant:', latest.event_type);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [latestOrchestrationNotificationId]); // Only run when a NEW notification arrives
|
|
||||||
|
|
||||||
// Mutations
|
// Mutations
|
||||||
const approvePO = useApprovePurchaseOrder();
|
const approvePO = useApprovePurchaseOrder();
|
||||||
const rejectPO = useRejectPurchaseOrder();
|
const rejectPO = useRejectPurchaseOrder();
|
||||||
const startBatch = useStartProductionBatch();
|
const startBatch = useStartProductionBatch();
|
||||||
const pauseBatch = usePauseProductionBatch();
|
|
||||||
|
|
||||||
// Handlers
|
// Handlers
|
||||||
const handleApprove = async (actionId: string) => {
|
const handleApprove = async (poId: string) => {
|
||||||
try {
|
try {
|
||||||
await approvePO.mutateAsync({ tenantId, poId: actionId });
|
await approvePO.mutateAsync({ tenantId, poId });
|
||||||
// Refetch to update UI
|
// SSE will handle refetch, but trigger immediate refetch for responsiveness
|
||||||
refetchActionQueue();
|
refetchDashboard();
|
||||||
refetchHealth();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error approving PO:', error);
|
console.error('Error approving PO:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReject = async (actionId: string, reason: string) => {
|
const handleReject = async (poId: string, reason: string) => {
|
||||||
try {
|
try {
|
||||||
await rejectPO.mutateAsync({ tenantId, poId: actionId, reason });
|
await rejectPO.mutateAsync({ tenantId, poId, reason });
|
||||||
// Refetch to update UI
|
refetchDashboard();
|
||||||
refetchActionQueue();
|
|
||||||
refetchHealth();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error rejecting PO:', error);
|
console.error('Error rejecting PO:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleViewDetails = (actionId: string) => {
|
const handleViewDetails = (poId: string) => {
|
||||||
// Open modal to show PO details in view mode
|
// Open modal to show PO details in view mode
|
||||||
setSelectedPOId(actionId);
|
setSelectedPOId(poId);
|
||||||
setPOModalMode('view');
|
setPOModalMode('view');
|
||||||
setIsPOModalOpen(true);
|
setIsPOModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleModify = (actionId: string) => {
|
|
||||||
// Open modal to edit PO details
|
|
||||||
setSelectedPOId(actionId);
|
|
||||||
setPOModalMode('edit');
|
|
||||||
setIsPOModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStartBatch = async (batchId: string) => {
|
const handleStartBatch = async (batchId: string) => {
|
||||||
try {
|
try {
|
||||||
await startBatch.mutateAsync({ tenantId, batchId });
|
await startBatch.mutateAsync({ tenantId, batchId });
|
||||||
refetchTimeline();
|
refetchDashboard();
|
||||||
refetchHealth();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error starting batch:', error);
|
console.error('Error starting batch:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePauseBatch = async (batchId: string) => {
|
|
||||||
try {
|
|
||||||
await pauseBatch.mutateAsync({ tenantId, batchId });
|
|
||||||
refetchTimeline();
|
|
||||||
refetchHealth();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error pausing batch:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Calculate configuration sections for setup flow
|
// Calculate configuration sections for setup flow
|
||||||
const setupSections = useMemo(() => {
|
const setupSections = useMemo(() => {
|
||||||
// Create safe fallbacks for icons to prevent React error #310
|
// Create safe fallbacks for icons to prevent React error #310
|
||||||
@@ -514,19 +256,11 @@ export function BakeryDashboard() {
|
|||||||
};
|
};
|
||||||
}, [setupSections, tenantId, loadingIngredients, loadingSuppliers, loadingRecipes, loadingQuality, setupProgressFromStorage]);
|
}, [setupSections, tenantId, loadingIngredients, loadingSuppliers, loadingRecipes, loadingQuality, setupProgressFromStorage]);
|
||||||
|
|
||||||
const handleRefreshAll = () => {
|
|
||||||
refetchHealth();
|
|
||||||
refetchOrchestration();
|
|
||||||
refetchActionQueue();
|
|
||||||
refetchExecutionProgress();
|
|
||||||
refetchTimeline();
|
|
||||||
refetchInsights();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddWizardComplete = (itemType: ItemType, data?: any) => {
|
const handleAddWizardComplete = (itemType: ItemType, data?: any) => {
|
||||||
console.log('Item created:', itemType, data);
|
console.log('Item created:', itemType, data);
|
||||||
// Refetch relevant data based on what was added
|
// SSE events will handle most updates automatically, but we refetch here
|
||||||
handleRefreshAll();
|
// to ensure immediate feedback after user actions
|
||||||
|
refetchDashboard();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Keyboard shortcut for Quick Add (Cmd/Ctrl + K)
|
// Keyboard shortcut for Quick Add (Cmd/Ctrl + K)
|
||||||
@@ -600,14 +334,6 @@ export function BakeryDashboard() {
|
|||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
|
||||||
onClick={handleRefreshAll}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 rounded-lg font-semibold transition-colors duration-200 border border-[var(--border-primary)] bg-[var(--bg-primary)] text-[var(--text-secondary)]"
|
|
||||||
>
|
|
||||||
<RefreshCw className="w-5 h-5" />
|
|
||||||
<span className="hidden sm:inline">{t('common:actions.refresh')}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Unified Add Button with Keyboard Shortcut */}
|
{/* Unified Add Button with Keyboard Shortcut */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsAddWizardOpen(true)}
|
onClick={() => setIsAddWizardOpen(true)}
|
||||||
@@ -632,8 +358,8 @@ export function BakeryDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Setup Flow - Three States */}
|
{/* Setup Flow - Three States */}
|
||||||
{loadingIngredients || loadingSuppliers || loadingRecipes || loadingQuality || !isReady ? (
|
{loadingIngredients || loadingSuppliers || loadingRecipes || loadingQuality ? (
|
||||||
/* Loading state - only show spinner until first priority data (health) is ready */
|
/* Loading state - only show spinner until setup data is ready */
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2" style={{ borderColor: 'var(--color-primary)' }}></div>
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2" style={{ borderColor: 'var(--color-primary)' }}></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -653,103 +379,45 @@ export function BakeryDashboard() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Main Dashboard Layout */}
|
{/* Main Dashboard Layout - 4 New Focused Blocks */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* SECTION 1: Glanceable Health Hero (Traffic Light) - PRIORITY 1 */}
|
{/* BLOCK 1: System Status + AI Summary */}
|
||||||
<div data-tour="dashboard-stats">
|
<div data-tour="dashboard-stats">
|
||||||
{healthLoading ? (
|
<SystemStatusBlock
|
||||||
<div className="bg-[var(--bg-primary)] rounded-lg border border-[var(--border-primary)] p-6 animate-pulse">
|
data={dashboardData}
|
||||||
<div className="h-24 bg-[var(--bg-secondary)] rounded"></div>
|
loading={dashboardLoading}
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<GlanceableHealthHero
|
|
||||||
healthStatus={healthStatus!}
|
|
||||||
loading={false}
|
|
||||||
urgentActionCount={actionQueue?.urgentCount || 0}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* SECTION 2: What Needs Your Attention (Unified Action Queue) - PRIORITY 2 */}
|
|
||||||
<div data-tour="pending-po-approvals">
|
|
||||||
{actionQueueLoading ? (
|
|
||||||
<div className="bg-[var(--bg-primary)] rounded-lg border border-[var(--border-primary)] p-6 animate-pulse">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="h-8 bg-[var(--bg-secondary)] rounded w-1/3"></div>
|
|
||||||
<div className="h-32 bg-[var(--bg-secondary)] rounded"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<UnifiedActionQueueCard
|
|
||||||
actionQueue={actionQueue!}
|
|
||||||
loading={false}
|
|
||||||
tenantId={tenantId}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* SECTION 3: Execution Progress Tracker (Plan vs Actual) - PRIORITY 3 */}
|
|
||||||
<div data-tour="execution-progress">
|
|
||||||
{executionProgressLoading ? (
|
|
||||||
<div className="bg-[var(--bg-primary)] rounded-lg border border-[var(--border-primary)] p-6 animate-pulse">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="h-8 bg-[var(--bg-secondary)] rounded w-1/2"></div>
|
|
||||||
<div className="h-24 bg-[var(--bg-secondary)] rounded"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ExecutionProgressTracker
|
|
||||||
progress={executionProgress}
|
|
||||||
loading={false}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* SECTION 4: Intelligent System Summary - Unified AI Impact & Orchestration */}
|
|
||||||
<div data-tour="intelligent-system-summary">
|
|
||||||
<IntelligentSystemSummaryCard
|
|
||||||
orchestrationSummary={orchestrationSummary!}
|
|
||||||
orchestrationLoading={orchestrationLoading}
|
|
||||||
onWorkflowComplete={handleRefreshAll}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* SECTION 6: Quick Action Links */}
|
{/* BLOCK 2: Pending Purchases (PO Approvals) */}
|
||||||
<div className="rounded-xl shadow-lg p-6 border border-[var(--border-primary)] bg-[var(--bg-primary)]">
|
<div data-tour="pending-po-approvals">
|
||||||
<h2 className="text-xl font-bold mb-4 text-[var(--text-primary)]">{t('dashboard:sections.quick_actions')}</h2>
|
<PendingPurchasesBlock
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
pendingPOs={dashboardData?.pendingPOs || []}
|
||||||
<button
|
loading={dashboardLoading}
|
||||||
onClick={() => navigate('/app/operations/procurement')}
|
onApprove={handleApprove}
|
||||||
className="flex items-center justify-between p-4 rounded-lg transition-colors duration-200 group bg-[var(--bg-tertiary)] border-l-4 border-l-[var(--color-info)]"
|
onReject={handleReject}
|
||||||
>
|
onViewDetails={handleViewDetails}
|
||||||
<span className="font-semibold text-[var(--text-primary)]">{t('dashboard:quick_actions.view_orders')}</span>
|
/>
|
||||||
<ExternalLink className="w-5 h-5 group-hover:translate-x-1 transition-transform duration-200 text-[var(--color-info)]" />
|
</div>
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
{/* BLOCK 3: Pending Deliveries (Overdue + Today) */}
|
||||||
onClick={() => navigate('/app/operations/production')}
|
<div data-tour="pending-deliveries">
|
||||||
className="flex items-center justify-between p-4 rounded-lg transition-colors duration-200 group bg-[var(--bg-tertiary)] border-l-4 border-l-[var(--color-success)]"
|
<PendingDeliveriesBlock
|
||||||
>
|
overdueDeliveries={dashboardData?.overdueDeliveries || []}
|
||||||
<span className="font-semibold text-[var(--text-primary)]">{t('dashboard:quick_actions.view_production')}</span>
|
pendingDeliveries={dashboardData?.pendingDeliveries || []}
|
||||||
<ExternalLink className="w-5 h-5 group-hover:translate-x-1 transition-transform duration-200 text-[var(--color-success)]" />
|
loading={dashboardLoading}
|
||||||
</button>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
{/* BLOCK 4: Production Status (Late/Running/Pending) */}
|
||||||
onClick={() => navigate('/app/database/inventory')}
|
<div data-tour="execution-progress">
|
||||||
className="flex items-center justify-between p-4 rounded-lg transition-colors duration-200 group bg-[var(--bg-tertiary)] border-l-4 border-l-[var(--color-secondary)]"
|
<ProductionStatusBlock
|
||||||
>
|
lateToStartBatches={dashboardData?.lateToStartBatches || []}
|
||||||
<span className="font-semibold text-[var(--text-primary)]">{t('dashboard:quick_actions.view_inventory')}</span>
|
runningBatches={dashboardData?.runningBatches || []}
|
||||||
<ExternalLink className="w-5 h-5 group-hover:translate-x-1 transition-transform duration-200 text-[var(--color-secondary)]" />
|
pendingBatches={dashboardData?.pendingBatches || []}
|
||||||
</button>
|
loading={dashboardLoading}
|
||||||
|
onStartBatch={handleStartBatch}
|
||||||
<button
|
/>
|
||||||
onClick={() => navigate('/app/database/suppliers')}
|
|
||||||
className="flex items-center justify-between p-4 rounded-lg transition-colors duration-200 group bg-[var(--bg-tertiary)] border-l-4 border-l-[var(--color-warning)]"
|
|
||||||
>
|
|
||||||
<span className="font-semibold text-[var(--text-primary)]">{t('dashboard:quick_actions.view_suppliers')}</span>
|
|
||||||
<ExternalLink className="w-5 h-5 group-hover:translate-x-1 transition-transform duration-200 text-[var(--color-warning)]" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -777,7 +445,8 @@ export function BakeryDashboard() {
|
|||||||
setIsPOModalOpen(false);
|
setIsPOModalOpen(false);
|
||||||
setSelectedPOId(null);
|
setSelectedPOId(null);
|
||||||
setPOModalMode('view');
|
setPOModalMode('view');
|
||||||
handleRefreshAll();
|
// SSE events will handle most updates automatically
|
||||||
|
refetchDashboard();
|
||||||
}}
|
}}
|
||||||
onApprove={handleApprove}
|
onApprove={handleApprove}
|
||||||
onReject={handleReject}
|
onReject={handleReject}
|
||||||
|
|||||||
@@ -643,7 +643,7 @@ async def seed_purchase_orders_for_tenant(db: AsyncSession, tenant_id: uuid.UUID
|
|||||||
{"name": "Sal Fina", "quantity": 30, "unit_price": 0.85, "uom": "kg"}
|
{"name": "Sal Fina", "quantity": 30, "unit_price": 0.85, "uom": "kg"}
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
po10.notes = "⚠️ ESCALATED: Pending approval for 72+ hours - Production batch depends on tomorrow morning delivery"
|
# Note: Manual notes removed to reflect real orchestrator behavior
|
||||||
pos_created.append(po10)
|
pos_created.append(po10)
|
||||||
|
|
||||||
# 11. DELIVERY OVERDUE - Expected delivery is 4 hours late (URGENT dashboard alert)
|
# 11. DELIVERY OVERDUE - Expected delivery is 4 hours late (URGENT dashboard alert)
|
||||||
@@ -661,7 +661,6 @@ async def seed_purchase_orders_for_tenant(db: AsyncSession, tenant_id: uuid.UUID
|
|||||||
# Override delivery date to be 4 hours ago (overdue)
|
# Override delivery date to be 4 hours ago (overdue)
|
||||||
po11.required_delivery_date = delivery_overdue_time
|
po11.required_delivery_date = delivery_overdue_time
|
||||||
po11.expected_delivery_date = delivery_overdue_time
|
po11.expected_delivery_date = delivery_overdue_time
|
||||||
po11.notes = "🔴 OVERDUE: Expected delivery was 4 hours ago - Contact supplier immediately"
|
|
||||||
pos_created.append(po11)
|
pos_created.append(po11)
|
||||||
|
|
||||||
# 12. DELIVERY ARRIVING SOON - Arriving in 8 hours (TODAY dashboard alert)
|
# 12. DELIVERY ARRIVING SOON - Arriving in 8 hours (TODAY dashboard alert)
|
||||||
@@ -680,7 +679,6 @@ async def seed_purchase_orders_for_tenant(db: AsyncSession, tenant_id: uuid.UUID
|
|||||||
# Override delivery date to be in 8 hours
|
# Override delivery date to be in 8 hours
|
||||||
po12.expected_delivery_date = arriving_soon_time
|
po12.expected_delivery_date = arriving_soon_time
|
||||||
po12.required_delivery_date = arriving_soon_time
|
po12.required_delivery_date = arriving_soon_time
|
||||||
po12.notes = "📦 ARRIVING SOON: Delivery expected in 8 hours - Prepare for stock receipt"
|
|
||||||
pos_created.append(po12)
|
pos_created.append(po12)
|
||||||
|
|
||||||
# 13. DELIVERY TODAY MORNING - Scheduled for 10 AM today
|
# 13. DELIVERY TODAY MORNING - Scheduled for 10 AM today
|
||||||
@@ -697,7 +695,6 @@ async def seed_purchase_orders_for_tenant(db: AsyncSession, tenant_id: uuid.UUID
|
|||||||
)
|
)
|
||||||
po13.expected_delivery_date = delivery_today_morning
|
po13.expected_delivery_date = delivery_today_morning
|
||||||
po13.required_delivery_date = delivery_today_morning
|
po13.required_delivery_date = delivery_today_morning
|
||||||
po13.notes = "📦 Delivery scheduled for 10 AM - Essential ingredients for morning production"
|
|
||||||
pos_created.append(po13)
|
pos_created.append(po13)
|
||||||
|
|
||||||
# 14. DELIVERY TODAY AFTERNOON - Scheduled for 3 PM today
|
# 14. DELIVERY TODAY AFTERNOON - Scheduled for 3 PM today
|
||||||
@@ -714,7 +711,6 @@ async def seed_purchase_orders_for_tenant(db: AsyncSession, tenant_id: uuid.UUID
|
|||||||
)
|
)
|
||||||
po14.expected_delivery_date = delivery_today_afternoon
|
po14.expected_delivery_date = delivery_today_afternoon
|
||||||
po14.required_delivery_date = delivery_today_afternoon
|
po14.required_delivery_date = delivery_today_afternoon
|
||||||
po14.notes = "📦 Packaging delivery expected at 3 PM"
|
|
||||||
pos_created.append(po14)
|
pos_created.append(po14)
|
||||||
|
|
||||||
# 15. DELIVERY TOMORROW EARLY - Scheduled for 8 AM tomorrow (high priority)
|
# 15. DELIVERY TOMORROW EARLY - Scheduled for 8 AM tomorrow (high priority)
|
||||||
@@ -724,6 +720,7 @@ async def seed_purchase_orders_for_tenant(db: AsyncSession, tenant_id: uuid.UUID
|
|||||||
PurchaseOrderStatus.approved,
|
PurchaseOrderStatus.approved,
|
||||||
Decimal("445.00"),
|
Decimal("445.00"),
|
||||||
created_offset_days=-1,
|
created_offset_days=-1,
|
||||||
|
priority="high",
|
||||||
items_data=[
|
items_data=[
|
||||||
{"name": "Harina Integral", "quantity": 300, "unit_price": 0.95, "uom": "kg"},
|
{"name": "Harina Integral", "quantity": 300, "unit_price": 0.95, "uom": "kg"},
|
||||||
{"name": "Sal Marina", "quantity": 50, "unit_price": 1.60, "uom": "kg"}
|
{"name": "Sal Marina", "quantity": 50, "unit_price": 1.60, "uom": "kg"}
|
||||||
@@ -731,8 +728,6 @@ async def seed_purchase_orders_for_tenant(db: AsyncSession, tenant_id: uuid.UUID
|
|||||||
)
|
)
|
||||||
po15.expected_delivery_date = delivery_tomorrow_early
|
po15.expected_delivery_date = delivery_tomorrow_early
|
||||||
po15.required_delivery_date = delivery_tomorrow_early
|
po15.required_delivery_date = delivery_tomorrow_early
|
||||||
po15.priority = "high"
|
|
||||||
po15.notes = "🔔 Critical delivery for weekend production - Confirm with supplier"
|
|
||||||
pos_created.append(po15)
|
pos_created.append(po15)
|
||||||
|
|
||||||
# 16. DELIVERY TOMORROW LATE - Scheduled for 5 PM tomorrow
|
# 16. DELIVERY TOMORROW LATE - Scheduled for 5 PM tomorrow
|
||||||
@@ -749,7 +744,6 @@ async def seed_purchase_orders_for_tenant(db: AsyncSession, tenant_id: uuid.UUID
|
|||||||
)
|
)
|
||||||
po16.expected_delivery_date = delivery_tomorrow_late
|
po16.expected_delivery_date = delivery_tomorrow_late
|
||||||
po16.required_delivery_date = delivery_tomorrow_late
|
po16.required_delivery_date = delivery_tomorrow_late
|
||||||
po16.notes = "📦 Specialty ingredients for chocolate products"
|
|
||||||
pos_created.append(po16)
|
pos_created.append(po16)
|
||||||
|
|
||||||
# 17. DELIVERY DAY AFTER - Scheduled for 11 AM in 2 days
|
# 17. DELIVERY DAY AFTER - Scheduled for 11 AM in 2 days
|
||||||
@@ -766,7 +760,6 @@ async def seed_purchase_orders_for_tenant(db: AsyncSession, tenant_id: uuid.UUID
|
|||||||
)
|
)
|
||||||
po17.expected_delivery_date = delivery_day_after
|
po17.expected_delivery_date = delivery_day_after
|
||||||
po17.required_delivery_date = delivery_day_after
|
po17.required_delivery_date = delivery_day_after
|
||||||
po17.notes = "📦 Dairy delivery for mid-week production"
|
|
||||||
pos_created.append(po17)
|
pos_created.append(po17)
|
||||||
|
|
||||||
# 18. DELIVERY THIS WEEK - Scheduled for 2 PM in 4 days
|
# 18. DELIVERY THIS WEEK - Scheduled for 2 PM in 4 days
|
||||||
@@ -784,7 +777,6 @@ async def seed_purchase_orders_for_tenant(db: AsyncSession, tenant_id: uuid.UUID
|
|||||||
)
|
)
|
||||||
po18.expected_delivery_date = delivery_this_week
|
po18.expected_delivery_date = delivery_this_week
|
||||||
po18.required_delivery_date = delivery_this_week
|
po18.required_delivery_date = delivery_this_week
|
||||||
po18.notes = "📦 Specialty items for artisan products"
|
|
||||||
pos_created.append(po18)
|
pos_created.append(po18)
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|||||||
Reference in New Issue
Block a user